Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .claude/skills/benchmark-runner/scripts/run_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def sha256_file(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest()


def repo_rel(p) -> str:
"""`p` relative to the repo when inside it, else absolute. A path outside the repo
(e.g. a one-question --canary-file in a scratch dir) used to crash here, after all the
answering and judging had been paid for, leaving the run without a manifest."""
p = Path(p).resolve()
return str(p.relative_to(REPO)) if p.is_relative_to(REPO) else str(p)


def git(*a) -> str:
return subprocess.check_output(["git", "-C", str(REPO), *a], text=True).strip()

Expand Down Expand Up @@ -304,16 +312,16 @@ def main() -> int:
"replicates": reps,
"question_set_hash": set_manifest["set_hash"],
"n_questions": len(questions),
"question_list": str(Path(args.canary_file).relative_to(REPO)) if args.mode == "canary" else "all",
"target": "local " + str(Path(args.mie_dir).resolve().relative_to(REPO))
"question_list": repo_rel(args.canary_file) if args.mode == "canary" else "all",
"target": "local " + repo_rel(args.mie_dir)
if args.target == "local" else f"production {PRODUCTION_URL}",
"server_version": meta.get("server_version") if meta else production_server_version(),
"usage_guide_version": meta.get("usage_guide_version"),
"mie_bundle_version": meta.get("mie_bundle_version"),
"git_commit": git("rev-parse", "HEAD"),
"git_dirty": bool(git("status", "--porcelain", "--", "togo_mcp", "benchmark/scripts")),
"baseline": "no-tool answer produced in the same batch, per question",
"base_config": str(Path(args.base_config).resolve().relative_to(REPO)),
"base_config": repo_rel(args.base_config),
"base_config_sha256": sha256_file(Path(args.base_config)),
"pricing_usd_per_mtok": list(price),
"ollama_judge_options": ollama_options() if any(not is_claude(j) for j in judges) else None,
Expand Down
57 changes: 56 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,60 @@ dominant client re-reads the schema each session. Only a removal/rename is MAJOR

## [Unreleased]

## [2.27.0] - 2026-10-17

<!-- whatsnew: 2026-10-17 | Every tool now shows a <strong>readable name</strong> in your client (for example “Run SPARQL query” instead of “Run Sparql”), and <code>run_sparql</code> names the endpoint each database is queried at, including the seven hosted outside RDF Portal. -->

Connectors Directory review fixes. MINOR: tools gain a `title` field; no name, parameter or
return shape changes.

### Added

- **Every tool has a human-readable title**, in both `Tool.title` and `annotations.title`
(e.g. "Get database schema (MIE file)" instead of the name-derived "Get Mie File"). Directory
review checks for this, and clients differ on which field they display. New tools get it through
`**read_only_tool("<title>")`, which also carries the read-only hints; a test enforces it.

### Changed

- **`run_sparql` names where queries go**, built from `endpoints.csv`: RDF Portal
(https://rdfportal.org/) for most databases, and by URL the seven databases that run their own
endpoints (glycosmos, togovar, wikipathways, idsm, lipidmaps, swisslipids, marpolbase). A
description saying only "RDF Portal" would have misstated where the data comes from for those.
README, the intro page and `get_sparql_endpoints`/`get_graph_list` no longer imply every
endpoint is on RDF Portal.
- **Short tool descriptions now say what they return and which API they call**: `ncbi_efetch`
(79 → 633 characters), `ncbi_list_databases`, `ncbi_esummary`, `search_mesh_descriptor`, both
PubChem tools, `togoid_getDescription`. `get_compound_attributes_from_pubchem` now states that an
unknown CID is not an error but an object with empty fields.
- **Tool descriptions state facts instead of giving orders.** Directory review rejects descriptions
that steer the model ("⚠️ CALL THIS TOOL FIRST every turn", "CALL get_MIE_file FIRST", "ALWAYS",
"MANDATORY", "must not be reported as one") as prompt injection. The same information is now
stated as what a tool returns and what its results depend on: `run_sparql` says correct results
depend on the MIE file and keeps the measured inflation figures; the guide tool says what the
guide covers and that it is meant to be read before the other tools. A test rejects the old
phrasings in any served description.
- **Long descriptions trimmed** to at most 2,800 characters (`togoid_identifyId` 3,493 → 2,668,
`search_chembl_molecule` 3,153 → 2,732, `search_chembl_id_lookup` 2,760 → 2,555), by cutting
implementation detail and repetition, not usage facts. A test holds the ceiling.
- **The Usage Guide no longer tells the agent how to write its answer.** The OUTPUT QUALITY
section (no meta-commentary, each fact once, prose or list) and the "Repetitive answer"
troubleshooting row are gone, and the stale-tool-list row states that the user must update
the client instead of instructing the agent what to say. The KEGG note in the database catalog
(core and reference, via the generator) likewise states that KEGG is unavailable without
`kegg_*` tools and why, instead of telling the agent what not to report. Answer format is the client's and the
user's business; the public Skills (PRISM, disease analysis, article analysis) keep their own
output templates. The query rules (graph pins, MIE before SPARQL, budgets) are unchanged.

### Security

- **The `endpoint_url` exemption for registered endpoints now covers the exact endpoint, not its
whole host.** Through 2.26.2 any path on a registered host:port skipped the public-address check,
so a caller could POST to, for example, a Virtuoso admin page on an internally resolving
endpoint host. Now scheme, host, port and path must match a registered endpoint (a trailing
slash or a query string is fine); any other path on that host goes through the same check as an
unregistered URL.

## [2.26.2] - 2026-10-16

Security fixes only. PATCH: no tool, parameter or return shape changed; what changes is which
Expand Down Expand Up @@ -3430,7 +3484,8 @@ their own file. No tool-surface change; the served MIE/guide content is correcte
_MIE database onboarding and revisions land continuously and are summarised per
release above; see git history for the full detail._

[Unreleased]: https://github.com/dbcls/togomcp/compare/v2.26.2...HEAD
[Unreleased]: https://github.com/dbcls/togomcp/compare/v2.27.0...HEAD
[2.27.0]: https://github.com/dbcls/togomcp/compare/v2.26.2...v2.27.0
[2.26.2]: https://github.com/dbcls/togomcp/compare/v2.26.1...v2.26.2
[2.26.1]: https://github.com/dbcls/togomcp/compare/v2.26.0...v2.26.1
[2.26.0]: https://github.com/dbcls/togomcp/compare/v2.25.2...v2.26.0
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TogoMCP

FastMCP server exposing the [RDF Portal](https://rdfportal.org/) (SPARQL) plus selected REST APIs (NCBI E-utilities, UniProt, ChEMBL, PDB, PubChem, Reactome, Rhea, MeSH, TogoID, TogoVar) to AI assistants.
FastMCP server exposing the [RDF Portal](https://rdfportal.org/) (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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
![Python >=3.11](https://img.shields.io/badge/python-%3E%3D3.11-blue)
![License: MIT](https://img.shields.io/badge/license-MIT-green)

An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that gives AI assistants (Claude, etc.) access to biological and biomedical RDF databases via SPARQL at the [RDF Portal](https://rdfportal.org/), as well as selected REST APIs (NCBI E-utilities, UniProt, ChEMBL, PDB, Reactome, Rhea, MeSH, and more).
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that gives AI assistants (Claude, etc.) access to biological and biomedical RDF databases via SPARQL — mostly at the [RDF Portal](https://rdfportal.org/), plus a few databases' own public endpoints (GlyCosmos, TogoVar, WikiPathways, IDSM, LIPID MAPS, SwissLipids, MarpolBase) — as well as selected REST APIs (NCBI E-utilities, UniProt, ChEMBL, PDB, Reactome, Rhea, MeSH, and more).

## Quick Start: Remote Server (No Installation)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ togo_mcp = [

[project]
name = "togo-mcp"
version = "2.26.2"
version = "2.27.0"
description = "MCP Servers for using the RDF Portal"
authors = [
{name = "Akira R. Kinjo"},
Expand Down
23 changes: 12 additions & 11 deletions scripts/generate_usage_guide_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,11 @@ def render_compact_kegg_note() -> str:
"**Not an RDF Portal database — KEGG.** `database=\"kegg\"` is invalid on "
"`run_sparql` and `get_MIE_file`: KEGG has no SPARQL endpoint and no MIE. The "
"`kegg_*` tools exist only on a local stdio server whose operator enabled them. "
"**If you see no `kegg_*` tool, KEGG is unavailable in this session** — answer "
"pathway questions from `reactome` or `rhea`, and do NOT report the absence as "
"an error or suggest enabling it. When the tools ARE present, this guide "
"carries a KEGG section with the details.",
"**If no `kegg_*` tool is listed, KEGG is unavailable in this session**; "
"`reactome` and `rhea` cover pathways over SPARQL. The absence is a licensing "
"decision of the deployment, not an error, and nothing the user does in this "
"session changes it. When the tools ARE present, this guide carries a KEGG "
"section with the details.",
"",
])

Expand Down Expand Up @@ -280,17 +281,17 @@ def render_non_sparql_companions() -> str:
"on `run_sparql` and `get_MIE_file` — there is no query you can write here that "
"reaches it.",
"",
"**Check your tool list before offering KEGG to the user.** KEGG is OFF BY "
"**KEGG is available only if the tool list has `kegg_*` tools.** It is OFF BY "
"DEFAULT. The `kegg_*` tools appear only on the local stdio server "
"(`togo-mcp-local`) AND only when the operator sets `TOGOMCP_ENABLE_KEGG=1`, "
"because the KEGG API is licensed to academic users at academic institutions; "
"the public host at togomcp.rdfportal.org cannot verify a caller's affiliation "
"and never exposes them whatever the environment says. **If you see no `kegg_*` "
"tool, KEGG is simply unavailable in this session** — answer pathway questions "
"from `reactome` or `rhea` over SPARQL, and do NOT report the absence as an "
"error, ask the user to retry, or suggest they enable it: eligibility is theirs "
"to judge, not yours to prompt for. When the tools ARE present, this guide "
"carries a KEGG section with the details.",
"and never exposes them whatever the environment says. **If no `kegg_*` tool "
"is listed, KEGG is simply unavailable in this session**; `reactome` and `rhea` "
"cover pathways over SPARQL. The absence is not an error and retrying does not "
"change it; whether a user is eligible for KEGG's academic licence is for them "
"to judge. When the tools ARE present, this guide carries a KEGG section with "
"the details.",
"",
])

Expand Down
19 changes: 19 additions & 0 deletions tests/test_request_target_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ def test_registered_host_on_another_port_is_not_exempt(monkeypatch):
_check("https://rdfportal.org:8890/sparql")


@pytest.mark.parametrize(
"suffix", ["", "/", "?default-graph-uri=http%3A%2F%2Fexample.org%2Fg"],
)
def test_registered_endpoint_spellings_stay_exempt(monkeypatch, suffix):
_resolve_to(monkeypatch, "10.0.0.5")
_check(server.SPARQL_ENDPOINT["uniprot"]["url"] + suffix)


@pytest.mark.parametrize(
"path", ["/conductor", "/sib/sparql/../../conductor", "/sib/sparq%6c", "/sib/sparql/x"],
)
def test_other_path_on_a_registered_host_is_not_exempt(monkeypatch, path):
"""The exemption is for the registered endpoint, not its whole host: a Virtuoso
admin UI on the same internal-resolving host must hit the public-address check."""
_resolve_to(monkeypatch, "10.0.0.5")
with pytest.raises(ValueError, match="non-public address"):
_check("https://rdfportal.org" + path)


@pytest.mark.parametrize("url", ["file:///etc/passwd", "gopher://x/", "ftp://x/"])
def test_non_http_scheme_is_rejected(url):
with pytest.raises(ValueError, match="only http and https"):
Expand Down
95 changes: 91 additions & 4 deletions tests/test_tool_descriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ def test_every_tool_description_states_what_it_returns(assembled_tools) -> None:


def test_run_sparql_mandates_the_mie_file_in_what_clients_receive(assembled_tools) -> None:
"""`run_sparql` must tell the agent to read the MIE file first — in the SERVED
description, not the docstring.
"""`run_sparql` must tell the agent its results depend on the MIE file — in the
SERVED description, not the docstring.

This is asserted on the assembled tool list rather than on `__doc__` for a
reason: `run_sparql` carries an explicit `description=` on its decorator, and
Expand All @@ -109,7 +109,10 @@ def test_run_sparql_mandates_the_mie_file_in_what_clients_receive(assembled_tool
"the docstring, clients never see it — the decorator's description= wins."
)
lowered = desc.lower()
assert "first" in lowered, "the MIE mandate must be ordered ('FIRST'), not merely suggested"
assert "depend on get_mie_file" in lowered, (
"the description must say correct results DEPEND on the MIE file — stated as a "
"fact (directory review rejects 'CALL ... FIRST' imperatives), but still stated"
)
assert "wrong answer" in lowered, (
"the description must say skipping the MIE yields a WRONG ANSWER, not a "
"failure — an agent watching for an error will trust a clean wrong result"
Expand Down Expand Up @@ -232,7 +235,7 @@ def test_every_tool_declares_read_only(self) -> None:
]
assert not missing, (
f"tools missing readOnlyHint=True: {missing}. Add "
"`annotations=READ_ONLY_TOOL` to the @mcp.tool decorator — without it "
"`**read_only_tool(\"<title>\")` to the @mcp.tool decorator — without it "
"clients treat the tool as a writer."
)

Expand All @@ -259,7 +262,91 @@ def test_destructive_and_idempotent_hints_stay_unset(self) -> None:
]
assert not wrong, f"read-only tools must leave destructive/idempotent unset: {wrong}"

def test_every_tool_has_a_human_title_in_both_places(self) -> None:
"""Connectors Directory review requires a human-readable title on every
tool. Without one FastMCP derives it from the function name ("Get Mie
File"), so the check is that `Tool.title` and `annotations.title` are both
set, equal, and NOT that derived form — which is what all 33 tools shipped
with through 2.26."""
tools = asyncio.run(self._tools())
bad = []
for t in tools:
ann_title = getattr(getattr(t, "annotations", None), "title", None)
derived = t.name.replace("_", " ").title()
if not t.title or t.title != ann_title or t.title == derived:
bad.append(f"{t.name} (title={t.title!r}, annotations.title={ann_title!r})")
assert not bad, (
f"tools without a human title in both Tool.title and annotations.title: {bad}. "
"Use `**read_only_tool(\"<title>\")` on the @mcp.tool decorator."
)

def test_titles_are_unique(self) -> None:
# By name: each _tools() call re-runs setup(), which re-mounts the
# sub-servers, so the same tool can be listed more than once.
titles = list({t.name: t.title for t in asyncio.run(self._tools())}.values())
assert len(titles) == len(set(titles)), sorted(x for x in titles if titles.count(x) > 1)

@staticmethod
async def _tools():
await setup()
return await mcp.list_tools()


def test_run_sparql_names_every_endpoint_it_can_send_a_query_to(assembled_tools) -> None:
"""Directory review asks a query-building tool to name the API it targets, and
"RDF Portal" alone is not true: some registered databases run their own
endpoints. Every registered endpoint URL off rdfportal.org must appear in the
served description, and RDF Portal itself must be named with its URL."""
import httpx

from togo_mcp.server import SPARQL_ENDPOINT

desc = next(t for t in assembled_tools if t.name == "run_sparql").description or ""
assert "https://rdfportal.org/" in desc
external = {
info["url"] for info in SPARQL_ENDPOINT.values()
if httpx.URL(info["url"]).host != "rdfportal.org"
}
assert external, "registry has no external endpoint — this test's premise changed"
missing = sorted(u for u in external if u not in desc)
assert not missing, f"run_sparql's description omits external endpoints: {missing}"


# Directory review rejects tool descriptions that steer the model rather than
# describe the tool ("CALL THIS TOOL FIRST every turn" read as prompt injection).
# State what a tool returns and what it depends on; leave the procedure to the
# Usage Guide. Lower-case usage notes ("check for 'error' before reading") are fine.
_DIRECTIVE_PATTERNS = (
r"\bCALL\b[^.]*\bFIRST\b", r"\bALWAYS\b", r"\bMANDATORY\b", r"\bMUST\b",
r"\bDO NOT\b", r"\bNEVER\b", r"\bCHECK FOR\b", r"\bREAD THEM\b", r"every turn",
r"must not be reported",
)
# Longest served description allowed. run_sparql is ~2,550 by itself because it
# lists the live database/endpoint registry; the rest were trimmed to fit in 2.27.
_MAX_DESCRIPTION_CHARS = 2800


def test_no_tool_description_issues_directives(assembled_tools) -> None:
import re as _re

hits = {
t.name: found
for t in assembled_tools
if (found := [p for p in _DIRECTIVE_PATTERNS if _re.search(p, t.description or "")])
}
assert not hits, (
f"directive wording in tool descriptions: {hits}. Rewrite it as what the tool "
"returns or depends on (see the run_sparql description)."
)


def test_tool_descriptions_stay_under_the_length_ceiling(assembled_tools) -> None:
long = {
t.name: len(t.description or "")
for t in assembled_tools
if len(t.description or "") > _MAX_DESCRIPTION_CHARS
}
assert not long, (
f"descriptions over {_MAX_DESCRIPTION_CHARS} characters: {long}. Move history "
"and implementation notes into code comments."
)
Loading
Loading