From 157eeede503af98aac44e13d37cf693b07581aec Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 21 Jan 2026 21:04:42 +0100 Subject: [PATCH 1/4] Add meta to `Client` methods --- README.md | 4 +- .../mcp_everything_server/server.py | 4 +- examples/snippets/clients/stdio_client.py | 4 +- src/mcp/client/client.py | 78 +++++++++++-------- src/mcp/client/experimental/tasks.py | 13 +--- src/mcp/client/session.py | 49 +++++++----- src/mcp/client/session_group.py | 2 +- src/mcp/server/fastmcp/server.py | 6 +- src/mcp/shared/context.py | 4 +- src/mcp/shared/progress.py | 5 +- src/mcp/shared/session.py | 7 +- src/mcp/types/__init__.py | 4 +- src/mcp/types/_types.py | 58 +++++++------- tests/client/test_session.py | 3 +- .../tasks/server/test_run_task_flow.py | 4 +- tests/issues/test_141_resource_templates.py | 9 +-- tests/issues/test_152_resource_mime_type.py | 9 +-- .../issues/test_1754_mime_type_parameters.py | 3 +- tests/issues/test_176_progress_token.py | 5 +- tests/issues/test_188_concurrency.py | 3 +- tests/server/fastmcp/test_integration.py | 13 ++-- tests/shared/test_progress_notifications.py | 3 +- tests/shared/test_ws.py | 9 +-- tests/test_examples.py | 3 +- 24 files changed, 155 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 468e1d85d..b6f8087ab 100644 --- a/README.md +++ b/README.md @@ -2119,8 +2119,6 @@ uv run client import asyncio import os -from pydantic import AnyUrl - from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio_client from mcp.shared.context import RequestContext @@ -2173,7 +2171,7 @@ async def run(): print(f"Available tools: {[t.name for t in tools.tools]}") # Read a resource (greeting resource from fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) + resource_content = await session.read_resource("greeting://World") content_block = resource_content.contents[0] if isinstance(content_block, types.TextContent): print(f"Resource content: {content_block.text}") diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index db6b09f3f..341fa1197 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -161,7 +161,9 @@ async def test_tool_with_progress(ctx: Context[ServerSession, None]) -> str: await ctx.report_progress(progress=100, total=100, message="Completed step 100 of 100") # Return progress token as string - progress_token = ctx.request_context.meta.progress_token if ctx.request_context and ctx.request_context.meta else 0 + progress_token = ( + ctx.request_context.meta.get("progress_token") if ctx.request_context and ctx.request_context.meta else 0 + ) return str(progress_token) diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py index b594a217b..e4d430397 100644 --- a/examples/snippets/clients/stdio_client.py +++ b/examples/snippets/clients/stdio_client.py @@ -5,8 +5,6 @@ import asyncio import os -from pydantic import AnyUrl - from mcp import ClientSession, StdioServerParameters, types from mcp.client.stdio import stdio_client from mcp.shared.context import RequestContext @@ -59,7 +57,7 @@ async def run(): print(f"Available tools: {[t.name for t in tools.tools]}") # Read a resource (greeting resource from fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) + resource_content = await session.read_resource("greeting://World") content_block = resource_content.contents[0] if isinstance(content_block, types.TextContent): print(f"Resource content: {content_block.text}") diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 6eafb794a..6ef319a90 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -2,27 +2,16 @@ from __future__ import annotations -import logging from contextlib import AsyncExitStack from typing import Any -from pydantic import AnyUrl - import mcp.types as types from mcp.client._memory import InMemoryTransport -from mcp.client.session import ( - ClientSession, - ElicitationFnT, - ListRootsFnT, - LoggingFnT, - MessageHandlerFnT, - SamplingFnT, -) +from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.server import Server from mcp.server.fastmcp import FastMCP from mcp.shared.session import ProgressFnT - -logger = logging.getLogger(__name__) +from mcp.types._types import RequestParamsMeta class Client: @@ -42,8 +31,11 @@ class Client: def add(a: int, b: int) -> int: return a + b - async with Client(server) as client: - result = await client.call_tool("add", {"a": 1, "b": 2}) + async def main(): + async with Client(server) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + + asyncio.run(main()) ``` """ @@ -150,9 +142,9 @@ def server_capabilities(self) -> types.ServerCapabilities | None: """The server capabilities received during initialization, or None if not yet initialized.""" return self.session.get_server_capabilities() - async def send_ping(self) -> types.EmptyResult: + async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a ping request to the server.""" - return await self.session.send_ping() + return await self.session.send_ping(meta=meta) async def send_progress_notification( self, @@ -169,19 +161,36 @@ async def send_progress_notification( message=message, ) - async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult: + async def set_logging_level( + self, + level: types.LoggingLevel, + *, + meta: RequestParamsMeta | None = None, + ) -> types.EmptyResult: """Set the logging level on the server.""" - return await self.session.set_logging_level(level) + return await self.session.set_logging_level(level=level, meta=meta) - async def list_resources(self, *, cursor: str | None = None) -> types.ListResourcesResult: + async def list_resources( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> types.ListResourcesResult: """List available resources from the server.""" - return await self.session.list_resources(params=types.PaginatedRequestParams(cursor=cursor)) + return await self.session.list_resources(params=types.PaginatedRequestParams(cursor=cursor, _meta=meta)) - async def list_resource_templates(self, *, cursor: str | None = None) -> types.ListResourceTemplatesResult: + async def list_resource_templates( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> types.ListResourceTemplatesResult: """List available resource templates from the server.""" - return await self.session.list_resource_templates(params=types.PaginatedRequestParams(cursor=cursor)) + return await self.session.list_resource_templates( + params=types.PaginatedRequestParams(cursor=cursor, _meta=meta) + ) - async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult: + async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.ReadResourceResult: """Read a resource from the server. Args: @@ -190,15 +199,15 @@ async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult: Returns: The resource content. """ - return await self.session.read_resource(uri) + return await self.session.read_resource(uri, meta=meta) - async def subscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult: + async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Subscribe to resource updates.""" - return await self.session.subscribe_resource(uri) + return await self.session.subscribe_resource(uri, meta=meta) - async def unsubscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult: + async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Unsubscribe from resource updates.""" - return await self.session.unsubscribe_resource(uri) + return await self.session.unsubscribe_resource(uri, meta=meta) async def call_tool( self, @@ -207,7 +216,7 @@ async def call_tool( read_timeout_seconds: float | None = None, progress_callback: ProgressFnT | None = None, *, - meta: dict[str, Any] | None = None, + meta: RequestParamsMeta | None = None, ) -> types.CallToolResult: """Call a tool on the server. @@ -229,9 +238,14 @@ async def call_tool( meta=meta, ) - async def list_prompts(self, *, cursor: str | None = None) -> types.ListPromptsResult: + async def list_prompts( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> types.ListPromptsResult: """List available prompts from the server.""" - return await self.session.list_prompts(params=types.PaginatedRequestParams(cursor=cursor)) + return await self.session.list_prompts(params=types.PaginatedRequestParams(cursor=cursor, _meta=meta)) async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: """Get a prompt from the server. diff --git a/src/mcp/client/experimental/tasks.py b/src/mcp/client/experimental/tasks.py index 2f890245c..da67c9832 100644 --- a/src/mcp/client/experimental/tasks.py +++ b/src/mcp/client/experimental/tasks.py @@ -28,6 +28,7 @@ import mcp.types as types from mcp.shared.experimental.tasks.polling import poll_until_terminal +from mcp.types._types import RequestParamsMeta if TYPE_CHECKING: from mcp.client.session import ClientSession @@ -53,7 +54,7 @@ async def call_tool_as_task( arguments: dict[str, Any] | None = None, *, ttl: int = 60000, - meta: dict[str, Any] | None = None, + meta: RequestParamsMeta | None = None, ) -> types.CreateTaskResult: """Call a tool as a task, returning a CreateTaskResult for polling. @@ -87,17 +88,13 @@ async def call_tool_as_task( # Get result final = await session.experimental.get_task_result(task_id, CallToolResult) """ - _meta: types.RequestParams.Meta | None = None - if meta is not None: - _meta = types.RequestParams.Meta(**meta) - return await self._session.send_request( types.CallToolRequest( params=types.CallToolRequestParams( name=name, arguments=arguments, task=types.TaskMetadata(ttl=ttl), - _meta=_meta, + _meta=meta, ), ), types.CreateTaskResult, @@ -113,9 +110,7 @@ async def get_task(self, task_id: str) -> types.GetTaskResult: GetTaskResult containing the task status and metadata """ return await self._session.send_request( - types.GetTaskRequest( - params=types.GetTaskRequestParams(task_id=task_id), - ), + types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)), types.GetTaskResult, ) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 7151d57cd..d5d4c8607 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -3,7 +3,7 @@ import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl, TypeAdapter +from pydantic import TypeAdapter import mcp.types as types from mcp.client.experimental import ExperimentalClientFeatures @@ -12,6 +12,7 @@ from mcp.shared.message import SessionMessage from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +from mcp.types._types import RequestParamsMeta DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -216,9 +217,9 @@ def experimental(self) -> ExperimentalClientFeatures: self._experimental_features = ExperimentalClientFeatures(self) return self._experimental_features - async def send_ping(self) -> types.EmptyResult: + async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a ping request.""" - return await self.send_request(types.PingRequest(), types.EmptyResult) + return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) async def send_progress_notification( self, @@ -226,6 +227,8 @@ async def send_progress_notification( progress: float, total: float | None = None, message: str | None = None, + *, + meta: RequestParamsMeta | None = None, ) -> None: """Send a progress notification.""" await self.send_notification( @@ -235,14 +238,20 @@ async def send_progress_notification( progress=progress, total=total, message=message, + _meta=meta, ), ) ) - async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult: + async def set_logging_level( + self, + level: types.LoggingLevel, + *, + meta: RequestParamsMeta | None = None, + ) -> types.EmptyResult: """Send a logging/setLevel request.""" return await self.send_request( # pragma: no cover - types.SetLevelRequest(params=types.SetLevelRequestParams(level=level)), + types.SetLevelRequest(params=types.SetLevelRequestParams(level=level, _meta=meta)), types.EmptyResult, ) @@ -267,24 +276,24 @@ async def list_resource_templates( types.ListResourceTemplatesResult, ) - async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult: + async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.ReadResourceResult: """Send a resources/read request.""" return await self.send_request( - types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=str(uri))), + types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=uri, _meta=meta)), types.ReadResourceResult, ) - async def subscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult: + async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/subscribe request.""" return await self.send_request( # pragma: no cover - types.SubscribeRequest(params=types.SubscribeRequestParams(uri=str(uri))), + types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) - async def unsubscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult: + async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/unsubscribe request.""" return await self.send_request( # pragma: no cover - types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=str(uri))), + types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) @@ -295,17 +304,13 @@ async def call_tool( read_timeout_seconds: float | None = None, progress_callback: ProgressFnT | None = None, *, - meta: dict[str, Any] | None = None, + meta: RequestParamsMeta | None = None, ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" - _meta: types.RequestParams.Meta | None = None - if meta is not None: - _meta = types.RequestParams.Meta(**meta) - result = await self.send_request( types.CallToolRequest( - params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=_meta), + params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=meta), ), types.CallToolResult, request_read_timeout_seconds=read_timeout_seconds, @@ -351,11 +356,17 @@ async def list_prompts(self, *, params: types.PaginatedRequestParams | None = No """ return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult) - async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + meta: RequestParamsMeta | None = None, + ) -> types.GetPromptResult: """Send a prompts/get request.""" return await self.send_request( types.GetPromptRequest( - params=types.GetPromptRequestParams(name=name, arguments=arguments), + params=types.GetPromptRequestParams(name=name, arguments=arguments, _meta=meta), ), types.GetPromptResult, ) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 31b9d475d..825e6d66e 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -196,7 +196,7 @@ async def call_tool( read_timeout_seconds: float | None = None, progress_callback: ProgressFnT | None = None, *, - meta: dict[str, Any] | None = None, + meta: types.RequestParamsMeta | None = None, ) -> types.CallToolResult: """Executes a tool given its name and arguments.""" session = self._tool_to_session[name] diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 27295e8bf..d0a550280 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1049,7 +1049,7 @@ async def report_progress(self, progress: float, total: float | None = None, mes total: Optional total value e.g. 100 message: Optional message e.g. Starting render... """ - progress_token = self.request_context.meta.progress_token if self.request_context.meta else None + progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None if progress_token is None: # pragma: no cover return @@ -1174,9 +1174,7 @@ async def log( @property def client_id(self) -> str | None: """Get the client ID if available.""" - return ( - getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None - ) # pragma: no cover + return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover @property def request_id(self) -> str: diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index f54a2efab..b140f9a77 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -7,7 +7,7 @@ from mcp.shared.message import CloseSSEStreamCallback from mcp.shared.session import BaseSession -from mcp.types import RequestId, RequestParams +from mcp.types import RequestId, RequestParamsMeta SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) LifespanContextT = TypeVar("LifespanContextT") @@ -17,7 +17,7 @@ @dataclass class RequestContext(Generic[SessionT, LifespanContextT, RequestT]): request_id: RequestId - meta: RequestParams.Meta | None + meta: RequestParamsMeta | None session: SessionT lifespan_context: LifespanContextT # NOTE: This is typed as Any to avoid circular imports. The actual type is diff --git a/src/mcp/shared/progress.py b/src/mcp/shared/progress.py index 245654d10..bc54304cb 100644 --- a/src/mcp/shared/progress.py +++ b/src/mcp/shared/progress.py @@ -48,10 +48,11 @@ def progress( ProgressContext[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], None, ]: - if ctx.meta is None or ctx.meta.progress_token is None: # pragma: no cover + progress_token = ctx.meta.get("progress_token") if ctx.meta else None + if progress_token is None: # pragma: no cover raise ValueError("No progress token provided") - progress_ctx = ProgressContext(ctx.session, ctx.meta.progress_token, total) + progress_ctx = ProgressContext(ctx.session, progress_token, total) try: yield progress_ctx finally: diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index d00fd764c..341e1fac0 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -28,7 +28,8 @@ JSONRPCRequest, JSONRPCResponse, ProgressNotification, - RequestParams, + ProgressToken, + RequestParamsMeta, ServerNotification, ServerRequest, ServerResult, @@ -71,7 +72,7 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): def __init__( self, request_id: RequestId, - request_meta: RequestParams.Meta | None, + request_meta: RequestParamsMeta | None, request: ReceiveRequestT, session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], on_complete: Callable[[RequestResponder[ReceiveRequestT, SendResultT]], Any], @@ -501,7 +502,7 @@ async def _received_notification(self, notification: ReceiveNotificationT) -> No async def send_progress_notification( self, - progress_token: str | int, + progress_token: ProgressToken, progress: float, total: float | None = None, message: str | None = None, diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index c4df66f8d..00bf83992 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -90,7 +90,6 @@ LoggingLevel, LoggingMessageNotification, LoggingMessageNotificationParams, - MCPModel, MethodT, ModelHint, ModelPreferences, @@ -116,6 +115,7 @@ RelatedTaskMetadata, Request, RequestParams, + RequestParamsMeta, RequestParamsT, Resource, ResourceContents, @@ -235,12 +235,12 @@ "TaskExecutionMode", "TaskStatus", # Base classes - "MCPModel", "BaseMetadata", "Request", "Notification", "Result", "RequestParams", + "RequestParamsMeta", "NotificationParams", "PaginatedRequest", "PaginatedRequestParams", diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index f63d3ebac..0e4c1fa8f 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -1,10 +1,11 @@ from __future__ import annotations from datetime import datetime -from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar +from typing import Annotated, Any, Final, Generic, Literal, NotRequired, TypeAlias, TypeVar from pydantic import BaseModel, ConfigDict, Field, FileUrl, TypeAdapter from pydantic.alias_generators import to_camel +from typing_extensions import TypedDict from mcp.types.jsonrpc import RequestId @@ -35,6 +36,19 @@ class MCPModel(BaseModel): model_config = ConfigDict(extra="allow", alias_generator=to_camel, populate_by_name=True) +Meta: TypeAlias = dict[str, Any] + + +class RequestParamsMeta(TypedDict, extra_items=Any): + progress_token: NotRequired[ProgressToken] + """ + If specified, the caller requests out-of-band progress notifications for + this request (as represented by notifications/progress). The value of this + parameter is an opaque token that will be attached to any subsequent + notifications. The receiver is not obligated to provide these notifications. + """ + + class TaskMetadata(MCPModel): """Metadata for augmenting a request with task execution. Include this in the `task` field of the request parameters. @@ -45,15 +59,6 @@ class TaskMetadata(MCPModel): class RequestParams(MCPModel): - class Meta(MCPModel): - progress_token: ProgressToken | None = None - """ - If specified, the caller requests out-of-band progress notifications for - this request (as represented by notifications/progress). The value of this - parameter is an opaque token that will be attached to any subsequent - notifications. The receiver is not obligated to provide these notifications. - """ - task: TaskMetadata | None = None """ If specified, the caller is requesting task-augmented execution for this request. @@ -64,7 +69,7 @@ class Meta(MCPModel): for task augmentation of specific request types in their capabilities. """ - meta: Meta | None = Field(alias="_meta", default=None) + meta: RequestParamsMeta | None = Field(alias="_meta", default=None) class PaginatedRequestParams(RequestParams): @@ -76,9 +81,6 @@ class PaginatedRequestParams(RequestParams): class NotificationParams(MCPModel): - class Meta(MCPModel): - pass - meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) @@ -115,7 +117,7 @@ class Notification(MCPModel, Generic[NotificationParamsT, MethodT]): class Result(MCPModel): """Base class for JSON-RPC results.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -624,7 +626,7 @@ class Resource(BaseMetadata): icons: list[Icon] | None = None """An optional list of icons for this resource.""" annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -649,7 +651,7 @@ class ResourceTemplate(BaseMetadata): icons: list[Icon] | None = None """An optional list of icons for this resource template.""" annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -698,7 +700,7 @@ class ResourceContents(MCPModel): """The URI of this resource.""" mime_type: str | None = None """The MIME type of this resource, if known.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -821,7 +823,7 @@ class Prompt(BaseMetadata): """A list of arguments to use for templating the prompt.""" icons: list[Icon] | None = None """An optional list of icons for this prompt.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -857,7 +859,7 @@ class TextContent(MCPModel): text: str """The text content of the message.""" annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -876,7 +878,7 @@ class ImageContent(MCPModel): image types. """ annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -895,7 +897,7 @@ class AudioContent(MCPModel): audio types. """ annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -922,7 +924,7 @@ class ToolUseContent(MCPModel): input: dict[str, Any] """Arguments to pass to the tool. Must conform to the tool's inputSchema.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -956,7 +958,7 @@ class ToolResultContent(MCPModel): is_error: bool | None = None """Whether the tool execution resulted in an error.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -980,7 +982,7 @@ class SamplingMessage(MCPModel): Message content. Can be a single content block or an array of content blocks for multi-modal messages and tool interactions. """ - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -1003,7 +1005,7 @@ class EmbeddedResource(MCPModel): type: Literal["resource"] = "resource" resource: TextResourceContents | BlobResourceContents annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -1134,7 +1136,7 @@ class Tool(BaseMetadata): """An optional list of icons for this tool.""" annotations: ToolAnnotations | None = None """Optional additional tool information.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -1482,7 +1484,7 @@ class Root(MCPModel): identifier for the root, which may be useful for display purposes or for referencing the root in other parts of the application. """ - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 5c1f55d23..220c571a5 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -19,6 +19,7 @@ JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + RequestParamsMeta, ServerCapabilities, TextContent, client_notification_adapter, @@ -608,7 +609,7 @@ async def mock_server(): @pytest.mark.anyio @pytest.mark.parametrize(argnames="meta", argvalues=[None, {"toolMeta": "value"}]) -async def test_client_tool_call_with_meta(meta: dict[str, Any] | None): +async def test_client_tool_call_with_meta(meta: RequestParamsMeta | None): """Test that client tool call requests can include metadata""" client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) diff --git a/tests/experimental/tasks/server/test_run_task_flow.py b/tests/experimental/tasks/server/test_run_task_flow.py index 13c702a1c..410a8b07a 100644 --- a/tests/experimental/tasks/server/test_run_task_flow.py +++ b/tests/experimental/tasks/server/test_run_task_flow.py @@ -78,8 +78,8 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResu ctx.experimental.validate_task_mode(TASK_REQUIRED) # Capture the meta from the request (if present) - if ctx.meta is not None and ctx.meta.model_extra: # pragma: no branch - received_meta[0] = ctx.meta.model_extra.get("custom_field") + if ctx.meta is not None and ctx.meta.get("model_extra"): # pragma: no branch + received_meta[0] = ctx.meta["model_extra"].get("custom_field") async def work(task: ServerTaskContext) -> CallToolResult: await task.update_status("Working...") diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index b024d8e92..be99e7583 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -1,5 +1,4 @@ import pytest -from pydantic import AnyUrl from mcp import Client from mcp.server.fastmcp import FastMCP @@ -88,14 +87,14 @@ def get_user_profile(user_id: str) -> str: assert "resource://users/{user_id}/profile" in templates # Read a resource with valid parameters - result = await session.read_resource(AnyUrl("resource://users/123/posts/456")) + result = await session.read_resource("resource://users/123/posts/456") contents = result.contents[0] assert isinstance(contents, TextResourceContents) assert contents.text == "Post 456 by user 123" assert contents.mime_type == "text/plain" # Read another resource with valid parameters - result = await session.read_resource(AnyUrl("resource://users/789/profile")) + result = await session.read_resource("resource://users/789/profile") contents = result.contents[0] assert isinstance(contents, TextResourceContents) assert contents.text == "Profile for user 789" @@ -103,7 +102,7 @@ def get_user_profile(user_id: str) -> str: # Verify invalid resource URIs raise appropriate errors with pytest.raises(Exception): # Specific exception type may vary - await session.read_resource(AnyUrl("resource://users/123/posts")) # Missing post_id + await session.read_resource("resource://users/123/posts") # Missing post_id with pytest.raises(Exception): # Specific exception type may vary - await session.read_resource(AnyUrl("resource://users/123/invalid")) # Invalid template + await session.read_resource("resource://users/123/invalid") # Invalid template diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index 9618d8414..7d1ac00c7 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -1,7 +1,6 @@ import base64 import pytest -from pydantic import AnyUrl from mcp import Client, types from mcp.server.fastmcp import FastMCP @@ -46,12 +45,12 @@ def get_image_as_bytes() -> bytes: assert bytes_resource.mime_type == "image/png", "Bytes resource mime type not respected" # Also verify the content can be read correctly - string_result = await client.read_resource(AnyUrl("test://image")) + string_result = await client.read_resource("test://image") assert len(string_result.contents) == 1 assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch" assert string_result.contents[0].mime_type == "image/png", "String content mime type not preserved" - bytes_result = await client.read_resource(AnyUrl("test://image_bytes")) + bytes_result = await client.read_resource("test://image_bytes") assert len(bytes_result.contents) == 1 assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch" assert bytes_result.contents[0].mime_type == "image/png", "Bytes content mime type not preserved" @@ -104,12 +103,12 @@ async def handle_read_resource(uri: str): assert bytes_resource.mime_type == "image/png", "Bytes resource mime type not respected" # Also verify the content can be read correctly - string_result = await client.read_resource(AnyUrl("test://image")) + string_result = await client.read_resource("test://image") assert len(string_result.contents) == 1 assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch" assert string_result.contents[0].mime_type == "image/png", "String content mime type not preserved" - bytes_result = await client.read_resource(AnyUrl("test://image_bytes")) + bytes_result = await client.read_resource("test://image_bytes") assert len(bytes_result.contents) == 1 assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch" assert bytes_result.contents[0].mime_type == "image/png", "Bytes content mime type not preserved" diff --git a/tests/issues/test_1754_mime_type_parameters.py b/tests/issues/test_1754_mime_type_parameters.py index c48d56b81..6ad7bdee8 100644 --- a/tests/issues/test_1754_mime_type_parameters.py +++ b/tests/issues/test_1754_mime_type_parameters.py @@ -5,7 +5,6 @@ """ import pytest -from pydantic import AnyUrl from mcp import Client from mcp.server.fastmcp import FastMCP @@ -63,6 +62,6 @@ def my_widget() -> str: async with Client(mcp) as client: # Read the resource - result = await client.read_resource(AnyUrl("ui://my-widget")) + result = await client.read_resource("ui://my-widget") assert len(result.contents) == 1 assert result.contents[0].mime_type == "text/html;profile=mcp-app" diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py index 07c3ce397..2dba0c675 100644 --- a/tests/issues/test_176_progress_token.py +++ b/tests/issues/test_176_progress_token.py @@ -16,13 +16,10 @@ async def test_progress_token_zero_first_call(): mock_session.send_progress_notification = AsyncMock() # Create request context with progress token 0 - mock_meta = MagicMock() - mock_meta.progress_token = 0 # This is the key test case - token is 0 - request_context = RequestContext( request_id="test-request", session=mock_session, - meta=mock_meta, + meta={"progress_token": 0}, lifespan_context=None, ) diff --git a/tests/issues/test_188_concurrency.py b/tests/issues/test_188_concurrency.py index 615df3d8e..61a9b2c6b 100644 --- a/tests/issues/test_188_concurrency.py +++ b/tests/issues/test_188_concurrency.py @@ -1,6 +1,5 @@ import anyio import pytest -from pydantic import AnyUrl from mcp import Client from mcp.server.fastmcp import FastMCP @@ -76,7 +75,7 @@ async def slow_resource(): # Start the tool first (it will wait on event) tg.start_soon(client_session.call_tool, "sleep") # Then the resource (it will set the event) - tg.start_soon(client_session.read_resource, AnyUrl("slow://slow_resource")) + tg.start_soon(client_session.read_resource, "slow://slow_resource") # Verify that both ran concurrently assert call_order == [ diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index 5f7caf7ac..a7f945f78 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -17,7 +17,7 @@ import pytest import uvicorn from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from inline_snapshot import snapshot from examples.snippets.servers import ( basic_prompt, @@ -300,14 +300,14 @@ async def test_basic_resources(server_transport: str, server_url: str) -> None: assert result.capabilities.resources is not None # Test document resource - doc_content = await session.read_resource(AnyUrl("file://documents/readme")) + doc_content = await session.read_resource("file://documents/readme") assert isinstance(doc_content, ReadResourceResult) assert len(doc_content.contents) == 1 assert isinstance(doc_content.contents[0], TextResourceContents) assert "Content of readme" in doc_content.contents[0].text # Test settings resource - settings_content = await session.read_resource(AnyUrl("config://settings")) + settings_content = await session.read_resource("config://settings") assert isinstance(settings_content, ReadResourceResult) assert len(settings_content.contents) == 1 assert isinstance(settings_content.contents[0], TextResourceContents) @@ -412,10 +412,7 @@ async def progress_callback(progress: float, total: float | None, message: str | {"task_name": "Test Task", "steps": steps}, progress_callback=progress_callback, ) - - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert "Task 'Test Task' completed" in tool_result.content[0].text + assert tool_result.content == snapshot([TextContent(text="Task 'Test Task' completed")]) # Verify progress updates assert len(progress_updates) == steps @@ -641,7 +638,7 @@ async def test_fastmcp_quickstart(server_transport: str, server_url: str) -> Non assert tool_result.content[0].text == "30" # Test greeting resource directly - resource_result = await session.read_resource(AnyUrl("greeting://Alice")) + resource_result = await session.read_resource("greeting://Alice") assert len(resource_result.contents) == 1 assert isinstance(resource_result.contents[0], TextResourceContents) assert resource_result.contents[0].text == "Hello, Alice!" diff --git a/tests/shared/test_progress_notifications.py b/tests/shared/test_progress_notifications.py index d65622822..13edcec01 100644 --- a/tests/shared/test_progress_notifications.py +++ b/tests/shared/test_progress_notifications.py @@ -275,11 +275,10 @@ async def handle_client_message( progress_token = "client_token_456" # Create request context - meta = types.RequestParams.Meta(progress_token=progress_token) request_context = RequestContext( request_id="test-request", session=client_session, - meta=meta, + meta={"progress_token": progress_token}, lifespan_context=None, ) diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index 06b56c63c..8fb7aeec3 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -8,7 +8,6 @@ import anyio import pytest import uvicorn -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.routing import WebSocketRoute from starlette.websockets import WebSocket @@ -164,7 +163,7 @@ async def test_ws_client_happy_request_and_response( initialized_ws_client_session: ClientSession, ) -> None: """Test a successful request and response via WebSocket""" - result = await initialized_ws_client_session.read_resource(AnyUrl("foobar://example")) + result = await initialized_ws_client_session.read_resource("foobar://example") assert isinstance(result, ReadResourceResult) assert isinstance(result.contents, list) assert len(result.contents) > 0 @@ -178,7 +177,7 @@ async def test_ws_client_exception_handling( ) -> None: """Test exception handling in WebSocket communication""" with pytest.raises(McpError) as exc_info: - await initialized_ws_client_session.read_resource(AnyUrl("unknown://example")) + await initialized_ws_client_session.read_resource("unknown://example") assert exc_info.value.error.code == 404 @@ -190,11 +189,11 @@ async def test_ws_client_timeout( # Set a very short timeout to trigger a timeout exception with pytest.raises(TimeoutError): with anyio.fail_after(0.1): # 100ms timeout - await initialized_ws_client_session.read_resource(AnyUrl("slow://example")) + await initialized_ws_client_session.read_resource("slow://example") # Now test that we can still use the session after a timeout with anyio.fail_after(5): # Longer timeout to allow completion - result = await initialized_ws_client_session.read_resource(AnyUrl("foobar://example")) + result = await initialized_ws_client_session.read_resource("foobar://example") assert isinstance(result, ReadResourceResult) assert isinstance(result.contents, list) assert len(result.contents) > 0 diff --git a/tests/test_examples.py b/tests/test_examples.py index 187cda321..327729d4a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -9,7 +9,6 @@ from pathlib import Path import pytest -from pydantic import AnyUrl from pytest_examples import CodeExample, EvalExample, find_examples from mcp import Client @@ -82,7 +81,7 @@ async def test_desktop(monkeypatch: pytest.MonkeyPatch): assert content.text == "3" # Test the desktop resource - result = await client.read_resource(AnyUrl("dir://desktop")) + result = await client.read_resource("dir://desktop") assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) From 54fcaa3ffa31f350885335d87b09a3ad188f3124 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 21 Jan 2026 21:11:07 +0100 Subject: [PATCH 2/4] Add migration note --- docs/migration.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/migration.md b/docs/migration.md index 19dc9326d..a703dc9fc 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -274,7 +274,19 @@ Affected types: - `UnsubscribeRequestParams.uri` - `ResourceUpdatedNotificationParams.uri` -The `ClientSession.read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` methods now accept both `str` and `AnyUrl` for backwards compatibility. +The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings: + +```python +# Before (v1) +from pydantic import AnyUrl + +await client.read_resource(AnyUrl("test://resource")) + +# After (v2) +await client.read_resource("test://resource") +# Or if you have an AnyUrl from elsewhere: +await client.read_resource(str(my_any_url)) +``` ## Deprecations From 384e82c57767a547b850046b544254a87d8a4f9d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 21 Jan 2026 21:26:09 +0100 Subject: [PATCH 3/4] update --- src/mcp/types/_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index 0e4c1fa8f..1f832870d 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -1,11 +1,11 @@ from __future__ import annotations from datetime import datetime -from typing import Annotated, Any, Final, Generic, Literal, NotRequired, TypeAlias, TypeVar +from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar from pydantic import BaseModel, ConfigDict, Field, FileUrl, TypeAdapter from pydantic.alias_generators import to_camel -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from mcp.types.jsonrpc import RequestId From 6ae2481b71adbf8295f3f6125b43e4356be34e4b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 21 Jan 2026 22:25:39 +0100 Subject: [PATCH 4/4] update --- tests/experimental/tasks/server/test_run_task_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/experimental/tasks/server/test_run_task_flow.py b/tests/experimental/tasks/server/test_run_task_flow.py index 410a8b07a..0d5d1df77 100644 --- a/tests/experimental/tasks/server/test_run_task_flow.py +++ b/tests/experimental/tasks/server/test_run_task_flow.py @@ -78,8 +78,8 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResu ctx.experimental.validate_task_mode(TASK_REQUIRED) # Capture the meta from the request (if present) - if ctx.meta is not None and ctx.meta.get("model_extra"): # pragma: no branch - received_meta[0] = ctx.meta["model_extra"].get("custom_field") + if ctx.meta is not None: # pragma: no branch + received_meta[0] = ctx.meta.get("custom_field") async def work(task: ServerTaskContext) -> CallToolResult: await task.update_status("Working...")