fastmcp 2.9.2__py3-none-any.whl → 2.10.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/client/auth/oauth.py +5 -82
- fastmcp/client/client.py +114 -24
- fastmcp/client/elicitation.py +63 -0
- fastmcp/client/transports.py +50 -36
- fastmcp/contrib/component_manager/README.md +170 -0
- fastmcp/contrib/component_manager/__init__.py +4 -0
- fastmcp/contrib/component_manager/component_manager.py +186 -0
- fastmcp/contrib/component_manager/component_service.py +225 -0
- fastmcp/contrib/component_manager/example.py +59 -0
- fastmcp/prompts/prompt.py +12 -4
- fastmcp/resources/resource.py +8 -3
- fastmcp/resources/template.py +5 -0
- fastmcp/server/auth/auth.py +15 -0
- fastmcp/server/auth/providers/bearer.py +41 -3
- fastmcp/server/auth/providers/bearer_env.py +4 -0
- fastmcp/server/auth/providers/in_memory.py +15 -0
- fastmcp/server/context.py +144 -4
- fastmcp/server/elicitation.py +160 -0
- fastmcp/server/http.py +1 -9
- fastmcp/server/low_level.py +4 -2
- fastmcp/server/middleware/__init__.py +14 -1
- fastmcp/server/middleware/logging.py +11 -0
- fastmcp/server/middleware/middleware.py +10 -6
- fastmcp/server/openapi.py +19 -77
- fastmcp/server/proxy.py +13 -6
- fastmcp/server/server.py +27 -7
- fastmcp/settings.py +0 -17
- fastmcp/tools/tool.py +209 -57
- fastmcp/tools/tool_manager.py +2 -3
- fastmcp/tools/tool_transform.py +125 -26
- fastmcp/utilities/components.py +5 -1
- fastmcp/utilities/json_schema_type.py +648 -0
- fastmcp/utilities/openapi.py +69 -0
- fastmcp/utilities/types.py +50 -19
- {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/METADATA +3 -2
- {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/RECORD +39 -31
- {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.9.2.dist-info → fastmcp-2.10.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
5
|
+
|
|
6
|
+
from mcp.server.elicitation import (
|
|
7
|
+
CancelledElicitation,
|
|
8
|
+
DeclinedElicitation,
|
|
9
|
+
)
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from fastmcp.utilities.json_schema import compress_schema
|
|
13
|
+
from fastmcp.utilities.logging import get_logger
|
|
14
|
+
from fastmcp.utilities.types import get_cached_typeadapter
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AcceptedElicitation",
|
|
18
|
+
"CancelledElicitation",
|
|
19
|
+
"DeclinedElicitation",
|
|
20
|
+
"get_elicitation_schema",
|
|
21
|
+
"ScalarElicitationType",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# we can't use the low-level AcceptedElicitation because it only works with BaseModels
|
|
30
|
+
class AcceptedElicitation(BaseModel, Generic[T]):
|
|
31
|
+
"""Result when user accepts the elicitation."""
|
|
32
|
+
|
|
33
|
+
action: Literal["accept"] = "accept"
|
|
34
|
+
data: T
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ScalarElicitationType(Generic[T]):
|
|
39
|
+
value: T
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
|
|
43
|
+
"""Get the schema for an elicitation response.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
response_type: The type of the response
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
schema = get_cached_typeadapter(response_type).json_schema()
|
|
50
|
+
schema = compress_schema(schema)
|
|
51
|
+
|
|
52
|
+
# Validate the schema to ensure it follows MCP elicitation requirements
|
|
53
|
+
validate_elicitation_json_schema(schema)
|
|
54
|
+
|
|
55
|
+
return schema
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
|
|
59
|
+
"""Validate that a JSON schema follows MCP elicitation requirements.
|
|
60
|
+
|
|
61
|
+
This ensures the schema is compatible with MCP elicitation requirements:
|
|
62
|
+
- Must be an object schema
|
|
63
|
+
- Must only contain primitive field types (string, number, integer, boolean)
|
|
64
|
+
- Must be flat (no nested objects or arrays of objects)
|
|
65
|
+
- Allows const fields (for Literal types) and enum fields (for Enum types)
|
|
66
|
+
- Only primitive types and their nullable variants are allowed
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
schema: The JSON schema to validate
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TypeError: If the schema doesn't meet MCP elicitation requirements
|
|
73
|
+
"""
|
|
74
|
+
ALLOWED_TYPES = {"string", "number", "integer", "boolean"}
|
|
75
|
+
|
|
76
|
+
# Check that the schema is an object
|
|
77
|
+
if schema.get("type") != "object":
|
|
78
|
+
raise TypeError(
|
|
79
|
+
f"Elicitation schema must be an object schema, got type '{schema.get('type')}'. "
|
|
80
|
+
"Elicitation schemas are limited to flat objects with primitive properties only."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
properties = schema.get("properties", {})
|
|
84
|
+
|
|
85
|
+
for prop_name, prop_schema in properties.items():
|
|
86
|
+
prop_type = prop_schema.get("type")
|
|
87
|
+
|
|
88
|
+
# Handle nullable types
|
|
89
|
+
if isinstance(prop_type, list):
|
|
90
|
+
if "null" in prop_type:
|
|
91
|
+
prop_type = [t for t in prop_type if t != "null"]
|
|
92
|
+
if len(prop_type) == 1:
|
|
93
|
+
prop_type = prop_type[0]
|
|
94
|
+
elif prop_schema.get("nullable", False):
|
|
95
|
+
continue # Nullable with no other type is fine
|
|
96
|
+
|
|
97
|
+
# Handle const fields (Literal types)
|
|
98
|
+
if "const" in prop_schema:
|
|
99
|
+
continue # const fields are allowed regardless of type
|
|
100
|
+
|
|
101
|
+
# Handle enum fields (Enum types)
|
|
102
|
+
if "enum" in prop_schema:
|
|
103
|
+
continue # enum fields are allowed regardless of type
|
|
104
|
+
|
|
105
|
+
# Handle references to definitions (like Enum types)
|
|
106
|
+
if "$ref" in prop_schema:
|
|
107
|
+
# Get the referenced definition
|
|
108
|
+
ref_path = prop_schema["$ref"]
|
|
109
|
+
if ref_path.startswith("#/$defs/"):
|
|
110
|
+
def_name = ref_path[8:] # Remove "#/$defs/" prefix
|
|
111
|
+
ref_def = schema.get("$defs", {}).get(def_name, {})
|
|
112
|
+
# If the referenced definition has an enum, it's allowed
|
|
113
|
+
if "enum" in ref_def:
|
|
114
|
+
continue
|
|
115
|
+
# If the referenced definition has a type that's allowed, it's allowed
|
|
116
|
+
ref_type = ref_def.get("type")
|
|
117
|
+
if ref_type in ALLOWED_TYPES:
|
|
118
|
+
continue
|
|
119
|
+
# If we can't determine what the ref points to, reject it for safety
|
|
120
|
+
raise TypeError(
|
|
121
|
+
f"Elicitation schema field '{prop_name}' contains a reference '{ref_path}' "
|
|
122
|
+
"that could not be validated. Only references to enum types or primitive types are allowed."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Handle union types (oneOf/anyOf)
|
|
126
|
+
if "oneOf" in prop_schema or "anyOf" in prop_schema:
|
|
127
|
+
union_schemas = prop_schema.get("oneOf", []) + prop_schema.get("anyOf", [])
|
|
128
|
+
for union_schema in union_schemas:
|
|
129
|
+
# Allow const and enum in unions
|
|
130
|
+
if "const" in union_schema or "enum" in union_schema:
|
|
131
|
+
continue
|
|
132
|
+
union_type = union_schema.get("type")
|
|
133
|
+
if union_type not in ALLOWED_TYPES:
|
|
134
|
+
raise TypeError(
|
|
135
|
+
f"Elicitation schema field '{prop_name}' has union type '{union_type}' which is not "
|
|
136
|
+
f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Check if it's a primitive type
|
|
141
|
+
if prop_type not in ALLOWED_TYPES:
|
|
142
|
+
raise TypeError(
|
|
143
|
+
f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not "
|
|
144
|
+
f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Check for nested objects or arrays of objects (not allowed)
|
|
148
|
+
if prop_type == "object":
|
|
149
|
+
raise TypeError(
|
|
150
|
+
f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. "
|
|
151
|
+
"Elicitation schemas must be flat objects with primitive properties only."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if prop_type == "array":
|
|
155
|
+
items_schema = prop_schema.get("items", {})
|
|
156
|
+
if items_schema.get("type") == "object":
|
|
157
|
+
raise TypeError(
|
|
158
|
+
f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. "
|
|
159
|
+
"Elicitation schemas must be flat objects with primitive properties only."
|
|
160
|
+
)
|
fastmcp/server/http.py
CHANGED
|
@@ -87,7 +87,7 @@ def setup_auth_middleware_and_routes(
|
|
|
87
87
|
middleware = [
|
|
88
88
|
Middleware(
|
|
89
89
|
AuthenticationMiddleware,
|
|
90
|
-
backend=BearerAuthBackend(
|
|
90
|
+
backend=BearerAuthBackend(auth),
|
|
91
91
|
),
|
|
92
92
|
Middleware(AuthContextMiddleware),
|
|
93
93
|
]
|
|
@@ -158,10 +158,6 @@ def create_sse_app(
|
|
|
158
158
|
A Starlette application with RequestContextMiddleware
|
|
159
159
|
"""
|
|
160
160
|
|
|
161
|
-
# Ensure the message_path ends with a trailing slash to avoid automatic redirects
|
|
162
|
-
if not message_path.endswith("/"):
|
|
163
|
-
message_path = message_path + "/"
|
|
164
|
-
|
|
165
161
|
server_routes: list[BaseRoute] = []
|
|
166
162
|
server_middleware: list[Middleware] = []
|
|
167
163
|
|
|
@@ -309,10 +305,6 @@ def create_streamable_http_app(
|
|
|
309
305
|
# Re-raise other RuntimeErrors if they don't match the specific message
|
|
310
306
|
raise
|
|
311
307
|
|
|
312
|
-
# Ensure the streamable_http_path ends with a trailing slash to avoid automatic redirects
|
|
313
|
-
if not streamable_http_path.endswith("/"):
|
|
314
|
-
streamable_http_path = streamable_http_path + "/"
|
|
315
|
-
|
|
316
308
|
# Add StreamableHTTP routes with or without auth
|
|
317
309
|
if auth:
|
|
318
310
|
auth_middleware, auth_routes, required_scopes = (
|
fastmcp/server/low_level.py
CHANGED
|
@@ -4,12 +4,14 @@ from mcp.server.lowlevel.server import (
|
|
|
4
4
|
LifespanResultT,
|
|
5
5
|
NotificationOptions,
|
|
6
6
|
RequestT,
|
|
7
|
-
|
|
7
|
+
)
|
|
8
|
+
from mcp.server.lowlevel.server import (
|
|
9
|
+
Server as _Server,
|
|
8
10
|
)
|
|
9
11
|
from mcp.server.models import InitializationOptions
|
|
10
12
|
|
|
11
13
|
|
|
12
|
-
class LowLevelServer(
|
|
14
|
+
class LowLevelServer(_Server[LifespanResultT, RequestT]):
|
|
13
15
|
def __init__(self, *args, **kwargs):
|
|
14
16
|
super().__init__(*args, **kwargs)
|
|
15
17
|
# FastMCP servers support notifications for all components
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
from .middleware import
|
|
1
|
+
from .middleware import (
|
|
2
|
+
Middleware,
|
|
3
|
+
MiddlewareContext,
|
|
4
|
+
CallNext,
|
|
5
|
+
ListToolsResult,
|
|
6
|
+
ListResourcesResult,
|
|
7
|
+
ListResourceTemplatesResult,
|
|
8
|
+
ListPromptsResult,
|
|
9
|
+
)
|
|
2
10
|
|
|
3
11
|
__all__ = [
|
|
4
12
|
"Middleware",
|
|
5
13
|
"MiddlewareContext",
|
|
14
|
+
"CallNext",
|
|
15
|
+
"ListToolsResult",
|
|
16
|
+
"ListResourcesResult",
|
|
17
|
+
"ListResourceTemplatesResult",
|
|
18
|
+
"ListPromptsResult",
|
|
6
19
|
]
|
|
@@ -32,6 +32,7 @@ class LoggingMiddleware(Middleware):
|
|
|
32
32
|
log_level: int = logging.INFO,
|
|
33
33
|
include_payloads: bool = False,
|
|
34
34
|
max_payload_length: int = 1000,
|
|
35
|
+
methods: list[str] | None = None,
|
|
35
36
|
):
|
|
36
37
|
"""Initialize logging middleware.
|
|
37
38
|
|
|
@@ -40,11 +41,13 @@ class LoggingMiddleware(Middleware):
|
|
|
40
41
|
log_level: Log level for messages (default: INFO)
|
|
41
42
|
include_payloads: Whether to include message payloads in logs
|
|
42
43
|
max_payload_length: Maximum length of payload to log (prevents huge logs)
|
|
44
|
+
methods: List of methods to log. If None, logs all methods.
|
|
43
45
|
"""
|
|
44
46
|
self.logger = logger or logging.getLogger("fastmcp.requests")
|
|
45
47
|
self.log_level = log_level
|
|
46
48
|
self.include_payloads = include_payloads
|
|
47
49
|
self.max_payload_length = max_payload_length
|
|
50
|
+
self.methods = methods
|
|
48
51
|
|
|
49
52
|
def _format_message(self, context: MiddlewareContext) -> str:
|
|
50
53
|
"""Format a message for logging."""
|
|
@@ -68,6 +71,8 @@ class LoggingMiddleware(Middleware):
|
|
|
68
71
|
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
|
|
69
72
|
"""Log all messages."""
|
|
70
73
|
message_info = self._format_message(context)
|
|
74
|
+
if self.methods and context.method not in self.methods:
|
|
75
|
+
return await call_next(context)
|
|
71
76
|
|
|
72
77
|
self.logger.log(self.log_level, f"Processing message: {message_info}")
|
|
73
78
|
|
|
@@ -105,6 +110,7 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
105
110
|
logger: logging.Logger | None = None,
|
|
106
111
|
log_level: int = logging.INFO,
|
|
107
112
|
include_payloads: bool = False,
|
|
113
|
+
methods: list[str] | None = None,
|
|
108
114
|
):
|
|
109
115
|
"""Initialize structured logging middleware.
|
|
110
116
|
|
|
@@ -112,10 +118,12 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
112
118
|
logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
|
|
113
119
|
log_level: Log level for messages (default: INFO)
|
|
114
120
|
include_payloads: Whether to include message payloads in logs
|
|
121
|
+
methods: List of methods to log. If None, logs all methods.
|
|
115
122
|
"""
|
|
116
123
|
self.logger = logger or logging.getLogger("fastmcp.structured")
|
|
117
124
|
self.log_level = log_level
|
|
118
125
|
self.include_payloads = include_payloads
|
|
126
|
+
self.methods = methods
|
|
119
127
|
|
|
120
128
|
def _create_log_entry(
|
|
121
129
|
self, context: MiddlewareContext, event: str, **extra_fields
|
|
@@ -141,6 +149,9 @@ class StructuredLoggingMiddleware(Middleware):
|
|
|
141
149
|
async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
|
|
142
150
|
"""Log structured message information."""
|
|
143
151
|
start_entry = self._create_log_entry(context, "request_start")
|
|
152
|
+
if self.methods and context.method not in self.methods:
|
|
153
|
+
return await call_next(context)
|
|
154
|
+
|
|
144
155
|
self.logger.log(self.log_level, json.dumps(start_entry))
|
|
145
156
|
|
|
146
157
|
try:
|
|
@@ -25,6 +25,16 @@ from fastmcp.tools.tool import Tool
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from fastmcp.server.context import Context
|
|
27
27
|
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Middleware",
|
|
30
|
+
"MiddlewareContext",
|
|
31
|
+
"CallNext",
|
|
32
|
+
"ListToolsResult",
|
|
33
|
+
"ListResourcesResult",
|
|
34
|
+
"ListResourceTemplatesResult",
|
|
35
|
+
"ListPromptsResult",
|
|
36
|
+
]
|
|
37
|
+
|
|
28
38
|
logger = logging.getLogger(__name__)
|
|
29
39
|
|
|
30
40
|
|
|
@@ -52,12 +62,6 @@ ServerResultT = TypeVar(
|
|
|
52
62
|
)
|
|
53
63
|
|
|
54
64
|
|
|
55
|
-
@dataclass(kw_only=True)
|
|
56
|
-
class CallToolResult:
|
|
57
|
-
content: list[mt.Content]
|
|
58
|
-
isError: bool = False
|
|
59
|
-
|
|
60
|
-
|
|
61
65
|
@dataclass(kw_only=True)
|
|
62
66
|
class ListToolsResult:
|
|
63
67
|
tools: dict[str, Tool]
|
fastmcp/server/openapi.py
CHANGED
|
@@ -21,15 +21,15 @@ from fastmcp.exceptions import ToolError
|
|
|
21
21
|
from fastmcp.resources import Resource, ResourceTemplate
|
|
22
22
|
from fastmcp.server.dependencies import get_http_headers
|
|
23
23
|
from fastmcp.server.server import FastMCP
|
|
24
|
-
from fastmcp.tools.tool import Tool,
|
|
24
|
+
from fastmcp.tools.tool import Tool, ToolResult
|
|
25
25
|
from fastmcp.utilities import openapi
|
|
26
26
|
from fastmcp.utilities.logging import get_logger
|
|
27
27
|
from fastmcp.utilities.openapi import (
|
|
28
28
|
HTTPRoute,
|
|
29
29
|
_combine_schemas,
|
|
30
|
+
format_array_parameter,
|
|
30
31
|
format_description_with_responses,
|
|
31
32
|
)
|
|
32
|
-
from fastmcp.utilities.types import MCPContent
|
|
33
33
|
|
|
34
34
|
if TYPE_CHECKING:
|
|
35
35
|
from fastmcp.server import Context
|
|
@@ -255,7 +255,7 @@ class OpenAPITool(Tool):
|
|
|
255
255
|
"""Custom representation to prevent recursion errors when printing."""
|
|
256
256
|
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
|
257
257
|
|
|
258
|
-
async def run(self, arguments: dict[str, Any]) ->
|
|
258
|
+
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
259
259
|
"""Execute the HTTP request based on the route configuration."""
|
|
260
260
|
|
|
261
261
|
# Prepare URL
|
|
@@ -297,46 +297,10 @@ class OpenAPITool(Tool):
|
|
|
297
297
|
if is_array:
|
|
298
298
|
# Format array values as comma-separated string
|
|
299
299
|
# This follows the OpenAPI 'simple' style (default for path)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
304
|
-
# Handle simple array types
|
|
305
|
-
path = path.replace(
|
|
306
|
-
f"{{{param_name}}}", ",".join(str(v) for v in param_value)
|
|
307
|
-
)
|
|
308
|
-
else:
|
|
309
|
-
# Handle complex array types (containing objects/dicts)
|
|
310
|
-
try:
|
|
311
|
-
# Try to create a simple representation without Python syntax artifacts
|
|
312
|
-
formatted_parts = []
|
|
313
|
-
for item in param_value:
|
|
314
|
-
if isinstance(item, dict):
|
|
315
|
-
# For objects, serialize key-value pairs
|
|
316
|
-
item_parts = []
|
|
317
|
-
for k, v in item.items():
|
|
318
|
-
item_parts.append(f"{k}:{v}")
|
|
319
|
-
formatted_parts.append(".".join(item_parts))
|
|
320
|
-
else:
|
|
321
|
-
# Fallback for other complex types
|
|
322
|
-
formatted_parts.append(str(item))
|
|
323
|
-
|
|
324
|
-
# Join parts with commas
|
|
325
|
-
formatted_value = ",".join(formatted_parts)
|
|
326
|
-
path = path.replace(f"{{{param_name}}}", formatted_value)
|
|
327
|
-
except Exception as e:
|
|
328
|
-
logger.warning(
|
|
329
|
-
f"Failed to format complex array path parameter '{param_name}': {e}"
|
|
330
|
-
)
|
|
331
|
-
# Fallback to string representation, but remove Python syntax artifacts
|
|
332
|
-
str_value = (
|
|
333
|
-
str(param_value)
|
|
334
|
-
.replace("[", "")
|
|
335
|
-
.replace("]", "")
|
|
336
|
-
.replace("'", "")
|
|
337
|
-
.replace('"', "")
|
|
338
|
-
)
|
|
339
|
-
path = path.replace(f"{{{param_name}}}", str_value)
|
|
300
|
+
formatted_value = format_array_parameter(
|
|
301
|
+
param_value, param_name, is_query_parameter=False
|
|
302
|
+
)
|
|
303
|
+
path = path.replace(f"{{{param_name}}}", str(formatted_value))
|
|
340
304
|
continue
|
|
341
305
|
|
|
342
306
|
# Default handling for non-array parameters or non-array schemas
|
|
@@ -356,44 +320,21 @@ class OpenAPITool(Tool):
|
|
|
356
320
|
# Format array query parameters as comma-separated strings
|
|
357
321
|
# following OpenAPI form style (default for query parameters)
|
|
358
322
|
if isinstance(param_value, list) and p.schema_.get("type") == "array":
|
|
359
|
-
# Get explode parameter from
|
|
323
|
+
# Get explode parameter from the parameter info, default is True for query parameters
|
|
360
324
|
# If explode is True, the array is serialized as separate parameters
|
|
361
325
|
# If explode is False, the array is serialized as a comma-separated string
|
|
362
|
-
explode = p.
|
|
326
|
+
explode = p.explode if p.explode is not None else True
|
|
363
327
|
|
|
364
328
|
if explode:
|
|
365
329
|
# When explode=True, we pass the array directly, which HTTPX will serialize
|
|
366
330
|
# as multiple parameters with the same name
|
|
367
331
|
query_params[p.name] = param_value
|
|
368
332
|
else:
|
|
369
|
-
#
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
query_params[p.name] = ",".join(str(v) for v in param_value)
|
|
375
|
-
else:
|
|
376
|
-
# For complex types, try to create a simpler representation
|
|
377
|
-
try:
|
|
378
|
-
# Try to create a simple string representation
|
|
379
|
-
formatted_parts = []
|
|
380
|
-
for item in param_value:
|
|
381
|
-
if isinstance(item, dict):
|
|
382
|
-
# For objects, serialize key-value pairs
|
|
383
|
-
item_parts = []
|
|
384
|
-
for k, v in item.items():
|
|
385
|
-
item_parts.append(f"{k}:{v}")
|
|
386
|
-
formatted_parts.append(".".join(item_parts))
|
|
387
|
-
else:
|
|
388
|
-
formatted_parts.append(str(item))
|
|
389
|
-
|
|
390
|
-
query_params[p.name] = ",".join(formatted_parts)
|
|
391
|
-
except Exception as e:
|
|
392
|
-
logger.warning(
|
|
393
|
-
f"Failed to format complex array query parameter '{p.name}': {e}"
|
|
394
|
-
)
|
|
395
|
-
# Fallback to string representation
|
|
396
|
-
query_params[p.name] = param_value
|
|
333
|
+
# Format array as comma-separated string when explode=False
|
|
334
|
+
formatted_value = format_array_parameter(
|
|
335
|
+
param_value, p.name, is_query_parameter=True
|
|
336
|
+
)
|
|
337
|
+
query_params[p.name] = formatted_value
|
|
397
338
|
else:
|
|
398
339
|
# Non-array parameters are passed as is
|
|
399
340
|
query_params[p.name] = param_value
|
|
@@ -451,10 +392,11 @@ class OpenAPITool(Tool):
|
|
|
451
392
|
# Try to parse as JSON first
|
|
452
393
|
try:
|
|
453
394
|
result = response.json()
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
395
|
+
if not isinstance(result, dict):
|
|
396
|
+
result = {"result": result}
|
|
397
|
+
return ToolResult(structured_content=result)
|
|
398
|
+
except json.JSONDecodeError:
|
|
399
|
+
return ToolResult(content=response.text)
|
|
458
400
|
|
|
459
401
|
except httpx.HTTPStatusError as e:
|
|
460
402
|
# Handle HTTP errors (4xx, 5xx)
|
fastmcp/server/proxy.py
CHANGED
|
@@ -22,10 +22,9 @@ from fastmcp.resources import Resource, ResourceTemplate
|
|
|
22
22
|
from fastmcp.resources.resource_manager import ResourceManager
|
|
23
23
|
from fastmcp.server.context import Context
|
|
24
24
|
from fastmcp.server.server import FastMCP
|
|
25
|
-
from fastmcp.tools.tool import Tool
|
|
25
|
+
from fastmcp.tools.tool import Tool, ToolResult
|
|
26
26
|
from fastmcp.tools.tool_manager import ToolManager
|
|
27
27
|
from fastmcp.utilities.logging import get_logger
|
|
28
|
-
from fastmcp.utilities.types import MCPContent
|
|
29
28
|
|
|
30
29
|
if TYPE_CHECKING:
|
|
31
30
|
from fastmcp.server import Context
|
|
@@ -67,7 +66,7 @@ class ProxyToolManager(ToolManager):
|
|
|
67
66
|
tools_dict = await self.get_tools()
|
|
68
67
|
return list(tools_dict.values())
|
|
69
68
|
|
|
70
|
-
async def call_tool(self, key: str, arguments: dict[str, Any]) ->
|
|
69
|
+
async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
|
|
71
70
|
"""Calls a tool, trying local/mounted first, then proxy if not found."""
|
|
72
71
|
try:
|
|
73
72
|
# First try local and mounted tools
|
|
@@ -75,7 +74,11 @@ class ProxyToolManager(ToolManager):
|
|
|
75
74
|
except NotFoundError:
|
|
76
75
|
# If not found locally, try proxy
|
|
77
76
|
async with self.client:
|
|
78
|
-
|
|
77
|
+
result = await self.client.call_tool(key, arguments)
|
|
78
|
+
return ToolResult(
|
|
79
|
+
content=result.content,
|
|
80
|
+
structured_content=result.structured_content,
|
|
81
|
+
)
|
|
79
82
|
|
|
80
83
|
|
|
81
84
|
class ProxyResourceManager(ResourceManager):
|
|
@@ -224,13 +227,14 @@ class ProxyTool(Tool):
|
|
|
224
227
|
description=mcp_tool.description,
|
|
225
228
|
parameters=mcp_tool.inputSchema,
|
|
226
229
|
annotations=mcp_tool.annotations,
|
|
230
|
+
output_schema=mcp_tool.outputSchema,
|
|
227
231
|
)
|
|
228
232
|
|
|
229
233
|
async def run(
|
|
230
234
|
self,
|
|
231
235
|
arguments: dict[str, Any],
|
|
232
236
|
context: Context | None = None,
|
|
233
|
-
) ->
|
|
237
|
+
) -> ToolResult:
|
|
234
238
|
"""Executes the tool by making a call through the client."""
|
|
235
239
|
# This is where the remote execution logic lives.
|
|
236
240
|
async with self._client:
|
|
@@ -240,7 +244,10 @@ class ProxyTool(Tool):
|
|
|
240
244
|
)
|
|
241
245
|
if result.isError:
|
|
242
246
|
raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
|
|
243
|
-
return
|
|
247
|
+
return ToolResult(
|
|
248
|
+
content=result.content,
|
|
249
|
+
structured_content=result.structuredContent,
|
|
250
|
+
)
|
|
244
251
|
|
|
245
252
|
|
|
246
253
|
class ProxyResource(Resource):
|