diff --git a/docs/docs.json b/docs/docs.json index 00c7d7f95..51ab4a5c7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -109,6 +109,7 @@ "pages": [ "servers/providers/overview", "servers/providers/transforms", + "servers/providers/resources-as-tools", "servers/providers/local", "servers/providers/filesystem", "servers/providers/mounting", diff --git a/docs/servers/providers/resources-as-tools.mdx b/docs/servers/providers/resources-as-tools.mdx new file mode 100644 index 000000000..3d32ba5fd --- /dev/null +++ b/docs/servers/providers/resources-as-tools.mdx @@ -0,0 +1,104 @@ +--- +title: Resources as Tools +sidebarTitle: Resources as Tools +description: Expose resources to tool-only clients +icon: toolbox +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + + + +Some MCP clients only support tools. They cannot list or read resources directly because they lack resource protocol support. The `ResourcesAsTools` transform bridges this gap by generating tools that provide access to your server's resources. + +When you add `ResourcesAsTools` to a server, it creates two tools that clients can call instead of using the resource protocol: + +- **`list_resources`** returns JSON describing all available resources and templates +- **`read_resource`** reads a specific resource by URI + +This means any client that can call tools can now access resources, even if the client has no native resource support. + +## Basic Usage + +Pass your server to `ResourcesAsTools` when adding the transform. The transform queries that server for resources whenever the generated tools are called. + +```python +from fastmcp import FastMCP +from fastmcp.server.transforms import ResourcesAsTools + +mcp = FastMCP("My Server") + +@mcp.resource("config://app") +def app_config() -> str: + """Application configuration.""" + return '{"app_name": "My App", "version": "1.0.0"}' + +@mcp.resource("user://{user_id}/profile") +def user_profile(user_id: str) -> str: + """Get a user's profile by ID.""" + return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' + +# Add the transform - creates list_resources and read_resource tools +mcp.add_transform(ResourcesAsTools(mcp)) +``` + +Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`. + +## Static Resources vs Templates + +Resources come in two forms, and the `list_resources` tool distinguishes between them in its JSON output. + +Static resources have fixed URIs. They represent concrete data that exists at a known location. In the listing output, static resources include a `uri` field containing the exact URI to request. + +Resource templates have parameterized URIs with placeholders like `{user_id}`. They represent patterns for accessing dynamic data. In the listing output, templates include a `uri_template` field showing the pattern with its placeholders. + +When a client calls `list_resources`, it receives JSON like this: + +```json +[ + { + "uri": "config://app", + "name": "app_config", + "description": "Application configuration.", + "mime_type": "text/plain" + }, + { + "uri_template": "user://{user_id}/profile", + "name": "user_profile", + "description": "Get a user's profile by ID." + } +] +``` + +The client can distinguish resource types by checking which field is present: `uri` for static resources, `uri_template` for templates. + +## Reading Resources + +The `read_resource` tool accepts a single `uri` argument. For static resources, pass the exact URI. For templates, fill in the placeholders with actual values. + +```python +# Reading a static resource +result = await client.call_tool("read_resource", {"uri": "config://app"}) +print(result.data) # '{"app_name": "My App", "version": "1.0.0"}' + +# Reading a templated resource - fill in {user_id} with an actual ID +result = await client.call_tool("read_resource", {"uri": "user://42/profile"}) +print(result.data) # '{"user_id": "42", "name": "User 42"}' +``` + +The transform handles template matching automatically. When you request `user://42/profile`, it matches against the `user://{user_id}/profile` template, extracts `user_id=42`, and calls your resource function with that parameter. + +## Binary Content + +Resources that return binary data (like images or files) are automatically base64-encoded when read through the `read_resource` tool. This ensures binary content can be transmitted as a string in the tool response. + +```python +@mcp.resource("data://binary", mime_type="application/octet-stream") +def binary_data() -> bytes: + return b"\x00\x01\x02\x03" + +# Client receives base64-encoded string +result = await client.call_tool("read_resource", {"uri": "data://binary"}) +decoded = base64.b64decode(result.data) # b'\x00\x01\x02\x03' +``` + diff --git a/examples/resources_as_tools/client.py b/examples/resources_as_tools/client.py new file mode 100644 index 000000000..8a23b12a6 --- /dev/null +++ b/examples/resources_as_tools/client.py @@ -0,0 +1,52 @@ +"""Example: Client using resources-as-tools. + +This client demonstrates calling the list_resources and read_resource tools +generated by the ResourcesAsTools transform. + +Run with: + uv run python examples/resources_as_tools/client.py +""" + +import asyncio +import json + +from fastmcp.client import Client + + +async def main(): + # Connect to the server + async with Client("examples/resources_as_tools/server.py") as client: + # List all available tools + print("=== Available Tools ===") + tools = await client.list_tools() + for tool in tools: + print(f" - {tool.name}: {tool.description}") + print() + + # Use list_resources tool to see what's available + print("=== Listing Resources ===") + result = await client.call_tool("list_resources", {}) + resources = json.loads(result.data) + for resource in resources: + if "uri" in resource: + print(f" Static: {resource['uri']}") + else: + print(f" Template: {resource['uri_template']}") + print(f" Name: {resource['name']}") + print(f" Description: {resource.get('description', 'N/A')}") + print() + + # Read a static resource + print("=== Reading Static Resource ===") + result = await client.call_tool("read_resource", {"uri": "config://app"}) + print(f"config://app content:\n{result.data}") + print() + + # Read a templated resource + print("=== Reading Templated Resource ===") + result = await client.call_tool("read_resource", {"uri": "user://42/profile"}) + print(f"user://42/profile content:\n{result.data}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/resources_as_tools/server.py b/examples/resources_as_tools/server.py new file mode 100644 index 000000000..25141591e --- /dev/null +++ b/examples/resources_as_tools/server.py @@ -0,0 +1,65 @@ +"""Example: Expose resources as tools using ResourcesAsTools transform. + +This example shows how to use ResourcesAsTools to make resources accessible +to clients that only support tools (not the resources protocol). + +Run with: + uv run python examples/resources_as_tools/server.py +""" + +from fastmcp import FastMCP +from fastmcp.server.transforms import ResourcesAsTools + +mcp = FastMCP("Resource Tools Demo") + + +# Static resource - has a fixed URI +@mcp.resource("config://app") +def app_config() -> str: + """Application configuration.""" + return """ + { + "app_name": "My App", + "version": "1.0.0", + "debug": false + } + """ + + +# Another static resource +@mcp.resource("readme://main") +def readme() -> str: + """Project README.""" + return """ + # My Project + + This is an example project demonstrating ResourcesAsTools. + """ + + +# Resource template - URI has placeholders +@mcp.resource("user://{user_id}/profile") +def user_profile(user_id: str) -> str: + """Get a user's profile by ID.""" + return f""" + {{ + "user_id": "{user_id}", + "name": "User {user_id}", + "email": "user{user_id}@example.com" + }} + """ + + +# Another template with multiple parameters +@mcp.resource("file://{directory}/{filename}") +def read_file(directory: str, filename: str) -> str: + """Read a file from a directory.""" + return f"Contents of {directory}/{filename}" + + +# Add the transform - this creates list_resources and read_resource tools +mcp.add_transform(ResourcesAsTools(mcp)) + + +if __name__ == "__main__": + mcp.run() diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py index 8c9a8a407..b68b2f4b6 100644 --- a/src/fastmcp/server/transforms/__init__.py +++ b/src/fastmcp/server/transforms/__init__.py @@ -222,6 +222,7 @@ class Transform: # Re-export built-in transforms (must be after Transform class to avoid circular imports) from fastmcp.server.transforms.enabled import Enabled, is_enabled # noqa: E402 from fastmcp.server.transforms.namespace import Namespace # noqa: E402 +from fastmcp.server.transforms.resources_as_tools import ResourcesAsTools # noqa: E402 from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 @@ -232,6 +233,7 @@ __all__ = [ "GetResourceTemplateNext", "GetToolNext", "Namespace", + "ResourcesAsTools", "ToolTransform", "Transform", "VersionFilter", diff --git a/src/fastmcp/server/transforms/resources_as_tools.py b/src/fastmcp/server/transforms/resources_as_tools.py new file mode 100644 index 000000000..00fc43822 --- /dev/null +++ b/src/fastmcp/server/transforms/resources_as_tools.py @@ -0,0 +1,190 @@ +"""Transform that exposes resources as tools. + +This transform generates tools for listing and reading resources, enabling +clients that only support tools to access resource functionality. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.transforms import ResourcesAsTools + + mcp = FastMCP("Server") + mcp.add_transform(ResourcesAsTools(mcp)) + # Now has list_resources and read_resource tools + ``` +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import Sequence +from typing import TYPE_CHECKING, Annotated, Any + +from fastmcp.server.transforms import GetToolNext, Transform +from fastmcp.tools.tool import Tool +from fastmcp.utilities.versions import VersionSpec + +if TYPE_CHECKING: + from fastmcp.server.providers.base import Provider + + +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 + - `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. + + Example: + ```python + mcp = FastMCP("Server") + mcp.add_transform(ResourcesAsTools(mcp)) + # Now has list_resources and read_resource tools + ``` + """ + + def __init__(self, provider: Provider) -> None: + """Initialize the transform with a provider reference. + + Args: + provider: The provider to query for resources. Typically this is + the same FastMCP server the transform is added to. + """ + self._provider = provider + + def __repr__(self) -> str: + return f"ResourcesAsTools({self._provider!r})" + + async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + """Add resource tools to the tool list.""" + return [ + *tools, + self._make_list_resources_tool(), + self._make_read_resource_tool(), + ] + + async def get_tool( + 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}. + """ + resources = await provider.list_resources() + templates = await provider.list_resource_templates() + + result: list[dict[str, Any]] = [] + + # Static resources + for r in resources: + result.append( + { + "uri": str(r.uri), + "name": r.name, + "description": r.description, + "mime_type": r.mime_type, + } + ) + + # Resource templates (URI contains placeholders like {name}) + for t in templates: + result.append( + { + "uri_template": t.uri_template, + "name": t.name, + "description": t.description, + } + ) + + return json.dumps(result, indent=2) + + return Tool.from_function(fn=list_resources) + + 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. + + 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}") + + return Tool.from_function(fn=read_resource) + + +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. + """ + # 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( + [ + { + "content": ( + c.content + if isinstance(c.content, str) + else base64.b64encode(c.content).decode() + ), + "mime_type": c.mime_type, + } + for c in result.contents + ] + ) diff --git a/tests/server/transforms/test_resources_as_tools.py b/tests/server/transforms/test_resources_as_tools.py new file mode 100644 index 000000000..49297f98a --- /dev/null +++ b/tests/server/transforms/test_resources_as_tools.py @@ -0,0 +1,227 @@ +"""Tests for ResourcesAsTools transform.""" + +import base64 +import json + +import pytest + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.server.transforms import ResourcesAsTools + + +class TestResourcesAsToolsBasic: + """Test basic ResourcesAsTools functionality.""" + + async def test_adds_list_resources_tool(self): + """Transform adds list_resources tool.""" + mcp = FastMCP("Test") + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "list_resources" in tool_names + + async def test_adds_read_resource_tool(self): + """Transform adds read_resource tool.""" + mcp = FastMCP("Test") + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "read_resource" in tool_names + + async def test_preserves_existing_tools(self): + """Transform preserves existing tools.""" + mcp = FastMCP("Test") + + @mcp.tool + def my_tool() -> str: + return "result" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + tools = await client.list_tools() + tool_names = {t.name for t in tools} + assert "my_tool" in tool_names + assert "list_resources" in tool_names + assert "read_resource" in tool_names + + +class TestListResourcesTool: + """Test the list_resources tool.""" + + async def test_lists_static_resources(self): + """list_resources returns static resources with uri.""" + mcp = FastMCP("Test") + + @mcp.resource("config://app") + def app_config() -> str: + return "config data" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + resources = json.loads(result.data) + + assert len(resources) == 1 + assert resources[0]["uri"] == "config://app" + assert resources[0]["name"] == "app_config" + + async def test_lists_resource_templates(self): + """list_resources returns templates with uri_template.""" + mcp = FastMCP("Test") + + @mcp.resource("file://{path}") + def read_file(path: str) -> str: + return f"content of {path}" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + resources = json.loads(result.data) + + assert len(resources) == 1 + assert resources[0]["uri_template"] == "file://{path}" + assert "uri" not in resources[0] + + async def test_lists_both_resources_and_templates(self): + """list_resources returns both static and templated resources.""" + mcp = FastMCP("Test") + + @mcp.resource("config://app") + def app_config() -> str: + return "config" + + @mcp.resource("file://{path}") + def read_file(path: str) -> str: + return f"content of {path}" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + resources = json.loads(result.data) + + assert len(resources) == 2 + # One has uri, one has uri_template + uris = [r.get("uri") for r in resources if r.get("uri")] + templates = [ + r.get("uri_template") for r in resources if r.get("uri_template") + ] + assert uris == ["config://app"] + assert templates == ["file://{path}"] + + async def test_empty_when_no_resources(self): + """list_resources returns empty list when no resources exist.""" + mcp = FastMCP("Test") + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("list_resources", {}) + assert json.loads(result.data) == [] + + +class TestReadResourceTool: + """Test the read_resource tool.""" + + async def test_reads_static_resource(self): + """read_resource reads a static resource by URI.""" + mcp = FastMCP("Test") + + @mcp.resource("config://app") + def app_config() -> str: + return "my config data" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("read_resource", {"uri": "config://app"}) + assert result.data == "my config data" + + async def test_reads_templated_resource(self): + """read_resource reads a templated resource with parameters.""" + mcp = FastMCP("Test") + + @mcp.resource("user://{user_id}/profile") + def user_profile(user_id: str) -> str: + return f"Profile for user {user_id}" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool( + "read_resource", {"uri": "user://123/profile"} + ) + assert result.data == "Profile for user 123" + + async def test_error_on_unknown_resource(self): + """read_resource raises error for unknown URI.""" + from fastmcp.exceptions import ToolError + + mcp = FastMCP("Test") + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="Unknown resource"): + await client.call_tool("read_resource", {"uri": "unknown://resource"}) + + async def test_reads_binary_as_base64(self): + """read_resource returns binary content as base64.""" + mcp = FastMCP("Test") + + @mcp.resource("data://binary", mime_type="application/octet-stream") + def binary_data() -> bytes: + return b"\x00\x01\x02\x03" + + mcp.add_transform(ResourcesAsTools(mcp)) + + async with Client(mcp) as client: + result = await client.call_tool("read_resource", {"uri": "data://binary"}) + # Should be base64 encoded + decoded = base64.b64decode(result.data) + assert decoded == b"\x00\x01\x02\x03" + + +class TestResourcesAsToolsWithNamespace: + """Test ResourcesAsTools combined with other transforms.""" + + async def test_works_with_namespace_on_provider(self): + """ResourcesAsTools works when provider has Namespace transform.""" + from fastmcp.server.providers import FastMCPProvider + from fastmcp.server.transforms import Namespace + + sub = FastMCP("Sub") + + @sub.resource("config://app") + def app_config() -> str: + return "sub config" + + main = FastMCP("Main") + provider = FastMCPProvider(sub) + provider.add_transform(Namespace("sub")) + main.add_provider(provider) + main.add_transform(ResourcesAsTools(main)) + + async with Client(main) as client: + result = await client.call_tool("list_resources", {}) + resources = json.loads(result.data) + + # Resource should have namespaced URI + assert len(resources) == 1 + assert resources[0]["uri"] == "config://sub/app" + + +class TestResourcesAsToolsRepr: + """Test ResourcesAsTools repr.""" + + def test_repr(self): + """Transform has useful repr.""" + mcp = FastMCP("Test") + transform = ResourcesAsTools(mcp) + assert "ResourcesAsTools" in repr(transform)