Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
- Follow existing patterns and maintain consistency
- **Prioritize readable, understandable code** - clarity over cleverness
- Avoid obfuscated or confusing patterns even if they're shorter
- Use `# type: ignore[attr-defined]` in tests for MCP results instead of type assertions
- Each feature needs corresponding tests

### Module Exports
Expand Down Expand Up @@ -264,7 +263,6 @@ uv sync # Installs all deps including dev tools
### Error Handling

- Never use bare `except` - be specific with exception types
- Use `# type: ignore[attr-defined]` in tests for MCP results

### Build Issues (Common Solutions)

Expand Down
84 changes: 81 additions & 3 deletions src/fastmcp/server/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ async def get_tool(self, name: str) -> Tool | None:
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any

from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
from fastmcp.tools.tool import Tool, ToolResult


@dataclass
Expand Down Expand Up @@ -228,6 +229,83 @@ async def get_prompt(self, name: str) -> Prompt | None:
prompts = await self.list_prompts()
return next((p for p in prompts if p.name == name), None)

# -------------------------------------------------------------------------
# Execution methods (optional - default implementations delegate to components)
# -------------------------------------------------------------------------

async def call_tool(
self, name: str, arguments: dict[str, Any]
) -> ToolResult | None:
"""Call a tool by name.

Default implementation gets the tool and calls its run() method.
Override for custom execution logic.

Returns:
ToolResult if the tool was found and executed, None otherwise.
"""
tool = await self.get_tool(name)
if tool is None:
return None
return await tool.run(arguments)

async def read_resource(self, uri: str) -> ResourceContent | None:
"""Read a resource by URI.

Default implementation gets the resource and calls its read() method.
Override for custom execution logic.

Returns:
ResourceContent if the resource was found and read, None otherwise.
"""
resource = await self.get_resource(uri)
if resource is None:
return None
result = await resource.read()
if isinstance(result, ResourceContent):
return result
return ResourceContent.from_value(result)

async def read_resource_template(self, uri: str) -> ResourceContent | None:
"""Read a resource template by URI.

Default implementation gets the template, extracts parameters from the URI,
and calls its read() method with those parameters.
Override for custom execution logic.

Returns:
ResourceContent if the template was found and read, None otherwise.
"""
template = await self.get_resource_template(uri)
if template is None:
return None
params = template.matches(uri)
if params is None:
return None
result = await template.read(params)
if isinstance(result, ResourceContent):
return result
return ResourceContent.from_value(result)

async def render_prompt(
self, name: str, arguments: dict[str, Any] | None
) -> PromptResult | None:
"""Render a prompt by name.

Default implementation gets the prompt and calls its render() method.
Override for custom execution logic.

Returns:
PromptResult if the prompt was found and rendered, None otherwise.
"""
prompt = await self.get_prompt(name)
if prompt is None:
return None
result = await prompt.render(arguments)
if isinstance(result, PromptResult):
return result
return PromptResult.from_value(result)

# -------------------------------------------------------------------------
# Task registration
# -------------------------------------------------------------------------
Expand Down
42 changes: 5 additions & 37 deletions src/fastmcp/server/providers/transforming.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
import re
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING

from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider, TaskComponents
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool import Tool

