Compare commits

...
Author SHA1 Message Date
Luca Chang
3d9d34552a
[v1.x] fix: return HTTP 404 for unknown session IDs instead of 400 (#1945)
Some checks failed
Main branch checks / checks (push) Has been cancelled
Co-authored-by: Maxime <67350340+max-rousseau@users.noreply.github.com>
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
2026-01-24 19:09:14 +00:00
Max Isbey
d891525958
Backport: Support for Resource and ResourceTemplate metadata (#1928)
Co-authored-by: Jacem Elwaar <jacem@mcpappsbuilders.com>
2026-01-22 14:02:25 +00:00
Max Isbey
d3787c96b4
ci: add all-green job to pull-request-checks workflow (#1929) 2026-01-22 13:56:22 +00:00
16 changed files with 392 additions and 8 deletions

View file

@ -6,3 +6,12 @@ on:
jobs: jobs:
checks: checks:
uses: ./.github/workflows/shared.yml 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) }}

View file

@ -1,7 +1,7 @@
"""Base classes and interfaces for FastMCP resources.""" """Base classes and interfaces for FastMCP resources."""
import abc import abc
from typing import Annotated from typing import Annotated, Any
from pydantic import ( from pydantic import (
AnyUrl, 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") 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") 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") @field_validator("name", mode="before")
@classmethod @classmethod

View file

@ -64,6 +64,7 @@ class ResourceManager:
mime_type: str | None = None, mime_type: str | None = None,
icons: list[Icon] | None = None, icons: list[Icon] | None = None,
annotations: Annotations | None = None, annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
) -> ResourceTemplate: ) -> ResourceTemplate:
"""Add a template from a function.""" """Add a template from a function."""
template = ResourceTemplate.from_function( template = ResourceTemplate.from_function(
@ -75,6 +76,7 @@ class ResourceManager:
mime_type=mime_type, mime_type=mime_type,
icons=icons, icons=icons,
annotations=annotations, annotations=annotations,
meta=meta,
) )
self._templates[template.uri_template] = template self._templates[template.uri_template] = template
return template return template

View file

@ -30,6 +30,7 @@ class ResourceTemplate(BaseModel):
mime_type: str = Field(default="text/plain", description="MIME type of the resource content") 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") 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") 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) fn: Callable[..., Any] = Field(exclude=True)
parameters: dict[str, Any] = Field(description="JSON schema for function parameters") 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") 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, mime_type: str | None = None,
icons: list[Icon] | None = None, icons: list[Icon] | None = None,
annotations: Annotations | None = None, annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
context_kwarg: str | None = None, context_kwarg: str | None = None,
) -> ResourceTemplate: ) -> ResourceTemplate:
"""Create a template from a function.""" """Create a template from a function."""
@ -74,6 +76,7 @@ class ResourceTemplate(BaseModel):
mime_type=mime_type or "text/plain", mime_type=mime_type or "text/plain",
icons=icons, icons=icons,
annotations=annotations, annotations=annotations,
meta=meta,
fn=fn, fn=fn,
parameters=parameters, parameters=parameters,
context_kwarg=context_kwarg, context_kwarg=context_kwarg,
@ -112,6 +115,7 @@ class ResourceTemplate(BaseModel):
mime_type=self.mime_type, mime_type=self.mime_type,
icons=self.icons, icons=self.icons,
annotations=self.annotations, annotations=self.annotations,
meta=self.meta,
fn=lambda: result, # Capture result in closure fn=lambda: result, # Capture result in closure
) )
except Exception as e: except Exception as e:

View file

@ -83,6 +83,7 @@ class FunctionResource(Resource):
mime_type: str | None = None, mime_type: str | None = None,
icons: list[Icon] | None = None, icons: list[Icon] | None = None,
annotations: Annotations | None = None, annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
) -> "FunctionResource": ) -> "FunctionResource":
"""Create a FunctionResource from a function.""" """Create a FunctionResource from a function."""
func_name = name or fn.__name__ func_name = name or fn.__name__
@ -101,6 +102,7 @@ class FunctionResource(Resource):
fn=fn, fn=fn,
icons=icons, icons=icons,
annotations=annotations, annotations=annotations,
meta=meta,
) )

View file

