Documentation
MCP Sheets for agents
How to connect an MCP client, what each tool does, and the patterns that let several agents share one workbook without stepping on each other.
On this page
Overview
MCP Sheets hosts shared spreadsheets, called workbooks, that AI agents read and write through the Model Context Protocol. People open the same workbook in the browser and see every change as it happens. Each workbook has sheets (tabs), each sheet is a grid with named, optionally typed columns, and every change is recorded with who made it, for whom, and why.
A good loop for an agent:
list_workbooksorcreate_workbook, thendescribe_workbookto learn the sheets, columns and recent changes.readwith one or more queries. Rows come back as objects keyed by column name, with_rowand_id.editwith every operation for one logical step in a single call, and anoteexplaining why.get_changeswithsince_revisionto see what other agents and people did since your last call.
Connecting
The server speaks Streamable HTTP at /mcp. It accepts two kinds of credentials:
- API key. Create one in the app's settings. Keys start with
msk_and are sent asAuthorization: Bearer msk_…(anx-api-keyheader also works). Name the key after the tool that uses it, such as "Codex": the name appears in the changelog. - OAuth. Clients that support remote connectors (Claude.ai, ChatGPT) register themselves and send you through sign-in. No key to copy.
Either way the agent acts as you: it sees your workbooks and has your role in each one.
Claude Code
claude mcp add --transport http sheets "/mcp" \
--header "Authorization: Bearer msk_your_key"Then run /mcp inside Claude Code to check that the sheets server is connected.
Codex CLI
Add the server to ~/.codex/config.toml and keep the key in an environment variable:
[mcp_servers.sheets]
url = "/mcp"
bearer_token_env_var = "MCP_SHEET_KEY"
# edit is marked destructive; approve it once so agents don't stop to ask
default_tools_approval_mode = "approve"export MCP_SHEET_KEY=msk_your_keyCursor and other clients
Most clients accept an mcpServers JSON block. For Cursor, put it in ~/.cursor/mcp.json (or .cursor/mcp.json in a project).
{
"mcpServers": {
"sheets": {
"url": "/mcp",
"headers": { "Authorization": "Bearer msk_your_key" }
}
}
}Claude.ai and ChatGPT
Add MCP Sheets as a custom connector. The connector signs in with OAuth, so there is no key to manage.
- 1Open Settings → Connectors → Add custom connector.
- 2Paste
/mcpas the server URL. - 3Sign in to MCP Sheets and approve access.
One workbook only
Add ?workbook= with a workbook id (or its exact name) to the URL, for example /mcp?workbook=k3j2h1g0f9d8. The connection then works on that workbook alone: the tools drop their workbook argument, list_workbooks and create_workbook go away, and calls about any other workbook are refused, in code mode too. The Connect agent button inside a workbook gives you this URL. Quote it in a shell, since ? is special in zsh.
It is a guard rail, not a permission: the same key or sign-in without ?workbook= still reaches all of your workbooks.
Concepts
| Term | Meaning |
|---|---|
| Workbook | A shared spreadsheet. Tools take its id (k3j2h1g0f9d8), its URL, or its exact name. |
| Sheet | A tab in a workbook, addressed by name (case-insensitive) or id. |
| Column | Has a letter, an optional name and a type. Refer to it by name (Status) or letter (C); names win over letters. |
| Row | Addressed by its 1-based number as shown in the UI, or by a stable id (r_k3j2h1g0f9) that survives inserts and deletes. Reads return both as _row and _id. |
| Key column | Optional per sheet. Identifies rows for upsert_rows and labels rows in the changelog. |
| Revision | A counter per workbook. Every edit that changes something becomes exactly one new revision. Reads and edits return the current one. |
Column types
| Type | Accepts |
|---|---|
auto | Anything. The default. |
text | Strings. |
number | Numbers. |
boolean | true or false. |
date | ISO dates (yyyy-mm-dd) or datetimes. |
url | Links. People see them as previews with title, favicon and image. |
select | One of the column's options. |
Values
- A cell holds a string, number or boolean.
nullor""clears it. - There are no formulas. A string like
=Tasks!B4stores a live reference to another cell. It follows that cell when rows or columns move. Reads return it as{ "ref": "=Tasks!B4", "value": … }. Start with'to store a literal=. - Cells that contain URLs show as rich link previews in the browser.
Tools
Seven tools. The read tools carry the MCP read-only hint. Every result is JSON. Errors name the failing operation and list valid sheets and columns, so an agent can correct itself.
list_workbooks
The workbooks you own or that were shared with you, most recent activity first, plus your plan limits.
{}{
"workbooks": [
{
"id": "k3j2h1g0f9d8",
"name": "Sprint 14",
"role": "owner",
"visibility": "private",
"revision": 128,
"updated_at": "2026-10-15T09:12:44.000Z",
"url": "/w/k3j2h1g0f9d8"
}
],
"plan": { "name": "Free", "private_workbooks_used": 1, "private_workbooks_limit": 1, "public_workbooks": "unlimited" }
}create_workbook
Creates a workbook, and optionally its sheets, typed columns and first rows, in one call.
- namestring
- Workbook name.
- visibility"private" | "public"
- Default
private. - sheetsarray
- Up to 20 of
{ name, columns?, rows?, key_column? }. Default: one empty sheet. - notestring
- Why it was created. Goes in the changelog.
{
"name": "Sprint 14",
"sheets": [
{
"name": "Tasks",
"key_column": "ID",
"columns": [
"ID",
"Title",
{ "name": "Status", "type": "select", "options": ["todo", "doing", "done"] },
"Owner",
{ "name": "Points", "type": "number" },
{ "name": "Link", "type": "url" }
],
"rows": [
{ "ID": "T-1", "Title": "Workbook schema in SQLite", "Status": "todo", "Points": 3 }
]
}
],
"note": "Planning board for sprint 14"
}Returns { id, name, visibility, revision, url }.
describe_workbook
Sheets with their columns (letter, name, type, options), row counts, key columns, a few preview rows, the current revision and the five latest changes. Call it before working in an unfamiliar workbook.
{ "workbook": "Sprint 14", "preview_rows": 3 }{
"id": "k3j2h1g0f9d8",
"name": "Sprint 14",
"your_role": "owner",
"revision": 128,
"sheets": [
{
"name": "Tasks",
"rows": 7,
"key_column": "ID",
"columns": [
{ "letter": "A", "name": "ID", "type": "auto" },
{ "letter": "C", "name": "Status", "type": "select", "options": ["todo", "doing", "done"] }
],
"preview": [{ "_row": 1, "_id": "r_0c4k2m9x1a", "ID": "T-1", "Status": "done" }]
}
],
"recent_changes": [ … ]
}read
Runs up to 20 queries in one call, for example a filtered view of one sheet plus a range from another. Each result carries the workbook revision. See Read queries for every option.
{
"workbook": "Sprint 14",
"queries": [
{
"sheet": "Tasks",
"where": { "Status": "todo", "Points": { "gte": 2 } },
"columns": ["ID", "Title", "Points"],
"order_by": [{ "column": "Points", "desc": true }]
},
{ "range": "Notes!A1:C10" }
]
}{
"sheet": "Tasks",
"revision": 128,
"total_rows": 2,
"returned": 2,
"rows": [
{ "_row": 5, "_id": "r_7hd02kq9zm", "ID": "T-5", "Title": "Link previews for URL cells", "Points": 3 },
{ "_row": 4, "_id": "r_p1x8c3n0va", "ID": "T-4", "Title": "Changelog export to Markdown", "Points": 2 }
]
}edit
Applies a batch of operations atomically: all of them succeed as one revision, or nothing changes. Group everything for one logical step into one call.
- workbookstring
- Id, URL or exact name.
- opsarray
- 1 to 200 operations, applied in order.
- notestring
- Why you are making the change. People and other agents read it in the changelog.
- agentstring
- Your name or role, such as
planner. Useful when several agents share one key. - expected_revisionnumber
- Fail with a conflict, listing the changes you missed, if the workbook has moved past this revision.
{
"workbook": "Sprint 14",
"agent": "planner",
"note": "Key rotation was missing from the plan",
"ops": [
{
"op": "append_rows",
"sheet": "Tasks",
"rows": [{ "ID": "T-7", "Title": "Rotate API keys", "Status": "todo", "Points": 2 }]
},
{ "op": "update_rows", "sheet": "Tasks", "where": { "ID": "T-3" }, "set": { "Points": 5 } }
]
}{
"workbook": "k3j2h1g0f9d8",
"revision": 127,
"changed": true,
"summary": "Tasks: appended row 7 {ID: \"T-7\", …}\nTasks: updated row 3 (\"T-3\") — Points: 3 → 5",
"results": [
{ "op": "append_rows", "sheet": "Tasks", "rows": [{ "row": 7, "id": "r_v9t3b6w2qe" }] },
{ "op": "update_rows", "sheet": "Tasks", "matched": 1, "changed": 1,
"rows": [{ "row": 3, "id": "r_m2k8f1s7dd" }] }
],
"url": "/w/k3j2h1g0f9d8"
}If the values were already as requested, the result has "changed": false and no revision is created.
get_changes
The changelog, oldest first. Pass the revision from your last read or edit to catch up on what others did.
- workbookstring
- Id, URL or exact name.
- since_revisionnumber
- Only changes after this revision. Without it you get the latest entries.
- limitnumber
- Default 20, max 200.
has_moreis set when more remain. - sheetstring
- Only changes that touched this sheet.
- include_cellsboolean
- Add exact before → after values per cell (up to 100 per change).
{ "workbook": "Sprint 14", "since_revision": 126, "include_cells": true }{
"workbook": "k3j2h1g0f9d8",
"revision": 128,
"changes": [
{
"rev": 128,
"at": "2026-10-15T09:12:44.000Z",
"by": "builder [Codex] (agent via MCP, for Dana)",
"note": "Claiming the next todo task",
"summary": "Tasks: updated row 3 (\"T-3\") — Status: \"todo\" → \"doing\", Owner: ∅ → \"builder\"",
"cells": [
{ "cell": "Tasks!C3", "column": "Status", "before": "todo", "after": "doing" },
{ "cell": "Tasks!D3", "column": "Owner", "before": null, "after": "builder" }
]
}
]
}wait_for_changes
Waits until a person or another agent changes the workbook, then returns those changes, so an agent can react instead of polling. Act on the result, then call it again with the revision it returned. It returns at once if there are already newer changes, and your own edits do not wake you. Each account can have 5 waits open at a time.
- workbookstring
- Id, URL or exact name.
- since_revisionnumber
- The last revision you saw.
- timeout_snumber
- Default 50, max 240. Then it returns
timed_out: truewith no changes: call it again. - sheetstring
- Only wake for changes that touched this sheet.
- include_ownboolean
- Also wake for your own edits.
- include_cellsboolean
- Add exact before → after values per cell.
Many clients stop a tool call after 60 seconds, which is why the default wait is shorter. The result has the same shape as get_changes, plus timed_out.
run_code
Runs JavaScript you write in an isolated sandbox, for work that would otherwise take many calls: loops, aggregation, edits that depend on what you read. Details in Code mode.
{ "code": "const r = await sheets.read(\"Sprint 14\", { sheet: \"Tasks\", where: { Status: \"done\" } })\nreturn r.rows.reduce((sum, row) => sum + (row.Points ?? 0), 0)" }{ "ok": true, "result": 5, "logs": [], "ms": 38 }Edit operations
Each item in ops has an op field. sheet takes a sheet name or id; it is optional for set, set_range and clear when addresses include the sheet (Notes!A1).
| op | What it does |
|---|---|
setsheet?, cells | Write cells by A1 address, such as { "B4": "done" }. Grows the sheet as needed. |
set_rangesheet?, start, values | Write a 2D block of rows starting at the top-left cell. |
append_rowssheet, rows, create_missing_columns? | Add rows at the bottom. Rows are objects keyed by column name, or arrays from column A. |
insert_rowssheet, before, rows? | count? | Insert rows before a row number. Rows below shift down. |
update_rowssheet, rows? | where?, set, limit? | Update listed rows, or rows matching a filter. With where and limit: 1 it is an atomic claim. |
upsert_rowssheet, key?, rows, create_missing_columns? | Update rows whose key matches and append the rest. key defaults to the sheet's key column. |
delete_rowssheet, rows? | where?, limit? | Delete rows. Rows below move up. |
clearsheet?, range | Empty a range such as B2:D20, C:C or 5:5. Rows and columns stay. |
add_sheetname, columns?, rows?, key_column?, position? | Add a tab, optionally with typed columns and rows. |
update_sheetsheet, name?, key_column?, position? | Rename, set or clear (null) the key column, or move the tab. |
delete_sheetsheet | Delete a sheet. |
add_columnssheet, columns, before? | Add columns at the end, or before another column. |
update_columnsheet, column, name?, type?, options?, width? | Rename, change type (existing values are validated), set select options or width. |
delete_columnssheet, columns | Delete columns. |
A column spec is either a name ("Owner", type auto) or { "name", "type"?, "options"?, "width"? }. Unknown column names in rows are an error unless create_missing_columns is true.
Read queries
Each query in read (and in sheets.read in code mode) accepts:
| Option | Description |
|---|---|
sheet | Sheet name or id. Omit it only with search, to search every sheet. |
range | A1 range such as A1:D20 or Notes!A1:C10. Implies grid format. |
columns | Only return these columns (names or letters). |
rows | Only these rows (numbers or row ids). |
where | Filter rows. All conditions must match. See operators below. |
search | Case-insensitive text search across all cells. |
changed_since | Only rows changed after this revision. Pair it with the revision from your last read. |
order_by | [{ "column": "Points", "desc": true }]. Sorts the result, not the sheet. |
offset | Paging. Results include next_offset when more rows remain. |
limit | Default 200, max 2000. |
format | objects (default): one { _row, _id, Column: value } per row, empty cells omitted. grid: 2D values. csv: compact text. |
Filter operators
A plain value means equals, and null means empty: { "Status": "todo", "Owner": null }. For anything else use an operator object:
| Operator | Matches |
|---|---|
eq | Equal to the value. |
ne | Not equal to the value. |
| gt gte lt lte | Greater / less than (numbers, or strings such as ISO dates). |
contains | Case-insensitive substring. |
in | Any of a list: { "in": ["todo", "doing"] }. |
empty | true for empty cells, false for cells with a value. |
{ "where": { "Points": { "gte": 3 }, "Title": { "contains": "auth" }, "Owner": { "empty": true } } }Coordinating agents
Each workbook is a single Cloudflare Durable Object, so edits are applied one at a time and every edit sees the result of the one before. Three patterns build on that.
Claim work atomically
Use update_rows with where and limit: 1. Two agents running this at the same moment never get the same row.
{
"workbook": "Sprint 14",
"agent": "builder",
"note": "Claiming the next todo task",
"ops": [{
"op": "update_rows",
"sheet": "Tasks",
"where": { "Status": "todo", "Owner": null },
"set": { "Status": "doing", "Owner": "builder" },
"limit": 1
}]
}Check results[0].changed: 1 means you own the row listed in results[0].rows; 0 means nothing was left to claim.
Catch up instead of re-reading
Keep the revision from your last call. Later, ask for only what happened since:
{ "workbook": "Sprint 14", "since_revision": 126 }Or read just the rows that changed, with { "sheet": "Tasks", "changed_since": 126 } as a read query.
Refuse to overwrite unseen changes
Pass expected_revision on edit. If someone wrote in the meantime, nothing is applied and the error lists what you missed:
Conflict: expected revision 126 but the workbook is at 128. Changes since then:
rev 127 by planner [Codex] (agent via MCP, for Dana): Tasks: appended row 7 {ID: "T-7", …}
rev 128 by builder [Codex] (agent via MCP, for Dana): Tasks: updated row 3 ("T-3") — Status: "todo" → "doing", …
Re-read what you need and retry.agent on every edit (planner, builder). The changelog then shows which one did what.Code mode
run_code runs your JavaScript in a fresh Cloudflare Dynamic Worker. The sandbox has no network access, a CPU budget of about 2 seconds, and one capability: the sheets API, scoped to the user the agent acts for. The code is the body of an async function.
sheets.list()
sheets.describe(workbook, { preview_rows }?)
sheets.read(workbook, query) // one query → one result
sheets.read(workbook, [query, query]) // an array → an array of results
sheets.edit(workbook, ops, { note, agent, expected_revision }?)
sheets.changes(workbook, { since_revision, limit, sheet, include_cells }?)
console.log(…) // captured and returned in "logs"Queries and operations are the same objects the read and edit tools take. Return any JSON-serializable value.
const r = await sheets.read("Sprint 14", { sheet: "Tasks", where: { Status: "todo" } })
const ops = r.rows.map((row) => ({
op: "update_rows",
sheet: "Tasks",
rows: [row._id],
set: { Priority: row.Points > 3 ? "high" : "low" },
}))
console.log(`prioritizing ${ops.length} tasks`)
return await sheets.edit("Sprint 14", ops, { note: "Prioritized todo tasks by points" })The tool returns { ok: true, result, logs, ms }, or { ok: false, error, logs, ms } if the code threw. Edits made from code mode appear in the changelog as agent via code mode. If your client does not list run_code, code mode is turned off on this server.
Changelog
Every edit, from an agent or a person, becomes one changelog entry with a revision number, a timestamp, who made it, the optional note, and a readable summary. Agents read it with get_changes. People see it live in the Activity panel.
Who
| Actor | Shown as |
|---|---|
| Person in the browser | Dana (web app) |
| Agent with an API key or OAuth | Codex (agent via MCP, for Dana), where "Codex" is the key name or OAuth client name |
Agent passing agent | builder [Codex] (agent via MCP, for Dana) |
Summary lines
One line per change, with ∅ for an empty cell. Large changes are summarized by count.
Tasks: updated row 3 ("T-3") — Status: "todo" → "doing", Owner: ∅ → "builder"
Tasks: appended row 7 {ID: "T-7", Title: "Rotate API keys", Status: "todo", Points: 2}
Tasks!E5 (Points): 2 → 3
Tasks: updated 12 rows matching {"Status":"todo"} (set Sprint="15")
Tasks: deleted row 6 ("T-6")Markdown export
The Activity panel exports the log as Markdown, ready to paste into any AI chat as context:
# Changelog: Sprint 14
Workbook /w/k3j2h1g0f9d8 · current revision 128
## rev 128 · 2026-10-15 09:12:44Z · builder [Codex] (agent via MCP, for Dana)
> Claiming the next todo task
- Tasks: updated row 3 ("T-3") — Status: "todo" → "doing", Owner: ∅ → "builder"Sharing and plans
- Invite people by email as editor or viewer. An invite to someone without an account applies when they sign up.
- Private workbooks are visible to you and invited members only. Public workbooks are read-only for everyone else: anyone with the link can view them and their agents can read them, but only you and invited editors can change anything. Who it is shared with stays private.
- Making a workbook private again, or removing someone, takes effect at once, even for people who have it open.
- Agents get the role of the person they act for. A viewer's agent can read but not edit.
- Sharing with someone gives them access once their email address is confirmed (we send them a link). The Free plan includes 1 private workbook and unlimited public workbooks. A Pro plan with more private workbooks is coming later.
list_workbooksreports your current usage.
Limits
| Limit | Value |
|---|---|
| Operations per edit call | 200 |
| Cell changes per edit call | 20,000 |
| Rows per sheet | 100,000 |
| Columns per sheet | 200 |
| Sheets per workbook | 50 |
| Characters per cell | 50,000 |
| Queries per read call | 20 |
| Rows per read query | 2,000 (default 200) |
| Changes per get_changes call | 200 (default 20) |
| Code mode | About 2s CPU, no network |
Hitting a size limit fails the whole edit with a message that says which one. Split large imports into several edit calls.
There are also fair-use limits on how fast an account can call. Going over returns HTTP 429 with Retry-After: wait and retry. Batching keeps you well under them: one edit call can carry many operations, and one read call many queries.
Ready to connect an agent?
Create a workbook in the app, then add the MCP URL to your client.