fastmcp 2.11.2__py3-none-any.whl → 2.12.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +5 -4
- fastmcp/cli/claude.py +22 -18
- fastmcp/cli/cli.py +472 -136
- fastmcp/cli/install/claude_code.py +37 -40
- fastmcp/cli/install/claude_desktop.py +37 -42
- fastmcp/cli/install/cursor.py +148 -38
- fastmcp/cli/install/mcp_json.py +38 -43
- fastmcp/cli/install/shared.py +64 -7
- fastmcp/cli/run.py +122 -215
- fastmcp/client/auth/oauth.py +69 -13
- fastmcp/client/client.py +46 -9
- fastmcp/client/logging.py +25 -1
- fastmcp/client/oauth_callback.py +91 -91
- fastmcp/client/sampling.py +12 -4
- fastmcp/client/transports.py +143 -67
- fastmcp/experimental/sampling/__init__.py +0 -0
- fastmcp/experimental/sampling/handlers/__init__.py +3 -0
- fastmcp/experimental/sampling/handlers/base.py +21 -0
- fastmcp/experimental/sampling/handlers/openai.py +163 -0
- fastmcp/experimental/server/openapi/routing.py +1 -3
- fastmcp/experimental/server/openapi/server.py +10 -25
- fastmcp/experimental/utilities/openapi/__init__.py +2 -2
- fastmcp/experimental/utilities/openapi/formatters.py +34 -0
- fastmcp/experimental/utilities/openapi/models.py +5 -2
- fastmcp/experimental/utilities/openapi/parser.py +252 -70
- fastmcp/experimental/utilities/openapi/schemas.py +135 -106
- fastmcp/mcp_config.py +40 -20
- fastmcp/prompts/prompt_manager.py +4 -2
- fastmcp/resources/resource_manager.py +16 -6
- fastmcp/server/auth/__init__.py +11 -1
- fastmcp/server/auth/auth.py +19 -2
- fastmcp/server/auth/oauth_proxy.py +1047 -0
- fastmcp/server/auth/providers/azure.py +270 -0
- fastmcp/server/auth/providers/github.py +287 -0
- fastmcp/server/auth/providers/google.py +305 -0
- fastmcp/server/auth/providers/jwt.py +27 -16
- fastmcp/server/auth/providers/workos.py +256 -2
- fastmcp/server/auth/redirect_validation.py +65 -0
- fastmcp/server/auth/registry.py +1 -1
- fastmcp/server/context.py +91 -41
- fastmcp/server/dependencies.py +32 -2
- fastmcp/server/elicitation.py +60 -1
- fastmcp/server/http.py +44 -37
- fastmcp/server/middleware/logging.py +66 -28
- fastmcp/server/proxy.py +2 -0
- fastmcp/server/sampling/handler.py +19 -0
- fastmcp/server/server.py +85 -20
- fastmcp/settings.py +18 -3
- fastmcp/tools/tool.py +23 -10
- fastmcp/tools/tool_manager.py +5 -1
- fastmcp/tools/tool_transform.py +75 -32
- fastmcp/utilities/auth.py +34 -0
- fastmcp/utilities/cli.py +148 -15
- fastmcp/utilities/components.py +21 -5
- fastmcp/utilities/inspect.py +166 -37
- fastmcp/utilities/json_schema_type.py +4 -2
- fastmcp/utilities/logging.py +4 -1
- fastmcp/utilities/mcp_config.py +47 -18
- fastmcp/utilities/mcp_server_config/__init__.py +25 -0
- fastmcp/utilities/mcp_server_config/v1/__init__.py +0 -0
- fastmcp/utilities/mcp_server_config/v1/environments/__init__.py +6 -0
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +30 -0
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +306 -0
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +446 -0
- fastmcp/utilities/mcp_server_config/v1/schema.json +361 -0
- fastmcp/utilities/mcp_server_config/v1/sources/__init__.py +0 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +30 -0
- fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +216 -0
- fastmcp/utilities/openapi.py +4 -4
- fastmcp/utilities/tests.py +7 -2
- fastmcp/utilities/types.py +15 -2
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/METADATA +3 -2
- fastmcp-2.12.0rc1.dist-info/RECORD +129 -0
- fastmcp-2.11.2.dist-info/RECORD +0 -108
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/transports.py
CHANGED
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import shutil
|
|
7
7
|
import sys
|
|
8
8
|
import warnings
|
|
9
|
-
from collections.abc import AsyncIterator
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Literal, TypeVar, cast, overload
|
|
12
12
|
|
|
@@ -21,7 +21,11 @@ from mcp.client.session import (
|
|
|
21
21
|
MessageHandlerFnT,
|
|
22
22
|
SamplingFnT,
|
|
23
23
|
)
|
|
24
|
+
from mcp.client.sse import sse_client
|
|
25
|
+
from mcp.client.stdio import stdio_client
|
|
26
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
24
27
|
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
|
28
|
+
from mcp.shared._httpx_utils import McpHttpClientFactory
|
|
25
29
|
from mcp.shared.memory import create_client_server_memory_streams
|
|
26
30
|
from pydantic import AnyUrl
|
|
27
31
|
from typing_extensions import TypedDict, Unpack
|
|
@@ -33,6 +37,7 @@ from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url
|
|
|
33
37
|
from fastmcp.server.dependencies import get_http_headers
|
|
34
38
|
from fastmcp.server.server import FastMCP
|
|
35
39
|
from fastmcp.utilities.logging import get_logger
|
|
40
|
+
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
|
|
36
41
|
|
|
37
42
|
logger = get_logger(__name__)
|
|
38
43
|
|
|
@@ -161,7 +166,7 @@ class SSETransport(ClientTransport):
|
|
|
161
166
|
headers: dict[str, str] | None = None,
|
|
162
167
|
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
|
163
168
|
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
|
164
|
-
httpx_client_factory:
|
|
169
|
+
httpx_client_factory: McpHttpClientFactory | None = None,
|
|
165
170
|
):
|
|
166
171
|
if isinstance(url, AnyUrl):
|
|
167
172
|
url = str(url)
|
|
@@ -177,7 +182,7 @@ class SSETransport(ClientTransport):
|
|
|
177
182
|
self.httpx_client_factory = httpx_client_factory
|
|
178
183
|
|
|
179
184
|
if isinstance(sse_read_timeout, int | float):
|
|
180
|
-
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
|
185
|
+
sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
|
|
181
186
|
self.sse_read_timeout = sse_read_timeout
|
|
182
187
|
|
|
183
188
|
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
|
@@ -191,8 +196,6 @@ class SSETransport(ClientTransport):
|
|
|
191
196
|
async def connect_session(
|
|
192
197
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
193
198
|
) -> AsyncIterator[ClientSession]:
|
|
194
|
-
from mcp.client.sse import sse_client
|
|
195
|
-
|
|
196
199
|
client_kwargs: dict[str, Any] = {}
|
|
197
200
|
|
|
198
201
|
# load headers from an active HTTP request, if available. This will only be true
|
|
@@ -233,7 +236,7 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
233
236
|
headers: dict[str, str] | None = None,
|
|
234
237
|
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
|
235
238
|
sse_read_timeout: datetime.timedelta | float | int | None = None,
|
|
236
|
-
httpx_client_factory:
|
|
239
|
+
httpx_client_factory: McpHttpClientFactory | None = None,
|
|
237
240
|
):
|
|
238
241
|
if isinstance(url, AnyUrl):
|
|
239
242
|
url = str(url)
|
|
@@ -249,7 +252,7 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
249
252
|
self.httpx_client_factory = httpx_client_factory
|
|
250
253
|
|
|
251
254
|
if isinstance(sse_read_timeout, int | float):
|
|
252
|
-
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
|
255
|
+
sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
|
|
253
256
|
self.sse_read_timeout = sse_read_timeout
|
|
254
257
|
|
|
255
258
|
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
|
@@ -263,8 +266,6 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
263
266
|
async def connect_session(
|
|
264
267
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
265
268
|
) -> AsyncIterator[ClientSession]:
|
|
266
|
-
from mcp.client.streamable_http import streamablehttp_client
|
|
267
|
-
|
|
268
269
|
client_kwargs: dict[str, Any] = {}
|
|
269
270
|
|
|
270
271
|
# load headers from an active HTTP request, if available. This will only be true
|
|
@@ -345,8 +346,7 @@ class StdioTransport(ClientTransport):
|
|
|
345
346
|
) -> AsyncIterator[ClientSession]:
|
|
346
347
|
try:
|
|
347
348
|
await self.connect(**session_kwargs)
|
|
348
|
-
|
|
349
|
-
yield self._session
|
|
349
|
+
yield cast(ClientSession, self._session)
|
|
350
350
|
finally:
|
|
351
351
|
if not self.keep_alive:
|
|
352
352
|
await self.disconnect()
|
|
@@ -359,42 +359,22 @@ class StdioTransport(ClientTransport):
|
|
|
359
359
|
if self._connect_task is not None:
|
|
360
360
|
return
|
|
361
361
|
|
|
362
|
-
|
|
363
|
-
from mcp.client.stdio import stdio_client
|
|
364
|
-
|
|
365
|
-
try:
|
|
366
|
-
async with contextlib.AsyncExitStack() as stack:
|
|
367
|
-
try:
|
|
368
|
-
server_params = StdioServerParameters(
|
|
369
|
-
command=self.command,
|
|
370
|
-
args=self.args,
|
|
371
|
-
env=self.env,
|
|
372
|
-
cwd=self.cwd,
|
|
373
|
-
)
|
|
374
|
-
transport = await stack.enter_async_context(
|
|
375
|
-
stdio_client(server_params)
|
|
376
|
-
)
|
|
377
|
-
read_stream, write_stream = transport
|
|
378
|
-
self._session = await stack.enter_async_context(
|
|
379
|
-
ClientSession(read_stream, write_stream, **session_kwargs)
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
logger.debug("Stdio transport connected")
|
|
383
|
-
self._ready_event.set()
|
|
384
|
-
|
|
385
|
-
# Wait until disconnect is requested (stop_event is set)
|
|
386
|
-
await self._stop_event.wait()
|
|
387
|
-
finally:
|
|
388
|
-
# Clean up client on exit
|
|
389
|
-
self._session = None
|
|
390
|
-
logger.debug("Stdio transport disconnected")
|
|
391
|
-
except Exception:
|
|
392
|
-
# Ensure ready event is set even if connection fails
|
|
393
|
-
self._ready_event.set()
|
|
394
|
-
raise
|
|
362
|
+
session_future: asyncio.Future[ClientSession] = asyncio.Future()
|
|
395
363
|
|
|
396
364
|
# start the connection task
|
|
397
|
-
self._connect_task = asyncio.create_task(
|
|
365
|
+
self._connect_task = asyncio.create_task(
|
|
366
|
+
_stdio_transport_connect_task(
|
|
367
|
+
command=self.command,
|
|
368
|
+
args=self.args,
|
|
369
|
+
env=self.env,
|
|
370
|
+
cwd=self.cwd,
|
|
371
|
+
session_kwargs=session_kwargs,
|
|
372
|
+
ready_event=self._ready_event,
|
|
373
|
+
stop_event=self._stop_event,
|
|
374
|
+
session_future=session_future,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
398
378
|
# wait for the client to be ready before returning
|
|
399
379
|
await self._ready_event.wait()
|
|
400
380
|
|
|
@@ -404,6 +384,9 @@ class StdioTransport(ClientTransport):
|
|
|
404
384
|
if exception is not None:
|
|
405
385
|
raise exception
|
|
406
386
|
|
|
387
|
+
self._session = await session_future
|
|
388
|
+
return self._session
|
|
389
|
+
|
|
407
390
|
async def disconnect(self):
|
|
408
391
|
if self._connect_task is None:
|
|
409
392
|
return
|
|
@@ -422,12 +405,61 @@ class StdioTransport(ClientTransport):
|
|
|
422
405
|
async def close(self):
|
|
423
406
|
await self.disconnect()
|
|
424
407
|
|
|
408
|
+
def __del__(self):
|
|
409
|
+
"""Ensure that we send a disconnection signal to the transport task if we are being garbage collected."""
|
|
410
|
+
if not self._stop_event.is_set():
|
|
411
|
+
self._stop_event.set()
|
|
412
|
+
|
|
425
413
|
def __repr__(self) -> str:
|
|
426
414
|
return (
|
|
427
415
|
f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>"
|
|
428
416
|
)
|
|
429
417
|
|
|
430
418
|
|
|
419
|
+
async def _stdio_transport_connect_task(
|
|
420
|
+
command: str,
|
|
421
|
+
args: list[str],
|
|
422
|
+
env: dict[str, str] | None,
|
|
423
|
+
cwd: str | None,
|
|
424
|
+
session_kwargs: SessionKwargs,
|
|
425
|
+
ready_event: anyio.Event,
|
|
426
|
+
stop_event: anyio.Event,
|
|
427
|
+
session_future: asyncio.Future[ClientSession],
|
|
428
|
+
):
|
|
429
|
+
"""A standalone connection task for a stdio transport. It is not a part of the StdioTransport class
|
|
430
|
+
to ensure that the connection task does not hold a reference to the Transport object."""
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
async with contextlib.AsyncExitStack() as stack:
|
|
434
|
+
try:
|
|
435
|
+
server_params = StdioServerParameters(
|
|
436
|
+
command=command,
|
|
437
|
+
args=args,
|
|
438
|
+
env=env,
|
|
439
|
+
cwd=cwd,
|
|
440
|
+
)
|
|
441
|
+
transport = await stack.enter_async_context(stdio_client(server_params))
|
|
442
|
+
read_stream, write_stream = transport
|
|
443
|
+
session_future.set_result(
|
|
444
|
+
await stack.enter_async_context(
|
|
445
|
+
ClientSession(read_stream, write_stream, **session_kwargs)
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
logger.debug("Stdio transport connected")
|
|
450
|
+
ready_event.set()
|
|
451
|
+
|
|
452
|
+
# Wait until disconnect is requested (stop_event is set)
|
|
453
|
+
await stop_event.wait()
|
|
454
|
+
finally:
|
|
455
|
+
# Clean up client on exit
|
|
456
|
+
logger.debug("Stdio transport disconnected")
|
|
457
|
+
except Exception:
|
|
458
|
+
# Ensure ready event is set even if connection fails
|
|
459
|
+
ready_event.set()
|
|
460
|
+
raise
|
|
461
|
+
|
|
462
|
+
|
|
431
463
|
class PythonStdioTransport(StdioTransport):
|
|
432
464
|
"""Transport for running Python scripts."""
|
|
433
465
|
|
|
@@ -564,16 +596,38 @@ class UvStdioTransport(StdioTransport):
|
|
|
564
596
|
f"Project directory not found: {project_directory}"
|
|
565
597
|
)
|
|
566
598
|
|
|
567
|
-
#
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
599
|
+
# Create Environment from provided parameters (internal use)
|
|
600
|
+
env_config = UVEnvironment(
|
|
601
|
+
python=python_version,
|
|
602
|
+
dependencies=with_packages,
|
|
603
|
+
requirements=with_requirements,
|
|
604
|
+
project=project_directory,
|
|
605
|
+
editable=None, # Not exposed in this transport
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Build uv arguments using the config
|
|
609
|
+
uv_args: list[str] = []
|
|
610
|
+
|
|
611
|
+
# Check if we need any environment setup
|
|
612
|
+
if env_config.needs_uv():
|
|
613
|
+
# Use the config to build args, but we need to handle the command differently
|
|
614
|
+
# since transport has specific needs
|
|
615
|
+
uv_args = ["run"]
|
|
616
|
+
|
|
617
|
+
if python_version:
|
|
618
|
+
uv_args.extend(["--python", python_version])
|
|
619
|
+
if project_directory:
|
|
620
|
+
uv_args.extend(["--directory", str(project_directory)])
|
|
621
|
+
|
|
622
|
+
# Note: Don't add fastmcp as dependency here, transport is for general use
|
|
623
|
+
for pkg in with_packages or []:
|
|
624
|
+
uv_args.extend(["--with", pkg])
|
|
625
|
+
if with_requirements:
|
|
626
|
+
uv_args.extend(["--with-requirements", str(with_requirements)])
|
|
627
|
+
else:
|
|
628
|
+
# No environment setup needed
|
|
629
|
+
uv_args = ["run"]
|
|
630
|
+
|
|
577
631
|
if module:
|
|
578
632
|
uv_args.append("--module")
|
|
579
633
|
|
|
@@ -829,12 +883,14 @@ class MCPConfigTransport(ClientTransport):
|
|
|
829
883
|
"""
|
|
830
884
|
|
|
831
885
|
def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True):
|
|
832
|
-
from fastmcp.utilities.mcp_config import
|
|
886
|
+
from fastmcp.utilities.mcp_config import mcp_config_to_servers_and_transports
|
|
833
887
|
|
|
834
888
|
if isinstance(config, dict):
|
|
835
889
|
config = MCPConfig.from_dict(config)
|
|
836
890
|
self.config = config
|
|
837
891
|
|
|
892
|
+
self._underlying_transports: list[ClientTransport] = []
|
|
893
|
+
|
|
838
894
|
# if there are no servers, raise an error
|
|
839
895
|
if len(self.config.mcpServers) == 0:
|
|
840
896
|
raise ValueError("No MCP servers defined in the config")
|
|
@@ -842,14 +898,22 @@ class MCPConfigTransport(ClientTransport):
|
|
|
842
898
|
# if there's exactly one server, create a client for that server
|
|
843
899
|
elif len(self.config.mcpServers) == 1:
|
|
844
900
|
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
|
901
|
+
self._underlying_transports.append(self.transport)
|
|
845
902
|
|
|
846
903
|
# otherwise create a composite client
|
|
847
904
|
else:
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
905
|
+
name = FastMCP.generate_name("MCPRouter")
|
|
906
|
+
self._composite_server = FastMCP[Any](name=name)
|
|
907
|
+
|
|
908
|
+
for name, server, transport in mcp_config_to_servers_and_transports(
|
|
909
|
+
self.config
|
|
910
|
+
):
|
|
911
|
+
self._underlying_transports.append(transport)
|
|
912
|
+
self._composite_server.mount(
|
|
913
|
+
server, prefix=name if name_as_prefix else None
|
|
851
914
|
)
|
|
852
|
-
|
|
915
|
+
|
|
916
|
+
self.transport = FastMCPTransport(mcp=self._composite_server)
|
|
853
917
|
|
|
854
918
|
@contextlib.asynccontextmanager
|
|
855
919
|
async def connect_session(
|
|
@@ -858,6 +922,10 @@ class MCPConfigTransport(ClientTransport):
|
|
|
858
922
|
async with self.transport.connect_session(**session_kwargs) as session:
|
|
859
923
|
yield session
|
|
860
924
|
|
|
925
|
+
async def close(self):
|
|
926
|
+
for transport in self._underlying_transports:
|
|
927
|
+
await transport.close()
|
|
928
|
+
|
|
861
929
|
def __repr__(self) -> str:
|
|
862
930
|
return f"<MCPConfigTransport(config='{self.config}')>"
|
|
863
931
|
|
|
@@ -957,28 +1025,36 @@ def infer_transport(
|
|
|
957
1025
|
|
|
958
1026
|
# the transport is a FastMCP server (2.x or 1.0)
|
|
959
1027
|
elif isinstance(transport, FastMCP | FastMCP1Server):
|
|
960
|
-
inferred_transport = FastMCPTransport(
|
|
1028
|
+
inferred_transport = FastMCPTransport(
|
|
1029
|
+
mcp=cast(FastMCP[Any] | FastMCP1Server, transport)
|
|
1030
|
+
)
|
|
961
1031
|
|
|
962
1032
|
# the transport is a path to a script
|
|
963
1033
|
elif isinstance(transport, Path | str) and Path(transport).exists():
|
|
964
1034
|
if str(transport).endswith(".py"):
|
|
965
|
-
inferred_transport = PythonStdioTransport(script_path=transport)
|
|
1035
|
+
inferred_transport = PythonStdioTransport(script_path=cast(Path, transport))
|
|
966
1036
|
elif str(transport).endswith(".js"):
|
|
967
|
-
inferred_transport = NodeStdioTransport(script_path=transport)
|
|
1037
|
+
inferred_transport = NodeStdioTransport(script_path=cast(Path, transport))
|
|
968
1038
|
else:
|
|
969
1039
|
raise ValueError(f"Unsupported script type: {transport}")
|
|
970
1040
|
|
|
971
1041
|
# the transport is an http(s) URL
|
|
972
1042
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
|
|
973
|
-
inferred_transport_type = infer_transport_type_from_url(
|
|
1043
|
+
inferred_transport_type = infer_transport_type_from_url(
|
|
1044
|
+
cast(AnyUrl | str, transport)
|
|
1045
|
+
)
|
|
974
1046
|
if inferred_transport_type == "sse":
|
|
975
|
-
inferred_transport = SSETransport(url=transport)
|
|
1047
|
+
inferred_transport = SSETransport(url=cast(AnyUrl | str, transport))
|
|
976
1048
|
else:
|
|
977
|
-
inferred_transport = StreamableHttpTransport(
|
|
1049
|
+
inferred_transport = StreamableHttpTransport(
|
|
1050
|
+
url=cast(AnyUrl | str, transport)
|
|
1051
|
+
)
|
|
978
1052
|
|
|
979
1053
|
# if the transport is a config dict or MCPConfig
|
|
980
1054
|
elif isinstance(transport, dict | MCPConfig):
|
|
981
|
-
inferred_transport = MCPConfigTransport(
|
|
1055
|
+
inferred_transport = MCPConfigTransport(
|
|
1056
|
+
config=cast(dict | MCPConfig, transport)
|
|
1057
|
+
)
|
|
982
1058
|
|
|
983
1059
|
# the transport is an unknown type
|
|
984
1060
|
else:
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Awaitable
|
|
3
|
+
|
|
4
|
+
from mcp import ClientSession, CreateMessageResult
|
|
5
|
+
from mcp.server.session import ServerSession
|
|
6
|
+
from mcp.shared.context import LifespanContextT, RequestContext
|
|
7
|
+
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
8
|
+
from mcp.types import (
|
|
9
|
+
SamplingMessage,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseLLMSamplingHandler(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def __call__(
|
|
16
|
+
self,
|
|
17
|
+
messages: list[SamplingMessage],
|
|
18
|
+
params: SamplingParams,
|
|
19
|
+
context: RequestContext[ServerSession, LifespanContextT]
|
|
20
|
+
| RequestContext[ClientSession, LifespanContextT],
|
|
21
|
+
) -> str | CreateMessageResult | Awaitable[str | CreateMessageResult]: ...
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from collections.abc import Iterator, Sequence
|
|
2
|
+
from typing import get_args
|
|
3
|
+
|
|
4
|
+
from mcp import ClientSession, ServerSession
|
|
5
|
+
from mcp.shared.context import LifespanContextT, RequestContext
|
|
6
|
+
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
7
|
+
from mcp.types import (
|
|
8
|
+
CreateMessageResult,
|
|
9
|
+
ModelPreferences,
|
|
10
|
+
SamplingMessage,
|
|
11
|
+
TextContent,
|
|
12
|
+
)
|
|
13
|
+
from openai import NOT_GIVEN, OpenAI
|
|
14
|
+
from openai.types.chat import (
|
|
15
|
+
ChatCompletion,
|
|
16
|
+
ChatCompletionAssistantMessageParam,
|
|
17
|
+
ChatCompletionMessageParam,
|
|
18
|
+
ChatCompletionSystemMessageParam,
|
|
19
|
+
ChatCompletionUserMessageParam,
|
|
20
|
+
)
|
|
21
|
+
from openai.types.shared.chat_model import ChatModel
|
|
22
|
+
from typing_extensions import override
|
|
23
|
+
|
|
24
|
+
from fastmcp.experimental.sampling.handlers.base import BaseLLMSamplingHandler
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpenAISamplingHandler(BaseLLMSamplingHandler):
|
|
28
|
+
def __init__(self, default_model: ChatModel, client: OpenAI | None = None):
|
|
29
|
+
self.client: OpenAI = client or OpenAI()
|
|
30
|
+
self.default_model: ChatModel = default_model
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
async def __call__(
|
|
34
|
+
self,
|
|
35
|
+
messages: list[SamplingMessage],
|
|
36
|
+
params: SamplingParams,
|
|
37
|
+
context: RequestContext[ServerSession, LifespanContextT]
|
|
38
|
+
| RequestContext[ClientSession, LifespanContextT],
|
|
39
|
+
) -> CreateMessageResult:
|
|
40
|
+
openai_messages: list[ChatCompletionMessageParam] = (
|
|
41
|
+
self._convert_to_openai_messages(
|
|
42
|
+
system_prompt=params.systemPrompt,
|
|
43
|
+
messages=messages,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
model: ChatModel = self._select_model_from_preferences(params.modelPreferences)
|
|
48
|
+
|
|
49
|
+
response = self.client.chat.completions.create(
|
|
50
|
+
model=model,
|
|
51
|
+
messages=openai_messages,
|
|
52
|
+
temperature=params.temperature or NOT_GIVEN,
|
|
53
|
+
max_tokens=params.maxTokens,
|
|
54
|
+
stop=params.stopSequences or NOT_GIVEN,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return self._chat_completion_to_create_message_result(response)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _iter_models_from_preferences(
|
|
61
|
+
model_preferences: ModelPreferences | str | list[str] | None,
|
|
62
|
+
) -> Iterator[str]:
|
|
63
|
+
if model_preferences is None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
if isinstance(model_preferences, str) and model_preferences in get_args(
|
|
67
|
+
ChatModel
|
|
68
|
+
):
|
|
69
|
+
yield model_preferences
|
|
70
|
+
|
|
71
|
+
if isinstance(model_preferences, list):
|
|
72
|
+
yield from model_preferences
|
|
73
|
+
|
|
74
|
+
if isinstance(model_preferences, ModelPreferences):
|
|
75
|
+
if not (hints := model_preferences.hints):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
for hint in hints:
|
|
79
|
+
if not (name := hint.name):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
yield name
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _convert_to_openai_messages(
|
|
86
|
+
system_prompt: str | None, messages: Sequence[SamplingMessage]
|
|
87
|
+
) -> list[ChatCompletionMessageParam]:
|
|
88
|
+
openai_messages: list[ChatCompletionMessageParam] = []
|
|
89
|
+
|
|
90
|
+
if system_prompt:
|
|
91
|
+
openai_messages.append(
|
|
92
|
+
ChatCompletionSystemMessageParam(
|
|
93
|
+
role="system",
|
|
94
|
+
content=system_prompt,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if isinstance(messages, str):
|
|
99
|
+
openai_messages.append(
|
|
100
|
+
ChatCompletionUserMessageParam(
|
|
101
|
+
role="user",
|
|
102
|
+
content=messages,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if isinstance(messages, list):
|
|
107
|
+
for message in messages:
|
|
108
|
+
if isinstance(message, str):
|
|
109
|
+
openai_messages.append(
|
|
110
|
+
ChatCompletionUserMessageParam(
|
|
111
|
+
role="user",
|
|
112
|
+
content=message,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if not isinstance(message.content, TextContent):
|
|
118
|
+
raise ValueError("Only text content is supported")
|
|
119
|
+
|
|
120
|
+
if message.role == "user":
|
|
121
|
+
openai_messages.append(
|
|
122
|
+
ChatCompletionUserMessageParam(
|
|
123
|
+
role="user",
|
|
124
|
+
content=message.content.text,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
openai_messages.append(
|
|
129
|
+
ChatCompletionAssistantMessageParam(
|
|
130
|
+
role="assistant",
|
|
131
|
+
content=message.content.text,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return openai_messages
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _chat_completion_to_create_message_result(
|
|
139
|
+
chat_completion: ChatCompletion,
|
|
140
|
+
) -> CreateMessageResult:
|
|
141
|
+
if len(chat_completion.choices) == 0:
|
|
142
|
+
raise ValueError("No response for completion")
|
|
143
|
+
|
|
144
|
+
first_choice = chat_completion.choices[0]
|
|
145
|
+
|
|
146
|
+
if content := first_choice.message.content:
|
|
147
|
+
return CreateMessageResult(
|
|
148
|
+
content=TextContent(type="text", text=content),
|
|
149
|
+
role="assistant",
|
|
150
|
+
model=chat_completion.model,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
raise ValueError("No content in response from completion")
|
|
154
|
+
|
|
155
|
+
def _select_model_from_preferences(
|
|
156
|
+
self, model_preferences: ModelPreferences | str | list[str] | None
|
|
157
|
+
) -> ChatModel:
|
|
158
|
+
for model_option in self._iter_models_from_preferences(model_preferences):
|
|
159
|
+
if model_option in get_args(ChatModel):
|
|
160
|
+
chosen_model: ChatModel = model_option # pyright: ignore[reportAssignmentType]
|
|
161
|
+
return chosen_model
|
|
162
|
+
|
|
163
|
+
return self.default_model
|
|
@@ -110,10 +110,8 @@ def _determine_route_type(
|
|
|
110
110
|
# Tags don't match, continue to next mapping
|
|
111
111
|
continue
|
|
112
112
|
|
|
113
|
-
# We know mcp_type is not None here due to post_init validation
|
|
114
|
-
assert route_map.mcp_type is not None
|
|
115
113
|
logger.debug(
|
|
116
|
-
f"Route {route.method} {route.path}
|
|
114
|
+
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
|
|
117
115
|
)
|
|
118
116
|
return route_map
|
|
119
117
|
|
|
@@ -11,7 +11,7 @@ from jsonschema_path import SchemaPath
|
|
|
11
11
|
from fastmcp.experimental.utilities.openapi import (
|
|
12
12
|
HTTPRoute,
|
|
13
13
|
extract_output_schema_from_responses,
|
|
14
|
-
|
|
14
|
+
format_simple_description,
|
|
15
15
|
parse_openapi_to_http_routes,
|
|
16
16
|
)
|
|
17
17
|
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
|
@@ -151,9 +151,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
151
151
|
try:
|
|
152
152
|
self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
|
|
153
153
|
self._director = RequestDirector(self._spec)
|
|
154
|
-
logger.debug(
|
|
155
|
-
"Initialized OpenAPI RequestDirector for stateless request building"
|
|
156
|
-
)
|
|
157
154
|
except Exception as e:
|
|
158
155
|
logger.error(f"Failed to initialize RequestDirector: {e}")
|
|
159
156
|
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
|
|
@@ -166,8 +163,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
166
163
|
# Determine route type based on mappings or default rules
|
|
167
164
|
route_map = _determine_route_type(route, route_maps)
|
|
168
165
|
|
|
169
|
-
# TODO: remove this once RouteType is removed and mcp_type is typed as MCPType without | None
|
|
170
|
-
assert route_map.mcp_type is not None
|
|
171
166
|
route_type = route_map.mcp_type
|
|
172
167
|
|
|
173
168
|
# Call route_map_fn if provided
|
|
@@ -270,7 +265,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
270
265
|
|
|
271
266
|
# Extract output schema from OpenAPI responses
|
|
272
267
|
output_schema = extract_output_schema_from_responses(
|
|
273
|
-
route.responses,
|
|
268
|
+
route.responses,
|
|
269
|
+
route.response_schemas,
|
|
270
|
+
route.openapi_version,
|
|
274
271
|
)
|
|
275
272
|
|
|
276
273
|
# Get a unique tool name
|
|
@@ -282,10 +279,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
282
279
|
or f"Executes {route.method} {route.path}"
|
|
283
280
|
)
|
|
284
281
|
|
|
285
|
-
#
|
|
286
|
-
enhanced_description =
|
|
282
|
+
# Use simplified description formatter for tools
|
|
283
|
+
enhanced_description = format_simple_description(
|
|
287
284
|
base_description=base_description,
|
|
288
|
-
responses=route.responses,
|
|
289
285
|
parameters=route.parameters,
|
|
290
286
|
request_body=route.request_body,
|
|
291
287
|
)
|
|
@@ -318,9 +314,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
318
314
|
|
|
319
315
|
# Register the tool by directly assigning to the tools dictionary
|
|
320
316
|
self._tool_manager._tools[final_tool_name] = tool
|
|
321
|
-
logger.debug(
|
|
322
|
-
f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
|
323
|
-
)
|
|
324
317
|
|
|
325
318
|
def _create_openapi_resource(
|
|
326
319
|
self,
|
|
@@ -337,10 +330,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
337
330
|
route.description or route.summary or f"Represents {route.path}"
|
|
338
331
|
)
|
|
339
332
|
|
|
340
|
-
#
|
|
341
|
-
enhanced_description =
|
|
333
|
+
# Use simplified description for resources
|
|
334
|
+
enhanced_description = format_simple_description(
|
|
342
335
|
base_description=base_description,
|
|
343
|
-
responses=route.responses,
|
|
344
336
|
parameters=route.parameters,
|
|
345
337
|
request_body=route.request_body,
|
|
346
338
|
)
|
|
@@ -372,9 +364,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
372
364
|
|
|
373
365
|
# Register the resource by directly assigning to the resources dictionary
|
|
374
366
|
self._resource_manager._resources[final_resource_uri] = resource
|
|
375
|
-
logger.debug(
|
|
376
|
-
f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
377
|
-
)
|
|
378
367
|
|
|
379
368
|
def _create_openapi_template(
|
|
380
369
|
self,
|
|
@@ -397,10 +386,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
397
386
|
route.description or route.summary or f"Template for {route.path}"
|
|
398
387
|
)
|
|
399
388
|
|
|
400
|
-
#
|
|
401
|
-
enhanced_description =
|
|
389
|
+
# Use simplified description for resource templates
|
|
390
|
+
enhanced_description = format_simple_description(
|
|
402
391
|
base_description=base_description,
|
|
403
|
-
responses=route.responses,
|
|
404
392
|
parameters=route.parameters,
|
|
405
393
|
request_body=route.request_body,
|
|
406
394
|
)
|
|
@@ -455,9 +443,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
455
443
|
|
|
456
444
|
# Register the template by directly assigning to the templates dictionary
|
|
457
445
|
self._resource_manager._templates[final_template_uri] = template
|
|
458
|
-
logger.debug(
|
|
459
|
-
f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
460
|
-
)
|
|
461
446
|
|
|
462
447
|
|
|
463
448
|
# Export public symbols
|