fastmcp 2.8.1__py3-none-any.whl → 2.9.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/cli/cli.py +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +23 -7
- fastmcp/client/logging.py +1 -2
- fastmcp/client/messages.py +126 -0
- 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 +109 -13
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +27 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +44 -2
- fastmcp/server/auth/providers/bearer.py +62 -13
- fastmcp/server/context.py +113 -10
- fastmcp/server/http.py +8 -0
- fastmcp/server/low_level.py +35 -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 +446 -280
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +22 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/tools/tool_transform.py +42 -16
- 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 +75 -5
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
- fastmcp-2.9.1.dist-info/RECORD +78 -0
- fastmcp-2.8.1.dist-info/RECORD +0 -69
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|
fastmcp/utilities/json_schema.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
|
+
from collections import defaultdict
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
def _prune_param(schema: dict, param: str) -> dict:
|
|
@@ -24,25 +25,77 @@ def _prune_param(schema: dict, param: str) -> dict:
|
|
|
24
25
|
return schema
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
def _prune_unused_defs(schema: dict) -> dict:
|
|
29
|
+
"""Walk the schema and prune unused defs."""
|
|
30
|
+
|
|
31
|
+
root_defs: set[str] = set()
|
|
32
|
+
referenced_by: defaultdict[str, list] = defaultdict(list)
|
|
33
|
+
|
|
34
|
+
defs = schema.get("$defs")
|
|
35
|
+
if defs is None:
|
|
36
|
+
return schema
|
|
37
|
+
|
|
38
|
+
def walk(
|
|
39
|
+
node: object, current_def: str | None = None, skip_defs: bool = False
|
|
40
|
+
) -> None:
|
|
41
|
+
if isinstance(node, dict):
|
|
42
|
+
# Process $ref for definition tracking
|
|
43
|
+
ref = node.get("$ref")
|
|
44
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
45
|
+
def_name = ref.split("/")[-1]
|
|
46
|
+
if current_def:
|
|
47
|
+
referenced_by[def_name].append(current_def)
|
|
48
|
+
else:
|
|
49
|
+
root_defs.add(def_name)
|
|
50
|
+
|
|
51
|
+
# Walk children
|
|
52
|
+
for k, v in node.items():
|
|
53
|
+
if skip_defs and k == "$defs":
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
walk(v, current_def=current_def)
|
|
57
|
+
|
|
58
|
+
elif isinstance(node, list):
|
|
59
|
+
for v in node:
|
|
60
|
+
walk(v)
|
|
61
|
+
|
|
62
|
+
# Traverse the schema once, skipping the $defs
|
|
63
|
+
walk(schema, skip_defs=True)
|
|
64
|
+
|
|
65
|
+
# Now figure out what defs reference other defs
|
|
66
|
+
for def_name, value in defs.items():
|
|
67
|
+
walk(value, current_def=def_name)
|
|
68
|
+
|
|
69
|
+
# Figure out what defs were referenced directly or recursively
|
|
70
|
+
def def_is_referenced(def_name):
|
|
71
|
+
if def_name in root_defs:
|
|
72
|
+
return True
|
|
73
|
+
references = referenced_by.get(def_name)
|
|
74
|
+
if references:
|
|
75
|
+
for reference in references:
|
|
76
|
+
if def_is_referenced(reference):
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Remove orphaned definitions if requested
|
|
81
|
+
for def_name in list(defs):
|
|
82
|
+
if not def_is_referenced(def_name):
|
|
83
|
+
defs.pop(def_name)
|
|
84
|
+
if not defs:
|
|
85
|
+
schema.pop("$defs", None)
|
|
86
|
+
|
|
87
|
+
return schema
|
|
88
|
+
|
|
89
|
+
|
|
27
90
|
def _walk_and_prune(
|
|
28
91
|
schema: dict,
|
|
29
|
-
prune_defs: bool = False,
|
|
30
92
|
prune_titles: bool = False,
|
|
31
93
|
prune_additional_properties: bool = False,
|
|
32
94
|
) -> dict:
|
|
33
|
-
"""Walk the schema and optionally prune titles
|
|
34
|
-
|
|
35
|
-
# Will only be used if prune_defs is True
|
|
36
|
-
used_defs: set[str] = set()
|
|
95
|
+
"""Walk the schema and optionally prune titles and additionalProperties: false."""
|
|
37
96
|
|
|
38
97
|
def walk(node: object) -> None:
|
|
39
98
|
if isinstance(node, dict):
|
|
40
|
-
# Process $ref for definition tracking
|
|
41
|
-
if prune_defs:
|
|
42
|
-
ref = node.get("$ref")
|
|
43
|
-
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
44
|
-
used_defs.add(ref.split("/")[-1])
|
|
45
|
-
|
|
46
99
|
# Remove title if requested
|
|
47
100
|
if prune_titles and "title" in node:
|
|
48
101
|
node.pop("title")
|
|
@@ -62,18 +115,8 @@ def _walk_and_prune(
|
|
|
62
115
|
for v in node:
|
|
63
116
|
walk(v)
|
|
64
117
|
|
|
65
|
-
# Traverse the schema once
|
|
66
118
|
walk(schema)
|
|
67
119
|
|
|
68
|
-
# Remove orphaned definitions if requested
|
|
69
|
-
if prune_defs:
|
|
70
|
-
defs = schema.get("$defs", {})
|
|
71
|
-
for def_name in list(defs):
|
|
72
|
-
if def_name not in used_defs:
|
|
73
|
-
defs.pop(def_name)
|
|
74
|
-
if not defs:
|
|
75
|
-
schema.pop("$defs", None)
|
|
76
|
-
|
|
77
120
|
return schema
|
|
78
121
|
|
|
79
122
|
|
|
@@ -109,12 +152,13 @@ def compress_schema(
|
|
|
109
152
|
schema = _prune_param(schema, param=param)
|
|
110
153
|
|
|
111
154
|
# Do a single walk to handle pruning operations
|
|
112
|
-
if
|
|
155
|
+
if prune_titles or prune_additional_properties:
|
|
113
156
|
schema = _walk_and_prune(
|
|
114
157
|
schema,
|
|
115
|
-
prune_defs=prune_defs,
|
|
116
158
|
prune_titles=prune_titles,
|
|
117
159
|
prune_additional_properties=prune_additional_properties,
|
|
118
160
|
)
|
|
161
|
+
if prune_defs:
|
|
162
|
+
schema = _prune_unused_defs(schema)
|
|
119
163
|
|
|
120
164
|
return schema
|
fastmcp/utilities/mcp_config.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
4
5
|
from urllib.parse import urlparse
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import AnyUrl, ConfigDict, Field
|
|
7
9
|
|
|
8
10
|
from fastmcp.utilities.types import FastMCPBaseModel
|
|
9
11
|
|
|
@@ -17,7 +19,7 @@ if TYPE_CHECKING:
|
|
|
17
19
|
|
|
18
20
|
def infer_transport_type_from_url(
|
|
19
21
|
url: str | AnyUrl,
|
|
20
|
-
) -> Literal["
|
|
22
|
+
) -> Literal["http", "sse"]:
|
|
21
23
|
"""
|
|
22
24
|
Infer the appropriate transport type from the given URL.
|
|
23
25
|
"""
|
|
@@ -28,10 +30,11 @@ def infer_transport_type_from_url(
|
|
|
28
30
|
parsed_url = urlparse(url)
|
|
29
31
|
path = parsed_url.path
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
# Match /sse followed by /, ?, &, or end of string
|
|
34
|
+
if re.search(r"/sse(/|\?|&|$)", path):
|
|
32
35
|
return "sse"
|
|
33
36
|
else:
|
|
34
|
-
return "
|
|
37
|
+
return "http"
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
class StdioMCPServer(FastMCPBaseModel):
|
|
@@ -55,14 +58,16 @@ class StdioMCPServer(FastMCPBaseModel):
|
|
|
55
58
|
class RemoteMCPServer(FastMCPBaseModel):
|
|
56
59
|
url: str
|
|
57
60
|
headers: dict[str, str] = Field(default_factory=dict)
|
|
58
|
-
transport: Literal["streamable-http", "sse"] | None = None
|
|
61
|
+
transport: Literal["http", "streamable-http", "sse"] | None = None
|
|
59
62
|
auth: Annotated[
|
|
60
|
-
str | Literal["oauth"] | None,
|
|
63
|
+
str | Literal["oauth"] | httpx.Auth | None,
|
|
61
64
|
Field(
|
|
62
|
-
description='Either a string representing a Bearer token
|
|
65
|
+
description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
|
|
63
66
|
),
|
|
64
67
|
] = None
|
|
65
68
|
|
|
69
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
70
|
+
|
|
66
71
|
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
|
67
72
|
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
68
73
|
|
|
@@ -74,6 +79,7 @@ class RemoteMCPServer(FastMCPBaseModel):
|
|
|
74
79
|
if transport == "sse":
|
|
75
80
|
return SSETransport(self.url, headers=self.headers, auth=self.auth)
|
|
76
81
|
else:
|
|
82
|
+
# Both "http" and "streamable-http" map to StreamableHttpTransport
|
|
77
83
|
return StreamableHttpTransport(
|
|
78
84
|
self.url, headers=self.headers, auth=self.auth
|
|
79
85
|
)
|
fastmcp/utilities/openapi.py
CHANGED
|
@@ -262,16 +262,24 @@ class OpenAPIParser(
|
|
|
262
262
|
|
|
263
263
|
if isinstance(resolved_schema, (self.schema_cls)):
|
|
264
264
|
# Convert schema to dictionary
|
|
265
|
-
|
|
265
|
+
result = resolved_schema.model_dump(
|
|
266
266
|
mode="json", by_alias=True, exclude_none=True
|
|
267
267
|
)
|
|
268
268
|
elif isinstance(resolved_schema, dict):
|
|
269
|
-
|
|
269
|
+
result = resolved_schema
|
|
270
270
|
else:
|
|
271
271
|
logger.warning(
|
|
272
272
|
f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
|
|
273
273
|
)
|
|
274
|
-
|
|
274
|
+
result = {}
|
|
275
|
+
|
|
276
|
+
return _replace_ref_with_defs(result)
|
|
277
|
+
except ValueError as e:
|
|
278
|
+
# Re-raise ValueError for external reference errors and other validation issues
|
|
279
|
+
if "External or non-local reference not supported" in str(e):
|
|
280
|
+
raise
|
|
281
|
+
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
|
282
|
+
return {}
|
|
275
283
|
except Exception as e:
|
|
276
284
|
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
|
277
285
|
return {}
|
|
@@ -300,11 +308,17 @@ class OpenAPIParser(
|
|
|
300
308
|
|
|
301
309
|
# Extract parameter info - handle both 3.0 and 3.1 parameter models
|
|
302
310
|
param_in = parameter.param_in # Both use param_in
|
|
303
|
-
|
|
311
|
+
# Handle enum or string parameter locations
|
|
312
|
+
from enum import Enum
|
|
313
|
+
|
|
314
|
+
param_in_str = (
|
|
315
|
+
param_in.value if isinstance(param_in, Enum) else param_in
|
|
316
|
+
)
|
|
317
|
+
param_location = self._convert_to_parameter_location(param_in_str)
|
|
304
318
|
param_schema_obj = parameter.param_schema # Both use param_schema
|
|
305
319
|
|
|
306
320
|
# Skip duplicate parameters (same name and location)
|
|
307
|
-
param_key = (parameter.name,
|
|
321
|
+
param_key = (parameter.name, param_in_str)
|
|
308
322
|
if param_key in seen_params:
|
|
309
323
|
continue
|
|
310
324
|
seen_params[param_key] = True
|
|
@@ -398,12 +412,30 @@ class OpenAPIParser(
|
|
|
398
412
|
request_body_info.content_schema[media_type_str] = (
|
|
399
413
|
schema_dict
|
|
400
414
|
)
|
|
415
|
+
except ValueError as e:
|
|
416
|
+
# Re-raise ValueError for external reference errors
|
|
417
|
+
if "External or non-local reference not supported" in str(
|
|
418
|
+
e
|
|
419
|
+
):
|
|
420
|
+
raise
|
|
421
|
+
logger.error(
|
|
422
|
+
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
|
423
|
+
)
|
|
401
424
|
except Exception as e:
|
|
402
425
|
logger.error(
|
|
403
426
|
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
|
404
427
|
)
|
|
405
428
|
|
|
406
429
|
return request_body_info
|
|
430
|
+
except ValueError as e:
|
|
431
|
+
# Re-raise ValueError for external reference errors
|
|
432
|
+
if "External or non-local reference not supported" in str(e):
|
|
433
|
+
raise
|
|
434
|
+
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
|
435
|
+
logger.error(
|
|
436
|
+
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
|
437
|
+
)
|
|
438
|
+
return None
|
|
407
439
|
except Exception as e:
|
|
408
440
|
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
|
409
441
|
logger.error(
|
|
@@ -447,6 +479,17 @@ class OpenAPIParser(
|
|
|
447
479
|
media_type_obj.media_type_schema
|
|
448
480
|
)
|
|
449
481
|
resp_info.content_schema[media_type_str] = schema_dict
|
|
482
|
+
except ValueError as e:
|
|
483
|
+
# Re-raise ValueError for external reference errors
|
|
484
|
+
if (
|
|
485
|
+
"External or non-local reference not supported"
|
|
486
|
+
in str(e)
|
|
487
|
+
):
|
|
488
|
+
raise
|
|
489
|
+
logger.error(
|
|
490
|
+
f"Failed to extract schema for media type '{media_type_str}' "
|
|
491
|
+
f"in response {status_code}: {e}"
|
|
492
|
+
)
|
|
450
493
|
except Exception as e:
|
|
451
494
|
logger.error(
|
|
452
495
|
f"Failed to extract schema for media type '{media_type_str}' "
|
|
@@ -454,6 +497,16 @@ class OpenAPIParser(
|
|
|
454
497
|
)
|
|
455
498
|
|
|
456
499
|
extracted_responses[str(status_code)] = resp_info
|
|
500
|
+
except ValueError as e:
|
|
501
|
+
# Re-raise ValueError for external reference errors
|
|
502
|
+
if "External or non-local reference not supported" in str(e):
|
|
503
|
+
raise
|
|
504
|
+
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
505
|
+
logger.error(
|
|
506
|
+
f"Failed to extract response for status code {status_code} "
|
|
507
|
+
f"from reference '{ref_name}': {e}",
|
|
508
|
+
exc_info=False,
|
|
509
|
+
)
|
|
457
510
|
except Exception as e:
|
|
458
511
|
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
459
512
|
logger.error(
|
|
@@ -554,6 +607,17 @@ class OpenAPIParser(
|
|
|
554
607
|
logger.info(
|
|
555
608
|
f"Successfully extracted route: {method_upper} {path_str}"
|
|
556
609
|
)
|
|
610
|
+
except ValueError as op_error:
|
|
611
|
+
# Re-raise ValueError for external reference errors
|
|
612
|
+
if "External or non-local reference not supported" in str(
|
|
613
|
+
op_error
|
|
614
|
+
):
|
|
615
|
+
raise
|
|
616
|
+
op_id = getattr(operation, "operationId", "unknown")
|
|
617
|
+
logger.error(
|
|
618
|
+
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
|
619
|
+
exc_info=True,
|
|
620
|
+
)
|
|
557
621
|
except Exception as op_error:
|
|
558
622
|
op_id = getattr(operation, "operationId", "unknown")
|
|
559
623
|
logger.error(
|
|
@@ -899,6 +963,12 @@ def _replace_ref_with_defs(
|
|
|
899
963
|
if ref_path.startswith("#/components/schemas/"):
|
|
900
964
|
schema_name = ref_path.split("/")[-1]
|
|
901
965
|
schema["$ref"] = f"#/$defs/{schema_name}"
|
|
966
|
+
elif not ref_path.startswith("#/"):
|
|
967
|
+
raise ValueError(
|
|
968
|
+
f"External or non-local reference not supported: {ref_path}. "
|
|
969
|
+
f"FastMCP only supports local schema references starting with '#/'. "
|
|
970
|
+
f"Please include all schema definitions within the OpenAPI document."
|
|
971
|
+
)
|
|
902
972
|
elif properties := schema.get("properties"):
|
|
903
973
|
if "$ref" in properties:
|
|
904
974
|
schema["properties"] = _replace_ref_with_defs(properties)
|
fastmcp/utilities/tests.py
CHANGED
|
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
|
|
|
20
20
|
@contextmanager
|
|
21
21
|
def temporary_settings(**kwargs: Any):
|
|
22
22
|
"""
|
|
23
|
-
Temporarily override
|
|
23
|
+
Temporarily override FastMCP setting values.
|
|
24
24
|
|
|
25
25
|
Args:
|
|
26
26
|
**kwargs: The settings to override, including nested settings.
|