Skip to content
Draft
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ Refactors, CI, and formatting land in the git history, not here.

## [Unreleased]

### Changed

- The MCP server now requires **MCP SDK 2.0** (`mcp>=2.0.0`). Tools, resources, the `/mcp`
endpoint and every response shape are unchanged — this is an SDK-internal rename
(`FastMCP` → `MCPServer`). One config surface goes away: the SDK's undocumented
`FASTMCP_HOST` / `FASTMCP_PORT` / `FASTMCP_STREAMABLE_HTTP_PATH` environment variables no
longer have any effect. Use `idc-mcp --http --host … --port …`, which is what the deployment
has always passed; the defaults (`127.0.0.1:8000`) are unchanged.

### Fixed

- The `/v3/viewer-url` OpenAPI examples (the values Swagger UI's "Try it out" pre-fills) used a StudyInstanceUID and SeriesInstanceUID that are not present in IDC, so running the example returned a `not_found` error instead of a viewer link. Both now use resolvable UIDs.
Expand Down
2 changes: 1 addition & 1 deletion dev/api_v3_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ general MCP guidance to treat all tool inputs as untrusted and apply defense in
## Tech stack & deployment
- **Python 3.11+**, **FastAPI** + uvicorn/gunicorn, **Pydantic v2**. FastAPI gives OpenAPI
docs + generated client SDKs for free (serves the non-Python/web-integration audience).
- **MCP:** the official `mcp` Python SDK (FastMCP server API). Hand-author tools over
- **MCP:** the official `mcp` Python SDK (`MCPServer` server API). Hand-author tools over
`core/` rather than auto-converting REST routes — auto-conversion yields generic,
poorly-described tools. Same image serves remote MCP (HTTP) next to REST; local MCP runs
via **stdio** from a `pip install idc-mcp` / `uvx` entrypoint.
Expand Down
6 changes: 3 additions & 3 deletions dev/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ for *how to work in it* see [`dev/developer_guide.md`](developer_guide.md).
┌──────────────────────┐ ┌──────────────────────┐
│ REST adapter │ │ MCP adapter │
│ src/idc_api/rest │ │ src/idc_api/mcp │
│ FastAPI routes │ │ FastMCP tools +
│ FastAPI routes │ │ MCPServer tools + │
│ │ │ resources │
└──────────┬───────────┘ └───────────┬──────────┘
│ (thin: validate + delegate) │
Expand Down Expand Up @@ -186,7 +186,7 @@ bytes through the service, and identical behavior local or hosted. (A local-only
endpoint/tool existed pre-3.0.0 and was removed in beta; see CHANGELOG.)

The hosted HTTP transport is configured **stateless** (`stateless_http=True`,
`json_response=True` on the `FastMCP(...)` constructor) so each request is self-contained and
`json_response=True` on the `streamable_http_app(...)` call) so each request is self-contained and
the service autoscales across instances like the REST API — no sticky sessions. This is safe
because the server exposes only client-initiated tools + static resources (none of the
server→client features that require a persistent session). The flags affect only the HTTP
Expand All @@ -212,7 +212,7 @@ rationale.
|---|---|---|
| Language/runtime | Python 3.11+ (dev on 3.12) | matches idc-index |
| Web framework | FastAPI + uvicorn | free OpenAPI/Swagger + generated SDKs |
| MCP | official `mcp` SDK (FastMCP) | hand-authored tools, not auto-converted routes |
| MCP | official `mcp` SDK (`MCPServer`) | hand-authored tools, not auto-converted routes |
| Query engine | DuckDB over Parquet | ms latency, no GCP/auth, matches idc-index |
| Models/validation | Pydantic v2 | one contract for both adapters |
| Packaging | `uv` + lockfile, hatchling src-layout | reproducible installs |
2 changes: 1 addition & 1 deletion dev/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ autoscales like REST. Caveats:

