fastmcp 2.10.2__py3-none-any.whl → 2.10.3__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/mcp_config.py ADDED
@@ -0,0 +1,282 @@
1
+ """Canonical MCP Configuration Format.
2
+
3
+ This module defines the standard configuration format for Model Context Protocol (MCP) servers.
4
+ It provides a client-agnostic, extensible format that can be used across all MCP implementations.
5
+
6
+ The configuration format supports both stdio and remote (HTTP/SSE) transports, with comprehensive
7
+ field definitions for server metadata, authentication, and execution parameters.
8
+
9
+ Example configuration:
10
+ {
11
+ "mcpServers": {
12
+ "my-server": {
13
+ "command": "npx",
14
+ "args": ["-y", "@my/mcp-server"],
15
+ "env": {"API_KEY": "secret"},
16
+ "timeout": 30000,
17
+ "description": "My MCP server"
18
+ }
19
+ }
20
+ }
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime
26
+ import json
27
+ import re
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
30
+ from urllib.parse import urlparse
31
+
32
+ import httpx
33
+ from pydantic import AnyUrl, BaseModel, ConfigDict, Field
34
+
35
+ if TYPE_CHECKING:
36
+ from fastmcp.client.transports import (
37
+ SSETransport,
38
+ StdioTransport,
39
+ StreamableHttpTransport,
40
+ )
41
+
42
+
43
+ def infer_transport_type_from_url(
44
+ url: str | AnyUrl,
45
+ ) -> Literal["http", "sse"]:
46
+ """
47
+ Infer the appropriate transport type from the given URL.
48
+ """
49
+ url = str(url)
50
+ if not url.startswith("http"):
51
+ raise ValueError(f"Invalid URL: {url}")
52
+
53
+ parsed_url = urlparse(url)
54
+ path = parsed_url.path
55
+
56
+ # Match /sse followed by /, ?, &, or end of string
57
+ if re.search(r"/sse(/|\?|&|$)", path):
58
+ return "sse"
59
+ else:
60
+ return "http"
61
+
62
+
63
+ class StdioMCPServer(BaseModel):
64
+ """MCP server configuration for stdio transport.
65
+
66
+ This is the canonical configuration format for MCP servers using stdio transport.
67
+ """
68
+
69
+ # Required fields
70
+ command: str
71
+
72
+ # Common optional fields
73
+ args: list[str] = Field(default_factory=list)
74
+ env: dict[str, Any] = Field(default_factory=dict)
75
+
76
+ # Transport specification
77
+ transport: Literal["stdio"] = "stdio"
78
+ type: Literal["stdio"] | None = None # Alternative transport field name
79
+
80
+ # Execution context
81
+ cwd: str | None = None # Working directory for command execution
82
+ timeout: int | None = None # Maximum response time in milliseconds
83
+
84
+ # Metadata
85
+ description: str | None = None # Human-readable server description
86
+ icon: str | None = None # Icon path or URL for UI display
87
+
88
+ # Authentication configuration
89
+ authentication: dict[str, Any] | None = None # Auth configuration object
90
+
91
+ model_config = ConfigDict(extra="allow") # Preserve unknown fields
92
+
93
+ def to_transport(self) -> StdioTransport:
94
+ from fastmcp.client.transports import StdioTransport
95
+
96
+ return StdioTransport(
97
+ command=self.command,
98
+ args=self.args,
99
+ env=self.env,
100
+ cwd=self.cwd,
101
+ )
102
+
103
+
104
+ class RemoteMCPServer(BaseModel):
105
+ """MCP server configuration for HTTP/SSE transport.
106
+
107
+ This is the canonical configuration format for MCP servers using remote transports.
108
+ """
109
+
110
+ # Required fields
111
+ url: str
112
+
113
+ # Transport configuration
114
+ transport: Literal["http", "streamable-http", "sse"] | None = None
115
+ headers: dict[str, str] = Field(default_factory=dict)
116
+
117
+ # Authentication
118
+ auth: Annotated[
119
+ str | Literal["oauth"] | httpx.Auth | None,
120
+ Field(
121
+ description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.',
122
+ ),
123
+ ] = None
124
+
125
+ # Timeout configuration
126
+ sse_read_timeout: datetime.timedelta | int | float | None = None
127
+ timeout: int | None = None # Maximum response time in milliseconds
128
+
129
+ # Metadata
130
+ description: str | None = None # Human-readable server description
131
+ icon: str | None = None # Icon path or URL for UI display
132
+
133
+ # Authentication configuration
134
+ authentication: dict[str, Any] | None = None # Auth configuration object
135
+
136
+ model_config = ConfigDict(
137
+ extra="allow", arbitrary_types_allowed=True
138
+ ) # Preserve unknown fields
139
+
140
+ def to_transport(self) -> StreamableHttpTransport | SSETransport:
141
+ from fastmcp.client.transports import SSETransport, StreamableHttpTransport
142
+
143
+ if self.transport is None:
144
+ transport = infer_transport_type_from_url(self.url)
145
+ else:
146
+ transport = self.transport
147
+
148
+ if transport == "sse":
149
+ return SSETransport(
150
+ self.url,
151
+ headers=self.headers,
152
+ auth=self.auth,
153
+ sse_read_timeout=self.sse_read_timeout,
154
+ )
155
+ else:
156
+ # Both "http" and "streamable-http" map to StreamableHttpTransport
157
+ return StreamableHttpTransport(
158
+ self.url,
159
+ headers=self.headers,
160
+ auth=self.auth,
161
+ sse_read_timeout=self.sse_read_timeout,
162
+ )
163
+
164
+
165
+ class MCPConfig(BaseModel):
166
+ """Canonical MCP configuration format.
167
+
168
+ This defines the standard configuration format for Model Context Protocol servers.
169
+ The format is designed to be client-agnostic and extensible for future use cases.
170
+ """
171
+
172
+ mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
173
+
174
+ model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
175
+
176
+ @classmethod
177
+ def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
178
+ """Parse MCP configuration from dictionary format."""
179
+ # Handle case where config is just the mcpServers object
180
+ if "mcpServers" not in config and any(
181
+ isinstance(v, dict) and ("command" in v or "url" in v)
182
+ for v in config.values()
183
+ ):
184
+ # This looks like a bare mcpServers object
185
+ servers_dict = config
186
+ else:
187
+ # Standard format with mcpServers wrapper
188
+ servers_dict = config.get("mcpServers", {})
189
+
190
+ # Parse each server configuration
191
+ parsed_servers = {}
192
+ for name, server_config in servers_dict.items():
193
+ if not isinstance(server_config, dict):
194
+ continue
195
+
196
+ # Determine if this is stdio or remote based on fields
197
+ if "command" in server_config:
198
+ parsed_servers[name] = StdioMCPServer.model_validate(server_config)
199
+ elif "url" in server_config:
200
+ parsed_servers[name] = RemoteMCPServer.model_validate(server_config)
201
+ else:
202
+ # Skip invalid server configs but preserve them as raw dicts
203
+ # This allows for forward compatibility with unknown server types
204
+ continue
205
+
206
+ # Create config with any extra top-level fields preserved
207
+ config_data = {k: v for k, v in config.items() if k != "mcpServers"}
208
+ config_data["mcpServers"] = parsed_servers
209
+
210
+ return cls.model_validate(config_data)
211
+
212
+ def to_dict(self) -> dict[str, Any]:
213
+ """Convert MCPConfig to dictionary format, preserving all fields."""
214
+ # Start with all extra fields at the top level
215
+ result = self.model_dump(exclude={"mcpServers"}, exclude_none=True)
216
+
217
+ # Add mcpServers with all fields preserved
218
+ result["mcpServers"] = {
219
+ name: server.model_dump(exclude_none=True)
220
+ for name, server in self.mcpServers.items()
221
+ }
222
+
223
+ return result
224
+
225
+ def write_to_file(self, file_path: Path) -> None:
226
+ """Write configuration to JSON file."""
227
+ file_path.parent.mkdir(parents=True, exist_ok=True)
228
+ with open(file_path, "w") as f:
229
+ json.dump(self.to_dict(), f, indent=2)
230
+
231
+ @classmethod
232
+ def from_file(cls, file_path: Path) -> MCPConfig:
233
+ """Load configuration from JSON file."""
234
+ if not file_path.exists():
235
+ return cls(mcpServers={})
236
+ with open(file_path) as f:
237
+ content = f.read().strip()
238
+ if not content:
239
+ return cls(mcpServers={})
240
+ data = json.loads(content)
241
+ return cls.from_dict(data)
242
+
243
+ def add_server(self, name: str, server: StdioMCPServer | RemoteMCPServer) -> None:
244
+ """Add or update a server in the configuration."""
245
+ self.mcpServers[name] = server
246
+
247
+ def remove_server(self, name: str) -> None:
248
+ """Remove a server from the configuration."""
249
+ if name in self.mcpServers:
250
+ del self.mcpServers[name]
251
+
252
+
253
+ def update_config_file(
254
+ file_path: Path,
255
+ server_name: str,
256
+ server_config: StdioMCPServer | RemoteMCPServer,
257
+ ) -> None:
258
+ """Update MCP configuration file with new server, preserving existing fields."""
259
+ config = MCPConfig.from_file(file_path)
260
+
261
+ # If updating an existing server, merge with existing configuration
262
+ # to preserve any unknown fields
263
+ if server_name in config.mcpServers:
264
+ existing_server = config.mcpServers[server_name]
265
+ # Get the raw dict representation of both servers
266
+ existing_dict = existing_server.model_dump()
267
+ new_dict = server_config.model_dump(exclude_none=True)
268
+
269
+ # Merge, with new values taking precedence
270
+ merged_dict = {**existing_dict, **new_dict}
271
+
272
+ # Create new server instance with merged data
273
+ if "command" in merged_dict:
274
+ merged_server = StdioMCPServer.model_validate(merged_dict)
275
+ else:
276
+ merged_server = RemoteMCPServer.model_validate(merged_dict)
277
+
278
+ config.add_server(server_name, merged_server)
279
+ else:
280
+ config.add_server(server_name, server_config)
281
+
282
+ config.write_to_file(file_path)
fastmcp/prompts/prompt.py CHANGED
@@ -329,7 +329,7 @@ class FunctionPrompt(Prompt):
329
329
 
330
330
  # Call function and check if result is a coroutine
331
331
  result = self.fn(**kwargs)
332
- if inspect.iscoroutine(result):
332
+ if inspect.isawaitable(result):
333
333
  result = await result
334
334
 
335
335
  # Validate messages
@@ -350,9 +350,7 @@ class FunctionPrompt(Prompt):
350
350
  )
351
351
  )
352
352
  else:
353
- content = pydantic_core.to_json(
354
- msg, fallback=str, indent=2
355
- ).decode()
353
+ content = pydantic_core.to_json(msg, fallback=str).decode()
356
354
  messages.append(
357
355
  PromptMessage(
358
356
  role="user",
@@ -182,7 +182,7 @@ class FunctionResource(Resource):
182
182
  kwargs[context_kwarg] = get_context()
183
183
 
184
184
  result = self.fn(**kwargs)
185
- if inspect.iscoroutinefunction(self.fn):
185
+ if inspect.isawaitable(result):
186
186
  result = await result
187
187
 
188
188
  if isinstance(result, Resource):
@@ -192,4 +192,4 @@ class FunctionResource(Resource):
192
192
  elif isinstance(result, str):
193
193
  return result
194
194
  else:
195
- return pydantic_core.to_json(result, fallback=str, indent=2).decode()
195
+ return pydantic_core.to_json(result, fallback=str).decode()
@@ -190,7 +190,7 @@ class FunctionResourceTemplate(ResourceTemplate):
190
190
  kwargs[context_kwarg] = get_context()
191
191
 
192
192
  result = self.fn(**kwargs)
193
- if inspect.iscoroutine(result):
193
+ if inspect.isawaitable(result):
194
194
  result = await result
195
195
  return result
196
196
 
fastmcp/server/openapi.py CHANGED
@@ -27,6 +27,7 @@ from fastmcp.utilities.logging import get_logger
27
27
  from fastmcp.utilities.openapi import (
28
28
  HTTPRoute,
29
29
  _combine_schemas,
30
+ extract_output_schema_from_responses,
30
31
  format_array_parameter,
31
32
  format_description_with_responses,
32
33
  )
@@ -234,6 +235,7 @@ class OpenAPITool(Tool):
234
235
  name: str,
235
236
  description: str,
236
237
  parameters: dict[str, Any],
238
+ output_schema: dict[str, Any] | None = None,
237
239
  tags: set[str] | None = None,
238
240
  timeout: float | None = None,
239
241
  annotations: ToolAnnotations | None = None,
@@ -243,6 +245,7 @@ class OpenAPITool(Tool):
243
245
  name=name,
244
246
  description=description,
245
247
  parameters=parameters,
248
+ output_schema=output_schema,
246
249
  tags=tags or set(),
247
250
  annotations=annotations,
248
251
  serializer=serializer,
@@ -392,9 +395,22 @@ class OpenAPITool(Tool):
392
395
  # Try to parse as JSON first
393
396
  try:
394
397
  result = response.json()
395
- if not isinstance(result, dict):
396
- result = {"result": result}
397
- return ToolResult(structured_content=result)
398
+
399
+ # Handle structured content based on output schema, if any
400
+ structured_output = None
401
+ if self.output_schema is not None:
402
+ if self.output_schema.get("x-fastmcp-wrap-result"):
403
+ # Schema says wrap - always wrap in result key
404
+ structured_output = {"result": result}
405
+ else:
406
+ structured_output = result
407
+ # If no output schema, use fallback logic for backward compatibility
408
+ elif not isinstance(result, dict):
409
+ structured_output = {"result": result}
410
+ else:
411
+ structured_output = result
412
+
413
+ return ToolResult(structured_content=structured_output)
398
414
  except json.JSONDecodeError:
399
415
  return ToolResult(content=response.text)
400
416
 
@@ -787,6 +803,11 @@ class FastMCPOpenAPI(FastMCP):
787
803
  """Creates and registers an OpenAPITool with enhanced description."""
788
804
  combined_schema = _combine_schemas(route)
789
805
 
806
+ # Extract output schema from OpenAPI responses
807
+ output_schema = extract_output_schema_from_responses(
808
+ route.responses, route.schema_definitions
809
+ )
810
+
790
811
  # Get a unique tool name
791
812
  tool_name = self._get_unique_name(name, "tool")
792
813
 
@@ -810,6 +831,7 @@ class FastMCPOpenAPI(FastMCP):
810
831
  name=tool_name,
811
832
  description=enhanced_description,
812
833
  parameters=combined_schema,
834
+ output_schema=output_schema,
813
835
  tags=set(route.tags or []) | tags,
814
836
  timeout=self._timeout,
815
837
  )
@@ -825,10 +847,13 @@ class FastMCPOpenAPI(FastMCP):
825
847
  f"Using component as-is."
826
848
  )
827
849
 
850
+ # Use the potentially modified tool name as the registration key
851
+ final_tool_name = tool.name
852
+
828
853
  # Register the tool by directly assigning to the tools dictionary
829
- self._tool_manager._tools[tool_name] = tool
854
+ self._tool_manager._tools[final_tool_name] = tool
830
855
  logger.debug(
831
- f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
856
+ f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
832
857
  )
833
858
 
834
859
  def _create_openapi_resource(
@@ -875,10 +900,13 @@ class FastMCPOpenAPI(FastMCP):
875
900
  f"Using component as-is."
876
901
  )
877
902
 
903
+ # Use the potentially modified resource URI as the registration key
904
+ final_resource_uri = str(resource.uri)
905
+
878
906
  # Register the resource by directly assigning to the resources dictionary
879
- self._resource_manager._resources[str(resource.uri)] = resource
907
+ self._resource_manager._resources[final_resource_uri] = resource
880
908
  logger.debug(
881
- f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
909
+ f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
882
910
  )
883
911
 
884
912
  def _create_openapi_template(
@@ -954,8 +982,11 @@ class FastMCPOpenAPI(FastMCP):
954
982
  f"Using component as-is."
955
983
  )
956
984
 
985
+ # Use the potentially modified template URI as the registration key
986
+ final_template_uri = template.uri_template
987
+
957
988
  # Register the template by directly assigning to the templates dictionary
958
- self._resource_manager._templates[uri_template_str] = template
989
+ self._resource_manager._templates[final_template_uri] = template
959
990
  logger.debug(
960
- f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
991
+ f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
961
992
  )