goodreads-mcp
The server answers statistical questions about a Goodreads BigQuery dataset through twelve read-only aggregate MCP tools, with enforced data-quality guards and per-figure caveats.
Explore the dataset:
dataset_overviewreports live row counts, column coverage, and all known defects (unrated books, edition overcounts, placeholder publish dates).Analyze book ratings:
rating_distributiongives a histogram of per-book mean ratings and pooled 1–5 star shares;top_books_by_ratinglists highest/lowest-rated books above a ratings threshold.Group ratings by dimension:
stats_by_language,stats_by_year,stats_by_publisher, andstats_by_authorcompute rating aggregates per group, with optionalorder_by,direction, filters, and aunitchoice between editions and works.Study publication patterns:
page_count_statscompares book length against rating;publish_month_seasonalityshows monthly publication seasonality (with January flagged as inflated).Analyze the user panel:
user_ratings_overviewdescribes the 4,154-user ratings panel;top_titles_by_user_ratingsranks best/worst-liked titles within that panel.Cross-reference panel vs. Goodreads:
compare_user_vs_book_ratingsjoins user ratings to book ratings by normalized title and highlights where the panel disagrees with the wider rating.Every result is guarded: rankings enforce
min_ratings≥ 1, output is capped, queries are cost-capped, banned columns are rejected before reaching BigQuery, and each result includesn,excluded,filters, caveats, and query metadata.
Provides statistical analysis of Goodreads book data, including rating distributions, top books by ratings, stats by language/year/publisher/author, page-count vs rating relationships, publication month seasonality, and comparisons of user ratings against overall book ratings.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@goodreads-mcp Show me the best-rated books with at least 500 ratings."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
goodreads-mcp
An MCP server answering statistical questions about the Goodreads dataset in
BigQuery (<project>.goodreads), built on FastMCP and
google-cloud-bigquery with Application Default Credentials.
The dataset has defects that produce confident wrong numbers. This server is
built so that a caller who never reads DATA_NOTES.md still cannot get one:
the rules are enforced in code, and the relevant caveat travels with every
figure returned.
Setup
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
gcloud auth application-default login # if ADC is not already set upRegister with Claude Code:
claude mcp add goodreads -- /home/safilo/projects/goodreads-mcp/.venv/bin/python \
-m goodreads_mcpEnvironment overrides: GOODREADS_BQ_PROJECT, GOODREADS_BQ_DATASET,
GOODREADS_BQ_LOCATION, GOODREADS_MAX_BYTES_BILLED (default 20 GiB).
Related MCP server: BigQuery MCP Server
Tools
Twelve tools, all aggregate. There is no row browser.
tool | answers |
| shape, live column coverage, every known defect |
| histogram of book ratings + pooled star share |
| best/worst books above a ratings threshold — |
| ratings grouped by |
| ratings and volume per publication year — |
| ratings grouped by publisher string — |
| ratings grouped by author string — |
| book length against rating |
| coarse monthly seasonality |
| shape of the 4,154-user panel |
| best/worst liked titles in the panel |
| where the panel disagrees with Goodreads |
Every tool returns the same envelope:
{"data": ..., "n": {...}, "excluded": {...}, "filters": {...},
"caveats": ["[measured] ...", "[DATA_NOTES.md #7] ..."], "query_meta": {...}}n is mandatory and keyword-only in queries.envelope() — no average leaves
this server without the count it rests on, and excluded reports what the
threshold removed to get there.
Editions or works: the unit parameter
A row in books is an edition, and the five tools marked unit above take
unit="editions" (default, except stats_by_author) or unit="works".
Which is right depends on the question. Most prolific publisher wants
editions — issuing five editions is five editions of work. Most-read author
wants works — a novel should not count five times because it has five
editions. stats_by_author therefore defaults to "works"; everything else
defaults to "editions", which preserves the numbers those tools returned
before this option existed.
Under "works", editions sharing a normalised title collapse to one
representative row — the edition carrying the most ratings — before
aggregating. Three things to know about that:
n_ratingsis a floor, not an exact work total. Editions repeat most of their work's rating pool but not exactly: of the 68,921 works with more than one edition at ≥100 ratings, 62,794 (91%) have editions whose totals differ, mean relative spread 8.6%. Summing would overcount and taking the maximum undercounts. The maximum is used.Deduplication is within a group, not across groups. A work issued by two publishers survives once in each, so group totals still do not sum to a corpus total.
Works are identified by title text alone, so the collapse still errs in both directions — see Two title keys and Why
authorsis not in the key below. No work key merges two distinct series ranges any more, but 7,749 keys (11.2%) span more than one author string, and editions titled differently stay split (Calvin And Hobbes: It's a Magical WorldvsCalvin & Hobbes: ...).
Both branches emit identical column names, so order_by and the envelope are
unaffected by the choice. Grouped results add n_edition_rows under "works"
so the size of the collapse is visible; n_books counts works.
Two title keys
queries.py has two title normalisers, and they are not interchangeable:
used for | series suffix | |
| the | stripped entirely |
| all work-level dedup and | range kept, volume number dropped |
title_norm() must reproduce the cleaning script's book_title_normalised
exactly or the documented 52,016-title join coverage breaks, so it cannot
change. But stripping the whole suffix is wrong as a work identity: it
deletes the only thing separating one boxed set from another, so
Harry Potter Boxed Set (Harry Potter, #1-5) and (#1-7) collapsed together
and the most-rated of the merged group displaced all four from a top-rated
list.
work_key() keeps a range (#1-5) and drops a single volume number
(#2). Only ranges denote a different product; a volume number is series
metadata about the same work, and keeping it would split an edition carrying
the suffix from one without it — a regression title_norm() did not have.
The Lion, the Witch and the Wardrobe is numbered both #1 and #2
depending on publication vs chronological order, and must stay merged.
Effect at ≥100 ratings: work keys merging two distinct ranges went 3 → 0, and a further 92 keys that mixed a ranged title with a bare one now split.
Why authors is not in the key
7,749 of the 68,921 multi-edition work keys (11.2%) span more than one author
string, which looks like an argument for adding authors to the key. It was
measured and rejected. Two kinds of case are mixed together and cannot be
separated from these fields:
example | splitting on author is | |
different books, same title |
| correct |
one work, adapter credited |
| wrong |
Two heuristics were tried and both failed. Author-token overlap classifies
92.9% as "disjoint authors", but its own highest-rated members are
Pride and Prejudice, 1984, Animal Farm and The Lion, the Witch and the Wardrobe — all the adapter case, all misclassified. Rating dominance does no
better: the top author's share of a key's ratings spreads smoothly across the
range (14.1% of keys ≥95%, 36.8% below 60%), with no threshold separating the
two kinds.
Adding authors would therefore split adaptations of single works, and those
are concentrated in exactly the heavily-rated classics that dominate rankings.
Against that, the benefit is small and shrinking:
stats_by_authoris structurally immune — it groups by author before collapsing, so a collision can never cross authors there. This is the tool theworksunit was added for.Grouping absorbs most of the rest: only 756 (work key, publisher) groups and 1,913 (work key, year) groups merge more than one author string.
top_books_by_ratingis the only tool partitioning onwork_keyalone, and exposure falls with the threshold — 11.2% of keys atmin_ratings=100, 4.0% at 5,000, and 0 of the top 50 works at 5,000.
Enforced rules
These are guards and validators, not conventions. tests/test_guards.py
covers each one.
publish_dayis never read.bq.guard()rejects any query mentioning it before the query reaches BigQuery, and a test asserts no SQL string in the codebase contains it.Grouping is on
language_normalised. The guard rejects the barelanguagecolumn; the regex leaveslanguage_normalisedalone because\bdoes not match betweenlanguageand_normalised.SELECT *is rejected on a 1.85M-row table.Every ranking takes
min_ratings, floored at 1, not 0 — see below.order_byanddirectionare whitelisted, never interpolated. All filter values are BigQuery named parameters.Row output is capped (100, or 200 for per-year series) and every query is cost-capped via
maximum_bytes_billed.
Caveats are code, not prose
caveats.py is a registry keyed by id. Tools name the ids belonging to the
code path they took; they never write caveat text inline. An unknown id raises
rather than silently omitting a warning. Each caveat is tagged with its
source — [DATA_NOTES.md #n] where the notes document it, [measured] where
this project found it by profiling the loaded tables.
Four defects found by profiling that DATA_NOTES.md does not mention
451,777 books (24.4%) have no ratings and are stored as
rating = 0.0. Not books rated zero. A naiveAVG(rating)is dragged toward zero by a quarter of the table. This is whymin_ratingsis floored at 1 rather than 0 — the floor excludes them structurally, andrequire_min_ratingsexplains why when it rejects 0.language_normalisedis populated for only 13.6% of rows, and 83% of that labelled slice is English. Any language grouping is a statement about a small, English-dominated subsample.A row is an edition, and each edition repeats most of its work's rating total. Crichton's The Lost World is five rows each holding ~117,000 ratings and an identical 3.78. The repetition is near-total but not exact — 62,794 of 68,921 multi-edition works (91%) have editions whose totals differ, mean spread 8.6%, so no exact work total is recoverable. So
SUM(rating_dist_total)overcounts — 7,529,817,002 against 3,148,039,676 deduplicated by work, a 2.4× overcount — andpooled_ratingover-weights works with many editions. Every grouped result reportsn_distinct_titlesandeditions_per_titleso the size of the effect is visible for that specific group. (It is usually mild within a publisher, ~1.05–1.22, and larger across the corpus.)publish_day = 1for 48.25% of rows (892,696 of 1,850,115), and the column has no NULLs. DATA_NOTES.md #1 previously said 73.6%; that was a denominator error, now corrected — 73.60% is the placeholder rate within the 1,212,960-row ambiguous subset of caveat 3, not within the whole table. Nothing to do with the transposition fix. The column stays banned either way.
Two averages, always
Every rating aggregate returns both, because they answer different questions and diverge whenever a group mixes blockbusters with long-tail titles:
avg_book_rating— mean of each book's own mean; every book counts once.pooled_rating— total stars ÷ total ratings; popular books dominate.
rating_dist_1..5 sums exactly to rating_dist_total on all 1,850,115 rows,
so the pooled figure is exact rather than reconstructed.
The one cross-table tool
user_ratings has no book ID, so compare_user_vs_book_ratings joins on
normalised title text, reproducing the cleaning script's normalise_title in
SQL. It uses [^\p{L}\p{N}_\s] rather than [^\w\s] because RE2's \w is
ASCII-only while Python's is Unicode-aware — with the Unicode classes it
reproduces the documented coverage of 52,016 of 98,686 titles exactly; with
\w it loses 922 matches. Editions are pooled per title before joining.
Confining the join to one tool keeps its 52.7% coverage caveat from leaking into eleven tools that would otherwise look authoritative.
Telemetry
Every tool is wrapped by @telemetry.instrument, sitting beneath @mcp.tool.
A test fails if a tool is added without it — the same structural enforcement the
query guards use. One JSON object per call is appended to
logs/telemetry.jsonl (gitignored):
{"ts":"...","tool":"stats_by_author","params":{"min_ratings":100,"unit":"works"},
"outcome":"ok","n_rows":5,"n_queries":2,"bytes_billed":232783872,
"bytes_processed":231847973,"cache_hit":false,"job_ids":["..."],
"duration_ms":4198.1,"bq_ms":4197.3,"overhead_ms":0.8,"queries":[...]}Query results are never recorded, and a guard rejection logs guard_rule and
guard_column — never the SQL that tripped it.
stdout is the MCP protocol channel. This server speaks JSON-RPC over stdio,
so a stray byte on stdout corrupts framing and kills the connection silently.
Telemetry writes to a file by path; its only fallback is an explicit
file=sys.stderr. test_no_server_module_can_reach_stdout walks every package
module's AST and fails on a stdout reference, a print() without an explicit
stderr target, or any logging.basicConfig call — whose default is stderr, but
whose stream= kwarg is one edit from stdout.
Configuration: GOODREADS_TELEMETRY_PATH moves the log,
GOODREADS_TELEMETRY=0 disables it entirely.
goodreads-telemetry # summary: calls, error rate, p50/p95,
# bytes billed, guard rules, params used
goodreads-telemetry --tool stats_by_author --jsonDeploying to Cloud Run
stdio remains the default and is unchanged — python -m goodreads_mcp with no
flag behaves exactly as before, and an existing local Claude Code registration
needs no edit. HTTP is a second transport, selected by --transport http
or GOODREADS_TRANSPORT=http.
./deploy.sh # project <project>, region us-central1
MIN_INSTANCES=0 ./deploy.sh # override the warm-instance knob (see below)IAM: what the service account gets, and why
goodreads-mcp-run@<project>.iam.gserviceaccount.com — read-only,
no key files. Credentials come from the Cloud Run metadata server.
role | scope | why |
| project |
|
| dataset |
|
| project | Container stdout → Cloud Logging, where telemetry goes in HTTP mode. Without it a custom runtime SA has its logs silently dropped. |
Deliberately not granted: roles/bigquery.user (carries
datasets.create, reservations.use and four cloudkms.* permissions) and
roles/bigquery.dataEditor (write access). Keeping the SA read-only is also
why telemetry goes to Cloud Logging rather than a BigQuery table.
Auth: the endpoint is not public
Deployed --no-allow-unauthenticated. Unauthenticated requests get 403 at
Google's edge before reaching the container. Connect through the authenticated
local proxy:
./proxy.sh # keep running
claude mcp add --transport http goodreads-remote http://127.0.0.1:8080/mcpNo header, no token, nothing in ~/.claude.json — proxy.sh injects
credentials and refreshes them itself. It must be running for the server to
connect; without it Claude Code reports ConnectionRefused.
The proxy needs the standalone Cloud SDK. gcloud components install cloud-run-proxy fails on a distro-packaged gcloud:
ERROR: You cannot perform this action because this Google Cloud CLI
installation is managed by an external package manager.Install from https://cloud.google.com/sdk/ — it coexists with the distro
package and shares ~/.config/gcloud, so authentication carries over with no
re-login. proxy.sh uses ~/google-cloud-sdk/bin/gcloud and clears
CLOUDSDK_ROOT_DIR, which a distro install exports and which would otherwise
point the standalone gcloud at the wrong root.
claude mcp add --transport http goodreads-remote \
https://goodreads-mcp-552178111715.us-central1.run.app/mcp \
--header 'Authorization: Bearer ${GOODREADS_ID_TOKEN}'
export GOODREADS_ID_TOKEN=$(gcloud auth print-identity-token)The ${VAR} form keeps the token out of ~/.claude.json. Measured: the token
lasts 60 minutes and its aud is the gcloud OAuth client ID, not the
service URL — the replay weakness Google documents. Re-export and restart
Claude Code when it expires. This is strictly worse than the proxy on both
ergonomics and security.
Trade-off: an extra local process and a gcloud dependency, and it only
works where you are gcloud-authenticated — not Claude.ai web, not a teammate
without a roles/run.invoker binding. In exchange there is no token in any
config file, nothing to expire, and revocation is one binding removal.
A static Authorization: Bearer $(gcloud auth print-identity-token) header
also works with Claude Code, but those tokens last about an hour and, per
Google's docs, lack an audience claim — worse on both ergonomics and security.
Note --ingress all is intentional: "not public" here means IAM returns 403,
not network unreachability. --ingress internal would break the proxy, which
reaches the public URL with a token.
The min-instances knob
deploy.sh sets MIN_INSTANCES=1. It is a knob, not a decision:
first-call latency | idle cost | |
| no cold start | one always-on instance, billed at the idle CPU rate |
| +3–5 s on the first call after scale-to-zero | nothing |
The cold-start figure comes from measuring this app's startup locally:
1,089 ms to import goodreads_mcp.server and a further 1,545 ms to
construct the BigQuery client — 2,634 ms of Python before a query starts, plus
container pull and start on top. --cpu-boost attacks that directly, and the
client is warmed at startup (HTTP mode only) so the first real call does not
pay the 1,545 ms.
Reverting is one variable: MIN_INSTANCES=0 ./deploy.sh. Idle cost is on the
order of tens of dollars a month for one small instance — verify against the
current Cloud Run pricing before
committing; that figure is an estimate, not a measurement.
Latency figures measured before deployment are cache-inflated
Do not quote the pre-deployment p50 as a production baseline.
The p50 of 3,557 ms and p95 of 6,457 ms in this repo's telemetry were measured locally on 2026-08-28 with a 95% BigQuery cache hit rate, produced by a smoke script issuing the same calls repeatedly. They understate real latency, for two compounding reasons:
The cache is per-identity. Google's docs: "Temporary, cached results tables are maintained per-user, per-project." Cross-user caching needs Enterprise edition. The Cloud Run service account is a different identity from your local ADC, so it starts with an empty cache and builds its own.
Real traffic varies parameters. Cache keys include parameter values. Model-driven calls varying
min_ratings,limitandunitwill miss far more often than a smoke script replaying identical calls.
The cache does work across Cloud Run instances — every instance runs as the same service account, so scaling out does not fragment it. Entries expire after about 24 hours.
Re-measure from production telemetry before anyone quotes a latency number. A cache hit bills 0 bytes, so the existing tooling already reports the real rate:
gcloud logging read \
'resource.type=cloud_run_revision AND resource.labels.service_name=goodreads-mcp AND jsonPayload.tool!=""' \
--project <project> --limit 1000 --format json \
| goodreads-telemetry --path -See RULES.md §6 — this is the same discipline the dataset figures are held to.
Telemetry in Cloud Run
HTTP mode switches the sink from the local file to structured JSON on
stdout, which Cloud Logging parses into jsonPayload and levels by the
severity field (ok → INFO, guard_rejected → WARNING, errors → ERROR).
stdout must stay purely structured for that to work, which is why uvicorn's
access log is disabled — it writes plain-text INFO: ... 200 OK lines to
stdout that would land as unstructured entries. Nothing is lost: Cloud Run
logs every request itself, with more structure. A test fails if a non-JSON
line appears on stdout in HTTP mode.
Retention is the _Default bucket's 30 days. If SQL access over telemetry is
ever wanted, add a Logging sink to BigQuery — that writes as a
Google-managed identity and keeps the runtime SA read-only.
Health check
GET /health returns status, transport and the active
max_bytes_billed. Note the path is /health, not /healthz — Google's
frontend intercepts /healthz before it reaches Cloud Run, so that path 404s
and never appears in the request log. It deliberately does not touch BigQuery: a probe that
queried would bill on every check and would fail the service during a
BigQuery incident the container could otherwise ride out.
GOODREADS_MAX_BYTES_BILLED survives the transport change
Read from the environment in bq.py and applied per job as
QueryJobConfig(maximum_bytes_billed=...). Nothing in either transport path
touches it. It is set explicitly in the Dockerfile and the deploy script rather
than relying on the 20 GiB default, surfaced by /healthz, and pinned by a
test.
The web console (webchat/)
A browser interface for the same twelve tools, deployed as a second Cloud
Run service, goodreads-chat. It exists to make the server's central property
visible: figures arrive with their limits attached.
The console is four views on a left rail. Overview is the default: one
paragraph, the live row counts from dataset_overview as tiles, the starting
points as cards, and the chat thread with its composer at the foot. Tools
is the explorer: the tool list in the rail, the selected tool's form at the
top of the main area, results below. Defects is dataset_overview laid out
by defect -- the unrated editions, the edition overcount and the placeholder
day as hero tiles, then every caveat with the live figures that quantify it.
Telemetry summarises the local log through the goodreads-telemetry
command's own functions, and says so: it is labelled local-session, because a
deployed server writes to Cloud Logging, which the console cannot read.
Every result card carries a closed query disclosure with each statement
behind the figure and the values bound to its named parameters, carried in
query_meta.statements by merge_meta().
Two paths reach the tools -- a model from a question, or you from the form -- and the path is the only difference between them:
chat | tools | |
what picks the tool | a model, from a question | you, from a list |
what sets the parameters | the model | a form built from the tool's JSON Schema |
needs a model key | yes — | no |
prose around the card | the model's, numeral-checked | none |
the card, caveats, charts, refusals | identical | identical |
The tool mode is the console with the model taken out. Everything downstream of
the tool call is the same code — the same envelope rendering, the same caveats
attached to the same fields, the same n/unit/threshold block, the same charts,
the same refusal cards, the same guard probe under its bff badge.
Two providers behind chat mode
Chat mode runs on Anthropic or on Gemini, and Gemini is there for a specific
reason: Google AI Studio's free tier makes the mode work with no paid
credits, which is the difference between a console someone can try and one
that needs an account with a card on it. Whichever key is present selects the
provider; CHAT_PROVIDER decides only when both are, defaulting to
anthropic. Naming a provider you have no key for is a startup error, not a
fallback. The masthead names the provider and the model, because "chat on" is
not enough to know what you are reading.
Only four things are actually model-shaped — how tools are declared, what a
stream event looks like, how a reply is stored, how a result is handed back —
and webchat/provider.py is all four. agent.py holds one loop, and
everything downstream of a normalised Reply is shared by construction: the
same frame builders, the same caveat attachment, the same numeral checker, the
same cards. A test drives that loop with two stub providers whose native
transcripts are deliberately incompatible and asserts the card frames come out
equal, because two loops could drift into two renderings and the console's
whole claim is that a figure comes from the tool's own envelope regardless of
what fetched it.
Neither SDK is imported at module scope. select() imports inside the branch
it picks, so tool mode loads no model SDK at all and a Gemini deployment never
loads anthropic.
What the schema translation costs. The tool schemas are never rewritten by
hand — gemini_schema.py re-dialects the exact tools/list output, the same
objects that generate the tool-mode forms. FunctionDeclaration accepts one of
two dialects and the default is parameters_json_schema, standard JSON Schema,
which our schemas already are: a test asserts every one of the 13 reaches
Gemini byte-identical to what the server published. The parameters fallback
(GEMINI_SCHEMA_DIALECT=openapi) is a genuine OpenAPI 3.0 subset, and against
this surface it drops exactly one keyword:
keyword | where | in the subset? |
| throughout | yes |
|
| no |
That loss is logged at startup rather than swallowed, and it demotes the
constraint rather than removing it: the MCP server still holds the real bound
in pydantic, so a bucket_size the dialect failed to forbid comes back as a
visible refusal card carrying the server's own reasoning — which is the
behaviour wanted, not a silent drop. anyOf: [T, null], which is how every
optional parameter here serialises, is rewritten to nullable: true, the form
Gemini's function-calling path honours. OPENAPI_SUBSET is read off the
installed SDK's own types.Schema and pinned to it by a test, so an SDK bump
that moves the dialect fails in the suite rather than in a deployment.
Which Gemini model, and what overriding it picks up. The default is
gemini-3.7-flash, chosen on a measurement rather than on recency. Sampled
turns on gemini-3.5-flash called dataset_overview before the tool that
actually answered — spending a BigQuery query the caller pays for to learn
what the specific tool already returns in its own caveats:
model | turns where |
| 5 of 6 |
| 0 of 6 |
So GEMINI_MODEL=gemini-3.5-flash still works and is still selectable, but it
costs an extra query on most questions. That is the thing to know before
overriding.
The Gemini system prompt carries an extra prohibition against that call,
written when the behaviour was thought to be a provider trait. A balanced A/B
across gemini-3.6-flash and gemini-3.7-flash then returned 0/6 with the
prohibition and 0/6 without — neither newer model does this at all, so the
test could not measure the prohibition and the behaviour turned out to be
specific to one model. It is kept because it costs nothing measurable and
3.5-flash remains selectable, but its effect on 3.5-flash is unverified:
that model's free-tier daily quota was spent before it could be re-sampled.
Model ids are worth checking rather than assuming. gemini-3-flash does not
exist, and gemini-2.5-flash is listed by models.list() but returns 404 for
new keys.
A transcript belongs to the provider that wrote it. A Gemini Content has
no tool_use block and an Anthropic message no functionResponse part, so a
session that changes hands is emptied and told about, in the turn it happens,
before any of the answer. The per-session turn ceiling survives that reset:
refilling it would make switching provider a way around the spend guard.
A failed model call is named, never retried. On the free tier a spent quota
is an ordinary outcome, so the provider translates its own error shapes —
"the Google AI Studio free tier's request quota for gemini-3.5-flash is spent;
retry in about 12s" rather than ClientError. There is no retry loop
anywhere, and a test asserts it: retrying would hide the unreliability this
console exists to make visible.
.venv/bin/pip install -e '.[web]'
./run-local.sh # starts proxy.sh if needed, then the console; prints the URL
./deploy-chat.sh # Cloud Run; prints the ?k= URLrun-local.sh is the whole local path in one command. It reads the model key
from a gitignored .env if there is one — creating the file
with a comment when it is absent, and saying which modes the run will offer
rather than refusing to start — generates a
CHAT_ACCESS_TOKEN on first run and saves it back to .env so the ?k= URL
stays stable between runs, starts proxy.sh only if nothing is already serving
the MCP endpoint, waits for both to answer their health checks, and prints the
ready-to-click URL.
Ctrl+C shuts down what it started, and only that. proxy.sh execs gcloud,
which spawns cloud-run-proxy as a child, so the proxy is started with setsid
and the whole process group is signalled — killing the script's own pid would
leave the tunnel behind. A proxy that was already running when the script
started is deliberately left up, because something else is using it.
Neither script requires the key. deploy-chat.sh mounts the Anthropic secret
only when it exists, because --set-secrets naming an absent secret fails the
deploy and mounting an empty one would leave the console advertising a chat mode
that 500s on every turn.
Env overrides: PORT (default 8081), MCP_PORT (8080), ENV_FILE, LOG_DIR.
.env is parsed rather than sourced — it holds secrets, and . would execute
whatever is in it — so only KEY=value lines are read, and an already-exported
value wins over the file.
The no-model mode, and why its forms are generated
The form is built in the browser from each tool's inputSchema, served by
/api/tools — which is MCPBridge.catalogue(), the same cached tools/list
output the model is given, reshaped and nothing else. So the widget, its
label, its bounds and its default all come from the server, and a Field(...)
edited in server.py moves the form and the model's tool definition together.
Hand-writing the forms would make the UI a hand-maintained copy of the tool
surface, which is the documentation-instead-of-structure failure the whole
project avoids. test_no_tool_parameter_name_is_written_into_the_client fails
if any parameter name appears in tools.js outside its preset values.
Two behaviours look like omissions and are not:
An empty field is not sent. The server's default applies, and the card's parameter row then shows exactly what was overridden rather than a wall of values nobody chose. Each field prints its default beside it and carries it as the placeholder, so nothing is hidden.
Values are passed verbatim.
min_ratings=0andunit=chaptersreach the server unaltered, andmin/maxare printed on the field rather than set as HTML attributes that would clamp them. The refusal that comes back — with the server's own explanation and the caveats behind the constraint — is the most instructive thing this console can show, and validating in the browser would replace it with silence. The route's only checks are the ones that keep it from being a general-purpose proxy: a known tool name, a flat object, scalar values.
Both paths end in MCPBridge.call() and both build their frame with
frames._result_frame — the same function object, asserted by a test, because
two builders would drift. frames.py exists so the tool path can build a card
without importing agent.py, which constructs an Anthropic client.
config.verify() therefore requires only CHAT_ACCESS_TOKEN. Without a key the
service starts, the chat button is disabled with the reason in its tooltip, and
/api/chat answers 503 naming the mode that does work.
Why a backend-for-frontend, and why an MCP client
A browser cannot call the server directly: MCP is JSON-RPC and the Cloud Run
service is --no-allow-unauthenticated. The console is therefore a BFF holding
two credentials the client never sees — a Google identity token for Cloud Run
and an Anthropic API key.
It connects as an MCP client rather than mirroring the tool definitions.
Tool schemas come from tools/list and the model's steering text from the
server's own instructions, so a docstring edit in server.py reaches the UI
on the next deploy. Mirroring would make the UI a hand-maintained copy of the
tool surface, which is the documentation-instead-of-structure failure the whole
project is built to avoid.
The Anthropic MCP connector (mcp_servers=[{type:"url", ...}]) was rejected
for two independent reasons: it would require either making the MCP service
publicly invokable or handing a Google identity token to a third party, and it
delivers tool results into the model's context rather than to the BFF, which
would make structural rendering of the figures impossible.
Two services, because they need opposite IAM postures
The console must be reachable by browsers, which carry no Google identity; the MCP server must stay private. One Cloud Run service has one IAM policy, so one service cannot be both. Everything else follows from that split:
|
| |
ingress |
|
|
service account |
|
|
build account |
|
|
BigQuery |
| none |
secrets | none | the access token, and an Anthropic key if chat is wanted |
image | root |
|
deploy-chat.sh adds three IAM bindings. Two are the console's own:
roles/run.invoker on goodreads-mcp for its service account, and
roles/secretmanager.secretAccessor on the two secrets. The third is
roles/cloudbuild.builds.builder on a separate goodreads-chat-build
account — the only project-level binding, held by an identity that never runs
the service. It exists because the alternative is the default compute service
account: leaving gcloud builds submit to fall back to that either fails the
build outright, on a project where it has no roles, or puts source-bucket and
Artifact Registry access on an identity shared with everything else in the
project. The console's runtime account grants no
BigQuery role — the console reaches BigQuery only as a consequence of a guarded
tool call running under the MCP service's identity — and no
iam.serviceAccountTokenCreator, because minting an ID token for its own
identity from the metadata server requires no role. That last one is the usual
place this gets over-granted.
Auth flow
Browser → console. No Google identity. A shared secret (
CHAT_ACCESS_TOKEN, from Secret Manager) presented as?k=on first visit, then held in anHttpOnlycookie. The token is required: the service refuses to start without one, with no override flag, because a public endpoint that bills BigQuery on every tool call — and an Anthropic account on every chat turn — is not an acceptable default. It is the only required secret.Console → MCP server. An OIDC identity token minted from the metadata server for
audience = <the MCP service's base URL>, cached until five minutes before it expires, sent asAuthorization: Bearer. Google's edge validates the signature, the audience androles/run.invokerbefore the request reaches the container.Console → the model. Only in chat mode. The key from Secret Manager via
--set-secrets, never--set-env-vars, never in the image or the repo. Absent it, this leg does not exist and neither does the mode.
Locally the default is the proxy.sh path: GOODREADS_MCP_URL points at
127.0.0.1:8080 and the console sends no credential of its own, because
gcloud run services proxy injects one. Setting GOODREADS_MCP_TOKEN
(from gcloud auth print-identity-token) is the direct alternative; setting
both it and an audience is a startup error rather than a silent precedence rule.
Neither credential is ever placed in an SSE frame or a log line, and transport
error text is scrubbed of Authorization before it reaches a client.
Spend ceilings
The console is public-by-URL, so the abuse surface is the bill rather than IAM:
a required access token, ten chat turns per IP per five minutes, twenty-five
turns per session, six tool calls per turn, --max-instances 3, and the
server's existing 20 GiB maximum_bytes_billed per query.
Tool mode has its own window — forty calls per IP per five minutes
(CHAT_TOOL_RATE_LIMIT_CALLS). A form submission bills BigQuery bytes but no
Anthropic tokens, and one call per form is a far tighter loop than one call per
sentence, so sharing the chat window would have made the mode unusable long
before it made it expensive.
The rendering contract, and how it is enforced
The model never renders a figure. Every number on screen is drawn by
webchat/static/cards.js from the tool's own envelope, together with its n,
the unit one row counts, the min-ratings threshold and what it excluded, the
caveats, and the query cost. Nothing is computed client-side: if a share is not
in the envelope it is not shown, because a derived figure would be a figure
with no caveats attached to it.
webchat/numcheck.py checks that the model kept to it. Every numeral in the
prose is canonicalised and looked up in the set of numerals the server actually
put in front of the session — tool results including caveat prose, tool
parameters, the server's instructions, and the user's own question. Anything
else is marked in place in the answer. A rounded figure fails by construction:
4.4 does not match 4.42. The check reports rather than blocks; suppressing
the answer would hide the violation instead of showing it.
Caveats attach to fields, not to the card. webchat/attach.py maps each
caveat id to the figure fields it qualifies, so the duplication caveat puts a
marker on n_ratings and pooled_rating specifically, and the caveat text sits
in the same card, always expanded — never a footnote, never behind a disclosure
triangle. test_every_registered_caveat_has_a_field_mapping fails if a caveat
is added to the server without one.
check_column_available: a demonstration probe
QueryGuardError is unreachable from the twelve tools by construction — no tool
interpolates a caller-supplied column into SQL — so a user asking about
publish_day gets nothing from the guard, because there is no tool through
which to ask. That is the design working, not a gap (see CLAUDE.md).
So the console carries one tool of its own, check_column_available, which runs
the server's real bq.guard() against a candidate query it never executes and
reports the verdict, the rule id and the server's own caveat prose for that
column. It is labelled bff / demonstration probe in the UI, distinct from
the mcp badge every real tool carries, and it is deliberately not a thirteenth
MCP tool: adding a tool parameter that reached a banned column would convert a
structural impossibility into a runtime rejection.
Refusals come from two layers, and the console distinguishes them
Live testing turned up something the offline tests cannot see, because they call tool functions directly and so bypass the schema:
layer | fires for | reaches the caller as |
tool schema (FastMCP/pydantic) | anything with a | a validation error, before the tool body runs |
| the unconstrained parameters — | a structured result carrying the server's full explanation |
So min_ratings=0 — the most instructive refusal in the server — never reaches
require_min_ratings() over MCP, and the validation error says only "Input
should be greater than or equal to 1", not why the floor exists. The console
renders that as its own refusal kind (schema) and re-attaches the server's
reasoning from the caveat registry, so the reader still gets the 451,777
unrated books. It re-attaches; it does not write a second explanation.
Both layers are wanted. The body validator is what protects a direct Python caller and what carries the prose, so neither was removed or loosened to make the console simpler.
Design notes
Typefaces. Two, by job. Prose -- paragraphs, headings, labels, buttons --
is set in Noto Sans; data -- numerals, parameters, tool and column names, SQL,
metadata -- is set in JetBrains Mono, with tabular numerals, an unambiguous
0/O, and parameter columns that line up because the characters are the
same width. A reader tells what is being said from what is being measured by
the face alone. Both are subset to Latin plus the punctuation the UI uses and
served from this origin -- about 57 KB for four files, no external request,
and the CSP's font-src 'self' would block a CDN anyway.
JetBrains Mono, © 2020 The JetBrains Mono Project Authors, licensed under the SIL Open Font License 1.1; the licence ships at
webchat/static/fonts/OFL.txt, upstream https://github.com/JetBrains/JetBrainsMono. Noto Sans, © 2012 Google Inc., subset from the system package's Apache-2.0 build; that licence ships atwebchat/static/fonts/NOTO-LICENSE.txt.
Palette. One accent — cyan, #0094a8 light and #00a6bc dark — plus one
reserved status ink for refusals and placeholder-inflated rows. The cyan was
chosen by measurement, not taste: it sits roughly opposite the reserved amber
on the hue wheel, so the two meanings this interface assigns to colour cannot
be confused. Their CVD separation is ΔE 18.9 (deutan) light and 19.0 (protan)
dark, well clear of the ΔE 8 floor. The accent needs three tokens because a
data mark, a text colour and a filled control answer to three different rules:
the mark must sit inside the categorical lightness band at 3:1, the text needs
4.5:1 and therefore cannot sit in that band, and the filled control needs 4.5:1
under white. The dark status amber was re-stepped rather than merely lightened,
because the light one falls outside the dark lightness band and that ink is
drawn as a bar fill on flagged rows.
Motion. One orchestrated moment: at load the shell settles in reading order
— masthead, heading, paragraph, list, composer — with staggered delays and a
backwards fill so nothing flashes before its turn. Deliberately scoped to the
furniture that exists at load; tool cards are never animated, because a card
arrives while its neighbour is being read and a figure that slides in under the
cursor is an interface fighting its reader. prefers-reduced-motion collapses
all of it, and a test asserts the reveal cannot reach a card, a figure or a
table.
Charts are hand-rolled inline SVG: no library, so every bar carries its own
exact value and nothing is read off an axis. Two measures never share an axis — stats_by_year draws volume and rating
as two stacked charts rather than one dual-axis chart. The masthead carries no
dataset figures at all; every number about the data appears inside a tool card,
from that tool's JSON.
Every size in app.css comes from one of two scales — six type steps, an
8-step 4px space scale — and every card section shares one horizontal padding,
so the tool name, the parameters, the figure, the n block and the cost all
start on the same vertical line. Two optical values sit deliberately off the
grid (--pill-pad, --code-pad) because a 10.5px badge looks wrong on layout
spacing; they are named tokens rather than guesses at each use. Colour appears
exactly once in the sheet, in the token blocks. Four tests enforce all of this:
a stray font-size: 14px, a padding: 17px, a hex colour in a rule, a
box-shadow, a removed focus ring, or a token with no dark value each fail the
suite rather than merely looking wrong.
Contrast is measured, not judged. Every ink/surface pair meets 4.5:1 and
every control boundary, data mark and focus ring meets 3:1, in both themes.
That drove three token changes: --ink-3 was darkened (it carries most of the
11–12px metadata and sat at 3.4:1 on --surface-2), filled buttons got
--accent-fill because white on --accent was 4.3:1, and controls got a
dedicated --edge at 3:1 — the hairline --rule tokens stay decorative and
are correctly exempt.
Accessibility. Each chart is role="img" with a label built by
figureDesc() from the envelope: the measure, the mark count, the unit one row
counts, the n, and what the threshold excluded — so a screen-reader user gets
the same grounding the card shows everyone else. Tables carry scope="col" and
a hidden caption; the figure scrolls, so it is focusable; the mode toggle is a
real tablist with roving tabindex and arrow keys; state is never colour alone
(the status dot has text beside it, flagged rows are labelled, unsourced
numerals are underlined); and a polite live region narrates each call.
Not for indexing
The console is private, token-gated, and its URL carries the access key, so
every response sends X-Robots-Tag: noindex, nofollow, noarchive, nosnippet
and /robots.txt returns Disallow: / — the one route deliberately readable
without the key, since a crawler that cannot fetch it never learns to stay
away. The page repeats the directive in a <meta name="robots"> that survives
being saved.
The key gets three separate protections. Referrer-Policy: no-referrer on
every response means it cannot reach a third party's logs; the client deletes
?k= from the address bar with history.replaceState once the cookie is set,
so it leaves the URL bar, the history entry and any screenshot; and no page
ever writes it into an href — the app shell creates no <a> elements at all,
and the locked page's placeholder is the literal string <key>. A test asserts
all three.
A CSP (default-src 'self', no unsafe-inline, img-src 'self' data: for the
inline SVG favicon) makes "zero external requests" enforceable rather than
merely true today: no fonts, no CDN, no analytics, and an added one fails in
the browser console instead of shipping quietly.
A missing key gets a styled 401 that explains what ?k= is and where the key
lives, and an unknown path gets a styled 404 naming what missed — both in the
console's own design, from webchat/pages.py. An unknown /api/ path returns
JSON instead, because that is what a fetch() there can read.
Conversation history is held server-side in memory, keyed by an HttpOnly
cookie, rather than posted back by the client each turn. That is a correctness
choice: a client that supplied the history could forge tool results into the
model's context, and fabricated figures in history is exactly the failure this
project exists to prevent. The cost is that an instance recycle loses the
transcript — the console says so rather than continuing against an empty one.
Tests
.venv/bin/python -m pytest tests/ -q # offline invariant tests, no network
PYTHONPATH=. .venv/bin/python tests/smoke_live.py # 18 live calls + probe, needs ADCtests/test_guards.py covers the dataset's rules; tests/test_webchat.py
covers the console's claims — that every caveat can be attached to the figure it
qualifies, that no numeral in the model's prose escapes the checker, that no
credential can reach a client, and that the no-model mode is the same path and
not a parallel one: the same frame builder, forms with no hard-coded parameter
in them, and values that reach the server exactly as typed.
Licence
MIT — see LICENSE.
Two bundled assets are licensed separately and are not covered by the MIT
grant. webchat/static/fonts/jetbrains-mono-*.woff2 is JetBrains Mono,
© 2020 The JetBrains Mono Project Authors, under the SIL Open Font License 1.1;
its licence travels with it at webchat/static/fonts/OFL.txt. The OFL permits
bundling and redistribution as done here; it does not permit selling the font
files on their own. webchat/static/fonts/noto-sans-*.woff2 is Noto Sans,
© 2012 Google Inc., subset from the Apache-2.0-licensed build shipped in the
system noto-fonts package; that licence travels with it at
webchat/static/fonts/NOTO-LICENSE.txt.
Available Tools
12 toolscompare_user_vs_book_ratingsCompare User Vs Book RatingsA
Where the 4,154-user panel disagrees with the wider Goodreads rating.
This is the only tool that crosses the two tables, and the join is on normalised title text because user_ratings carries no book ID. It reaches 52,016 of 98,686 rated titles (52.7%) -- roughly half the panel's ratings have no book row to match and are simply absent. Editions of the same title are pooled, so book rating counts are summed across up to 36 rows.
Inputs: limit, order_by, min_book_ratings, min_user_ratings. Returns: object
dataset_overviewDataset OverviewA
Shape, coverage and known defects of the Goodreads dataset.
Call this before answering anything substantive. It reports live row and population counts for every column that has a coverage problem, and returns the full caveat list, including three defects measured from the loaded tables that the project's own DATA_NOTES.md does not mention.
Inputs: no named parameters. Returns: object
page_count_statsPage Count StatsA
Book length against rating: page-count quartiles overall, and rating statistics for each band of book length.
Answers "do longer books rate higher?". Books with a NULL pages_number are excluded and counted separately -- 11,216 implausible values were nulled during cleaning.
Inputs: year_to, language, year_from, min_ratings. Returns: object
publish_month_seasonalityPublish Month SeasonalityA
Coarse publishing seasonality by month, plus per-month rating averages.
January is inflated -- it holds 17.72% of rows against a uniform 8.3% because unknown dates were recorded as January 1. The January row is flagged in the output. Prefer stats_by_year for real time-series work.
Inputs: year_to, language, year_from, min_ratings. Returns: object
rating_distributionRating DistributionA
How book ratings are distributed: a histogram of per-book mean ratings, plus the pooled share of 1-5 star ratings across every rating in scope.
Answers "are ratings clustered high?", "what does a typical rating look like?", "how unusual is a 4.5?".
Inputs: year_to, language, year_from, bucket_size, +1 more. Returns: object
stats_by_authorStats By AuthorA
Rating statistics grouped by author string.
authors is one free-text field per book, not a list, so a co-authored
book forms its own group rather than counting toward each author. There
are 675,289 distinct author strings.
Inputs: unit, limit, year_to, language, +5 more. Returns: object
stats_by_languageStats By LanguageA
Rating statistics grouped by language.
Grouped on language_normalised, never the raw language column. Read the
coverage caveat before quoting anything from this: only 13.6% of books
carry a language label, and 83% of those are English.
Inputs: unit, limit, year_to, order_by, +4 more. Returns: object
stats_by_publisherStats By PublisherA
Rating statistics grouped by publisher string.
publisher is unnormalised free text with 79,423 distinct values, so each
row is one spelling rather than one publisher. Penguin alone occupies six
or more separate rows. Treat every figure here as a lower bound on that
imprint's real output.
Inputs: unit, limit, year_to, order_by, +4 more. Returns: object
stats_by_yearStats By YearA
Rating statistics and publication volume per publication year.
publish_year is the only reliable temporal field in this dataset -- use this rather than publish_month for any real time series. Always ordered chronologically.
Inputs: unit, limit, year_to, language, +3 more. Returns: object
top_books_by_ratingTop Books By RatingA
Highest- or lowest-rated books, subject to a minimum-ratings threshold.
The threshold is the whole point: raise it for a result about well-known books, lower it to reach the long tail. Ties break toward the more heavily rated book.
Under the default unit="editions" a work with several editions can take several places in the list -- all with the same rating, since editions of one work largely share a rating pool. Pass unit="works" for a list of distinct works.
Inputs: unit, limit, year_to, language, +3 more. Returns: object
top_titles_by_user_ratingsTop Titles By User RatingsA
Best- or worst-liked titles among the 4,154 users in user_ratings.
Stays entirely inside user_ratings -- no join, so no title-matching loss. These are the opinions of a small user panel, not the books table.
Inputs: limit, order_by, direction, min_ratings. Returns: object
user_ratings_overviewUser Ratings OverviewA
Shape of the user_ratings table: how the 1-5 stars are distributed, and how active the users are.
This describes 4,154 users only. It is a separate dataset from books,
not a sample of it, and does not generalise to Goodreads.
Inputs: no named parameters. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 12 tools
Each tool targets a distinct analytical question, and the detailed descriptions make the boundaries fairly clear. However, top_books_by_rating and top_titles_by_user_ratings, plus dataset_overview and user_ratings_overview, are similar enough at name level that an agent could initially pick the wrong one.
All tool names use snake_case and the stats_by_* family is a recognizable pattern for grouped summaries. But descriptive names like dataset_overview, user_ratings_overview, and publish_month_seasonality break the otherwise consistent pattern.
Twelve tools is a well-scoped size for a dataset-analysis server. Each tool covers a meaningful slice of the data, and none feel redundant or purely decorative.
The server covers the dataset's core analytical needs: overview, rating distributions, top lists, grouping by language/year/publisher/author, page-count effects, seasonality, and cross-table comparison. There are no obvious dead ends or missing operations for the declared purpose.
Maintenance
Related MCP Connectors
Valid and reliable data engineering and statistical analysis without hallucinations.
Design a data model, metrics and dashboards; generate realistic data and check known answers.
Hosted BigQuery MCP server for Claude, ChatGPT and Cursor, with each action switched per account.
Query your data in plain English or SQL, build datasets, and fit Bayesian models.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceA read-only BigQuery MCP server with auto-LIMIT injection, dry-run cost guard, and ADC authentication. Allows safe SQL querying of BigQuery by LLMs without risk of data modification or unexpected costs.1MIT
- AlicenseNot gradedqualityCmaintenanceEnables LLMs to explore BigQuery datasets and tables, run safe read-only queries, and optionally perform vector search using BigQuery embeddings.109 PyPI10MIT
- FlicenseNot gradedqualityDmaintenanceEnables read-only interaction with Google BigQuery, including SQL queries, dataset/table listing, schema retrieval, table preview, and metadata access via service account authentication.-
- FlicenseNot gradedqualityDmaintenanceEnables safe, read-only interaction with Google BigQuery through Claude, with layered guardrails preventing dangerous or expensive queries. Exposes tools for listing datasets, tables, estimating costs, and running queries.-