From 0691701951fc2684aae285497a7f36036fd46e25 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:07:53 -0700 Subject: [PATCH] Add _fastmcp meta namespace (#1290) --- docs/clients/prompts.mdx | 12 +- docs/clients/resources.mdx | 15 +- docs/clients/tools.mdx | 12 +- docs/integrations/openapi.mdx | 9 +- docs/servers/prompts.mdx | 2 +- docs/servers/resources.mdx | 2 +- docs/servers/server.mdx | 66 +++-- docs/servers/tools.mdx | 2 +- src/fastmcp/prompts/prompt.py | 9 +- src/fastmcp/resources/resource.py | 9 +- src/fastmcp/resources/template.py | 9 +- src/fastmcp/server/server.py | 34 ++- src/fastmcp/settings.py | 15 + src/fastmcp/tools/tool.py | 9 +- src/fastmcp/utilities/components.py | 47 +++- .../openapi/test_basic_functionality.py | 4 +- tests/server/proxy/test_proxy_client.py | 2 +- tests/server/proxy/test_proxy_server.py | 8 +- tests/server/test_server_interactions.py | 258 +++++++++++++----- 19 files changed, 373 insertions(+), 151 deletions(-) diff --git a/docs/clients/prompts.mdx b/docs/clients/prompts.mdx index ea5ee5604..a6d6e155b 100644 --- a/docs/clients/prompts.mdx +++ b/docs/clients/prompts.mdx @@ -27,11 +27,14 @@ async with client: print(f"Arguments: {[arg.name for arg in prompt.arguments]}") # Access tags and other metadata if hasattr(prompt, '_meta') and prompt._meta: - print(f"Tags: {prompt._meta.get('tags', [])}") + fastmcp_meta = prompt._meta.get('_fastmcp', {}) + print(f"Tags: {fastmcp_meta.get('tags', [])}") ``` ### Filtering by Tags + + You can use the `meta` field to filter prompts based on their tags: ```python @@ -41,15 +44,16 @@ async with client: # Filter prompts by tag analysis_prompts = [ prompt for prompt in prompts - if hasattr(prompt, '_meta') and prompt._meta and - 'analysis' in prompt._meta.get('tags', []) + if hasattr(prompt, '_meta') and prompt._meta and + prompt._meta.get('_fastmcp', {}) and + 'analysis' in prompt._meta.get('_fastmcp', {}).get('tags', []) ] print(f"Found {len(analysis_prompts)} analysis prompts") ``` -The `_meta` field is part of the standard MCP specification, but the inclusion of tags within it is specific to FastMCP servers. Other MCP server implementations may not provide tags in their metadata. +The `_meta` field is part of the standard MCP specification. FastMCP servers include tags and other metadata within a `_fastmcp` namespace (e.g., `_meta._fastmcp.tags`) to avoid conflicts with user-defined metadata. This behavior can be controlled with the server's `include_fastmcp_meta` setting - when disabled, the `_fastmcp` namespace won't be included. Other MCP server implementations may not provide this metadata structure. ## Using Prompts diff --git a/docs/clients/resources.mdx b/docs/clients/resources.mdx index a8686e184..b5120fdf1 100644 --- a/docs/clients/resources.mdx +++ b/docs/clients/resources.mdx @@ -36,7 +36,8 @@ async with client: print(f"MIME Type: {resource.mimeType}") # Access tags and other metadata if hasattr(resource, '_meta') and resource._meta: - print(f"Tags: {resource._meta.get('tags', [])}") + fastmcp_meta = resource._meta.get('_fastmcp', {}) + print(f"Tags: {fastmcp_meta.get('tags', [])}") ``` ### Resource Templates @@ -54,11 +55,14 @@ async with client: print(f"Description: {template.description}") # Access tags and other metadata if hasattr(template, '_meta') and template._meta: - print(f"Tags: {template._meta.get('tags', [])}") + fastmcp_meta = template._meta.get('_fastmcp', {}) + print(f"Tags: {fastmcp_meta.get('tags', [])}") ``` ### Filtering by Tags + + You can use the `meta` field to filter resources based on their tags: ```python @@ -68,15 +72,16 @@ async with client: # Filter resources by tag config_resources = [ resource for resource in resources - if hasattr(resource, '_meta') and resource._meta and - 'config' in resource._meta.get('tags', []) + if hasattr(resource, '_meta') and resource._meta and + resource._meta.get('_fastmcp', {}) and + 'config' in resource._meta.get('_fastmcp', {}).get('tags', []) ] print(f"Found {len(config_resources)} config resources") ``` -The `_meta` field is part of the standard MCP specification, but the inclusion of tags within it is specific to FastMCP servers. Other MCP server implementations may not provide tags in their metadata. +The `_meta` field is part of the standard MCP specification. FastMCP servers include tags and other metadata within a `_fastmcp` namespace (e.g., `_meta._fastmcp.tags`) to avoid conflicts with user-defined metadata. This behavior can be controlled with the server's `include_fastmcp_meta` setting - when disabled, the `_fastmcp` namespace won't be included. Other MCP server implementations may not provide this metadata structure. ## Reading Resources diff --git a/docs/clients/tools.mdx b/docs/clients/tools.mdx index ed6b9d390..78ba5fb59 100644 --- a/docs/clients/tools.mdx +++ b/docs/clients/tools.mdx @@ -27,11 +27,14 @@ async with client: print(f"Parameters: {tool.inputSchema}") # Access tags and other metadata if hasattr(tool, '_meta') and tool._meta: - print(f"Tags: {tool._meta.get('tags', [])}") + fastmcp_meta = tool._meta.get('_fastmcp', {}) + print(f"Tags: {fastmcp_meta.get('tags', [])}") ``` ### Filtering by Tags + + You can use the `meta` field to filter tools based on their tags: ```python @@ -41,15 +44,16 @@ async with client: # Filter tools by tag analysis_tools = [ tool for tool in tools - if hasattr(tool, '_meta') and tool._meta and - 'analysis' in tool._meta.get('tags', []) + if hasattr(tool, '_meta') and tool._meta and + tool._meta.get('_fastmcp', {}) and + 'analysis' in tool._meta.get('_fastmcp', {}).get('tags', []) ] print(f"Found {len(analysis_tools)} analysis tools") ``` -The `_meta` field is part of the standard MCP specification, but the inclusion of tags within it is specific to FastMCP servers. Other MCP server implementations may not provide tags in their metadata. +The `_meta` field is part of the standard MCP specification. FastMCP servers include tags and other metadata within a `_fastmcp` namespace (e.g., `_meta._fastmcp.tags`) to avoid conflicts with user-defined metadata. This behavior can be controlled with the server's `include_fastmcp_meta` setting - when disabled, the `_fastmcp` namespace won't be included. Other MCP server implementations may not provide this metadata structure. ## Executing Tools diff --git a/docs/integrations/openapi.mdx b/docs/integrations/openapi.mdx index ec7a15796..056cea1bb 100644 --- a/docs/integrations/openapi.mdx +++ b/docs/integrations/openapi.mdx @@ -334,7 +334,7 @@ mcp = FastMCP.from_openapi( #### OpenAPI Tags in Client Meta -FastMCP automatically includes OpenAPI tags from your specification in the component's metadata. These tags are available to MCP clients through the `_meta` field, allowing clients to filter and organize components based on the original OpenAPI tagging: +FastMCP automatically includes OpenAPI tags from your specification in the component's metadata. These tags are available to MCP clients through the `_meta._fastmcp.tags` field, allowing clients to filter and organize components based on the original OpenAPI tagging: ```json {5} OpenAPI spec with tags @@ -350,13 +350,14 @@ FastMCP automatically includes OpenAPI tags from your specification in the compo } } ``` -```python {6-8} Access OpenAPI tags in MCP client +```python {6-9} Access OpenAPI tags in MCP client async with client: tools = await client.list_tools() for tool in tools: if hasattr(tool, '_meta') and tool._meta: - # OpenAPI tags are now available! - openapi_tags = tool._meta.get('tags', []) + # OpenAPI tags are now available in _fastmcp namespace! + fastmcp_meta = tool._meta.get('_fastmcp', {}) + openapi_tags = fastmcp_meta.get('tags', []) if 'users' in openapi_tags: print(f"Found user-related tool: {tool.name}") ``` diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index 814cc2490..470bf2e68 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -85,7 +85,7 @@ def data_analysis_prompt( - A set of strings used to categorize the prompt. Clients might use tags to filter or group available prompts. Tags are available to clients in the prompt's `meta` field when the prompt is listed (via `list_prompts`) + A set of strings used to categorize the prompt. These can be used by the server and, in some cases, by clients to filter or group available prompts. diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 031dd1b79..596ae027e 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -98,7 +98,7 @@ def get_application_status() -> dict: - A set of strings for categorization, potentially used by clients for filtering. Tags are available to clients in the resource's `meta` field when the resource is listed (via `list_resources` or `list_resource_templates`) + A set of strings used to categorize the resource. These can be used by the server and, in some cases, by clients to filter or group available resources. diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index deb5d5f27..b2a1140da 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -44,16 +44,38 @@ The `FastMCP` constructor accepts several arguments: An async context manager function for server startup and shutdown logic - - A set of strings to tag the server itself - - A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator - - Keyword arguments corresponding to additional `ServerSettings` configuration + + Optional server dependencies list with package specifications + + + + Only expose components with at least one matching tag + + + + Hide components with any matching tag + + + + How to handle duplicate tool registrations + + + + How to handle duplicate resource registrations + + + + How to handle duplicate prompt registrations + + + + + + Whether to include FastMCP metadata in component responses. When `True`, component tags and other FastMCP-specific metadata are included in the `_fastmcp` namespace within each component's `meta` field. When `False`, this metadata is omitted, resulting in cleaner integration with external systems. Can be overridden globally via `FASTMCP_INCLUDE_FASTMCP_META` environment variable ## Components @@ -274,37 +296,10 @@ mcp = FastMCP( on_duplicate_tools="error", # Handle duplicate registrations on_duplicate_resources="warn", on_duplicate_prompts="replace", + include_fastmcp_meta=False, # Disable FastMCP metadata for cleaner integration ) ``` -### Constructor Parameters - - - - Optional server dependencies list with package specifications - - - - Only expose components with at least one matching tag - - - - Hide components with any matching tag - - - - How to handle duplicate tool registrations - - - - How to handle duplicate resource registrations - - - - How to handle duplicate prompt registrations - - - ### Global Settings Global settings affect all FastMCP servers and can be configured via environment variables (prefixed with `FASTMCP_`) or in a `.env` file: @@ -316,12 +311,14 @@ import fastmcp print(fastmcp.settings.log_level) # Default: "INFO" print(fastmcp.settings.mask_error_details) # Default: False print(fastmcp.settings.resource_prefix_format) # Default: "path" +print(fastmcp.settings.include_fastmcp_meta) # Default: True ``` Common global settings include: - **`log_level`**: Logging level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), set with `FASTMCP_LOG_LEVEL` - **`mask_error_details`**: Whether to hide detailed error information from clients, set with `FASTMCP_MASK_ERROR_DETAILS` - **`resource_prefix_format`**: How to format resource prefixes ("path" or "protocol"), set with `FASTMCP_RESOURCE_PREFIX_FORMAT` +- **`include_fastmcp_meta`**: Whether to include FastMCP metadata in component responses (default: True), set with `FASTMCP_INCLUDE_FASTMCP_META` ### Transport-Specific Configuration @@ -353,6 +350,7 @@ Global FastMCP settings can be configured via environment variables (prefixed wi export FASTMCP_LOG_LEVEL=DEBUG export FASTMCP_MASK_ERROR_DETAILS=True export FASTMCP_RESOURCE_PREFIX_FORMAT=protocol +export FASTMCP_INCLUDE_FASTMCP_META=False ``` ### Custom Tool Serialization diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 9baeaadee..c94f7ea77 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -76,7 +76,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l - A set of strings to categorize the tool. Clients might use tags to filter or group available tools. Tags are available to clients in the tool's `meta` field when the tool is listed (via `list_tools`) + A set of strings used to categorize the tool. These can be used by the server and, in some cases, by clients to filter or group available tools. diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index e098c7369..511f5b13d 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -85,7 +85,12 @@ class Prompt(FastMCPComponent, ABC): except RuntimeError: pass # No context available - def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt: + def to_mcp_prompt( + self, + *, + include_fastmcp_meta: bool | None = None, + **overrides: Any, + ) -> MCPPrompt: """Convert the prompt to an MCP prompt.""" arguments = [ MCPPromptArgument( @@ -100,7 +105,7 @@ class Prompt(FastMCPComponent, ABC): "description": self.description, "arguments": arguments, "title": self.title, - "_meta": self.get_meta(), + "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), } return MCPPrompt(**kwargs | overrides) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 3527e541e..9f8e1a5b6 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -113,7 +113,12 @@ class Resource(FastMCPComponent, abc.ABC): """Read the resource content.""" pass - def to_mcp_resource(self, **overrides: Any) -> MCPResource: + def to_mcp_resource( + self, + *, + include_fastmcp_meta: bool | None = None, + **overrides: Any, + ) -> MCPResource: """Convert the resource to an MCPResource.""" kwargs = { "uri": self.uri, @@ -122,7 +127,7 @@ class Resource(FastMCPComponent, abc.ABC): "mimeType": self.mime_type, "title": self.title, "annotations": self.annotations, - "_meta": self.get_meta(), + "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), } return MCPResource(**kwargs | overrides) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 6ab9dbb99..9af3487d2 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -145,7 +145,12 @@ class ResourceTemplate(FastMCPComponent): enabled=self.enabled, ) - def to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate: + def to_mcp_template( + self, + *, + include_fastmcp_meta: bool | None = None, + **overrides: Any, + ) -> MCPResourceTemplate: """Convert the resource template to an MCPResourceTemplate.""" kwargs = { "uriTemplate": self.uri_template, @@ -154,7 +159,7 @@ class ResourceTemplate(FastMCPComponent): "mimeType": self.mime_type, "title": self.title, "annotations": self.annotations, - "_meta": self.get_meta(), + "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), } return MCPResourceTemplate(**kwargs | overrides) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index fe348c0a4..4b7cd056c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -154,6 +154,7 @@ class FastMCP(Generic[LifespanResultT]): dependencies: list[str] | None = None, include_tags: set[str] | None = None, exclude_tags: set[str] | None = None, + include_fastmcp_meta: bool | None = None, # --- # --- # --- The following arguments are DEPRECATED --- @@ -223,6 +224,12 @@ class FastMCP(Generic[LifespanResultT]): self._setup_handlers() self.dependencies = dependencies or fastmcp.settings.server_dependencies + self.include_fastmcp_meta = ( + include_fastmcp_meta + if include_fastmcp_meta is not None + else fastmcp.settings.include_fastmcp_meta + ) + # handle deprecated settings self._handle_deprecated_settings( log_level=log_level, @@ -470,7 +477,13 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): tools = await self._list_tools() - return [tool.to_mcp_tool(name=tool.key) for tool in tools] + return [ + tool.to_mcp_tool( + name=tool.key, + include_fastmcp_meta=self.include_fastmcp_meta, + ) + for tool in tools + ] async def _list_tools(self) -> list[Tool]: """ @@ -509,7 +522,11 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): resources = await self._list_resources() return [ - resource.to_mcp_resource(uri=resource.key) for resource in resources + resource.to_mcp_resource( + uri=resource.key, + include_fastmcp_meta=self.include_fastmcp_meta, + ) + for resource in resources ] async def _list_resources(self) -> list[Resource]: @@ -550,7 +567,10 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): templates = await self._list_resource_templates() return [ - template.to_mcp_template(uriTemplate=template.key) + template.to_mcp_template( + uriTemplate=template.key, + include_fastmcp_meta=self.include_fastmcp_meta, + ) for template in templates ] @@ -591,7 +611,13 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): prompts = await self._list_prompts() - return [prompt.to_mcp_prompt(name=prompt.key) for prompt in prompts] + return [ + prompt.to_mcp_prompt( + name=prompt.key, + include_fastmcp_meta=self.include_fastmcp_meta, + ) + for prompt in prompts + ] async def _list_prompts(self) -> list[Prompt]: """ diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index bcdf93c6d..a6a82e3e3 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -304,6 +304,21 @@ class Settings(BaseSettings): ), ] = None + include_fastmcp_meta: Annotated[ + bool, + Field( + default=True, + description=inspect.cleandoc( + """ + Whether to include FastMCP meta in the server's MCP responses. + If True, a `_fastmcp` key will be added to the `meta` field of + all MCP component responses. This key will contain a dict of + various FastMCP-specific metadata, such as tags. + """ + ), + ), + ] = True + def __getattr__(name: str): """ diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index a9d95ecd6..5030822f3 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -130,7 +130,12 @@ class Tool(FastMCPComponent): except RuntimeError: pass # No context available - def to_mcp_tool(self, **overrides: Any) -> MCPTool: + def to_mcp_tool( + self, + *, + include_fastmcp_meta: bool | None = None, + **overrides: Any, + ) -> MCPTool: if self.title: title = self.title elif self.annotations and self.annotations.title: @@ -145,7 +150,7 @@ class Tool(FastMCPComponent): "outputSchema": self.output_schema, "annotations": self.annotations, "title": title, - "_meta": self.get_meta(), + "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), } return MCPTool(**kwargs | overrides) diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index f2955c6b0..8d0756e83 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -1,14 +1,27 @@ +from __future__ import annotations + from collections.abc import Sequence -from typing import Annotated, Any, TypeVar +from typing import Annotated, Any, TypedDict, TypeVar from pydantic import BeforeValidator, Field, PrivateAttr from typing_extensions import Self +import fastmcp from fastmcp.utilities.types import FastMCPBaseModel T = TypeVar("T") +class FastMCPMeta(TypedDict, total=False): + tags: list[str] + + +def _merge_meta(left: FastMCPMeta, right: FastMCPMeta) -> FastMCPMeta: + return FastMCPMeta( + tags=sorted(set(left.get("tags", [])) | set(right.get("tags", []))) + ) + + def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: """Convert a sequence to a set, defaulting to an empty set if None.""" if maybe_set is None: @@ -36,8 +49,8 @@ class FastMCPComponent(FastMCPBaseModel): default_factory=set, description="Tags for the component.", ) - meta: dict[str, Any] = Field( - default_factory=dict, description="Meta information about the prompt" + meta: dict[str, Any] | None = Field( + default=None, description="Meta information about the component" ) enabled: bool = Field( default=True, @@ -60,12 +73,28 @@ class FastMCPComponent(FastMCPBaseModel): """ return self._key or self.name - def get_meta(self) -> dict[str, Any]: - """Get the meta information about the component.""" - if self.tags: - return {"tags": sorted(self.tags)} | self.meta - else: - return self.meta + def get_meta( + self, include_fastmcp_meta: bool | None = None + ) -> dict[str, Any] | None: + """ + Get the meta information about the component. + + If include_fastmcp_meta is True, a `_fastmcp` key will be added to the + meta, containing a `tags` field with the tags of the component. + """ + + if include_fastmcp_meta is None: + include_fastmcp_meta = fastmcp.settings.include_fastmcp_meta + + meta = self.meta or {} + + if include_fastmcp_meta: + fastmcp_meta = FastMCPMeta(tags=sorted(self.tags)) + if upstream_meta := meta.get("_fastmcp"): + fastmcp_meta = _merge_meta(upstream_meta, fastmcp_meta) + meta["_fastmcp"] = fastmcp_meta + + return meta or None def with_key(self, key: str) -> Self: return self.model_copy(update={"_key": key}) diff --git a/tests/server/openapi/test_basic_functionality.py b/tests/server/openapi/test_basic_functionality.py index 014531b87..ca558c115 100644 --- a/tests/server/openapi/test_basic_functionality.py +++ b/tests/server/openapi/test_basic_functionality.py @@ -97,7 +97,7 @@ class TestTools: assert tools[0].model_dump() == dict( name="create_user_users_post", - meta=dict(tags=["create", "users"]), + meta=dict(_fastmcp=dict(tags=["create", "users"])), title=None, annotations=None, description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL), @@ -122,7 +122,7 @@ class TestTools: ) assert tools[1].model_dump() == dict( name="update_user_name_users", - meta=dict(tags=["update", "users"]), + meta=dict(_fastmcp=dict(tags=["update", "users"])), title=None, annotations=None, description=IsStr( diff --git a/tests/server/proxy/test_proxy_client.py b/tests/server/proxy/test_proxy_client.py index 331252694..436f66f89 100644 --- a/tests/server/proxy/test_proxy_client.py +++ b/tests/server/proxy/test_proxy_client.py @@ -91,7 +91,7 @@ class TestProxyClient: async with Client(proxy_server) as client: tools = await client.list_tools() echo_tool = next(t for t in tools if t.name == "echo") - assert echo_tool.meta == {"tags": ["echo"]} + assert echo_tool.meta == {"_fastmcp": {"tags": ["echo"]}} async def test_forward_error_response(self, proxy_server: FastMCP): """ diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py index 42e0cb51b..e41b6f42a 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -124,7 +124,7 @@ class TestTools: async def test_get_tools_meta(self, proxy_server): tools = await proxy_server.get_tools() greet_tool = tools["greet"] - assert greet_tool.meta == {"tags": ["greet"]} + assert greet_tool.meta == {"_fastmcp": {"tags": ["greet"]}} async def test_get_transformed_tools( self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy @@ -246,7 +246,7 @@ class TestResources: async def test_get_resources_meta(self, proxy_server): resources = await proxy_server.get_resources() wave_resource = resources["resource://wave"] - assert wave_resource.meta == {"tags": ["wave"]} + assert wave_resource.meta == {"_fastmcp": {"tags": ["wave"]}} async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server): assert ( @@ -345,7 +345,7 @@ class TestResourceTemplates: async def test_get_resource_templates_meta(self, proxy_server): templates = await proxy_server.get_resource_templates() get_user_template = templates["data://user/{user_id}"] - assert get_user_template.meta == {"tags": ["users"]} + assert get_user_template.meta == {"_fastmcp": {"tags": ["users"]}} async def test_list_resource_templates_same_as_original( self, fastmcp_server, proxy_server @@ -449,7 +449,7 @@ class TestPrompts: async def test_get_prompts_meta(self, proxy_server): prompts = await proxy_server.get_prompts() welcome_prompt = prompts["welcome"] - assert welcome_prompt.meta == {"tags": ["welcome"]} + assert welcome_prompt.meta == {"_fastmcp": {"tags": ["welcome"]}} async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server): async with Client(fastmcp_server) as client: diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index eaf0211a6..6e02ce610 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -28,6 +28,7 @@ from fastmcp.resources import FileResource, ResourceTemplate from fastmcp.resources.resource import FunctionResource from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.json_schema import compress_schema +from fastmcp.utilities.tests import temporary_settings from fastmcp.utilities.types import Audio, File, Image @@ -268,21 +269,6 @@ class TestToolTags: result_2 = await client.call_tool("tool_2", {}) assert result_2.data == 2 - async def test_tags_in_meta(self): - mcp = FastMCP() - - @mcp.tool(tags={"tool-example", "test-tool-tag"}) - def sample_tool(x: int) -> int: - """A sample tool.""" - return x * 2 - - async with Client(mcp) as client: - tools = await client.list_tools() - assert len(tools) == 1 - tool = tools[0] - assert tool.meta is not None - assert set(tool.meta["tags"]) == {"tool-example", "test-tool-tag"} - class TestToolReturnTypes: async def test_string(self): @@ -1559,26 +1545,6 @@ class TestResourceTags: with pytest.raises(McpError, match="Unknown resource"): await client.read_resource(AnyUrl("resource://1")) - async def test_tags_in_meta(self): - mcp = FastMCP() - - @mcp.resource( - uri="test://resource", tags={"resource-example", "test-resource-tag"} - ) - def sample_resource() -> str: - """A sample resource.""" - return "resource content" - - async with Client(mcp) as client: - resources = await client.list_resources() - assert len(resources) == 1 - resource = resources[0] - assert resource.meta is not None - assert set(resource.meta["tags"]) == { - "resource-example", - "test-resource-tag", - } - class TestResourceContext: async def test_resource_with_context_annotation_gets_context(self): @@ -2007,26 +1973,6 @@ class TestResourceTemplatesTags: result = await client.read_resource("resource://2/x") assert result[0].text == "Template resource 2: x" # type: ignore[attr-defined] - async def test_tags_in_meta(self): - mcp = FastMCP() - - @mcp.resource( - "test://template/{id}", tags={"template-example", "test-template-tag"} - ) - def sample_template(id: str) -> str: - """A sample resource template.""" - return f"template content for {id}" - - async with Client(mcp) as client: - templates = await client.list_resource_templates() - assert len(templates) == 1 - template = templates[0] - assert template.meta is not None - assert set(template.meta["tags"]) == { - "template-example", - "test-template-tag", - } - class TestResourceTemplateContext: async def test_resource_template_context(self): @@ -2394,20 +2340,6 @@ class TestPrompts: prompt = prompts_dict["sample_prompt"] assert prompt.tags == {"example", "test-tag"} - async def test_tags_in_meta(self): - mcp = FastMCP() - - @mcp.prompt(tags={"example", "test-tag"}) - def sample_prompt() -> str: - return "Hello, world!" - - async with Client(mcp) as client: - prompts = await client.list_prompts() - assert len(prompts) == 1 - prompt = prompts[0] - assert prompt.meta is not None - assert set(prompt.meta["tags"]) == {"example", "test-tag"} - class TestPromptEnabled: async def test_toggle_enabled(self): @@ -2605,3 +2537,191 @@ class TestPromptTags: result = await client.get_prompt("prompt_2") assert result.messages[0].content.text == "2" # type: ignore[attr-defined] + + +class TestMeta: + """Test that include_fastmcp_meta controls whether _fastmcp key is present in meta.""" + + async def test_tool_tags_in_meta_with_default_setting(self): + """Test that tool tags appear in meta under _fastmcp key with default setting.""" + mcp = FastMCP() + + @mcp.tool(tags={"tool-example", "test-tool-tag"}) + def sample_tool(x: int) -> int: + """A sample tool.""" + return x * 2 + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "sample_tool") + assert tool.meta is not None + assert set(tool.meta["_fastmcp"]["tags"]) == { + "tool-example", + "test-tool-tag", + } + + async def test_resource_tags_in_meta_with_default_setting(self): + """Test that resource tags appear in meta under _fastmcp key with default setting.""" + mcp = FastMCP() + + @mcp.resource( + uri="test://resource", tags={"resource-example", "test-resource-tag"} + ) + def sample_resource() -> str: + """A sample resource.""" + return "resource content" + + async with Client(mcp) as client: + resources = await client.list_resources() + resource = next(r for r in resources if str(r.uri) == "test://resource") + assert resource.meta is not None + assert set(resource.meta["_fastmcp"]["tags"]) == { + "resource-example", + "test-resource-tag", + } + + async def test_resource_template_tags_in_meta_with_default_setting(self): + """Test that resource template tags appear in meta under _fastmcp key with default setting.""" + mcp = FastMCP() + + @mcp.resource( + "test://template/{id}", tags={"template-example", "test-template-tag"} + ) + def sample_template(id: str) -> str: + """A sample resource template.""" + return f"template content for {id}" + + async with Client(mcp) as client: + templates = await client.list_resource_templates() + template = next( + t for t in templates if t.uriTemplate == "test://template/{id}" + ) + assert template.meta is not None + assert set(template.meta["_fastmcp"]["tags"]) == { + "template-example", + "test-template-tag", + } + + async def test_prompt_tags_in_meta_with_default_setting(self): + """Test that prompt tags appear in meta under _fastmcp key with default setting.""" + mcp = FastMCP() + + @mcp.prompt(tags={"example", "test-tag"}) + def sample_prompt() -> str: + return "Hello, world!" + + async with Client(mcp) as client: + prompts = await client.list_prompts() + prompt = next(p for p in prompts if p.name == "sample_prompt") + assert prompt.meta is not None + assert set(prompt.meta["_fastmcp"]["tags"]) == {"example", "test-tag"} + + async def test_tool_meta_with_include_fastmcp_meta_false(self): + mcp = FastMCP(include_fastmcp_meta=False) + + @mcp.tool(tags={"tool-example", "test-tool-tag"}) + def sample_tool(x: int) -> int: + """A sample tool.""" + return x * 2 + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "sample_tool") + # Meta should be None when include_fastmcp_meta is False and no explicit meta is set + assert tool.meta is None + + async def test_resource_meta_with_include_fastmcp_meta_false(self): + mcp = FastMCP(include_fastmcp_meta=False) + + @mcp.resource( + uri="test://resource", tags={"resource-example", "test-resource-tag"} + ) + def sample_resource() -> str: + """A sample resource.""" + return "resource content" + + async with Client(mcp) as client: + resources = await client.list_resources() + resource = next(r for r in resources if str(r.uri) == "test://resource") + # Meta should be None when include_fastmcp_meta is False and no explicit meta is set + assert resource.meta is None + + async def test_resource_template_meta_with_include_fastmcp_meta_false(self): + mcp = FastMCP(include_fastmcp_meta=False) + + @mcp.resource( + "test://template/{id}", tags={"template-example", "test-template-tag"} + ) + def sample_template(id: str) -> str: + """A sample resource template.""" + return f"template content for {id}" + + async with Client(mcp) as client: + templates = await client.list_resource_templates() + template = next( + t for t in templates if t.uriTemplate == "test://template/{id}" + ) + # Meta should be None when include_fastmcp_meta is False and no explicit meta is set + assert template.meta is None + + async def test_prompt_meta_with_include_fastmcp_meta_false(self): + mcp = FastMCP(include_fastmcp_meta=False) + + @mcp.prompt(tags={"example", "test-tag"}) + def sample_prompt() -> str: + return "Hello, world!" + + async with Client(mcp) as client: + prompts = await client.list_prompts() + prompt = next(p for p in prompts if p.name == "sample_prompt") + # Meta should be None when include_fastmcp_meta is False and no explicit meta is set + assert prompt.meta is None + + async def test_global_settings_inheritance(self): + """Test that servers inherit the global include_fastmcp_meta setting.""" + with temporary_settings(include_fastmcp_meta=False): + # Server should inherit global setting + mcp = FastMCP() + + @mcp.tool(tags={"test-tag"}) + def sample_tool(x: int) -> int: + return x * 2 + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "sample_tool") + # Meta should be None because global setting is False + assert tool.meta is None + + # Verify that default behavior is restored + mcp2 = FastMCP() + + @mcp2.tool(tags={"test-tag"}) + def another_tool(x: int) -> int: + return x * 2 + + async with Client(mcp2) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "another_tool") + # Meta should have _fastmcp key because global setting is back to default (True) + assert tool.meta is not None + assert "_fastmcp" in tool.meta + assert tool.meta["_fastmcp"]["tags"] == ["test-tag"] + + async def test_explicit_override_of_global_setting(self): + """Test that explicit include_fastmcp_meta parameter overrides global setting.""" + with temporary_settings(include_fastmcp_meta=False): + # Explicitly override global setting to True + mcp = FastMCP(include_fastmcp_meta=True) + + @mcp.tool(tags={"test-tag"}) + def sample_tool(x: int) -> int: + return x * 2 + + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "sample_tool") + # Meta should have _fastmcp key because explicit setting overrides global + assert tool.meta is not None + assert "_fastmcp" in tool.meta + assert tool.meta["_fastmcp"]["tags"] == ["test-tag"]