From d3787c96b4085de73680beb1f775dbe258f4b40f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:56:22 +0000 Subject: [PATCH 1/3] ci: add all-green job to pull-request-checks workflow (#1929) --- .github/workflows/pull-request-checks.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index a7e7a8bf..502a3631 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -6,3 +6,12 @@ on: jobs: checks: uses: ./.github/workflows/shared.yml + + all-green: + if: always() + needs: [checks] + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 + with: + jobs: ${{ toJSON(needs) }} From d891525958e587703b61a2004d9c1886ee606cc6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:02:25 +0000 Subject: [PATCH 2/3] Backport: Support for Resource and ResourceTemplate metadata (#1928) Co-authored-by: Jacem Elwaar --- src/mcp/server/fastmcp/resources/base.py | 3 +- .../fastmcp/resources/resource_manager.py | 2 + src/mcp/server/fastmcp/resources/templates.py | 4 ++ src/mcp/server/fastmcp/resources/types.py | 2 + src/mcp/server/fastmcp/server.py | 8 ++- src/mcp/server/lowlevel/helper_types.py | 2 + src/mcp/server/lowlevel/server.py | 11 ++- .../resources/test_function_resources.py | 35 ++++++++++ .../resources/test_resource_manager.py | 40 +++++++++++ .../resources/test_resource_template.py | 47 +++++++++++++ .../fastmcp/resources/test_resources.py | 38 +++++++++++ tests/server/fastmcp/test_server.py | 68 +++++++++++++++++++ tests/server/lowlevel/test_helper_types.py | 60 ++++++++++++++++ 13 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 tests/server/lowlevel/test_helper_types.py diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index 557775ea..e34b97a8 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -1,7 +1,7 @@ """Base classes and interfaces for FastMCP resources.""" import abc -from typing import Annotated +from typing import Annotated, Any from pydantic import ( AnyUrl, @@ -32,6 +32,7 @@ class Resource(BaseModel, abc.ABC): ) icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource") annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource") @field_validator("name", mode="before") @classmethod diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index 2e7dc171..20f67bbe 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -64,6 +64,7 @@ class ResourceManager: mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, ) -> ResourceTemplate: """Add a template from a function.""" template = ResourceTemplate.from_function( @@ -75,6 +76,7 @@ class ResourceManager: mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) self._templates[template.uri_template] = template return template diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index a98d37f0..89a8ceb3 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -30,6 +30,7 @@ class ResourceTemplate(BaseModel): mime_type: str = Field(default="text/plain", description="MIME type of the resource content") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template") annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template") fn: Callable[..., Any] = Field(exclude=True) parameters: dict[str, Any] = Field(description="JSON schema for function parameters") context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") @@ -45,6 +46,7 @@ class ResourceTemplate(BaseModel): mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, context_kwarg: str | None = None, ) -> ResourceTemplate: """Create a template from a function.""" @@ -74,6 +76,7 @@ class ResourceTemplate(BaseModel): mime_type=mime_type or "text/plain", icons=icons, annotations=annotations, + meta=meta, fn=fn, parameters=parameters, context_kwarg=context_kwarg, @@ -112,6 +115,7 @@ class ResourceTemplate(BaseModel): mime_type=self.mime_type, icons=self.icons, annotations=self.annotations, + meta=self.meta, fn=lambda: result, # Capture result in closure ) except Exception as e: diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index 680e72dc..5f724301 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -83,6 +83,7 @@ class FunctionResource(Resource): mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, ) -> "FunctionResource": """Create a FunctionResource from a function.""" func_name = name or fn.__name__ @@ -101,6 +102,7 @@ class FunctionResource(Resource): fn=fn, icons=icons, annotations=annotations, + meta=meta, ) diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index f74b6555..7a43bd7c 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -358,6 +358,7 @@ class FastMCP(Generic[LifespanResultT]): mimeType=resource.mime_type, icons=resource.icons, annotations=resource.annotations, + _meta=resource.meta, ) for resource in resources ] @@ -373,6 +374,7 @@ class FastMCP(Generic[LifespanResultT]): mimeType=template.mime_type, icons=template.icons, annotations=template.annotations, + _meta=template.meta, ) for template in templates ] @@ -387,7 +389,7 @@ class FastMCP(Generic[LifespanResultT]): try: content = await resource.read() - return [ReadResourceContents(content=content, mime_type=resource.mime_type)] + return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] except Exception as e: # pragma: no cover logger.exception(f"Error reading resource {uri}") raise ResourceError(str(e)) @@ -539,6 +541,7 @@ class FastMCP(Generic[LifespanResultT]): mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a function as a resource. @@ -557,6 +560,7 @@ class FastMCP(Generic[LifespanResultT]): title: Optional human-readable title for the resource description: Optional description of the resource mime_type: Optional MIME type for the resource + meta: Optional metadata dictionary for the resource Example: @server.resource("resource://my-resource") @@ -615,6 +619,7 @@ class FastMCP(Generic[LifespanResultT]): mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) else: # Register as regular resource @@ -627,6 +632,7 @@ class FastMCP(Generic[LifespanResultT]): mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) self.add_resource(resource) return fn diff --git a/src/mcp/server/lowlevel/helper_types.py b/src/mcp/server/lowlevel/helper_types.py index 3d09b250..fecc716d 100644 --- a/src/mcp/server/lowlevel/helper_types.py +++ b/src/mcp/server/lowlevel/helper_types.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass @@ -7,3 +8,4 @@ class ReadResourceContents: content: str | bytes mime_type: str | None = None + meta: dict[str, Any] | None = None diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 3fc2d497..67453624 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -338,19 +338,23 @@ class Server(Generic[LifespanResultT, RequestT]): async def handler(req: types.ReadResourceRequest): result = await func(req.params.uri) - def create_content(data: str | bytes, mime_type: str | None): + def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any] | None = None): + # Note: ResourceContents uses Field(alias="_meta"), so we must use the alias key + meta_kwargs: dict[str, Any] = {"_meta": meta} if meta is not None else {} match data: case str() as data: return types.TextResourceContents( uri=req.params.uri, text=data, mimeType=mime_type or "text/plain", + **meta_kwargs, ) case bytes() as data: # pragma: no cover return types.BlobResourceContents( uri=req.params.uri, blob=base64.b64encode(data).decode(), mimeType=mime_type or "application/octet-stream", + **meta_kwargs, ) match result: @@ -364,7 +368,10 @@ class Server(Generic[LifespanResultT, RequestT]): content = create_content(data, None) case Iterable() as contents: contents_list = [ - create_content(content_item.content, content_item.mime_type) for content_item in contents + create_content( + content_item.content, content_item.mime_type, getattr(content_item, "meta", None) + ) + for content_item in contents ] return types.ServerResult( types.ReadResourceResult( diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py index fccada47..4619fd2e 100644 --- a/tests/server/fastmcp/resources/test_function_resources.py +++ b/tests/server/fastmcp/resources/test_function_resources.py @@ -155,3 +155,38 @@ class TestFunctionResource: assert resource.mime_type == "text/plain" assert resource.name == "test" assert resource.uri == AnyUrl("function://test") + + +class TestFunctionResourceMetadata: + def test_from_function_with_metadata(self): + # from_function() accepts meta dict and stores it on the resource for static resources + + def get_data() -> str: # pragma: no cover + return "test data" + + metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]} + + resource = FunctionResource.from_function( + fn=get_data, + uri="resource://data", + meta=metadata, + ) + + assert resource.meta is not None + assert resource.meta == metadata + assert resource.meta["cache_ttl"] == 300 + assert "data" in resource.meta["tags"] + assert "readonly" in resource.meta["tags"] + + def test_from_function_without_metadata(self): + # meta parameter is optional and defaults to None for backward compatibility + + def get_data() -> str: # pragma: no cover + return "test data" + + resource = FunctionResource.from_function( + fn=get_data, + uri="resource://data", + ) + + assert resource.meta is None diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index a0c06be8..565c816f 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -134,3 +134,43 @@ class TestResourceManager: resources = manager.list_resources() assert len(resources) == 2 assert resources == [resource1, resource2] + + +class TestResourceManagerMetadata: + """Test ResourceManager Metadata""" + + def test_add_template_with_metadata(self): + """Test that ResourceManager.add_template() accepts and passes meta parameter.""" + + manager = ResourceManager() + + def get_item(id: str) -> str: # pragma: no cover + return f"Item {id}" + + metadata = {"source": "database", "cached": True} + + template = manager.add_template( + fn=get_item, + uri_template="resource://items/{id}", + meta=metadata, + ) + + assert template.meta is not None + assert template.meta == metadata + assert template.meta["source"] == "database" + assert template.meta["cached"] is True + + def test_add_template_without_metadata(self): + """Test that ResourceManager.add_template() works without meta parameter.""" + + manager = ResourceManager() + + def get_item(id: str) -> str: # pragma: no cover + return f"Item {id}" + + template = manager.add_template( + fn=get_item, + uri_template="resource://items/{id}", + ) + + assert template.meta is None diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index c910f8fa..f3d3ba5e 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -258,3 +258,50 @@ class TestResourceTemplateAnnotations: # Verify the resource works correctly content = await resource.read() assert content == "Item 123" + + +class TestResourceTemplateMetadata: + """Test ResourceTemplate meta handling.""" + + def test_template_from_function_with_metadata(self): + """Test that ResourceTemplate.from_function() accepts and stores meta parameter.""" + + def get_user(user_id: str) -> str: # pragma: no cover + return f"User {user_id}" + + metadata = {"requires_auth": True, "rate_limit": 100} + + template = ResourceTemplate.from_function( + fn=get_user, + uri_template="resource://users/{user_id}", + meta=metadata, + ) + + assert template.meta is not None + assert template.meta == metadata + assert template.meta["requires_auth"] is True + assert template.meta["rate_limit"] == 100 + + @pytest.mark.anyio + async def test_template_created_resources_inherit_metadata(self): + """Test that resources created from templates inherit meta from template.""" + + def get_item(item_id: str) -> str: + return f"Item {item_id}" + + metadata = {"category": "inventory", "cacheable": True} + + template = ResourceTemplate.from_function( + fn=get_item, + uri_template="resource://items/{item_id}", + meta=metadata, + ) + + # Create a resource from the template + resource = await template.create_resource("resource://items/123", {"item_id": "123"}) + + # The resource should inherit the template's metadata + assert resource.meta is not None + assert resource.meta == metadata + assert resource.meta["category"] == "inventory" + assert resource.meta["cacheable"] is True diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py index 32fc23b1..d617774f 100644 --- a/tests/server/fastmcp/resources/test_resources.py +++ b/tests/server/fastmcp/resources/test_resources.py @@ -193,3 +193,41 @@ class TestAnnotationsValidation: # Invalid roles should raise validation error with pytest.raises(Exception): # Pydantic validation error Annotations(audience=["invalid_role"]) # type: ignore + + +class TestResourceMetadata: + """Test metadata field on base Resource class.""" + + def test_resource_with_metadata(self): + """Test that Resource base class accepts meta parameter.""" + + def dummy_func() -> str: # pragma: no cover + return "data" + + metadata = {"version": "1.0", "category": "test"} + + resource = FunctionResource( + uri=AnyUrl("resource://test"), + name="test", + fn=dummy_func, + meta=metadata, + ) + + assert resource.meta is not None + assert resource.meta == metadata + assert resource.meta["version"] == "1.0" + assert resource.meta["category"] == "test" + + def test_resource_without_metadata(self): + """Test that meta field defaults to None.""" + + def dummy_func() -> str: # pragma: no cover + return "data" + + resource = FunctionResource( + uri=AnyUrl("resource://test"), + name="test", + fn=dummy_func, + ) + + assert resource.meta is None diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 3935f3bd..7f7d27e8 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -953,6 +953,74 @@ class TestServerResourceTemplates: assert result.contents[0].text == "csv for bob" +class TestServerResourceMetadata: + """Test FastMCP @resource decorator meta parameter for list operations. + + Meta flows: @resource decorator -> resource/template storage -> list_resources/list_resource_templates. + Note: read_resource does NOT pass meta to protocol response (lowlevel/server.py only extracts content/mime_type). + """ + + @pytest.mark.anyio + async def test_resource_decorator_with_metadata(self): + """Test that @resource decorator accepts and passes meta parameter.""" + # Tests static resource flow: decorator -> FunctionResource -> list_resources (server.py:544,635,361) + mcp = FastMCP() + + metadata = {"ui": {"component": "file-viewer"}, "priority": "high"} + + @mcp.resource("resource://config", meta=metadata) + def get_config() -> str: # pragma: no cover + return '{"debug": false}' + + resources = await mcp.list_resources() + assert len(resources) == 1 + assert resources[0].meta is not None + assert resources[0].meta == metadata + assert resources[0].meta["ui"]["component"] == "file-viewer" + assert resources[0].meta["priority"] == "high" + + @pytest.mark.anyio + async def test_resource_template_decorator_with_metadata(self): + """Test that @resource decorator passes meta to templates.""" + # Tests template resource flow: decorator -> add_template() -> list_resource_templates (server.py:544,622,377) + mcp = FastMCP() + + metadata = {"api_version": "v2", "deprecated": False} + + @mcp.resource("resource://{city}/weather", meta=metadata) + def get_weather(city: str) -> str: # pragma: no cover + return f"Weather for {city}" + + templates = await mcp.list_resource_templates() + assert len(templates) == 1 + assert templates[0].meta is not None + assert templates[0].meta == metadata + assert templates[0].meta["api_version"] == "v2" + + @pytest.mark.anyio + async def test_read_resource_returns_meta(self): + """Test that read_resource includes meta in response.""" + # Tests end-to-end: Resource.meta -> ReadResourceContents.meta -> protocol _meta (lowlevel/server.py:341,371) + mcp = FastMCP() + + metadata = {"version": "1.0", "category": "config"} + + @mcp.resource("resource://data", meta=metadata) + def get_data() -> str: + return "test data" + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://data")) + + # Verify content and metadata in protocol response + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "test data" + assert result.contents[0].meta is not None + assert result.contents[0].meta == metadata + assert result.contents[0].meta["version"] == "1.0" + assert result.contents[0].meta["category"] == "config" + + class TestContextInjection: """Test context injection in tools, resources, and prompts.""" diff --git a/tests/server/lowlevel/test_helper_types.py b/tests/server/lowlevel/test_helper_types.py new file mode 100644 index 00000000..27a8081b --- /dev/null +++ b/tests/server/lowlevel/test_helper_types.py @@ -0,0 +1,60 @@ +"""Test helper_types.py meta field. + +These tests verify the changes made to helper_types.py:11 where we added: + meta: dict[str, Any] | None = field(default=None) + +ReadResourceContents is the return type for resource read handlers. It's used internally +by the low-level server to package resource content before sending it over the MCP protocol. +""" + +from mcp.server.lowlevel.helper_types import ReadResourceContents + + +class TestReadResourceContentsMetadata: + """Test ReadResourceContents meta field. + + ReadResourceContents is an internal helper type used by the low-level MCP server. + When a resource is read, the server creates a ReadResourceContents instance that + contains the content, mime type, and now metadata. The low-level server then + extracts the meta field and includes it in the protocol response as _meta. + """ + + def test_read_resource_contents_with_metadata(self): + """Test that ReadResourceContents accepts meta parameter.""" + # Bridge between Resource.meta and MCP protocol _meta field (helper_types.py:11) + metadata = {"version": "1.0", "cached": True} + + contents = ReadResourceContents( + content="test content", + mime_type="text/plain", + meta=metadata, + ) + + assert contents.meta is not None + assert contents.meta == metadata + assert contents.meta["version"] == "1.0" + assert contents.meta["cached"] is True + + def test_read_resource_contents_without_metadata(self): + """Test that ReadResourceContents meta defaults to None.""" + # Ensures backward compatibility - meta defaults to None, _meta omitted from protocol (helper_types.py:11) + contents = ReadResourceContents( + content="test content", + mime_type="text/plain", + ) + + assert contents.meta is None + + def test_read_resource_contents_with_bytes(self): + """Test that ReadResourceContents works with bytes content and meta.""" + # Verifies meta works with both str and bytes content (binary resources like images, PDFs) + metadata = {"encoding": "utf-8"} + + contents = ReadResourceContents( + content=b"binary content", + mime_type="application/octet-stream", + meta=metadata, + ) + + assert contents.content == b"binary content" + assert contents.meta == metadata From 3d9d34552a9ab8988acf8d06e4a705bd799fc32b Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:09:14 -0800 Subject: [PATCH 3/3] [v1.x] fix: return HTTP 404 for unknown session IDs instead of 400 (#1945) Co-authored-by: Maxime <67350340+max-rousseau@users.noreply.github.com> Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- src/mcp/server/streamable_http_manager.py | 20 ++++++-- tests/server/test_streamable_http_manager.py | 51 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 50d2aefa..6a167241 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -22,6 +22,7 @@ from mcp.server.streamable_http import ( StreamableHTTPServerTransport, ) from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError logger = logging.getLogger(__name__) @@ -276,10 +277,21 @@ class StreamableHTTPSessionManager: # Handle the HTTP request and return the response await http_transport.handle_request(scope, receive, send) - else: # pragma: no cover - # Invalid session ID + else: + # Unknown or expired session ID - return 404 per MCP spec + # TODO: Align error code once spec clarifies + # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 + error_response = JSONRPCError( + jsonrpc="2.0", + id="server-error", + error=ErrorData( + code=INVALID_REQUEST, + message="Session not found", + ), + ) response = Response( - "Bad Request: No valid session ID provided", - status_code=HTTPStatus.BAD_REQUEST, + content=error_response.model_dump_json(by_alias=True, exclude_none=True), + status_code=HTTPStatus.NOT_FOUND, + media_type="application/json", ) await response(scope, receive, send) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 6fcf08aa..af1b2361 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -1,5 +1,6 @@ """Tests for StreamableHTTPSessionManager.""" +import json from typing import Any from unittest.mock import AsyncMock, patch @@ -11,6 +12,7 @@ from mcp.server import streamable_http_manager from mcp.server.lowlevel import Server from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.types import INVALID_REQUEST @pytest.mark.anyio @@ -262,3 +264,52 @@ async def test_stateless_requests_memory_cleanup(): # Verify internal state is cleaned up assert len(transport._request_streams) == 0, "Transport should have no active request streams" + + +@pytest.mark.anyio +async def test_unknown_session_id_returns_404(): + """Test that requests with unknown session IDs return HTTP 404 per MCP spec.""" + app = Server("test-unknown-session") + manager = StreamableHTTPSessionManager(app=app) + + async with manager.run(): + sent_messages: list[Message] = [] + response_body = b"" + + async def mock_send(message: Message): + nonlocal response_body + sent_messages.append(message) + if message["type"] == "http.response.body": + response_body += message.get("body", b"") + + # Request with a non-existent session ID + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + (b"mcp-session-id", b"non-existent-session-id"), + ], + } + + async def mock_receive(): + return {"type": "http.request", "body": b"{}", "more_body": False} # pragma: no cover + + await manager.handle_request(scope, mock_receive, mock_send) + + # Find the response start message + response_start = next( + (msg for msg in sent_messages if msg["type"] == "http.response.start"), + None, + ) + assert response_start is not None, "Should have sent a response" + assert response_start["status"] == 404, "Should return HTTP 404 for unknown session ID" + + # Verify JSON-RPC error format + error_data = json.loads(response_body) + assert error_data["jsonrpc"] == "2.0" + assert error_data["id"] == "server-error" + assert error_data["error"]["code"] == INVALID_REQUEST + assert error_data["error"]["message"] == "Session not found"