diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f0990..bed9f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Adopt MCP revision `2026-07-28` (the stateless revision) by moving to the + `mcp` 2.x SDK** (`mcp>=2.0.0,<3.0`; the previous `<2.0` cap did its job and + held the fleet at 1.x until this was reviewed). `FastMCP` is now `MCPServer`; + the tool decorators and every tool's behaviour are unchanged, and a v2 server + still serves 2025-era clients from the same process, so existing MCP clients + keep working. Verified green against `mcp-conformance` 0.2.0 (17 contracts). + +### Fixed +- **The `omind node` stdio transport went silent under the 2.x SDK.** Our + fd-readiness transport parsed lines with + `mcp.types.JSONRPCMessage.model_validate_json`, but in 2.x `JSONRPCMessage` is + a plain union alias with no such method; the resulting `AttributeError` was + swallowed into the read stream and every request hung until timeout. It now + parses through the SDK's `jsonrpc_message_adapter`, matching the SDK's own + stdio transport. +- The same transport serialized replies with `exclude_none=True`; switched to + `exclude_unset=True` (what the SDK's transport uses), which is what keeps the + 2026-07-28 envelope fields — `resultType`, `ttlMs`, `cacheScope` — on the wire. + ### Removed - Remove the deprecated `graph-path`, `graph-orphans`, `graph-dangling`, and `graph-stats` MCP compatibility aliases after the 5.0 bridge release. Use the diff --git a/pyproject.toml b/pyproject.toml index 5d9b875..a4f914d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,10 @@ dependencies = [ "fastapi>=0.110,<1.0", "uvicorn[standard]>=0.29,<1.0", "pyyaml>=6.0,<7.0", - "mcp>=1.28.1,<2.0", + # Deliberately stepped to the mcp 2.x major (the cap above did its job and + # held the fleet at 1.x until this was reviewed): 2.0 implements MCP + # revision 2026-07-28, the stateless revision. Re-capped at the next major. + "mcp>=2.0.0,<3.0", "tomlkit>=0.12,<1.0", # Security floors for runtime transitives pulled by fastapi/mcp. "cryptography>=48.0.1,<50.0", diff --git a/src/omind/server.py b/src/omind/server.py index 755a2f5..55bb5b0 100644 --- a/src/omind/server.py +++ b/src/omind/server.py @@ -26,7 +26,7 @@ import anyio import mcp.types as mcp_types from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.shared.message import SessionMessage from omind import graph @@ -63,7 +63,10 @@ async def _fd_stdio_server() -> AsyncIterator[ async def send_line(line: str) -> None: try: - message = mcp_types.JSONRPCMessage.model_validate_json(line) + # mcp 2.x: JSONRPCMessage is a plain union alias, not a RootModel — + # it has no .model_validate_json. Parse through the SDK's TypeAdapter, + # with the same flags the SDK's own stdio transport uses. + message = mcp_types.jsonrpc_message_adapter.validate_json(line, by_name=False) except Exception as exc: await read_stream_writer.send(exc) return @@ -121,8 +124,12 @@ async def stdout_writer() -> None: try: async with write_stream_reader: async for session_message in write_stream_reader: + # exclude_unset (not exclude_none) matches the SDK's own + # stdio transport. It is what keeps the 2026-07-28 envelope + # fields the server explicitly set — resultType, ttlMs, + # cacheScope — on the wire. payload = session_message.message.model_dump_json( - by_alias=True, exclude_none=True + by_alias=True, exclude_unset=True ) await write_all((payload + "\n").encode("utf-8")) except (anyio.ClosedResourceError, BrokenPipeError): # pragma: no cover @@ -171,7 +178,7 @@ def _parse_action_items(items: list[str]) -> list[ActionItem]: return parsed -def build_server(omi_dir: Path | str, node_id: str | None = None) -> FastMCP: +def build_server(omi_dir: Path | str, node_id: str | None = None) -> MCPServer: """Build the node MCP server over one OMI folder. ``node_id`` (from the mesh config, when initialized) turns on Lamport @@ -182,7 +189,7 @@ def build_server(omi_dir: Path | str, node_id: str | None = None) -> FastMCP: # the mesh daemon, not just this server's tools. store = OmiStore(omi_dir, node_id=node_id) - mcp = FastMCP(SERVER_NAME, instructions=_INSTRUCTIONS) + mcp = MCPServer(SERVER_NAME, instructions=_INSTRUCTIONS) # The five graph tools each rebuilt the whole [[wikilink]] graph from disk # (a full-vault read+parse) on every call. Cache it, invalidated by a cheap @@ -479,10 +486,10 @@ def run_node(omi_dir: Path, node_id: str | None = None) -> int: async def run_stdio() -> None: mcp = build_server(omi_dir, node_id=node_id) async with _fd_stdio_server() as (read_stream, write_stream): - await mcp._mcp_server.run( # noqa: SLF001 - FastMCP exposes no public lower-level runner. + await mcp._lowlevel_server.run( # noqa: SLF001 - MCPServer exposes no public lower-level runner. read_stream, write_stream, - mcp._mcp_server.create_initialization_options(), # noqa: SLF001 + mcp._lowlevel_server.create_initialization_options(), # noqa: SLF001 ) anyio.run(run_stdio) diff --git a/tests/test_server.py b/tests/test_server.py index cb97ab5..613a587 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,7 +2,7 @@ # Copyright 2026 Aaron K. Clark """Tests for omind.server: the `omind node` mesh-node MCP server. -In-process tests drive FastMCP's tool layer directly; one subprocess smoke +In-process tests drive MCPServer's tool layer directly; one subprocess smoke test does a real stdio handshake and asserts the clean-exit-on-EOF contract (the regression test for the obsidian-mcp hang class, issue #49). """ @@ -19,8 +19,8 @@ from typing import Any import pytest -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 omind.paths import sync_signal_path from omind.server import build_server @@ -50,23 +50,26 @@ def omi_dir(tmp_path: Path) -> Path: @pytest.fixture -def server(omi_dir: Path) -> FastMCP: +def server(omi_dir: Path) -> MCPServer: return build_server(omi_dir, node_id="testnode-abc123") -def call(server: FastMCP, name: str, args: dict[str, Any]) -> Any: - """Invoke a tool in-process and return its structured result.""" - _content, structured = asyncio.run(server.call_tool(name, args)) - return structured +def call(server: MCPServer, name: str, args: dict[str, Any]) -> Any: + """Invoke a tool in-process and return its structured result. + + v2's ``call_tool`` returns a ``CallToolResult`` rather than v1's + ``(content, structured)`` tuple. + """ + return asyncio.run(server.call_tool(name, args)).structured_content -def test_exposes_exactly_the_designed_tools(server: FastMCP) -> None: +def test_exposes_exactly_the_designed_tools(server: MCPServer) -> None: tools = asyncio.run(server.list_tools()) assert {t.name for t in tools} == EXPECTED_TOOLS assert all(t.description for t in tools) -def test_create_read_round_trip(server: FastMCP, omi_dir: Path) -> None: +def test_create_read_round_trip(server: MCPServer, omi_dir: Path) -> None: created = call( server, "create-note", @@ -98,7 +101,7 @@ def test_create_read_round_trip(server: FastMCP, omi_dir: Path) -> None: assert "fields" not in raw -def test_edit_note_partial_update(server: FastMCP) -> None: +def test_edit_note_partial_update(server: MCPServer) -> None: call(server, "create-note", {"title": "Partial", "summary": "old", "tags": ["keep"]}) edited = call(server, "edit-note", {"name": "Partial.md", "summary": "new"}) assert edited["filename"] == "Partial.md" @@ -107,7 +110,7 @@ def test_edit_note_partial_update(server: FastMCP) -> None: assert got["fields"]["tags"] == ["keep"] # omitted fields untouched -def test_edit_note_version_conflict(server: FastMCP) -> None: +def test_edit_note_version_conflict(server: MCPServer) -> None: call(server, "create-note", {"title": "Versioned", "summary": "v1"}) stale = call(server, "read-note", {"name": "Versioned.md"})["version"] call(server, "edit-note", {"name": "Versioned.md", "summary": "v2"}) @@ -119,7 +122,7 @@ def test_edit_note_version_conflict(server: FastMCP) -> None: ) -def test_delete_archives_and_restore(server: FastMCP, omi_dir: Path) -> None: +def test_delete_archives_and_restore(server: MCPServer, omi_dir: Path) -> None: call(server, "create-note", {"title": "Archived", "summary": "s"}) deleted = call(server, "delete-note", {"name": "Archived.md"}) assert deleted == {"filename": "Archived.md", "status": "archived"} @@ -136,7 +139,7 @@ def test_delete_archives_and_restore(server: FastMCP, omi_dir: Path) -> None: assert "Archived.md" in names -def test_search_vault(server: FastMCP) -> None: +def test_search_vault(server: MCPServer) -> None: call(server, "create-note", {"title": "Alpha", "summary": "quantum cats", "tags": ["pets"]}) call(server, "create-note", {"title": "Beta", "details": "classical dogs", "tags": ["pets"]}) hits = call(server, "search-vault", {"query": "quantum"})["result"] @@ -145,7 +148,7 @@ def test_search_vault(server: FastMCP) -> None: assert {h["filename"] for h in by_tag} == {"Alpha.md", "Beta.md"} -def test_search_vault_is_bounded_and_pageable(server: FastMCP) -> None: +def test_search_vault_is_bounded_and_pageable(server: MCPServer) -> None: for number in range(7): call(server, "create-note", {"title": f"Page {number}", "summary": "shared"}) first = call(server, "search-vault", {"query": "shared", "limit": 2}) @@ -161,7 +164,7 @@ def test_search_vault_is_bounded_and_pageable(server: FastMCP) -> None: ) -def test_every_list_tool_is_bounded(server: FastMCP) -> None: +def test_every_list_tool_is_bounded(server: MCPServer) -> None: """No tool may return the whole vault in one result. `list-notes` used to: ~348 KB / 87k tokens on a 744-note vault, in a single @@ -195,7 +198,7 @@ def test_every_list_tool_is_bounded(server: FastMCP) -> None: assert page["count"] <= 1, tool -def test_search_hits_carry_an_excerpt_of_the_matched_text(server: FastMCP) -> None: +def test_search_hits_carry_an_excerpt_of_the_matched_text(server: MCPServer) -> None: """The excerpt is why a search result is often enough on its own — it shows the matched text even when the match is in a section `summary` never shows.""" call( @@ -209,7 +212,7 @@ def test_search_hits_carry_an_excerpt_of_the_matched_text(server: FastMCP) -> No assert hit["score"] > 0 -def test_recall_note_returns_one_bounded_representation(server: FastMCP) -> None: +def test_recall_note_returns_one_bounded_representation(server: MCPServer) -> None: call( server, "create-note", @@ -243,7 +246,7 @@ def test_recall_note_returns_one_bounded_representation(server: FastMCP) -> None assert section["section"] == "Details" -def test_help_tool_is_generated_from_live_cli(server: FastMCP) -> None: +def test_help_tool_is_generated_from_live_cli(server: MCPServer) -> None: result = call(server, "help", {"command": "/omind help ai usage"}) assert result["ok"] is True assert result["command"] == "omind ai usage" @@ -253,7 +256,7 @@ def test_help_tool_is_generated_from_live_cli(server: FastMCP) -> None: assert "usage" in unknown["error"] -def test_backlinks_and_tags(server: FastMCP) -> None: +def test_backlinks_and_tags(server: MCPServer) -> None: call(server, "create-note", {"title": "Hub", "summary": "s", "tags": ["one"]}) call(server, "create-note", {"title": "Spoke", "summary": "see [[Hub]]", "tags": ["two"]}) links = call(server, "backlinks", {"name": "Hub.md"})["result"] @@ -261,7 +264,7 @@ def test_backlinks_and_tags(server: FastMCP) -> None: assert call(server, "list-tags", {})["result"] == ["one", "two"] -def test_graph_tools(server: FastMCP) -> None: +def test_graph_tools(server: MCPServer) -> None: call(server, "create-note", {"title": "A", "summary": "s", "connections": ["B"]}) call(server, "create-note", {"title": "B", "summary": "s", "connections": ["C"]}) call(server, "create-note", {"title": "C", "summary": "s"}) @@ -282,7 +285,7 @@ def test_graph_tools(server: FastMCP) -> None: assert call(server, "graph", {"op": "stats"})["notes"] == 4 -def test_unified_graph_validates_operation_and_path_arguments(server: FastMCP) -> None: +def test_unified_graph_validates_operation_and_path_arguments(server: MCPServer) -> None: with pytest.raises(ToolError, match="one of"): call(server, "graph", {"op": "unknown"}) with pytest.raises(ToolError, match="requires source and target"): @@ -312,22 +315,22 @@ def counting(omi: Path) -> Any: assert calls["n"] == 2 # cache busted, rebuilt once -def test_graph_neighbors_unknown_note_is_a_tool_error(server: FastMCP) -> None: +def test_graph_neighbors_unknown_note_is_a_tool_error(server: MCPServer) -> None: with pytest.raises(ToolError, match="not found"): call(server, "graph-neighbors", {"name": "Nope"}) -def test_missing_note_is_a_tool_error(server: FastMCP) -> None: +def test_missing_note_is_a_tool_error(server: MCPServer) -> None: with pytest.raises(ToolError, match="not found"): call(server, "read-note", {"name": "Nope.md"}) -def test_traversal_is_a_tool_error(server: FastMCP) -> None: +def test_traversal_is_a_tool_error(server: MCPServer) -> None: with pytest.raises(ToolError, match="path separators"): call(server, "read-note", {"name": "../escape.md"}) -def test_writes_touch_the_sync_signal(server: FastMCP, omi_dir: Path) -> None: +def test_writes_touch_the_sync_signal(server: MCPServer, omi_dir: Path) -> None: signal = sync_signal_path(omi_dir) assert not signal.exists() call(server, "create-note", {"title": "Trigger", "summary": "s"}) @@ -337,7 +340,7 @@ def test_writes_touch_the_sync_signal(server: FastMCP, omi_dir: Path) -> None: assert signal.stat().st_mtime_ns >= first -def test_reads_do_not_touch_the_sync_signal(server: FastMCP, omi_dir: Path) -> None: +def test_reads_do_not_touch_the_sync_signal(server: MCPServer, omi_dir: Path) -> None: call(server, "create-note", {"title": "Quiet", "summary": "s"}) signal = sync_signal_path(omi_dir) signal.unlink() diff --git a/uv.lock b/uv.lock index d968adc..7193a73 100644 --- a/uv.lock +++ b/uv.lock @@ -1054,7 +1054,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1511,15 +1511,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - [[package]] name = "httpx2" version = "2.9.1" @@ -1861,15 +1852,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -1879,9 +1870,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -2392,7 +2396,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110,<1.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.0,<3.0" }, - { name = "mcp", specifier = ">=1.28.1,<2.0" }, + { name = "mcp", specifier = ">=2.0.0,<3.0" }, { name = "model2vec", marker = "extra == 'embed'", specifier = ">=0.3,<1.0" }, { name = "msgpack", marker = "extra == 'dev'", specifier = ">=1.2.1,<2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.9" }, @@ -2412,6 +2416,18 @@ requires-dist = [ ] provides-extras = ["e2e", "embed", "dev"] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6"