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.
Files changed (69) hide show
  1. fastmcp/__init__.py +5 -4
  2. fastmcp/cli/claude.py +22 -18
  3. fastmcp/cli/cli.py +472 -136
  4. fastmcp/cli/install/claude_code.py +37 -40
  5. fastmcp/cli/install/claude_desktop.py +37 -42
  6. fastmcp/cli/install/cursor.py +148 -38
  7. fastmcp/cli/install/mcp_json.py +38 -43
  8. fastmcp/cli/install/shared.py +64 -7
  9. fastmcp/cli/run.py +122 -215
  10. fastmcp/client/auth/oauth.py +69 -13
  11. fastmcp/client/client.py +46 -9
  12. fastmcp/client/oauth_callback.py +91 -91
  13. fastmcp/client/sampling.py +12 -4
  14. fastmcp/client/transports.py +139 -64
  15. fastmcp/experimental/sampling/__init__.py +0 -0
  16. fastmcp/experimental/sampling/handlers/__init__.py +3 -0
  17. fastmcp/experimental/sampling/handlers/base.py +21 -0
  18. fastmcp/experimental/sampling/handlers/openai.py +163 -0
  19. fastmcp/experimental/server/openapi/routing.py +0 -2
  20. fastmcp/experimental/server/openapi/server.py +0 -2
  21. fastmcp/experimental/utilities/openapi/parser.py +5 -1
  22. fastmcp/mcp_config.py +40 -20
  23. fastmcp/prompts/prompt_manager.py +2 -0
  24. fastmcp/resources/resource_manager.py +4 -0
  25. fastmcp/server/auth/__init__.py +2 -0
  26. fastmcp/server/auth/auth.py +2 -1
  27. fastmcp/server/auth/oauth_proxy.py +1047 -0
  28. fastmcp/server/auth/providers/azure.py +270 -0
  29. fastmcp/server/auth/providers/github.py +287 -0
  30. fastmcp/server/auth/providers/google.py +305 -0
  31. fastmcp/server/auth/providers/jwt.py +24 -12
  32. fastmcp/server/auth/providers/workos.py +256 -2
  33. fastmcp/server/auth/redirect_validation.py +65 -0
  34. fastmcp/server/context.py +91 -41
  35. fastmcp/server/elicitation.py +60 -1
  36. fastmcp/server/http.py +3 -3
  37. fastmcp/server/middleware/logging.py +66 -28
  38. fastmcp/server/proxy.py +2 -0
  39. fastmcp/server/sampling/handler.py +19 -0
  40. fastmcp/server/server.py +76 -15
  41. fastmcp/settings.py +16 -1
  42. fastmcp/tools/tool.py +22 -9
  43. fastmcp/tools/tool_manager.py +2 -0
  44. fastmcp/tools/tool_transform.py +39 -10
  45. fastmcp/utilities/auth.py +34 -0
  46. fastmcp/utilities/cli.py +148 -15
  47. fastmcp/utilities/components.py +2 -1
  48. fastmcp/utilities/inspect.py +166 -37
  49. fastmcp/utilities/json_schema_type.py +4 -2
  50. fastmcp/utilities/logging.py +4 -1
  51. fastmcp/utilities/mcp_config.py +47 -18
  52. fastmcp/utilities/mcp_server_config/__init__.py +25 -0
  53. fastmcp/utilities/mcp_server_config/v1/__init__.py +0 -0
  54. fastmcp/utilities/mcp_server_config/v1/environments/__init__.py +6 -0
  55. fastmcp/utilities/mcp_server_config/v1/environments/base.py +30 -0
  56. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +306 -0
  57. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +446 -0
  58. fastmcp/utilities/mcp_server_config/v1/schema.json +361 -0
  59. fastmcp/utilities/mcp_server_config/v1/sources/__init__.py +0 -0
  60. fastmcp/utilities/mcp_server_config/v1/sources/base.py +30 -0
  61. fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +216 -0
  62. fastmcp/utilities/tests.py +7 -2
  63. fastmcp/utilities/types.py +15 -2
  64. {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/METADATA +2 -1
  65. fastmcp-2.12.0.dist-info/RECORD +129 -0
  66. fastmcp-2.11.3.dist-info/RECORD +0 -108
  67. {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/WHEEL +0 -0
  68. {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/entry_points.txt +0 -0
  69. {fastmcp-2.11.3.dist-info → fastmcp-2.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
- assert self._session is not None
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
- async def _connect_task():
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(_connect_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
- # Build uv arguments
569
- uv_args: list[str] = ["run"]
570
- if project_directory:
571
- uv_args.extend(["--directory", str(project_directory)])
572
- if python_version:
573
- uv_args.extend(["--python", python_version])
574
- for pkg in with_packages or []:
575
- uv_args.extend(["--with", pkg])
576
- if with_requirements:
577
- uv_args.extend(["--with-requirements", str(with_requirements)])
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 composite_server_from_mcp_config
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
- self.transport = FastMCPTransport(
850
- mcp=composite_server_from_mcp_config(
851
- self.config, name_as_prefix=name_as_prefix
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(mcp=transport)
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(transport)
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(url=transport)
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(config=transport)
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,3 @@
1
+ from .openai import OpenAISamplingHandler
2
+
3
+ __all__ = ["OpenAISamplingHandler"]
@@ -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 schema_name not in collected and schema_name in all_schemas:
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 to_transport(self) -> FastMCPTransport:
94
- """Get the transport for the server."""
95
- from fastmcp.client.transports import FastMCPTransport
96
- from fastmcp.server.server import FastMCP
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
- transport,
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 FastMCPTransport(wrapped_mcp_server)
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
- def validate_mcp_servers(self, info: ValidationInfo) -> dict[str, Any]:
241
- """Validate the MCP servers."""
242
- if not isinstance(self, dict):
243
- raise ValueError("MCPConfig format requires a dictionary of servers.")
244
-
245
- if "mcpServers" not in self:
246
- self = {"mcpServers": self}
247
-
248
- return self
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: