diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 3ffc28f..1ced7fa 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -14,6 +14,7 @@ from nitrostack.transforms import ( SyntheticTool, ToolArgumentError, TransformContext, validate_tool_arguments, ) +from nitrostack.protocol.errors import JsonRpcErrorCode import mcp.types as types from mcp import MCPError from mcp.server.context import ServerRequestContext @@ -954,7 +955,7 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict # Same source of truth as `NitroStackMcpServer.get_capabilities`, so # `server/discover` and real capability negotiation never disagree. resources_subscribe = supports_resource_subscriptions(self.protocol_era) - return build_discover_result( + result = build_discover_result( server_name=self.server_config.name, server_version=self.server_config.version, protocol_version=version, @@ -963,6 +964,26 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict advertise_app=has_widgets, custom_extensions=self._custom_extensions(), ) + meta = dict(result.get("_meta") or {}) + meta["io.modelcontextprotocol/serverInfo"] = { + "name": self.server_config.name, + "version": self.server_config.version, + } + result["_meta"] = meta + result.setdefault("resultType", "complete") + return result + + def _stamp_discover_meta(self, result: Dict[str, Any]) -> Dict[str, Any]: + """2026 puts serverInfo and resultType on the result, keeping any cache hint in _meta.""" + out = dict(result) + meta = dict(out.get("_meta") or {}) + meta["io.modelcontextprotocol/serverInfo"] = { + "name": self.server_config.name, + "version": self.server_config.version, + } + out["_meta"] = meta + out.setdefault("resultType", "complete") + return out def handle_sessionless_initialize(self, requested_version: Optional[str] = None) -> Dict[str, Any]: """Answer 2025 ``initialize`` on the sessionless ``auto`` path. No session.""" @@ -1022,6 +1043,11 @@ async def _list_tools( _meta=self._list_endpoint_cache_meta(), ) + async def _ping(_ctx: ServerRequestContext, _params: Optional[RequestParams]): + if self.protocol_era == "legacy": + return types.EmptyResult() + raise MCPError(types.METHOD_NOT_FOUND, "ping was removed in MCP 2026-07-28") + async def _call_tool(ctx: ServerRequestContext, params: CallToolRequestParams): token = request_ctx.set(ctx) try: @@ -1086,7 +1112,7 @@ async def _get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams) params.name, {str(key): str(value) for key, value in arguments.items()}, ) - + server.add_request_handler("ping", RequestParams, _ping) async def _discover(ctx: ServerRequestContext, _params: Optional[RequestParams]): return self.handle_server_discover(getattr(ctx, "protocol_version", None)) @@ -1646,6 +1672,17 @@ async def _call_real_tool(self, name: str, arguments: Dict[str, Any], task: Any trace = _trace_context_from_request_ctx(rc) is_task = task_metadata is not None + if self.protocol_era in ("auto", "modern") and is_task: + raise MCPError( + types.INVALID_PARAMS, + "Server does not support task augmentation on the MCP 2026 wire", + ) + if is_task and not getattr(self.mcp_server, "has_task_support", True): + raise MCPError( + JsonRpcErrorCode.MISSING_REQUIRED_CLIENT_CAPABILITY, + f"Tasks are not available on this protocol version; tool '{cfg.name}' " + "cannot be run as a task.", + ) if self.protocol_era in ("auto", "modern") and is_task: raise MCPError( types.INVALID_PARAMS, diff --git a/nitrostack/protocol/errors.py b/nitrostack/protocol/errors.py index 09d4bb0..5429260 100644 --- a/nitrostack/protocol/errors.py +++ b/nitrostack/protocol/errors.py @@ -12,6 +12,7 @@ class JsonRpcErrorCode(IntEnum): INVALID_PARAMS = -32602 INTERNAL_ERROR = -32603 HEADER_BODY_MISMATCH = -32020 + MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 UNSUPPORTED_PROTOCOL_VERSION = -32022 @@ -22,5 +23,6 @@ class JsonRpcErrorCode(IntEnum): JsonRpcErrorCode.INVALID_PARAMS: "Invalid params", JsonRpcErrorCode.INTERNAL_ERROR: "Internal error", JsonRpcErrorCode.HEADER_BODY_MISMATCH: "Header/body mismatch", + JsonRpcErrorCode.MISSING_REQUIRED_CLIENT_CAPABILITY: "Missing required client capability", JsonRpcErrorCode.UNSUPPORTED_PROTOCOL_VERSION: "Unsupported protocol version", } diff --git a/tests/test_mcp22_official_http_owner.py b/tests/test_mcp22_official_http_owner.py index 7e12f8c..26aa51c 100644 --- a/tests/test_mcp22_official_http_owner.py +++ b/tests/test_mcp22_official_http_owner.py @@ -78,7 +78,7 @@ def test_health_endpoint_remains_available(): assert response.json()["status"] == "ok" -def test_official_mcp_handles_ping_without_a_default_session_id(): +def test_official_mcp_ping_is_method_not_found_without_a_default_session_id(): response = _request( "/mcp", method="POST", @@ -86,7 +86,9 @@ def test_official_mcp_handles_ping_without_a_default_session_id(): ) assert response.status_code == 200 assert response.headers.get("mcp-session-id") is None - assert response.json() == {"jsonrpc": "2.0", "id": 1, "result": {}} + body = response.json() + assert body["error"]["code"] == -32601 + assert "result" not in body def test_official_mcp_returns_jsonrpc_error_for_unknown_method(): diff --git a/tests/test_mcp26_wire.py b/tests/test_mcp26_wire.py new file mode 100644 index 0000000..19ef35c --- /dev/null +++ b/tests/test_mcp26_wire.py @@ -0,0 +1,168 @@ +import asyncio +import json + +import mcp.types as types +import pytest +from mcp import MCPError +from pydantic import BaseModel +from starlette.testclient import TestClient + +from nitrostack import ExecutionContext, injectable, module, tool +from nitrostack.protocol.errors import JsonRpcErrorCode +from nitrostack.testing import NitroTestingModule + +MODERN_VERSION = "2026-07-28" + + +class In(BaseModel): + q: str = "" + + +@injectable() +class C: + @tool(name="alpha", description="first", input_schema=In, task_support="optional") + async def alpha(self, input: In, context: ExecutionContext) -> str: + return "a" + + +@module(name="m", controllers=[C]) +class M: + pass + + +def make_app(monkeypatch, era): + monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era) + monkeypatch.delenv("MCP_STATELESS", raising=False) + harness = asyncio.run(NitroTestingModule.create(M)) + return harness.app + + +def envelope(extra=None): + meta = { + "io.modelcontextprotocol/protocolVersion": MODERN_VERSION, + "io.modelcontextprotocol/clientCapabilities": {}, + } + params = dict(extra or {}) + params["_meta"] = {**params.get("_meta", {}), **meta} + return params + + +def rpc(client, method, params=None, *, headers=None, id_=1, envelope=True): + base = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + } + base.update(headers or {}) + p = dict(params or {}) + if envelope: + meta = dict(p.get("_meta") or {}) + meta.setdefault("io.modelcontextprotocol/protocolVersion", MODERN_VERSION) + meta.setdefault("io.modelcontextprotocol/clientCapabilities", {}) + p["_meta"] = meta + body = {"jsonrpc": "2.0", "id": id_, "method": method, "params": p} + return client.post("/mcp", content=json.dumps(body), headers=base) + +def body_of(resp): + ctype = resp.headers.get("content-type", "") + if "text/event-stream" in ctype: + for line in resp.text.splitlines(): + if line.startswith("data:"): + return json.loads(line[5:].strip()) + return {} + try: + return resp.json() + except ValueError: + return {} + + +def modern_headers(method, name=None): + h = {"MCP-Protocol-Version": MODERN_VERSION, "Mcp-Method": method} + if name: + h["Mcp-Name"] = name + return h + + +def error_code(payload): + return (payload.get("error") or {}).get("code") + + +def exc_code(exc): + err = getattr(exc, "error", None) + return getattr(err, "code", getattr(exc, "code", None)) + + +@pytest.mark.parametrize("era", ["modern", "auto"]) +def test_ping_is_method_not_found(monkeypatch, era): + app = make_app(monkeypatch, era) + with TestClient(app.get_combined_app()) as client: + resp = rpc(client, "ping", headers=modern_headers("ping")) + assert error_code(body_of(resp)) == -32601 + +def test_ping_still_answered_on_legacy(monkeypatch): + app = make_app(monkeypatch, "legacy") + app.protocol_era = "legacy" + + async def go(): + entry = app.mcp_server._request_handlers["ping"] + return await entry.handler(None, None) + + result = asyncio.run(go()) + assert isinstance(result, types.EmptyResult) + + +@pytest.mark.parametrize("era", ["modern", "auto"]) +def test_discover_carries_server_info_in_meta(monkeypatch, era): + app = make_app(monkeypatch, era) + with TestClient(app.get_combined_app()) as client: + resp = rpc(client, "server/discover", headers=modern_headers("server/discover")) + assert resp.status_code == 200 + assert "mcp-session-id" not in {k.lower() for k in resp.headers} + result = body_of(resp)["result"] + info = result["_meta"]["io.modelcontextprotocol/serverInfo"] + assert info["name"] and info["version"] + assert result["resultType"] == "complete" + + +def test_discover_keeps_cache_hint_in_meta(monkeypatch): + app = make_app(monkeypatch, "modern") + result = app.handle_server_discover() + assert "io.modelcontextprotocol/serverInfo" in result["_meta"] + assert len(result["_meta"]) >= 2 # serverInfo plus the existing cache hint + + +def test_initialize_is_not_served_on_modern(monkeypatch): + app = make_app(monkeypatch, "modern") + with TestClient(app.get_combined_app()) as client: + resp = rpc( + client, + "initialize", + {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}, + headers=modern_headers("initialize"), + ) + assert error_code(body_of(resp)) == -32601 + + +def test_tools_call_without_mcp_method_header_fails_closed(monkeypatch): + app = make_app(monkeypatch, "modern") + with TestClient(app.get_combined_app()) as client: + resp = rpc( + client, + "tools/call", + {"name": "alpha", "arguments": {}}, + headers={"MCP-Protocol-Version": MODERN_VERSION}, + ) + assert resp.status_code >= 400 or error_code(body_of(resp)) == -32020 + + +def test_missing_client_capability_code_is_defined(): + assert -32021 in {int(member.value) for member in JsonRpcErrorCode} + + + +def test_task_call_still_works_when_tasks_available(monkeypatch): + app = make_app(monkeypatch, "legacy") + + async def go(): + return await app._call_tool("alpha", {}, task=types.TaskMetadata(ttl=1000)) + + assert isinstance(asyncio.run(go()), types.CreateTaskResult)