fastmcp 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +8 -2
- fastmcp/cli/__init__.py +3 -1
- fastmcp/cli/claude.py +20 -18
- fastmcp/cli/cli.py +20 -16
- fastmcp/prompts/__init__.py +4 -0
- fastmcp/prompts/base.py +149 -0
- fastmcp/prompts/manager.py +50 -0
- fastmcp/prompts/prompt_manager.py +36 -0
- fastmcp/resources/__init__.py +23 -0
- fastmcp/resources/base.py +63 -0
- fastmcp/resources/resource_manager.py +94 -0
- fastmcp/resources/templates.py +80 -0
- fastmcp/resources/types.py +171 -0
- fastmcp/server.py +449 -112
- fastmcp/tools/__init__.py +4 -0
- fastmcp/tools/base.py +79 -0
- fastmcp/tools/tool_manager.py +55 -0
- fastmcp/utilities/logging.py +6 -5
- fastmcp/utilities/types.py +53 -0
- fastmcp-0.3.1.dist-info/METADATA +407 -0
- fastmcp-0.3.1.dist-info/RECORD +26 -0
- {fastmcp-0.2.0.dist-info → fastmcp-0.3.1.dist-info}/WHEEL +1 -2
- fastmcp-0.3.1.dist-info/licenses/LICENSE +201 -0
- fastmcp/_version.py +0 -16
- fastmcp/app.py +0 -6
- fastmcp/resources.py +0 -232
- fastmcp/tools.py +0 -150
- fastmcp-0.2.0.dist-info/METADATA +0 -143
- fastmcp-0.2.0.dist-info/RECORD +0 -17
- fastmcp-0.2.0.dist-info/top_level.txt +0 -1
- {fastmcp-0.2.0.dist-info → fastmcp-0.3.1.dist-info}/entry_points.txt +0 -0
fastmcp/__init__.py
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"""FastMCP - A more ergonomic interface for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from .server import FastMCP, Context
|
|
5
|
+
from .utilities.types import Image
|
|
6
|
+
|
|
7
|
+
__version__ = version("fastmcp")
|
|
8
|
+
__all__ = ["FastMCP", "Context", "Image"]
|
fastmcp/cli/__init__.py
CHANGED
fastmcp/cli/claude.py
CHANGED
|
@@ -25,8 +25,8 @@ def get_claude_config_path() -> Path | None:
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def update_claude_config(
|
|
28
|
-
|
|
29
|
-
server_name:
|
|
28
|
+
file_spec: str,
|
|
29
|
+
server_name: str,
|
|
30
30
|
*,
|
|
31
31
|
with_editable: Optional[Path] = None,
|
|
32
32
|
with_packages: Optional[list[str]] = None,
|
|
@@ -35,9 +35,8 @@ def update_claude_config(
|
|
|
35
35
|
"""Add the MCP server to Claude's configuration.
|
|
36
36
|
|
|
37
37
|
Args:
|
|
38
|
-
|
|
39
|
-
server_name:
|
|
40
|
-
defaults to the file stem
|
|
38
|
+
file_spec: Path to the server file, optionally with :object suffix
|
|
39
|
+
server_name: Name for the server in Claude's config
|
|
41
40
|
with_editable: Optional directory to install in editable mode
|
|
42
41
|
with_packages: Optional list of additional packages to install
|
|
43
42
|
force: If True, replace existing server with same name
|
|
@@ -55,46 +54,49 @@ def update_claude_config(
|
|
|
55
54
|
if "mcpServers" not in config:
|
|
56
55
|
config["mcpServers"] = {}
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
name = server_name or file.stem
|
|
60
|
-
if name in config["mcpServers"]:
|
|
57
|
+
if server_name in config["mcpServers"]:
|
|
61
58
|
if not force:
|
|
62
59
|
logger.warning(
|
|
63
|
-
f"Server '{
|
|
60
|
+
f"Server '{server_name}' already exists in Claude config. "
|
|
64
61
|
"Use `--force` to replace.",
|
|
65
62
|
extra={"config_file": str(config_file)},
|
|
66
63
|
)
|
|
67
64
|
return False
|
|
68
65
|
logger.info(
|
|
69
|
-
f"Replacing existing server '{
|
|
66
|
+
f"Replacing existing server '{server_name}' in Claude config",
|
|
70
67
|
extra={"config_file": str(config_file)},
|
|
71
68
|
)
|
|
72
69
|
|
|
73
70
|
# Build uv run command
|
|
74
|
-
args = ["run"]
|
|
71
|
+
args = ["run", "--with", "fastmcp"]
|
|
75
72
|
|
|
76
73
|
if with_editable:
|
|
77
74
|
args.extend(["--with-editable", str(with_editable)])
|
|
78
75
|
|
|
79
|
-
# Always include fastmcp
|
|
80
|
-
args.extend(["--with", "fastmcp"])
|
|
81
|
-
|
|
82
|
-
# Add additional packages
|
|
83
76
|
if with_packages:
|
|
84
77
|
for pkg in with_packages:
|
|
85
78
|
if pkg:
|
|
86
79
|
args.extend(["--with", pkg])
|
|
87
80
|
|
|
88
|
-
|
|
81
|
+
# Convert file path to absolute before adding to command
|
|
82
|
+
# Split off any :object suffix first
|
|
83
|
+
if ":" in file_spec:
|
|
84
|
+
file_path, server_object = file_spec.rsplit(":", 1)
|
|
85
|
+
file_spec = f"{Path(file_path).resolve()}:{server_object}"
|
|
86
|
+
else:
|
|
87
|
+
file_spec = str(Path(file_spec).resolve())
|
|
88
|
+
|
|
89
|
+
# Add fastmcp run command
|
|
90
|
+
args.extend(["fastmcp", "run", file_spec])
|
|
89
91
|
|
|
90
|
-
config["mcpServers"][
|
|
92
|
+
config["mcpServers"][server_name] = {
|
|
91
93
|
"command": "uv",
|
|
92
94
|
"args": args,
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
config_file.write_text(json.dumps(config, indent=2))
|
|
96
98
|
logger.info(
|
|
97
|
-
f"Added server '{
|
|
99
|
+
f"Added server '{server_name}' to Claude config",
|
|
98
100
|
extra={"config_file": str(config_file)},
|
|
99
101
|
)
|
|
100
102
|
return True
|
fastmcp/cli/cli.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing_extensions import Annotated
|
|
|
13
13
|
from ..utilities.logging import get_logger
|
|
14
14
|
from . import claude
|
|
15
15
|
|
|
16
|
-
logger = get_logger(
|
|
16
|
+
logger = get_logger("cli")
|
|
17
17
|
|
|
18
18
|
app = typer.Typer(
|
|
19
19
|
name="fastmcp",
|
|
@@ -24,11 +24,11 @@ app = typer.Typer(
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def _build_uv_command(
|
|
27
|
-
|
|
27
|
+
file_spec: str,
|
|
28
28
|
with_editable: Optional[Path] = None,
|
|
29
29
|
with_packages: Optional[list[str]] = None,
|
|
30
30
|
) -> list[str]:
|
|
31
|
-
"""Build the uv run command."""
|
|
31
|
+
"""Build the uv run command that runs a FastMCP server through fastmcp run."""
|
|
32
32
|
cmd = ["uv"]
|
|
33
33
|
|
|
34
34
|
cmd.extend(["run", "--with", "fastmcp"])
|
|
@@ -41,7 +41,8 @@ def _build_uv_command(
|
|
|
41
41
|
if pkg:
|
|
42
42
|
cmd.extend(["--with", pkg])
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
# Add fastmcp run command
|
|
45
|
+
cmd.extend(["fastmcp", "run", file_spec])
|
|
45
46
|
return cmd
|
|
46
47
|
|
|
47
48
|
|
|
@@ -89,7 +90,7 @@ def _import_server(file: Path, server_object: Optional[str] = None):
|
|
|
89
90
|
module = importlib.util.module_from_spec(spec)
|
|
90
91
|
spec.loader.exec_module(module)
|
|
91
92
|
|
|
92
|
-
# If no object specified, try
|
|
93
|
+
# If no object specified, try common server names
|
|
93
94
|
if not server_object:
|
|
94
95
|
# Look for the most common server object names
|
|
95
96
|
for name in ["mcp", "server", "app"]:
|
|
@@ -97,7 +98,9 @@ def _import_server(file: Path, server_object: Optional[str] = None):
|
|
|
97
98
|
return getattr(module, name)
|
|
98
99
|
|
|
99
100
|
logger.error(
|
|
100
|
-
f"No server object found in {file}. Please
|
|
101
|
+
f"No server object found in {file}. Please either:\n"
|
|
102
|
+
"1. Use a standard variable name (mcp, server, or app)\n"
|
|
103
|
+
"2. Specify the object name with file:object syntax",
|
|
101
104
|
extra={"file": str(file)},
|
|
102
105
|
)
|
|
103
106
|
sys.exit(1)
|
|
@@ -178,7 +181,7 @@ def dev(
|
|
|
178
181
|
)
|
|
179
182
|
|
|
180
183
|
try:
|
|
181
|
-
uv_cmd = _build_uv_command(
|
|
184
|
+
uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
|
|
182
185
|
# Run the MCP Inspector command
|
|
183
186
|
process = subprocess.run(
|
|
184
187
|
["npx", "@modelcontextprotocol/inspector"] + uv_cmd,
|
|
@@ -229,7 +232,12 @@ def run(
|
|
|
229
232
|
),
|
|
230
233
|
] = None,
|
|
231
234
|
) -> None:
|
|
232
|
-
"""Run a FastMCP server.
|
|
235
|
+
"""Run a FastMCP server.
|
|
236
|
+
|
|
237
|
+
The server can be specified in two ways:
|
|
238
|
+
1. Module approach: server.py - runs the module directly, expecting a server.run() call
|
|
239
|
+
2. Import approach: server.py:app - imports and runs the specified server object
|
|
240
|
+
"""
|
|
233
241
|
file, server_object = _parse_file_path(file_spec)
|
|
234
242
|
|
|
235
243
|
logger.debug(
|
|
@@ -255,7 +263,7 @@ def run(
|
|
|
255
263
|
|
|
256
264
|
except Exception as e:
|
|
257
265
|
logger.error(
|
|
258
|
-
"Failed to run server",
|
|
266
|
+
f"Failed to run server: {e}",
|
|
259
267
|
extra={
|
|
260
268
|
"file": str(file),
|
|
261
269
|
"error": str(e),
|
|
@@ -338,17 +346,13 @@ def install(
|
|
|
338
346
|
name = file.stem
|
|
339
347
|
|
|
340
348
|
if claude.update_claude_config(
|
|
341
|
-
|
|
349
|
+
file_spec,
|
|
342
350
|
name,
|
|
343
351
|
with_editable=with_editable,
|
|
344
352
|
with_packages=with_packages,
|
|
345
353
|
force=force,
|
|
346
354
|
):
|
|
347
|
-
|
|
355
|
+
logger.info(f"Successfully installed {name} in Claude app")
|
|
348
356
|
else:
|
|
349
|
-
|
|
357
|
+
logger.error(f"Failed to install {name} in Claude app")
|
|
350
358
|
sys.exit(1)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if __name__ == "__main__":
|
|
354
|
-
app()
|
fastmcp/prompts/base.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Base classes for FastMCP prompts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call
|
|
8
|
+
from mcp.types import TextContent, ImageContent, EmbeddedResource
|
|
9
|
+
import pydantic_core
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Message(BaseModel):
|
|
13
|
+
"""Base class for all prompt messages."""
|
|
14
|
+
|
|
15
|
+
role: Literal["user", "assistant"]
|
|
16
|
+
content: Union[TextContent, ImageContent, EmbeddedResource]
|
|
17
|
+
|
|
18
|
+
def __init__(self, content, **kwargs):
|
|
19
|
+
super().__init__(content=content, **kwargs)
|
|
20
|
+
|
|
21
|
+
@field_validator("content", mode="before")
|
|
22
|
+
def validate_content(cls, v):
|
|
23
|
+
if isinstance(v, str):
|
|
24
|
+
return TextContent(type="text", text=v)
|
|
25
|
+
return v
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserMessage(Message):
|
|
29
|
+
"""A message from the user."""
|
|
30
|
+
|
|
31
|
+
role: Literal["user"] = "user"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AssistantMessage(Message):
|
|
35
|
+
"""A message from the assistant."""
|
|
36
|
+
|
|
37
|
+
role: Literal["assistant"] = "assistant"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
message_validator = TypeAdapter(Union[UserMessage, AssistantMessage])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PromptArgument(BaseModel):
|
|
44
|
+
"""An argument that can be passed to a prompt."""
|
|
45
|
+
|
|
46
|
+
name: str = Field(description="Name of the argument")
|
|
47
|
+
description: str | None = Field(
|
|
48
|
+
None, description="Description of what the argument does"
|
|
49
|
+
)
|
|
50
|
+
required: bool = Field(
|
|
51
|
+
default=False, description="Whether the argument is required"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Prompt(BaseModel):
|
|
56
|
+
"""A prompt template that can be rendered with parameters."""
|
|
57
|
+
|
|
58
|
+
name: str = Field(description="Name of the prompt")
|
|
59
|
+
description: str | None = Field(
|
|
60
|
+
None, description="Description of what the prompt does"
|
|
61
|
+
)
|
|
62
|
+
arguments: list[PromptArgument] | None = Field(
|
|
63
|
+
None, description="Arguments that can be passed to the prompt"
|
|
64
|
+
)
|
|
65
|
+
fn: Callable = Field(exclude=True)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_function(
|
|
69
|
+
cls,
|
|
70
|
+
fn: Callable[..., Sequence[Message]],
|
|
71
|
+
name: Optional[str] = None,
|
|
72
|
+
description: Optional[str] = None,
|
|
73
|
+
) -> "Prompt":
|
|
74
|
+
"""Create a Prompt from a function."""
|
|
75
|
+
func_name = name or fn.__name__
|
|
76
|
+
|
|
77
|
+
if func_name == "<lambda>":
|
|
78
|
+
raise ValueError("You must provide a name for lambda functions")
|
|
79
|
+
|
|
80
|
+
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
|
81
|
+
parameters = TypeAdapter(fn).json_schema()
|
|
82
|
+
|
|
83
|
+
# Convert parameters to PromptArguments
|
|
84
|
+
arguments = []
|
|
85
|
+
if "properties" in parameters:
|
|
86
|
+
for param_name, param in parameters["properties"].items():
|
|
87
|
+
required = param_name in parameters.get("required", [])
|
|
88
|
+
arguments.append(
|
|
89
|
+
PromptArgument(
|
|
90
|
+
name=param_name,
|
|
91
|
+
description=param.get("description"),
|
|
92
|
+
required=required,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# ensure the arguments are properly cast
|
|
97
|
+
fn = validate_call(fn)
|
|
98
|
+
|
|
99
|
+
return cls(
|
|
100
|
+
name=func_name,
|
|
101
|
+
description=description or fn.__doc__ or "",
|
|
102
|
+
arguments=arguments,
|
|
103
|
+
fn=fn,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def render(self, arguments: Optional[Dict[str, Any]] = None) -> list[Message]:
|
|
107
|
+
"""Render the prompt with arguments."""
|
|
108
|
+
# Validate required arguments
|
|
109
|
+
if self.arguments:
|
|
110
|
+
required = {arg.name for arg in self.arguments if arg.required}
|
|
111
|
+
provided = set(arguments or {})
|
|
112
|
+
missing = required - provided
|
|
113
|
+
if missing:
|
|
114
|
+
raise ValueError(f"Missing required arguments: {missing}")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Call function and check if result is a coroutine
|
|
118
|
+
result = self.fn(**(arguments or {}))
|
|
119
|
+
if inspect.iscoroutine(result):
|
|
120
|
+
result = await result
|
|
121
|
+
|
|
122
|
+
# Validate messages
|
|
123
|
+
if not isinstance(result, (list, tuple)):
|
|
124
|
+
result = [result]
|
|
125
|
+
|
|
126
|
+
# Convert result to messages
|
|
127
|
+
messages = []
|
|
128
|
+
for msg in result:
|
|
129
|
+
try:
|
|
130
|
+
if isinstance(msg, Message):
|
|
131
|
+
messages.append(msg)
|
|
132
|
+
elif isinstance(msg, dict):
|
|
133
|
+
msg = message_validator.validate_python(msg)
|
|
134
|
+
messages.append(msg)
|
|
135
|
+
elif isinstance(msg, str):
|
|
136
|
+
messages.append(
|
|
137
|
+
UserMessage(content=TextContent(type="text", text=msg))
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
msg = json.dumps(pydantic_core.to_jsonable_python(msg))
|
|
141
|
+
messages.append(Message(role="user", content=msg))
|
|
142
|
+
except Exception:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"Could not convert prompt result to message: {msg}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return messages
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Prompt management functionality."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from fastmcp.prompts.base import Message, Prompt
|
|
6
|
+
from fastmcp.utilities.logging import get_logger
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PromptManager:
|
|
12
|
+
"""Manages FastMCP prompts."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, warn_on_duplicate_prompts: bool = True):
|
|
15
|
+
self._prompts: Dict[str, Prompt] = {}
|
|
16
|
+
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
|
|
17
|
+
|
|
18
|
+
def get_prompt(self, name: str) -> Optional[Prompt]:
|
|
19
|
+
"""Get prompt by name."""
|
|
20
|
+
return self._prompts.get(name)
|
|
21
|
+
|
|
22
|
+
def list_prompts(self) -> list[Prompt]:
|
|
23
|
+
"""List all registered prompts."""
|
|
24
|
+
return list(self._prompts.values())
|
|
25
|
+
|
|
26
|
+
def add_prompt(
|
|
27
|
+
self,
|
|
28
|
+
prompt: Prompt,
|
|
29
|
+
) -> Prompt:
|
|
30
|
+
"""Add a prompt to the manager."""
|
|
31
|
+
|
|
32
|
+
# Check for duplicates
|
|
33
|
+
existing = self._prompts.get(prompt.name)
|
|
34
|
+
if existing:
|
|
35
|
+
if self.warn_on_duplicate_prompts:
|
|
36
|
+
logger.warning(f"Prompt already exists: {prompt.name}")
|
|
37
|
+
return existing
|
|
38
|
+
|
|
39
|
+
self._prompts[prompt.name] = prompt
|
|
40
|
+
return prompt
|
|
41
|
+
|
|
42
|
+
async def render_prompt(
|
|
43
|
+
self, name: str, arguments: Optional[Dict[str, Any]] = None
|
|
44
|
+
) -> list[Message]:
|
|
45
|
+
"""Render a prompt by name with arguments."""
|
|
46
|
+
prompt = self.get_prompt(name)
|
|
47
|
+
if not prompt:
|
|
48
|
+
raise ValueError(f"Unknown prompt: {name}")
|
|
49
|
+
|
|
50
|
+
return await prompt.render(arguments)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Prompt management functionality."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from fastmcp.prompts.base import Prompt
|
|
7
|
+
from fastmcp.utilities.logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PromptManager:
|
|
13
|
+
"""Manages FastMCP prompts."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, warn_on_duplicate_prompts: bool = True):
|
|
16
|
+
self._prompts: Dict[str, Prompt] = {}
|
|
17
|
+
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
|
|
18
|
+
|
|
19
|
+
def add_prompt(self, prompt: Prompt) -> Prompt:
|
|
20
|
+
"""Add a prompt to the manager."""
|
|
21
|
+
logger.debug(f"Adding prompt: {prompt.name}")
|
|
22
|
+
existing = self._prompts.get(prompt.name)
|
|
23
|
+
if existing:
|
|
24
|
+
if self.warn_on_duplicate_prompts:
|
|
25
|
+
logger.warning(f"Prompt already exists: {prompt.name}")
|
|
26
|
+
return existing
|
|
27
|
+
self._prompts[prompt.name] = prompt
|
|
28
|
+
return prompt
|
|
29
|
+
|
|
30
|
+
def get_prompt(self, name: str) -> Optional[Prompt]:
|
|
31
|
+
"""Get prompt by name."""
|
|
32
|
+
return self._prompts.get(name)
|
|
33
|
+
|
|
34
|
+
def list_prompts(self) -> list[Prompt]:
|
|
35
|
+
"""List all registered prompts."""
|
|
36
|
+
return list(self._prompts.values())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .base import Resource
|
|
2
|
+
from .types import (
|
|
3
|
+
TextResource,
|
|
4
|
+
BinaryResource,
|
|
5
|
+
FunctionResource,
|
|
6
|
+
FileResource,
|
|
7
|
+
HttpResource,
|
|
8
|
+
DirectoryResource,
|
|
9
|
+
)
|
|
10
|
+
from .templates import ResourceTemplate
|
|
11
|
+
from .resource_manager import ResourceManager
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Resource",
|
|
15
|
+
"TextResource",
|
|
16
|
+
"BinaryResource",
|
|
17
|
+
"FunctionResource",
|
|
18
|
+
"FileResource",
|
|
19
|
+
"HttpResource",
|
|
20
|
+
"DirectoryResource",
|
|
21
|
+
"ResourceTemplate",
|
|
22
|
+
"ResourceManager",
|
|
23
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Base classes and interfaces for FastMCP resources."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from typing import Annotated, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import (
|
|
7
|
+
AnyUrl,
|
|
8
|
+
BaseModel,
|
|
9
|
+
BeforeValidator,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
FileUrl,
|
|
13
|
+
ValidationInfo,
|
|
14
|
+
field_validator,
|
|
15
|
+
)
|
|
16
|
+
from pydantic.networks import _BaseUrl # TODO: remove this once pydantic is updated
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def maybe_cast_str_to_any_url(x) -> AnyUrl:
|
|
20
|
+
if isinstance(x, FileUrl):
|
|
21
|
+
return x
|
|
22
|
+
elif isinstance(x, AnyUrl):
|
|
23
|
+
return x
|
|
24
|
+
elif isinstance(x, str):
|
|
25
|
+
if x.startswith("file://"):
|
|
26
|
+
return FileUrl(x)
|
|
27
|
+
return AnyUrl(x)
|
|
28
|
+
raise ValueError(f"Expected str or AnyUrl, got {type(x)}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
LaxAnyUrl = Annotated[_BaseUrl | str, BeforeValidator(maybe_cast_str_to_any_url)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Resource(BaseModel, abc.ABC):
|
|
35
|
+
"""Base class for all resources."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(validate_default=True)
|
|
38
|
+
|
|
39
|
+
uri: LaxAnyUrl = Field(default=..., description="URI of the resource")
|
|
40
|
+
name: str | None = Field(description="Name of the resource", default=None)
|
|
41
|
+
description: str | None = Field(
|
|
42
|
+
description="Description of the resource", default=None
|
|
43
|
+
)
|
|
44
|
+
mime_type: str = Field(
|
|
45
|
+
default="text/plain",
|
|
46
|
+
description="MIME type of the resource content",
|
|
47
|
+
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@field_validator("name", mode="before")
|
|
51
|
+
@classmethod
|
|
52
|
+
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
|
|
53
|
+
"""Set default name from URI if not provided."""
|
|
54
|
+
if name:
|
|
55
|
+
return name
|
|
56
|
+
if uri := info.data.get("uri"):
|
|
57
|
+
return str(uri)
|
|
58
|
+
raise ValueError("Either name or uri must be provided")
|
|
59
|
+
|
|
60
|
+
@abc.abstractmethod
|
|
61
|
+
async def read(self) -> Union[str, bytes]:
|
|
62
|
+
"""Read the resource content."""
|
|
63
|
+
pass
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Resource manager functionality."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Dict, Optional, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import AnyUrl
|
|
6
|
+
|
|
7
|
+
from fastmcp.resources.base import Resource
|
|
8
|
+
from fastmcp.resources.templates import ResourceTemplate
|
|
9
|
+
from fastmcp.utilities.logging import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResourceManager:
|
|
15
|
+
"""Manages FastMCP resources."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, warn_on_duplicate_resources: bool = True):
|
|
18
|
+
self._resources: Dict[str, Resource] = {}
|
|
19
|
+
self._templates: Dict[str, ResourceTemplate] = {}
|
|
20
|
+
self.warn_on_duplicate_resources = warn_on_duplicate_resources
|
|
21
|
+
|
|
22
|
+
def add_resource(self, resource: Resource) -> Resource:
|
|
23
|
+
"""Add a resource to the manager.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
resource: A Resource instance to add
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The added resource. If a resource with the same URI already exists,
|
|
30
|
+
returns the existing resource.
|
|
31
|
+
"""
|
|
32
|
+
logger.debug(
|
|
33
|
+
"Adding resource",
|
|
34
|
+
extra={
|
|
35
|
+
"uri": resource.uri,
|
|
36
|
+
"type": type(resource).__name__,
|
|
37
|
+
"name": resource.name,
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
existing = self._resources.get(str(resource.uri))
|
|
41
|
+
if existing:
|
|
42
|
+
if self.warn_on_duplicate_resources:
|
|
43
|
+
logger.warning(f"Resource already exists: {resource.uri}")
|
|
44
|
+
return existing
|
|
45
|
+
self._resources[str(resource.uri)] = resource
|
|
46
|
+
return resource
|
|
47
|
+
|
|
48
|
+
def add_template(
|
|
49
|
+
self,
|
|
50
|
+
fn: Callable,
|
|
51
|
+
uri_template: str,
|
|
52
|
+
name: Optional[str] = None,
|
|
53
|
+
description: Optional[str] = None,
|
|
54
|
+
mime_type: Optional[str] = None,
|
|
55
|
+
) -> ResourceTemplate:
|
|
56
|
+
"""Add a template from a function."""
|
|
57
|
+
template = ResourceTemplate.from_function(
|
|
58
|
+
fn,
|
|
59
|
+
uri_template=uri_template,
|
|
60
|
+
name=name,
|
|
61
|
+
description=description,
|
|
62
|
+
mime_type=mime_type,
|
|
63
|
+
)
|
|
64
|
+
self._templates[template.uri_template] = template
|
|
65
|
+
return template
|
|
66
|
+
|
|
67
|
+
async def get_resource(self, uri: Union[AnyUrl, str]) -> Optional[Resource]:
|
|
68
|
+
"""Get resource by URI, checking concrete resources first, then templates."""
|
|
69
|
+
uri_str = str(uri)
|
|
70
|
+
logger.debug("Getting resource", extra={"uri": uri_str})
|
|
71
|
+
|
|
72
|
+
# First check concrete resources
|
|
73
|
+
if resource := self._resources.get(uri_str):
|
|
74
|
+
return resource
|
|
75
|
+
|
|
76
|
+
# Then check templates
|
|
77
|
+
for template in self._templates.values():
|
|
78
|
+
if params := template.matches(uri_str):
|
|
79
|
+
try:
|
|
80
|
+
return await template.create_resource(uri_str, params)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise ValueError(f"Error creating resource from template: {e}")
|
|
83
|
+
|
|
84
|
+
raise ValueError(f"Unknown resource: {uri}")
|
|
85
|
+
|
|
86
|
+
def list_resources(self) -> list[Resource]:
|
|
87
|
+
"""List all registered resources."""
|
|
88
|
+
logger.debug("Listing resources", extra={"count": len(self._resources)})
|
|
89
|
+
return list(self._resources.values())
|
|
90
|
+
|
|
91
|
+
def list_templates(self) -> list[ResourceTemplate]:
|
|
92
|
+
"""List all registered templates."""
|
|
93
|
+
logger.debug("Listing templates", extra={"count": len(self._templates)})
|
|
94
|
+
return list(self._templates.values())
|