mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add ResourcesAsTools transform (#2943)
This commit is contained in:
parent
a036ad31e2
commit
d3327269d7
7 changed files with 641 additions and 0 deletions
|
|
@ -109,6 +109,7 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"servers/providers/overview",
|
"servers/providers/overview",
|
||||||
"servers/providers/transforms",
|
"servers/providers/transforms",
|
||||||
|
"servers/providers/resources-as-tools",
|
||||||
"servers/providers/local",
|
"servers/providers/local",
|
||||||
"servers/providers/filesystem",
|
"servers/providers/filesystem",
|
||||||
"servers/providers/mounting",
|
"servers/providers/mounting",
|
||||||
|
|
|
||||||
104
docs/servers/providers/resources-as-tools.mdx
Normal file
104
docs/servers/providers/resources-as-tools.mdx
Normal 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'
|
||||||
|
```
|
||||||
|
|
||||||
52
examples/resources_as_tools/client.py
Normal file
52
examples/resources_as_tools/client.py
Normal 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())
|
||||||
65
examples/resources_as_tools/server.py
Normal file
65
examples/resources_as_tools/server.py
Normal 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()
|
||||||
|
|
@ -222,6 +222,7 @@ class Transform:
|
||||||
# Re-export built-in transforms (must be after Transform class to avoid circular imports)
|
# 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.enabled import Enabled, is_enabled # noqa: E402
|
||||||
from fastmcp.server.transforms.namespace import Namespace # 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.tool_transform import ToolTransform # noqa: E402
|
||||||
from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402
|
from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402
|
||||||
|
|
||||||
|
|
@ -232,6 +233,7 @@ __all__ = [
|
||||||
"GetResourceTemplateNext",
|
"GetResourceTemplateNext",
|
||||||
"GetToolNext",
|
"GetToolNext",
|
||||||
"Namespace",
|
"Namespace",
|
||||||
|
"ResourcesAsTools",
|
||||||
"ToolTransform",
|
"ToolTransform",
|
||||||
"Transform",
|
"Transform",
|
||||||
"VersionFilter",
|
"VersionFilter",
|
||||||
|
|
|
||||||
190
src/fastmcp/server/transforms/resources_as_tools.py
Normal file
190
src/fastmcp/server/transforms/resources_as_tools.py
Normal 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
|
||||||
|
]
|
||||||
|
)
|
||||||
227
tests/server/transforms/test_resources_as_tools.py
Normal file
227
tests/server/transforms/test_resources_as_tools.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue