Skip to content

Commit 9a7c125

Browse files
author
Tapan Chugh
committed
Add example + minor fixes + add tests
1 parent 9eea409 commit 9a7c125

File tree

9 files changed

+479
-16
lines changed

9 files changed

+479
-16
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Example demonstrating hierarchical organization of tools, prompts, and resources using custom URIs.
2+
3+
This example shows how to:
4+
1. Register tools, prompts, and resources with hierarchical URIs
5+
2. Create group discovery resources at well-known URIs
6+
3. Filter items by URI paths for better organization
7+
"""
8+
9+
import json
10+
from typing import cast
11+
12+
from pydantic import AnyUrl
13+
14+
from mcp.server.fastmcp import FastMCP
15+
from mcp.types import ListFilters, TextContent, TextResourceContents
16+
17+
# Create FastMCP server instance
18+
mcp = FastMCP("hierarchical-example")
19+
20+
21+
# Group discovery resources
22+
@mcp.resource("mcp://groups/tools")
23+
def get_tool_groups() -> str:
24+
"""Discover available tool groups."""
25+
return json.dumps(
26+
{
27+
"groups": [
28+
{"name": "math", "description": "Mathematical operations", "uri_paths": ["mcp://tools/math/"]},
29+
{"name": "string", "description": "String manipulation", "uri_paths": ["mcp://tools/string/"]},
30+
]
31+
},
32+
indent=2,
33+
)
34+
35+
36+
@mcp.resource("mcp://groups/prompts")
37+
def get_prompt_groups() -> str:
38+
"""Discover available prompt groups."""
39+
return json.dumps(
40+
{
41+
"groups": [
42+
{"name": "greetings", "description": "Greeting prompts", "uri_paths": ["mcp://prompts/greetings/"]},
43+
{
44+
"name": "instructions",
45+
"description": "Instructional prompts",
46+
"uri_paths": ["mcp://prompts/instructions/"],
47+
},
48+
]
49+
},
50+
indent=2,
51+
)
52+
53+
54+
# Math tools organized under mcp://tools/math/
55+
@mcp.tool(uri="mcp://tools/math/add")
56+
def add(a: float, b: float) -> float:
57+
"""Add two numbers."""
58+
return a + b
59+
60+
61+
@mcp.tool(uri="mcp://tools/math/multiply")
62+
def multiply(a: float, b: float) -> float:
63+
"""Multiply two numbers."""
64+
return a * b
65+
66+
67+
# String tools organized under mcp://tools/string/
68+
@mcp.tool(uri="mcp://tools/string/reverse")
69+
def reverse(text: str) -> str:
70+
"""Reverse a string."""
71+
return text[::-1]
72+
73+
74+
@mcp.tool(uri="mcp://tools/string/upper")
75+
def upper(text: str) -> str:
76+
"""Convert to uppercase."""
77+
return text.upper()
78+
79+
80+
# Greeting prompts organized under mcp://prompts/greetings/
81+
@mcp.prompt(uri="mcp://prompts/greetings/hello")
82+
def hello_prompt(name: str) -> str:
83+
"""Generate a hello greeting."""
84+
return f"Hello, {name}! How can I help you today?"
85+
86+
87+
@mcp.prompt(uri="mcp://prompts/greetings/goodbye")
88+
def goodbye_prompt(name: str) -> str:
89+
"""Generate a goodbye message."""
90+
return f"Goodbye, {name}! Have a great day!"
91+
92+
93+
# Instruction prompts organized under mcp://prompts/instructions/
94+
@mcp.prompt(uri="mcp://prompts/instructions/setup")
95+
def setup_prompt(tool: str) -> str:
96+
"""Generate setup instructions for a tool."""
97+
return (
98+
f"To set up {tool}, follow these steps:\n"
99+
"1. Install the required dependencies\n"
100+
"2. Configure the settings\n"
101+
"3. Run the initialization script\n"
102+
"4. Verify the installation"
103+
)
104+
105+
106+
@mcp.prompt(uri="mcp://prompts/instructions/debug")
107+
def debug_prompt(error: str) -> str:
108+
"""Generate debugging instructions for an error."""
109+
return (
110+
f"To debug '{error}':\n"
111+
"1. Check the error logs\n"
112+
"2. Verify input parameters\n"
113+
"3. Enable verbose logging\n"
114+
"4. Isolate the issue with minimal reproduction"
115+
)
116+
117+
118+
if __name__ == "__main__":
119+
# Example of testing the hierarchical organization
120+
import asyncio
121+
122+
from mcp.shared.memory import create_connected_server_and_client_session
123+
124+
async def test_hierarchy():
125+
"""Test the hierarchical organization."""
126+
async with create_connected_server_and_client_session(mcp._mcp_server) as client:
127+
# 1. List ALL tools to show what's available
128+
print("=== All Available Tools ===")
129+
all_tools = await client.list_tools()
130+
for tool in all_tools.tools:
131+
print(f"- {tool.name} ({tool.uri}): {tool.description}")
132+
133+
# 2. Discover tool groups and list tools in each group
134+
print("\n=== Discovering Tool Groups ===")
135+
result = await client.read_resource(uri=AnyUrl("mcp://groups/tools"))
136+
tool_groups = json.loads(cast(TextResourceContents, result.contents[0]).text)
137+
138+
for group in tool_groups["groups"]:
139+
print(f"\n--- {group['name'].upper()} Tools ({group['description']}) ---")
140+
# Use the URI paths from the group definition
141+
group_tools = await client.list_tools(
142+
filters=ListFilters(uri_paths=[AnyUrl(uri) for uri in group["uri_paths"]])
143+
)
144+
for tool in group_tools.tools:
145+
print(f" - {tool.name}: {tool.description}")
146+
147+
# 3. Call tools by name (still works!)
148+
print("\n=== Calling Tools by Name ===")
149+
result = await client.call_tool("add", {"a": 10, "b": 5})
150+
print(f"add(10, 5) = {cast(TextContent, result.content[0]).text}")
151+
152+
result = await client.call_tool("reverse", {"text": "Hello"})
153+
print(f"reverse('Hello') = {cast(TextContent, result.content[0]).text}")
154+
155+
# 4. Call tools by URI
156+
print("\n=== Calling Tools by URI ===")
157+
result = await client.call_tool("mcp://tools/math/multiply", {"a": 7, "b": 8})
158+
print(
159+
f"Call mcp://tools/math/multiply with {{'a': 7, 'b': 8}} = {cast(TextContent, result.content[0]).text}"
160+
)
161+
162+
result = await client.call_tool("mcp://tools/string/upper", {"text": "hello world"})
163+
print(
164+
f"Call mcp://tools/string/upper with {{'text': 'hello world'}} = "
165+
f"{cast(TextContent, result.content[0]).text}"
166+
)
167+
168+
# 5. List all prompts
169+
print("\n=== All Available Prompts ===")
170+
all_prompts = await client.list_prompts()
171+
for prompt in all_prompts.prompts:
172+
print(f"- {prompt.name} ({prompt.uri}): {prompt.description}")
173+
174+
# 5. Discover prompt groups and list prompts in each group
175+
print("\n=== Discovering Prompt Groups ===")
176+
result = await client.read_resource(uri=AnyUrl("mcp://groups/prompts"))
177+
prompt_groups = json.loads(cast(TextResourceContents, result.contents[0]).text)
178+
179+
for group in prompt_groups["groups"]:
180+
print(f"\n--- {group['name'].upper()} Prompts ({group['description']}) ---")
181+
# Use the URI paths from the group definition
182+
group_prompts = await client.list_prompts(
183+
filters=ListFilters(uri_paths=[AnyUrl(uri) for uri in group["uri_paths"]])
184+
)
185+
for prompt in group_prompts.prompts:
186+
print(f" - {prompt.name}: {prompt.description}")
187+
188+
# 6. Use a prompt
189+
print("\n=== Using a Prompt ===")
190+
result = await client.get_prompt("hello_prompt", {"name": "Alice"})
191+
print(f"Prompt result: {cast(TextContent, result.messages[0].content).text}")
192+
193+
# Run the test
194+
asyncio.run(test_hierarchy())

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class Prompt(BaseModel):
6666

6767
def __init__(self, **data: Any) -> None:
6868
"""Initialize Prompt, generating URI from name if not provided."""
69-
if "uri" not in data and "name" in data:
69+
if not data.get("uri", None):
7070
data["uri"] = AnyUrl(f"{PROMPT_SCHEME}/{data['name']}")
7171
super().__init__(**data)
7272

@@ -75,6 +75,7 @@ def from_function(
7575
cls,
7676
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
7777
name: str | None = None,
78+
uri: str | AnyUrl | None = None,
7879
title: str | None = None,
7980
description: str | None = None,
8081
) -> "Prompt":
@@ -112,6 +113,7 @@ def from_function(
112113

113114
return cls(
114115
name=func_name,
116+
uri=uri,
115117
title=title,
116118
description=description or fn.__doc__ or "",
117119
arguments=arguments,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ def get_prompt(self, name_or_uri: AnyUrl | str) -> Prompt | None:
3636
"""Get prompt by name or URI."""
3737
if isinstance(name_or_uri, AnyUrl):
3838
return self._prompts.get(str(name_or_uri))
39+
40+
# Try as a direct URI first
41+
if name_or_uri in self._prompts:
42+
return self._prompts[name_or_uri]
43+
44+
# Try to find a prompt by name
45+
for prompt in self._prompts.values():
46+
if prompt.name == name_or_uri:
47+
return prompt
48+
49+
# Finally try normalizing to URI
3950
uri = self._normalize_to_uri(name_or_uri)
4051
return self._prompts.get(uri)
4152

src/mcp/server/fastmcp/server.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ def add_tool(
355355
self,
356356
fn: AnyFunction,
357357
name: str | None = None,
358+
uri: str | AnyUrl | None = None,
358359
title: str | None = None,
359360
description: str | None = None,
360361
annotations: ToolAnnotations | None = None,
@@ -368,6 +369,7 @@ def add_tool(
368369
Args:
369370
fn: The function to register as a tool
370371
name: Optional name for the tool (defaults to function name)
372+
uri: Optional URI for the tool (defaults to {TOOL_SCHEME}/{{name}})
371373
title: Optional human-readable title for the tool
372374
description: Optional description of what the tool does
373375
annotations: Optional ToolAnnotations providing additional tool information
@@ -379,6 +381,7 @@ def add_tool(
379381
self._tool_manager.add_tool(
380382
fn,
381383
name=name,
384+
uri=uri,
382385
title=title,
383386
description=description,
384387
annotations=annotations,
@@ -388,6 +391,7 @@ def add_tool(
388391
def tool(
389392
self,
390393
name: str | None = None,
394+
uri: str | AnyUrl | None = None,
391395
title: str | None = None,
392396
description: str | None = None,
393397
annotations: ToolAnnotations | None = None,
@@ -401,6 +405,7 @@ def tool(
401405
402406
Args:
403407
name: Optional name for the tool (defaults to function name)
408+
uri: Optional URI for the tool (defaults to {TOOL_SCHEME}/{{name}})
404409
title: Optional human-readable title for the tool
405410
description: Optional description of what the tool does
406411
annotations: Optional ToolAnnotations providing additional tool information
@@ -434,6 +439,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
434439
self.add_tool(
435440
fn,
436441
name=name,
442+
uri=uri,
437443
title=title,
438444
description=description,
439445
annotations=annotations,
@@ -570,12 +576,17 @@ def add_prompt(self, prompt: Prompt) -> None:
570576
self._prompt_manager.add_prompt(prompt)
571577

572578
def prompt(
573-
self, name: str | None = None, title: str | None = None, description: str | None = None
579+
self,
580+
name: str | None = None,
581+
uri: str | AnyUrl | None = None,
582+
title: str | None = None,
583+
description: str | None = None,
574584
) -> Callable[[AnyFunction], AnyFunction]:
575585
"""Decorator to register a prompt.
576586
577587
Args:
578588
name: Optional name for the prompt (defaults to function name)
589+
uri: Optional URI for the prompt (defaults to {PROMPT_SCHEME}/{{name}})
579590
title: Optional human-readable title for the prompt
580591
description: Optional description of what the prompt does
581592
@@ -614,7 +625,7 @@ async def analyze_file(path: str) -> list[Message]:
614625
)
615626