- **Retrieval is manifests/URLs only** on every transport — the server never transfers files;
callers download directly from the public S3/GCS buckets (`idc` CLI / s5cmd).
- **Both `…/mcp` and `…/mcp/` are served directly** — no redirect either way. FastMCP registers one
- **Both `…/mcp` and `…/mcp/` are served directly** — no redirect either way. The SDK registers one
exact-path route at `/mcp`, so out of the box Starlette 307s `/mcp/` onto it; `http_app()` in
[mcp/server.py](../src/idc_api/mcp/server.py) registers the trailing-slash form as a real route
and turns `redirect_slashes` off, because making an RPC client replay its POST body across a
Expand Down
4 changes: 2 additions & 2 deletions dev/developer_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ uv run --directory . pytest tests/test_backend_guards.py -q # one file
| [test_clinical.py](../tests/test_clinical.py) | Clinical tables registered under the `clinical` schema, hidden from `list_tables`, discoverable/readable, joinable to `index` |

Fixtures live in [tests/conftest.py](../tests/conftest.py): `ctx` (the core
`AppContext`), `client` (FastAPI `TestClient`), and `parse_mcp` (normalizes a FastMCP
`AppContext`), `client` (FastAPI `TestClient`), and `parse_mcp` (normalizes an MCP
`call_tool` return into plain Python).

