togomcp
CLAUDE.md21 starsChanged 2 months agoMITView on GitHub
FastMCP server exposing the RDF Portal (SPARQL — most databases; seven run their own endpoints, see endpoints.csv) plus selected REST APIs (NCBI E-utilities, UniProt, ChEMBL, PDB, PubChem, Reactome, Rhea, MeSH, TogoID, TogoVar) to AI assistants. The server is assembled in main.py: a root FastMCP instance (mcp, defined in server.py) mounts four sub-servers as prefixes (plus kegg, stdio-only and opt-in): A REST wrapper becomes a mounted sub-server (not a flat api_tools.py search_* tool) when the external API is a cohesive multi-endpoint…
- Reads credentials
- Sends data out
- Commits and pushes
In this repository4 other files it gives its agents.
- benchmark-runnerSkill
- intro-page-updaterSkill
- mie-generatorSkill
- qa-generatorSkill
The file
Files
TogoMCP
FastMCP server exposing the RDF Portal (SPARQL — most databases; seven run their own endpoints, see endpoints.csv) plus selected REST APIs (NCBI E-utilities, UniProt, ChEMBL, PDB, PubChem, Reactome, Rhea, MeSH, TogoID, TogoVar) to AI assistants.
Architecture
The server is assembled in main.py: a root FastMCP instance (mcp, defined in server.py) mounts four sub-servers as prefixes (plus kegg, stdio-only and opt-in):
togoid_mcpfrom togoid.py — mounted astogoidncbi_mcpfrom ncbi_tools.py — mounted asncbitogovar_mcpfrom togovar.py — mounted astogovarpubcasefinder_mcpfrom pubcasefinder.py — mounted aspubcasefinder
A REST wrapper becomes a mounted sub-server (not a flat api_tools.py search_* tool) when the external API is a cohesive multi-endpoint surface with no SPARQL counterpart in RDF Portal — the flat wrappers are keyword-search front doors to a SPARQL database (their error hint points back to run_sparql), so a DB without a SPARQL endpoint doesn't belong there. TogoVar (human genome variation: gnomAD/ToMMo/JGA/BBJ frequencies, ClinVar+MGeND significance) fits the sub-server shape, like TogoID and NCBI. PubCaseFinder is the other valid route in: it DOES have an RDF counterpart (database="pubcasefinder"), but the two tools wrap what the RDF cannot give — a computed phenotype-similarity ranking and a text-mined case-report index — so they are not keyword front doors either. Wrap only what SPARQL cannot reproduce; the rest of its REST API is SPARQList over the same RDF.
Tools registered directly on the root mcp live in rdf_portal.py (SPARQL, MIE files, endpoint resolution) and api_tools.py (REST search wrappers).
Entry points (from pyproject.toml):
togo-mcp-server— HTTP transport on0.0.0.0:8000togo-mcp-local— stdio transport (Claude Desktop)
Data layout
Bundled under togo_mcp/data/ and shipped in the wheel via package-data:
mie/*.yaml— one MIE (Metadata-Interoperability-Exchange) file per supported RDF databaseresources/endpoints.csv— SPARQL endpoint registry consumed byload_sparql_endpointsresources/usage_guide_v7/— the Usage Guide, tiered. Top-level*.mdparts are assembled (sorted, plus the generated Workflows list) into the core thatTogoMCP_Usage_Guidereturns;references/*.mdare the on-demand sections, served byget_workflow(name="usage-guide", path="references/<file>");local_only/holds parts gated on tools that are not on every transport. The core must stay small enough to arrive inline (Claude Code saves a result past 50,000 characters or 25,000 tokens to a file — the 57 KB v6 guide reached no Sonnet 5.5 session whole);tests/test_usage_guide_size.pyenforces 40,000. The core must also stand alone: a client with a tool list older thanget_workflowcannot fetch a reference file.02b_database_catalog.md(compact) andreferences/database-catalog.md(full) are GENERATED byscripts/generate_usage_guide_catalog.py— never hand-edit them. The directory name carries the guide version.docs/— developer docs (MIE spec, examples)skills/public/<name>/— Agent Skills served byget_workflowand asskill://resources (registry: skills.py; loaded at startup, a malformed skill fails the boot)..claude/skills/<name>for these are symlinks into here. Developer skills (mie-generator, qa-generator, intro-page-updater) stay real directories in.claude/skills/and must never be put underpublic/.
Parameter conventions
These conventions were deliberately normalized across tools; preserve them when adding new tools:
databaseis the canonical parameter name for RDF-database selection.dbnameis deprecated — do not add it to new tools. Onlyget_MIE_fileinrdf_portal.pystill acceptsdbname/dbas legacy aliases (itsdatabasehas a default, so alias-only calls resolve).run_sparql/get_graph_listdropped these aliases:databaseis schema-requiredthere, so alias-only calls were already rejected by validation and the fallback was dead code.databasevsendpoint_name:databasetakes a single RDF database key (e.g.uniprot,chembl);endpoint_nametakes an endpoint group (e.g.ebi,sib). Combining a validdatabasewith a validendpoint_nameis the supported cross-DB/federated pattern (see the next bullet) — what fails is an invaliddatabase/endpoint_namevalue, which returns a deterministic error telling the caller not to retry. Do not add fallback behavior for the invalid case.databaseis required onrun_sparql/get_graph_list. Its params are keyword-only (*,) so the schemarequiredarray carriesdatabase— many LLMs omit fields that aren't marked required and only fail at runtime, so this is deliberate; prefer a hardrequiredover aoneOf/anyOfsoft constraint. Endpoint-selection is still overridable: pass a memberdatabaseplusendpoint_name/endpoint_urlfor cross-DB/federated queries (priorityendpoint_url > endpoint_name > database). A consequence: endpoint-only calls are rejected by schema validation — always supplydatabase. (These two tools no longer definedbname/dbat all, so passing either now errors as an unexpected argument rather than being silently ignored.)idsacceptsstr | list[str]across NCBI and TogoID tools. Normalize with_normalize_ids(ncbi_tools.py) or_ids_to_csv(togoid.py).- Search-tool query aliases: the flat
search_*wrappers in api_tools.py acceptquery/search/term/keyword/keywords/search_term/name, resolved via_resolve_query_alias. This does not cover the mounted sub-server tools — the TogoVartogovar_search_*tools takequerydirectly (no alias set), and the NCBI tools accept the raw E-utilities names instead:db/term/retmax/retstartonesearch,db/idonesummary/efetch.
Return-shape and error conventions
Two intentional, different error conventions coexist — preserve each module's:
- REST-wrapper tools in api_tools.py (
search_uniprot/pdb/reactome/rhea_entity,search_mesh_descriptor, ChEMBL, PubChem): raiseValueErrorfor bad parameters (caught early, tells the caller not to retry) but degrade gracefully on upstream/HTTP failure by returning a payload carrying anerrorkey plus a "fall back to SPARQL" hint. Never raise on a transient HTTP error here. (Thelist_databases/find_databases/list_categoriesdiscovery trio that also lived here was retired at the v3 MIE release — the database catalog moved to a static, generated Usage-Guide section (02b_database_catalog.md); the fulldatabase=roster is on therun_sparql/get_MIE_fileschema.) - TogoID tools in togoid.py: raise on HTTP error via
raise_for_status_with_body(with aclient_error_hint). This is the module's consistent convention; FastMCP surfaces the message to the caller. Don't convert only some togoid tools to the return-JSON style — keep the module uniform. - PubCaseFinder tools in pubcasefinder.py: same raise convention as TogoVar. Two things specific to it: the ranking uses the web app's undocumented
/pcf_get_ranking_by_hpo_idbecause the documented/api/pcf_get_ranked_listreturns every candidate with full descriptions (~10 MB, ~11 s per call; it also 404'd during the 2026-09 outage) — read the module docstring before "fixing" the path; and DBCLS limits API use to 10/min, 100/h, 1,000/day, which on the HTTP deployment is ONE budget shared by every user — hence the process-wide sliding-window limiter and the caches. Unknown HPO/MONDO IDs come back upstream as a silent[], so the wrapper validates them. - TogoVar tools in togovar.py: same convention as TogoID — raise
ValueErroron bad params AND on HTTP error (viaraise_for_status_with_body); never return an{"error": ...}payload. No "fall back to SPARQL" hint (TogoVar has no RDF Portal endpoint). Keep the module uniform. The variant-query DSL is assembled by the pure helper_build_variant_query(unit-tested without HTTP); thesearch_varianttool exposes flat filters, not raw nested JSON. Note two live-API quirks the wrapper handles: the client pinsAccept: application/json(else the API 501s), and a frequencydatasetis an object{"name": ...}(a bare string 500s). Each list row'sidis the tgv ID (surfaced astgv_id), but it isnullfor most variants, and since the 2026-10 reload those are in SPARQL anyway — sovariant_iriis emitted for every row (it was gated ontgv_idwhile SPARQL was a 2.8× smaller subset).rs/clinvarcross-links come from each row'sexternal_linksand genes fromgenes; the same reload renamed both (fromexternal_link/symbols) and emptied those fields silently, so the wrapper reads either spelling.
List-style result tools return a JSON string of a bare array (not a Python list): empty and non-empty then share one wire shape. Returning a bare list makes FastMCP double-represent it (text array + wrapped {"result": ...}), so empty vs non-empty diverge for clients. dict returns (ChEMBL, search_reactome_entity, get_sparql_endpoints) and NCBI list[TextContent] returns are exempt — they aren't wrapped. search_reactome_entity uses the ChEMBL envelope {total_count, has_more, results} on success and {"error": ...} on failure (it validates+normalizes species/types case-insensitively against vendored vocabularies and raises on an unknown value — the server-side filter is case-sensitive and silently ignores a mis-cased value; limit is a true overall cap, not per-type; summation is opt-in).
Deployment
compose.yaml is NOT the production path. Production and test run on the DBCLS host (vs94) via
scripts/deploy.sh — rootless podman (podman run -p, network mode slirp4netns), test-before-prod,
promoting the exact tested image without rebuilding. Compose is for local/dev only. deploy.sh is
DBCLS-internal and deliberately absent from README.md.
This distinction is written down because assuming Compose was the deployment cost two releases in a row (2026-07-29):
forwarded_allow_ipswas defaulted to Docker's172.16.0.0/12, read offcompose.yaml. Under rootless podman + slirp4netns the rootlesskit port handler SNATs every inbound connection to the container's own address10.0.2.100, so the value never matched andX-Forwarded-Protokept being discarded.deploy.shforwards a fixed list of env vars (TOGOMCP_PERSERVICE_VARS/TOGOMCP_SHARED_VARS). A knob added tocompose.yamland.env.examplebut not to that list is inert in production. Add new env vars in all three places.
Both failures were silent, and both passed a green test suite, because the tests asserted the assumption (the Docker range) rather than the environment. Two checks read what a client actually receives and cut through this class of bug — prefer them over local assertions whenever a change has to survive the deploy boundary:
# version actually being served (no session needed; /health returns only "OK")
curl -sS -X POST https://togomcp.rdfportal.org/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'For tool metadata, capture Mcp-Session-Id from that response, POST notifications/initialized, then
tools/list with the session header — that is how the 2.1.0 readOnlyHint annotations were confirmed to
reach clients rather than merely to build.
Versioning
The version in pyproject.toml is read at runtime via importlib.metadata and reported in serverInfo.version (the deployment tell — a stale build shows the old number). Bump it on every dev → main release, and sync uv.lock in the same commit.
A dev → main PR that bumps the version is a release. All four steps below are required — do not do only the first:
- Bump
pyproject.toml+ syncuv.lockin the same commit. - Add a dated
CHANGELOG.mdsection (## [x.y.z] - YYYY-MM-DD) and move anything under[Unreleased]that ships in it; add the compare link at the foot of the file. CI enforces the heading (changelog.yml) but not its quality — write for someone diffing two versions, and say why a change matters, not just what moved. If the release has a user-facing highlight (new database/tool, a capability or format change a user would notice), add a<!-- whatsnew: YYYY-MM-DD | one sentence -->marker under the heading and runpython scripts/generate_whatsnew.pyto refresh the intro page's "What's New" (drift-guarded by whatsnew.yml +tests/test_whatsnew_in_sync.py). - Merge via PR to
main(every release since #23 has;gh pr create --base main --head dev). - Tag the MERGE commit —
git tag vX.Y.Z <merge-sha> && git push origin vX.Y.Z. Lightweight tags on the merge, not the bump: that is what every existing tag points at. Not every merge is a release — a docs-only merge (e.g. #157) carries no bump and gets no tag.
Steps 2 and 4 exist as rules because they were the ones nobody wrote down: the changelog fell 8 releases and 303 commits behind, and tagging stopped dead after v1.0.1 — while the bump rule, which was written here, was followed every time. Both were reconstructed on 2026-07-17; don't let them drift again.
The "public contract" of this server is the tool surface as a client sees it — tool names, parameters, and return shapes — NOT any importable Python API. Apply semver against that surface, using the agent-pragmatic policy (our dominant client is an LLM that re-reads the tool schema every session and adapts, so a return-shape change is less catastrophic than for a compiled client):
- MAJOR (
x.0.0) — a change an agent genuinely can't recover from: remove or rename a tool, remove a parameter, or make an optional parameter required. - MINOR (
1.x.0) — new capability, old calls still work: add a tool/database/optional parameter, add a field to a return object, widen accepted input (new alias, case-insensitivity). Return-shape changes ride here too (e.g. bare-list →{total_count, has_more, results}) — that's why the Reactome (1.1→1.2) and Rhea (1.2→1.3) overhauls were minor despite reshaping returns. Strict semver would call those MAJOR; we don't, deliberately, because agents adapt. (Caveat: a purely programmatic client hitting the endpoint can break on a reshape — weigh that if such consumers appear.) - PATCH (
1.3.x) — behavior fixed, contract unchanged: a bug fix that makes a tool return what it already promised (thechebi:CHEBI:500, the empty-query guard), docstring/description fixes, a silently-relaxed filter now honored, internal refactors/tests.
Clients cache the tool list; assume months of lag. ChatGPT connectors work from a frozen snapshot of the tool list (taken at Scan Tools / publish time) and never refetch; updating it is plan-specific and mostly an admin task (OpenAI help article, 2026-08) — the 2026-07-27→31 production log carried 28 calls to tools that no longer exist, all from openai-mcp, one cohort still using names retired three months earlier. Two consequences:
- A rename or removal is MAJOR because the client can't recover, not just the agent. Budget a long tail of calls on the old name, and prefer a deprecation alias over a clean break where the traffic matters. The alias mechanism is
_StaleToolNamesin server.py: add the old name to_RENAMED_TOOLS(served under the new name with a refresh-your-connector notice, never listed intools/list) or to_RETIRED_TOOLS(still an error, but one that says what replaced it). Only rename-with-compatible-arguments belongs in_RENAMED_TOOLS. - When you ADD a tool, repoint the canaries in the stale-tool-list row of
usage_guide_v7/04_reference.mdat it. That row is how a stale client finds out it is stale, and a canary only detects caches older than itself — an out-of-date canary silently under-detects.tests/test_usage_guide_canaries.pyenforces that the canaries exist (a removed one would fire the row for every healthy client) but deliberately cannot check that they are the newest tools. That part is this checklist's job. - Prompts are cached the same way. After the 2.29.0 deploy, Claude Desktop did not show the new
record_for_paperprompt until the connector was disconnected and reconnected. A server restart ends the session, the client reconnects on its own, and it keeps the prompt list it already had. That list was empty here, since 2.29.0 introduced the server's first prompt. Aprompts/list_changednotification cannot help, because the new prompt exists only after the restart. When a prompt is the user-side trigger for something (asrecord_for_paperis on Claude Desktop, where tool descriptions and server instructions don't reach the model), its user-facing docs must say "reconnect if it isn't in the menu". - Never remove an output schema from a tool that has one. Clients validate each result against the schema they cached at
tools/listtime, so a stale client rejects every call ("has an output schema but did not return structured content") while the server and a fresh client look perfectly healthy. 2.23.0 did this toTogoMCP_Usage_Guide(output_schema=None, to make a saved result pageable) and a claude.ai connector with a cached 2.21 list could no longer call the guide; 2.23.3 restored it. The reverse is harmless (structured content sent to a client expecting none is ignored), so a schema can be added but not taken away.tests/test_output_schema_stability.pypins the set. A return-shape change is MINOR only while the declared schema still validates the old client's expectations.
Reach differs by kind of change, so weigh it when choosing how to ship a capability: a new database reaches every client immediately (the catalog ships inside TogoMCP_Usage_Guide at query time, and database is a free-form string validated server-side), while a new tool is invisible to any client that has not re-scanned. Where reach matters, prefer a new database or a parameter on an existing tool over a new tool.
Testing
uv run pytest # all tests
uv run pytest tests/test_server.py -vtests/test_api_tools.py has a pre-existing failure caused by FastMCP wrapping tool functions as FunctionTool objects (not directly callable from tests). Don't block on it unless explicitly asked.
Running locally
export NCBI_API_KEY="..." # required for NCBI tools (has a default fallback but rate-limited)
uv sync
uv run togo-mcp-local # stdio
uv run togo-mcp-server # HTTP :8000Editing MIE files
MIE YAMLs in togo_mcp/data/mie/ drive the get_MIE_file tool — the primary schema/example resource LLMs read before writing SPARQL. The corpus is the v3 format; the format spec is MIE_v3_spec.md (it supersedes the retired v2 MIE_file_specs.md). The authoring tooling that implements it is the mie-generator skill under .claude/skills/. When adding a database, also add an entry to resources/endpoints.csv. A served MIE must stay under the inline limit: tests/test_mie_size.py fails a file whose get_MIE_file response exceeds 47,000 characters (Claude Code saves anything past 50,000 to a file, and the model then reads a fraction of it). The served size is not the file size: check: blocks are stripped and a banner is added, but YAML comments ARE served. When a file is over, cut restatement and authoring history, never a verified example.
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.

