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.

MCP endpoint/mcp
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:

  1. list_workbooks or create_workbook, then describe_workbook to learn the sheets, columns and recent changes.
  2. read with one or more queries. Rows come back as objects keyed by column name, with _row and _id.
  3. edit with every operation for one logical step in a single call, and a note explaining why.
  4. get_changes with since_revision to 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 as Authorization: Bearer msk_… (an x-api-key header 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 CodeClaude Code

terminal
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.

CodexCodex CLI

Add the server to ~/.codex/config.toml and keep the key in an environment variable:

~/.codex/config.toml
[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"
shell
export MCP_SHEET_KEY=msk_your_key

CursorCursor 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).

mcp.json
{
  "mcpServers": {
    "sheets": {
      "url": "/mcp",
      "headers": { "Authorization": "Bearer msk_your_key" }
    }
  }
}

ClaudeChatGPTClaude.ai and ChatGPT

Add MCP Sheets as a custom connector. The connector signs in with OAuth, so there is no key to manage.

  1. 1Open Settings → Connectors → Add custom connector.
  2. 2Paste /mcp as the server URL.
  3. 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

TermMeaning
WorkbookA shared spreadsheet. Tools take its id (k3j2h1g0f9d8), its URL, or its exact name.
SheetA tab in a workbook, addressed by name (case-insensitive) or id.
ColumnHas a letter, an optional name and a type. Refer to it by name (Status) or letter (C); names win over letters.
RowAddressed 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 columnOptional per sheet. Identifies rows for upsert_rows and labels rows in the changelog.
RevisionA counter per workbook. Every edit that changes something becomes exactly one new revision. Reads and edits return the current one.

Column types

TypeAccepts
autoAnything. The default.
textStrings.
numberNumbers.
booleantrue or false.
dateISO dates (yyyy-mm-dd) or datetimes.
urlLinks. People see them as previews with title, favicon and image.
selectOne of the column's options.

Values

  • A cell holds a string, number or boolean. null or "" clears it.
  • There are no formulas. A string like =Tasks!B4 stores 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.

arguments
{}
result
{
  "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.
arguments
{
  "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.

arguments
{ "workbook": "Sprint 14", "preview_rows": 3 }
result (trimmed)
{
  "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.

arguments
{
  "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" }
  ]
}
result (first query)
{
  "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.
arguments
{
  "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 } }
  ]
}
result
{
  "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_more is 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).
arguments
{ "workbook": "Sprint 14", "since_revision": 126, "include_cells": true }
result
{
  "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: true with 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.

arguments
{ "code": "const r = await sheets.read(\"Sprint 14\", { sheet: \"Tasks\", where: { Status: \"done\" } })\nreturn r.rows.reduce((sum, row) => sum + (row.Points ?? 0), 0)" }
result
{ "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).

opWhat it does
setsheet?, cellsWrite cells by A1 address, such as { "B4": "done" }. Grows the sheet as needed.
set_rangesheet?, start, valuesWrite 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?, rangeEmpty 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_sheetsheetDelete 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, columnsDelete 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:

OptionDescription
sheetSheet name or id. Omit it only with search, to search every sheet.
rangeA1 range such as A1:D20 or Notes!A1:C10. Implies grid format.
columnsOnly return these columns (names or letters).
rowsOnly these rows (numbers or row ids).
whereFilter rows. All conditions must match. See operators below.
searchCase-insensitive text search across all cells.
changed_sinceOnly 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.
offsetPaging. Results include next_offset when more rows remain.
limitDefault 200, max 2000.
formatobjects (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:

OperatorMatches
eqEqual to the value.
neNot equal to the value.
gt gte lt lteGreater / less than (numbers, or strings such as ISO dates).
containsCase-insensitive substring.
inAny of a list: { "in": ["todo", "doing"] }.
emptytrue 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.
When several agents share one API key, pass 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.

available inside run_code
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.

example
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

ActorShown as
Person in the browserDana (web app)
Agent with an API key or OAuthCodex (agent via MCP, for Dana), where "Codex" is the key name or OAuth client name
Agent passing agentbuilder [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.md
# 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_workbooks reports your current usage.

Limits

LimitValue
Operations per edit call200
Cell changes per edit call20,000
Rows per sheet100,000
Columns per sheet200
Sheets per workbook50
Characters per cell50,000
Queries per read call20
Rows per read query2,000 (default 200)
Changes per get_changes call200 (default 20)
Code modeAbout 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.

Open app