From 18db8585f78dd529476895c6784c9c6c5c7f7a10 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 15 Sep 2026 22:51:00 +0530 Subject: [PATCH 01/10] fix: unify HTTP factory - single CORS, subscribe-flag agreement, public stdio APIs --- nitrostack/core/app.py | 4 + nitrostack/core/mcp_server.py | 6 +- nitrostack/protocol/discovery.py | 10 +- nitrostack/transports/http.py | 24 +++- nitrostack/transports/stdio.py | 39 ++++-- tests/test_mcp20_stdio.py | 5 +- tests/test_mcp23_http_factory.py | 222 +++++++++++++++++++++++++++++++ 7 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 tests/test_mcp23_http_factory.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 255d504..0f6c0df 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -869,10 +869,14 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict getattr(self, "protocol_era", None), self.server_config.protocol_version, ) + # Same source of truth as `NitroStackMcpServer.get_capabilities`, so + # `server/discover` and real capability negotiation never disagree. + resources_subscribe = getattr(self, "protocol_era", None) != "modern" return build_discover_result( server_name=self.server_config.name, server_version=self.server_config.version, protocol_version=version, + resources_subscribe=resources_subscribe, advertise_tasks=self._advertise_tasks_extension(), advertise_app=has_widgets, custom_extensions=self._custom_extensions(), diff --git a/nitrostack/core/mcp_server.py b/nitrostack/core/mcp_server.py index b383ff2..926bcc4 100644 --- a/nitrostack/core/mcp_server.py +++ b/nitrostack/core/mcp_server.py @@ -33,6 +33,7 @@ def __init__(self, name: str, version: Optional[str] = None): self.has_task_support: bool = False self.http_engine: Optional[str] = None self.sessionful: bool = False + self.protocol_era: Optional[str] = None self.discover_handler: Optional[Callable[[], dict[str, Any]]] = None self.initialize_handler: Optional[Callable[[Optional[str]], dict[str, Any]]] = None # In-process tests still look up handlers by request type. @@ -67,7 +68,10 @@ def get_capabilities( ) if caps.resources is not None: - caps.resources.subscribe = True + # `resources/subscribe` is only rejected on `modern` + # (see `rejects_deprecated_method`); `legacy` and `auto` both serve + # it live, so this must track era, not the sessionful/sessionless split. + caps.resources.subscribe = self.protocol_era != "modern" if self.has_task_support: caps.tasks = types.ServerTasksCapability( diff --git a/nitrostack/protocol/discovery.py b/nitrostack/protocol/discovery.py index d15fc0a..4360261 100644 --- a/nitrostack/protocol/discovery.py +++ b/nitrostack/protocol/discovery.py @@ -36,11 +36,17 @@ def build_discover_result( custom_extensions: Optional[dict[str, str]] = None, tools_list_changed: bool = True, resources_list_changed: bool = True, + resources_subscribe: bool = False, prompts_list_changed: bool = True, ttl_ms: int = DEFAULT_LIST_CACHE_TTL_MS, cache_scope: Literal["public", "private"] = "private", ) -> dict[str, Any]: - """Build the ``server/discover`` result for the mounted HTTP engine.""" + """Build the ``server/discover`` result for the mounted HTTP engine. + + ``resources_subscribe`` must match ``NitroStackMcpServer.get_capabilities`` + for the same era, so ``server/discover`` and real capability negotiation + never disagree on ``resources.subscribe``. + """ versions = list(supported_versions or SUPPORTED_PROTOCOL_VERSIONS) extensions: dict[str, dict[str, str]] = {} if advertise_app: @@ -53,7 +59,7 @@ def build_discover_result( capabilities: dict[str, Any] = { "tools": {"listChanged": tools_list_changed}, - "resources": {"subscribe": False, "listChanged": resources_list_changed}, + "resources": {"subscribe": resources_subscribe, "listChanged": resources_list_changed}, "prompts": {"listChanged": prompts_list_changed}, } if extensions: diff --git a/nitrostack/transports/http.py b/nitrostack/transports/http.py index 7d958fb..ebffa0c 100644 --- a/nitrostack/transports/http.py +++ b/nitrostack/transports/http.py @@ -34,6 +34,7 @@ from starlette.applications import Starlette from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.routing import Mount, Route from starlette.types import ASGIApp, Receive, Scope, Send @@ -41,6 +42,7 @@ from nitrostack.transports.landing_page import render_landing_page from nitrostack.widgets.preview_page import render_preview_page from nitrostack.core.di import DIContainer +from nitrostack.transports.cors import configured_cors_origins from pydantic_core import PydanticUndefined from mcp.server.sse import SseServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager @@ -56,10 +58,10 @@ from nitrostack.protocol.constants import LEGACY_SESSION_HEADER from nitrostack.transports.headers import ( CORS_ALLOW_HEADER_NAMES, + CORS_EXPOSE_HEADER_NAMES, SSE_SUBSCRIPTIONS_PATH, strip_legacy_session_headers_asgi, ) -from nitrostack.transports.cors import McpCorsMiddleware from nitrostack.transports.subscriptions import subscriptions_listen_endpoint from nitrostack.transports.proxy import public_url_for_request from nitrostack.core.log_emitter import install_log_handler @@ -490,6 +492,7 @@ def build_http_app( if server is not None: server.http_engine = http_engine server.sessionful = http_engine == "sessionful" + server.protocol_era = protocol_era security_settings = None if not enable_cors: @@ -662,7 +665,7 @@ async def json_version(request): return JSONResponse( { "Browser": meta["name"], - "Protocol-Version": "2025-06-18", + "Protocol-Version": protocol_version_for_era(protocol_era), "User-Agent": f"NitroStack/{meta['version']}", "webSocketDebuggerUrl": "", "transport": "mcp", @@ -741,14 +744,21 @@ async def lifespan(app): Middleware(ExactEndpointSlashMiddleware, endpoint=endpoint), ] if enable_cors: - # CORS policy lives in transports/cors.py (MCP_CORS_ALLOWED_ORIGINS, Mcp-* headers). - # Mcp-Session-Id is exposed only on the sessionful (legacy) engine. + expose_headers = list(CORS_EXPOSE_HEADER_NAMES) + if http_engine == "sessionful": + expose_headers.append(LEGACY_SESSION_HEADER) + # Single CORS layer for `/mcp`: the sidecar that used to add a second + # one has been removed (#22), so `MCP_CORS_ALLOWED_ORIGINS` is honored + # here and nowhere else. + allow_origins = list(configured_cors_origins()) or ["*"] middleware.insert( 0, Middleware( - McpCorsMiddleware, - expose_session_id=http_engine == "sessionful", - fallback_allow_headers=CORS_ALLOW_HEADERS, + CORSMiddleware, + allow_origins=allow_origins, + allow_methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_headers=CORS_ALLOW_HEADERS, + expose_headers=expose_headers, ), ) diff --git a/nitrostack/transports/stdio.py b/nitrostack/transports/stdio.py index d76e580..f6fe5cf 100644 --- a/nitrostack/transports/stdio.py +++ b/nitrostack/transports/stdio.py @@ -64,15 +64,37 @@ async def serve_stdio_streams( async with server.lifespan(server) as lifespan_state: try: if era == "modern": - from mcp.server.runner import _serve_modern_stream - - await _serve_modern_stream( - server, - read_stream, - write_stream, - lifespan_state=lifespan_state, - raise_exceptions=False, + # A per-request envelope loop (no `initialize` handshake) built from + # documented, public `mcp.server.runner` building blocks, so this does + # not depend on a leading-underscore internal that mcp is free to + # remove without notice. + from mcp.server.runner import ( + Connection, + JSONRPCDispatcher, + LATEST_MODERN_VERSION, + NotifyOnlyOutbound, + ServerRunner, + aclose_shielded, + modern_on_request, ) + + dispatcher: JSONRPCDispatcher = JSONRPCDispatcher(read_stream, write_stream) + outbound = NotifyOnlyOutbound(dispatcher) + + async def _on_notify(dctx, method, params): + # Fresh per-notification `Connection`, mirroring the request path: + # notifications carry no envelope of their own at this era. + connection = Connection.from_envelope( + LATEST_MODERN_VERSION, None, None, outbound=outbound + ) + try: + await ServerRunner(server, connection, lifespan_state).on_notify( + dctx, method, params + ) + finally: + await aclose_shielded(connection) + + await dispatcher.run(modern_on_request(server, lifespan_state), _on_notify) else: await serve_loop( server, @@ -87,5 +109,6 @@ async def serve_stdio_streams( async def run_stdio(server: "Server[Any]", era: ProtocolEra) -> None: """Serve official mcp 2.x on process stdin/stdout for the active era.""" + server.protocol_era = era async with stdio_server() as (read_stream, write_stream): await serve_stdio_streams(server, read_stream, write_stream, era) diff --git a/tests/test_mcp20_stdio.py b/tests/test_mcp20_stdio.py index 94a4b23..68705a4 100644 --- a/tests/test_mcp20_stdio.py +++ b/tests/test_mcp20_stdio.py @@ -135,8 +135,11 @@ def test_modern_stdio_rejects_initialize(monkeypatch): }, ) ) + # Modern has no `initialize` handshake handler at all (rejected via the + # public `modern_on_request` dispatch path, not a bespoke error message), + # so this surfaces as a standard JSON-RPC method-not-found. assert "error" in payload - assert "initialize" in payload["error"]["message"] + assert payload["error"]["code"] == -32601 def test_legacy_stdio_still_answers_initialize(monkeypatch): diff --git a/tests/test_mcp23_http_factory.py b/tests/test_mcp23_http_factory.py new file mode 100644 index 0000000..82e6b6a --- /dev/null +++ b/tests/test_mcp23_http_factory.py @@ -0,0 +1,222 @@ +"""Regression tests for Issue #23: single CORS layer, `resources.subscribe` +agreement between discover and real capability negotiation, era-driven +`/json/version`, and no private `mcp.server.runner` imports in stdio. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path + +import anyio +import httpx +import pytest +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp_types import PROTOCOL_VERSION_META_KEY, jsonrpc_message_adapter +from pydantic import BaseModel, Field + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import DIContainer, ExecutionContext, injectable, module, tool +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.protocol.version import LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION +from nitrostack.transports.stdio import serve_stdio_streams + +CLIENT_META = { + "io.modelcontextprotocol/clientInfo": {"name": "issue23-test", "version": "1.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, +} + + +def setup_function() -> None: + DIContainer.reset() + + +def teardown_function() -> None: + DIContainer.reset() + + +class EchoInput(BaseModel): + value: str = Field(default="ok") + + +def _era_app(era: str, name: str): + @injectable() + class EchoController: + @tool(name="echo", description="echo", input_schema=EchoInput) + async def echo(self, input: EchoInput, context: ExecutionContext) -> str: + return input.value + + @module(name=f"issue23_{name}", controllers=[EchoController]) + class EchoModule: + pass + + @mcp_app(module=EchoModule, server=ServerConfig(name=name, protocol_era=era)) + class EchoApp: + pass + + return asyncio.run(McpApplicationFactory.create(EchoApp)) + + +async def _send(http_app, method: str, path: str, *, headers: dict | None = None, payload: dict | None = None): + transport = httpx.ASGITransport(app=http_app) + async with http_app.router.lifespan_context(http_app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.request( + method, + path, + headers=headers or {}, + content=json.dumps(payload) if payload is not None else None, + ) + + +# 1. OPTIONS /mcp: exactly one CORS policy, not two fighting layers. +def test_single_cors_layer_on_options_mcp(): + app = _era_app("auto", "issue23-cors") + http_app = app.get_combined_app(json_response=True) + response = asyncio.run( + _send( + http_app, + "OPTIONS", + "/mcp", + headers={ + "Origin": "http://example.com", + "Access-Control-Request-Method": "POST", + }, + ) + ) + allow_origin = response.headers.get_list("access-control-allow-origin") + assert len(allow_origin) == 1 + + +# 2. server/discover and NitroStackMcpServer.get_capabilities must never disagree. +@pytest.mark.parametrize("era", ["legacy", "auto", "modern"]) +def test_discover_and_capabilities_agree_on_resources_subscribe(era): + app = _era_app(era, f"issue23-subscribe-{era}") + app.get_combined_app(json_response=True) # wires protocol_era onto mcp_server + discover = app.handle_server_discover() + caps = app.mcp_server.get_capabilities() + assert discover["capabilities"]["resources"]["subscribe"] == caps.resources.subscribe + # Only `modern` rejects `resources/subscribe` (see rejects_deprecated_method). + assert caps.resources.subscribe == (era != "modern") + + +# 3. No private `mcp.server.runner` symbol left in stdio.py. +def test_stdio_module_has_no_private_mcp_runner_import(): + source = Path("nitrostack/transports/stdio.py").read_text(encoding="utf-8") + assert "_serve_modern_stream" not in source + + +# 4. /json/version tracks era instead of a hardcoded 2025 date. +@pytest.mark.parametrize( + "era, expected_version", + [ + ("legacy", LEGACY_PROTOCOL_VERSION), + ("auto", MODERN_PROTOCOL_VERSION), + ("modern", MODERN_PROTOCOL_VERSION), + ], +) +def test_json_version_matches_era(era, expected_version): + app = _era_app(era, f"issue23-jsonversion-{era}") + http_app = app.get_combined_app(json_response=True) + response = asyncio.run(_send(http_app, "GET", "/json/version")) + assert response.status_code == 200 + assert response.json()["Protocol-Version"] == expected_version + + +# 5. Modern-era stdio boots and serves real requests without the removed +# private import, via the public JSONRPCDispatcher/modern_on_request path. +def test_modern_stdio_serves_tool_call(): + app = _era_app("modern", "issue23-modern-stdio") + + async def roundtrip(): + async with create_client_server_memory_streams() as (client, server): + client_read, client_write = client + server_read, server_write = server + + async def run_server(): + await serve_stdio_streams(app.mcp_server, server_read, server_write, "modern") + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + await client_write.send( + SessionMessage( + jsonrpc_message_adapter.validate_python( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "echo", + "arguments": {"value": "hi"}, + "_meta": {PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSION, **CLIENT_META}, + }, + } + ) + ) + ) + response = await asyncio.wait_for(client_read.receive(), timeout=2) + tg.cancel_scope.cancel() + return response.message.model_dump(by_alias=True, mode="json") + + payload = asyncio.run(roundtrip()) + assert "result" in payload + assert payload["result"]["content"][0]["text"] == "hi" + + +# 5b. The hand-built on_notify path (no public one-liner exists for it) must +# not crash or hang the dispatcher — proven by a normal request still working +# on the same stream right after a notification. +def test_modern_stdio_notification_does_not_break_the_stream(): + app = _era_app("modern", "issue23-modern-notify") + + async def roundtrip(): + async with create_client_server_memory_streams() as (client, server): + client_read, client_write = client + server_read, server_write = server + + async def run_server(): + await serve_stdio_streams(app.mcp_server, server_read, server_write, "modern") + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + await client_write.send( + SessionMessage( + jsonrpc_message_adapter.validate_python( + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { + "requestId": 999, + "_meta": {PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSION}, + }, + } + ) + ) + ) + await client_write.send( + SessionMessage( + jsonrpc_message_adapter.validate_python( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "echo", + "arguments": {"value": "still-alive"}, + "_meta": {PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSION, **CLIENT_META}, + }, + } + ) + ) + ) + response = await asyncio.wait_for(client_read.receive(), timeout=2) + tg.cancel_scope.cancel() + return response.message.model_dump(by_alias=True, mode="json") + + payload = asyncio.run(roundtrip()) + assert payload["result"]["content"][0]["text"] == "still-alive" From 005e167615090eabd9ba41d6f7c90f49ec35ac4c Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 27 Oct 2026 18:46:54 +0530 Subject: [PATCH 02/10] test: migrate sidecar coverage to official MCP HTTP handlers --- tests/http_probe.py | 106 ++ tests/test_mcp20_blueprint.py | 55 +- tests/test_mcp20_deprecated.py | 195 +--- tests/test_mcp20_jsonrpc_wire.py | 40 +- tests/test_mcp20_stateless_http.py | 1614 ++-------------------------- 5 files changed, 312 insertions(+), 1698 deletions(-) create mode 100644 tests/http_probe.py diff --git a/tests/http_probe.py b/tests/http_probe.py new file mode 100644 index 0000000..8679f19 --- /dev/null +++ b/tests/http_probe.py @@ -0,0 +1,106 @@ +"""Shared probes of the actual official MCP HTTP mount (no sidecar doubles).""" + +import asyncio +import json +import os +from contextlib import contextmanager +from unittest.mock import patch + +from mcp_types import PROTOCOL_VERSION_META_KEY +from pydantic import BaseModel +from starlette.testclient import TestClient + +from nitrostack import DIContainer, module, resource, tool +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.protocol.version import LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION + + +class EchoInput(BaseModel): + value: str = "ok" + + +def make_app(era="auto"): + """Resolve era deterministically without leaking process environment or DI.""" + DIContainer.reset() + + class ProbeTools: + @tool(name="echo", description="Echo and expose verified context", input_schema=EchoInput, + task_support="optional") + async def echo(self, input, context): + return {"value": input.value, "user": context.user, + "protocol_version": context.protocol_version, + "mirrored": context.mcp_param_headers} + + @resource(uri="probe://info", name="Probe", description="Readable probe") + async def info(self, context): + return "probe resource" + + @module(name="http-probe", providers=[ProbeTools]) + class ProbeModule: + pass + + @mcp_app(module=ProbeModule, server=ServerConfig(name="http-probe", protocol_era=era)) + class ProbeApp: + pass + + with patch.dict(os.environ, {"NITRO_MCP_PROTOCOL_VERSION": era, "MCP_STATELESS": ""}): + return asyncio.run(McpApplicationFactory.create(ProbeApp)) + + +def envelope(method="tools/list", params=None, *, version=MODERN_PROTOCOL_VERSION, modern=True): + params = dict(params or {}) + if modern: + params["_meta"] = { + PROTOCOL_VERSION_META_KEY: version, + "io.modelcontextprotocol/clientInfo": {"name": "http-probe", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": {}, + **params.get("_meta", {}), + } + return {"jsonrpc": "2.0", "id": "probe", "method": method, "params": params} + + +def headers(method="tools/list", *, name=None, version=MODERN_PROTOCOL_VERSION): + result = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": version, "Mcp-Method": method} + if name is not None: + result["Mcp-Name"] = name + return result + + +def decode(response): + """Handle JSON or a completed request's SSE response explicitly.""" + if "text/event-stream" in response.headers.get("content-type", ""): + messages = [json.loads(line[5:].strip()) for line in response.text.splitlines() + if line.startswith("data:")] + return next(message for message in messages if "id" in message) + return response.json() + + +def post(client, method="tools/list", params=None, *, modern=True, extra_headers=None, + version=MODERN_PROTOCOL_VERSION): + return client.post("/mcp", json=envelope(method, params, modern=modern, version=version), + headers={**headers(method, name=(params or {}).get("name") or (params or {}).get("uri"), + version=version), **(extra_headers or {})}) + + +@contextmanager +def http_client(era="auto", *, app=None, json_response=True, **options): + app = app or make_app(era) + http = app.get_combined_app(json_response=json_response, **options) + try: + with TestClient(http) as client: + if era == "legacy": + response = post(client, "initialize", { + "protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {}, + "clientInfo": {"name": "legacy-probe", "version": "1"}, + }, modern=False, version=LEGACY_PROTOCOL_VERSION) + assert response.status_code == 200, response.text + assert "result" in decode(response), response.text + session = response.headers.get("mcp-session-id") + assert session + client.headers["mcp-session-id"] = session + client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + headers=headers("notifications/initialized", version=LEGACY_PROTOCOL_VERSION)) + yield app, http, client + finally: + DIContainer.reset() diff --git a/tests/test_mcp20_blueprint.py b/tests/test_mcp20_blueprint.py index 98645a4..66275fb 100644 --- a/tests/test_mcp20_blueprint.py +++ b/tests/test_mcp20_blueprint.py @@ -3,17 +3,15 @@ from __future__ import annotations import asyncio -import json import os import sys from unittest.mock import patch import mcp.types as types import pytest -from nitrostack.runtime.request_ctx import Experimental, RequestContext, RequestParamsMeta, request_ctx +from nitrostack.runtime.request_ctx import Experimental, RequestContext, request_ctx from mcp import MCPError as McpError from pydantic import BaseModel, Field -from starlette.testclient import TestClient sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -23,7 +21,7 @@ from nitrostack.core.di import DIContainer from nitrostack.core.errors import ResourceNotFoundError, TaskNotFoundError from nitrostack.core.task import TaskManager -from nitrostack.protocol.constants import LEGACY_SESSION_HEADER, MAX_CIMD_BYTES +from nitrostack.protocol.constants import MAX_CIMD_BYTES from nitrostack.protocol.deprecated import deprecated_method_message from nitrostack.protocol.discovery import build_discover_result from nitrostack.protocol.errors import JsonRpcErrorCode @@ -40,11 +38,23 @@ protocol_version_matches_blueprint, verify_package_layout, ) -from nitrostack.runtime.stateless import assert_stateless_headers from nitrostack.tasks.eviction import should_evict_terminal_task from nitrostack.tasks.types import TaskAccessContext, TaskEntry, TaskWireData, datetime_to_ms, utc_now from nitrostack.tasks.authorization import check_task_access from nitrostack.transports.cors import cors_preflight_response_headers +from tests.http_probe import decode, http_client, post + + +def test_blueprint_discover_uses_official_http_without_session(): + with http_client("auto") as (app, _, client): + response = post(client, "server/discover") + assert response.status_code == 200, response.text + result = decode(response)["result"] + assert MODERN_PROTOCOL_VERSION in result["supportedVersions"] + assert result["capabilities"]["resources"]["subscribe"] is False + assert result["capabilities"]["tools"]["listChanged"] is True + assert "mcp-session-id" not in response.headers + assert app.mcp_server.get_capabilities().resources.subscribe is False class EchoInput(BaseModel): @@ -95,36 +105,11 @@ def test_discover_advertises_modern_protocol_and_extensions(self): def test_post_mcp_stateless_no_session_header(self): - @injectable() - class PingController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="BlueprintHttp", controllers=[PingController]) - class HttpModule: - pass - - @mcp_app(module=HttpModule, server=ServerConfig(name="blueprint-http", stateless=True)) - class HttpApp: - pass - - app = asyncio.run(McpApplicationFactory.create(HttpApp)) - http_app = app.get_combined_app(stateless=True, json_response=True) - with TestClient(http_app) as client: - resp = client.post( - "/mcp", - headers={**JSON_HEADERS, "Mcp-Method": "tools/call", "Mcp-Name": "echo"}, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "ok"}}, - }, - ) - assert resp.status_code == 200 - assert LEGACY_SESSION_HEADER not in {k.title() for k in resp.headers.keys()} - assert resp.json()["result"]["content"][0]["text"] == "ok" + with http_client("auto") as (_, _, client): + response = post(client, "tools/call", {"name": "echo", "arguments": {"value": "ok"}}) + assert response.status_code == 200, response.text + assert "mcp-session-id" not in response.headers + assert decode(response)["result"]["structuredContent"]["value"] == "ok" def test_cors_preflight_includes_mcp_headers(self): headers = cors_preflight_response_headers({"Origin": "https://app.example.com"}) diff --git a/tests/test_mcp20_deprecated.py b/tests/test_mcp20_deprecated.py index e637ef7..33e6495 100644 --- a/tests/test_mcp20_deprecated.py +++ b/tests/test_mcp20_deprecated.py @@ -1,77 +1,14 @@ -"""Deprecated-method policy is the same on every modern entry point.""" - -from __future__ import annotations +"""Retired methods are rejected by the official handler and HTTP transport.""" import asyncio -import json -import os -import sys import mcp.types as types import pytest -from mcp import MCPError as McpError -from pydantic import BaseModel, Field -from starlette.testclient import TestClient - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from mcp import MCPError -from nitrostack import ExecutionContext, injectable, module, tool -from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from tests.http_probe import decode, http_client, make_app, post from nitrostack.core.di import DIContainer -from nitrostack.protocol.deprecated import ( - DEPRECATED_MODERN_METHODS, - deprecated_method_message, - rejects_deprecated_method, -) -from nitrostack.protocol.errors import JsonRpcErrorCode -from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION - -INITIALIZE_BODY = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, -} - -JSON_HEADERS = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", -} - - -class EchoInput(BaseModel): - value: str = Field(default="") - - -def setup_function() -> None: - DIContainer.reset() - - -def teardown_function() -> None: - DIContainer.reset() - - -def _echo_http_app(era: str): - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name=f"Deprecated{era.title()}", controllers=[EchoController]) - class EchoModule: - pass - - @mcp_app(module=EchoModule, server=ServerConfig(name=f"deprecated-{era}", protocol_era=era)) - class EchoApp: - pass - - app = asyncio.run(McpApplicationFactory.create(EchoApp)) - return app, app.get_combined_app(json_response=True) +from nitrostack.protocol.deprecated import DEPRECATED_MODERN_METHODS, deprecated_method_message, rejects_deprecated_method def test_initialize_is_not_in_retired_method_table(): @@ -81,96 +18,56 @@ def test_initialize_is_not_in_retired_method_table(): @pytest.mark.parametrize("method", sorted(DEPRECATED_MODERN_METHODS)) -def test_retired_methods_error_only_on_modern(method: str): - assert rejects_deprecated_method(method, "modern") is True - assert rejects_deprecated_method(method, "auto") is False - assert rejects_deprecated_method(method, "legacy") is False - - -async def _asgi_json_get(app, headers: dict[str, str]) -> tuple[int, dict]: - status = 0 - chunks: list[bytes] = [] - sent_request = False - finished = asyncio.Event() - - async def receive(): - nonlocal sent_request - if not sent_request: - sent_request = True - return {"type": "http.request", "body": b"", "more_body": False} - await finished.wait() - return {"type": "http.disconnect"} - - async def send(message): - nonlocal status - if message["type"] == "http.response.start": - status = message["status"] - elif message["type"] == "http.response.body": - chunks.append(message.get("body") or b"") - if not message.get("more_body", False): - finished.set() - - scope = { - "type": "http", - "asgi": {"version": "3.0"}, - "http_version": "1.1", - "method": "GET", - "scheme": "http", - "path": "/mcp", - "raw_path": b"/mcp", - "query_string": b"", - "headers": [ - (key.lower().encode("latin-1"), value.encode("latin-1")) - for key, value in headers.items() - ], - "client": ("testclient", 50000), - "server": ("test", 80), - } - - async def _http(): - await app(scope, receive, send) - await asyncio.wait_for(finished.wait(), timeout=2) - return status, json.loads(b"".join(chunks) or b"{}") - - started = asyncio.Event() - messages: asyncio.Queue = asyncio.Queue() - await messages.put({"type": "lifespan.startup"}) - - async def life_receive(): - return await messages.get() - - async def life_send(message): - if message["type"] == "lifespan.startup.complete": - started.set() - - task = asyncio.create_task(app({"type": "lifespan"}, life_receive, life_send)) - await asyncio.wait_for(started.wait(), timeout=2) - try: - return await _http() - finally: - await messages.put({"type": "lifespan.shutdown"}) - await asyncio.wait_for(task, timeout=2) - - -def test_auto_official_handler_still_lists_tasks(monkeypatch): - monkeypatch.delenv("NITRO_MCP_PROTOCOL_VERSION", raising=False) - monkeypatch.delenv("MCP_STATELESS", raising=False) - app, _ = _echo_http_app("auto") +def test_retired_method_policy(method): + assert rejects_deprecated_method(method, "modern") + assert not rejects_deprecated_method(method, "auto") + assert not rejects_deprecated_method(method, "legacy") + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +@pytest.mark.parametrize("method", ["resources/subscribe", "resources/unsubscribe"]) +@pytest.mark.parametrize("modern", [True, False]) +def test_sessionless_resource_subscriptions_are_rejected(era, method, modern): + with http_client(era) as (_, _, client): + response = post(client, method, {"uri": "probe://info"}, modern=modern, + version="2026-07-28" if modern else "2025-06-18") + assert response.status_code == (404 if modern else 200), response.text + error = decode(response)["error"] + assert error["code"] == (types.METHOD_NOT_FOUND if modern or era == "auto" else -32022) + + +@pytest.mark.parametrize("method", ["resources/subscribe", "resources/unsubscribe"]) +def test_legacy_resource_subscription_compatibility(method): + with http_client("legacy") as (_, _, client): + response = post(client, method, {"uri": "probe://info"}, modern=False, version="2025-06-18") + assert response.status_code == 200, response.text + assert decode(response)["result"] == {} + + +@pytest.mark.parametrize("method", ["tasks/list", "tasks/result", "logging/setLevel"]) +def test_modern_retired_methods_reach_official_dispatch(method): + params = {"tasks/list": {}, "tasks/result": {"taskId": "missing"}, "logging/setLevel": {"level": "info"}}[method] + with http_client("modern") as (_, _, client): + response = post(client, method, params) + assert response.status_code == 404, response.text + assert decode(response)["error"]["code"] == types.METHOD_NOT_FOUND + + +def test_auto_official_handler_still_lists_tasks(): + app = make_app("auto") try: handler = app.mcp_server.request_handlers[types.ListTasksRequest] - result = asyncio.run(handler(types.ListTasksRequest(method="tasks/list", params={}))) - assert result.tasks == [] + response = asyncio.run(handler(types.ListTasksRequest(method="tasks/list", params={}))) + assert response.tasks == [] finally: DIContainer.reset() -def test_modern_official_handler_rejects_tasks_list(monkeypatch): - monkeypatch.delenv("NITRO_MCP_PROTOCOL_VERSION", raising=False) - monkeypatch.delenv("MCP_STATELESS", raising=False) - app, _ = _echo_http_app("modern") +def test_modern_official_handler_rejects_tasks_list(): + app = make_app("modern") try: handler = app.mcp_server.request_handlers[types.ListTasksRequest] - with pytest.raises(McpError) as exc: + with pytest.raises(MCPError) as exc: asyncio.run(handler(types.ListTasksRequest(method="tasks/list", params={}))) assert exc.value.error.code == types.METHOD_NOT_FOUND assert exc.value.error.message == deprecated_method_message("tasks/list") diff --git a/tests/test_mcp20_jsonrpc_wire.py b/tests/test_mcp20_jsonrpc_wire.py index 1f368cc..f602018 100644 --- a/tests/test_mcp20_jsonrpc_wire.py +++ b/tests/test_mcp20_jsonrpc_wire.py @@ -1,6 +1,5 @@ """Tests for MCP 2.0 JSON-RPC wire specification.""" -import asyncio import json import pytest @@ -31,6 +30,45 @@ split_params_and_meta, ) from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION +from tests.http_probe import decode, http_client, post + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_official_http_rejects_unknown_protocol_header(era): + with http_client(era) as (_, _, client): + response = post(client, version="1999-01-01") + assert response.status_code == 400, response.text + assert "result" not in decode(response) + + +@pytest.mark.parametrize("era,version", [ + ("auto", "2025-06-18"), ("auto", "2025-11-25"), ("auto", "2026-07-28"), + ("modern", "2026-07-28"), +]) +def test_official_http_accepts_supported_protocol(era, version): + with http_client(era) as (_, _, client): + response = post(client, modern=version == MODERN_PROTOCOL_VERSION, version=version) + assert response.status_code == 200, response.text + assert decode(response)["result"]["tools"][0]["name"] == "echo" + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +@pytest.mark.parametrize("wrong_header", [{"Mcp-Method": "tools/list"}, {"Mcp-Name": "wrong"}]) +def test_official_http_checks_method_and_name_headers(era, wrong_header): + with http_client(era) as (_, _, client): + response = post(client, "tools/call", {"name": "echo", "arguments": {}}, + extra_headers=wrong_header) + assert response.status_code == 400, response.text + assert decode(response)["error"]["code"] == HEADER_BODY_MISMATCH + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_protocol_header_and_envelope_must_agree_in_official_runner(era): + with http_client(era) as (_, _, client): + response = post(client, extra_headers={"MCP-Protocol-Version": "2025-06-18"}) + # The 2025 header selects the SDK's legacy HTTP error mapping. + assert response.status_code == 200, response.text + assert decode(response)["error"]["code"] == HEADER_BODY_MISMATCH class TestErrorCodes: diff --git a/tests/test_mcp20_stateless_http.py b/tests/test_mcp20_stateless_http.py index 43d4ee2..87a178d 100644 --- a/tests/test_mcp20_stateless_http.py +++ b/tests/test_mcp20_stateless_http.py @@ -1,13 +1,12 @@ """Tests for MCP 2.0 stateless HTTP transport.""" -import asyncio import json import pytest from nitrostack.protocol.constants import LEGACY_SESSION_HEADER from nitrostack.protocol.contracts import MCP_CACHE_HINT_KEY -from nitrostack.protocol.discovery import DISCOVER_RESULT_TYPE, SERVER_DISCOVER_METHOD, build_discover_result +from nitrostack.protocol.discovery import DISCOVER_RESULT_TYPE, build_discover_result from nitrostack.protocol.jsonrpc import ( HEADER_BODY_MISMATCH, PARSE_ERROR, @@ -16,21 +15,14 @@ parse_jsonrpc_request, validate_header_body_method, ) -from nitrostack.protocol.method_contract import ( - MODERN_METHOD_CONTRACTS, - mcp_name_field, - mcp_name_is_required, -) from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION from nitrostack.transports.cors import build_cors_headers, cors_preflight_response_headers, resolve_allowed_origin from nitrostack.transports.headers import ( MAX_MCP_PARAM_VALUE_BYTES, - build_mcp_echo_headers, build_mcp_response_headers, build_sse_stream_headers, extract_mcp_param_headers, first_oversized_mcp_param, - handled_protocol_version, merge_mcp_param_headers, scope_with_header_snapshot, scope_without_session_headers, @@ -39,6 +31,106 @@ strip_legacy_session_headers_asgi, ) from nitrostack.transports.sse import format_sse_message, sse_notification +from tests.http_probe import decode, envelope, headers, http_client, post + + +@pytest.mark.parametrize("era", ["legacy", "auto", "modern"]) +@pytest.mark.parametrize("json_response", [True, False]) +def test_official_transport_tool_and_resource_roundtrip(era, json_response): + version = "2025-06-18" if era == "legacy" else MODERN_PROTOCOL_VERSION + with http_client(era, json_response=json_response) as (_, http, client): + assert http.state.streamable_http_manager_count == 1 + assert http.state.sessionful == (era == "legacy") + response = post(client, "tools/call", {"name": "echo", "arguments": {"value": "wire"}}, + modern=era != "legacy", version=version) + assert response.status_code == 200, response.text + assert decode(response)["result"]["structuredContent"]["value"] == "wire" + response = post(client, "resources/read", {"uri": "probe://info"}, + modern=era != "legacy", version=version) + assert response.status_code == 200, response.text + assert decode(response)["result"]["contents"][0]["text"] == "probe resource" + if era != "legacy": + assert "mcp-session-id" not in response.headers + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +@pytest.mark.parametrize("body", [b"", b"not-json", b"{}"]) +def test_header_only_ping_cannot_synthesize_a_success(era, body): + with http_client(era) as (_, _, client): + response = client.post("/mcp", content=body, headers=headers("ping")) + assert response.status_code == 400, response.text + assert "result" not in decode(response) + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_unknown_method_is_answered_by_official_dispatch(era): + with http_client(era) as (_, _, client): + response = post(client, "unknown/probe") + assert response.status_code == 404, response.text + assert decode(response)["error"]["code"] == -32601 + + +@pytest.mark.parametrize("era", ["legacy", "auto", "modern"]) +def test_http_health_version_slash_and_era(era): + with http_client(era) as (_, http, client): + health = client.get("/mcp/health") + assert health.status_code == 200 + assert http.state.protocol_era == era + version = client.get("/json/version").json()["Protocol-Version"] + assert version == ("2025-06-18" if era == "legacy" else MODERN_PROTOCOL_VERSION) + response = client.post("/mcp/", json=envelope(), headers=headers()) + if era != "legacy": + assert response.status_code == 200, response.text + assert decode(response)["result"]["tools"][0]["name"] == "echo" + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_allowlisted_cors_applies_once_to_success_and_errors(era, monkeypatch): + monkeypatch.setenv("MCP_CORS_ALLOWED_ORIGINS", "https://allowed.example") + with http_client(era) as (_, _, client): + for method in ["tools/list", "unknown/probe"]: + response = post(client, method, extra_headers={"Origin": "https://allowed.example"}) + assert response.headers.get_list("access-control-allow-origin") == ["https://allowed.example"] + denied = post(client, extra_headers={"Origin": "https://denied.example"}) + assert "access-control-allow-origin" not in denied.headers + preflight = client.options("/mcp", headers={"Origin": "https://allowed.example", + "Access-Control-Request-Method": "POST"}) + assert preflight.status_code == 200 + assert preflight.headers.get_list("access-control-allow-origin") == ["https://allowed.example"] + + +def test_verified_http_identity_and_argument_header_mirroring(): + from nitrostack.auth.jwt import JWTService + from nitrostack.core.di import DIContainer + + with http_client("auto") as (_, _, client): + jwt = JWTService() + DIContainer.get_instance().register_value(JWTService, jwt) + token = jwt.create_token({"sub": "alice", "tenant_id": "one"}) + response = post(client, "tools/call", { + "name": "echo", "arguments": {}, "_meta": {"userId": "mallory"}, + }, extra_headers={"Authorization": "Bearer " + token, "Mcp-Param-value": "from-header"}) + result = decode(response)["result"]["structuredContent"] + assert result["value"] == "from-header" + assert result["mirrored"] == {"value": "from-header"} + assert result["user"] == "alice" + assert result["protocol_version"] == MODERN_PROTOCOL_VERSION + kept = post(client, "tools/call", {"name": "echo", "arguments": {"value": "from-body"}}, + extra_headers={"Mcp-Param-value": "from-header"}) + assert decode(kept)["result"]["structuredContent"]["value"] == "from-body" + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_incoming_session_id_cannot_attach_sessionless_calls(era): + with http_client(era) as (_, _, client): + first = post(client, "tools/call", {"name": "echo", "arguments": {"value": "first"}}, + extra_headers={"Mcp-Session-Id": "forged"}) + second = post(client, "tools/call", {"name": "echo", "arguments": {"value": "second"}}, + extra_headers={"Mcp-Session-Id": "forged"}) + for response, expected in [(first, "first"), (second, "second")]: + assert response.status_code == 200, response.text + assert "mcp-session-id" not in response.headers + assert decode(response)["result"]["structuredContent"]["value"] == expected class TestRequestHeaders: @@ -212,89 +304,6 @@ def test_oversized_param_is_detected(self): assert first_oversized_mcp_param({"Mcp-Param-City": "Boston"}) is None - def test_http_header_fills_missing_tool_argument(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> dict: - return { - "value": input.value, - "mirrored": context.mcp_param_headers, - } - - @module(name="McpParamHttp", controllers=[EchoController]) - class McpParamModule: - pass - - @mcp_app(module=McpParamModule, server=ServerConfig(name="mcp-param-http")) - class McpParamApp: - pass - - app = asyncio.run(McpApplicationFactory.create(McpParamApp)) - http_app = app.get_combined_app(json_response=True) - json_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "Mcp-Method": "tools/call", - "Mcp-Name": "echo", - "MCP-Protocol-Version": "2025-06-18", - } - with TestClient(http_app) as client: - filled = client.post( - "/mcp", - headers={**json_headers, "Mcp-Param-value": "from-header"}, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "echo", "arguments": {}}, - }, - ) - kept = client.post( - "/mcp", - headers={**json_headers, "Mcp-Param-value": "from-header"}, - json={ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "from-body"}}, - }, - ) - assert filled.status_code == 200, filled.text - filled_body = filled.json()["result"] - filled_payload = filled_body.get("structuredContent") or json.loads( - filled_body["content"][0]["text"] - ) - assert filled_payload["value"] == "from-header" - assert filled_payload["mirrored"]["value"] == "from-header" - assert kept.status_code == 200, kept.text - kept_body = kept.json()["result"] - kept_payload = kept_body.get("structuredContent") or json.loads( - kept_body["content"][0]["text"] - ) - assert kept_payload["value"] == "from-body" - assert kept_payload["mirrored"]["value"] == "from-header" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - class TestCors: def test_cors_allow_methods(self): headers = build_cors_headers() @@ -391,1424 +400,3 @@ def test_sse_task_status_notification(self): {"method": "notifications/tasks/status", "params": {"taskId": "abc"}} ) assert b"notifications/tasks/status" in frame - - -class TestOptionsScopedToMcpPath: - def test_options_mcp_is_204_health_is_forwarded(self): - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - from pydantic import BaseModel, Field - - class EchoInput(BaseModel): - value: str = Field(default="") - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="OptionsHttp", controllers=[EchoController]) - class OptionsModule: - pass - - @mcp_app(module=OptionsModule, server=ServerConfig(name="options-http", stateless=True)) - class OptionsApp: - pass - - app = asyncio.run(McpApplicationFactory.create(OptionsApp)) - http_app = app.get_combined_app(stateless=True, json_response=True) - with TestClient(http_app) as client: - mcp_opt = client.options("/mcp") - health_opt = client.options("/mcp/health") - bare_health = client.options("/health") - oauth_opt = client.options("/oauth/v2/register") - health_preflight = client.options( - "/mcp/health", - headers={ - "Origin": "https://app.example.com", - "Access-Control-Request-Method": "GET", - }, - ) - oauth_preflight = client.options( - "/oauth/v2/register", - headers={ - "Origin": "https://app.example.com", - "Access-Control-Request-Method": "POST", - }, - ) - call = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "Mcp-Method": "tools/call", - "Mcp-Name": "echo", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "ok"}}, - }, - ) - assert mcp_opt.status_code == 204 - assert "Access-Control-Allow-Origin" in mcp_opt.headers - assert health_opt.status_code != 204 - assert bare_health.status_code != 204 - assert oauth_opt.status_code != 204 - assert health_preflight.status_code != 204 - assert oauth_preflight.status_code != 204 - assert call.status_code == 200 - assert call.json()["result"]["content"][0]["text"] == "ok" - finally: - DIContainer.reset() - - def test_options_mcp_is_not_sidecar_204_when_cors_off(self): - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - from pydantic import BaseModel, Field - - class EchoInput(BaseModel): - value: str = Field(default="") - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="OptionsCorsOffHttp", controllers=[EchoController]) - class OptionsCorsOffModule: - pass - - @mcp_app(module=OptionsCorsOffModule, server=ServerConfig(name="options-cors-off")) - class OptionsCorsOffApp: - pass - - app = asyncio.run(McpApplicationFactory.create(OptionsCorsOffApp)) - http_app = app.get_combined_app(stateless=True, json_response=True, enable_cors=False) - with TestClient(http_app) as client: - mcp_opt = client.options("/mcp") - assert mcp_opt.status_code != 204 - finally: - DIContainer.reset() - - def test_options_allow_headers_include_2026_mcp_names(self, monkeypatch): - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - from pydantic import BaseModel, Field - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - monkeypatch.setenv("MCP_CORS_ALLOWED_ORIGINS", "https://app.example.com") - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="CorsAllowHeadersHttp", controllers=[EchoController]) - class CorsAllowHeadersModule: - pass - - @mcp_app(module=CorsAllowHeadersModule, server=ServerConfig(name="cors-allow-headers")) - class CorsAllowHeadersApp: - pass - - app = asyncio.run(McpApplicationFactory.create(CorsAllowHeadersApp)) - http_app = app.get_combined_app(json_response=True) - with TestClient(http_app) as client: - allowed = client.options( - "/mcp", - headers={ - "Origin": "https://app.example.com", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": ( - "Mcp-Name, Mcp-Method, MCP-Protocol-Version, Mcp-Param-City" - ), - }, - ) - unknown = client.options( - "/mcp", - headers={ - "Origin": "https://evil.example", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "Mcp-Name", - }, - ) - assert allowed.status_code == 204 - allow = allowed.headers.get("access-control-allow-headers", "") - assert "Mcp-Name" in allow - assert "Mcp-Method" in allow - assert "MCP-Protocol-Version" in allow - assert "Mcp-Param-City" in allow - assert allowed.headers.get("access-control-allow-origin") == "https://app.example.com" - assert unknown.headers.get("access-control-allow-origin") != "https://evil.example" - finally: - DIContainer.reset() - monkeypatch.delenv("MCP_CORS_ALLOWED_ORIGINS", raising=False) - monkeypatch.delenv("NITRO_MCP_PROTOCOL_VERSION", raising=False) - - -class TestProviderToolDiscovery: - def test_provider_tools_are_registered(self): - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.decorators import ToolConfig - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - assert ToolConfig(name="x", description="d", input_schema={}).task_support == "forbidden" - - DIContainer.reset() - try: - @injectable() - class ToolProvider: - @tool(name="from_provider", description="provider", input_schema=EchoInput) - async def from_provider(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="ProviderTools", providers=[ToolProvider], controllers=[]) - class ProviderModule: - pass - - @mcp_app(module=ProviderModule, server=ServerConfig(name="provider-tools")) - class ProviderApp: - pass - - app = asyncio.run(McpApplicationFactory.create(ProviderApp)) - assert "from_provider" in app._tools - assert app._tools["from_provider"].config.task_support == "forbidden" - finally: - DIContainer.reset() - - -class TestNitroMcpProtocolVersionEnv: - def test_modern_era_enables_stateless_without_mcp_stateless(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "2026-07-28") - monkeypatch.delenv("MCP_STATELESS", raising=False) - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="EraHttp", controllers=[EchoController]) - class EraModule: - pass - - @mcp_app(module=EraModule, server=ServerConfig(name="era-http")) - class EraApp: - pass - - app = asyncio.run(McpApplicationFactory.create(EraApp)) - http_app = app.get_combined_app(json_response=True) - with TestClient(http_app) as client: - mcp_opt = client.options("/mcp") - call = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "Mcp-Method": "ping", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "ping", - }, - ) - assert mcp_opt.status_code == 204 - assert call.status_code == 200 - assert call.json()["result"] == {} - assert app.protocol_era == "modern" - inner = getattr(http_app, "app", http_app) - assert inner.state.protocol_era == "modern" - assert inner.state.wire_mode == "reject" - assert inner.state.stateless is True - assert inner.state.http_engine == "sessionless" - assert app.mcp_server.http_engine == "sessionless" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - def test_auto_era_keeps_era_and_does_not_force_stateless(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="AutoEraHttp", controllers=[EchoController]) - class AutoEraModule: - pass - - @mcp_app(module=AutoEraModule, server=ServerConfig(name="auto-era-http")) - class AutoEraApp: - pass - - app = asyncio.run(McpApplicationFactory.create(AutoEraApp)) - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert app.protocol_era == "auto" - assert state.protocol_era == "auto" - assert state.wire_mode == "stateless" - assert state.stateless is True - assert state.http_engine == "sessionless" - assert state.streamable_http_manager_count == 1 - assert app.mcp_server.http_engine == "sessionless" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - def test_legacy_era_uses_sessionful_engine(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "legacy") - monkeypatch.delenv("MCP_STATELESS", raising=False) - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="LegacyEngineHttp", controllers=[EchoController]) - class LegacyEngineModule: - pass - - @mcp_app(module=LegacyEngineModule, server=ServerConfig(name="legacy-engine-http")) - class LegacyEngineApp: - pass - - app = asyncio.run(McpApplicationFactory.create(LegacyEngineApp)) - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert app.protocol_era == "legacy" - assert state.http_engine == "sessionful" - assert state.sessionful is True - assert state.stateless is False - assert state.session_manager.stateless is False - assert app.mcp_server.http_engine == "sessionful" - assert app.mcp_server.sessionful is True - assert "echo" in app._tools - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - -def _http_app_state(asgi): - current = asgi - seen: set[int] = set() - while current is not None and id(current) not in seen: - seen.add(id(current)) - state = getattr(current, "state", None) - if state is not None and getattr(state, "protocol_era", None) is not None: - return state - current = getattr(current, "app", None) - raise AssertionError("HTTP app is missing protocol era state") - - -class TestEraSessionfulCoexistence: - def _echo_app(self, monkeypatch, era_value: str): - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era_value) - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name=f"EraCoexist{era_value.title()}", controllers=[EchoController]) - class EraModule: - pass - - @mcp_app(module=EraModule, server=ServerConfig(name=f"era-coexist-{era_value}")) - class EraApp: - pass - - return asyncio.run(McpApplicationFactory.create(EraApp)) - - def test_auto_stateless_false_does_not_start_sessionful_manager(self, monkeypatch): - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - try: - app = self._echo_app(monkeypatch, "auto") - http_app = app.get_combined_app(json_response=True, stateless=False) - state = _http_app_state(http_app) - assert state.http_engine == "sessionless" - assert state.sessionful is False - assert state.session_manager.stateless is True - assert state.streamable_http_manager_count == 1 - assert app.mcp_server.sessionful is False - assert "echo" in app._tools - - with TestClient(http_app) as client: - init = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - }, - ) - assert init.status_code == 200, init.text - assert "result" in init.json() - init_headers = {key.lower(): value for key, value in init.headers.items()} - assert LEGACY_SESSION_HEADER.lower() not in init_headers - assert not state.session_manager._server_instances - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - def test_modern_stateless_false_still_rejects_initialize(self, monkeypatch): - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - try: - app = self._echo_app(monkeypatch, "modern") - http_app = app.get_combined_app(json_response=True, stateless=False) - state = _http_app_state(http_app) - assert state.http_engine == "sessionless" - assert state.sessionful is False - assert "echo" in app._tools - - with TestClient(http_app) as client: - init = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - }, - ) - assert init.status_code == 200 - assert init.json()["error"]["code"] == -32601 - init_headers = {key.lower(): value for key, value in init.headers.items()} - assert LEGACY_SESSION_HEADER.lower() not in init_headers - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - def test_legacy_initialize_creates_session_without_modern_headers(self, monkeypatch): - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - try: - app = self._echo_app(monkeypatch, "legacy") - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert state.http_engine == "sessionful" - assert state.sessionful is True - assert state.streamable_http_manager_count == 1 - assert "echo" in app._tools - - json_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - } - with TestClient(http_app) as client: - init = client.post( - "/mcp", - headers=json_headers, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - }, - ) - session_id = init.headers.get("mcp-session-id") or init.headers.get( - "Mcp-Session-Id" - ) - assert init.status_code == 200, init.text - assert session_id - client.post( - "/mcp", - headers={**json_headers, LEGACY_SESSION_HEADER: session_id}, - json={"jsonrpc": "2.0", "method": "notifications/initialized"}, - ) - call = client.post( - "/mcp", - headers={**json_headers, LEGACY_SESSION_HEADER: session_id}, - json={ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "ok"}}, - }, - ) - assert call.status_code == 200, call.text - assert call.json()["result"]["content"][0]["text"] == "ok" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - -class TestUnsetEnvDefault: - def _echo_app(self): - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - - class EchoInput(BaseModel): - value: str = Field(default="") - - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="UnsetEnvDefault", controllers=[EchoController]) - class UnsetEnvModule: - pass - - @mcp_app(module=UnsetEnvModule, server=ServerConfig(name="unset-env-default")) - class UnsetEnvApp: - pass - - return asyncio.run(McpApplicationFactory.create(UnsetEnvApp)) - - def test_unset_env_http_is_auto_sessionless(self, monkeypatch, caplog): - import logging - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - monkeypatch.delenv("NITRO_MCP_PROTOCOL_VERSION", raising=False) - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - try: - with caplog.at_level(logging.INFO, logger="nitrostack.core.app"): - app = self._echo_app() - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert app.protocol_era == "auto" - assert app.protocol_era_source == "default" - assert state.protocol_era == "auto" - assert state.http_engine == "sessionless" - assert "protocol era=auto (source=default)" in caplog.text - - with TestClient(http_app) as client: - init = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - }, - ) - assert init.status_code == 200, init.text - assert "result" in init.json() - assert init.json()["result"]["protocolVersion"] == "2025-06-18" - init_headers = {key.lower(): value for key, value in init.headers.items()} - assert LEGACY_SESSION_HEADER.lower() not in init_headers - assert not state.session_manager._server_instances - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - os.environ.pop("MCP_STATELESS", None) - - def test_mcp_stateless_false_still_forces_legacy(self, monkeypatch, caplog): - import logging - import os - - from nitrostack.core.di import DIContainer - - monkeypatch.delenv("NITRO_MCP_PROTOCOL_VERSION", raising=False) - monkeypatch.setenv("MCP_STATELESS", "false") - DIContainer.reset() - try: - with caplog.at_level(logging.INFO, logger="nitrostack.core.app"): - app = self._echo_app() - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert app.protocol_era == "legacy" - assert app.protocol_era_source == "mcp_stateless" - assert state.protocol_era == "legacy" - assert state.http_engine == "sessionful" - assert "protocol era=legacy (source=mcp_stateless)" in caplog.text - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - os.environ.pop("MCP_STATELESS", None) - - -class TestAutoEraOneMcpDualClients: - def test_auto_serves_initialize_and_discover_on_one_mcp(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="DualClientHttp", controllers=[EchoController]) - class DualClientModule: - pass - - @mcp_app(module=DualClientModule, server=ServerConfig(name="dual-client-http")) - class DualClientApp: - pass - - app = asyncio.run(McpApplicationFactory.create(DualClientApp)) - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert state.protocol_era == "auto" - assert state.streamable_http_manager_count == 1 - assert state.session_manager.stateless is True - - json_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - } - modern_headers = { - **json_headers, - "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, - } - - with TestClient(http_app) as client: - init = client.post( - "/mcp", - headers=json_headers, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - }, - ) - initialized = client.post( - "/mcp", - headers=json_headers, - json={"jsonrpc": "2.0", "method": "notifications/initialized"}, - ) - discover = client.post( - "/mcp", - headers={**modern_headers, "Mcp-Method": "server/discover"}, - json={ - "jsonrpc": "2.0", - "id": 2, - "method": "server/discover", - "params": { - "_meta": { - "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION, - "io.modelcontextprotocol/clientCapabilities": {}, - } - }, - }, - ) - call = client.post( - "/mcp", - headers={ - **json_headers, - "MCP-Protocol-Version": "2025-06-18", - "Mcp-Method": "tools/call", - "Mcp-Name": "echo", - }, - json={ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "ok"}}, - }, - ) - - assert init.status_code == 200, init.text - init_body = init.json()["result"] - assert init_body["protocolVersion"] == "2025-06-18" - assert "serverInfo" in init_body - assert "capabilities" in init_body - init_headers = {key.lower(): value for key, value in init.headers.items()} - assert LEGACY_SESSION_HEADER.lower() not in init_headers - assert not state.session_manager._server_instances - - assert initialized.status_code == 202, initialized.text - if initialized.content: - assert initialized.json() == {} - initialized_headers = { - key.lower(): value for key, value in initialized.headers.items() - } - assert LEGACY_SESSION_HEADER.lower() not in initialized_headers - assert not state.session_manager._server_instances - - assert discover.status_code == 200, discover.text - discover_body = discover.json()["result"] - assert "capabilities" in discover_body - assert discover_body["resultType"] == "complete" - - assert call.status_code == 200, call.text - assert call.json()["result"]["content"][0]["text"] == "ok" - call_headers = {key.lower(): value for key, value in call.headers.items()} - assert LEGACY_SESSION_HEADER.lower() not in call_headers - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - -class TestModernEraRejectsLegacyWire: - def test_modern_initialize_is_jsonrpc_error_without_session(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "modern") - monkeypatch.delenv("MCP_STATELESS", raising=False) - - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="ModernRejectHttp", controllers=[EchoController]) - class ModernRejectModule: - pass - - @mcp_app(module=ModernRejectModule, server=ServerConfig(name="modern-reject-http")) - class ModernRejectApp: - pass - - app = asyncio.run(McpApplicationFactory.create(ModernRejectApp)) - http_app = app.get_combined_app(json_response=True) - state = _http_app_state(http_app) - assert state.wire_mode == "reject" - - headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - } - initialize_body = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - } - initialized_body = {"jsonrpc": "2.0", "method": "notifications/initialized"} - - with TestClient(http_app) as client: - init = client.post("/mcp", headers=headers, json=initialize_body) - initialized = client.post( - "/mcp", headers=headers, json=initialized_body - ) - - assert init.status_code == 200 - assert init.json()["error"]["code"] == -32601 - assert init.json()["error"]["message"] == "Method not found: initialize" - init_headers = {key.lower(): value for key, value in init.headers.items()} - assert LEGACY_SESSION_HEADER.lower() not in init_headers - assert not state.session_manager._server_instances - - # Rejecting notifications/initialized and an incoming session id on - # the modern wire are separate rules, tracked in #27. - assert initialized.status_code == 202 - assert not state.session_manager._server_instances - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - -class TestHealthAdvertisesEra: - def _health_body(self, monkeypatch, era_value: str) -> dict: - import os - - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era_value) - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="HealthEraHttp", controllers=[EchoController]) - class HealthEraModule: - pass - - @mcp_app(module=HealthEraModule, server=ServerConfig(name="health-era-http")) - class HealthEraApp: - pass - - app = asyncio.run(McpApplicationFactory.create(HealthEraApp)) - http_app = app.get_combined_app(json_response=True) - with TestClient(http_app) as client: - response = client.get("/mcp/health") - assert response.status_code == 200 - return response.json() - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - def test_modern_health_reports_modern_era(self, monkeypatch): - body = self._health_body(monkeypatch, "modern") - assert body["status"] == "ok" - assert body["transport"] == "streamable-http" - assert body["protocolEra"] == "modern" - assert body["protocolVersion"] == "2026-07-28" - assert body["statelessCapable"] is True - assert "stateless" in body - assert "uptimeSeconds" in body - - def test_auto_health_reports_auto_not_modern(self, monkeypatch): - body = self._health_body(monkeypatch, "auto") - assert body["protocolEra"] == "auto" - assert body["protocolEra"] != "modern" - assert body["statelessCapable"] is True - - def test_legacy_health_reports_legacy_era(self, monkeypatch): - body = self._health_body(monkeypatch, "legacy") - assert body["protocolEra"] == "legacy" - assert body["protocolVersion"] == "2025-06-18" - assert body["statelessCapable"] is False - - -class TestTrustedProxyPublicUrl: - def _http_app(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.delenv("MCP_STATELESS", raising=False) - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - DIContainer.reset() - - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="ProxyHttp", controllers=[EchoController]) - class ProxyModule: - pass - - @mcp_app(module=ProxyModule, server=ServerConfig(name="proxy-http")) - class ProxyApp: - pass - - app = asyncio.run(McpApplicationFactory.create(ProxyApp)) - return app.get_combined_app(json_response=True) - - def test_health_ignores_untrusted_forwarded_host(self, monkeypatch): - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - try: - http_app = self._http_app(monkeypatch) - with TestClient(http_app, client=("8.8.8.8", 4321)) as client: - response = client.get( - "/mcp/health", - headers={ - "Host": "internal:3000", - "X-Forwarded-Host": "mcp.example.com", - "X-Forwarded-Proto": "https", - }, - ) - docs = client.get( - "/", - headers={ - "Host": "internal:3000", - "X-Forwarded-Host": "mcp.example.com", - "X-Forwarded-Proto": "https", - }, - ) - oauth = client.get( - "/.well-known/oauth-authorization-server", - headers={ - "Host": "internal:3000", - "X-Forwarded-Host": "mcp.example.com", - "X-Forwarded-Proto": "https", - }, - ) - assert response.status_code == 200 - assert "mcp.example.com" not in response.json()["publicUrl"] - assert "mcp.example.com" not in docs.text - assert "mcp.example.com" not in oauth.json()["error_description"] - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - def test_health_honors_trusted_forwarded_host(self, monkeypatch): - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - monkeypatch.setenv("TRUSTED_PROXIES", "10.0.0.5") - try: - http_app = self._http_app(monkeypatch) - with TestClient(http_app, client=("10.0.0.5", 4321)) as client: - headers = { - "Host": "internal:3000", - "X-Forwarded-Host": "mcp.example.com", - "X-Forwarded-Proto": "https", - } - response = client.get("/mcp/health", headers=headers) - docs = client.get("/", headers=headers) - oauth = client.get("/.well-known/oauth-authorization-server", headers=headers) - version = client.get("/json/version", headers=headers) - assert response.status_code == 200 - assert response.json()["publicUrl"] == "https://mcp.example.com/mcp" - assert "https://mcp.example.com/mcp" in docs.text - assert "https://mcp.example.com/mcp" in oauth.json()["error_description"] - assert version.json()["publicUrl"] == "https://mcp.example.com/mcp" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - os.environ.pop("TRUSTED_PROXIES", None) - - -class TestHeaderTriggeredPing: - def _http_app(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.delenv("MCP_STATELESS", raising=False) - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - DIContainer.reset() - - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="HeaderPingHttp", controllers=[EchoController]) - class HeaderPingModule: - pass - - @mcp_app(module=HeaderPingModule, server=ServerConfig(name="header-ping-http")) - class HeaderPingApp: - pass - - app = asyncio.run(McpApplicationFactory.create(HeaderPingApp)) - return app.get_combined_app(json_response=True) - -class TestEnvelopeOnHandlerContext: - def test_tool_reads_trace_and_protocol_version_not_spoofed_user(self, monkeypatch): - import os - - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EmptyInput(BaseModel): - pass - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - try: - @injectable() - class EnvelopeController: - @tool(name="envelope", description="read envelope", input_schema=EmptyInput) - async def envelope(self, input: EmptyInput, context: ExecutionContext) -> dict: - return { - "protocolVersion": context.protocol_version, - "trace": context.rpc_meta.trace if context.rpc_meta else None, - "user": context.user, - } - - @module(name="EnvelopeHttp", controllers=[EnvelopeController]) - class EnvelopeModule: - pass - - @mcp_app(module=EnvelopeModule, server=ServerConfig(name="envelope-http")) - class EnvelopeApp: - pass - - app = asyncio.run(McpApplicationFactory.create(EnvelopeApp)) - http_app = app.get_combined_app(json_response=True) - headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "MCP-Protocol-Version": "2025-06-18", - "Mcp-Method": "tools/call", - "Mcp-Name": "envelope", - } - with TestClient(http_app) as client: - response = client.post( - "/mcp", - headers=headers, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "envelope", - "arguments": {}, - "_meta": { - "trace": {"id": "http-span"}, - "userId": "spoofed", - }, - }, - }, - ) - - assert response.status_code == 200, response.text - result = response.json()["result"] - body = result.get("structuredContent") or json.loads(result["content"][0]["text"]) - assert body["protocolVersion"] == "2025-06-18" - assert body["trace"] == {"id": "http-span"} - assert body["user"] is None - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - -class TestAuthFromEnvelopeAndHeaders: - def test_tool_user_comes_from_header_jwt_not_unsigned_meta(self, monkeypatch): - import os - - from pydantic import BaseModel - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.auth.jwt import JWTService - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EmptyInput(BaseModel): - pass - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - jwt = JWTService() - DIContainer.get_instance().register_value(JWTService, jwt) - token = jwt.create_token({"sub": "alice", "tenant_id": "acme"}) - try: - @injectable() - class AuthController: - @tool(name="whoami", description="identity", input_schema=EmptyInput) - async def whoami(self, input: EmptyInput, context: ExecutionContext) -> dict: - return {"user": context.user} - - @module(name="AuthHttp", controllers=[AuthController]) - class AuthModule: - pass - - @mcp_app(module=AuthModule, server=ServerConfig(name="auth-http")) - class AuthApp: - pass - - app = asyncio.run(McpApplicationFactory.create(AuthApp)) - http_app = app.get_combined_app(json_response=True) - with TestClient(http_app) as client: - response = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "MCP-Protocol-Version": "2025-06-18", - "Mcp-Method": "tools/call", - "Mcp-Name": "whoami", - "Authorization": f"Bearer {token}", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "whoami", - "arguments": {}, - "_meta": { - "userId": "eve", - "tenantId": "evil", - "io.modelcontextprotocol/auth": {"userId": "mallory"}, - }, - }, - }, - ) - - assert response.status_code == 200, response.text - result = response.json()["result"] - body = result.get("structuredContent") or json.loads(result["content"][0]["text"]) - assert body["user"] == "alice" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - -class TestIncomingSessionIdRejection: - def _echo_app(self, monkeypatch, era_value: str): - import os - - from pydantic import BaseModel, Field - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", era_value) - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="SessionRejectHttp", controllers=[EchoController]) - class SessionRejectModule: - pass - - @mcp_app(module=SessionRejectModule, server=ServerConfig(name="session-reject-http")) - class SessionRejectApp: - pass - - return asyncio.run(McpApplicationFactory.create(SessionRejectApp)) - - def test_legacy_post_accepts_session_id_after_initialize(self, monkeypatch): - import os - - from starlette.testclient import TestClient - - from nitrostack.core.di import DIContainer - - try: - app = self._echo_app(monkeypatch, "legacy") - http_app = app.get_combined_app(json_response=True) - json_headers = { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - } - with TestClient(http_app) as client: - init = client.post( - "/mcp", - headers=json_headers, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "legacy-client", "version": "1.0"}, - }, - }, - ) - session_id = init.headers.get("mcp-session-id") or init.headers.get( - "Mcp-Session-Id" - ) - assert init.status_code == 200, init.text - assert session_id - client.post( - "/mcp", - headers={**json_headers, LEGACY_SESSION_HEADER: session_id}, - json={"jsonrpc": "2.0", "method": "notifications/initialized"}, - ) - call = client.post( - "/mcp", - headers={ - **json_headers, - LEGACY_SESSION_HEADER: session_id, - "Mcp-Method": "tools/call", - "Mcp-Name": "echo", - }, - json={ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "ok"}}, - }, - ) - assert call.status_code == 200, call.text - assert call.json()["result"]["content"][0]["text"] == "ok" - finally: - DIContainer.reset() - os.environ.pop("NITRO_MCP_PROTOCOL_VERSION", None) - - - - -class TestSep2243AllModernMethods: - def test_table_covers_modern_surface(self): - methods = {row.method for row in MODERN_METHOD_CONTRACTS} - assert "server/discover" in methods - assert "resources/read" in methods - assert "prompts/get" in methods - assert "completion/complete" in methods - assert "tasks/get" in methods - assert "tools/call" in methods - - -class TestResponseEchoHeaders: - def test_echo_uses_supported_request_version(self): - headers = build_mcp_echo_headers( - {"MCP-Protocol-Version": "2025-06-18", "Mcp-Method": "tools/call"}, - protocol_version=MODERN_PROTOCOL_VERSION, - supported_versions={"2026-07-28", "2025-06-18"}, - ) - assert headers["MCP-Protocol-Version"] == "2025-06-18" - assert headers["Mcp-Method"] == "tools/call" - assert handled_protocol_version( - {"MCP-Protocol-Version": "1999-01-01"}, - fallback=MODERN_PROTOCOL_VERSION, - supported={"2026-07-28", "2025-06-18"}, - ) == MODERN_PROTOCOL_VERSION - -class TestCorsExposeHeaders: - def test_http_mcp_response_exposes_echo_headers_not_session_id(self, monkeypatch): - from pydantic import BaseModel, Field - from starlette.testclient import TestClient - - from nitrostack import ExecutionContext, injectable, module, tool - from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app - from nitrostack.core.di import DIContainer - - class EchoInput(BaseModel): - value: str = Field(default="") - - monkeypatch.setenv("NITRO_MCP_PROTOCOL_VERSION", "auto") - monkeypatch.delenv("MCP_STATELESS", raising=False) - DIContainer.reset() - try: - @injectable() - class EchoController: - @tool(name="echo", description="echo", input_schema=EchoInput) - async def echo(self, input: EchoInput, context: ExecutionContext) -> str: - return input.value - - @module(name="CorsExposeHttp", controllers=[EchoController]) - class CorsExposeModule: - pass - - @mcp_app(module=CorsExposeModule, server=ServerConfig(name="cors-expose")) - class CorsExposeApp: - pass - - app = asyncio.run(McpApplicationFactory.create(CorsExposeApp)) - http_app = app.get_combined_app(json_response=True) - origin = "https://app.example.com" - with TestClient(http_app) as client: - response = client.post( - "/mcp", - headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "Origin": origin, - "MCP-Protocol-Version": "2025-06-18", - "Mcp-Method": "tools/call", - "Mcp-Name": "echo", - }, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "echo", "arguments": {"value": "ok"}}, - }, - ) - preflight = client.options( - "/mcp", - headers={ - "Origin": origin, - "Access-Control-Request-Method": "POST", - }, - ) - assert response.status_code == 200, response.text - expose = response.headers.get("access-control-expose-headers", "") - assert "MCP-Protocol-Version" in expose - assert "Mcp-Method" in expose - assert "Mcp-Session-Id" not in expose - assert preflight.status_code == 204 - preflight_expose = preflight.headers.get("access-control-expose-headers", "") - assert "MCP-Protocol-Version" in preflight_expose - assert "Mcp-Method" in preflight_expose - assert "Mcp-Session-Id" not in preflight_expose - finally: - DIContainer.reset() - monkeypatch.delenv("NITRO_MCP_PROTOCOL_VERSION", raising=False) From 97e324a69f289327a97ba94a6ceb99d7b343eec0 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 27 Oct 2026 18:46:55 +0530 Subject: [PATCH 03/10] fix: align resource capabilities and enforce protocol eras in MCP runner --- nitrostack/core/app.py | 8 ++- nitrostack/core/mcp_server.py | 45 ++++++++++++++-- nitrostack/protocol/version.py | 5 ++ tests/test_mcp23_http_factory.py | 90 ++++++++++++++++++++++++++++++-- 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 0f6c0df..68a8db4 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -66,6 +66,7 @@ resolve_http_engine, resolve_protocol_era_resolution, wire_mode_for_era, + supports_resource_subscriptions, ) from nitrostack.protocol.mrtr import InputRequiredResult, split_mrtr_from_arguments from nitrostack.protocol.cache_hints import ( @@ -871,7 +872,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 = getattr(self, "protocol_era", None) != "modern" + resources_subscribe = supports_resource_subscriptions(self.protocol_era) return build_discover_result( server_name=self.server_config.name, server_version=self.server_config.version, @@ -908,6 +909,7 @@ def _list_endpoint_cache_meta(self) -> Dict[str, Any]: return build_list_endpoint_cache_hint_meta() def _setup_handlers(self, server: NitroStackMcpServer) -> None: + server.protocol_era = self.protocol_era async def _list_tools( ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams], @@ -971,7 +973,7 @@ async def _read_resource(ctx: ServerRequestContext, params: ReadResourceRequestP return _read_contents_to_result(uri, await self._read_resource(uri)) async def _subscribe_resource(_ctx: ServerRequestContext, params: SubscribeRequestParams): - if rejects_deprecated_method("resources/subscribe", self.protocol_era): + if not supports_resource_subscriptions(self.protocol_era): message = deprecated_method_message("resources/subscribe") raise MCPError(types.METHOD_NOT_FOUND, message or "Not supported") if str(params.uri) not in self._resources: @@ -979,6 +981,8 @@ async def _subscribe_resource(_ctx: ServerRequestContext, params: SubscribeReque return types.EmptyResult() async def _unsubscribe_resource(_ctx: ServerRequestContext, _params: UnsubscribeRequestParams): + if not supports_resource_subscriptions(self.protocol_era): + raise MCPError(types.METHOD_NOT_FOUND, "Resource subscriptions are not supported in this era") return types.EmptyResult() async def _list_prompts(_ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams]): diff --git a/nitrostack/core/mcp_server.py b/nitrostack/core/mcp_server.py index 926bcc4..1c8c490 100644 --- a/nitrostack/core/mcp_server.py +++ b/nitrostack/core/mcp_server.py @@ -8,9 +8,19 @@ from typing import Any, Callable, Dict, Optional import mcp.types as types +from mcp import MCPError from mcp.server.lowlevel import Server as LowLevelServer from mcp.server.lowlevel.server import NotificationOptions from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler +from nitrostack.protocol.errors import JsonRpcErrorCode +from nitrostack.protocol.version import ( + MODERN_PROTOCOL_VERSION, + ProtocolEra, + supports_resource_subscriptions, + supported_protocol_versions_for_era, +) +from nitrostack.protocol.meta import flatten_request_meta_object +from nitrostack.protocol.jsonrpc import HeaderBodyMismatchError, validate_protocol_version_header_meta class NitroStackMcpServer(LowLevelServer): @@ -33,13 +43,41 @@ def __init__(self, name: str, version: Optional[str] = None): self.has_task_support: bool = False self.http_engine: Optional[str] = None self.sessionful: bool = False - self.protocol_era: Optional[str] = None + self.protocol_era: Optional[ProtocolEra] = None + self.middleware.append(self._enforce_protocol_era) self.discover_handler: Optional[Callable[[], dict[str, Any]]] = None self.initialize_handler: Optional[Callable[[Optional[str]], dict[str, Any]]] = None # In-process tests still look up handlers by request type. self.request_handlers: Dict[Any, Callable[..., Any]] = {} self.notification_handlers: Dict[Any, Callable[..., Any]] = {} + async def _enforce_protocol_era(self, context, call_next): + """Apply era policy inside the official runner, after its JSON-RPC parsing.""" + if self.protocol_era == "modern" and context.method in ( + "initialize", "notifications/initialized", + ): + raise MCPError(types.METHOD_NOT_FOUND, f"Method not found: {context.method}") + request_headers = getattr(getattr(context, "request", None), "headers", {}) + meta = flatten_request_meta_object(context.meta) + header_version = request_headers.get("mcp-protocol-version") + meta_version = meta.get("io.modelcontextprotocol/protocolVersion") + try: + validate_protocol_version_header_meta(header_version, meta_version) + except HeaderBodyMismatchError as exc: + raise MCPError(int(exc.code), exc.message) from None + claimed_version = header_version or meta_version + # The official SDK uses a 2025-03-26 default when a legacy-compatible + # HTTP request omits the header. Do not reject that implicit fallback. + unsupported_claim = self.protocol_era is not None and claimed_version and ( + claimed_version not in supported_protocol_versions_for_era(self.protocol_era) + ) + modern_legacy_request = ( + self.protocol_era == "modern" and context.protocol_version != MODERN_PROTOCOL_VERSION + ) + if unsupported_claim or modern_legacy_request: + raise MCPError(int(JsonRpcErrorCode.UNSUPPORTED_PROTOCOL_VERSION), "Unsupported protocol version") + return await call_next(context) + def handle_server_discover(self) -> dict[str, Any]: """Answer ``server/discover`` from this server instance.""" if self.discover_handler is None: @@ -68,10 +106,7 @@ def get_capabilities( ) if caps.resources is not None: - # `resources/subscribe` is only rejected on `modern` - # (see `rejects_deprecated_method`); `legacy` and `auto` both serve - # it live, so this must track era, not the sessionful/sessionless split. - caps.resources.subscribe = self.protocol_era != "modern" + caps.resources.subscribe = supports_resource_subscriptions(self.protocol_era) if self.has_task_support: caps.tasks = types.ServerTasksCapability( diff --git a/nitrostack/protocol/version.py b/nitrostack/protocol/version.py index 24cf3f0..93d2828 100644 --- a/nitrostack/protocol/version.py +++ b/nitrostack/protocol/version.py @@ -207,6 +207,11 @@ def needs_sessionful_engine(era: ProtocolEra) -> bool: return era == "legacy" +def supports_resource_subscriptions(era: Optional[ProtocolEra]) -> bool: + """Only the legacy compatibility era serves resource subscriptions.""" + return era == "legacy" + + def http_engine_for_era(era: ProtocolEra) -> HttpEngine: """ Select the /mcp HTTP engine for an era. diff --git a/tests/test_mcp23_http_factory.py b/tests/test_mcp23_http_factory.py index 82e6b6a..3cb13a7 100644 --- a/tests/test_mcp23_http_factory.py +++ b/tests/test_mcp23_http_factory.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import ast import json import os import sys @@ -25,6 +26,7 @@ from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app from nitrostack.protocol.version import LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION from nitrostack.transports.stdio import serve_stdio_streams +from tests.http_probe import decode, http_client, make_app, post CLIENT_META = { "io.modelcontextprotocol/clientInfo": {"name": "issue23-test", "version": "1.0"}, @@ -97,18 +99,25 @@ def test_single_cors_layer_on_options_mcp(): @pytest.mark.parametrize("era", ["legacy", "auto", "modern"]) def test_discover_and_capabilities_agree_on_resources_subscribe(era): app = _era_app(era, f"issue23-subscribe-{era}") - app.get_combined_app(json_response=True) # wires protocol_era onto mcp_server discover = app.handle_server_discover() caps = app.mcp_server.get_capabilities() assert discover["capabilities"]["resources"]["subscribe"] == caps.resources.subscribe - # Only `modern` rejects `resources/subscribe` (see rejects_deprecated_method). - assert caps.resources.subscribe == (era != "modern") + assert caps.resources.subscribe == (era == "legacy") + extra_server = app.create_configured_mcp_server() + assert extra_server.protocol_era == era + assert extra_server.get_capabilities().resources.subscribe == (era == "legacy") + if era == "auto": + assert app.handle_sessionless_initialize()["capabilities"]["resources"]["subscribe"] is False # 3. No private `mcp.server.runner` symbol left in stdio.py. def test_stdio_module_has_no_private_mcp_runner_import(): source = Path("nitrostack/transports/stdio.py").read_text(encoding="utf-8") - assert "_serve_modern_stream" not in source + tree = ast.parse(source) + imports = [node for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module == "mcp.server.runner"] + assert imports + assert not [alias.name for node in imports for alias in node.names if alias.name.startswith("_")] # 4. /json/version tracks era instead of a hardcoded 2025 date. @@ -220,3 +229,76 @@ async def run_server(): payload = asyncio.run(roundtrip()) assert payload["result"]["content"][0]["text"] == "still-alive" + + +async def _stdio_exchange(server, era, messages, expected_responses): + """Keep one stream alive through handshake, notifications, and requests.""" + async with create_client_server_memory_streams() as (client, streams): + client_read, client_write = client + server_read, server_write = streams + async with anyio.create_task_group() as group: + group.start_soon(serve_stdio_streams, server, server_read, server_write, era) + for message in messages: + await client_write.send(SessionMessage(jsonrpc_message_adapter.validate_python(message))) + responses = [] + while len(responses) < expected_responses: + frame = await asyncio.wait_for(client_read.receive(), timeout=3) + data = frame.message.model_dump(by_alias=True, mode="json") + if "id" in data: + responses.append(data) + group.cancel_scope.cancel() + return responses + + +@pytest.mark.parametrize("era,style", [ + ("legacy", "legacy"), ("auto", "legacy"), ("auto", "modern"), ("modern", "modern"), +]) +def test_stdio_era_roundtrip_and_initialization_capabilities(era, style): + app = make_app(era) + messages = [] + if style == "legacy": + messages.extend([ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {}, + "clientInfo": {"name": "probe", "version": "1"}, + }}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + ]) + params = {"name": "echo", "arguments": {"value": "stdio"}} + if style == "modern": + params["_meta"] = {PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSION, **CLIENT_META} + messages.append({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": params}) + responses = asyncio.run(_stdio_exchange(app.mcp_server, era, messages, 2 if style == "legacy" else 1)) + if style == "legacy": + assert responses[0]["result"]["capabilities"]["resources"]["subscribe"] == (era == "legacy") + assert responses[-1]["result"]["structuredContent"]["value"] == "stdio" + + +def test_modern_stdio_rejects_initialize_and_then_serves_a_request(): + app = make_app("modern") + messages = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {}, + "clientInfo": {"name": "probe", "version": "1"}, + }}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": { + "_meta": {PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSION, **CLIENT_META}, + }}, + ] + responses = asyncio.run(_stdio_exchange(app.mcp_server, "modern", messages, 2)) + assert responses[0]["error"]["code"] == -32601 + assert responses[1]["result"]["tools"][0]["name"] == "echo" + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_http_initialize_respects_era(era): + with http_client(era) as (_, _, client): + response = post(client, "initialize", {"protocolVersion": LEGACY_PROTOCOL_VERSION, + "capabilities": {}, "clientInfo": {"name": "probe", "version": "1"}}, + modern=False, version=LEGACY_PROTOCOL_VERSION) + assert response.status_code == 200, response.text + data = decode(response) + if era == "modern": + assert data["error"]["code"] == -32601 + else: + assert data["result"]["capabilities"]["resources"]["subscribe"] is False From 63accb7372aed713fb968a6b9ea19d63030bb9f4 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 27 Oct 2026 18:49:37 +0530 Subject: [PATCH 04/10] test: retain protocol contract table and response echo coverage --- tests/test_mcp20_stateless_http.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_mcp20_stateless_http.py b/tests/test_mcp20_stateless_http.py index 87a178d..49ffb3c 100644 --- a/tests/test_mcp20_stateless_http.py +++ b/tests/test_mcp20_stateless_http.py @@ -16,13 +16,16 @@ validate_header_body_method, ) from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION +from nitrostack.protocol.method_contract import MODERN_METHOD_CONTRACTS from nitrostack.transports.cors import build_cors_headers, cors_preflight_response_headers, resolve_allowed_origin from nitrostack.transports.headers import ( MAX_MCP_PARAM_VALUE_BYTES, + build_mcp_echo_headers, build_mcp_response_headers, build_sse_stream_headers, extract_mcp_param_headers, first_oversized_mcp_param, + handled_protocol_version, merge_mcp_param_headers, scope_with_header_snapshot, scope_without_session_headers, @@ -133,6 +136,26 @@ def test_incoming_session_id_cannot_attach_sessionless_calls(era): assert decode(response)["result"]["structuredContent"]["value"] == expected +def test_method_contract_table_still_covers_modern_surface(): + methods = {row.method for row in MODERN_METHOD_CONTRACTS} + assert {"server/discover", "resources/read", "prompts/get", "completion/complete", + "tasks/get", "tools/call"} <= methods + + +def test_response_echo_uses_supported_request_version(): + echoed = build_mcp_echo_headers( + {"MCP-Protocol-Version": "2025-06-18", "Mcp-Method": "tools/call"}, + protocol_version=MODERN_PROTOCOL_VERSION, + supported_versions={MODERN_PROTOCOL_VERSION, "2025-06-18"}, + ) + assert echoed["MCP-Protocol-Version"] == "2025-06-18" + assert echoed["Mcp-Method"] == "tools/call" + assert handled_protocol_version( + {"MCP-Protocol-Version": "1999-01-01"}, fallback=MODERN_PROTOCOL_VERSION, + supported={MODERN_PROTOCOL_VERSION, "2025-06-18"}, + ) == MODERN_PROTOCOL_VERSION + + class TestRequestHeaders: def test_strip_legacy_session_header(self): headers = {"Mcp-Session-Id": "legacy", "Content-Type": "application/json"} From 53d318e5cb5d9431f737e455e4655b685beec503 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 27 Oct 2026 18:54:49 +0530 Subject: [PATCH 05/10] docs: record PR 36 migration and full test matrix results --- docs/pr36-review-verification.md | 131 +++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/pr36-review-verification.md diff --git a/docs/pr36-review-verification.md b/docs/pr36-review-verification.md new file mode 100644 index 0000000..85dd957 --- /dev/null +++ b/docs/pr36-review-verification.md @@ -0,0 +1,131 @@ +# PR #36: HTTP factory review fixes and verification + +## Baseline and dependency + +The review was reproduced on PR #36 head `e458282b24ef81f05dd6577f336f0d018541c118`. +Complete collection failed on the four modules listed by the reviewer. Running +the importability and supported-version tests directly reproduced both additional +failures. + +Refs recorded before implementation: + +| Ref | Commit | +| --- | --- | +| PR #36 / `feature/issue-23-http-factory` | `e458282b24ef81f05dd6577f336f0d018541c118` | +| PR #34 head | `55512e0b8b5231b066b9adb6b2284bdfc0989160` | +| Upstream `develop` | `b3a2e13ae9f6af024f568b10d7c689927ae8ec2a` | + +Upstream develop was integrated with normal merge commit `8df5fc4`. The merge +kept the sidecar files deleted. The supported-version test fix was already on +develop and arrived with that merge; no supported protocol date was removed. + +PR #36 remains dependent on PR #34 and should land after it. PR #34 was not +modified, and its newer test exclusions were not imported. The separate +spillover branch and PR are not part of this change. + +## Architecture and behavior + +Official MCP 2.x remains the sole owner of `/mcp`: it parses JSON-RPC, dispatches +requests, and serializes responses. No removed ping helper, dispatch pipeline, +body-replay middleware, or wrapper was restored. + +A small public `Server.middleware` hook applies era policy to the SDK's already +parsed request context. It rejects initialization on modern-only servers, +rejects explicit unsupported version claims, and checks that a supplied HTTP +version agrees with its envelope. It delegates valid calls to the official +runner. It does not read or buffer the HTTP request body, dispatch a second +handler, or construct JSON-RPC responses. + +The official SDK uses an implicit `2025-03-26` version when compatible HTTP +requests omit a protocol header. That SDK fallback remains allowed for legacy +compatibility; it is not added as a newly advertised protocol date. Explicit +versions still follow the existing era policy. + +| Era | Explicit supported dates | Resource subscribe capability | +| --- | --- | --- | +| legacy | 2025-06-18, 2025-11-25 | true; existing compatibility behavior | +| auto | 2025-06-18, 2025-11-25, 2026-07-28 | false; subscription requests rejected | +| modern | 2026-07-28 | false; subscription requests rejected | + +The shared subscription predicate is used by discover, capabilities, and +subscribe/unsubscribe handling. Server instances receive the resolved era when +their handlers are configured, so capabilities are correct before HTTP startup, +for additional server instances, and during stdio initialization. + +The existing single CORS layer, era-driven `/json/version`, official session +manager, health routes, slash compatibility, and public stdio runner remain. + +## Test migration map + +| Previous failure or test target | Replacement and retained coverage | +| --- | --- | +| Blueprint import of deleted dispatch classes | Real `server/discover` and tool calls through the mounted HTTP application; capabilities and no-session assertions retained | +| Deprecated-method tests using pipeline and replay doubles | Official HTTP dispatch and registered handlers; sessionless subscription rejection and legacy compatibility tested separately | +| Wire tests using deleted pipeline classes | Actual HTTP method/name/version rejection and successful supported-version requests; parsing, error mapping, identity, and pure validators retained | +| Stateless HTTP imports of deleted ping/dispatch helpers | Mounted-app tool/resource round trips, JSON and SSE responses, all eras, header-only malformed request rejection, unknown-method errors, CORS, slash/health/version checks, verified identity, parameter mirroring, and sessionless isolation | +| Test-module importability | Existing registry remains enabled and unchanged; all registered modules import | +| Supported versions per era | Existing additional legacy date included in test expectations through the develop merge | +| Private stdio dependency | AST checks of runner imports, real memory-stream exchanges, legacy initialization, modern calls, initialization rejection, notifications, and existing stdout safety tests | + +The large test deletion reflects removal of tests of retired implementation +details and duplicate pipeline/replay entry points. It does not exclude files +from pytest. Pure header, CORS, parser, SSE, method-contract-table, and response +echo utility tests remain. Coverage of current behavior is exercised at the +official transport boundary instead of through a replacement sidecar. + +MCP 2.2 maps modern method-not-found errors to HTTP 404; the legacy transport +maps those errors to HTTP 200 with a JSON-RPC error body. Tests assert those +official transport mappings rather than preserving a deleted sidecar's status. + +## Verification + +All tests used MCP **2.2.0**. Full runs used: + +```text +python -m pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 +``` + +| Platform | Python | Complete suite | Coverage | +| --- | --- | --- | --- | +| Windows | 3.12.14 | 807 passed | 86.02% | +| Ubuntu / WSL | 3.10.21 | 807 passed | 86.04% | +| Ubuntu / WSL | 3.11.16 | 807 passed | 86.02% | +| Ubuntu / WSL | 3.12.14 | 807 passed | 86.02% | + +No tests were skipped, marked xfail, ignored, or deselected in these complete +runs. Coverage's separate "1 empty file skipped" message refers to an empty +source file in its report, not a skipped pytest test. + +The final review restored two additional pre-existing pure utility checks: +the method-contract surface and supported response-version echo. Both passed +on all four runtimes after the complete runs above. Final collection contains +**809 tests**, with no collection errors. These two additions do not change +production behavior. The hosted workflow should run all 809 tests on the final +commit; local Linux matrix results above are not a claim about hosted CI status. + +Other recorded checks: + +- The six reviewer-reported test modules: **225 passed** before the two final + utility checks were restored. +- The original issue verification command: **34 passed**. +- The issue #22/#23 ownership/factory and related HTTP/stdio/deprecation set: + **76 passed**. +- AST validation rejects private `mcp.server.runner` imports. +- Production searches find no deleted sidecar wrappers or ping helpers. +- `git diff --check` passes. + +The exact original issue verification command is: + +```text +python -m pytest tests/test_transport_http.py tests/test_lifecycle_http.py tests/test_mcp20_stdio.py tests/test_transports.py -q +``` + +For a fresh environment, install the repository's development extra first: + +```text +python -m pip install -e ".[dev]" +``` + +The existing GitHub workflow provides the Linux Python 3.10/3.11/3.12 matrix. +Its status must be checked separately on the pushed commit. No reviewer reply +was posted as part of the implementation; a response is prepared for user review. From 682630b6bff195d1dc66e1b3910478ca7482a291 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:24:27 +0530 Subject: [PATCH 06/10] fix: retain merged browser header coverage with Starlette CORS --- nitrostack/transports/http.py | 4 +++- tests/test_mcp22_official_http_owner.py | 9 ++++----- tests/test_transports.py | 6 ++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/nitrostack/transports/http.py b/nitrostack/transports/http.py index ebffa0c..e651624 100644 --- a/nitrostack/transports/http.py +++ b/nitrostack/transports/http.py @@ -757,7 +757,9 @@ async def lifespan(app): CORSMiddleware, allow_origins=allow_origins, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], - allow_headers=CORS_ALLOW_HEADERS, + # SEP-2243 permits application-defined Mcp-Param-* names. + # Starlette mirrors requested names when '*' is enabled. + allow_headers=[*CORS_ALLOW_HEADERS, "*"], expose_headers=expose_headers, ), ) diff --git a/tests/test_mcp22_official_http_owner.py b/tests/test_mcp22_official_http_owner.py index a552078..3f61bbd 100644 --- a/tests/test_mcp22_official_http_owner.py +++ b/tests/test_mcp22_official_http_owner.py @@ -268,9 +268,10 @@ def test_cors_honours_the_configured_origin_allowlist(monkeypatch): headers={**_HEADERS, "Origin": "https://app.example.com"}, json=_rpc("ping"), ) - assert allowed.status_code == 204 + assert allowed.status_code == 200 assert allowed.headers["access-control-allow-origin"] == "https://app.example.com" assert "mcp-param-city" in allowed.headers["access-control-allow-headers"].lower() + assert denied.status_code == 400 assert denied.headers.get("access-control-allow-origin") != "*" assert denied.headers.get("access-control-allow-origin") != "https://evil.example.com" assert post.headers["access-control-allow-origin"] == "https://app.example.com" @@ -283,10 +284,8 @@ def test_cors_exposes_session_id_only_on_the_legacy_wire(monkeypatch): for era, exposed in (("legacy", True), ("auto", False)): app = _build(era) with TestClient(app) as client: - response = client.options( - "/mcp", - headers={"Origin": "http://x.test", "Access-Control-Request-Method": "POST"}, - ) + response = client.get("/mcp/health", headers={"Origin": "http://x.test"}) + assert response.status_code == 200 expose = response.headers.get("access-control-expose-headers", "") assert ("Mcp-Session-Id" in expose) is exposed, era diff --git a/tests/test_transports.py b/tests/test_transports.py index 62aa057..f085366 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -165,9 +165,11 @@ def test_http_health_and_cors(): preflight = client.options( "/mcp", - headers={"Origin": "http://example.com", "Access-Control-Request-Method": "POST"}, + headers={"Origin": "http://example.com", "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": + "Mcp-Session-Id, Mcp-Name, Mcp-Method, MCP-Protocol-Version"}, ) - assert preflight.status_code == 204 + assert preflight.status_code == 200 assert preflight.headers.get("access-control-allow-origin") == "*" allow_headers = preflight.headers.get("access-control-allow-headers", "") assert "Mcp-Session-Id" in allow_headers From 90ddc612597c0c744c9e36822698ea0282e563d0 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:30:04 +0530 Subject: [PATCH 07/10] docs: record develop rebase and refreshed PR 36 validation --- docs/pr36-review-verification.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/pr36-review-verification.md b/docs/pr36-review-verification.md index 85dd957..8890ad2 100644 --- a/docs/pr36-review-verification.md +++ b/docs/pr36-review-verification.md @@ -129,3 +129,33 @@ python -m pip install -e ".[dev]" The existing GitHub workflow provides the Linux Python 3.10/3.11/3.12 matrix. Its status must be checked separately on the pushed commit. No reviewer reply was posted as part of the implementation; a response is prepared for user review. + +## Rebase after PR #34 merged (2026-10-28) + +PR #34 merged normally as `805db05`. PR #36 was rebased onto current develop +`63ecd9240d98bbe06c879089dd887494c7e99293`, which also includes the upstream +SecretValue work. The previous head `71e47f1` is preserved locally as +`backup/pr36-before-develop-rebase-20261028`. + +The rebased branch preserves the migrated tests and single Starlette CORS layer. +Conflicts with the merged foundation's older tests/custom CORS layer were +resolved while retaining browser-defined Mcp-Param headers and origin checks. +Comparison against the previously tested merged foundation plus new develop +found no functional differences beyond these retained corrections. + +Complete collection succeeds with **866 tests**. Exact-source local full suites +at rebased implementation head `682630b`, using MCP **2.2.0**, report: + +| Platform | Python | Result | Coverage | +|---|---|---|---| +| Ubuntu / WSL | 3.10.21 | 866 passed | 86.11% | +| Ubuntu / WSL | 3.11.16 | 866 passed | 86.11% | +| Ubuntu / WSL | 3.12.14 | 866 passed | 86.11% | +| Windows | 3.12.14 | 866 passed | 86.11% | + +No ignored files, deselections, skips or xfails were used. `git diff --check` +passes. Logs are retained locally in `.pytest_cache/rebase-linux-3.10.log`, +`rebase-linux-3.11.log`, `rebase-linux-3.12.log` and +`rebase-windows-full.tmp.log`. The hosted CI results above apply to the old head; +hosted CI must be checked again on the rebased published head. PR #34 is no +longer an unmerged dependency. PR #37 remains stacked on this PR until #36 lands. From 63bf72184205f018794a2979d05f10e12b506796 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:12:26 +0530 Subject: [PATCH 08/10] fix(mcp): align modern wire behavior and migrate protocol coverage --- nitrostack/core/app.py | 41 ++--- nitrostack/core/mcp_server.py | 34 ++-- nitrostack/protocol/__init__.py | 16 -- nitrostack/protocol/discovery.py | 58 ++---- nitrostack/protocol/errors.py | 2 + nitrostack/protocol/jsonrpc.py | 76 -------- nitrostack/protocol/method_contract.py | 110 +---------- nitrostack/runtime/stateless.py | 3 +- nitrostack/transports/http.py | 28 ++- tests/test_mcp20_jsonrpc_wire.py | 48 ----- tests/test_mcp20_stateless_http.py | 13 +- tests/test_mcp20_stdio.py | 1 + tests/test_mcp22_official_http_owner.py | 14 +- tests/test_mcp23_http_factory.py | 8 +- tests/test_mcp24_wire_alignment.py | 233 ++++++++++++++++++++++++ 15 files changed, 324 insertions(+), 361 deletions(-) create mode 100644 tests/test_mcp24_wire_alignment.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 68a8db4..5dfe4e9 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -443,7 +443,7 @@ def _read_contents_to_result(uri: str, items: List[ReadResourceContents]) -> typ uri=uri, mimeType=mime, text=str(item.content), **extra ) ) - return types.ReadResourceResult(contents=contents) + return types.ReadResourceResult(contents=contents, resultType="complete") def _http_headers_from_request_ctx(rc: Any) -> Dict[str, str]: @@ -847,10 +847,9 @@ async def health_status_resource(context: ExecutionContext) -> str: # ------------------------------------------------------------------ def _advertise_tasks_extension(self) -> bool: - return any( - entry.config.task_support in ("optional", "required") - for entry in self._tools.values() - ) + # Legacy task handlers do not implement the complete 2026 extension. + # Advertising it is deferred to the dedicated tasks issue. + return False def _custom_extensions(self) -> Optional[Dict[str, str]]: extensions = getattr(self.server_config, "extensions", None) @@ -883,28 +882,6 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict custom_extensions=self._custom_extensions(), ) - def handle_sessionless_initialize(self, requested_version: Optional[str] = None) -> Dict[str, Any]: - """Answer 2025 ``initialize`` on the sessionless ``auto`` path. No session.""" - from nitrostack.protocol.discovery import build_sessionless_initialize_result - - has_widgets = any( - getattr(entry, "component", None) is not None - for entry in getattr(self, "_tools", {}).values() - ) - version = protocol_version_for_era( - getattr(self, "protocol_era", None), - self.server_config.protocol_version, - ) - return build_sessionless_initialize_result( - server_name=self.server_config.name, - server_version=self.server_config.version, - requested_version=requested_version, - protocol_version=version, - advertise_tasks=self._advertise_tasks_extension(), - advertise_app=has_widgets, - custom_extensions=self._custom_extensions(), - ) - def _list_endpoint_cache_meta(self) -> Dict[str, Any]: return build_list_endpoint_cache_hint_meta() @@ -937,6 +914,7 @@ async def _list_tools( return types.ListToolsResult( tools=tools, _meta=self._list_endpoint_cache_meta(), + resultType="complete", ) async def _call_tool(ctx: ServerRequestContext, params: CallToolRequestParams): @@ -953,12 +931,13 @@ async def _call_tool(ctx: ServerRequestContext, params: CallToolRequestParams): }, isError=False, ) - return result + return result.model_copy(update={"result_type": result.result_type or "complete"}) async def _list_resources(_ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams]): return types.ListResourcesResult( resources=[self._build_resource_definition(entry) for entry in self._resources.values()], _meta=self._list_endpoint_cache_meta(), + resultType="complete", ) async def _list_resource_templates(_ctx: ServerRequestContext, _params: Optional[PaginatedRequestParams]): @@ -966,6 +945,7 @@ async def _list_resource_templates(_ctx: ServerRequestContext, _params: Optional resource_templates=[ self._build_resource_template_definition(entry) for entry in self._resource_templates ], + resultType="complete", ) async def _read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams): @@ -989,14 +969,16 @@ async def _list_prompts(_ctx: ServerRequestContext, _params: Optional[PaginatedR return types.ListPromptsResult( prompts=[self._build_prompt_definition(entry) for entry in self._prompts.values()], _meta=self._list_endpoint_cache_meta(), + resultType="complete", ) async def _get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams): arguments = params.arguments or {} - return await self._get_prompt( + result = await self._get_prompt( params.name, {str(key): str(value) for key, value in arguments.items()}, ) + return result.model_copy(update={"result_type": result.result_type or "complete"}) async def _discover(ctx: ServerRequestContext, _params: Optional[RequestParams]): return self.handle_server_discover(getattr(ctx, "protocol_version", None)) @@ -1058,7 +1040,6 @@ async def handle_get_prompt(req): self._register_task_handlers(server) self._register_initialized_handler(server) - server.discover_handler = self.handle_server_discover server.middleware.insert(1, self._request_scope_middleware) def _build_transform_context(self, rc: Any, *, invoker: Any) -> TransformContext: diff --git a/nitrostack/core/mcp_server.py b/nitrostack/core/mcp_server.py index 1c8c490..35a3dae 100644 --- a/nitrostack/core/mcp_server.py +++ b/nitrostack/core/mcp_server.py @@ -13,6 +13,7 @@ from mcp.server.lowlevel.server import NotificationOptions from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler from nitrostack.protocol.errors import JsonRpcErrorCode +from nitrostack.protocol.deprecated import deprecated_method_message, rejects_deprecated_method from nitrostack.protocol.version import ( MODERN_PROTOCOL_VERSION, ProtocolEra, @@ -39,20 +40,24 @@ def __init__(self, name: str, version: Optional[str] = None): name=name, version=version or "", on_subscriptions_listen=self.listen_handler, + on_ping=self._handle_ping, ) self.has_task_support: bool = False self.http_engine: Optional[str] = None self.sessionful: bool = False self.protocol_era: Optional[ProtocolEra] = None self.middleware.append(self._enforce_protocol_era) - self.discover_handler: Optional[Callable[[], dict[str, Any]]] = None - self.initialize_handler: Optional[Callable[[Optional[str]], dict[str, Any]]] = None # In-process tests still look up handlers by request type. self.request_handlers: Dict[Any, Callable[..., Any]] = {} self.notification_handlers: Dict[Any, Callable[..., Any]] = {} async def _enforce_protocol_era(self, context, call_next): """Apply era policy inside the official runner, after its JSON-RPC parsing.""" + if ( + context.protocol_version == MODERN_PROTOCOL_VERSION + and rejects_deprecated_method(context.method, "modern") + ): + raise MCPError(types.METHOD_NOT_FOUND, deprecated_method_message(context.method)) if self.protocol_era == "modern" and context.method in ( "initialize", "notifications/initialized", ): @@ -61,6 +66,15 @@ async def _enforce_protocol_era(self, context, call_next): meta = flatten_request_meta_object(context.meta) header_version = request_headers.get("mcp-protocol-version") meta_version = meta.get("io.modelcontextprotocol/protocolVersion") + if ( + meta_version == MODERN_PROTOCOL_VERSION + and context.request is not None + and not header_version + ): + raise MCPError( + int(JsonRpcErrorCode.HEADER_BODY_MISMATCH), + "MCP-Protocol-Version header is required", + ) try: validate_protocol_version_header_meta(header_version, meta_version) except HeaderBodyMismatchError as exc: @@ -78,17 +92,11 @@ async def _enforce_protocol_era(self, context, call_next): raise MCPError(int(JsonRpcErrorCode.UNSUPPORTED_PROTOCOL_VERSION), "Unsupported protocol version") return await call_next(context) - def handle_server_discover(self) -> dict[str, Any]: - """Answer ``server/discover`` from this server instance.""" - if self.discover_handler is None: - raise RuntimeError("server/discover is not configured on this server") - return self.discover_handler() - - def handle_sessionless_initialize(self, requested_version: Optional[str] = None) -> dict[str, Any]: - """Answer sessionless ``initialize`` from this server instance.""" - if self.initialize_handler is None: - raise RuntimeError("sessionless initialize is not configured on this server") - return self.initialize_handler(requested_version) + async def _handle_ping(self, _context, _params): + """Keep the official ping hook only for the legacy protocol era.""" + if self.protocol_era != "legacy": + raise MCPError(types.METHOD_NOT_FOUND, "Method not found: ping") + return types.EmptyResult() def get_capabilities( self, diff --git a/nitrostack/protocol/__init__.py b/nitrostack/protocol/__init__.py index 1aaf760..7b78b7f 100644 --- a/nitrostack/protocol/__init__.py +++ b/nitrostack/protocol/__init__.py @@ -14,19 +14,11 @@ from nitrostack.protocol.deprecated import deprecated_method_message, rejects_deprecated_method from nitrostack.protocol.method_contract import ( DEPRECATED_MODERN_METHODS, - MODERN_METHOD_CONTRACTS, - MethodContract, - contract_for, - mcp_method_is_required, - mcp_name_field, - mcp_name_is_required, ) from nitrostack.protocol.discovery import ( DISCOVER_RESULT_TYPE, - INITIALIZE_METHOD, SERVER_DISCOVER_METHOD, build_discover_result, - build_sessionless_initialize_result, ) from nitrostack.protocol.errors import ERROR_CODE_MESSAGES, JsonRpcErrorCode from nitrostack.protocol.extensions import MCPExtensionId @@ -153,16 +145,8 @@ "deprecated_method_message", "rejects_deprecated_method", "DEPRECATED_MODERN_METHODS", - "MODERN_METHOD_CONTRACTS", - "MethodContract", - "contract_for", - "mcp_method_is_required", - "mcp_name_field", - "mcp_name_is_required", "DISCOVER_RESULT_TYPE", - "INITIALIZE_METHOD", "SERVER_DISCOVER_METHOD", - "build_sessionless_initialize_result", "build_discover_result", "parse_jsonrpc_request", "jsonrpc_success", diff --git a/nitrostack/protocol/discovery.py b/nitrostack/protocol/discovery.py index 4360261..2d696ae 100644 --- a/nitrostack/protocol/discovery.py +++ b/nitrostack/protocol/discovery.py @@ -10,19 +10,10 @@ from nitrostack.protocol.cache_hints import DEFAULT_LIST_CACHE_TTL_MS, build_list_endpoint_cache_hint_meta from nitrostack.protocol.extensions import MCPExtensionId -from nitrostack.protocol.version import ( - LEGACY_PROTOCOL_VERSION, - MODERN_PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, -) +from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS SERVER_DISCOVER_METHOD = "server/discover" DISCOVER_RESULT_TYPE = "complete" -INITIALIZE_METHOD = "initialize" -INITIALIZED_NOTIFICATION = "notifications/initialized" -_SESSIONLESS_INITIALIZE_VERSIONS = frozenset( - {LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, "2025-11-25"} -) def build_discover_result( @@ -31,7 +22,7 @@ def build_discover_result( server_version: str, protocol_version: str = MODERN_PROTOCOL_VERSION, supported_versions: Optional[tuple[str, ...]] = None, - advertise_tasks: bool = True, + advertise_tasks: bool = False, advertise_app: bool = False, custom_extensions: Optional[dict[str, str]] = None, tools_list_changed: bool = True, @@ -54,7 +45,7 @@ def build_discover_result( if advertise_tasks: extensions[MCPExtensionId.TASKS.value] = {"version": protocol_version} for extension_id, version in (custom_extensions or {}).items(): - if extension_id and version: + if extension_id and version and (extension_id != MCPExtensionId.TASKS.value or advertise_tasks): extensions[str(extension_id)] = {"version": str(version)} capabilities: dict[str, Any] = { @@ -65,46 +56,19 @@ def build_discover_result( if extensions: capabilities["extensions"] = extensions + server_info = {"name": server_name, "version": server_version} + # 2026-07-28 requires `serverInfo` on the result `_meta`; the top-level + # copy stays for `auto`/legacy clients that read it from the body. + meta = build_list_endpoint_cache_hint_meta(ttl_ms=ttl_ms, cache_scope=cache_scope) + meta["serverInfo"] = server_info + return { "resultType": DISCOVER_RESULT_TYPE, "protocolVersion": protocol_version, "supportedVersions": versions, - "serverInfo": {"name": server_name, "version": server_version}, + "serverInfo": server_info, "capabilities": capabilities, "ttlMs": ttl_ms, "cacheScope": cache_scope, - "_meta": build_list_endpoint_cache_hint_meta(ttl_ms=ttl_ms, cache_scope=cache_scope), - } - - -def build_sessionless_initialize_result( - *, - server_name: str, - server_version: str, - requested_version: Optional[str] = None, - protocol_version: str = MODERN_PROTOCOL_VERSION, - advertise_tasks: bool = True, - advertise_app: bool = False, - custom_extensions: Optional[dict[str, str]] = None, -) -> dict[str, Any]: - """ - Initialize-shaped result for era ``auto`` with no session. - - Official v2 ``legacy: 'stateless'`` is not mounted yet. This adapter - answers 2025 ``initialize`` on the same engine as ``server/discover``. - """ - requested = (requested_version or "").strip() - negotiated = requested if requested in _SESSIONLESS_INITIALIZE_VERSIONS else protocol_version - discovered = build_discover_result( - server_name=server_name, - server_version=server_version, - protocol_version=protocol_version, - advertise_tasks=advertise_tasks, - advertise_app=advertise_app, - custom_extensions=custom_extensions, - ) - return { - "protocolVersion": negotiated, - "capabilities": discovered["capabilities"], - "serverInfo": discovered["serverInfo"], + "_meta": meta, } 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/nitrostack/protocol/jsonrpc.py b/nitrostack/protocol/jsonrpc.py index 43c7e87..010f353 100644 --- a/nitrostack/protocol/jsonrpc.py +++ b/nitrostack/protocol/jsonrpc.py @@ -129,82 +129,6 @@ def jsonrpc_method_from_body(raw_body: Optional[bytes]) -> Optional[str]: return None -def validate_header_body_method(header_method: Optional[str], body_method: str) -> None: - """SEP-2243: reject when Mcp-Method header mirrors a different JSON-RPC method.""" - if header_method is None: - return - if header_method != body_method: - raise HeaderBodyMismatchError( - f"Mcp-Method header '{header_method}' does not match body method '{body_method}'" - ) - - -def validate_header_body_name( - header_name: Optional[str], - body_name: Optional[str], -) -> None: - """SEP-2243: reject when Mcp-Name header mirrors a different resource/tool name.""" - if header_name is None or body_name is None: - return - if header_name != body_name: - raise HeaderBodyMismatchError( - f"Mcp-Name header '{header_name}' does not match body name '{body_name}'" - ) - - -REQUIRED_MCP_NAME_MESSAGE = "Mcp-Name header is required" -REQUIRED_MCP_METHOD_MESSAGE = "Mcp-Method header is required" - - -def validate_required_header_body( - header_value: Optional[str], - body_value: Optional[str], - *, - header_name: str, - body_label: str, - required_message: str, -) -> None: - """Require a header and match it to the JSON-RPC body field exactly.""" - header = header_value.strip() if isinstance(header_value, str) else "" - body = body_value.strip() if isinstance(body_value, str) else "" - if not header or not body: - raise HeaderBodyMismatchError(required_message) - if header != body: - raise HeaderBodyMismatchError( - f"{header_name} header '{header_value}' does not match body {body_label} '{body_value}'" - ) - - -def validate_required_mcp_name( - header_name: Optional[str], - body_name: Optional[str], - *, - body_label: str = "name", -) -> None: - """Require ``Mcp-Name`` and match the body name or URI exactly.""" - validate_required_header_body( - header_name, - body_name, - header_name="Mcp-Name", - body_label=body_label, - required_message=REQUIRED_MCP_NAME_MESSAGE, - ) - - -def validate_required_mcp_method( - header_method: Optional[str], - body_method: Optional[str], -) -> None: - """Require ``Mcp-Method`` and match JSON-RPC ``method`` exactly.""" - validate_required_header_body( - header_method, - body_method, - header_name="Mcp-Method", - body_label="method", - required_message=REQUIRED_MCP_METHOD_MESSAGE, - ) - - def validate_protocol_version_header_meta( header_version: Optional[str], meta_version: Optional[str], diff --git a/nitrostack/protocol/method_contract.py b/nitrostack/protocol/method_contract.py index b9142ab..39c908c 100644 --- a/nitrostack/protocol/method_contract.py +++ b/nitrostack/protocol/method_contract.py @@ -1,82 +1,12 @@ -"""SEP-2243 method contracts for the 2026 method surface. +"""Methods retired from the 2026-07-28 wire. -Official v2 owns this table once mounted. Until then the sidecar validator -reads one row per method: required ``Mcp-Method``, optional ``Mcp-Name`` field, -and whether ``auto`` requires the method header. +SEP-2243 header enforcement belongs to the official MCP transport. +This module keeps only the shared retirement policy used by handlers. """ from __future__ import annotations -from dataclasses import dataclass -from typing import Optional - -from nitrostack.protocol.version import ProtocolEra, WireMode - -NAME_FIELD_NAME = "name" -NAME_FIELD_URI = "uri" - - -@dataclass(frozen=True) -class MethodContract: - """Header contract for one JSON-RPC method.""" - - method: str - name_field: Optional[str] = None - # ``auto`` keeps handshake / ping / discover optional (2025 clients). - # ``modern`` requires ``Mcp-Method`` on every JSON-RPC POST. - requires_method_header: bool = True - - -def _contract( - method: str, - *, - name_field: Optional[str] = None, - requires_method_header: bool = True, -) -> MethodContract: - return MethodContract( - method=method, - name_field=name_field, - requires_method_header=requires_method_header, - ) - - -MODERN_METHOD_CONTRACTS: tuple[MethodContract, ...] = ( - _contract("ping", requires_method_header=False), - _contract("initialize", requires_method_header=False), - _contract("notifications/initialized", requires_method_header=False), - _contract("notifications/cancelled"), - _contract("notifications/progress"), - _contract("server/discover", requires_method_header=False), - _contract("subscriptions/listen"), - _contract("notifications/subscriptions/acknowledged"), - _contract("tools/list", requires_method_header=False), - _contract("tools/call", name_field=NAME_FIELD_NAME, requires_method_header=False), - _contract("notifications/tools/list_changed"), - _contract("resources/list", requires_method_header=False), - _contract("resources/templates/list", requires_method_header=False), - _contract("resources/read", name_field=NAME_FIELD_URI), - _contract("resources/subscribe", name_field=NAME_FIELD_URI), - _contract("resources/unsubscribe", name_field=NAME_FIELD_URI), - _contract("notifications/resources/list_changed"), - _contract("notifications/resources/updated"), - _contract("prompts/list", requires_method_header=False), - _contract("prompts/get", name_field=NAME_FIELD_NAME), - _contract("notifications/prompts/list_changed"), - _contract("completion/complete"), - _contract("tasks/get"), - _contract("tasks/cancel"), - _contract("tasks/result"), - _contract("tasks/list"), - _contract("logging/setLevel"), -) - -_CONTRACTS_BY_METHOD: dict[str, MethodContract] = { - row.method: row for row in MODERN_METHOD_CONTRACTS -} - -NAME_SCOPED_METHODS: frozenset[str] = frozenset( - row.method for row in MODERN_METHOD_CONTRACTS if row.name_field -) +from nitrostack.protocol.version import ProtocolEra # 2025 methods removed from the 2026-07-28 wire. Handshake methods # (``initialize``, ``notifications/initialized``) are not in this table: @@ -95,7 +25,7 @@ def _contract( } -def deprecated_method_message(method: str) -> Optional[str]: +def deprecated_method_message(method: str) -> str | None: """Return the modern-wire rejection text when ``method`` is retired.""" return DEPRECATED_MODERN_METHODS.get(method) @@ -103,33 +33,3 @@ def deprecated_method_message(method: str) -> Optional[str]: def rejects_deprecated_method(method: str, era: ProtocolEra) -> bool: """True when era ``modern`` must answer ``method`` as not found.""" return era == "modern" and method in DEPRECATED_MODERN_METHODS - - -def contract_for(method: str) -> Optional[MethodContract]: - """Return the table row for ``method``, or ``None`` when unlisted.""" - return _CONTRACTS_BY_METHOD.get(method) - - -def mcp_name_field(method: str) -> Optional[str]: - """Body field mirrored by ``Mcp-Name``, if this method is name-scoped.""" - row = contract_for(method) - return row.name_field if row is not None else None - - -def mcp_name_is_required(method: str) -> bool: - """``Mcp-Name`` is required on ``tools/call``, ``resources/read``, ``prompts/get``.""" - return mcp_name_field(method) is not None and method in { - "tools/call", - "resources/read", - "prompts/get", - } - - -def mcp_method_is_required(method: str, wire_mode: WireMode) -> bool: - """``modern`` requires ``Mcp-Method`` on every POST. ``auto`` follows the table.""" - if wire_mode == "reject": - return True - row = contract_for(method) - if row is None: - return False - return row.requires_method_header diff --git a/nitrostack/runtime/stateless.py b/nitrostack/runtime/stateless.py index cb849db..16fbcef 100644 --- a/nitrostack/runtime/stateless.py +++ b/nitrostack/runtime/stateless.py @@ -47,7 +47,8 @@ def request_protocol_version( The header wins when present. Otherwise ``_meta.mcp.protocolVersion`` is used. When neither is present the request proceeds (legacy default). The - header is not required on ``modern`` in this sidecar. + HTTP's modern mount requires the header before entering the official + transport; this pure helper also serves legacy/auto compatibility callers. """ for raw in (header_version, envelope_version): if isinstance(raw, str) and raw.strip(): diff --git a/nitrostack/transports/http.py b/nitrostack/transports/http.py index e651624..d5a2b60 100644 --- a/nitrostack/transports/http.py +++ b/nitrostack/transports/http.py @@ -276,17 +276,19 @@ class HeaderCompatMiddleware: ``drop_session_headers`` is set. ``MCP-Protocol-Version`` is never deleted. Duplicate casings are collapsed - to one ``mcp-protocol-version`` entry and the client value is kept so later - version checks see what the client sent. + to lowercase names and client values are preserved so the official + transport can reject duplicate or inconsistent version claims. Stack order on the HTTP app: CORS → this middleware (preserve) → handler. This middleware only normalizes transport headers; JSON-RPC dispatch remains owned by the official MCP Streamable HTTP manager. """ - def __init__(self, app: ASGIApp, *, drop_session_headers: bool = False) -> None: + def __init__(self, app: ASGIApp, *, drop_session_headers: bool = False, + require_protocol_version: bool = False) -> None: self.app = app self.drop_session_headers = drop_session_headers + self.require_protocol_version = require_protocol_version @staticmethod def _normalize_accept(value: Optional[str]) -> Optional[str]: @@ -306,7 +308,7 @@ def _normalize_accept(value: Optional[str]) -> Optional[str]: @staticmethod def _canonicalize_protocol_version(headers: List[Any]) -> List[Any]: - """Keep the protocol version value; emit one lowercase header name.""" + """Normalize casing without hiding duplicates from MCP's validator.""" version_values: List[bytes] = [] kept: List[Any] = [] for key, value in headers: @@ -316,7 +318,7 @@ def _canonicalize_protocol_version(headers: List[Any]) -> List[Any]: kept.append((key, value)) if not version_values: return headers - kept.append((b"mcp-protocol-version", version_values[0])) + kept.extend((b"mcp-protocol-version", value) for value in version_values) return kept async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -325,6 +327,19 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return headers: List[Any] = list(scope.get("headers") or []) + if self.require_protocol_version and scope.get("method") == "POST" and not any( + key.lower() == b"mcp-protocol-version" and value.strip() for key, value in headers + ): + # The official manager selects its strict path using this header. + # Reject before that selection without reading/replaying the body + # or interpreting a method. All JSON-RPC dispatch stays in MCP. + response = JSONResponse( + {"jsonrpc": "2.0", "id": None, "error": { + "code": -32020, "message": "MCP-Protocol-Version header is required", + }}, status_code=400, + ) + await response(scope, receive, send) + return if self.drop_session_headers: headers = strip_legacy_session_headers_asgi(headers) headers = self._canonicalize_protocol_version(headers) @@ -529,7 +544,8 @@ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> # Wraps only the Streamable HTTP mount, so `/mcp/health` and the legacy SSE # routes keep their own (correct) content negotiation. mcp_asgi_app: ASGIApp = HeaderCompatMiddleware( - handle_streamable_http, drop_session_headers=stateless + handle_streamable_http, drop_session_headers=stateless, + require_protocol_version=protocol_era == "modern", ) session_cap: Optional[SessionCapMiddleware] = None if max_sessions and not stateless: diff --git a/tests/test_mcp20_jsonrpc_wire.py b/tests/test_mcp20_jsonrpc_wire.py index f602018..4e8abe0 100644 --- a/tests/test_mcp20_jsonrpc_wire.py +++ b/tests/test_mcp20_jsonrpc_wire.py @@ -17,9 +17,6 @@ build_tool_error_result, map_exception_to_jsonrpc, parse_jsonrpc_request, - validate_header_body_name, - validate_header_body_method, - validate_required_mcp_name, ) from nitrostack.core.context import AuthContext, ExecutionContext from nitrostack.protocol.meta import ( @@ -233,51 +230,6 @@ def test_apply_request_envelope_sets_verified_jwt_user(self): class TestHeaderBodyMismatch: - def test_mcp_method_mismatch(self): - with pytest.raises(HeaderBodyMismatchError) as exc: - validate_header_body_method("tools/list", "tools/call") - assert int(exc.value.code) == HEADER_BODY_MISMATCH - - def test_mcp_name_mismatch(self): - with pytest.raises(HeaderBodyMismatchError): - validate_header_body_name("get_weather", "get_forecast") - - def test_optional_name_allows_missing_header(self): - validate_header_body_name(None, "echo") - - def test_required_mcp_name_rejects_missing_header(self): - with pytest.raises(HeaderBodyMismatchError) as exc: - validate_required_mcp_name(None, "echo") - assert int(exc.value.code) == HEADER_BODY_MISMATCH - assert "required" in exc.value.message - - def test_required_mcp_name_rejects_mismatch(self): - with pytest.raises(HeaderBodyMismatchError) as exc: - validate_required_mcp_name("foo", "bar") - assert int(exc.value.code) == HEADER_BODY_MISMATCH - - def test_required_mcp_name_accepts_exact_match(self): - validate_required_mcp_name("echo", "echo") - - def test_required_mcp_method_rejects_missing_header(self): - from nitrostack.protocol.jsonrpc import validate_required_mcp_method - - with pytest.raises(HeaderBodyMismatchError) as exc: - validate_required_mcp_method(None, "tools/call") - assert int(exc.value.code) == HEADER_BODY_MISMATCH - assert "required" in exc.value.message - - def test_required_mcp_method_rejects_mismatch(self): - from nitrostack.protocol.jsonrpc import validate_required_mcp_method - - with pytest.raises(HeaderBodyMismatchError): - validate_required_mcp_method("tools/list", "tools/call") - - def test_required_mcp_method_accepts_exact_match(self): - from nitrostack.protocol.jsonrpc import validate_required_mcp_method - - validate_required_mcp_method("tools/call", "tools/call") - def test_protocol_version_header_meta_mismatch(self): from nitrostack.protocol.jsonrpc import validate_protocol_version_header_meta diff --git a/tests/test_mcp20_stateless_http.py b/tests/test_mcp20_stateless_http.py index 49ffb3c..6c16365 100644 --- a/tests/test_mcp20_stateless_http.py +++ b/tests/test_mcp20_stateless_http.py @@ -13,10 +13,8 @@ HeaderBodyMismatchError, JsonRpcParseError, parse_jsonrpc_request, - validate_header_body_method, ) from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION -from nitrostack.protocol.method_contract import MODERN_METHOD_CONTRACTS from nitrostack.transports.cors import build_cors_headers, cors_preflight_response_headers, resolve_allowed_origin from nitrostack.transports.headers import ( MAX_MCP_PARAM_VALUE_BYTES, @@ -136,11 +134,6 @@ def test_incoming_session_id_cannot_attach_sessionless_calls(era): assert decode(response)["result"]["structuredContent"]["value"] == expected -def test_method_contract_table_still_covers_modern_surface(): - methods = {row.method for row in MODERN_METHOD_CONTRACTS} - assert {"server/discover", "resources/read", "prompts/get", "completion/complete", - "tasks/get", "tools/call"} <= methods - def test_response_echo_uses_supported_request_version(): echoed = build_mcp_echo_headers( @@ -381,10 +374,6 @@ def test_parse_invalid_json(self): parse_jsonrpc_request(b"{bad") assert exc.value.code == PARSE_ERROR - def test_header_body_mismatch(self): - with pytest.raises(HeaderBodyMismatchError) as exc: - validate_header_body_method("tools/list", "tools/call") - assert exc.value.code == HEADER_BODY_MISMATCH class TestDiscovery: @@ -397,7 +386,7 @@ def test_discover_result_shape(self): assert result["supportedVersions"] == ["2026-07-28"] assert result["serverInfo"]["name"] == "my-stateless-mcp-server" assert result["capabilities"]["resources"]["subscribe"] is False - assert "io.modelcontextprotocol/tasks" in result["capabilities"]["extensions"] + assert "io.modelcontextprotocol/tasks" not in result["capabilities"].get("extensions", {}) assert result["resultType"] == DISCOVER_RESULT_TYPE assert isinstance(result["ttlMs"], int) and result["ttlMs"] >= 0 assert result["cacheScope"] in ("public", "private") diff --git a/tests/test_mcp20_stdio.py b/tests/test_mcp20_stdio.py index 68705a4..bf6a422 100644 --- a/tests/test_mcp20_stdio.py +++ b/tests/test_mcp20_stdio.py @@ -175,6 +175,7 @@ def test_dual_http_and_stdio_share_the_same_tools(monkeypatch): "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Mcp-Method": "tools/list", + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, }, json={ "jsonrpc": "2.0", diff --git a/tests/test_mcp22_official_http_owner.py b/tests/test_mcp22_official_http_owner.py index 3f61bbd..12f1c21 100644 --- a/tests/test_mcp22_official_http_owner.py +++ b/tests/test_mcp22_official_http_owner.py @@ -73,15 +73,16 @@ 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_lists_tools_without_a_default_session_id(): response = _request( "/mcp", method="POST", - payload={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + payload={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, ) assert response.status_code == 200 assert response.headers.get("mcp-session-id") is None - assert response.json() == {"jsonrpc": "2.0", "id": 1, "result": {}} + assert response.json()["id"] == 1 + assert response.json()["result"]["tools"] == [] def test_official_mcp_returns_jsonrpc_error_for_unknown_method(): @@ -229,12 +230,13 @@ def test_legacy_era_transform_context_carries_the_session_id(): def test_modern_era_rejects_the_2025_initialize_on_the_official_path(): + from tests.http_probe import envelope app = _build("modern") with TestClient(app) as client: response = client.post( "/mcp", - headers=_HEADERS, - json=_rpc( + headers={**_HEADERS, "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": "initialize"}, + json=envelope( "initialize", { "protocolVersion": "2025-06-18", @@ -245,7 +247,7 @@ def test_modern_era_rejects_the_2025_initialize_on_the_official_path(): ) body = response.json() assert body["error"]["code"] == -32601 - assert body["error"]["message"] == "Method not found: initialize" + assert "initialize" in body["error"]["message"] assert "mcp-session-id" not in response.headers diff --git a/tests/test_mcp23_http_factory.py b/tests/test_mcp23_http_factory.py index 3cb13a7..69fa934 100644 --- a/tests/test_mcp23_http_factory.py +++ b/tests/test_mcp23_http_factory.py @@ -107,7 +107,13 @@ def test_discover_and_capabilities_agree_on_resources_subscribe(era): assert extra_server.protocol_era == era assert extra_server.get_capabilities().resources.subscribe == (era == "legacy") if era == "auto": - assert app.handle_sessionless_initialize()["capabilities"]["resources"]["subscribe"] is False + with http_client("auto", app=app) as (_, _, client): + response = post(client, "initialize", { + "protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {}, + "clientInfo": {"name": "issue23", "version": "1"}, + }, modern=False, version=LEGACY_PROTOCOL_VERSION) + assert response.status_code == 200 + assert decode(response)["result"]["capabilities"]["resources"]["subscribe"] is False # 3. No private `mcp.server.runner` symbol left in stdio.py. diff --git a/tests/test_mcp24_wire_alignment.py b/tests/test_mcp24_wire_alignment.py new file mode 100644 index 0000000..28e9fd9 --- /dev/null +++ b/tests/test_mcp24_wire_alignment.py @@ -0,0 +1,233 @@ +"""Issue #24 wire behavior through the official MCP mount and public APIs.""" + +import asyncio +import os +from unittest.mock import patch + +import mcp.types as types +from mcp import MCPError +import pytest +from pydantic import BaseModel + +from nitrostack import DIContainer, module, prompt, resource, tool +from nitrostack.core.app import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.core.mcp_server import NitroStackMcpServer +from nitrostack.protocol.contracts import MCP_CACHE_HINT_KEY +from nitrostack.protocol.errors import ERROR_CODE_MESSAGES, JsonRpcErrorCode +from nitrostack.protocol.version import LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION +from nitrostack.transports.http import HeaderCompatMiddleware +from tests.http_probe import decode, envelope, headers, http_client, make_app, post + + +class EchoInput(BaseModel): + value: str = "ok" + + +def wire_app(era): + DIContainer.reset() + class Operations: + @tool(name="echo", description="echo", input_schema=EchoInput, task_support="optional") + async def echo(self, input, context): + return types.CallToolResult( + content=[types.TextContent(type="text", text=input.value)], + _meta={"application/example": "preserved"}, + ) + + @resource(uri="wire://info", name="info", description="info") + async def info(self, context): + return "wire resource" + + @resource(uri="wire://items/{item}", name="item", description="item template") + async def item(self, item, context): + return item + + @prompt(name="greet", description="greet") + async def greet(self, arguments, context): + return [{"role": "user", "content": "hello"}] + + @module(name="wire-operations", providers=[Operations]) + class WireModule: + pass + + @mcp_app(module=WireModule, server=ServerConfig(name="wire-test", version="1.2.3", protocol_era=era)) + class WireApp: + pass + + with patch.dict(os.environ, {"NITRO_MCP_PROTOCOL_VERSION": era, "MCP_STATELESS": ""}): + return asyncio.run(McpApplicationFactory.create(WireApp)) + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +@pytest.mark.parametrize("method,params", [ + ("ping", {}), ("initialize", {}), ("tasks/list", {}), + ("tasks/result", {"taskId": "missing"}), + ("resources/subscribe", {"uri": "probe://info"}), + ("logging/setLevel", {"level": "info"}), +]) +def test_retired_methods_on_the_modern_wire(era, method, params): + with http_client(era) as (_, _, client): + response = post(client, method, params) + assert response.status_code == 404, response.text + assert decode(response)["error"]["code"] == -32601 + + +def test_auto_legacy_shaped_ping_is_also_retired(): + with http_client("auto") as (_, _, client): + response = client.post("/mcp", json=envelope("ping", modern=False), + headers={"Accept": "application/json, text/event-stream"}) + assert response.status_code == 200 + assert decode(response)["error"]["code"] == -32601 + + +def test_legacy_ping_still_works_after_initialization(): + with http_client("legacy") as (_, _, client): + response = post(client, "ping", modern=False, version=LEGACY_PROTOCOL_VERSION) + assert response.status_code == 200 + assert decode(response)["result"] == {} + + +@pytest.mark.parametrize("method,params", [ + ("server/discover", {}), ("tools/list", {}), + ("tools/call", {"name": "echo", "arguments": {}}), + ("resources/read", {"uri": "probe://info"}), +]) +@pytest.mark.parametrize("body_has_modern_meta", [True, False]) +@pytest.mark.parametrize("version", [None, "", " "]) +def test_modern_requests_without_a_version_header_fail_closed(method, params, body_has_modern_meta, version): + with http_client("modern") as (_, _, client): + outgoing = headers(method) + outgoing.pop("MCP-Protocol-Version") + if version is not None: + outgoing["MCP-Protocol-Version"] = version + response = client.post("/mcp", headers=outgoing, + json=envelope(method, params, modern=body_has_modern_meta)) + assert response.status_code == 400, response.text + body = decode(response) + assert body["error"]["code"] == -32020 + assert "result" not in body + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +@pytest.mark.parametrize("method,params,name", [ + ("tools/call", {"name": "echo", "arguments": {}}, "echo"), + ("resources/read", {"uri": "probe://info"}, "probe://info"), + ("prompts/get", {"name": "greet"}, "greet"), +]) +@pytest.mark.parametrize("fault", ["missing-method", "wrong-method", "missing-name", "wrong-name"]) +def test_official_transport_enforces_method_and_name_headers(era, method, params, name, fault): + with http_client(era) as (_, _, client): + outgoing = headers(method, name=name) + if fault == "missing-method": + outgoing.pop("Mcp-Method") + elif fault == "wrong-method": + outgoing["Mcp-Method"] = "tools/list" + elif fault == "missing-name": + outgoing.pop("Mcp-Name") + else: + outgoing["Mcp-Name"] = "wrong" + response = client.post("/mcp", headers=outgoing, json=envelope(method, params)) + assert response.status_code == 400, response.text + assert decode(response)["error"]["code"] == -32020 + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +@pytest.mark.parametrize("method,params", [ + ("tools/list", {}), ("tools/call", {"name": "echo", "arguments": {"value": "hello"}}), + ("resources/list", {}), ("resources/templates/list", {}), + ("resources/read", {"uri": "wire://info"}), + ("prompts/list", {}), ("prompts/get", {"name": "greet"}), +]) +@pytest.mark.parametrize("json_response", [True, False]) +def test_complete_results_use_native_result_type_and_preserve_metadata(era, method, params, json_response): + with http_client(era, app=wire_app(era), json_response=json_response) as (_, _, client): + response = post(client, method, params) + assert response.status_code == 200, response.text + result = decode(response)["result"] + assert result["resultType"] == "complete" + assert result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == "wire-test" + if method == "tools/call": + assert result["content"][0]["text"] == "hello" + assert result["_meta"]["application/example"] == "preserved" + if method in ("tools/list", "resources/list", "prompts/list"): + assert MCP_CACHE_HINT_KEY in result["_meta"] + if method == "resources/templates/list": + assert result["resourceTemplates"][0]["uriTemplate"] == "wire://items/{item}" + assert "mcp-session-id" not in response.headers + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_discovery_uses_one_builder_and_does_not_advertise_unimplemented_tasks(era): + app = wire_app(era) + direct = app.handle_server_discover() + assert direct["_meta"]["serverInfo"] == {"name": "wire-test", "version": "1.2.3"} + assert "io.modelcontextprotocol/tasks" not in direct["capabilities"].get("extensions", {}) + with http_client(era, app=app) as (_, _, client): + response = post(client, "server/discover") + result = decode(response)["result"] + assert result["_meta"]["serverInfo"] == direct["_meta"]["serverInfo"] + assert result["resultType"] == "complete" + assert "io.modelcontextprotocol/tasks" not in result["capabilities"].get("extensions", {}) + + +def test_auto_still_accepts_legacy_initialization_and_tool_calls(): + with http_client("auto") as (_, _, client): + initialized = post(client, "initialize", { + "protocolVersion": LEGACY_PROTOCOL_VERSION, "capabilities": {}, + "clientInfo": {"name": "legacy", "version": "1"}, + }, modern=False, version=LEGACY_PROTOCOL_VERSION) + assert "result" in decode(initialized) + called = post(client, "tools/call", {"name": "echo", "arguments": {"value": "legacy"}}, + modern=False, version=LEGACY_PROTOCOL_VERSION) + assert decode(called)["result"]["structuredContent"]["value"] == "legacy" + + +def test_missing_client_capability_code_is_public_for_the_tasks_followup(): + # Definition/export only: live capability failures belong to the tasks issue. + from nitrostack.protocol import JsonRpcErrorCode as PublicCodes + code = PublicCodes.MISSING_REQUIRED_CLIENT_CAPABILITY + assert int(code) == -32021 + assert ERROR_CODE_MESSAGES[code] == "Missing required client capability" + + +def test_header_rejection_never_reads_or_dispatches_the_body(): + async def scenario(): + async def forbidden(*args): + raise AssertionError("A missing-version rejection must not read or dispatch JSON-RPC") + sent = [] + async def send(message): + sent.append(message) + await HeaderCompatMiddleware(forbidden, require_protocol_version=True)( + {"type": "http", "method": "POST", "headers": []}, forbidden, send, + ) + assert sent[0]["status"] == 400 + asyncio.run(scenario()) + + +@pytest.mark.parametrize("era", ["auto", "modern"]) +def test_duplicate_version_headers_reach_official_rejection(era): + with http_client(era) as (_, _, client): + outgoing = list(headers().items()) + [("MCP-Protocol-Version", LEGACY_PROTOCOL_VERSION)] + response = client.post("/mcp", headers=outgoing, json=envelope()) + assert response.status_code == 400, response.text + assert "result" not in decode(response) + + +def test_auto_modern_envelope_cannot_bypass_version_header_requirement(): + with http_client("auto") as (_, _, client): + response = client.post("/mcp", headers={"Accept": "application/json, text/event-stream"}, + json=envelope()) + assert decode(response)["error"]["code"] == -32020 + + +def test_fresh_server_and_mrtr_results_have_correct_policy_before_http_startup(): + app = make_app("modern") + try: + server = app.create_configured_mcp_server() + assert isinstance(server, NitroStackMcpServer) and server.protocol_era == "modern" + with pytest.raises(MCPError) as exc: + asyncio.run(server._handle_ping(None, None)) + assert exc.value.error.code == -32601 + result = app._to_call_tool_result(types.CallToolResult(content=[], resultType="input_required")) + assert result.result_type == "input_required" + finally: + DIContainer.reset() From 12bc418f3cc4f5c45d78e40af49712c03430d020 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:17:32 +0530 Subject: [PATCH 09/10] docs: record issue 24 wire decisions and full validation results --- docs/issue24-wire-alignment.md | 89 +++++++++++++++++++++++ docs/pr37-review-verification.md | 118 +++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 docs/issue24-wire-alignment.md create mode 100644 docs/pr37-review-verification.md diff --git a/docs/issue24-wire-alignment.md b/docs/issue24-wire-alignment.md new file mode 100644 index 0000000..bcc0235 --- /dev/null +++ b/docs/issue24-wire-alignment.md @@ -0,0 +1,89 @@ +# Issue #24: modern MCP wire alignment + +PR #37 extends the official-transport foundation from PRs #34 and #36. The +official MCP implementation owns JSON-RPC parsing, dispatch, negotiation, SSE and +the modern method/name header checks. NitroStack supplies its handlers and era +policy; it does not recreate the deleted JSON-RPC sidecar. + +## Behavior + +| Area | Result | +|---|---| +| Ping | Available only in the legacy era through MCP's public `on_ping` hook. Auto and modern reject it. | +| Retired methods | Modern requests reject retired methods even when the application is configured as auto. Legacy-compatible auto requests retain their existing supported behavior. | +| Discovery | The shared builder supplies `_meta.serverInfo`, preserving cache hints and its top-level compatibility copy. MCP may omit legacy-only top-level fields when serializing the modern wire. | +| Ordinary results | Tool, resource and prompt handlers use native top-level `resultType`; existing metadata and specialized result types are preserved. | +| Capability error | `JsonRpcErrorCode.MISSING_REQUIRED_CLIENT_CAPABILITY` defines `-32021`, with its default message and public protocol export. NitroStack-specific live task-capability enforcement is deferred to the tasks issue. | +| Tasks extension | The application does not advertise the unimplemented 2026 tasks extension, including through custom discovery extensions. Existing legacy task functionality remains available. | + +## Why a small HTTP header check is still necessary + +MCP 2.2.0 selects its strict modern HTTP entry using `MCP-Protocol-Version`. On +that entry, MCP validates `Mcp-Method`, `Mcp-Name`, protocol metadata, duplicates +and unsupported versions. A missing version header instead selects the SDK's +legacy-compatible HTTP entry, so the strict path cannot enforce its own entry +condition. + +The existing mount's `HeaderCompatMiddleware` therefore rejects a modern-era +POST with a missing or blank version header using HTTP 400 / error `-32020`. +It does not read the body, infer a method, replay input or dispatch a request. +The response uses a null ID because the JSON-RPC body was not inspected. Health +and other routes remain outside this mount-specific check. + +Auto preserves genuine 2025 requests. If a request's body explicitly claims the +modern protocol but lacks the HTTP version header, the existing server middleware +rejects the inconsistency. Stdio has no HTTP headers and is exempt from HTTP-only +requirements. Version-header normalization retains duplicate entries so the +official validator can reject them. + +The retirement middleware also evaluates the request's negotiated protocol, not +only the application's era. This prevents auto's legacy task handlers from +answering methods retired on a modern request. Handler registration still uses +MCP's public APIs. + +## Result fields and compatibility + +The SDK's models provide a native `result_type` field serialized as `resultType`. +The handlers use that field rather than inventing `_meta.resultType`. Model +updates retain application metadata and any already-selected result type. + +Discovery's requested `_meta.serverInfo` is distinct from MCP 2.2.0's reserved +`io.modelcontextprotocol/serverInfo` metadata stamp on ordinary modern results. +Tests check the correct location for each. Existing MRTR and legacy task handling +is retained; this PR does not implement a new tasks or MRTR protocol. + +## Removed machinery and replacement coverage + +Removed code includes the unused sessionless initialize payload builder and +application/server aliases, the test-only modern method-contract table, and the +duplicate optional/required method/name validators. Initialization remains owned +by MCP. + +| Previous coverage | Replacement | +|---|---| +| Direct checks of deleted dispatch/pipeline objects | PR #36's migrated HTTP probes retained through the dependency merge. | +| Required/optional method and name helper tests | HTTP requests with missing and mismatched headers for tools, resources and prompts in auto/modern. | +| Static method-contract membership assertion | Successful real HTTP operations covering the requested tool/resource/prompt surface. | +| Sessionless initialize helper inspection | Actual auto initialization response through the official transport. | +| Ping as an HTTP success probe | Tool listing as the success probe, plus explicit legacy success and modern/auto retirement tests for ping. | + +Pure JSON-RPC parsing/error mapping, protocol-version consistency, CORS utilities, +SSE formatting and response-header utility tests remain. No new skips, xfail +markers or exclusions are used. + +## Dependency merge details + +The updated PR #34 contained the new custom CORS middleware and older versions of +the four migrated test modules. The conflict resolution retains the corrected +PR #36 tests and the single Starlette `CORSMiddleware`. Browser-defined parameter +headers are supported through Starlette's request-header mirroring, while allowed +origins remain governed by `MCP_CORS_ALLOWED_ORIGINS`. + +CORS tests assert Starlette's HTTP 200 preflight response and inspect exposed +headers on actual responses. The dual HTTP/stdio test now includes the required +HTTP version header with its modern body metadata. + +The stash was inspected selectively and remains intact. Generated bytecode, +scaffolded applications, OAuth client changes and spillover changes were not +ported into this implementation. See `pr37-review-verification.md` for exact +commit IDs, reproduction commands and validation results. diff --git a/docs/pr37-review-verification.md b/docs/pr37-review-verification.md new file mode 100644 index 0000000..18b8343 --- /dev/null +++ b/docs/pr37-review-verification.md @@ -0,0 +1,118 @@ +# PR #37 / issue #24 verification + +Validation date: 2026-10-28. Branch: `feature/issue-24-wire-alignment`. +Implementation commit: `0ec5fe265c46d6cbcf35900eca54dcded8865de0`. + +## Recorded inputs and dependency history + +| Input | Commit | +|---|---| +| Published PR #37 head | `e458282b24ef81f05dd6577f336f0d018541c118` | +| Current upstream develop | `e9cf1dbd8127aaa0154d9734586a0245f1bd2e84` | +| PR #34 head | `680c722837f2fa86da03a8aca9b1f10e97dc239f` | +| Corrected PR #36 head | `71e47f10577bb255e18ec745f0ece18c669fedd0` | +| Preserved local stash | `4163a6583b2fc2d3b95f471f64e05d046bd7c318` | + +PRs #34 and #36 were open and unmerged when fetched. The issue branch received +normal merges of PR #36 (`d0391ef`) and the updated PR #34 (`749d4d6`), followed +by the CORS conflict-resolution correction (`e81d34c`). Develop is already an +ancestor through the updated dependency; a subsequent develop merge reported +already up to date. No history was rewritten or force-pushed. + +The issue implementation selectively ports relevant ideas from the stash without +applying or dropping it. Its generated bytecode and scaffolded application were +excluded. OAuth and spillover feature commits are not included. + +## Reviewer failure reproduction + +Collection against an isolated archive of the published head reproduced all four +reviewer-reported errors: **566 tests collected, four collection errors**. + +| Module | Original failure | Correction | +|---|---|---| +| `test_mcp20_blueprint.py` | Deleted dispatch module import | Retain PR #36's actual HTTP discovery/statelessness tests. | +| `test_mcp20_deprecated.py` | Deleted dispatch module import | Retain official HTTP and registered-handler retirement tests. | +| `test_mcp20_jsonrpc_wire.py` | Deleted dispatch module import | Retain pure parsing/error tests and official HTTP validation probes. | +| `test_mcp20_stateless_http.py` | Removed ping helper import | Retain migrated HTTP, malformed-body, CORS and SSE coverage. | + +After dependency integration, collection succeeded with **826 tests**. The final +branch collects **914 tests**, including **99 issue #24 wire cases**. Obsolete +method/name-validator and static method-table checks were replaced with real HTTP +header and successful-operation tests. See `issue24-wire-alignment.md` for the +coverage mapping and architecture explanation. + +## Final results + +All complete runs use **MCP 2.2.0**, the development extra and a 60-second test +timeout. No ignore flags, deselection expressions, new skips or xfail markers +were used. The coverage tool's empty-file message does not indicate a skipped +test. + +| Platform | Python | Complete suite | Coverage | +|---|---|---|---| +| Ubuntu / WSL | 3.10.21 | 914 passed | 86.15% | +| Ubuntu / WSL | 3.11.16 | 914 passed | 86.11% | +| Ubuntu / WSL | 3.12.14 | 914 passed | 86.11% | +| Windows | 3.12.14 | 914 passed | 86.13% | + +The Linux matrix runs an exact `git archive` of implementation commit `0ec5fe2` +on WSL's native filesystem in isolated environments. This avoids the mounted +Windows filesystem's substantial test/build overhead. Logs are copied to the +worktree's ignored `.pytest_cache/linux-final-3.10.log`, +`linux-final-3.11.log` and `linux-final-3.12.log`. The complete final Windows log +is `windows-final.tmp.log`, also ignored. These are local matrix runs, not hosted +GitHub Actions results. + +The combined reviewer/protocol/transport verification set passed **379 tests**: + +```powershell +python -m pytest tests/test_mcp24_wire_alignment.py tests/test_mcp20_blueprint.py tests/test_mcp20_deprecated.py tests/test_mcp20_jsonrpc_wire.py tests/test_mcp20_stateless_http.py tests/test_mcp20_acceptance.py tests/test_mcp20_foundation.py tests/test_mcp22_official_http_owner.py tests/test_mcp23_http_factory.py tests/test_transport_http.py tests/test_lifecycle_http.py tests/test_mcp20_stdio.py tests/test_transports.py -o addopts= -q --timeout=60 --tb=short +``` + +Complete verification commands: + +```powershell +python -m pytest tests --collect-only -o addopts= -q +python -m pytest tests --cov=nitrostack --cov-report=term-missing --timeout=60 --tb=short -o addopts= -q +git diff --check +``` + +## Earlier failures and Windows comparison + +An initial full Windows run reported 912 passed and two failures. One was the +dual HTTP/stdio test sending modern envelope metadata without the required HTTP +version header. Its request now supplies that header and still verifies that +both transports expose the same tools. An earlier Linux 3.10 run also executed +that pre-correction request; the exact-commit final matrix above supersedes it. + +The other Windows failure was the unchanged live OAuth localhost socket test, +`test_discovery_dcr_404_when_disabled_or_missing_client_id`, with WinError 10053. +An archive of the unchanged merged foundation (`e81d34c`), using the same Python +3.12.14 environment and MCP version, reproduced WinError 10053 in the companion +`test_discovery_server_idempotent_start_and_dcr_http` test: **34 passed, 1 failed**. + +Baseline comparison command: + +```powershell +python -m pytest tests/test_oauth.py tests/test_mcp20_stdio.py::test_dual_http_and_stdio_share_the_same_tools -o addopts= -q --timeout=60 --tb=short +``` + +The final complete Windows run passed all tests. The earlier baseline-reproducible +socket failure remains documented as intermittent; its OS-level cause is not +claimed resolved. No OAuth server or socket test was changed or excluded. + +## Static and publication checks + +- `git diff --check` passes and no merge-conflict markers remain. +- Production does not reference the deleted sidecar wrappers or ping helpers. +- Duplicate optional/required method/name validators and the obsolete static + method-contract table are gone; their coverage moved to the official transport. +- The existing stdio AST/public-runner and behavioral regressions pass. +- Dependency files and release version are unchanged by the issue implementation. +- Develop and both dependency heads are ancestors of this branch. +- The saved stash remains intact, and other feature worktrees are unchanged. + +The branch remains stacked on #34 and #36 until they land. PR #37 must target +develop when its updates are published. No push, PR-base edit, reviewer reply or +merge is included in this implementation step. Hosted CI must be checked on the +eventual pushed head. From 0cc7d68c8ddd2faab216cbd1dc40799b3bc4fd0e Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:35:59 +0530 Subject: [PATCH 10/10] docs: record rebased PR 37 matrix and develop socket comparison --- docs/pr37-review-verification.md | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/pr37-review-verification.md b/docs/pr37-review-verification.md index 18b8343..792b3c9 100644 --- a/docs/pr37-review-verification.md +++ b/docs/pr37-review-verification.md @@ -116,3 +116,50 @@ The branch remains stacked on #34 and #36 until they land. PR #37 must target develop when its updates are published. No push, PR-base edit, reviewer reply or merge is included in this implementation step. Hosted CI must be checked on the eventual pushed head. + +## Rebase after PR #34 merged (2026-10-28) + +PR #34 merged normally as `805db05`. Both dependent branches now contain +develop `63ecd9240d98bbe06c879089dd887494c7e99293`, including upstream SecretValue +changes. PR #36 was rebased first; PR #37's issue-specific commits were replayed +onto that rebased foundation. The original local PR #37 head is preserved as +`backup/pr37-before-develop-rebase-20261028`. The original published head was +still `e458282`; publishing uses an explicit force-with-lease against that head. + +PR #37 now targets develop. It still includes PR #36 until that PR merges, but +the already-merged PR #34 commits are no longer outstanding dependency changes. +Tree comparison against the previously validated implementation plus new develop +shows no functional changes lost during the rebase. + +Complete collection succeeds with **954 tests**, including the 99 wire cases. +Exact-source runs at rebased head `a577fe7` use MCP **2.2.0**: + +| Platform | Python | Complete suite | Coverage | +|---|---|---|---| +| Ubuntu / WSL | 3.10.21 | 954 passed | 86.32% | +| Ubuntu / WSL | 3.11.16 | 954 passed | 86.30% | +| Ubuntu / WSL | 3.12.14 | 954 passed | 86.30% | +| Windows | 3.12.14 | 953 passed, 1 socket failure on first run | 86.30% | + +The Windows failure is `test_discovery_dcr_404_when_disabled_or_missing_client_id` +with WinError 10053, matching the intermittent baseline-reproduced failure +documented above. A complete repeat is recorded separately below. No exclusions, +skips or xfails were added. Logs are retained in +`.pytest_cache/rebase-linux-3.10.log`, `rebase-linux-3.11.log`, +`rebase-linux-3.12.log`, `rebase-windows-full.tmp.log` and +`rebase-windows-repeat.tmp.log`. Hosted CI must be checked on the new published +head; the old 914-test results above are historical. + +The complete Windows repeat reported **952 passed, 2 failed** (86.30% coverage), +both unchanged live OAuth discovery socket tests: +`test_discovery_server_idempotent_start_and_dcr_http` and +`test_discovery_dcr_404_when_disabled_or_missing_client_id`, with WinError 10053. +These Windows full suites are not claimed green. No OAuth source or test changes +were introduced by this rebase, and none of these tests were excluded. + +A fresh archive of current develop `63ecd92`, tested with the same Windows +Python/MCP environment, reproduced **both** failures: **72 passed, 2 failed**. +The command was `python -m pytest tests/test_oauth.py tests/test_secret_value.py +-o addopts= -q --timeout=60 --tb=short`. The log is +`.pytest_cache/rebase-develop-windows.log`. Import-path inspection confirmed the +archive's package was loaded rather than the editable feature checkout.