Add ResourcesAsTools transform (#2943)

This commit is contained in:
Jeremiah Lowin 2026-01-19 17:40:31 -05:00 committed by GitHub
commit d3327269d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 641 additions and 0 deletions

View file

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

View file

@ -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'
<VersionBadge version="3.0.0" />
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'
```

View file

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

View file

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

View file

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

View file

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

View file

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