### Continuous integration
Expand Down Expand Up @@ -93,7 +93,7 @@ src/idc_api/
duckdb_backend.py # read-only DuckDB over idc-index Parquet
services/ # discovery, cohort, query, clinical, manifest, viewer, citations, licenses
rest/app.py # FastAPI app + routes
mcp/server.py # FastMCP tools + resources + entrypoint
mcp/server.py # MCPServer tools + resources + entrypoint
tests/ # pytest suite
```

Expand Down
7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@ dependencies = [
# REST adapter
"fastapi>=0.139.1",
"uvicorn[standard]>=0.51.0",
# MCP adapter (official SDK; bundles FastMCP server API).
# Capped below 2.0: that release removes `mcp.server.fastmcp` entirely (FastMCP ->
# MCPServer) and moves transport config off the constructor, so the adapter does not
# import under it. Migration is a deliberate PR, not a dependency bump.
"mcp>=1.28.1,<2",
# MCP adapter (official SDK; bundles the MCPServer high-level API)
"mcp>=2.0.0",
]

[project.optional-dependencies]
Expand Down
60 changes: 30 additions & 30 deletions src/idc_api/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
import time
from typing import Any

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.transport_security import TransportSecuritySettings
from starlette.applications import Starlette
from starlette.routing import Route
Expand Down Expand Up @@ -67,7 +67,9 @@


# stateless_http=True / json_response=True make the hosted (streamable-http) transport
# horizontally scalable on Cloud Run.
# horizontally scalable on Cloud Run. Both are passed to streamable_http_app() in http_app()
# below — as of MCP SDK 2.0 the transport settings live on that call rather than on the server
# object, so stdio mode never constructs them at all.
#
# Why: MCP's streamable-HTTP transport is normally *session-oriented* — the server keeps each
# client's session state in the memory of the process that handled `initialize`, and every
Expand Down Expand Up @@ -109,25 +111,18 @@ def _server_version() -> str:
Base is the installed package version; when a deploy sets IDC_API_BUILD (e.g. a short git
SHA) it is appended as a PEP 440 local segment so the string moves on every redeploy — this
is how a caller confirms which build a hosted instance is actually running. Without setting
`version=`, FastMCP would fall back to the MCP SDK's own version, which says nothing about
this server. Shared with the REST adapter (which reports the same string via /v3/version and
`version=`, the SDK would fall back to its own version, which says nothing about this
server. Shared with the REST adapter (which reports the same string via /v3/version and
/v3/openapi.json) through core.version.
"""
return core_version.server_version()


mcp = FastMCP(
mcp = MCPServer(
"IDC (Imaging Data Commons)",
instructions=INSTRUCTIONS,
stateless_http=True,
json_response=True,
transport_security=_transport_security(),
version=_server_version(),
)
# FastMCP doesn't forward a version to the low-level server; left unset, the initialize
# handshake reports the MCP SDK's own version (meaningless for tracking this server). Set it on
# the underlying server so serverInfo.version reflects our build. No public accessor exists in
# this SDK version, hence the _mcp_server reach-in.
mcp._mcp_server.version = _server_version()
ctx = AppContext()


Expand Down Expand Up @@ -573,11 +568,11 @@ def schema_resource(table: str) -> str:
# --- entrypoint ---------------------------------------------------------------------------


def http_app(server: FastMCP | None = None) -> Starlette:
def http_app(server: MCPServer | None = None, streamable_http_path: str = "/mcp") -> Starlette:
"""Starlette app for the hosted (streamable-http) transport.

Same app FastMCP builds, with one fix: serve the endpoint at both ``/mcp`` and ``/mcp/``
rather than redirecting between them. FastMCP registers a single *exact-path* Route at
Same app the SDK builds, with one fix: serve the endpoint at both ``/mcp`` and ``/mcp/``
rather than redirecting between them. The SDK registers a single *exact-path* Route at
``streamable_http_path`` (default ``/mcp``), so Starlette's ``redirect_slashes`` answers a
request for ``/mcp/`` with a 307 to ``/mcp``. That redirect is correct HTTP but a poor fit
for an RPC endpoint fronted by a load balancer: it makes every ``/mcp/`` client re-issue its
Expand All @@ -586,18 +581,25 @@ def http_app(server: FastMCP | None = None) -> Starlette:
LB's ``/mcp``, ``/mcp/*`` rule) uses is the path that gets served.

Slash-agnostic in the configured path: whether ``streamable_http_path`` is ``/mcp`` or
``/mcp/``, FastMCP registers whichever spelling it was given and we add the other.
``/mcp/``, the SDK registers whichever spelling it was given and we add the other.

``transport_security`` must be passed here explicitly: the SDK enables DNS-rebinding
protection by default, which answers every request on a hosted domain with HTTP 421.
"""
server = server or mcp
app = server.streamable_http_app()
configured = server.settings.streamable_http_path
app = server.streamable_http_app(
streamable_http_path=streamable_http_path,
stateless_http=True,
json_response=True,
transport_security=_transport_security(),
)
# "/mcp" and "/mcp/" are the same endpoint; a configured "/" has no distinct twin.
base = configured.rstrip("/")
base = streamable_http_path.rstrip("/")
spellings = [base, f"{base}/"] if base else ["/"]
found = {r.path: r for r in app.router.routes if isinstance(r, Route) and r.path in spellings}
if not found:
raise RuntimeError(
f"FastMCP registered no route at {configured!r} — the SDK's routing changed and "
f"the SDK registered no route at {streamable_http_path!r} — its routing changed and "
"http_app() can no longer find the endpoint to alias."
)
# methods=None on the source Route: the endpoint is an ASGI app, so it matches every method
Expand All @@ -624,24 +626,22 @@ def main() -> None:
action="store_true",
help="Serve over streamable-http (hosted/shared) instead of stdio (local).",
)
parser.add_argument("--host", default=None)
parser.add_argument("--port", type=int, default=None)
# Defaults match what the SDK's own settings used to supply; as of MCP SDK 2.0 host/port are
# no longer server state, so they live here.
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()

if args.http:
import uvicorn

if args.host:
mcp.settings.host = args.host
if args.port:
mcp.settings.port = args.port
# Equivalent to mcp.run(transport="streamable-http"), but serving http_app() so the
# trailing-slash route above is present. uvicorn's proxy_headers default (with
# FORWARDED_ALLOW_IPS from the environment) is unchanged.
uvicorn.run(
http_app(),
host=mcp.settings.host,
port=mcp.settings.port,
host=args.host,
port=args.port,
log_level=mcp.settings.log_level.lower(),
)
else:
Expand Down
11 changes: 6 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ def client():


def mcp_json(result) -> object:
"""Normalize a FastMCP ``call_tool`` return into plain Python (dict/list)."""
if isinstance(result, dict):
# Structured-output dict; FastMCP wraps bare list/scalar returns under "result".
return result.get("result", result)
for block in result: # Sequence[ContentBlock]
"""Normalize an MCP ``call_tool`` return (``CallToolResult``) into plain Python."""
structured = result.structured_content
if structured is not None:
# The SDK wraps bare list/scalar returns under "result"; dict returns come through as-is.
return structured.get("result", structured)
for block in result.content: # Sequence[ContentBlock]
text = getattr(block, "text", None)
if text is not None:
return json.loads(text)
Expand Down
23 changes: 10 additions & 13 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def test_run_sql(server, parse_mcp):
async def test_run_sql_error_carries_engine_hint(server):
# A SQL mistake must return DuckDB's self-correction hints (candidate columns,
# "Did you mean") to the agent — not the guard's generic "Internal error" fallback.
from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.mcpserver.exceptions import ToolError

with pytest.raises(ToolError) as exc:
await server.call_tool("run_sql", {"sql": "SELECT no_such_column FROM index"})
Expand All @@ -80,7 +80,7 @@ async def test_run_sql_error_carries_engine_hint(server):


async def test_error_is_clean(server):
from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.mcpserver.exceptions import ToolError

with pytest.raises(ToolError) as exc:
await server.call_tool("get_collection", {"collection_id": "__nope__"})
Expand All @@ -90,21 +90,20 @@ async def test_error_is_clean(server):
def test_server_version_is_our_build_not_sdk_fallback(server):
"""initialize must advertise our package version, not the MCP SDK's own version.

The low-level server defaults version to None, which makes the handshake echo the `mcp`
SDK version — useless for tracking this server. We set it explicitly; guard that wiring
(and the private _mcp_server reach-in it depends on) against SDK changes.
Left unset, the handshake echoes the `mcp` SDK version — useless for tracking this server.
We pass it to the constructor; guard that wiring against SDK changes.
"""
from importlib.metadata import version

from idc_api.mcp.server import _server_version

advertised = server._mcp_server.version
advertised = server.version
assert advertised, "serverInfo.version unset → SDK-version fallback"
assert advertised == _server_version()
assert advertised.startswith(version("idc-api"))
assert advertised != version("mcp")
# the value the initialize handshake actually returns
opts = server._mcp_server.create_initialization_options()
opts = server._lowlevel_server.create_initialization_options()
assert opts.server_version == advertised


Expand All @@ -131,7 +130,7 @@ async def test_resources(server):


def test_http_app_serves_both_slash_forms_without_redirect():
"""`/mcp` and `/mcp/` both answer directly. FastMCP alone 307s the trailing-slash form to
"""`/mcp` and `/mcp/` both answer directly. The SDK alone 307s the trailing-slash form to
the bare one, which forces clients and proxies to replay the POST body."""
from fastapi.testclient import TestClient

Expand Down Expand Up @@ -164,15 +163,13 @@ def test_http_app_serves_both_slash_forms_without_redirect():

@pytest.mark.parametrize("configured", ["/mcp", "/mcp/"])
def test_http_app_is_slash_agnostic_in_configured_path(configured):
"""Both spellings are routed whichever one FastMCP was configured with. Only reachable by
editing the FastMCP(...) call — FASTMCP_STREAMABLE_HTTP_PATH cannot reach it, since
FastMCP.__init__ always passes streamable_http_path= explicitly and that outranks env."""
from mcp.server.fastmcp import FastMCP
"""Both spellings are routed whichever one the endpoint path is configured with."""
from mcp.server.mcpserver import MCPServer
from starlette.routing import Route

from idc_api.mcp.server import http_app

app = http_app(FastMCP("probe", streamable_http_path=configured))
app = http_app(MCPServer("probe"), streamable_http_path=configured)
paths = {r.path for r in app.router.routes if isinstance(r, Route)}
assert {"/mcp", "/mcp/"} <= paths
assert app.router.redirect_slashes is False
Loading