@ -358,6 +358,7 @@ class FastMCP(Generic[LifespanResultT]):
mimeType=resource.mime_type, mimeType=resource.mime_type,
icons=resource.icons, icons=resource.icons,
annotations=resource.annotations, annotations=resource.annotations,
_meta=resource.meta,
) )
for resource in resources for resource in resources
] ]
@ -373,6 +374,7 @@ class FastMCP(Generic[LifespanResultT]):
mimeType=template.mime_type, mimeType=template.mime_type,
icons=template.icons, icons=template.icons,
annotations=template.annotations, annotations=template.annotations,
_meta=template.meta,
) )
for template in templates for template in templates
] ]
@ -387,7 +389,7 @@ class FastMCP(Generic[LifespanResultT]):
try: try:
content = await resource.read() 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 except Exception as e: # pragma: no cover
logger.exception(f"Error reading resource {uri}") logger.exception(f"Error reading resource {uri}")
raise ResourceError(str(e)) raise ResourceError(str(e))
@ -539,6 +541,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type: str | None = None, mime_type: str | None = None,
icons: list[Icon] | None = None, icons: list[Icon] | None = None,
annotations: Annotations | None = None, annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
) -> Callable[[AnyFunction], AnyFunction]: ) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a function as a resource. """Decorator to register a function as a resource.
@ -557,6 +560,7 @@ class FastMCP(Generic[LifespanResultT]):
title: Optional human-readable title for the resource title: Optional human-readable title for the resource
description: Optional description of the resource description: Optional description of the resource
mime_type: Optional MIME type for the resource mime_type: Optional MIME type for the resource
meta: Optional metadata dictionary for the resource
Example: Example:
@server.resource("resource://my-resource") @server.resource("resource://my-resource")
@ -615,6 +619,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type=mime_type, mime_type=mime_type,
icons=icons, icons=icons,
annotations=annotations, annotations=annotations,
meta=meta,
) )
else: else:
# Register as regular resource # Register as regular resource
@ -627,6 +632,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type=mime_type, mime_type=mime_type,
icons=icons, icons=icons,
annotations=annotations, annotations=annotations,
meta=meta,
) )
self.add_resource(resource) self.add_resource(resource)
return fn return fn

View file

@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
@dataclass @dataclass
@ -7,3 +8,4 @@ class ReadResourceContents:
content: str | bytes content: str | bytes
mime_type: str | None = None mime_type: str | None = None
meta: dict[str, Any] | None = None

View file

@ -338,19 +338,23 @@ class Server(Generic[LifespanResultT, RequestT]):
async def handler(req: types.ReadResourceRequest): async def handler(req: types.ReadResourceRequest):
result = await func(req.params.uri) 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: match data:
case str() as data: case str() as data:
return types.TextResourceContents( return types.TextResourceContents(
uri=req.params.uri, uri=req.params.uri,
text=data, text=data,
mimeType=mime_type or "text/plain", mimeType=mime_type or "text/plain",
**meta_kwargs,
) )
case bytes() as data: # pragma: no cover case bytes() as data: # pragma: no cover
return types.BlobResourceContents( return types.BlobResourceContents(
uri=req.params.uri, uri=req.params.uri,
blob=base64.b64encode(data).decode(), blob=base64.b64encode(data).decode(),
mimeType=mime_type or "application/octet-stream", mimeType=mime_type or "application/octet-stream",
**meta_kwargs,
) )
match result: match result:
@ -364,7 +368,10 @@ class Server(Generic[LifespanResultT, RequestT]):
content = create_content(data, None) content = create_content(data, None)
case Iterable() as contents: case Iterable() as contents:
contents_list = [ 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( return types.ServerResult(
types.ReadResourceResult( types.ReadResourceResult(

View file

@ -22,6 +22,7 @@ from mcp.server.streamable_http import (
StreamableHTTPServerTransport, StreamableHTTPServerTransport,
) )
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -276,10 +277,21 @@ class StreamableHTTPSessionManager:
# Handle the HTTP request and return the response # Handle the HTTP request and return the response
await http_transport.handle_request(scope, receive, send) await http_transport.handle_request(scope, receive, send)
else: # pragma: no cover else:
# Invalid session ID # 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( response = Response(
"Bad Request: No valid session ID provided", content=error_response.model_dump_json(by_alias=True, exclude_none=True),
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.NOT_FOUND,
media_type="application/json",
) )
await response(scope, receive, send) await response(scope, receive, send)

View file

@ -155,3 +155,38 @@ class TestFunctionResource:
assert resource.mime_type == "text/plain" assert resource.mime_type == "text/plain"
assert resource.name == "test" assert resource.name == "test"
assert resource.uri == AnyUrl("function://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

View file

@ -134,3 +134,43 @@ class TestResourceManager:
resources = manager.list_resources() resources = manager.list_resources()
assert len(resources) == 2 assert len(resources) == 2
assert resources == [resource1, resource2] 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

View file

@ -258,3 +258,50 @@ class TestResourceTemplateAnnotations:
# Verify the resource works correctly # Verify the resource works correctly
content = await resource.read() content = await resource.read()
assert content == "Item 123" 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

View file

@ -193,3 +193,41 @@ class TestAnnotationsValidation:
# Invalid roles should raise validation error # Invalid roles should raise validation error
with pytest.raises(Exception): # Pydantic validation error with pytest.raises(Exception): # Pydantic validation error
Annotations(audience=["invalid_role"]) # type: ignore 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

View file

@ -953,6 +953,74 @@ class TestServerResourceTemplates:
assert result.contents[0].text == "csv for bob" 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: class TestContextInjection:
"""Test context injection in tools, resources, and prompts.""" """Test context injection in tools, resources, and prompts."""

View file

@ -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

View file

@ -1,5 +1,6 @@
"""Tests for StreamableHTTPSessionManager.""" """Tests for StreamableHTTPSessionManager."""
import json
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch 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.lowlevel import Server
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import INVALID_REQUEST
@pytest.mark.anyio @pytest.mark.anyio
@ -262,3 +264,52 @@ async def test_stateless_requests_memory_cleanup():
# Verify internal state is cleaned up # Verify internal state is cleaned up
assert len(transport._request_streams) == 0, "Transport should have no active request streams" 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"