if TYPE_CHECKING:
from fastmcp.prompts.prompt import FunctionPrompt
Expand Down Expand Up @@ -74,7 +74,7 @@ def __init__(
use the specified name instead of namespace prefixing.
"""
super().__init__()
self._wrapped = provider
self._wrapped: Provider = provider
self.namespace = namespace
self.tool_renames = tool_renames or {}

Expand Down Expand Up @@ -186,15 +186,6 @@ async def get_tool(self, name: str) -> Tool | None:
return tool.model_copy(update={"name": name})
return None

async def call_tool(
self, name: str, arguments: dict[str, Any]
) -> ToolResult | None:
"""Call tool by transformed name."""
original = self._reverse_tool_name(name)
if original is None:
return None
return await self._wrapped.call_tool(original, arguments)

# -------------------------------------------------------------------------
# Resource methods
# -------------------------------------------------------------------------
Expand All @@ -217,13 +208,6 @@ async def get_resource(self, uri: str) -> Resource | None:
return resource.model_copy(update={"uri": uri})
return None

async def read_resource(self, uri: str) -> ResourceContent | None:
"""Read resource by transformed URI."""
original = self._reverse_resource_uri(uri)
if original is None:
return None
return await self._wrapped.read_resource(original)

# -------------------------------------------------------------------------
# Resource template methods
# -------------------------------------------------------------------------
Expand Down Expand Up @@ -252,13 +236,6 @@ async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
)
return None

async def read_resource_template(self, uri: str) -> ResourceContent | None:
"""Read resource template by transformed URI."""
original = self._reverse_resource_uri(uri)
if original is None:
return None
return await self._wrapped.read_resource_template(original)

# -------------------------------------------------------------------------
# Prompt methods
# -------------------------------------------------------------------------
Expand All @@ -281,15 +258,6 @@ async def get_prompt(self, name: str) -> Prompt | None:
return prompt.model_copy(update={"name": name})
return None

async def render_prompt(
self, name: str, arguments: dict[str, Any] | None
) -> PromptResult | None:
"""Render prompt by transformed name."""
original = self._reverse_prompt_name(name)
if original is None:
return None
return await self._wrapped.render_prompt(original, arguments)

# -------------------------------------------------------------------------
# Task registration
# -------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ def test_version_command_execution(self):
def test_version_command_parsing(self):
"""Test that the version command parses arguments correctly."""
command, bound, _ = app.parse_args(["version"])
assert callable(command)
assert command.__name__ == "version" # type: ignore[attr-defined]
# Default arguments aren't included in bound.arguments
assert bound.arguments == {}

def test_version_command_with_copy_flag(self):
"""Test that the version command parses --copy flag correctly."""
command, bound, _ = app.parse_args(["version", "--copy"])
assert callable(command)
assert command.__name__ == "version" # type: ignore[attr-defined]
assert bound.arguments == {"copy": True}

Expand Down
1 change: 1 addition & 0 deletions tests/cli/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class TestWorkerCommand:
def test_worker_command_parsing(self):
"""Test that worker command parses arguments correctly."""
command, bound, _ = tasks_app.parse_args(["worker", "server.py"])
assert callable(command)
assert command.__name__ == "worker" # type: ignore[attr-defined]
assert bound.arguments["server_spec"] == "server.py"

Expand Down
4 changes: 3 additions & 1 deletion tests/client/auth/test_oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import httpx
import pytest
from mcp.types import TextResourceContents

from fastmcp.client import Client
from fastmcp.client.auth import OAuth
Expand Down Expand Up @@ -103,7 +104,8 @@ async def test_read_resource(client_with_headless_oauth: Client):
"""Test that we can read a resource."""
async with client_with_headless_oauth:
resource = await client_with_headless_oauth.read_resource("resource://test")
assert resource[0].text == "Hello from authenticated resource!" # type: ignore[attr-defined]
assert isinstance(resource[0], TextResourceContents)
assert resource[0].text == "Hello from authenticated resource!"


async def test_oauth_server_metadata_discovery(streamable_http_server: str):
Expand Down
10 changes: 7 additions & 3 deletions tests/client/tasks/test_prompt_task_mcp_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import mcp.types
from mcp.types import TextContent

from fastmcp import FastMCP
from fastmcp.client import Client
Expand All @@ -25,7 +26,8 @@ async def greeting(name: str) -> list[mcp.types.PromptMessage]:
async with Client(mcp_server) as client:
task = await client.get_prompt("greeting", {"name": "World"}, task=True)
result = await task.result()
assert "Hello World" in result.messages[0].content.text # type: ignore[attr-defined]
assert isinstance(result.messages[0].content, TextContent)
assert "Hello World" in result.messages[0].content.text


async def test_prompt_task_with_multiple_mcp_prompt_messages():
Expand Down Expand Up @@ -53,5 +55,7 @@ async def conversation(topic: str) -> list[mcp.types.PromptMessage]:
task = await client.get_prompt("conversation", {"topic": "space"}, task=True)
result = await task.result()
assert len(result.messages) == 2
assert "Tell me about space" in result.messages[0].content.text # type: ignore[attr-defined]
assert "space is fascinating" in result.messages[1].content.text # type: ignore[attr-defined]
assert isinstance(result.messages[0].content, TextContent)
assert "Tell me about space" in result.messages[0].content.text
assert isinstance(result.messages[1].content, TextContent)
assert "space is fascinating" in result.messages[1].content.text
34 changes: 22 additions & 12 deletions tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest
from mcp import ClientSession, McpError
from mcp.client.auth import OAuthClientProvider
from mcp.types import TextContent
from pydantic import AnyUrl

import fastmcp
Expand Down Expand Up @@ -124,7 +125,8 @@ async def test_call_tool(fastmcp_server):
async with client:
result = await client.call_tool("greet", {"name": "World"})

assert result.content[0].text == "Hello, World!" # type: ignore[attr-defined]
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello, World!"
assert result.structured_content == {"result": "Hello, World!"}
assert result.data == "Hello, World!"
assert result.is_error is False
Expand Down Expand Up @@ -249,7 +251,8 @@ async def test_get_prompt(fastmcp_server):
result = await client.get_prompt("welcome", {"name": "Developer"})

# The result should contain our welcome message
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
assert result.description == "Example greeting prompt."


Expand All @@ -261,7 +264,8 @@ async def test_get_prompt_mcp(fastmcp_server):
result = await client.get_prompt_mcp("welcome", {"name": "Developer"})

# The result should contain our welcome message
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined]
assert isinstance(result.messages[0].content, TextContent)
assert result.messages[0].content.text == "Welcome to FastMCP, Developer!"
assert result.description == "Example greeting prompt."


Expand All @@ -286,7 +290,8 @@ def echo_args(arg1: str, arg2: str, arg3: str) -> str:
},
)

content = result.messages[0].content.text # type: ignore[attr-defined]
assert isinstance(result.messages[0].content, TextContent)
content = result.messages[0].content.text
assert "arg1: hello" in content
assert "arg2: [1,2,3]" in content # JSON serialized list
assert 'arg3: {"key":"value"}' in content # JSON serialized dict
Expand All @@ -309,7 +314,8 @@ def typed_prompt(numbers: list[int], config: dict[str, str]) -> str:
{"numbers": [1, 2, 3, 4], "config": {"theme": "dark", "lang": "en"}},
)

content = result.messages[0].content.text # type: ignore[attr-defined]
assert isinstance(result.messages[0].content, TextContent)
content = result.messages[0].content.text
assert "Got 4 numbers and 2 config items" in content


Expand Down Expand Up @@ -800,8 +806,9 @@ def error_tool():
async with client:
result = await client.call_tool_mcp("error_tool", {})
assert result.isError
assert "test error" in result.content[0].text # type: ignore[attr-defined]
assert "abc" in result.content[0].text # type: ignore[attr-defined]
assert isinstance(result.content[0], TextContent)
assert "test error" in result.content[0].text
assert "abc" in result.content[0].text

async def test_general_tool_exceptions_are_masked_when_enabled(self):
mcp = FastMCP("TestServer", mask_error_details=True)
Expand All @@ -815,8 +822,9 @@ def error_tool():
async with client:
result = await client.call_tool_mcp("error_tool", {})
assert result.isError
assert "test error" not in result.content[0].text # type: ignore[attr-defined]
assert "abc" not in result.content[0].text # type: ignore[attr-defined]
assert isinstance(result.content[0], TextContent)
assert "test error" not in result.content[0].text
assert "abc" not in result.content[0].text

async def test_validation_errors_are_not_masked_when_enabled(self):
mcp = FastMCP("TestServer", mask_error_details=True)
Expand All @@ -829,7 +837,8 @@ def validated_tool(x: int) -> int:
result = await client.call_tool_mcp("validated_tool", {"x": "abc"})
assert result.isError
# Pydantic validation error message should NOT be masked
assert "Input should be a valid integer" in result.content[0].text # type: ignore[attr-defined]
assert isinstance(result.content[0], TextContent)
assert "Input should be a valid integer" in result.content[0].text

async def test_specific_tool_errors_are_sent_to_client(self):
mcp = FastMCP("TestServer")
Expand All @@ -843,8 +852,9 @@ def custom_error_tool():
async with client:
result = await client.call_tool_mcp("custom_error_tool", {})
assert result.isError
assert "test error" in result.content[0].text # type: ignore[attr-defined]
assert "abc" in result.content[0].text # type: ignore[attr-defined]
assert isinstance(result.content[0], TextContent)
assert "test error" in result.content[0].text
assert "abc" in result.content[0].text

async def test_general_resource_exceptions_are_not_masked_by_default(self):
mcp = FastMCP("TestServer")
Expand Down
Loading