feat(serve): dual-compat with MCP SDK 1.x and 2.x

mcp 2.0 removed the low-level decorator API the server was built on and
changed several models. _build_server now defines the four handlers as
plain coroutines and binds them at the end in a version-aware way: the
@server.list_tools()/... decorators on 1.x, the on_list_tools=/...
constructor callbacks (with explicit ListToolsResult/CallToolResult/
ListResourcesResult/ReadResourceResult wrapping) on 2.x. Model diffs
handled: Tool.inputSchema is input_schema on 2.x (constructor aliases
work on both, attribute access does not); Resource.uri is a plain str
on 2.x and rejects AnyUrl instances, so resource URIs are passed as
strings (1.x coerces them). The 2.x server also reports graphifyy's own
version in serverInfo. Extras relaxed from mcp>=1,<2 to mcp>=1,<3.

Verified: full stdio handshake (initialize / tools/list / tools/call /
resources/list / resources/read) and the tests in test_serve.py +
test_serve_http.py (145 passed) under BOTH mcp 1.29.0 and mcp 2.0.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nicola Avancini
2026-07-30 17:13:48 +01:00
committed by safishamsi
co-authored by Claude Fable 5
parent 50008cdf36
commit 576d5cbb61
3 changed files with 71 additions and 19 deletions
+4
View File
@@ -2,6 +2,10 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## Unreleased
- Feature: the MCP server is dual-compatible with SDK 1.x AND 2.x (`mcp>=1,<3`, lifting the `<2` cap 0.9.30 introduced). The 2.0 SDK removed the low-level decorator API (`Server.list_tools`/`call_tool`/...); `_build_server` now binds the same handlers via the 1.x decorators or the 2.x `on_*` constructor callbacks, picked at runtime. Also adapted: `Tool.inputSchema` attribute access (snake_case in 2.x), `Resource.uri` (plain `str` in 2.x, which rejects `AnyUrl` objects), and the `AnyUrl` import (dropped as a re-export in 2.0, falls back to pydantic's). Verified with full stdio handshakes and the serve/HTTP test suites under both mcp 1.29 and 2.0.
## 0.9.30 (2026-07-29)
- Fix: pin `mcp` below 2.0 so a fresh `graphifyy[mcp]` / `graphifyy[all]` install works again (#2277, #2279, #2291). The `mcp` 2.0.0 major dropped the `mcp.types.AnyUrl` re-export and the `Server` decorator-registration API that `graphify/serve.py` uses, so an unpinned resolve broke `graphify-mcp` on every new install with an `ImportError`. The `mcp` and `all` extras now require `mcp>=1,<2` (resolving to 1.29.0) and `starlette>=1.3.1,<2`. Adapting to the mcp 2.x API is tracked as a follow-up.
+61 -13
View File
@@ -1246,9 +1246,10 @@ def _build_server(graph_path: str):
G, communities = _load_ctx(path)
active_graph_path = str(Path(path).resolve())
server = Server("graphify")
@server.list_tools()
# NOTE: no decorators here — the handlers below are plain coroutines,
# bound to the Server at the END of this function in a version-aware way:
# mcp 1.x exposes the @server.list_tools()/... decorator API, mcp 2.x
# replaced it with on_list_tools=/... constructor callbacks.
async def list_tools() -> list[types.Tool]:
_tools = [
types.Tool(
@@ -1380,7 +1381,12 @@ def _build_server(graph_path: str):
# stays in lockstep as tools are added. Omitting it keeps the historical
# single-graph behaviour, so this is purely additive for existing callers.
for _t in _tools:
_t.inputSchema.setdefault("properties", {})["project_path"] = {
# The constructor accepts the camelCase alias in both majors, but
# attribute access is inputSchema on mcp 1.x and input_schema on 2.x.
_schema = getattr(_t, "inputSchema", None)
if _schema is None:
_schema = _t.input_schema
_schema.setdefault("properties", {})["project_path"] = {
"type": "string",
"description": (
"Absolute path to a project directory containing "
@@ -1690,18 +1696,18 @@ def _build_server(graph_path: str):
pass
return {cid: f"Community {cid}" for cid in communities}
@server.list_resources()
async def list_resources() -> list[types.Resource]:
# Plain-string URIs on purpose: mcp 1.x types the field as AnyUrl and
# coerces strings, mcp 2.x types it as str and REJECTS AnyUrl objects.
return [
types.Resource(uri=AnyUrl("graphify://report"), name="Graph Report", description="Full GRAPH_REPORT.md", mimeType="text/markdown"),
types.Resource(uri=AnyUrl("graphify://stats"), name="Graph Stats", description="Node/edge/community counts and confidence breakdown", mimeType="text/plain"),
types.Resource(uri=AnyUrl("graphify://god-nodes"), name="God Nodes", description="Top 10 most-connected nodes", mimeType="text/plain"),
types.Resource(uri=AnyUrl("graphify://surprises"), name="Surprising Connections", description="Cross-community surprising connections", mimeType="text/plain"),
types.Resource(uri=AnyUrl("graphify://audit"), name="Confidence Audit", description="EXTRACTED/INFERRED/AMBIGUOUS edge breakdown", mimeType="text/plain"),
types.Resource(uri=AnyUrl("graphify://questions"), name="Suggested Questions", description="Suggested questions for this codebase", mimeType="text/plain"),
types.Resource(uri="graphify://report", name="Graph Report", description="Full GRAPH_REPORT.md", mimeType="text/markdown"),
types.Resource(uri="graphify://stats", name="Graph Stats", description="Node/edge/community counts and confidence breakdown", mimeType="text/plain"),
types.Resource(uri="graphify://god-nodes", name="God Nodes", description="Top 10 most-connected nodes", mimeType="text/plain"),
types.Resource(uri="graphify://surprises", name="Surprising Connections", description="Cross-community surprising connections", mimeType="text/plain"),
types.Resource(uri="graphify://audit", name="Confidence Audit", description="EXTRACTED/INFERRED/AMBIGUOUS edge breakdown", mimeType="text/plain"),
types.Resource(uri="graphify://questions", name="Suggested Questions", description="Suggested questions for this codebase", mimeType="text/plain"),
]
@server.read_resource()
async def read_resource(uri: AnyUrl) -> str:
_select_graph(None) # resources read the server's default graph
uri_str = str(uri)
@@ -1753,7 +1759,6 @@ def _build_server(graph_path: str):
return f"Could not generate questions: {exc}"
raise ValueError(f"Unknown resource: {uri_str}")
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
arguments = dict(arguments or {})
project_path = arguments.pop("project_path", None)
@@ -1766,6 +1771,49 @@ def _build_server(graph_path: str):
except Exception as exc:
return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]
if hasattr(Server, "list_tools"):
# mcp 1.x: decorator-based registration. The SDK wraps the raw returns
# (list[Tool] -> ListToolsResult, str -> resource contents) itself.
server = Server("graphify")
server.list_tools()(list_tools)
server.call_tool()(call_tool)
server.list_resources()(list_resources)
server.read_resource()(read_resource)
else:
# mcp 2.x: handlers ride the Server constructor as on_* callbacks with
# the (ctx, params) -> Result contract, so wrap the same impls and
# build the result models the 1.x decorators used to build for us.
async def _on_list_tools(ctx, params) -> types.ListToolsResult:
return types.ListToolsResult(tools=await list_tools())
async def _on_call_tool(ctx, params) -> types.CallToolResult:
content = await call_tool(params.name, dict(params.arguments or {}))
return types.CallToolResult(content=content)
async def _on_list_resources(ctx, params) -> types.ListResourcesResult:
return types.ListResourcesResult(resources=await list_resources())
async def _on_read_resource(ctx, params) -> types.ReadResourceResult:
text = await read_resource(params.uri)
mime = "text/markdown" if str(params.uri).startswith("graphify://report") else "text/plain"
return types.ReadResourceResult(
contents=[types.TextResourceContents(uri=params.uri, mimeType=mime, text=text)]
)
try:
from importlib.metadata import version as _pkg_version
_version = _pkg_version("graphifyy")
except Exception:
_version = "0"
server = Server(
"graphify",
version=_version,
on_list_tools=_on_list_tools,
on_call_tool=_on_call_tool,
on_list_resources=_on_list_resources,
on_read_resource=_on_read_resource,
)
return server
+6 -6
View File
@@ -52,11 +52,11 @@ Issues = "https://github.com/Graphify-Labs/graphify/issues"
# starlette is pulled in transitively by mcp, but graphify/serve.py imports it
# directly for the HTTP transport, so declare it here and floor it above the
# CVE-2026-48818 / CVE-2026-54283 fixes (both resolved by 1.3.1) (#1391, #1396).
# mcp is capped below 2.0: the 2.0.0 major dropped the mcp.types.AnyUrl
# re-export and the Server decorator-registration API graphify/serve.py uses,
# so an unpinned resolve broke every fresh graphifyy[mcp] install (#2277/#2279/
# #2291). starlette is capped below its next major for the same reason.
mcp = ["mcp>=1,<2", "starlette>=1.3.1,<2"]
# serve.py is dual-compat with the 1.x decorator API and the 2.x on_*
# constructor-callback API (registration is picked at runtime in
# _build_server), lifting the <2 cap 0.9.30 introduced for #2277/#2279/#2291;
# cap below 3 as the tested range. starlette stays capped below its next major.
mcp = ["mcp>=1,<3", "starlette>=1.3.1,<2"]
neo4j = ["neo4j"]
falkordb = ["falkordb"]
pdf = ["pypdf>=6.12.0", "markdownify"]
@@ -85,7 +85,7 @@ pascal = ["tree-sitter-pascal"]
# avoids breaking the default `uv tool install graphifyy` for everyone (#1104).
dm = ["tree-sitter-dm"]
terraform = ["tree-sitter-hcl"]
all = ["mcp>=1,<2", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"]
all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"]
[project.scripts]
graphify = "graphify.__main__:main"