An AI assistant can answer questions about your own Google Sheet with nothing in between. A Google Apps Script web app can speak MCP, the open standard assistants use to reach data, so Claude or ChatGPT connect to the script inside your spreadsheet through a link, read what they need, and answer. I built this in October for a spending tracker whose database is the user's own sheet. This is what it took, what Apps Script makes awkward, and where it stops.
Why no server in the middle
The usual way an app adds AI is to send your data to the app maker's server, which calls a model and sends back the answer. For money, that means one more company holding your transactions, under its own terms, for as long as it likes.
A spreadsheet in your own Drive changes the shape. The script bound to it already runs in your Google account, under your consent. If that script answers the assistant directly, there are three parties: you, Google, which already holds the sheet, and the assistant you chose. Nobody else is in the path, so there is nobody else to trust.
What MCP asks of a server
MCP is JSON-RPC over HTTP. A client sends a message such as tools/list, the server lists its tools, each with a name, a plain description and a JSON schema for its arguments, and then the client calls tools/call with arguments the model picked. The descriptions do most of the work: they teach the model the vocabulary. Mine say what "kept" means, what an intention is, and to ask who a question is about rather than guess.
The spec moved while I worked. The released revisions open with an initialize handshake. The 2026-07-28 draft is stateless: no initialize, no sessions, every message its own POST. A server can answer both, by treating an initialize as the old way and a message carrying the new metadata as the new one. The official TypeScript SDK I tested with spoke only the older revision, so the server answers both.
What Apps Script makes awkward
Three limits shaped everything.
Every POST is answered with a redirect. A web app's /exec address does not answer a POST itself: it replies 302 to a script.googleusercontent.com address, and the client has to fetch that with a GET to read the result. Browsers and Node's fetch do this on their own. curl does it with -L, as long as you pass the body with --data; adding -X POST made the second hop fail with a 411. claude.ai's connector follows the redirect. Its set-up check does not: it reports 302 or Not found and offers Continue anyway, and after that the connection works. The official TypeScript SDK refuses a redirect to another host unless you set its redirectPolicy to 'follow', and recent versions of the Python SDK and the Rust SDK refuse it too. So any client built on those fails at the first step, and there is nothing a script can do about it.
Every answer is HTTP 200. MCP expects a 202 with no body for a notification, and a 4xx for a bad request. Apps Script sends 200 for everything. A notification gets a 200 with an empty body, and an error goes inside the JSON-RPC body, where the spec puts it anyway. Claude's connector worked with both.
The script cannot read request headers. doPost sees the body and the query string, nothing else. So an Authorization header is useless, and the token that guards the sheet rides in the link's query string. That makes the link itself a password, and it has to be treated as one: copied from the app's settings, given only to assistants you use, and replaced by making a new token when it leaks, which also cuts off every assistant holding the old link.
Speed was fine. Listing the tools took two to three seconds, a month's figures five to six, and the connector did not time out.
Three rules before the first tool
The assistant goes through the app's own functions, not the sheet. Every tool is a handler that calls a function the app already calls, so an assistant cannot do anything the app does not allow, by construction. The functions that activate a licence or change the password are not on the list.
Private stays private, on the server. The app hides private categories behind a PIN, but that veil lived in the browser: the server returned private rows to anyone holding the token. With an assistant on the other end, that had to move to the server. A private category now reaches an assistant as one "Private" line a month, the total kept so the month still adds up, the shop, note and category name dropped. Each person can lift it for their own categories, with their PIN, entered in the app and not typed into a chat, and even then only for questions about them.
Proposals, not writes. There is one write tool, and it writes nothing to the ledger. It stages suggested entries in a hidden tab, and the app says "Your assistant suggested 6 entries", and Review opens the same review board a bank statement import uses. Nothing is saved until the person taps Import. A model's dates and categories are guesses, and a board you can see is the right place to catch them.
What it looks like
On a test sheet, in a fresh Claude chat with the connector added, I asked: "what did Kashish spend in September 2026, and how many entries?" Claude called the tool that lists the people in the sheet, matched the name to a profile on its own, called the month tool for that person and month, and answered: A$4,560 across 9 entries, no income recorded that month, none of it paid from funds. Those were the sheet's own figures. ChatGPT, connected as a custom MCP server on a free account, gave the same total and added the largest entry, rent at A$2,000.
A connector added on the web also shows up in Claude's phone app, so the same question can be asked from a phone, and the answer still comes from the sheet in your Drive.
The honest limits
- Which assistants take the link. Claude with a plan that has custom connectors, Claude Code and ChatGPT connected when I tried them. Grok's custom connector did not connect. Anything built on an SDK that refuses a cross-host redirect will fail the same way, and I have only listed the ones I connected myself.
- The link is a password. Anyone holding it reads what an assistant reads. Treat it the way you would treat the password to the sheet.
- The assistant's own terms apply. What you ask, and the figures needed to answer, go to the provider you use, under your plan. Depending on the provider, the plan and your settings, they may use it to improve their models.
- A model called from the browser needs the provider to allow it. For people whose assistant cannot take a link, a model can be called straight from the web page with their own key. That works only where the provider answers a browser's request: Google, Anthropic with its browser-access header, OpenAI, xAI and Groq did. A local model has to be told to accept calls from the page.
Questions
Can a Google Apps Script web app be an MCP server?
Yes. It can answer MCP's JSON-RPC messages from doPost. The client has to follow Google's 302 redirect with a GET, accept HTTP 200 for every answer, and carry the token in the link, because the script cannot read request headers.
Why does Claude's connector check say 302 or Not found?
The set-up check stops at the redirect Google sends for every POST to a web app. Press Continue anyway: the connection itself follows the redirect and works.
Is it safe to give an assistant a link to my spreadsheet's script?
It is as safe as the link is private. The link carries the token, so anyone with it can read what the assistant reads. Give it only to assistants you use, and make a new token if it leaks.
Can the assistant change my spreadsheet?
Only if the script gives it a tool that writes. Here the one write tool stages suggested entries for review, and nothing reaches the ledger until a person accepts them.
Sources
- Claude Help Center: custom connectors using remote MCP — added by URL under Customize → Connectors; "No sign-in: anyone with access to the server URL can use the connector"; phones use connectors added on the web or desktop; read 2026-10-24
- MCP TypeScript SDK: StreamableHTTPClientTransport — redirectPolicy defaults to "same-origin"; "follow" restores fetch's own behaviour; read 2026-10-24
- MCP Python SDK: client transports — redirects followed only to the same scheme, host and port; read 2026-10-24
- MCP Rust SDK issue 1203 — the default client does not follow redirects; read 2026-10-24
Pass it on