Skip to content

Commit a9635c3

Browse files
author
Tapan Chugh
committed
feat: Convert tools and prompts to URI-based storage
- Add uri field to FastMCP Tool and Prompt classes with automatic generation - Change ToolManager and PromptManager to use URIs as primary keys - Implement backward compatibility with automatic name-to-URI conversion - Update server to use stored URIs instead of generating them - Fix tests to handle URI-based warning messages This aligns tools and prompts with the resource storage pattern, where URIs are the primary identifiers. Names are automatically converted to URIs (e.g., 'my_tool' -> 'tool://my_tool') for backward compatibility.
1 parent a9e67fd commit a9635c3

File tree

15 files changed

+143
-327
lines changed

15 files changed

+143
-327
lines changed

src/mcp/server/fastmcp/prompts/base.py

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Base classes for FastMCP prompts."""
22

33
import inspect
4-
import json
54
from collections.abc import Awaitable, Callable, Sequence
65
from typing import Any, Literal
76

87
import pydantic_core
98
from pydantic import BaseModel, Field, TypeAdapter, validate_call
109

11-
from mcp.server.fastmcp.resources import PromptResource
1210
from mcp.types import ContentBlock, TextContent
1311

1412

@@ -56,12 +54,22 @@ class PromptArgument(BaseModel):
5654
required: bool = Field(default=False, description="Whether the argument is required")
5755

5856

59-
class Prompt(PromptResource):
57+
class Prompt(BaseModel):
6058
"""A prompt template that can be rendered with parameters."""
6159

60+
name: str = Field(description="Name of the prompt")
61+
uri: str = Field(description="URI of the prompt")
62+
title: str | None = Field(None, description="Human-readable title of the prompt")
63+
description: str = Field(description="Description of what the prompt does")
6264
arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt")
6365
fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
6466

67+
def __init__(self, **data: Any) -> None:
68+
"""Initialize Prompt, generating URI from name if not provided."""
69+
if "uri" not in data and "name" in data:
70+
data["uri"] = f"prompt://{data['name']}"
71+
super().__init__(**data)
72+
6573
@classmethod
6674
def from_function(
6775
cls,
@@ -110,28 +118,6 @@ def from_function(
110118
fn=fn,
111119
)
112120

113-
async def read(self) -> str | bytes:
114-
"""Read the prompt template/documentation as JSON."""
115-
prompt_info: dict[str, Any] = {
116-
"name": self.name,
117-
"title": self.title,
118-
"description": self.description,
119-
"uri": str(self.uri),
120-
}
121-
122-
# Include arguments if available
123-
if self.arguments:
124-
prompt_info["arguments"] = [
125-
{
126-
"name": arg.name,
127-
"description": arg.description,
128-
"required": arg.required,
129-
}
130-
for arg in self.arguments
131-
]
132-
133-
return json.dumps(prompt_info, indent=2)
134-
135121
async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]:
136122
"""Render the prompt with arguments."""
137123
# Validate required arguments

src/mcp/server/fastmcp/prompts/prompt_manager.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,27 @@ def __init__(self, warn_on_duplicate_prompts: bool = True):
1313
self._prompts: dict[str, Prompt] = {}
1414
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
1515

16+
def _normalize_to_uri(self, name_or_uri: str) -> str:
17+
"""Convert name to URI if needed."""
18+
if name_or_uri.startswith("prompt://"):
19+
return name_or_uri
20+
return f"prompt://{name_or_uri}"
21+
1622
def add_prompt(self, prompt: Prompt) -> Prompt:
1723
"""Add a prompt to the manager."""
18-
logger.debug(f"Adding prompt: {prompt.name}")
19-
existing = self._prompts.get(prompt.name)
24+
logger.debug(f"Adding prompt: {prompt.name} with URI: {prompt.uri}")
25+
existing = self._prompts.get(prompt.uri)
2026
if existing:
2127
if self.warn_on_duplicate_prompts:
22-
logger.warning(f"Prompt already exists: {prompt.name}")
28+
logger.warning(f"Prompt already exists: {prompt.uri}")
2329
return existing
24-
self._prompts[prompt.name] = prompt
30+
self._prompts[prompt.uri] = prompt
2531
return prompt
2632

2733
def get_prompt(self, name: str) -> Prompt | None:
28-
"""Get prompt by name."""
29-
return self._prompts.get(name)
34+
"""Get prompt by name or URI."""
35+
uri = self._normalize_to_uri(name)
36+
return self._prompts.get(uri)
3037

3138
def list_prompts(self) -> list[Prompt]:
3239
"""List all registered prompts."""

src/mcp/server/fastmcp/resources/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from .base import Resource
2-
from .prompt_resource import PromptResource
32
from .resource_manager import ResourceManager
43
from .templates import ResourceTemplate
5-
from .tool_resource import ToolResource
64
from .types import (
75
BinaryResource,
86
DirectoryResource,
@@ -22,6 +20,4 @@
2220
"DirectoryResource",
2321
"ResourceTemplate",
2422
"ResourceManager",
25-
"ToolResource",
26-
"PromptResource",
2723
]

src/mcp/server/fastmcp/resources/prompt_resource.py

Lines changed: 0 additions & 37 deletions
This file was deleted.

src/mcp/server/fastmcp/resources/resource_manager.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -87,26 +87,11 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
8787
raise ValueError(f"Unknown resource: {uri}")
8888

8989
def list_resources(self, prefix: str | None = None) -> list[Resource]:
90-
"""List all registered resources, optionally filtered by URI prefix.
91-
92-
Note: Tool and prompt resources (with tool:// and prompt:// URIs) are excluded
93-
by default to maintain API compatibility.
94-
"""
95-
all_resources = list(self._resources.values())
96-
97-
# Filter out tool and prompt resources to maintain API compatibility
98-
resources = [
99-
r for r in all_resources if not (str(r.uri).startswith("tool://") or str(r.uri).startswith("prompt://"))
100-
]
101-
102-
# Apply prefix filter if provided
90+
"""List all registered resources, optionally filtered by URI prefix."""
91+
resources = list(self._resources.values())
10392
if prefix:
10493
resources = [r for r in resources if str(r.uri).startswith(prefix)]
105-
106-
logger.debug(
107-
"Listing resources",
108-
extra={"total_count": len(all_resources), "filtered_count": len(resources), "prefix": prefix},
109-
)
94+
logger.debug("Listing resources", extra={"count": len(resources), "prefix": prefix})
11095
return resources
11196

11297
def list_templates(self, prefix: str | None = None) -> list[ResourceTemplate]:

src/mcp/server/fastmcp/resources/tool_resource.py

Lines changed: 0 additions & 37 deletions
This file was deleted.

src/mcp/server/fastmcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ async def list_tools(self) -> list[MCPTool]:
275275
name=info.name,
276276
title=info.title,
277277
description=info.description,
278+
uri=info.uri,
278279
inputSchema=info.parameters,
279280
outputSchema=info.output_schema,
280281
annotations=info.annotations,
@@ -973,6 +974,7 @@ async def list_prompts(self) -> list[MCPPrompt]:
973974
name=prompt.name,
974975
title=prompt.title,
975976
description=prompt.description,
977+
uri=prompt.uri,
976978
arguments=[
977979
MCPPromptArgument(
978980
name=arg.name,

src/mcp/server/fastmcp/tools/base.py

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22

33
import functools
44
import inspect
5-
import json
65
from collections.abc import Callable
76
from functools import cached_property
87
from typing import TYPE_CHECKING, Any, get_origin
98

10-
from pydantic import Field
9+
from pydantic import BaseModel, Field
1110

1211
from mcp.server.fastmcp.exceptions import ToolError
13-
from mcp.server.fastmcp.resources import ToolResource
1412
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
1513
from mcp.types import ToolAnnotations
1614

@@ -20,9 +18,13 @@
2018
from mcp.shared.context import LifespanContextT, RequestT
2119

2220

23-
class Tool(ToolResource):
21+
class Tool(BaseModel):
2422
"""Internal tool registration info."""
2523

24+
name: str = Field(description="Name of the tool")
25+
uri: str = Field(description="URI of the tool")
26+
title: str | None = Field(None, description="Human-readable title of the tool")
27+
description: str = Field(description="Description of what the tool does")
2628
fn: Callable[..., Any] = Field(exclude=True)
2729
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
2830
fn_metadata: FuncMetadata = Field(
@@ -32,6 +34,12 @@ class Tool(ToolResource):
3234
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
3335
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
3436

37+
def __init__(self, **data: Any) -> None:
38+
"""Initialize Tool, generating URI from name if not provided."""
39+
if "uri" not in data and "name" in data:
40+
data["uri"] = f"tool://{data['name']}"
41+
super().__init__(**data)
42+
3543
@cached_property
3644
def output_schema(self) -> dict[str, Any] | None:
3745
return self.fn_metadata.output_schema
@@ -86,26 +94,6 @@ def from_function(
8694
annotations=annotations,
8795
)
8896

89-
async def read(self) -> str | bytes:
90-
"""Read the tool schema/documentation as JSON."""
91-
tool_info = {
92-
"name": self.name,
93-
"title": self.title,
94-
"description": self.description,
95-
"uri": str(self.uri),
96-
"parameters": self.parameters,
97-
}
98-
99-
# Include output schema if available
100-
if self.output_schema:
101-
tool_info["output_schema"] = self.output_schema
102-
103-
# Include annotations if available
104-
if self.annotations:
105-
tool_info["annotations"] = self.annotations.model_dump(exclude_none=True)
106-
107-
return json.dumps(tool_info, indent=2)
108-
10997
async def run(
11098
self,
11199
arguments: dict[str, Any],

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,22 @@ def __init__(
2828
self._tools: dict[str, Tool] = {}
2929
if tools is not None:
3030
for tool in tools:
31-
if warn_on_duplicate_tools and tool.name in self._tools:
32-
logger.warning(f"Tool already exists: {tool.name}")
33-
self._tools[tool.name] = tool
31+
if warn_on_duplicate_tools and tool.uri in self._tools:
32+
logger.warning(f"Tool already exists: {tool.uri}")
33+
self._tools[tool.uri] = tool
3434

3535
self.warn_on_duplicate_tools = warn_on_duplicate_tools
3636

37+
def _normalize_to_uri(self, name_or_uri: str) -> str:
38+
"""Convert name to URI if needed."""
39+
if name_or_uri.startswith("tool://"):
40+
return name_or_uri
41+
return f"tool://{name_or_uri}"
42+
3743
def get_tool(self, name: str) -> Tool | None:
38-
"""Get tool by name."""
39-
return self._tools.get(name)
44+
"""Get tool by name or URI."""
45+
uri = self._normalize_to_uri(name)
46+
return self._tools.get(uri)
4047

4148
def list_tools(self) -> list[Tool]:
4249
"""List all registered tools."""
@@ -60,12 +67,12 @@ def add_tool(
6067
annotations=annotations,
6168
structured_output=structured_output,
6269
)
63-
existing = self._tools.get(tool.name)
70+
existing = self._tools.get(tool.uri)
6471
if existing:
6572
if self.warn_on_duplicate_tools:
66-
logger.warning(f"Tool already exists: {tool.name}")
73+
logger.warning(f"Tool already exists: {tool.uri}")
6774
return existing
68-
self._tools[tool.name] = tool
75+
self._tools[tool.uri] = tool
6976
return tool
7077

7178
async def call_tool(

src/mcp/types.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from collections.abc import Callable
22
from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar
33

4-
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
4+
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel, model_validator
55
from pydantic.networks import AnyUrl, UrlConstraints
66
from typing_extensions import deprecated
77

@@ -445,6 +445,14 @@ class Resource(BaseMetadata):
445445
"""
446446
model_config = ConfigDict(extra="allow")
447447

448+
@model_validator(mode="after")
449+
def validate_uri_scheme(self) -> "Resource":
450+
"""Ensure resource URI doesn't use reserved schemes."""
451+
uri_str = str(self.uri)
452+
if uri_str.startswith(("tool://", "prompt://")):
453+
raise ValueError(f"Resource URI cannot use reserved schemes 'tool://' or 'prompt://', got: {self.uri}")
454+
return self
455+
448456

449457
class ResourceTemplate(BaseMetadata):
450458
"""A template description for resources available on the server."""
@@ -642,6 +650,8 @@ class PromptArgument(BaseModel):
642650
class Prompt(BaseMetadata):
643651
"""A prompt or prompt template that the server offers."""
644652

653+
uri: Annotated[AnyUrl, UrlConstraints(allowed_schemes=["prompt"], host_required=False)]
654+
"""URI for the prompt. Must use 'prompt' scheme."""
645655
description: str | None = None
646656
"""An optional description of what this prompt provides."""
647657
arguments: list[PromptArgument] | None = None
@@ -861,6 +871,8 @@ class ToolAnnotations(BaseModel):
861871
class Tool(BaseMetadata):
862872
"""Definition for a tool the client can call."""
863873

874+
uri: Annotated[AnyUrl, UrlConstraints(allowed_schemes=["tool"], host_required=False)]
875+
"""URI for the tool. Must use 'tool' scheme."""
864876
description: str | None = None
865877
"""A human-readable description of the tool."""
866878
inputSchema: dict[str, Any]

0 commit comments

Comments
 (0)