From 2bb5dfa0e3489fb1221f32d30ff2b35929bda364 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:12:26 +0530 Subject: [PATCH 1/3] fix(mcp): align modern wire behavior and migrate protocol coverage --- nitrostack/core/app.py | 43 ++--- 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 | 108 +---------- nitrostack/runtime/stateless.py | 4 +- nitrostack/transports/http.py | 28 ++- tests/test_mcp20_jsonrpc_wire.py | 48 ----- tests/test_mcp20_stateless_http.py | 15 +- 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, 326 insertions(+), 362 deletions(-) create mode 100644 tests/test_mcp24_wire_alignment.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 5848ab3..5527d38 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -446,7 +446,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]: @@ -916,12 +916,9 @@ def _tasks_visible_on_wire(self) -> bool: return self.protocol_era not in ("auto", "modern") def _advertise_tasks_extension(self) -> bool: - if not self._tasks_visible_on_wire(): - return False - 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) @@ -954,28 +951,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() @@ -1010,6 +985,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): @@ -1030,7 +1006,7 @@ async def _call_tool(ctx: ServerRequestContext, params: CallToolRequestParams): }, isError=False, ) - return result + return result.model_copy(update={"result_type": result.result_type or "complete"}) finally: request_ctx.reset(token) @@ -1038,6 +1014,7 @@ async def _list_resources(_ctx: ServerRequestContext, _params: Optional[Paginate 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]): @@ -1045,6 +1022,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): @@ -1068,14 +1046,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)) @@ -1145,7 +1125,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 2c3403d..39c908c 100644 --- a/nitrostack/protocol/method_contract.py +++ b/nitrostack/protocol/method_contract.py @@ -1,83 +1,19 @@ -"""SEP-2243 method contracts for the 2026 method surface. +"""Methods retired from the 2026-07-28 wire. -The official MCP SDK owns request dispatch. This table records the method/header -contract used by the 2026 protocol checks: 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("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: # ``modern`` rejects them as method-not-found; ``auto`` still answers them. DEPRECATED_MODERN_METHODS: dict[str, str] = { + "tasks/result": "Method 'tasks/result' is not supported in MCP 2026-07-28; use 'tasks/get'.", + "tasks/list": "Method 'tasks/list' is not supported in modern stateless MCP 2026-07-28.", "resources/subscribe": ( "Method 'resources/subscribe' is not supported in stateless MCP 2026-07-28; " "use SSE subscriptions/listen." @@ -89,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) @@ -97,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 778b90d..16fbcef 100644 --- a/nitrostack/runtime/stateless.py +++ b/nitrostack/runtime/stateless.py @@ -47,8 +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`` because the official SDK owns the - HTTP request lifecycle. + 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 637b235..3c849aa 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 840a5b3..169b61c 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,13 +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", - "tools/call"} <= methods - assert not any(method.startswith("tasks/") for method in methods) - - def test_response_echo_uses_supported_request_version(): echoed = build_mcp_echo_headers( {"MCP-Protocol-Version": "2025-06-18", "Mcp-Method": "tools/call"}, @@ -382,10 +373,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: @@ -398,7 +385,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 2ad71042aa220d86919b9743f0854645441a020a Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:17:32 +0530 Subject: [PATCH 2/3] 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 166bb151d59e60bc6e00914df96408ad92a4e3ea Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:35:59 +0530 Subject: [PATCH 3/3] 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.