Skip to content

Commit d727b30

Browse files
committed
Add Bundler class for managing tools, resources, and prompts
1 parent 2ca2de7 commit d727b30

File tree

3 files changed

+262
-0
lines changed

3 files changed

+262
-0
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from mcp.server.fastmcp.prompts import Prompt, PromptManager
3939
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
4040
from mcp.server.fastmcp.tools import Tool, ToolManager
41+
from mcp.server.fastmcp.utilities.bundler import Bundler
4142
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
4243
from mcp.server.fastmcp.utilities.types import Image
4344
from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -586,6 +587,13 @@ def decorator(
586587
return func
587588

588589
return decorator
590+
591+
def include_bunder(self, bundler: Bundler) -> None:
592+
"""Add bundler of resources, tools and prompts to the server."""
593+
bundler_tools = bundler.get_tools()
594+
for name, tool in bundler_tools.items():
595+
self.add_tool(tool.fn, name, tool.description, tool.annotations)
596+
# TODO finish code for resources and prompts
589597

590598
async def run_stdio_async(self) -> None:
591599
"""Run the server using stdio transport."""

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def __init__(
3737
def get_tool(self, name: str) -> Tool | None:
3838
"""Get tool by name."""
3939
return self._tools.get(name)
40+
41+
def get_all_tools(self) -> dict[str, Tool]:
42+
return self._tools
4043

4144
def list_tools(self) -> list[Tool]:
4245
"""List all registered tools."""
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import inspect
2+
import re
3+
from collections.abc import Callable
4+
5+
from mcp.server.fastmcp.prompts import Prompt, PromptManager
6+
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
7+
from mcp.server.fastmcp.tools import Tool, ToolManager
8+
from mcp.types import (
9+
AnyFunction,
10+
ToolAnnotations,
11+
)
12+
13+
14+
class Bundler:
15+
def __init__(self, tools: list[Tool] | None = None,):
16+
self._tool_manager = ToolManager(
17+
tools=tools, warn_on_duplicate_tools=True
18+
)
19+
self._resource_manager = ResourceManager(
20+
warn_on_duplicate_resources=True
21+
)
22+
self._prompt_manager = PromptManager(
23+
warn_on_duplicate_prompts=True
24+
)
25+
26+
def add_tool(
27+
self,
28+
fn: AnyFunction,
29+
name: str | None = None,
30+
description: str | None = None,
31+
annotations: ToolAnnotations | None = None,
32+
) -> None:
33+
"""Add a tool to the bundler.
34+
35+
The tool function can optionally request a Context object by adding a parameter
36+
with the Context type annotation. See the @tool decorator for examples.
37+
38+
Args:
39+
fn: The function to register as a tool
40+
name: Optional name for the tool (defaults to function name)
41+
description: Optional description of what the tool does
42+
annotations: Optional ToolAnnotations providing additional tool information
43+
"""
44+
self._tool_manager.add_tool(
45+
fn, name=name, description=description, annotations=annotations
46+
)
47+
48+
def tool(
49+
self,
50+
name: str | None = None,
51+
description: str | None = None,
52+
annotations: ToolAnnotations | None = None,
53+
) -> Callable[[AnyFunction], AnyFunction]:
54+
"""Decorator to register a tool.
55+
56+
Tools can optionally request a Context object by adding a parameter with the
57+
Context type annotation. The context provides access to MCP capabilities like
58+
logging, progress reporting, and resource access.
59+
60+
Args:
61+
name: Optional name for the tool (defaults to function name)
62+
description: Optional description of what the tool does
63+
annotations: Optional ToolAnnotations providing additional tool information
64+
65+
Example:
66+
@server.tool()
67+
def my_tool(x: int) -> str:
68+
return str(x)
69+
70+
@server.tool()
71+
def tool_with_context(x: int, ctx: Context) -> str:
72+
ctx.info(f"Processing {x}")
73+
return str(x)
74+
75+
@server.tool()
76+
async def async_tool(x: int, context: Context) -> str:
77+
await context.report_progress(50, 100)
78+
return str(x)
79+
"""
80+
# Check if user passed function directly instead of calling decorator
81+
if callable(name):
82+
raise TypeError(
83+
"The @tool decorator was used incorrectly. "
84+
"Did you forget to call it? Use @tool() instead of @tool"
85+
)
86+
87+
def decorator(fn: AnyFunction) -> AnyFunction:
88+
self.add_tool(
89+
fn, name=name, description=description, annotations=annotations
90+
)
91+
return fn
92+
93+
return decorator
94+
95+
def get_tools(self) -> dict[str, Tool]:
96+
return self._tool_manager.get_all_tools()
97+
98+
def add_resource(self, resource: Resource) -> None:
99+
"""Add a resource to the server.
100+
101+
Args:
102+
resource: A Resource instance to add
103+
"""
104+
self._resource_manager.add_resource(resource)
105+
106+
def resource(
107+
self,
108+
uri: str,
109+
*,
110+
name: str | None = None,
111+
description: str | None = None,
112+
mime_type: str | None = None,
113+
) -> Callable[[AnyFunction], AnyFunction]:
114+
"""Decorator to register a function as a resource.
115+
116+
The function will be called when the resource is read to generate its content.
117+
The function can return:
118+
- str for text content
119+
- bytes for binary content
120+
- other types will be converted to JSON
121+
122+
If the URI contains parameters (e.g. "resource://{param}") or the function
123+
has parameters, it will be registered as a template resource.
124+
125+
Args:
126+
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
127+
name: Optional name for the resource
128+
description: Optional description of the resource
129+
mime_type: Optional MIME type for the resource
130+
131+
Example:
132+
@server.resource("resource://my-resource")
133+
def get_data() -> str:
134+
return "Hello, world!"
135+
136+
@server.resource("resource://my-resource")
137+
async get_data() -> str:
138+
data = await fetch_data()
139+
return f"Hello, world! {data}"
140+
141+
@server.resource("resource://{city}/weather")
142+
def get_weather(city: str) -> str:
143+
return f"Weather for {city}"
144+
145+
@server.resource("resource://{city}/weather")
146+
async def get_weather(city: str) -> str:
147+
data = await fetch_weather(city)
148+
return f"Weather for {city}: {data}"
149+
"""
150+
# Check if user passed function directly instead of calling decorator
151+
if callable(uri):
152+
raise TypeError(
153+
"The @resource decorator was used incorrectly. "
154+
"Did you forget to call it? Use @resource('uri') instead of @resource"
155+
)
156+
157+
def decorator(fn: AnyFunction) -> AnyFunction:
158+
# Check if this should be a template
159+
has_uri_params = "{" in uri and "}" in uri
160+
has_func_params = bool(inspect.signature(fn).parameters)
161+
162+
if has_uri_params or has_func_params:
163+
# Validate that URI params match function params
164+
uri_params = set(re.findall(r"{(\w+)}", uri))
165+
func_params = set(inspect.signature(fn).parameters.keys())
166+
167+
if uri_params != func_params:
168+
raise ValueError(
169+
f"Mismatch between URI parameters {uri_params} "
170+
f"and function parameters {func_params}"
171+
)
172+
173+
# Register as template
174+
self._resource_manager.add_template(
175+
fn=fn,
176+
uri_template=uri,
177+
name=name,
178+
description=description,
179+
mime_type=mime_type,
180+
)
181+
else:
182+
# Register as regular resource
183+
resource = FunctionResource.from_function(
184+
fn=fn,
185+
uri=uri,
186+
name=name,
187+
description=description,
188+
mime_type=mime_type,
189+
)
190+
self.add_resource(resource)
191+
return fn
192+
193+
return decorator
194+
195+
def add_prompt(self, prompt: Prompt) -> None:
196+
"""Add a prompt to the server.
197+
198+
Args:
199+
prompt: A Prompt instance to add
200+
"""
201+
self._prompt_manager.add_prompt(prompt)
202+
203+
def prompt(
204+
self, name: str | None = None, description: str | None = None
205+
) -> Callable[[AnyFunction], AnyFunction]:
206+
"""Decorator to register a prompt.
207+
208+
Args:
209+
name: Optional name for the prompt (defaults to function name)
210+
description: Optional description of what the prompt does
211+
212+
Example:
213+
@server.prompt()
214+
def analyze_table(table_name: str) -> list[Message]:
215+
schema = read_table_schema(table_name)
216+
return [
217+
{
218+
"role": "user",
219+
"content": f"Analyze this schema:\n{schema}"
220+
}
221+
]
222+
223+
@server.prompt()
224+
async def analyze_file(path: str) -> list[Message]:
225+
content = await read_file(path)
226+
return [
227+
{
228+
"role": "user",
229+
"content": {
230+
"type": "resource",
231+
"resource": {
232+
"uri": f"file://{path}",
233+
"text": content
234+
}
235+
}
236+
}
237+
]
238+
"""
239+
# Check if user passed function directly instead of calling decorator
240+
if callable(name):
241+
raise TypeError(
242+
"The @prompt decorator was used incorrectly. "
243+
"Did you forget to call it? Use @prompt() instead of @prompt"
244+
)
245+
246+
def decorator(func: AnyFunction) -> AnyFunction:
247+
prompt = Prompt.from_function(func, name=name, description=description)
248+
self.add_prompt(prompt)
249+
return func
250+
251+
return decorator

0 commit comments

Comments
 (0)