616627
def decorator(func: AnyFunction) -> AnyFunction:
617-
prompt = Prompt.from_function(func, name=name, title=title, description=description)
628+
prompt = Prompt.from_function(func, name=name, uri=uri, title=title, description=description)
618629
self.add_prompt(prompt)
619630
return func
620631

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Tool(BaseModel):
3636

3737
def __init__(self, **data: Any) -> None:
3838
"""Initialize Tool, generating URI from name if not provided."""
39-
if "uri" not in data and "name" in data:
39+
if not data.get("uri", None):
4040
data["uri"] = AnyUrl(f"{TOOL_SCHEME}/{data['name']}")
4141
super().__init__(**data)
4242

@@ -49,6 +49,7 @@ def from_function(
4949
cls,
5050
fn: Callable[..., Any],
5151
name: str | None = None,
52+
uri: str | AnyUrl | None = None,
5253
title: str | None = None,
5354
description: str | None = None,
5455
context_kwarg: str | None = None,
@@ -85,6 +86,7 @@ def from_function(
8586
return cls(
8687
fn=fn,
8788
name=func_name,
89+
uri=uri,
8890
title=title,
8991
description=func_doc,
9092
parameters=parameters,

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def __init__(
3131
self._tools: dict[str, Tool] = {}
3232
if tools is not None:
3333
for tool in tools:
34-
if warn_on_duplicate_tools and tool.uri in self._tools:
34+
if warn_on_duplicate_tools and str(tool.uri) in self._tools:
3535
logger.warning(f"Tool already exists: {tool.uri}")
3636
self._tools[str(tool.uri)] = tool
3737

@@ -55,6 +55,17 @@ def get_tool(self, name_or_uri: AnyUrl | str) -> Tool | None:
5555
"""Get tool by name or URI."""
5656
if isinstance(name_or_uri, AnyUrl):
5757
return self._tools.get(str(name_or_uri))
58+
59+
# Try as a direct URI first
60+
if name_or_uri in self._tools:
61+
return self._tools[name_or_uri]
62+
63+
# Try to find a tool by name
64+
for tool in self._tools.values():
65+
if tool.name == name_or_uri:
66+
return tool
67+
68+
# Finally try normalizing to URI
5869
uri = self._normalize_to_uri(name_or_uri)
5970
return self._tools.get(uri)
6071

@@ -70,6 +81,7 @@ def add_tool(
7081
self,
7182
fn: Callable[..., Any],
7283
name: str | None = None,
84+
uri: str | AnyUrl | None = None,
7385
title: str | None = None,
7486
description: str | None = None,
7587
annotations: ToolAnnotations | None = None,
@@ -79,6 +91,7 @@ def add_tool(
7991
tool = Tool.from_function(
8092
fn,
8193
name=name,
94+
uri=uri,
8295
title=title,
8396
description=description,
8497
annotations=annotations,

0 commit comments

Comments
 (0)