fastmcp 2.8.1__py3-none-any.whl → 2.9.0__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/cli/cli.py +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +21 -5
- fastmcp/client/transports.py +17 -2
- fastmcp/contrib/mcp_mixin/README.md +79 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
- fastmcp/prompts/prompt.py +91 -11
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +11 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +27 -1
- fastmcp/server/auth/providers/bearer.py +32 -10
- fastmcp/server/context.py +41 -2
- fastmcp/server/http.py +8 -0
- fastmcp/server/middleware/__init__.py +6 -0
- fastmcp/server/middleware/error_handling.py +206 -0
- fastmcp/server/middleware/logging.py +165 -0
- fastmcp/server/middleware/middleware.py +236 -0
- fastmcp/server/middleware/rate_limiting.py +231 -0
- fastmcp/server/middleware/timing.py +156 -0
- fastmcp/server/proxy.py +250 -140
- fastmcp/server/server.py +320 -242
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +6 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/utilities/components.py +22 -2
- fastmcp/utilities/inspect.py +326 -0
- fastmcp/utilities/json_schema.py +67 -23
- fastmcp/utilities/mcp_config.py +13 -7
- fastmcp/utilities/openapi.py +5 -3
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/METADATA +2 -2
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/RECORD +38 -31
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/settings.py
CHANGED
|
@@ -192,9 +192,9 @@ class Settings(BaseSettings):
|
|
|
192
192
|
# HTTP settings
|
|
193
193
|
host: str = "127.0.0.1"
|
|
194
194
|
port: int = 8000
|
|
195
|
-
sse_path: str = "/sse"
|
|
195
|
+
sse_path: str = "/sse/"
|
|
196
196
|
message_path: str = "/messages/"
|
|
197
|
-
streamable_http_path: str = "/mcp"
|
|
197
|
+
streamable_http_path: str = "/mcp/"
|
|
198
198
|
debug: bool = False
|
|
199
199
|
|
|
200
200
|
# error handling
|
fastmcp/tools/tool.py
CHANGED
|
@@ -18,6 +18,7 @@ from fastmcp.utilities.json_schema import compress_schema
|
|
|
18
18
|
from fastmcp.utilities.logging import get_logger
|
|
19
19
|
from fastmcp.utilities.types import (
|
|
20
20
|
Audio,
|
|
21
|
+
File,
|
|
21
22
|
Image,
|
|
22
23
|
MCPContent,
|
|
23
24
|
find_kwarg_by_type,
|
|
@@ -231,7 +232,7 @@ class ParsedFunction:
|
|
|
231
232
|
|
|
232
233
|
# collect name and doc before we potentially modify the function
|
|
233
234
|
fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__
|
|
234
|
-
fn_doc = fn
|
|
235
|
+
fn_doc = inspect.getdoc(fn)
|
|
235
236
|
|
|
236
237
|
# if the fn is a callable class, we need to get the __call__ method from here out
|
|
237
238
|
if not inspect.isroutine(fn):
|
|
@@ -277,6 +278,9 @@ def _convert_to_content(
|
|
|
277
278
|
elif isinstance(result, Audio):
|
|
278
279
|
return [result.to_audio_content()]
|
|
279
280
|
|
|
281
|
+
elif isinstance(result, File):
|
|
282
|
+
return [result.to_resource_content()]
|
|
283
|
+
|
|
280
284
|
if isinstance(result, list | tuple) and not _process_as_single_item:
|
|
281
285
|
# if the result is a list, then it could either be a list of MCP types,
|
|
282
286
|
# or a "regular" list that the tool is returning, or a mix of both.
|
|
@@ -288,7 +292,7 @@ def _convert_to_content(
|
|
|
288
292
|
other_content = []
|
|
289
293
|
|
|
290
294
|
for item in result:
|
|
291
|
-
if isinstance(item, MCPContent | Image | Audio):
|
|
295
|
+
if isinstance(item, MCPContent | Image | Audio | File):
|
|
292
296
|
mcp_types.append(_convert_to_content(item)[0])
|
|
293
297
|
else:
|
|
294
298
|
other_content.append(item)
|
fastmcp/tools/tool_manager.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
4
|
from collections.abc import Callable
|
|
@@ -14,7 +14,7 @@ from fastmcp.utilities.logging import get_logger
|
|
|
14
14
|
from fastmcp.utilities.types import MCPContent
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
|
-
|
|
17
|
+
from fastmcp.server.server import MountedServer
|
|
18
18
|
|
|
19
19
|
logger = get_logger(__name__)
|
|
20
20
|
|
|
@@ -28,6 +28,7 @@ class ToolManager:
|
|
|
28
28
|
mask_error_details: bool | None = None,
|
|
29
29
|
):
|
|
30
30
|
self._tools: dict[str, Tool] = {}
|
|
31
|
+
self._mounted_servers: list[MountedServer] = []
|
|
31
32
|
self.mask_error_details = mask_error_details or settings.mask_error_details
|
|
32
33
|
|
|
33
34
|
# Default to "warn" if None is provided
|
|
@@ -42,23 +43,72 @@ class ToolManager:
|
|
|
42
43
|
|
|
43
44
|
self.duplicate_behavior = duplicate_behavior
|
|
44
45
|
|
|
45
|
-
def
|
|
46
|
+
def mount(self, server: MountedServer) -> None:
|
|
47
|
+
"""Adds a mounted server as a source for tools."""
|
|
48
|
+
self._mounted_servers.append(server)
|
|
49
|
+
|
|
50
|
+
async def _load_tools(self, *, via_server: bool = False) -> dict[str, Tool]:
|
|
51
|
+
"""
|
|
52
|
+
The single, consolidated recursive method for fetching tools. The 'via_server'
|
|
53
|
+
parameter determines the communication path.
|
|
54
|
+
|
|
55
|
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
|
56
|
+
- via_server=True: Server-to-server path for filtered MCP requests
|
|
57
|
+
"""
|
|
58
|
+
all_tools: dict[str, Tool] = {}
|
|
59
|
+
|
|
60
|
+
for mounted in self._mounted_servers:
|
|
61
|
+
try:
|
|
62
|
+
if via_server:
|
|
63
|
+
# Use the server-to-server filtered path
|
|
64
|
+
child_results = await mounted.server._list_tools()
|
|
65
|
+
else:
|
|
66
|
+
# Use the manager-to-manager unfiltered path
|
|
67
|
+
child_results = await mounted.server._tool_manager.list_tools()
|
|
68
|
+
|
|
69
|
+
# The combination logic is the same for both paths
|
|
70
|
+
child_dict = {t.key: t for t in child_results}
|
|
71
|
+
if mounted.prefix:
|
|
72
|
+
for tool in child_dict.values():
|
|
73
|
+
prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}")
|
|
74
|
+
all_tools[prefixed_tool.key] = prefixed_tool
|
|
75
|
+
else:
|
|
76
|
+
all_tools.update(child_dict)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
# Skip failed mounts silently, matches existing behavior
|
|
79
|
+
logger.warning(
|
|
80
|
+
f"Failed to get tools from mounted server '{mounted.prefix}': {e}"
|
|
81
|
+
)
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Finally, add local tools, which always take precedence
|
|
85
|
+
all_tools.update(self._tools)
|
|
86
|
+
return all_tools
|
|
87
|
+
|
|
88
|
+
async def has_tool(self, key: str) -> bool:
|
|
46
89
|
"""Check if a tool exists."""
|
|
47
|
-
|
|
90
|
+
tools = await self.get_tools()
|
|
91
|
+
return key in tools
|
|
48
92
|
|
|
49
|
-
def get_tool(self, key: str) -> Tool:
|
|
93
|
+
async def get_tool(self, key: str) -> Tool:
|
|
50
94
|
"""Get tool by key."""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
tools = await self.get_tools()
|
|
96
|
+
if key in tools:
|
|
97
|
+
return tools[key]
|
|
98
|
+
raise NotFoundError(f"Tool {key!r} not found")
|
|
54
99
|
|
|
55
|
-
def get_tools(self) -> dict[str, Tool]:
|
|
56
|
-
"""
|
|
57
|
-
|
|
100
|
+
async def get_tools(self) -> dict[str, Tool]:
|
|
101
|
+
"""
|
|
102
|
+
Gets the complete, unfiltered inventory of all tools.
|
|
103
|
+
"""
|
|
104
|
+
return await self._load_tools(via_server=False)
|
|
58
105
|
|
|
59
|
-
def list_tools(self) -> list[Tool]:
|
|
60
|
-
"""
|
|
61
|
-
|
|
106
|
+
async def list_tools(self) -> list[Tool]:
|
|
107
|
+
"""
|
|
108
|
+
Lists all tools, applying protocol filtering.
|
|
109
|
+
"""
|
|
110
|
+
tools_dict = await self._load_tools(via_server=True)
|
|
111
|
+
return list(tools_dict.values())
|
|
62
112
|
|
|
63
113
|
def add_tool_from_fn(
|
|
64
114
|
self,
|
|
@@ -89,22 +139,21 @@ class ToolManager:
|
|
|
89
139
|
)
|
|
90
140
|
return self.add_tool(tool)
|
|
91
141
|
|
|
92
|
-
def add_tool(self, tool: Tool
|
|
142
|
+
def add_tool(self, tool: Tool) -> Tool:
|
|
93
143
|
"""Register a tool with the server."""
|
|
94
|
-
|
|
95
|
-
existing = self._tools.get(key)
|
|
144
|
+
existing = self._tools.get(tool.key)
|
|
96
145
|
if existing:
|
|
97
146
|
if self.duplicate_behavior == "warn":
|
|
98
|
-
logger.warning(f"Tool already exists: {key}")
|
|
99
|
-
self._tools[key] = tool
|
|
147
|
+
logger.warning(f"Tool already exists: {tool.key}")
|
|
148
|
+
self._tools[tool.key] = tool
|
|
100
149
|
elif self.duplicate_behavior == "replace":
|
|
101
|
-
self._tools[key] = tool
|
|
150
|
+
self._tools[tool.key] = tool
|
|
102
151
|
elif self.duplicate_behavior == "error":
|
|
103
|
-
raise ValueError(f"Tool already exists: {key}")
|
|
152
|
+
raise ValueError(f"Tool already exists: {tool.key}")
|
|
104
153
|
elif self.duplicate_behavior == "ignore":
|
|
105
154
|
return existing
|
|
106
155
|
else:
|
|
107
|
-
self._tools[key] = tool
|
|
156
|
+
self._tools[tool.key] = tool
|
|
108
157
|
return tool
|
|
109
158
|
|
|
110
159
|
def remove_tool(self, key: str) -> None:
|
|
@@ -119,28 +168,48 @@ class ToolManager:
|
|
|
119
168
|
if key in self._tools:
|
|
120
169
|
del self._tools[key]
|
|
121
170
|
else:
|
|
122
|
-
raise NotFoundError(f"
|
|
171
|
+
raise NotFoundError(f"Tool {key!r} not found")
|
|
123
172
|
|
|
124
173
|
async def call_tool(self, key: str, arguments: dict[str, Any]) -> list[MCPContent]:
|
|
125
|
-
"""
|
|
126
|
-
tool
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
"""
|
|
175
|
+
Internal API for servers: Finds and calls a tool, respecting the
|
|
176
|
+
filtered protocol path.
|
|
177
|
+
"""
|
|
178
|
+
# 1. Check local tools first. The server will have already applied its filter.
|
|
179
|
+
if key in self._tools:
|
|
180
|
+
tool = await self.get_tool(key)
|
|
181
|
+
if not tool:
|
|
182
|
+
raise NotFoundError(f"Tool {key!r} not found")
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return await tool.run(arguments)
|
|
186
|
+
|
|
187
|
+
# raise ToolErrors as-is
|
|
188
|
+
except ToolError as e:
|
|
189
|
+
logger.exception(f"Error calling tool {key!r}: {e}")
|
|
190
|
+
raise e
|
|
191
|
+
|
|
192
|
+
# Handle other exceptions
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.exception(f"Error calling tool {key!r}: {e}")
|
|
195
|
+
if self.mask_error_details:
|
|
196
|
+
# Mask internal details
|
|
197
|
+
raise ToolError(f"Error calling tool {key!r}") from e
|
|
198
|
+
else:
|
|
199
|
+
# Include original error details
|
|
200
|
+
raise ToolError(f"Error calling tool {key!r}: {e}") from e
|
|
201
|
+
|
|
202
|
+
# 2. Check mounted servers using the filtered protocol path.
|
|
203
|
+
for mounted in reversed(self._mounted_servers):
|
|
204
|
+
tool_key = key
|
|
205
|
+
if mounted.prefix:
|
|
206
|
+
if key.startswith(f"{mounted.prefix}_"):
|
|
207
|
+
tool_key = key.removeprefix(f"{mounted.prefix}_")
|
|
208
|
+
else:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
return await mounted.server._call_tool(tool_key, arguments)
|
|
212
|
+
except NotFoundError:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
raise NotFoundError(f"Tool {key!r} not found.")
|
fastmcp/utilities/components.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from collections.abc import Sequence
|
|
2
|
-
from typing import Annotated, TypeVar
|
|
2
|
+
from typing import Annotated, Any, TypeVar
|
|
3
3
|
|
|
4
|
-
from pydantic import BeforeValidator, Field
|
|
4
|
+
from pydantic import BeforeValidator, Field, PrivateAttr
|
|
5
|
+
from typing_extensions import Self
|
|
5
6
|
|
|
6
7
|
from fastmcp.utilities.types import FastMCPBaseModel
|
|
7
8
|
|
|
@@ -37,6 +38,25 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
|
37
38
|
description="Whether the component is enabled.",
|
|
38
39
|
)
|
|
39
40
|
|
|
41
|
+
_key: str | None = PrivateAttr()
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, key: str | None = None, **kwargs: Any) -> None:
|
|
44
|
+
super().__init__(**kwargs)
|
|
45
|
+
self._key = key
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def key(self) -> str:
|
|
49
|
+
"""
|
|
50
|
+
The key of the component. This is used for internal bookkeeping
|
|
51
|
+
and may reflect e.g. prefixes or other identifiers. You should not depend on
|
|
52
|
+
keys having a certain value, as the same tool loaded from different
|
|
53
|
+
hierarchies of servers may have different keys.
|
|
54
|
+
"""
|
|
55
|
+
return self._key or self.name
|
|
56
|
+
|
|
57
|
+
def with_key(self, key: str) -> Self:
|
|
58
|
+
return self.model_copy(update={"_key": key})
|
|
59
|
+
|
|
40
60
|
def __eq__(self, other: object) -> bool:
|
|
41
61
|
if type(self) is not type(other):
|
|
42
62
|
return False
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Utilities for inspecting FastMCP instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP as FastMCP1x
|
|
10
|
+
|
|
11
|
+
import fastmcp
|
|
12
|
+
from fastmcp.server.server import FastMCP
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ToolInfo:
|
|
17
|
+
"""Information about a tool."""
|
|
18
|
+
|
|
19
|
+
key: str
|
|
20
|
+
name: str
|
|
21
|
+
description: str | None
|
|
22
|
+
input_schema: dict[str, Any]
|
|
23
|
+
annotations: dict[str, Any] | None = None
|
|
24
|
+
tags: list[str] | None = None
|
|
25
|
+
enabled: bool | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PromptInfo:
|
|
30
|
+
"""Information about a prompt."""
|
|
31
|
+
|
|
32
|
+
key: str
|
|
33
|
+
name: str
|
|
34
|
+
description: str | None
|
|
35
|
+
arguments: list[dict[str, Any]] | None = None
|
|
36
|
+
tags: list[str] | None = None
|
|
37
|
+
enabled: bool | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ResourceInfo:
|
|
42
|
+
"""Information about a resource."""
|
|
43
|
+
|
|
44
|
+
key: str
|
|
45
|
+
uri: str
|
|
46
|
+
name: str | None
|
|
47
|
+
description: str | None
|
|
48
|
+
mime_type: str | None = None
|
|
49
|
+
tags: list[str] | None = None
|
|
50
|
+
enabled: bool | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class TemplateInfo:
|
|
55
|
+
"""Information about a resource template."""
|
|
56
|
+
|
|
57
|
+
key: str
|
|
58
|
+
uri_template: str
|
|
59
|
+
name: str | None
|
|
60
|
+
description: str | None
|
|
61
|
+
mime_type: str | None = None
|
|
62
|
+
tags: list[str] | None = None
|
|
63
|
+
enabled: bool | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class FastMCPInfo:
|
|
68
|
+
"""Information extracted from a FastMCP instance."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
instructions: str | None
|
|
72
|
+
fastmcp_version: str
|
|
73
|
+
mcp_version: str
|
|
74
|
+
server_version: str
|
|
75
|
+
tools: list[ToolInfo]
|
|
76
|
+
prompts: list[PromptInfo]
|
|
77
|
+
resources: list[ResourceInfo]
|
|
78
|
+
templates: list[TemplateInfo]
|
|
79
|
+
capabilities: dict[str, Any]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|
83
|
+
"""Extract information from a FastMCP v2.x instance.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
mcp: The FastMCP v2.x instance to inspect
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
FastMCPInfo dataclass containing the extracted information
|
|
90
|
+
"""
|
|
91
|
+
# Get all the components using FastMCP2's direct methods
|
|
92
|
+
tools_dict = await mcp.get_tools()
|
|
93
|
+
prompts_dict = await mcp.get_prompts()
|
|
94
|
+
resources_dict = await mcp.get_resources()
|
|
95
|
+
templates_dict = await mcp.get_resource_templates()
|
|
96
|
+
|
|
97
|
+
# Extract detailed tool information
|
|
98
|
+
tool_infos = []
|
|
99
|
+
for key, tool in tools_dict.items():
|
|
100
|
+
# Convert to MCP tool to get input schema
|
|
101
|
+
mcp_tool = tool.to_mcp_tool(name=key)
|
|
102
|
+
tool_infos.append(
|
|
103
|
+
ToolInfo(
|
|
104
|
+
key=key,
|
|
105
|
+
name=tool.name or key,
|
|
106
|
+
description=tool.description,
|
|
107
|
+
input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
|
|
108
|
+
annotations=tool.annotations.model_dump() if tool.annotations else None,
|
|
109
|
+
tags=list(tool.tags) if tool.tags else None,
|
|
110
|
+
enabled=tool.enabled,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Extract detailed prompt information
|
|
115
|
+
prompt_infos = []
|
|
116
|
+
for key, prompt in prompts_dict.items():
|
|
117
|
+
prompt_infos.append(
|
|
118
|
+
PromptInfo(
|
|
119
|
+
key=key,
|
|
120
|
+
name=prompt.name or key,
|
|
121
|
+
description=prompt.description,
|
|
122
|
+
arguments=[arg.model_dump() for arg in prompt.arguments]
|
|
123
|
+
if prompt.arguments
|
|
124
|
+
else None,
|
|
125
|
+
tags=list(prompt.tags) if prompt.tags else None,
|
|
126
|
+
enabled=prompt.enabled,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Extract detailed resource information
|
|
131
|
+
resource_infos = []
|
|
132
|
+
for key, resource in resources_dict.items():
|
|
133
|
+
resource_infos.append(
|
|
134
|
+
ResourceInfo(
|
|
135
|
+
key=key,
|
|
136
|
+
uri=key, # For v2, key is the URI
|
|
137
|
+
name=resource.name,
|
|
138
|
+
description=resource.description,
|
|
139
|
+
mime_type=resource.mime_type,
|
|
140
|
+
tags=list(resource.tags) if resource.tags else None,
|
|
141
|
+
enabled=resource.enabled,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Extract detailed template information
|
|
146
|
+
template_infos = []
|
|
147
|
+
for key, template in templates_dict.items():
|
|
148
|
+
template_infos.append(
|
|
149
|
+
TemplateInfo(
|
|
150
|
+
key=key,
|
|
151
|
+
uri_template=key, # For v2, key is the URI template
|
|
152
|
+
name=template.name,
|
|
153
|
+
description=template.description,
|
|
154
|
+
mime_type=template.mime_type,
|
|
155
|
+
tags=list(template.tags) if template.tags else None,
|
|
156
|
+
enabled=template.enabled,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Basic MCP capabilities that FastMCP supports
|
|
161
|
+
capabilities = {
|
|
162
|
+
"tools": {"listChanged": True},
|
|
163
|
+
"resources": {"subscribe": False, "listChanged": False},
|
|
164
|
+
"prompts": {"listChanged": False},
|
|
165
|
+
"logging": {},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return FastMCPInfo(
|
|
169
|
+
name=mcp.name,
|
|
170
|
+
instructions=mcp.instructions,
|
|
171
|
+
fastmcp_version=fastmcp.__version__,
|
|
172
|
+
mcp_version=importlib.metadata.version("mcp"),
|
|
173
|
+
server_version=fastmcp.__version__, # v2.x uses FastMCP version
|
|
174
|
+
tools=tool_infos,
|
|
175
|
+
prompts=prompt_infos,
|
|
176
|
+
resources=resource_infos,
|
|
177
|
+
templates=template_infos,
|
|
178
|
+
capabilities=capabilities,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def inspect_fastmcp_v1(mcp: Any) -> FastMCPInfo:
|
|
183
|
+
"""Extract information from a FastMCP v1.x instance using a Client.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
mcp: The FastMCP v1.x instance to inspect
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
FastMCPInfo dataclass containing the extracted information
|
|
190
|
+
"""
|
|
191
|
+
from fastmcp import Client
|
|
192
|
+
|
|
193
|
+
# Use a client to interact with the FastMCP1x server
|
|
194
|
+
async with Client(mcp) as client:
|
|
195
|
+
# Get components via client calls (these return MCP objects)
|
|
196
|
+
mcp_tools = await client.list_tools()
|
|
197
|
+
mcp_prompts = await client.list_prompts()
|
|
198
|
+
mcp_resources = await client.list_resources()
|
|
199
|
+
|
|
200
|
+
# Try to get resource templates (FastMCP 1.x does have templates)
|
|
201
|
+
try:
|
|
202
|
+
mcp_templates = await client.list_resource_templates()
|
|
203
|
+
except Exception:
|
|
204
|
+
mcp_templates = []
|
|
205
|
+
|
|
206
|
+
# Extract detailed tool information from MCP Tool objects
|
|
207
|
+
tool_infos = []
|
|
208
|
+
for mcp_tool in mcp_tools:
|
|
209
|
+
# Extract annotations if they exist
|
|
210
|
+
annotations = None
|
|
211
|
+
if hasattr(mcp_tool, "annotations") and mcp_tool.annotations:
|
|
212
|
+
if hasattr(mcp_tool.annotations, "model_dump"):
|
|
213
|
+
annotations = mcp_tool.annotations.model_dump()
|
|
214
|
+
elif isinstance(mcp_tool.annotations, dict):
|
|
215
|
+
annotations = mcp_tool.annotations
|
|
216
|
+
else:
|
|
217
|
+
annotations = None
|
|
218
|
+
|
|
219
|
+
tool_infos.append(
|
|
220
|
+
ToolInfo(
|
|
221
|
+
key=mcp_tool.name, # For 1.x, key and name are the same
|
|
222
|
+
name=mcp_tool.name,
|
|
223
|
+
description=mcp_tool.description,
|
|
224
|
+
input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {},
|
|
225
|
+
annotations=annotations,
|
|
226
|
+
tags=None, # 1.x doesn't have tags
|
|
227
|
+
enabled=None, # 1.x doesn't have enabled field
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Extract detailed prompt information from MCP Prompt objects
|
|
232
|
+
prompt_infos = []
|
|
233
|
+
for mcp_prompt in mcp_prompts:
|
|
234
|
+
# Convert arguments if they exist
|
|
235
|
+
arguments = None
|
|
236
|
+
if hasattr(mcp_prompt, "arguments") and mcp_prompt.arguments:
|
|
237
|
+
arguments = [arg.model_dump() for arg in mcp_prompt.arguments]
|
|
238
|
+
|
|
239
|
+
prompt_infos.append(
|
|
240
|
+
PromptInfo(
|
|
241
|
+
key=mcp_prompt.name, # For 1.x, key and name are the same
|
|
242
|
+
name=mcp_prompt.name,
|
|
243
|
+
description=mcp_prompt.description,
|
|
244
|
+
arguments=arguments,
|
|
245
|
+
tags=None, # 1.x doesn't have tags
|
|
246
|
+
enabled=None, # 1.x doesn't have enabled field
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Extract detailed resource information from MCP Resource objects
|
|
251
|
+
resource_infos = []
|
|
252
|
+
for mcp_resource in mcp_resources:
|
|
253
|
+
resource_infos.append(
|
|
254
|
+
ResourceInfo(
|
|
255
|
+
key=str(mcp_resource.uri), # For 1.x, key and uri are the same
|
|
256
|
+
uri=str(mcp_resource.uri),
|
|
257
|
+
name=mcp_resource.name,
|
|
258
|
+
description=mcp_resource.description,
|
|
259
|
+
mime_type=mcp_resource.mimeType,
|
|
260
|
+
tags=None, # 1.x doesn't have tags
|
|
261
|
+
enabled=None, # 1.x doesn't have enabled field
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Extract detailed template information from MCP ResourceTemplate objects
|
|
266
|
+
template_infos = []
|
|
267
|
+
for mcp_template in mcp_templates:
|
|
268
|
+
template_infos.append(
|
|
269
|
+
TemplateInfo(
|
|
270
|
+
key=str(
|
|
271
|
+
mcp_template.uriTemplate
|
|
272
|
+
), # For 1.x, key and uriTemplate are the same
|
|
273
|
+
uri_template=str(mcp_template.uriTemplate),
|
|
274
|
+
name=mcp_template.name,
|
|
275
|
+
description=mcp_template.description,
|
|
276
|
+
mime_type=mcp_template.mimeType,
|
|
277
|
+
tags=None, # 1.x doesn't have tags
|
|
278
|
+
enabled=None, # 1.x doesn't have enabled field
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Basic MCP capabilities
|
|
283
|
+
capabilities = {
|
|
284
|
+
"tools": {"listChanged": True},
|
|
285
|
+
"resources": {"subscribe": False, "listChanged": False},
|
|
286
|
+
"prompts": {"listChanged": False},
|
|
287
|
+
"logging": {},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return FastMCPInfo(
|
|
291
|
+
name=mcp.name,
|
|
292
|
+
instructions=getattr(mcp, "instructions", None),
|
|
293
|
+
fastmcp_version=fastmcp.__version__, # Report current fastmcp version
|
|
294
|
+
mcp_version=importlib.metadata.version("mcp"),
|
|
295
|
+
server_version="1.0", # FastMCP 1.x version
|
|
296
|
+
tools=tool_infos,
|
|
297
|
+
prompts=prompt_infos,
|
|
298
|
+
resources=resource_infos,
|
|
299
|
+
templates=template_infos, # FastMCP1x does have templates
|
|
300
|
+
capabilities=capabilities,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _is_fastmcp_v1(mcp: Any) -> bool:
|
|
305
|
+
"""Check if the given instance is a FastMCP v1.x instance."""
|
|
306
|
+
|
|
307
|
+
# Check if it's an instance of FastMCP1x and not FastMCP2
|
|
308
|
+
return isinstance(mcp, FastMCP1x) and not isinstance(mcp, FastMCP)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def inspect_fastmcp(mcp: FastMCP[Any] | Any) -> FastMCPInfo:
|
|
312
|
+
"""Extract information from a FastMCP instance into a dataclass.
|
|
313
|
+
|
|
314
|
+
This function automatically detects whether the instance is FastMCP v1.x or v2.x
|
|
315
|
+
and uses the appropriate extraction method.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
mcp: The FastMCP instance to inspect (v1.x or v2.x)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
FastMCPInfo dataclass containing the extracted information
|
|
322
|
+
"""
|
|
323
|
+
if _is_fastmcp_v1(mcp):
|
|
324
|
+
return await inspect_fastmcp_v1(mcp)
|
|
325
|
+
else:
|
|
326
|
+
return await inspect_fastmcp_v2(mcp)
|