mcp-coda
AGENTS.md2 starsChanged 8 months agoMITView on GitHub
MCP server for the Coda v1 API. It provides 54 tools, 12 resources (2 live data + 5 rules + 5 guides), and 5 prompts. They cover docs, pages, tables, rows, formulas, controls, permissions, folders, publishing, automations, and analytics. mcp-coda runs on FastMCP. PyPI hosts it as the mcp-coda package. Install it with uvx mcp-coda.
- Reads credentials
We split this AGENTS.md into the rules it gives an agent
12 rules. About 0 are common, 4 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 34
Write tools are declared @tool_result(write=True), which runs _check_write(ctx) before the body
- Very specific: line 51
Every tool MUST pass annotations={} with at least readOnlyHint and openWorldHint.
- Distinctive: line 71
Every optional parameter has a default
- Distinctive: line 77
Never expose stack traces, tokens, or internal paths
- Very specific: line 85
Add it to the matching server module, or create a new module under servers/ — every non-underscore module there is imported automatically
- Very specific: line 86
Import helpers from ._helpers — never re-implement _get_client, _ok, _err, _check_write
- Very specific: line 88
Add unit tests in tests/unit/test_tools_<module>.py
- Distinctive: line 89
Update the docs listed under "Documentation freshness" in the same commit
- Very specific: line 183
Never edit the pyproject.toml version directly — the workflow owns it
- Distinctive: line 184
Never create tags manually
- Very specific: line 185
Commit messages must follow conventional commits (feat:, fix:, docs:, …) so the changelog generates
- Very specific: line 200
Check: uv run pytest tests/test_doc_parity.py re-derives every count, tool name and version string from the source and fails on drift.
≈ “Common” is an estimate: at least 1 in 5 of the 36,368 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
mcp-coda — Agent Context
MCP server for the Coda v1 API. It provides 54 tools, 12 resources (2 live data + 5 rules + 5 guides), and 5 prompts. They cover docs, pages, tables, rows, formulas, controls, permissions, folders, publishing, automations, and analytics.
mcp-coda runs on FastMCP. PyPI hosts it as the mcp-coda package. Install it with uvx mcp-coda.
Protocol support
- Implements the MCP 2026-07-28 specification (MCP 2.0). Stays compatible with 2025-11-25 clients.
- Built on FastMCP 4.x and the MCP Python SDK 2.x.
- Transports:
stdio(default) andstreamable-http(recommended for remote).ssestill works, but the 2026-07-28 spec deprecates it, so the server prints a warning. - Uses tools, resources, and prompts only. No Roots, Sampling, Logging, elicitation, or resource subscriptions, so the 2026-07-28 deprecations do not affect it.
Layout
src/mcp_coda/__init__.py— click CLI, loads.envvia python-dotenv, runs the FastMCP serversrc/mcp_coda/servers/__init__.py— theFastMCPinstance,lifespan(builds the sharedCodaClient), and_register_tools()which imports every tool module so decorators executesrc/mcp_coda/servers/*.py— one module per domain (docs, pages, tables, rows, …)src/mcp_coda/servers/_helpers.py— shared_get_client,_ok,_ok_markdown,_err,_check_write,_format_list_as_markdownsrc/mcp_coda/servers/resources.py/prompts.py— MCP resources and promptssrc/mcp_coda/resources/— static markdown backing the rules/guides resourcessrc/mcp_coda/client.py— async httpx client wrapping every Coda API call. Transport (R2): a per-phasehttpx.Timeoutwith the connect phase capped at 10s andAsyncHTTPTransport(retries=2)for connection errors; a single_sendpath adds GET-only status retries (502/503/504 with jitter, 429 whenRetry-After≤ 5s, max 2 attempts, writes never retried) and splits a timeout (CodaTimeoutError) from an unreachable host (CodaConnectionError; aConnectTimeoutcounts as unreachable). Worst-case latency against a hard-down host is roughly2 × (3 × connect-cap + timeout) + jitter— the connect cap is 10s, the two outer attempts are the GET status-retry and each wraps httpx's three connect tries (retries=2)src/mcp_coda/config.py—CodaConfigdataclass built from env vars;CODA_TIMEOUTis validated with a named errorsrc/mcp_coda/exceptions.py—CodaApiError,CodaNotFoundError,CodaRateLimitError,CodaTimeoutError,CodaConnectionError, …tests/unit/— ~307 tool- and unit-level tests;tests/conftest.pyholds shared fixturestests/test_doc_parity.py— re-derives tool/resource/prompt counts, tool names and version strings from the source and asserts the docs match;tests/test_links.pychecks external URLsevaluations/eval.xml— question / expected-answer / expected-tool pairs used to sanity-check tool selection
Key patterns
- Every tool sits under
@tool_result(from_helpers.py), below@mcp.tool. For an expectedCodaError(401/403/404/429, other API statuses, read-only mode) it returns aToolResult(is_error=True)whose text is the_err(e)JSON and nostructuredContent. Any other exception is logged with its traceback and re-raised asToolError, so the MCP result is alsoisError: truewithTypeName: messagetext - Errors: expected failures are
isError: trueresults whose error body ({"error", "hint", "status_code"?, "body"?, "retry_after"?}) is the text content; bugs areisError: truewithTypeName: messagetext, logged with a traceback. No error result carriesstructuredContent— the TS MCP SDK validates it against the output schema even whenisErroris true, so an error payload there would make strict clients reject the result - Every tool returns a fastmcp
ToolResult. Success:_ok/_pageput the Coda JSON object instructuredContentbeside the pretty text; the two markdown branches use_text(text only, nostructuredContent). Expected failures come from_error_result(_err(e)). Tools advertise nooutputSchema(payloads pass upstream fields through untyped); a typed schema is opt-in per tool via@mcp.tool(output_schema=…) - Write tools are declared
@tool_result(write=True), which runs_check_write(ctx)before the body - List responses go through
_page(data)and include{items, has_more, next_cursor, total_count}; most paginated endpoints default tolimit=50(coda_list_docs25,coda_list_columns100) - Rate-limit errors carry
retry_afterseconds; the client retries a 429 once when itsRetry-Afteris ≤ 5s (jittered GET-only retries also cover 502/503/504), and otherwise surfacesretry_afterwithout retrying. Writes are never retried - Row writes are async server-side: they return a
requestId, poll it withcoda_get_mutation_status
Access control (three layers)
- Server-level:
CODA_READ_ONLY=true(or the sharedMCP_READ_ONLY=true) locks the server read-only — write tools and write-workflow prompts are hidden fromtools/list/prompts/list, calling a hidden tool is anUnknown toolerror, and_check_writestill refuses on any path that reaches it. The lock is computed once at startup and cannot be lifted at runtime;--read-onlycan only raise it. It is a deployment control, not a per-user one: a Coda token has no scopes and there is no per-request auth, so it only holds when the deployer owns the launch (managed MCP configenv, container/k8s env, deployer-run HTTP) - MCP annotations:
readOnlyHint/destructiveHint/idempotentHintdrive client-side permission prompts - Coda token: Coda's sharing settings enforce doc-level access. Tokens have no scopes and reach everything their owner can reach
HTTP transports add a network layer: Host/Origin protection is not authentication, so binding to a non-loopback host requires CODA_HTTP_BEARER_TOKEN (min 16 chars, constant-time compared via a TokenVerifier subclass). The CLI refuses to start on a non-loopback host without it.
MCP compliance rules
Tool annotations (mandatory)
Every tool MUST pass annotations={} with at least readOnlyHint and openWorldHint.
- Read:
{"openWorldHint": True, "readOnlyHint": True, "idempotentHint": True} - Non-destructive write:
{"openWorldHint": True, "readOnlyHint": False} - Destructive write:
{"openWorldHint": True, "destructiveHint": True, "readOnlyHint": False} - Idempotent write (PUT/update): add
"idempotentHint": True
Also pass tags={"coda", "<domain>", "read"|"write"}.
Tool descriptions
1–2 sentences, front-loading what it does and what it returns.
- Bad: "This tool gets rows."
- Good: "List rows in a Coda table with optional filtering. Returns row data with values, timestamps, and pagination cursor."
Parameters
Annotated[type, Field(description="...")]on every parameterLiteral[...]for known value sets instead of barestr- Every optional parameter has a default
- Flat — no nested dicts unless genuinely required (e.g.
cells)
Errors
- Actionable text: what went wrong plus a suggested fix
- Never expose stack traces, tokens, or internal paths
Naming
coda_{verb}_{resource}, snake_case. Verbs in use: create, get, list, update, delete, push, trigger, export, publish, unpublish, resolve.
Adding a tool
- Add it to the matching server module, or create a new module under
servers/— every non-underscore module there is imported automatically - Import helpers from
._helpers— never re-implement_get_client,_ok,_err,_check_write - Include
annotations={}andtags={}on the@mcp.tool()decorator - Add unit tests in
tests/unit/test_tools_<module>.py - Update the docs listed under "Documentation freshness" in the same commit
Development
uv sync --all-extras # install deps incl. dev group
uv run pytest --cov # tests (asyncio_mode = auto; respx mocks httpx)
uv run ruff check .
uv run ruff format --check .
uv run pyright # type check (src only, basic mode)CI: tests.yml runs the suite on Python 3.10–3.13 and uploads coverage from 3.13; lint.yml runs the two ruff commands and pyright. Ruff config lives in ruff.toml (line-length 100, rule set E,F,B,W,I,N,UP,S,C4,EM,ISC; tests/** ignores S101,S105,S106,S104).
Running locally:
uvx mcp-coda # stdio (default)
uvx mcp-coda --transport sse --host 127.0.0.1 --port 8000
uvx mcp-coda --transport streamable-http --port 9000
uvx mcp-coda --read-only # CODA_API_TOKEN from env (--coda-token is deprecated, hidden, warns on stderr)Tool inventory (54)
| Module | Tools | Type | Covers |
| account | 3 | read | coda_whoami, coda_resolve_browser_link, coda_get_mutation_status |
| docs | 5 | read/write | list, get, create, update, delete docs |
| pages | 9 | read/write | list, get, create, update, delete, export pages; get/delete page content; export status |
| tables | 4 | read | list/get tables, list/get columns |
| rows | 7 | read/write | list, get, insert (upsert via key_columns), update, delete row/rows, push button |
| permissions | 6 | read/write | sharing metadata, list/add/delete permissions, search principals, ACL settings |
| folders | 5 | read/write | list, get, create, update, delete folders |
| misc | 8 | read/write | list/get formulas, list/get controls, list categories, publish/unpublish, trigger automation |
| analytics | 7 | read | doc/page/pack analytics + summaries, last-updated day |
Common workflows
- Table data:
coda_list_docs→coda_list_tables→coda_list_columns→coda_list_rows→coda_insert_rows/coda_update_row - Page editing:
coda_resolve_browser_link→coda_get_page→coda_update_page - Doc setup:
coda_create_doc→coda_create_page→coda_list_tables→coda_insert_rows - Access control:
coda_list_permissions→coda_add_permission→coda_publish_doc - Automation:
coda_list_docs→coda_trigger_automation→coda_get_mutation_status
Environment variables
| Variable | Required | Default | Description |
CODA_API_TOKEN | Yes | — | Coda API token |
CODA_READ_ONLY | No | false | Lock the server read-only: write tools and write-workflow prompts are not listed and are refused before any API call. Any value other than false/0/no/off/empty locks |
MCP_READ_ONLY | No | false | Shared alias for CODA_READ_ONLY across the mcp-* servers; either locking wins, any value other than false/0/no/off/empty locks, nothing lifts it at runtime, and --read-only can only raise the lock |
CODA_BASE_URL | No | https://coda.io/apis/v1 | API base URL (trailing slash stripped) |
CODA_TIMEOUT | No | 30 | HTTP timeout, seconds |
CODA_HTTP_BEARER_TOKEN | No | — | Bearer token (min 16 chars) required to bind an HTTP transport to a non-loopback host; clients send Authorization: Bearer |
The token is read from CODA_API_TOKEN, then CODA_TOKEN, then CODA_PAT — first non-empty wins. Tokens are generated at https://coda.io/account#apiSettings under "API settings". CodaConfig.validate() rejects non-ASCII tokens (httpx header encoding).
Coda API gotchas
- Browser URLs are not API IDs — run
coda_resolve_browser_linkfirst to get doc/page/table/row IDs - The API cannot insert or execute pack formulas (DrawFlowchart, Mermaid, etc.)
- Rate limits are per-token and vary by plan; a 429 returns
retry_afterseconds. The client retries a 429 once whenRetry-After≤ 5s (and 502/503/504 with jitter), GET-only; otherwise it surfacesretry_afterwithout retrying - Analytics endpoints generally require doc ownership, not only Viewer access
- Minimum doc roles: Viewer for reads; Editor for page/row writes, folder management, and triggering automations; Doc Owner for permissions and publishing
Release workflow
Releases run through GitHub Actions. Never bump versions or create tags by hand.
The six workflows (tests, lint, link-check, release, publish, reconcile) are thin stubs
that call reusable workflow_call workflows in vish288/.github,
pinned by commit SHA with a # vX.Y.Z comment so Dependabot bumps them. The shared scripts/ci/*.sh
and scripts/derive_artifacts.py live there too; this repo no longer carries its own copies. The stubs
are Copier-owned (.copier-answers.yml): change CI in vish288/.github and run copier update here,
not by editing these files directly. A CI fix is one PR in vish288/.github plus a tag bump, not a
change in each server.
gh workflow run release.yml -f bump=minor # 0.3.0 → 0.4.0
gh workflow run release.yml -f bump=patch # 0.4.0 → 0.4.1
gh workflow run release.yml -f bump=major # 0.4.0 → 1.0.0
gh workflow run release.yml -f bump=minor -f dry_run=true # preview changelog, no pushrelease.yml(workflow_dispatch) runstests.ymlandlint.ymlon the released commit first (needs: [test, lint]), bumps thepyproject.tomlversion, regeneratesuv.lock, generatesCHANGELOG.md, and creates the release commit + tag via the GitHub API. The commit is created withGITHUB_TOKENand no author/committer, so GitHub signs it asgithub-actions[bot](verificationverified: true); only themainand tag ref updates useRELEASE_PAT(needed to bypass the branch ruleset);release.ymldispatchespublish.yml, and the PAT's tag push no longer triggers publish (publish.yml is dispatch-only)publish.ymlruns as split, idempotent jobs: the reusablepublish-prepare.yml(status → build), then a local top-levelpypijob withenvironment: pypi(PyPI trusted publishing checks the OIDCjob_workflow_ref, so thepypa/gh-action-pypi-publishjob must stay in this repo'spublish.yml), then the reusablepublish-finish.yml(confirm-pypi → github-release → registry). Each target is checked first and written only when not already done, so PyPI, the GitHub release and the MCP Registry each complete independently and recoverably- Recovery of a failed publish is
gh run rerun <id> --failedorgh workflow run publish.yml --ref refs/tags/vX.Y.Z; both are no-ops for targets that are already done reconcile.ymldoes this unattended — about 5 min after a failed publish run, then every 6 h on a schedule — scanning the last three tags and dispatchingpublish.ymlas needed, capped at 8 failed attempts per tag before it asks for a human. A tag pushed by hand outside release.yml publishes only via a manualgh workflow run publish.yml --ref refs/tags/vX.Y.Zor the reconcile cron (there is no tag-push trigger).
PR commits land signed via a local signed rebase + fast-forward push of the branch to
main, never GitHub's rebase-merge, squash, or merge-commit buttons; thechore(release)commit is GitHub-signed (github-actions[bot], Verified).mainrequires signed commits and linear history.
Rules:
- Never edit the
pyproject.tomlversion directly — the workflow owns it - Never create tags manually
- Commit messages must follow conventional commits (
feat:,fix:,docs:, …) so the changelog generates github-actions[bot]authors the release commit with the messagechore(release): X.Y.Z
Documentation freshness (mandatory)
Any changeset that adds, removes, or modifies tools, resources, or prompts MUST update all of these in the same commit:
README.md— tool count in heading and intro, tool table, full tool reference, usage examples, permissions tablellms.txt— tool count in tagline and documentation linkllms-full.txt— tool count in tagline, documentation link, full tool reference sectionAGENTS.md— tool count in intro, tool inventory tableserver.json—descriptionfield (≤100 chars)gemini-extension.json—descriptionfieldsrc/mcp_coda/resources/**.md— the rules/guides shipped to clients; they must only name tools that exist
Check: uv run pytest tests/test_doc_parity.py re-derives every count, tool name and version string from the source and fails on drift. To count the total: grep -c '^async def coda_' src/mcp_coda/servers/*.py. Also confirm the category list is complete and new tools appear in the right sections with parameters and annotations.
llms.txt and gemini-extension.json are derived from llms-full.txt / server.json by derive_artifacts.py, which now lives in vish288/.github and is run by the release.yml pipeline (with coda's \n## Setup Examples split marker). Do not hand-edit them; to preview locally, run the script from a vish288/.github checkout against this repo root: LLMS_SPLIT_MARKER=$'\n## Setup Examples' python /path/to/.github/scripts/derive_artifacts.py.
Known limitations
- Passthrough objects stay untyped. Tools return the raw Coda API JSON through
_ok(wrapped in aToolResultasstructuredContentplus pretty text), so field names keep the API's camelCase and unknown fields survive; tools advertise nooutputSchema._okshrinks oversized list envelopes by dropping items (never slicing the JSON) and caps single non-list payloads at 25k characters via_text(text only), and each list tool builds the{items, has_more, next_cursor, total_count}envelope inline. A typedoutput_schema=is opt-in per tool; blanket typed response models would rename every field and silently drop extras, so that is a breaking change, not a refactor.
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 28,693 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository2 other files it gives its agents.
- llms-full.txtllms.txt
- llms.txtllms.txt
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

