fastmcp 2.11.3__py3-none-any.whl → 2.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__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/oauth_callback.py +91 -91
- fastmcp/client/sampling.py +12 -4
- fastmcp/client/transports.py +139 -64
- 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 +0 -2
- fastmcp/experimental/server/openapi/server.py +0 -2
- fastmcp/experimental/utilities/openapi/parser.py +5 -1
- fastmcp/mcp_config.py +40 -20
- fastmcp/prompts/prompt_manager.py +2 -0
- fastmcp/resources/resource_manager.py +4 -0
- fastmcp/server/auth/__init__.py +2 -0
- fastmcp/server/auth/auth.py +2 -1
- 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 +24 -12
- fastmcp/server/auth/providers/workos.py +256 -2
- fastmcp/server/auth/redirect_validation.py +65 -0
- fastmcp/server/context.py +91 -41
- fastmcp/server/elicitation.py +60 -1
- fastmcp/server/http.py +3 -3
- fastmcp/server/middleware/logging.py +66 -28
- fastmcp/server/proxy.py +2 -0
- fastmcp/server/sampling/handler.py +19 -0
- fastmcp/server/server.py +76 -15
- fastmcp/settings.py +16 -1
- fastmcp/tools/tool.py +22 -9
- fastmcp/tools/tool_manager.py +2 -0
- fastmcp/tools/tool_transform.py +39 -10
- fastmcp/utilities/auth.py +34 -0
- fastmcp/utilities/cli.py +148 -15
- fastmcp/utilities/components.py +2 -1
- 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/tests.py +7 -2
- fastmcp/utilities/types.py +15 -2
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/METADATA +2 -1
- fastmcp-2.12.0.dist-info/RECORD +129 -0
- fastmcp-2.11.3.dist-info/RECORD +0 -108
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/transports.py
CHANGED
|
@@ -21,6 +21,9 @@ 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
|
|
25
28
|
from mcp.shared._httpx_utils import McpHttpClientFactory
|
|
26
29
|
from mcp.shared.memory import create_client_server_memory_streams
|
|
@@ -34,6 +37,7 @@ from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url
|
|
|
34
37
|
from fastmcp.server.dependencies import get_http_headers
|
|
35
38
|
from fastmcp.server.server import FastMCP
|
|
36
39
|
from fastmcp.utilities.logging import get_logger
|
|
40
|
+
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
|
|
37
41
|
|
|
38
42
|
logger = get_logger(__name__)
|
|
39
43
|
|
|
@@ -178,7 +182,7 @@ class SSETransport(ClientTransport):
|
|
|
178
182
|
self.httpx_client_factory = httpx_client_factory
|
|
179
183
|
|
|
180
184
|
if isinstance(sse_read_timeout, int | float):
|
|
181
|
-
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
|
185
|
+
sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
|
|
182
186
|
self.sse_read_timeout = sse_read_timeout
|
|
183
187
|
|
|
184
188
|
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
|
@@ -192,8 +196,6 @@ class SSETransport(ClientTransport):
|
|
|
192
196
|
async def connect_session(
|
|
193
197
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
194
198
|
) -> AsyncIterator[ClientSession]:
|
|
195
|
-
from mcp.client.sse import sse_client
|
|
196
|
-
|
|
197
199
|
client_kwargs: dict[str, Any] = {}
|
|
198
200
|
|
|
199
201
|
# load headers from an active HTTP request, if available. This will only be true
|
|
@@ -250,7 +252,7 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
250
252
|
self.httpx_client_factory = httpx_client_factory
|
|
251
253
|
|
|
252
254
|
if isinstance(sse_read_timeout, int | float):
|
|
253
|
-
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
|
|
255
|
+
sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
|
|
254
256
|
self.sse_read_timeout = sse_read_timeout
|
|
255
257
|
|
|
256
258
|
def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
|
|
@@ -264,8 +266,6 @@ class StreamableHttpTransport(ClientTransport):
|
|
|
264
266
|
async def connect_session(
|
|
265
267
|
self, **session_kwargs: Unpack[SessionKwargs]
|
|
266
268
|
) -> AsyncIterator[ClientSession]:
|
|
267
|
-
from mcp.client.streamable_http import streamablehttp_client
|
|
268
|
-
|
|
269
269
|
client_kwargs: dict[str, Any] = {}
|
|
270
270
|
|
|
271
271
|
# load headers from an active HTTP request, if available. This will only be true
|
|
@@ -346,8 +346,7 @@ class StdioTransport(ClientTransport):
|
|
|
346
346
|
) -> AsyncIterator[ClientSession]:
|
|
347
347
|
try:
|
|
348
348
|
await self.connect(**session_kwargs)
|
|
349
|
-
|
|
350
|
-
yield self._session
|
|
349
|
+
yield cast(ClientSession, self._session)
|
|
351
350
|
finally:
|
|
352
351
|
if not self.keep_alive:
|
|
353
352
|
await self.disconnect()
|
|
@@ -360,42 +359,22 @@ class StdioTransport(ClientTransport):
|
|
|
360
359
|
if self._connect_task is not None:
|
|
361
360
|
return
|
|
362
361
|
|
|
363
|
-
|
|
364
|
-
from mcp.client.stdio import stdio_client
|
|
365
|
-
|
|
366
|
-
try:
|
|
367
|
-
async with contextlib.AsyncExitStack() as stack:
|
|
368
|
-
try:
|
|
369
|
-
server_params = StdioServerParameters(
|
|
370
|
-
command=self.command,
|
|
371
|
-
args=self.args,
|
|
372
|
-
env=self.env,
|
|
373
|
-
cwd=self.cwd,
|
|
374
|
-
)
|
|
375
|
-
transport = await stack.enter_async_context(
|
|
376
|
-
stdio_client(server_params)
|
|
377
|
-
)
|
|
378
|
-
read_stream, write_stream = transport
|
|
379
|
-
self._session = await stack.enter_async_context(
|
|
380
|
-
ClientSession(read_stream, write_stream, **session_kwargs)
|
|
381
|
-
)
|
|
382
|
-
|
|
383
|
-
logger.debug("Stdio transport connected")
|
|
384
|
-
self._ready_event.set()
|
|
385
|
-
|
|
386
|
-
# Wait until disconnect is requested (stop_event is set)
|
|
387
|
-
await self._stop_event.wait()
|
|
388
|
-
finally:
|
|
389
|
-
# Clean up client on exit
|
|
390
|
-
self._session = None
|
|
391
|
-
logger.debug("Stdio transport disconnected")
|
|
392
|
-
except Exception:
|
|
393
|
-
# Ensure ready event is set even if connection fails
|
|
394
|
-
self._ready_event.set()
|
|
395
|
-
raise
|
|
362
|
+
session_future: asyncio.Future[ClientSession] = asyncio.Future()
|
|
396
363
|
|
|
397
364
|
# start the connection task
|
|
398
|
-
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
|
+
|
|
399
378
|
# wait for the client to be ready before returning
|
|
400
379
|
await self._ready_event.wait()
|
|
401
380
|
|
|
@@ -405,6 +384,9 @@ class StdioTransport(ClientTransport):
|
|
|
405
384
|
if exception is not None:
|
|
406
385
|
raise exception
|
|
407
386
|
|
|
387
|
+
self._session = await session_future
|
|
388
|
+
return self._session
|
|
389
|
+
|
|
408
390
|
async def disconnect(self):
|
|
409
391
|
if self._connect_task is None:
|
|
410
392
|
return
|
|
@@ -423,12 +405,61 @@ class StdioTransport(ClientTransport):
|
|
|
423
405
|
async def close(self):
|
|
424
406
|
await self.disconnect()
|
|
425
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
|
+
|
|
426
413
|
def __repr__(self) -> str:
|
|
427
414
|
return (
|
|
428
415
|
f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>"
|
|
429
416
|
)
|
|
430
417
|
|
|
431
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
|
+
|
|
432
463
|
class PythonStdioTransport(StdioTransport):
|
|
433
464
|
"""Transport for running Python scripts."""
|
|
434
465
|
|
|
@@ -565,16 +596,38 @@ class UvStdioTransport(StdioTransport):
|
|
|
565
596
|
f"Project directory not found: {project_directory}"
|
|
566
597
|
)
|
|
567
598
|
|
|
568
|
-
#
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
+
|
|
578
631
|
if module:
|
|
579
632
|
uv_args.append("--module")
|
|
580
633
|
|
|
@@ -830,12 +883,14 @@ class MCPConfigTransport(ClientTransport):
|
|
|
830
883
|
"""
|
|
831
884
|
|
|
832
885
|
def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True):
|
|
833
|
-
from fastmcp.utilities.mcp_config import
|
|
886
|
+
from fastmcp.utilities.mcp_config import mcp_config_to_servers_and_transports
|
|
834
887
|
|
|
835
888
|
if isinstance(config, dict):
|
|
836
889
|
config = MCPConfig.from_dict(config)
|
|
837
890
|
self.config = config
|
|
838
891
|
|
|
892
|
+
self._underlying_transports: list[ClientTransport] = []
|
|
893
|
+
|
|
839
894
|
# if there are no servers, raise an error
|
|
840
895
|
if len(self.config.mcpServers) == 0:
|
|
841
896
|
raise ValueError("No MCP servers defined in the config")
|
|
@@ -843,14 +898,22 @@ class MCPConfigTransport(ClientTransport):
|
|
|
843
898
|
# if there's exactly one server, create a client for that server
|
|
844
899
|
elif len(self.config.mcpServers) == 1:
|
|
845
900
|
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
|
901
|
+
self._underlying_transports.append(self.transport)
|
|
846
902
|
|
|
847
903
|
# otherwise create a composite client
|
|
848
904
|
else:
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
|
852
914
|
)
|
|
853
|
-
|
|
915
|
+
|
|
916
|
+
self.transport = FastMCPTransport(mcp=self._composite_server)
|
|
854
917
|
|
|
855
918
|
@contextlib.asynccontextmanager
|
|
856
919
|
async def connect_session(
|
|
@@ -859,6 +922,10 @@ class MCPConfigTransport(ClientTransport):
|
|
|
859
922
|
async with self.transport.connect_session(**session_kwargs) as session:
|
|
860
923
|
yield session
|
|
861
924
|
|
|
925
|
+
async def close(self):
|
|
926
|
+
for transport in self._underlying_transports:
|
|
927
|
+
await transport.close()
|
|
928
|
+
|
|
862
929
|
def __repr__(self) -> str:
|
|
863
930
|
return f"<MCPConfigTransport(config='{self.config}')>"
|
|
864
931
|
|
|
@@ -958,28 +1025,36 @@ def infer_transport(
|
|
|
958
1025
|
|
|
959
1026
|
# the transport is a FastMCP server (2.x or 1.0)
|
|
960
1027
|
elif isinstance(transport, FastMCP | FastMCP1Server):
|
|
961
|
-
inferred_transport = FastMCPTransport(
|
|
1028
|
+
inferred_transport = FastMCPTransport(
|
|
1029
|
+
mcp=cast(FastMCP[Any] | FastMCP1Server, transport)
|
|
1030
|
+
)
|
|
962
1031
|
|
|
963
1032
|
# the transport is a path to a script
|
|
964
1033
|
elif isinstance(transport, Path | str) and Path(transport).exists():
|
|
965
1034
|
if str(transport).endswith(".py"):
|
|
966
|
-
inferred_transport = PythonStdioTransport(script_path=transport)
|
|
1035
|
+
inferred_transport = PythonStdioTransport(script_path=cast(Path, transport))
|
|
967
1036
|
elif str(transport).endswith(".js"):
|
|
968
|
-
inferred_transport = NodeStdioTransport(script_path=transport)
|
|
1037
|
+
inferred_transport = NodeStdioTransport(script_path=cast(Path, transport))
|
|
969
1038
|
else:
|
|
970
1039
|
raise ValueError(f"Unsupported script type: {transport}")
|
|
971
1040
|
|
|
972
1041
|
# the transport is an http(s) URL
|
|
973
1042
|
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
|
|
974
|
-
inferred_transport_type = infer_transport_type_from_url(
|
|
1043
|
+
inferred_transport_type = infer_transport_type_from_url(
|
|
1044
|
+
cast(AnyUrl | str, transport)
|
|
1045
|
+
)
|
|
975
1046
|
if inferred_transport_type == "sse":
|
|
976
|
-
inferred_transport = SSETransport(url=transport)
|
|
1047
|
+
inferred_transport = SSETransport(url=cast(AnyUrl | str, transport))
|
|
977
1048
|
else:
|
|
978
|
-
inferred_transport = StreamableHttpTransport(
|
|
1049
|
+
inferred_transport = StreamableHttpTransport(
|
|
1050
|
+
url=cast(AnyUrl | str, transport)
|
|
1051
|
+
)
|
|
979
1052
|
|
|
980
1053
|
# if the transport is a config dict or MCPConfig
|
|
981
1054
|
elif isinstance(transport, dict | MCPConfig):
|
|
982
|
-
inferred_transport = MCPConfigTransport(
|
|
1055
|
+
inferred_transport = MCPConfigTransport(
|
|
1056
|
+
config=cast(dict | MCPConfig, transport)
|
|
1057
|
+
)
|
|
983
1058
|
|
|
984
1059
|
# the transport is an unknown type
|
|
985
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,8 +110,6 @@ 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
114
|
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
|
|
117
115
|
)
|
|
@@ -163,8 +163,6 @@ class FastMCPOpenAPI(FastMCP):
|
|
|
163
163
|
# Determine route type based on mappings or default rules
|
|
164
164
|
route_map = _determine_route_type(route, route_maps)
|
|
165
165
|
|
|
166
|
-
# TODO: remove this once RouteType is removed and mcp_type is typed as MCPType without | None
|
|
167
|
-
assert route_map.mcp_type is not None
|
|
168
166
|
route_type = route_map.mcp_type
|
|
169
167
|
|
|
170
168
|
# Call route_map_fn if provided
|
|
@@ -549,7 +549,11 @@ class OpenAPIParser(
|
|
|
549
549
|
return
|
|
550
550
|
|
|
551
551
|
# Add this schema and recursively find its dependencies
|
|
552
|
-
if
|
|
552
|
+
if (
|
|
553
|
+
collected is not None
|
|
554
|
+
and schema_name not in collected
|
|
555
|
+
and schema_name in all_schemas
|
|
556
|
+
):
|
|
553
557
|
collected.add(schema_name)
|
|
554
558
|
# Recursively find dependencies of this schema
|
|
555
559
|
find_refs(all_schemas[schema_name])
|
fastmcp/mcp_config.py
CHANGED
|
@@ -27,7 +27,7 @@ from __future__ import annotations
|
|
|
27
27
|
import datetime
|
|
28
28
|
import re
|
|
29
29
|
from pathlib import Path
|
|
30
|
-
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
30
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
|
31
31
|
from urllib.parse import urlparse
|
|
32
32
|
|
|
33
33
|
import httpx
|
|
@@ -36,7 +36,6 @@ from pydantic import (
|
|
|
36
36
|
BaseModel,
|
|
37
37
|
ConfigDict,
|
|
38
38
|
Field,
|
|
39
|
-
ValidationInfo,
|
|
40
39
|
model_validator,
|
|
41
40
|
)
|
|
42
41
|
from typing_extensions import Self, override
|
|
@@ -47,11 +46,11 @@ from fastmcp.utilities.types import FastMCPBaseModel
|
|
|
47
46
|
if TYPE_CHECKING:
|
|
48
47
|
from fastmcp.client.transports import (
|
|
49
48
|
ClientTransport,
|
|
50
|
-
FastMCPTransport,
|
|
51
49
|
SSETransport,
|
|
52
50
|
StdioTransport,
|
|
53
51
|
StreamableHttpTransport,
|
|
54
52
|
)
|
|
53
|
+
from fastmcp.server.server import FastMCP
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
def infer_transport_type_from_url(
|
|
@@ -90,21 +89,38 @@ class _TransformingMCPServerMixin(FastMCPBaseModel):
|
|
|
90
89
|
description="The tags to exclude in the proxy.",
|
|
91
90
|
)
|
|
92
91
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
def _to_server_and_underlying_transport(
|
|
93
|
+
self,
|
|
94
|
+
server_name: str | None = None,
|
|
95
|
+
client_name: str | None = None,
|
|
96
|
+
) -> tuple[FastMCP[Any], ClientTransport]:
|
|
97
|
+
"""Turn the Transforming MCPServer into a FastMCP Server and also return the underlying transport."""
|
|
98
|
+
from fastmcp import FastMCP
|
|
99
|
+
from fastmcp.client import Client
|
|
100
|
+
from fastmcp.client.transports import (
|
|
101
|
+
ClientTransport, # pyright: ignore[reportUnusedImport]
|
|
102
|
+
)
|
|
97
103
|
|
|
98
104
|
transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
|
|
105
|
+
transport = cast(ClientTransport, transport)
|
|
106
|
+
|
|
107
|
+
client: Client[ClientTransport] = Client(transport=transport, name=client_name)
|
|
99
108
|
|
|
100
109
|
wrapped_mcp_server = FastMCP.as_proxy(
|
|
101
|
-
|
|
110
|
+
name=server_name,
|
|
111
|
+
backend=client,
|
|
102
112
|
tool_transformations=self.tools,
|
|
103
113
|
include_tags=self.include_tags,
|
|
104
114
|
exclude_tags=self.exclude_tags,
|
|
105
115
|
)
|
|
106
116
|
|
|
107
|
-
return
|
|
117
|
+
return wrapped_mcp_server, transport
|
|
118
|
+
|
|
119
|
+
def to_transport(self) -> ClientTransport:
|
|
120
|
+
"""Get the transport for the transforming MCP server."""
|
|
121
|
+
from fastmcp.client.transports import FastMCPTransport
|
|
122
|
+
|
|
123
|
+
return FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0])
|
|
108
124
|
|
|
109
125
|
|
|
110
126
|
class StdioMCPServer(BaseModel):
|
|
@@ -232,20 +248,24 @@ class MCPConfig(BaseModel):
|
|
|
232
248
|
For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class.
|
|
233
249
|
"""
|
|
234
250
|
|
|
235
|
-
mcpServers: dict[str, MCPServerTypes]
|
|
251
|
+
mcpServers: dict[str, MCPServerTypes] = Field(default_factory=dict)
|
|
236
252
|
|
|
237
253
|
model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields
|
|
238
254
|
|
|
239
255
|
@model_validator(mode="before")
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
256
|
+
@classmethod
|
|
257
|
+
def wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
258
|
+
"""If there's no mcpServers key but there are server configs at root, wrap them."""
|
|
259
|
+
if "mcpServers" not in values:
|
|
260
|
+
# Check if any values look like server configs
|
|
261
|
+
has_servers = any(
|
|
262
|
+
isinstance(v, dict) and ("command" in v or "url" in v)
|
|
263
|
+
for v in values.values()
|
|
264
|
+
)
|
|
265
|
+
if has_servers:
|
|
266
|
+
# Move all server-like configs under mcpServers
|
|
267
|
+
return {"mcpServers": values}
|
|
268
|
+
return values
|
|
249
269
|
|
|
250
270
|
def add_server(self, name: str, server: MCPServerTypes) -> None:
|
|
251
271
|
"""Add or update a server in the configuration."""
|
|
@@ -282,7 +302,7 @@ class CanonicalMCPConfig(MCPConfig):
|
|
|
282
302
|
The format is designed to be client-agnostic and extensible for future use cases.
|
|
283
303
|
"""
|
|
284
304
|
|
|
285
|
-
mcpServers: dict[str, CanonicalMCPServerTypes]
|
|
305
|
+
mcpServers: dict[str, CanonicalMCPServerTypes] = Field(default_factory=dict)
|
|
286
306
|
|
|
287
307
|
@override
|
|
288
308
|
def add_server(self, name: str, server: CanonicalMCPServerTypes) -> None:
|