From d43270b914de42c90cf58be7b3934f94c3c4f668 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Wed, 28 Oct 2026 18:12:26 +0530 Subject: [PATCH 1/2] fix(mcp): align modern wire behavior and migrate protocol coverage --- nitrostack/core/app.py | 44 ++--- nitrostack/core/mcp_server.py | 34 ++-- nitrostack/protocol/__init__.py | 16 -- nitrostack/protocol/discovery.py | 58 ++---- 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 | 19 +- tests/test_mcp23_http_factory.py | 8 +- tests/test_mcp24_wire_alignment.py | 233 ++++++++++++++++++++++++ 14 files changed, 333 insertions(+), 359 deletions(-) create mode 100644 tests/test_mcp24_wire_alignment.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 2b6e0bd..3b4d0f5 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -457,7 +457,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]: @@ -960,12 +960,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) @@ -1018,28 +1015,6 @@ def _stamp_discover_meta(self, result: Dict[str, Any]) -> Dict[str, Any]: out.setdefault("resultType", "complete") return out - def handle_sessionless_initialize(self, requested_version: Optional[str] = None) -> Dict[str, Any]: - """Answer 2025 ``initialize`` on the sessionless ``auto`` path. No session.""" - 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() @@ -1074,6 +1049,7 @@ async def _list_tools( return types.ListToolsResult( tools=tools, _meta=self._list_endpoint_cache_meta(), + resultType="complete", ) async def _ping(_ctx: ServerRequestContext, _params: Optional[RequestParams]): @@ -1099,7 +1075,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) @@ -1107,6 +1083,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]): @@ -1114,6 +1091,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): @@ -1137,14 +1115,17 @@ 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"}) + server.add_request_handler("ping", RequestParams, _ping) async def _discover(ctx: ServerRequestContext, _params: Optional[RequestParams]): return self.handle_server_discover(getattr(ctx, "protocol_version", None)) @@ -1214,7 +1195,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/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 333be84..6773d3c 100644 --- a/tests/test_mcp20_stdio.py +++ b/tests/test_mcp20_stdio.py @@ -173,6 +173,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 26aa51c..bbfabfc 100644 --- a/tests/test_mcp22_official_http_owner.py +++ b/tests/test_mcp22_official_http_owner.py @@ -78,6 +78,18 @@ def test_health_endpoint_remains_available(): assert response.json()["status"] == "ok" +def test_official_mcp_lists_tools_without_a_default_session_id(): + response = _request( + "/mcp", + method="POST", + 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()["id"] == 1 + assert response.json()["result"]["tools"] == [] + + def test_official_mcp_ping_is_method_not_found_without_a_default_session_id(): response = _request( "/mcp", @@ -236,12 +248,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", @@ -252,7 +265,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 f304d93..d222156 100644 --- a/tests/test_mcp23_http_factory.py +++ b/tests/test_mcp23_http_factory.py @@ -111,7 +111,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 runner implementation imports or private MCP modules 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 0dc6bf108643804733df92fda89a46872c97f92a Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Wed, 4 Nov 2026 15:11:35 +0530 Subject: [PATCH 2/2] refactor(mcp): drop test-only registries and dead task-guard code Remove runtime/acceptance.py, runtime/conformance.py and protocol/layers.py. Nothing in production imported them; they only backed layout/count tests, which go with them. Behavioural tests in the same files stay. In core/app.py, drop the second copy of the 2026 task-augmentation guard, the -32021 branch between the two copies (has_task_support is only False on auto/modern, which the first guard already rejects with -32602), the unused _stamp_discover_meta() helper and the now-unused import. Co-Authored-By: Claude Opus 5.5 --- nitrostack/core/app.py | 24 ---- nitrostack/protocol/__init__.py | 2 - nitrostack/protocol/layers.py | 12 -- nitrostack/runtime/acceptance.py | 204 ------------------------------ nitrostack/runtime/conformance.py | 98 -------------- tests/test_mcp20_acceptance.py | 76 ----------- tests/test_mcp20_blueprint.py | 35 +---- tests/test_mcp20_foundation.py | 12 -- 8 files changed, 1 insertion(+), 462 deletions(-) delete mode 100644 nitrostack/protocol/layers.py delete mode 100644 nitrostack/runtime/acceptance.py delete mode 100644 nitrostack/runtime/conformance.py diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 3b4d0f5..99e2970 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -15,7 +15,6 @@ from nitrostack.transforms import ( SyntheticTool, ToolArgumentError, TransformContext, validate_tool_arguments, ) -from nitrostack.protocol.errors import JsonRpcErrorCode import mcp.types as types from mcp import MCPError from mcp.server.context import ServerRequestContext @@ -1003,18 +1002,6 @@ def handle_server_discover(self, protocol_version: Optional[str] = None) -> Dict result.setdefault("resultType", "complete") return result - def _stamp_discover_meta(self, result: Dict[str, Any]) -> Dict[str, Any]: - """2026 puts serverInfo and resultType on the result, keeping any cache hint in _meta.""" - out = dict(result) - meta = dict(out.get("_meta") or {}) - meta["io.modelcontextprotocol/serverInfo"] = { - "name": self.server_config.name, - "version": self.server_config.version, - } - out["_meta"] = meta - out.setdefault("resultType", "complete") - return out - def _list_endpoint_cache_meta(self) -> Dict[str, Any]: return build_list_endpoint_cache_hint_meta() @@ -1685,17 +1672,6 @@ async def _call_real_tool(self, name: str, arguments: Dict[str, Any], task: Any trace = _trace_context_from_request_ctx(rc) is_task = task_metadata is not None - if self.protocol_era in ("auto", "modern") and is_task: - raise MCPError( - types.INVALID_PARAMS, - "Server does not support task augmentation on the MCP 2026 wire", - ) - if is_task and not getattr(self.mcp_server, "has_task_support", True): - raise MCPError( - JsonRpcErrorCode.MISSING_REQUIRED_CLIENT_CAPABILITY, - f"Tasks are not available on this protocol version; tool '{cfg.name}' " - "cannot be run as a task.", - ) if self.protocol_era in ("auto", "modern") and is_task: raise MCPError( types.INVALID_PARAMS, diff --git a/nitrostack/protocol/__init__.py b/nitrostack/protocol/__init__.py index 7b78b7f..40a02d7 100644 --- a/nitrostack/protocol/__init__.py +++ b/nitrostack/protocol/__init__.py @@ -30,7 +30,6 @@ map_exception_to_jsonrpc, parse_jsonrpc_request, ) -from nitrostack.protocol.layers import RuntimeLayer from nitrostack.protocol.cache_hints import ( build_list_endpoint_cache_hint_meta, resolve_resource_cache_hint_meta, @@ -127,7 +126,6 @@ "supported_protocol_versions_for_era", "wire_mode_for_era", "MCPExtensionId", - "RuntimeLayer", "LEGACY_SESSION_HEADER", "MAX_CIMD_BYTES", "MAX_SCHEMA_DEPTH", diff --git a/nitrostack/protocol/layers.py b/nitrostack/protocol/layers.py deleted file mode 100644 index 7f30a65..0000000 --- a/nitrostack/protocol/layers.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Runtime layer identifiers for the MCP 2026-07-28 architecture.""" - -from enum import Enum - - -class RuntimeLayer(str, Enum): - """Decoupled layers of the NitroStack stateless MCP runtime.""" - - TRANSPORT_SECURITY = "transport_security" - PROTOCOL_DISPATCHER = "protocol_dispatcher" - REGISTRIES = "registries" - TASK_MANAGEMENT = "task_management" diff --git a/nitrostack/runtime/acceptance.py b/nitrostack/runtime/acceptance.py deleted file mode 100644 index 7183445..0000000 --- a/nitrostack/runtime/acceptance.py +++ /dev/null @@ -1,204 +0,0 @@ -"""MCP 2026-07-28 feature-area and acceptance-criteria registry.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import IntEnum -from typing import Iterable - -from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION - - -class ProtocolArea(IntEnum): - """Major MCP 2026-07-28 implementation areas.""" - - CORE_JSONRPC = 1 - STATELESS_HTTP = 2 - REGISTRIES_SCHEMA = 3 - ASYNC_TASKS = 4 - MULTI_TENANT = 5 - OAUTH_CIMD = 6 - MRTR_OBSERVABILITY = 7 - - -@dataclass(frozen=True) -class ProtocolDeliverable: - area: ProtocolArea - item: str - test_module: str - - -@dataclass(frozen=True) -class AcceptanceCriterion: - key: str - description: str - test_module: str - - -PROTOCOL_DELIVERABLES: tuple[ProtocolDeliverable, ...] = ( - ProtocolDeliverable( - ProtocolArea.CORE_JSONRPC, - "JSON-RPC error code hierarchy (-32700..-32603, -32020)", - "tests/test_mcp20_jsonrpc_wire.py", - ), - ProtocolDeliverable( - ProtocolArea.CORE_JSONRPC, - "SEP-2164 missing resource returns -32602", - "tests/test_mcp20_contracts.py", - ), - ProtocolDeliverable( - ProtocolArea.CORE_JSONRPC, - "Reject tasks/result and tasks/list on modern wire", - "tests/test_mcp20_tasks.py", - ), - ProtocolDeliverable( - ProtocolArea.CORE_JSONRPC, - "Deprecated methods share one policy on every modern route", - "tests/test_mcp20_deprecated.py", - ), - ProtocolDeliverable( - ProtocolArea.CORE_JSONRPC, - "Strip envelope keys from tool arguments before handlers", - "tests/test_mcp20_tool_args.py", - ), - ProtocolDeliverable( - ProtocolArea.STATELESS_HTTP, - "POST /mcp stateless ingress without Mcp-Session-Id", - "tests/test_mcp20_stateless_http.py", - ), - ProtocolDeliverable( - ProtocolArea.STATELESS_HTTP, - "server/discover and ping fast path", - "tests/test_mcp20_stateless_http.py", - ), - ProtocolDeliverable( - ProtocolArea.STATELESS_HTTP, - "CORS headers for MCP preflight", - "tests/test_mcp20_stateless_http.py", - ), - ProtocolDeliverable( - ProtocolArea.STATELESS_HTTP, - "GET /subscriptions/listen SSE attach on modern and auto", - "tests/test_mcp20_subscriptions.py", - ), - ProtocolDeliverable( - ProtocolArea.STATELESS_HTTP, - "Concurrent identical JSON-RPC ids stay isolated", - "tests/test_mcp20_concurrency.py", - ), - ProtocolDeliverable( - ProtocolArea.STATELESS_HTTP, - "Official mcp 2.x stdio follows the active era", - "tests/test_mcp20_stdio.py", - ), - ProtocolDeliverable( - ProtocolArea.REGISTRIES_SCHEMA, - "JSON Schema 2020-12 with depth bounding (max 64)", - "tests/test_mcp20_contracts.py", - ), - ProtocolDeliverable( - ProtocolArea.REGISTRIES_SCHEMA, - "Resource URI templates and resolution", - "tests/test_mcp20_contracts.py", - ), - ProtocolDeliverable( - ProtocolArea.ASYNC_TASKS, - "Legacy/in-process Task state machine and embedded result", - "tests/test_mcp20_tasks.py", - ), - ProtocolDeliverable( - ProtocolArea.ASYNC_TASKS, - "Pluggable TaskStore and terminal-only TTL eviction", - "tests/test_mcp20_task_store.py", - ), - ProtocolDeliverable( - ProtocolArea.MULTI_TENANT, - "TaskAccessContext isolation and anti-enumeration", - "tests/test_mcp20_task_authorization.py", - ), - ProtocolDeliverable( - ProtocolArea.OAUTH_CIMD, - "CIMD resolver with SSRF defenses and RFC 9207 iss", - "tests/test_mcp20_oauth_cimd.py", - ), - ProtocolDeliverable( - ProtocolArea.MRTR_OBSERVABILITY, - "MRTR input_required elicitation helpers", - "tests/test_mcp20_mrtr.py", - ), - ProtocolDeliverable( - ProtocolArea.MRTR_OBSERVABILITY, - "Extensions map, trace context, and cache hints", - "tests/test_mcp20_extensions_cache_observability.py", - ), -) - -ACCEPTANCE_CRITERIA: tuple[AcceptanceCriterion, ...] = ( - AcceptanceCriterion( - "stateless", - "No Mcp-Session-Id emitted; stateless POST /mcp works", - "tests/test_mcp20_blueprint.py", - ), - AcceptanceCriterion( - "task_conformance", - "Task-augmented tools/call returns immediate TaskData", - "tests/test_mcp20_tasks.py", - ), - AcceptanceCriterion( - "task_cancellation", - "Legacy tasks/cancel marks cancelled; terminal cancel returns -32602", - "tests/test_mcp20_tasks.py", - ), - AcceptanceCriterion( - "ttl_safety", - "Active tasks never evicted; terminal TTL from lastUpdatedAt", - "tests/test_mcp20_task_store.py", - ), - AcceptanceCriterion( - "multi_tenant", - "Cross-tenant access returns TaskNotFoundError / -32602", - "tests/test_mcp20_task_authorization.py", - ), - AcceptanceCriterion( - "ssrf_security", - "CIMD blocks special-use IPs and oversized payloads", - "tests/test_mcp20_oauth_cimd.py", - ), - AcceptanceCriterion( - "error_parity", - "JSON-RPC codes match specification including SEP-2164", - "tests/test_mcp20_jsonrpc_wire.py", - ), - AcceptanceCriterion( - "automated_tests", - "pytest suite covers all seven protocol areas", - "tests/test_mcp20_acceptance.py", - ), -) - -MCP20_TEST_MODULES: tuple[str, ...] = tuple( - sorted({d.test_module for d in PROTOCOL_DELIVERABLES} | {c.test_module for c in ACCEPTANCE_CRITERIA}) -) - - -def deliverables_for_area(area: ProtocolArea) -> tuple[ProtocolDeliverable, ...]: - return tuple(item for item in PROTOCOL_DELIVERABLES if item.area == area) - - -def protocol_coverage_complete() -> bool: - """True when every protocol area has at least one mapped deliverable.""" - return all(deliverables_for_area(area) for area in ProtocolArea) - - -def acceptance_criteria_registered() -> bool: - return len(ACCEPTANCE_CRITERIA) >= 8 - - -def modern_protocol_target() -> str: - return MODERN_PROTOCOL_VERSION - - -def iter_area_summary() -> Iterable[str]: - for area in ProtocolArea: - items = deliverables_for_area(area) - yield f"Area {area.value}: {len(items)} deliverable(s)" diff --git a/nitrostack/runtime/conformance.py b/nitrostack/runtime/conformance.py deleted file mode 100644 index 177c169..0000000 --- a/nitrostack/runtime/conformance.py +++ /dev/null @@ -1,98 +0,0 @@ -"""MCP 2026-07-28 implementation blueprint conformance registry.""" - -from __future__ import annotations - -from enum import Enum -from importlib import import_module -from pathlib import Path -from typing import Iterable - -from nitrostack.protocol.errors import JsonRpcErrorCode -from nitrostack.protocol.method_contract import DEPRECATED_MODERN_METHODS as _DEPRECATED_MESSAGES -from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION - -_PACKAGE_ROOT = Path(__file__).resolve().parent.parent - - -class ConformanceArea(str, Enum): - """Five verification areas for the MCP 2026-07-28 blueprint.""" - - STATELESS_HTTP = "stateless_http" - JSONRPC = "jsonrpc" - TASKS = "tasks" - MULTI_TENANT = "multi_tenant" - CIMD_SSRF = "cimd_ssrf" - - -BLUEPRINT_CONFORMANCE_AREAS: dict[ConformanceArea, str] = { - ConformanceArea.STATELESS_HTTP: ( - "Stateless POST /mcp, server/discover on 2026-07-28, no Mcp-Session-Id, CORS" - ), - ConformanceArea.JSONRPC: ( - "Standard JSON-RPC error codes; deprecated methods rejected on modern wire" - ), - ConformanceArea.TASKS: ( - "MCP 2026 hides the incomplete Tasks surface; legacy/in-process task lifecycle remains" - ), - ConformanceArea.MULTI_TENANT: ( - "Cross-tenant task access raises TaskNotFoundError without enumeration" - ), - ConformanceArea.CIMD_SSRF: ( - "CIMD fetch blocks special-use IPs, redirects, and payloads over 5 KiB" - ), -} - -# Canonical module paths implementing the recommended package layout. -REQUIRED_BLUEPRINT_MODULES: tuple[str, ...] = ( - "nitrostack.core.app", - "nitrostack.core.context", - "nitrostack.core.decorators", - "nitrostack.core.errors", - "nitrostack.core.task", - "nitrostack.protocol.version", - "nitrostack.protocol.schema", - "nitrostack.protocol.mrtr", - "nitrostack.protocol.cache_hints", - "nitrostack.protocol.observability", - "nitrostack.tasks.store", - "nitrostack.tasks.memory", - "nitrostack.auth.cimd", - "nitrostack.auth.oauth", - "nitrostack.transports.http", - "nitrostack.transports.sse", - "nitrostack.transports.stdio", -) - -MODERN_JSONRPC_ERROR_CODES: frozenset[int] = frozenset( - { - JsonRpcErrorCode.PARSE_ERROR, - JsonRpcErrorCode.INVALID_REQUEST, - JsonRpcErrorCode.METHOD_NOT_FOUND, - JsonRpcErrorCode.INVALID_PARAMS, - JsonRpcErrorCode.INTERNAL_ERROR, - } -) - -DEPRECATED_MODERN_METHODS: frozenset[str] = frozenset(_DEPRECATED_MESSAGES) - - -def verify_package_layout(modules: Iterable[str] | None = None) -> list[str]: - """Return import errors for any required blueprint module that cannot load.""" - missing: list[str] = [] - for module_path in modules or REQUIRED_BLUEPRINT_MODULES: - try: - import_module(module_path) - except Exception as exc: # pragma: no cover - surfaced by tests - missing.append(f"{module_path}: {exc}") - return missing - - -def assert_blueprint_layout() -> None: - """Raise ``ImportError`` when a required blueprint module is unavailable.""" - errors = verify_package_layout() - if errors: - raise ImportError("Blueprint package layout incomplete:\n" + "\n".join(errors)) - - -def protocol_version_matches_blueprint() -> bool: - return MODERN_PROTOCOL_VERSION == "2026-07-28" diff --git a/tests/test_mcp20_acceptance.py b/tests/test_mcp20_acceptance.py index a6b01b7..ef158fb 100644 --- a/tests/test_mcp20_acceptance.py +++ b/tests/test_mcp20_acceptance.py @@ -3,11 +3,9 @@ from __future__ import annotations import asyncio -import importlib.util import os import sys import time -from pathlib import Path import mcp.types as types import pytest @@ -22,22 +20,8 @@ from nitrostack.core.task import TaskManager, TaskStatus from nitrostack.protocol.constants import LEGACY_SESSION_HEADER from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION -from nitrostack.runtime.acceptance import ( - ACCEPTANCE_CRITERIA, - MCP20_TEST_MODULES, - PROTOCOL_DELIVERABLES, - ProtocolArea, - acceptance_criteria_registered, - deliverables_for_area, - iter_area_summary, - modern_protocol_target, - protocol_coverage_complete, -) -from nitrostack.runtime.conformance import assert_blueprint_layout -REPO_ROOT = Path(__file__).resolve().parents[1] - class EchoInput(BaseModel): value: str = Field(default="") @@ -51,50 +35,6 @@ def teardown_function() -> None: DIContainer.reset() -class TestProtocolRegistry: - def test_all_areas_have_deliverables(self): - assert protocol_coverage_complete() - for line in iter_area_summary(): - assert "0 deliverable" not in line - - def test_deliverable_count(self): - assert len(PROTOCOL_DELIVERABLES) >= 14 - - def test_acceptance_criteria_registered(self): - assert acceptance_criteria_registered() - keys = {item.key for item in ACCEPTANCE_CRITERIA} - assert keys == { - "stateless", - "task_conformance", - "task_cancellation", - "ttl_safety", - "multi_tenant", - "ssrf_security", - "error_parity", - "automated_tests", - } - - def test_modern_protocol_target(self): - assert modern_protocol_target() == "2026-07-28" - - @pytest.mark.parametrize("area", list(ProtocolArea)) - def test_each_area_maps_to_tests(self, area: ProtocolArea): - items = deliverables_for_area(area) - assert items, f"Area {area} has no deliverables" - for item in items: - assert item.test_module.startswith("tests/test_mcp20_") - - -class TestLayoutAndTestTraceability: - def test_all_mcp20_test_modules_exist(self): - for module_path in MCP20_TEST_MODULES: - path = REPO_ROOT / module_path - assert path.is_file(), module_path - - def test_blueprint_layout_importable(self): - assert_blueprint_layout() - - class TestAcceptanceTaskConformance: def test_task_augmented_call_returns_immediately(self): @injectable() @@ -208,19 +148,3 @@ def test_server_config_defaults_to_modern_protocol(self): cfg = ServerConfig(name="x") assert cfg.protocol_version == MODERN_PROTOCOL_VERSION - -class TestAutomatedTestCoverageMap: - def test_mcp20_test_modules_importable(self): - for module_path in MCP20_TEST_MODULES: - file_path = REPO_ROOT / module_path - spec = importlib.util.spec_from_file_location( - module_path.replace("/", "_").replace(".", "_"), - file_path, - ) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - def test_full_mcp20_suite_module_count(self): - suite = sorted(REPO_ROOT.glob("tests/test_mcp20_*.py")) - assert len(suite) == 17 diff --git a/tests/test_mcp20_blueprint.py b/tests/test_mcp20_blueprint.py index 2c34e03..e7d81c2 100644 --- a/tests/test_mcp20_blueprint.py +++ b/tests/test_mcp20_blueprint.py @@ -28,16 +28,7 @@ from nitrostack.protocol.extensions import MCPExtensionId from nitrostack.protocol.jsonrpc import map_exception_to_jsonrpc from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION -from nitrostack.runtime.conformance import ( - BLUEPRINT_CONFORMANCE_AREAS, - ConformanceArea, - DEPRECATED_MODERN_METHODS, - MODERN_JSONRPC_ERROR_CODES, - REQUIRED_BLUEPRINT_MODULES, - assert_blueprint_layout, - protocol_version_matches_blueprint, - verify_package_layout, -) +from nitrostack.protocol.method_contract import DEPRECATED_MODERN_METHODS 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 @@ -72,23 +63,6 @@ def teardown_function() -> None: DIContainer.reset() -class TestBlueprintRegistry: - def test_conformance_areas_defined(self): - assert set(BLUEPRINT_CONFORMANCE_AREAS) == set(ConformanceArea) - assert len(BLUEPRINT_CONFORMANCE_AREAS) == 5 - - def test_modern_protocol_version(self): - assert protocol_version_matches_blueprint() - assert MODERN_PROTOCOL_VERSION == "2026-07-28" - - def test_required_modules_import(self): - assert verify_package_layout() == [] - assert_blueprint_layout() - - def test_blueprint_module_count(self): - assert len(REQUIRED_BLUEPRINT_MODULES) >= 17 - - class TestStatelessHttpConformance: def test_discover_advertises_modern_protocol_and_extensions(self): result = build_discover_result( @@ -121,13 +95,6 @@ def test_cors_preflight_includes_mcp_headers(self): class TestJsonRpcConformance: - @pytest.mark.parametrize( - "code", - sorted(MODERN_JSONRPC_ERROR_CODES), - ) - def test_standard_error_codes_registered(self, code: JsonRpcErrorCode): - assert int(code) in MODERN_JSONRPC_ERROR_CODES - def test_missing_resource_maps_to_invalid_params(self): resp = map_exception_to_jsonrpc(ResourceNotFoundError("mcp://missing"), "99") assert resp["error"]["code"] == JsonRpcErrorCode.INVALID_PARAMS diff --git a/tests/test_mcp20_foundation.py b/tests/test_mcp20_foundation.py index 9d57c28..4299400 100644 --- a/tests/test_mcp20_foundation.py +++ b/tests/test_mcp20_foundation.py @@ -9,7 +9,6 @@ MAX_SCHEMA_DEPTH, ) from nitrostack.protocol.extensions import MCPExtensionId -from nitrostack.protocol.layers import RuntimeLayer from nitrostack.protocol.version import ( LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, @@ -84,17 +83,6 @@ def test_legacy_session_header_name(self): assert LEGACY_SESSION_HEADER == "Mcp-Session-Id" -class TestRuntimeLayers: - def test_all_architecture_layers_defined(self): - layers = {layer.value for layer in RuntimeLayer} - assert layers == { - "transport_security", - "protocol_dispatcher", - "registries", - "task_management", - } - - class TestExtensions: def test_canonical_extension_ids(self): assert MCPExtensionId.APP.value == "io.modelcontextprotocol/app"