Repository navigation
Releases
v0.4.5
A token bench added
Added
bench/tokens.py: what reading the 50k fixture costs a model, in Claude tokens, for
the working tree or any release (--ref v0.1.0). It runs offline, against an
in-memory Sheets API checked against the real one, and counts with
messages.count_tokens, or through the Claude Code CLI when there is no API key.
v0.4.4
Negative money reads as money, and the README measures the tokens it quotes.
Fixed
- A negative amount in a currency format is a number again. Sheets writes the
minus outside the symbol —-$87,-€87— andparse_numbertook a leading
symbol off but not a sign in front of it, so those cells counted as text:
gsheets_aggregateskipped every negative in a dollar column and a total came
back with the refunds missing. Sign, accounting brackets, currency and percent
now come off in any order, which also reads($87.00),US$87,USD 87,
87 €,R$ 87,00,87 zł, a minus sign or en dash pasted in for the hyphen,
and dot-grouped thousands,1.234.567and€1.234,56;1.234alone is still
one point two three four. Checked against a spreadsheet of 49 formatted cells,
kept astests/fixtures/parse_number_cases.csv: 20 read right before, 48 now, and
the 49th is the percent below. 0,125is 0.125, not 125: no group of thousands starts with a zero. A separator
that would have to do two jobs, as in12,345,67or1,234 567, no longer makes
a number, and neither does a minus inside brackets,(-87).
Added
- A
mixed scale:line fromgsheets_aggregatewhen one group feeds a metric both
percentages and plain numbers. A percent reads at face value,12%as 12, while
the sheet stores it as 0.12, so a cell showing that same value without the format,
0.12, lands in the sum a hundred times smaller. No single cell can tell; the
group can. Percentages in one group and dollars in another, the way a long-format
table of metrics keeps them, are not a mix.
Changed
- The README's settlement example is Opus 5.5's answer, and its token figures are
measured: about 2,300 tokens of tool results and 15,000 for the whole exchange,
thinking included, where it said 42,000; two reruns took 17,000 and 25,000. That
is Claude Code on Opus 5.5 with only this server connected, read as the context
after the answer minus the same session answering "OK". A row of the fixture is
about 115 tokens and three of its columns about 18, where the README said ninety
and a dozen. The 174-token aggregate, the 600,000-token page and the six-million
tab were already right. generate_amazon_settlement.pyscales its token estimate by the same measurement,
so it prints ~5.75M for the 50k fixture instead of 4.79M and agrees with the
README.
v0.4.3
A second example in the README, on a 50k-row report anyone can try, and a setup that
fits on one screen.
Added
- A second example at the top of the README: one question about a 50,009-row Amazon
settlement report, answered by Sonnet 5 in five small tool calls and about 42,000
tokens all told, where loading the tab itself would take close to six million. The
report is the synthetic fixture fromtests/fixtures/, published as a
public Google Sheet,
so the same question works for anyone. The last step of Setup points at it too, and
the first question after installing needs no data of your own.
Changed
- Setup is three short steps — Google Cloud, install, connect — and the service
account and the HTTP transport moved to their own section, "Running on a server".
Claude Code connects over stdio with a singleclaude mcp add, registered for every
project with-s user; the old command had no scope, so the server showed up only
in sessions started from the directory it was added in. - "Reading a big tab" and "Summarising a big tab" are one section, "Analysing a big
tab": summarise first, read the rows when you need them. - Token figures in the README are measured with Claude's tokenizer instead of
estimated: a 5,000-row page of the settlement report is nearly 600,000 tokens rather
than half a million, and the whole 50k-row tab close to six million rather than five.
v0.4.2
The answer without the rows: a tab too big to read can still be summed up.
Added
gsheets_aggregate— count, count_distinct, sum, min, max or avg per group, over
the rows that passwhere, with none of those rows reaching the model. Only the
columns the question names leave Google, every row of them, and what comes back is
one line per group, sorted by the first metric: "how many orders" on the 50k-row
fixture is one call and a few dozen tokens where it was ten pages of half a
million each. Columns are named by header text or letter. Empty cells never count,
so a totals line with a blank order id is not an order;sumandavgskip cells
that are not numbers and say so on askipped:line, andavgis rounded to four
decimals. Numbers are read the way the sheet displays them —$1,240.50,12%,
(340)— with the same parsing that types columns ingsheets_list_sheets. Nine
whereoperators, numeric when both sides are numbers and textual otherwise. 200
groups by default; thegroups:line says how many there were. It changes
nothing, so it is there in read-only mode too.parse_numberinformatting: the number behind a formatted cell, which the type
inference used to establish and throw away.
v0.4.1
A first step towards tabs too big to read whole: read the columns a question needs
rather than every row, and a fixture large enough to make that matter.
Added
columnsongsheets_read_sheet— letters, or spans likeA:D, of the columns
to read instead of whole rows. Each is its own bounded range in a single
batchGet, read column-major and stitched into rows on the server, so the call
costs one round trip however many columns it names. Paging is unchanged:offset
andlimitaddress the rows, the header rides along on page two as before, and
the response opens withcolumns: G, H, Oso the grid stays labelled. On the
24-column settlement report below a row costs about a dozen tokens instead of
ninety. One caveat, stated in the tool's description: the API trims a column's
trailing blanks, so a page is as tall as the tallest column asked for, and a
sparse column requested on its own reads shorter than the tab.scripts/generate_amazon_settlement.py, and the two files it writes to
tests/fixtures/: a synthetic Amazon settlement report — the real report's 24
columns and row grammar, several lines per order and several items per order,
fees, tax, promotions, refunds, reserves — over an invented catalogue and invented
ids. The 5k-line one is just over a defaultGSHEETS_MAX_READ_ROWSpage, so a
whole-tab read has to page exactly once; the 50k-line one is ten pages. Both are
deterministic per seed, and--rows N --out filewrites one of any size.
Changed
include_headeris parsed like the other flags:"false"from a model now turns
the repeated header off, and a value that is neither true nor false is an error
rather than a silent true.
v0.4.0
Added
-
gsheets_list_sheetsprofiles the tabs it lists. Per tab: one line per column
with its letter, name and inferred type, a few sample rows, and roughly how many
rows of data the tab actually holds — whichgridProperties.rowCountnever said,
because it counts the grid and a blank tab claims a thousand rows. Answering "what
is in this document" used to mean a read per tab, and a read is capped at 5000
rows precisely because a page of them is expensive; a twelve-column page costs
around 120k tokens to learn twelve column names. The preview is two batched round
trips for the whole document however many tabs it has, and costs a few hundred
tokens a tab. -
sample_rows(default 3, capped at 20) andpreview: falseon the same tool.
With preview off it is the old bare listing, and one API call rather than three.
sample_rows: 0prints no rows and still names and types every column, which on a
wide document is most of the value for about half the tokens. The rows the types
are inferred from are read either way and are not the rows printed: a type is a
claim about the column, and inferring it from the print window madesample_rows: 0
answeremptyfor everything — "nothing under this header" — next to a row count
saying otherwise. -
The tab line reports
charts=N, so a model that is about to draw a chart can see
the one that is already there. The chart ids ride along in the properties request
that was being made anyway, so this costs no call and three tokens. -
(gap at N)on the tab line: row N is blank and has content below it, which is a
totals line or a note under the table rather than the end of it. A row count on its
own cannot tell those apart, and a read that trusts it swallows the footer.
Changed
- The tab line is
gid=rather thansheet_id=— no tool takes one as an argument,
so the only use left is the#gid=fragment of a tab's URL — and it no longer
carries the grid size once the real row count is known.grid=still appears where
it is the only size there is: an empty tab, orpreview: false. - The tool's own description is about 44% shorter than the first cut of it. It is paid
intools/listfor every conversation the server is connected to, whether or not a
spreadsheet ever comes up. gsheets_list_sheetsreturns tab-separated text by default, the way the read
tools already do. As JSON every profiled column spent a pair of braces and three
quoted keys to say what one tab-separated line says, on every column of every tab.
GSHEETS_OUTPUT_FORMAT=jsonreturns the structured form, now withschema,
sample,data_rows,empty_columnsandemptyalongside the existing fields.
This changes the shape of the tool's output for anything parsing it.- The server instructions and
gsheets_read_sheet's description now say that
reading a whole tab to find out what is in it is the wrong move, and point at the
preview instead. The instructions are what the model actually reads, so a tool
nothing tells it to prefer is a tool it will not reach for.
v0.3.0
Added
gsheets_add_chart— a chart drawn from a range that is already on the sheet:
line, column, bar, area or scatter. The range is read as a table, first column
the x axis and every column after it a series named by its header, which is the
shape a model already has in hand after writing the data. It floats one column
right of that range by default, so placing it costs no arguments;anchor,
new_sheet,stacked,widthandheightare there when the default is wrong.
v0.2.1
Hardening for the HTTP transport, and one more protocol revision on the list. No
tool changed, and nothing here affects a stdio setup except the version bump.
Fixed
- The
/mcpendpoint no longer runs the JSON-RPC handler on the event loop. The
tools beneath it make blocking calls to Google, so one slow read used to stall
every other request the worker had —/healthzincluded — for the whole round
trip. It now runs in a worker thread.
Security
- Cross-origin browser requests to
/mcpare rejected with403, which is what
the Streamable HTTP transport requires. Binding to127.0.0.1is not a defence
on its own: a page on the open web can point its own domain at loopback and
arrive as same-origin. TheOriginheader is what tells that apart from a real
local client, so the check runs before the token check — it has to cover the
case that needs it most, a local server started with noMCP_AUTH_TOKEN. MCP_ALLOWED_ORIGINS— comma-separated origins allowed past that check.
Loopback is always allowed, and clients that send noOrigin(Claude Code,
curl, a reverse proxy) are unaffected, so local and stdio setups need nothing.
Added
2025-11-25is negotiated alongside the revisions already supported, and is now
what an unrecognised version is answered with. Nothing in it breaks a tools-only
server, and its two hard requirements —403on a badOrigin, and input
validation reported as a tool error rather than a protocol error — were already
met, the first by this release and the second since 0.1.0.
v0.2.0
Read optimisation: TSV instead of JSON, paging instead of a full download. A 1000×8
table costs ~16k tokens instead of ~37k.
Added
GSHEETS_OUTPUT_FORMAT(tsv|json, defaulttsv) — the wire format for
sheet contents. A server setting, not a tool argument, so the model neither sees
it nor has to choose.- Paging arguments on
gsheets_read_sheet:offset,limitandinclude_header.
From page two on, the range's first row is repeated above the page so the columns
stay named; it rides in the same request, so a page is still one round trip. read_grid()ingsheets_mcp.tools— the facts of a read as a dict, before any
wire format. Use it when composing a tool on top of a read.window_a1(),column_letters(),rows_to_tsv()andescape_cell()in
gsheets_mcp.formatting.
Changed
- Sheet contents come back as a tab-separated grid under a short
range:/rows:
header instead of a JSON 2D array. A tab or newline inside a cell is escaped to a
literal\t/\n, and ragged rows are padded, so the grid stays rectangular.
Nothing is lost: the values API already hands back every cell as a string. - Every tool result is serialised as compact JSON. Indented JSON put each cell of a
nested array on its own line, which nearly doubled the tokens of every response. GSHEETS_MAX_READ_ROWSnow means page size, and the ceiling onlimit, rather
than the point where a read was silently cut short.0still means no limit, and
now also means no paging.- A read asks Google for the page rather than the sheet: the window is pushed into
the A1 range, with one extra row requested to detect whether a next page exists.
Removed
- The
truncated,returned_rowsandnotefields on a read result. A read that
has more rows now says so withnext_offsetand the offset to continue from.
Upgrading
gsheets_read_sheet()returns a string by default. Code that indexed into its
result (result["values"]) should callread_grid()instead, or set
GSHEETS_OUTPUT_FORMAT=json.- A client that paged by hand with explicit
rangearguments can keep doing so —
offset/limitpage withinrange— but no longer needs to.
v0.1.0
First public release. A self-hosted MCP server that gives a model your Google
Sheets, in roughly 1,500 lines of Python.
Added
- Read tools:
gsheets_list_sheets,gsheets_read_sheet. - Write tools:
gsheets_append_rows,gsheets_update_sheet,gsheets_format_cells,
gsheets_add_sheet,gsheets_delete_sheet,gsheets_create_spreadsheet. gsheets_find_spreadsheets, behindGSHEETS_ENABLE_DRIVE_SEARCH— off by
default, because it is the one tool that can reach a document you never shared.- Two transports: MCP Streamable HTTP (JSON-RPC 2.0 over POST) and stdio.
- Google identity as either a service account or a stored OAuth token.
- Guard rails:
GSHEETS_READ_ONLY(write tools are not registered at all),
GSHEETS_ALLOWED_SPREADSHEETS,GSHEETS_MAX_READ_ROWS, and bearer-token auth
viaMCP_AUTH_TOKEN.