diff --git a/CLAUDE.md b/CLAUDE.md index a27089e19..60efc0fb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,7 @@ When modifying MCP functionality, changes typically need to be applied across al - **Structure:** Headers form navigation guide, logical H2/H3 hierarchy - **Content:** User-focused sections, motivate features (why) before mechanics (how) - **Style:** Prose over code comments for important information +- **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead. ## Critical Patterns diff --git a/docs/servers/transforms/prompts-as-tools.mdx b/docs/servers/transforms/prompts-as-tools.mdx index b68c0891d..6a9ab1b47 100644 --- a/docs/servers/transforms/prompts-as-tools.mdx +++ b/docs/servers/transforms/prompts-as-tools.mdx @@ -21,7 +21,11 @@ This means any client that can call tools can now access prompts, even if the cl ## Basic Usage -Pass your server to `PromptsAsTools` when adding the transform. The transform queries that server for prompts whenever the generated tools are called. +Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls. + + +`PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there. + ```python from fastmcp import FastMCP diff --git a/docs/servers/transforms/resources-as-tools.mdx b/docs/servers/transforms/resources-as-tools.mdx index 2251d9b49..b79980dcc 100644 --- a/docs/servers/transforms/resources-as-tools.mdx +++ b/docs/servers/transforms/resources-as-tools.mdx @@ -21,7 +21,11 @@ This means any client that can call tools can now access resources, even if the ## Basic Usage -Pass your server to `ResourcesAsTools` when adding the transform. The transform queries that server for resources whenever the generated tools are called. +Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls. + + +`ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there. + ```python from fastmcp import FastMCP diff --git a/src/fastmcp/server/transforms/prompts_as_tools.py b/src/fastmcp/server/transforms/prompts_as_tools.py index 83ef30815..783342f3d 100644 --- a/src/fastmcp/server/transforms/prompts_as_tools.py +++ b/src/fastmcp/server/transforms/prompts_as_tools.py @@ -3,6 +3,10 @@ This transform generates tools for listing and getting prompts, enabling clients that only support tools to access prompt functionality. +The generated tools route through `ctx.fastmcp` at runtime, so all server +middleware (auth, visibility, rate limiting, etc.) applies to prompt +operations exactly as it would for direct `prompts/get` calls. + Example: ```python from fastmcp import FastMCP @@ -22,6 +26,7 @@ from typing import TYPE_CHECKING, Annotated, Any from mcp.types import TextContent +from fastmcp.server.dependencies import get_context from fastmcp.server.transforms import GetToolNext, Transform from fastmcp.tools.tool import Tool from fastmcp.utilities.versions import VersionSpec @@ -29,19 +34,20 @@ from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.server.providers.base import Provider -# Note: FastMCP imported inside tools to avoid circular import - class PromptsAsTools(Transform): """Transform that adds tools for listing and getting prompts. Generates two tools: - - `list_prompts`: Lists all prompts from the provider + - `list_prompts`: Lists all prompts - `get_prompt`: Gets a specific prompt with optional arguments - The transform captures a provider reference at construction and queries it - for prompts when the generated tools are called. When used with FastMCP, - the provider's auth and visibility filtering is automatically applied. + The generated tools route through the server at runtime, so auth, + middleware, and visibility apply automatically. + + This transform should be applied to a FastMCP server instance, not + a raw Provider, because the generated tools need the server's + middleware chain for auth and visibility filtering. Example: ```python @@ -52,12 +58,15 @@ class PromptsAsTools(Transform): """ def __init__(self, provider: Provider) -> None: - """Initialize the transform with a provider reference. + from fastmcp.server.server import FastMCP - Args: - provider: The provider to query for prompts. Typically this is - the same FastMCP server the transform is added to. - """ + if not isinstance(provider, FastMCP): + raise TypeError( + "PromptsAsTools requires a FastMCP server instance, not a" + f" {type(provider).__name__}. The generated tools route through" + " the server's middleware chain at runtime for auth and" + " visibility. Pass your FastMCP server: PromptsAsTools(mcp)" + ) self._provider = provider def __repr__(self) -> str: @@ -75,18 +84,14 @@ class PromptsAsTools(Transform): self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, including generated prompt tools.""" - # Check if it's one of our generated tools if name == "list_prompts": return self._make_list_prompts_tool() if name == "get_prompt": return self._make_get_prompt_tool() - - # Otherwise delegate to downstream return await call_next(name, version=version) def _make_list_prompts_tool(self) -> Tool: """Create the list_prompts tool.""" - provider = self._provider async def list_prompts() -> str: """List all available prompts. @@ -94,7 +99,8 @@ class PromptsAsTools(Transform): Returns JSON with prompt metadata including name, description, and optional arguments. """ - prompts = await provider.list_prompts() + ctx = get_context() + prompts = await ctx.fastmcp.list_prompts() result: list[dict[str, Any]] = [] for p in prompts: @@ -119,7 +125,6 @@ class PromptsAsTools(Transform): def _make_get_prompt_tool(self) -> Tool: """Create the get_prompt tool.""" - provider = self._provider async def get_prompt( name: Annotated[str, "The name of the prompt to get"], @@ -131,21 +136,11 @@ class PromptsAsTools(Transform): """Get a prompt by name with optional arguments. Returns the rendered prompt as JSON with a messages array. - Arguments should be provided as a dict mapping argument names to values. + Arguments should be provided as a dict mapping argument names + to values. """ - from fastmcp.server.server import FastMCP - - # Use FastMCP.render_prompt() if available - runs middleware chain - if isinstance(provider, FastMCP): - result = await provider.render_prompt(name, arguments=arguments or {}) - return _format_prompt_result(result) - - # Fallback for plain providers - no middleware - prompt = await provider.get_prompt(name) - if prompt is None: - raise ValueError(f"Prompt not found: {name}") - - result = await prompt._render(arguments or {}) + ctx = get_context() + result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {}) return _format_prompt_result(result) return Tool.from_function(fn=get_prompt) @@ -162,7 +157,6 @@ def _format_prompt_result(result: Any) -> str: if isinstance(msg.content, TextContent): content = msg.content.text else: - # Preserve structured content (e.g., EmbeddedResource) as dict content = msg.content.model_dump(mode="json", exclude_none=True) messages.append( diff --git a/src/fastmcp/server/transforms/resources_as_tools.py b/src/fastmcp/server/transforms/resources_as_tools.py index 007b9bda6..850d996e5 100644 --- a/src/fastmcp/server/transforms/resources_as_tools.py +++ b/src/fastmcp/server/transforms/resources_as_tools.py @@ -3,6 +3,10 @@ This transform generates tools for listing and reading resources, enabling clients that only support tools to access resource functionality. +The generated tools route through `ctx.fastmcp` at runtime, so all server +middleware (auth, visibility, rate limiting, etc.) applies to resource +operations exactly as it would for direct `resources/read` calls. + Example: ```python from fastmcp import FastMCP @@ -23,6 +27,7 @@ from typing import TYPE_CHECKING, Annotated, Any from mcp.types import ToolAnnotations +from fastmcp.server.dependencies import get_context from fastmcp.server.transforms import GetToolNext, Transform from fastmcp.tools.tool import Tool from fastmcp.utilities.versions import VersionSpec @@ -37,12 +42,15 @@ class ResourcesAsTools(Transform): """Transform that adds tools for listing and reading resources. Generates two tools: - - `list_resources`: Lists all resources and templates from the provider + - `list_resources`: Lists all resources and templates - `read_resource`: Reads a resource by URI - The transform captures a provider reference at construction and queries it - for resources when the generated tools are called. When used with FastMCP, - the provider's auth and visibility filtering is automatically applied. + The generated tools route through the server at runtime, so auth, + middleware, and visibility apply automatically. + + This transform should be applied to a FastMCP server instance, not + a raw Provider, because the generated tools need the server's + middleware chain for auth and visibility filtering. Example: ```python @@ -53,12 +61,15 @@ class ResourcesAsTools(Transform): """ def __init__(self, provider: Provider) -> None: - """Initialize the transform with a provider reference. + from fastmcp.server.server import FastMCP - Args: - provider: The provider to query for resources. Typically this is - the same FastMCP server the transform is added to. - """ + if not isinstance(provider, FastMCP): + raise TypeError( + "ResourcesAsTools requires a FastMCP server instance, not a" + f" {type(provider).__name__}. The generated tools route through" + " the server's middleware chain at runtime for auth and" + " visibility. Pass your FastMCP server: ResourcesAsTools(mcp)" + ) self._provider = provider def __repr__(self) -> str: @@ -76,31 +87,28 @@ class ResourcesAsTools(Transform): self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, including generated resource tools.""" - # Check if it's one of our generated tools if name == "list_resources": return self._make_list_resources_tool() if name == "read_resource": return self._make_read_resource_tool() - - # Otherwise delegate to downstream return await call_next(name, version=version) def _make_list_resources_tool(self) -> Tool: """Create the list_resources tool.""" - provider = self._provider async def list_resources() -> str: """List all available resources and resource templates. - Returns JSON with resource metadata. Static resources have a 'uri' field, - while templates have a 'uri_template' field with placeholders like {name}. + Returns JSON with resource metadata. Static resources have a + 'uri' field, while templates have a 'uri_template' field with + placeholders like {name}. """ - resources = await provider.list_resources() - templates = await provider.list_resource_templates() + ctx = get_context() + resources = await ctx.fastmcp.list_resources() + templates = await ctx.fastmcp.list_resource_templates() result: list[dict[str, Any]] = [] - # Static resources for r in resources: result.append( { @@ -111,7 +119,6 @@ class ResourcesAsTools(Transform): } ) - # Resource templates (URI contains placeholders like {name}) for t in templates: result.append( { @@ -127,40 +134,21 @@ class ResourcesAsTools(Transform): def _make_read_resource_tool(self) -> Tool: """Create the read_resource tool.""" - provider = self._provider async def read_resource( uri: Annotated[str, "The URI of the resource to read"], ) -> str: """Read a resource by its URI. - For static resources, provide the exact URI. For templated resources, - provide the URI with template parameters filled in. + For static resources, provide the exact URI. For templated + resources, provide the URI with template parameters filled in. Returns the resource content as a string. Binary content is base64-encoded. """ - from fastmcp import FastMCP - - # Use FastMCP.read_resource() if available - runs middleware chain - if isinstance(provider, FastMCP): - result = await provider.read_resource(uri) - return _format_result(result) - - # Fallback for plain providers - no middleware - resource = await provider.get_resource(uri) - if resource is not None: - result = await resource._read() - return _format_result(result) - - template = await provider.get_resource_template(uri) - if template is not None: - params = template.matches(uri) - if params is not None: - result = await template._read(uri, params) - return _format_result(result) - - raise ValueError(f"Resource not found: {uri}") + ctx = get_context() + result = await ctx.fastmcp.read_resource(uri) + return _format_result(result) return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS) @@ -168,17 +156,15 @@ class ResourcesAsTools(Transform): def _format_result(result: Any) -> str: """Format ResourceResult for tool output. - Single text content is returned as-is. Single binary content is base64-encoded. - Multiple contents are JSON-encoded with each item containing content and mime_type. + Single text content is returned as-is. Single binary content is + base64-encoded. Multiple contents are JSON-encoded. """ - # result is a ResourceResult with .contents list if len(result.contents) == 1: content = result.contents[0].content if isinstance(content, bytes): return base64.b64encode(content).decode() return content - # Multiple contents - JSON encode return json.dumps( [ { diff --git a/tests/server/transforms/test_resources_as_tools.py b/tests/server/transforms/test_resources_as_tools.py index fd10d2d88..278d72c25 100644 --- a/tests/server/transforms/test_resources_as_tools.py +++ b/tests/server/transforms/test_resources_as_tools.py @@ -7,6 +7,8 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client +from fastmcp.exceptions import ToolError +from fastmcp.server.auth import AuthContext from fastmcp.server.transforms import ResourcesAsTools @@ -251,3 +253,106 @@ class TestResourcesAsToolsAnnotations: tool = next(t for t in tools if t.name == "read_resource") assert tool.annotations is not None assert tool.annotations.readOnlyHint is True + + +def _deny_all(ctx: AuthContext) -> bool: + """Auth check that always denies access.""" + return False + + +class TestResourcesAsToolsAuthOnServer: + """Auth checks work when using ResourcesAsTools on a FastMCP server.""" + + async def test_auth_protected_resources_hidden_from_list(self): + """Auth-protected resources are filtered from list_resources tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://open") + def open_resource() -> str: + return "open content" + + @mcp.resource("test://protected", auth=_deny_all) + def protected_resource() -> str: + return "protected content" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + items = json.loads(result.data) + uris = [r.get("uri") for r in items if r.get("uri")] + assert "test://open" in uris + assert "test://protected" not in uris + + async def test_auth_protected_resource_cannot_be_read(self): + """Auth-protected resources cannot be read via read_resource tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://protected", auth=_deny_all) + def protected_resource() -> str: + return "protected content" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("read_resource", {"uri": "test://protected"}) + + async def test_open_resource_still_accessible(self): + """Non-auth-protected resources can still be read.""" + mcp = FastMCP("Test") + + @mcp.resource("test://open") + def open_resource() -> str: + return "open content" + + @mcp.resource("test://protected", auth=_deny_all) + def protected_resource() -> str: + return "protected content" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("read_resource", {"uri": "test://open"}) + assert result.data == "open content" + + +class TestResourcesAsToolsVisibilityOnServer: + """Visibility filtering works when using ResourcesAsTools on a server.""" + + async def test_disabled_resources_hidden_from_list(self): + """Disabled resources are not listed via list_resources tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://public") + def public_resource() -> str: + return "public content" + + @mcp.resource("test://secret") + def secret_resource() -> str: + return "secret content" + + mcp.disable(names={"test://secret"}) + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + items = json.loads(result.data) + uris = [r.get("uri") for r in items if r.get("uri")] + assert "test://public" in uris + assert "test://secret" not in uris + + async def test_disabled_resource_cannot_be_read(self): + """Disabled resources cannot be read via read_resource tool.""" + mcp = FastMCP("Test") + + @mcp.resource("test://secret") + def secret_resource() -> str: + return "secret content" + + mcp.disable(names={"test://secret"}) + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("read_resource", {"uri": "test://secret"})