fastmcp 2.3.5__py3-none-any.whl → 2.5.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/server/server.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import datetime
6
+ import re
6
7
  import warnings
7
8
  from collections.abc import AsyncIterator, Awaitable, Callable
8
9
  from contextlib import (
@@ -16,7 +17,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal
16
17
 
17
18
  import anyio
18
19
  import httpx
19
- import pydantic
20
20
  import uvicorn
21
21
  from mcp.server.auth.provider import OAuthAuthorizationServerProvider
22
22
  from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -58,16 +58,22 @@ from fastmcp.tools.tool import Tool
58
58
  from fastmcp.utilities.cache import TimedCache
59
59
  from fastmcp.utilities.decorators import DecoratedFunction
60
60
  from fastmcp.utilities.logging import get_logger
61
+ from fastmcp.utilities.mcp_config import MCPConfig
61
62
 
62
63
  if TYPE_CHECKING:
63
64
  from fastmcp.client import Client
64
65
  from fastmcp.client.transports import ClientTransport
65
- from fastmcp.server.openapi import FastMCPOpenAPI
66
+ from fastmcp.server.openapi import ComponentFn as OpenAPIComponentFn
67
+ from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
68
+ from fastmcp.server.openapi import RouteMapFn as OpenAPIRouteMapFn
66
69
  from fastmcp.server.proxy import FastMCPProxy
67
70
  logger = get_logger(__name__)
68
71
 
69
72
  DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
70
73
 
74
+ # Compiled URI parsing regex to split a URI into protocol and path components
75
+ URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
76
+
71
77
 
72
78
  @asynccontextmanager
73
79
  async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
@@ -120,6 +126,8 @@ class FastMCP(Generic[LifespanResultT]):
120
126
  on_duplicate_tools: DuplicateBehavior | None = None,
121
127
  on_duplicate_resources: DuplicateBehavior | None = None,
122
128
  on_duplicate_prompts: DuplicateBehavior | None = None,
129
+ resource_prefix_format: Literal["protocol", "path"] | None = None,
130
+ mask_error_details: bool | None = None,
123
131
  **settings: Any,
124
132
  ):
125
133
  if settings:
@@ -134,6 +142,18 @@ class FastMCP(Generic[LifespanResultT]):
134
142
  )
135
143
  self.settings = fastmcp.settings.ServerSettings(**settings)
136
144
 
145
+ # If mask_error_details is provided, override the settings value
146
+ if mask_error_details is not None:
147
+ self.settings.mask_error_details = mask_error_details
148
+
149
+ self.resource_prefix_format: Literal["protocol", "path"]
150
+ if resource_prefix_format is None:
151
+ self.resource_prefix_format = (
152
+ fastmcp.settings.settings.resource_prefix_format
153
+ )
154
+ else:
155
+ self.resource_prefix_format = resource_prefix_format
156
+
137
157
  self.tags: set[str] = tags or set()
138
158
  self.dependencies = dependencies
139
159
  self._cache = TimedCache(
@@ -144,11 +164,16 @@ class FastMCP(Generic[LifespanResultT]):
144
164
  self._tool_manager = ToolManager(
145
165
  duplicate_behavior=on_duplicate_tools,
146
166
  serializer=tool_serializer,
167
+ mask_error_details=self.settings.mask_error_details,
147
168
  )
148
169
  self._resource_manager = ResourceManager(
149
- duplicate_behavior=on_duplicate_resources
170
+ duplicate_behavior=on_duplicate_resources,
171
+ mask_error_details=self.settings.mask_error_details,
172
+ )
173
+ self._prompt_manager = PromptManager(
174
+ duplicate_behavior=on_duplicate_prompts,
175
+ mask_error_details=self.settings.mask_error_details,
150
176
  )
151
- self._prompt_manager = PromptManager(duplicate_behavior=on_duplicate_prompts)
152
177
 
153
178
  if lifespan is None:
154
179
  self._has_lifespan = False
@@ -364,21 +389,30 @@ class FastMCP(Generic[LifespanResultT]):
364
389
  async def _mcp_call_tool(
365
390
  self, key: str, arguments: dict[str, Any]
366
391
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
367
- """Call a tool by name with arguments."""
392
+ """Handle MCP 'callTool' requests.
393
+
394
+ Args:
395
+ key: The name of the tool to call
396
+ arguments: Arguments to pass to the tool
368
397
 
398
+ Returns:
399
+ List of MCP Content objects containing the tool results
400
+ """
401
+ logger.debug("Call tool: %s with %s", key, arguments)
402
+
403
+ # Create and use context for the entire call
369
404
  with fastmcp.server.context.Context(fastmcp=self):
405
+ # Get tool, checking first from our tools, then from the mounted servers
370
406
  if self._tool_manager.has_tool(key):
371
- result = await self._tool_manager.call_tool(key, arguments)
407
+ return await self._tool_manager.call_tool(key, arguments)
372
408
 
373
- else:
374
- for server in self._mounted_servers.values():
375
- if server.match_tool(key):
376
- new_key = server.strip_tool_prefix(key)
377
- result = await server.server._mcp_call_tool(new_key, arguments)
378
- break
379
- else:
380
- raise NotFoundError(f"Unknown tool: {key}")
381
- return result
409
+ # Check mounted servers to see if they have the tool
410
+ for server in self._mounted_servers.values():
411
+ if server.match_tool(key):
412
+ tool_key = server.strip_tool_prefix(key)
413
+ return await server.server._mcp_call_tool(tool_key, arguments)
414
+
415
+ raise NotFoundError(f"Unknown tool: {key}")
382
416
 
383
417
  async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
384
418
  """
@@ -406,24 +440,30 @@ class FastMCP(Generic[LifespanResultT]):
406
440
  async def _mcp_get_prompt(
407
441
  self, name: str, arguments: dict[str, Any] | None = None
408
442
  ) -> GetPromptResult:
409
- """
410
- Get a prompt by name with arguments, in the format expected by the low-level
411
- MCP server.
443
+ """Handle MCP 'getPrompt' requests.
444
+
445
+ Args:
446
+ name: The name of the prompt to render
447
+ arguments: Arguments to pass to the prompt
412
448
 
449
+ Returns:
450
+ GetPromptResult containing the rendered prompt messages
413
451
  """
452
+ logger.debug("Get prompt: %s with %s", name, arguments)
453
+
454
+ # Create and use context for the entire call
414
455
  with fastmcp.server.context.Context(fastmcp=self):
456
+ # Get prompt, checking first from our prompts, then from the mounted servers
415
457
  if self._prompt_manager.has_prompt(name):
416
- prompt_result = await self._prompt_manager.render_prompt(
417
- name, arguments=arguments or {}
418
- )
419
- return prompt_result
420
- else:
421
- for server in self._mounted_servers.values():
422
- if server.match_prompt(name):
423
- new_key = server.strip_prompt_prefix(name)
424
- return await server.server._mcp_get_prompt(new_key, arguments)
425
- else:
426
- raise NotFoundError(f"Unknown prompt: {name}")
458
+ return await self._prompt_manager.render_prompt(name, arguments)
459
+
460
+ # Check mounted servers to see if they have the prompt
461
+ for server in self._mounted_servers.values():
462
+ if server.match_prompt(name):
463
+ prompt_name = server.strip_prompt_prefix(name)
464
+ return await server.server._mcp_get_prompt(prompt_name, arguments)
465
+
466
+ raise NotFoundError(f"Unknown prompt: {name}")
427
467
 
428
468
  def add_tool(
429
469
  self,
@@ -841,7 +881,6 @@ class FastMCP(Generic[LifespanResultT]):
841
881
  auth_server_provider=self._auth_server_provider,
842
882
  auth_settings=self.settings.auth,
843
883
  debug=self.settings.debug,
844
- routes=self._additional_http_routes,
845
884
  middleware=middleware,
846
885
  )
847
886
 
@@ -892,7 +931,6 @@ class FastMCP(Generic[LifespanResultT]):
892
931
  json_response=self.settings.json_response,
893
932
  stateless_http=self.settings.stateless_http,
894
933
  debug=self.settings.debug,
895
- routes=self._additional_http_routes,
896
934
  middleware=middleware,
897
935
  )
898
936
  elif transport == "sse":
@@ -903,7 +941,6 @@ class FastMCP(Generic[LifespanResultT]):
903
941
  auth_server_provider=self._auth_server_provider,
904
942
  auth_settings=self.settings.auth,
905
943
  debug=self.settings.debug,
906
- routes=self._additional_http_routes,
907
944
  middleware=middleware,
908
945
  )
909
946
 
@@ -935,10 +972,11 @@ class FastMCP(Generic[LifespanResultT]):
935
972
  self,
936
973
  prefix: str,
937
974
  server: FastMCP[LifespanResultT],
975
+ as_proxy: bool | None = None,
976
+ *,
938
977
  tool_separator: str | None = None,
939
978
  resource_separator: str | None = None,
940
979
  prompt_separator: str | None = None,
941
- as_proxy: bool | None = None,
942
980
  ) -> None:
943
981
  """Mount another FastMCP server on this server with the given prefix.
944
982
 
@@ -949,15 +987,15 @@ class FastMCP(Generic[LifespanResultT]):
949
987
  through the parent.
950
988
 
951
989
  When a server is mounted:
952
- - Tools from the mounted server are accessible with prefixed names using the tool_separator.
990
+ - Tools from the mounted server are accessible with prefixed names.
953
991
  Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
954
- - Resources are accessible with prefixed URIs using the resource_separator.
992
+ - Resources are accessible with prefixed URIs.
955
993
  Example: If server has a resource with URI "weather://forecast", it will be available as
956
- "prefix+weather://forecast".
957
- - Templates are accessible with prefixed URI templates using the resource_separator.
994
+ "weather://prefix/forecast".
995
+ - Templates are accessible with prefixed URI templates.
958
996
  Example: If server has a template with URI "weather://location/{id}", it will be available
959
- as "prefix+weather://location/{id}".
960
- - Prompts are accessible with prefixed names using the prompt_separator.
997
+ as "weather://prefix/location/{id}".
998
+ - Prompts are accessible with prefixed names.
961
999
  Example: If server has a prompt named "weather_prompt", it will be available as
962
1000
  "prefix_weather_prompt".
963
1001
 
@@ -975,17 +1013,44 @@ class FastMCP(Generic[LifespanResultT]):
975
1013
  Args:
976
1014
  prefix: Prefix to use for the mounted server's objects.
977
1015
  server: The FastMCP server to mount.
978
- tool_separator: Separator character for tool names (defaults to "_").
979
- resource_separator: Separator character for resource URIs (defaults to "+").
980
- prompt_separator: Separator character for prompt names (defaults to "_").
981
1016
  as_proxy: Whether to treat the mounted server as a proxy. If None (default),
982
1017
  automatically determined based on whether the server has a custom lifespan
983
1018
  (True if it has a custom lifespan, False otherwise).
1019
+ tool_separator: Deprecated. Separator character for tool names.
1020
+ resource_separator: Deprecated. Separator character for resource URIs.
1021
+ prompt_separator: Deprecated. Separator character for prompt names.
984
1022
  """
985
1023
  from fastmcp import Client
986
1024
  from fastmcp.client.transports import FastMCPTransport
987
1025
  from fastmcp.server.proxy import FastMCPProxy
988
1026
 
1027
+ if tool_separator is not None:
1028
+ # Deprecated since 2.4.0
1029
+ warnings.warn(
1030
+ "The tool_separator parameter is deprecated and will be removed in a future version. "
1031
+ "Tools are now prefixed using 'prefix_toolname' format.",
1032
+ DeprecationWarning,
1033
+ stacklevel=2,
1034
+ )
1035
+
1036
+ if resource_separator is not None:
1037
+ # Deprecated since 2.4.0
1038
+ warnings.warn(
1039
+ "The resource_separator parameter is deprecated and ignored. "
1040
+ "Resource prefixes are now added using the protocol://prefix/path format.",
1041
+ DeprecationWarning,
1042
+ stacklevel=2,
1043
+ )
1044
+
1045
+ if prompt_separator is not None:
1046
+ # Deprecated since 2.4.0
1047
+ warnings.warn(
1048
+ "The prompt_separator parameter is deprecated and will be removed in a future version. "
1049
+ "Prompts are now prefixed using 'prefix_promptname' format.",
1050
+ DeprecationWarning,
1051
+ stacklevel=2,
1052
+ )
1053
+
989
1054
  # if as_proxy is not specified and the server has a custom lifespan,
990
1055
  # we should treat it as a proxy
991
1056
  if as_proxy is None:
@@ -997,9 +1062,6 @@ class FastMCP(Generic[LifespanResultT]):
997
1062
  mounted_server = MountedServer(
998
1063
  server=server,
999
1064
  prefix=prefix,
1000
- tool_separator=tool_separator,
1001
- resource_separator=resource_separator,
1002
- prompt_separator=prompt_separator,
1003
1065
  )
1004
1066
  self._mounted_servers[prefix] = mounted_server
1005
1067
  self._cache.clear()
@@ -1025,90 +1087,177 @@ class FastMCP(Generic[LifespanResultT]):
1025
1087
  future changes to the imported server will not be reflected in the
1026
1088
  importing server. Server-level configurations and lifespans are not imported.
1027
1089
 
1028
- When a server is mounted: - The tools are imported with prefixed names
1029
- using the tool_separator
1090
+ When a server is imported:
1091
+ - The tools are imported with prefixed names
1030
1092
  Example: If server has a tool named "get_weather", it will be
1031
- available as "weatherget_weather"
1032
- - The resources are imported with prefixed URIs using the
1033
- resource_separator Example: If server has a resource with URI
1034
- "weather://forecast", it will be available as
1035
- "weather+weather://forecast"
1036
- - The templates are imported with prefixed URI templates using the
1037
- resource_separator Example: If server has a template with URI
1038
- "weather://location/{id}", it will be available as
1039
- "weather+weather://location/{id}"
1040
- - The prompts are imported with prefixed names using the
1041
- prompt_separator Example: If server has a prompt named
1042
- "weather_prompt", it will be available as "weather_weather_prompt"
1093
+ available as "prefix_get_weather"
1094
+ - The resources are imported with prefixed URIs using the new format
1095
+ Example: If server has a resource with URI "weather://forecast", it will
1096
+ be available as "weather://prefix/forecast"
1097
+ - The templates are imported with prefixed URI templates using the new format
1098
+ Example: If server has a template with URI "weather://location/{id}", it will
1099
+ be available as "weather://prefix/location/{id}"
1100
+ - The prompts are imported with prefixed names
1101
+ Example: If server has a prompt named "weather_prompt", it will be available as
1102
+ "prefix_weather_prompt"
1043
1103
 
1044
1104
  Args:
1045
- prefix: The prefix to use for the mounted server server: The FastMCP
1046
- server to mount tool_separator: Separator for tool names (defaults
1047
- to "_") resource_separator: Separator for resource URIs (defaults to
1048
- "+") prompt_separator: Separator for prompt names (defaults to "_")
1105
+ prefix: The prefix to use for the imported server
1106
+ server: The FastMCP server to import
1107
+ tool_separator: Deprecated. Separator for tool names.
1108
+ resource_separator: Deprecated and ignored. Prefix is now
1109
+ applied using the protocol://prefix/path format
1110
+ prompt_separator: Deprecated. Separator for prompt names.
1049
1111
  """
1050
- if tool_separator is None:
1051
- tool_separator = "_"
1052
- if resource_separator is None:
1053
- resource_separator = "+"
1054
- if prompt_separator is None:
1055
- prompt_separator = "_"
1112
+ if tool_separator is not None:
1113
+ # Deprecated since 2.4.0
1114
+ warnings.warn(
1115
+ "The tool_separator parameter is deprecated and will be removed in a future version. "
1116
+ "Tools are now prefixed using 'prefix_toolname' format.",
1117
+ DeprecationWarning,
1118
+ stacklevel=2,
1119
+ )
1120
+
1121
+ if resource_separator is not None:
1122
+ # Deprecated since 2.4.0
1123
+ warnings.warn(
1124
+ "The resource_separator parameter is deprecated and ignored. "
1125
+ "Resource prefixes are now added using the protocol://prefix/path format.",
1126
+ DeprecationWarning,
1127
+ stacklevel=2,
1128
+ )
1129
+
1130
+ if prompt_separator is not None:
1131
+ # Deprecated since 2.4.0
1132
+ warnings.warn(
1133
+ "The prompt_separator parameter is deprecated and will be removed in a future version. "
1134
+ "Prompts are now prefixed using 'prefix_promptname' format.",
1135
+ DeprecationWarning,
1136
+ stacklevel=2,
1137
+ )
1056
1138
 
1057
1139
  # Import tools from the mounted server
1058
- tool_prefix = f"{prefix}{tool_separator}"
1140
+ tool_prefix = f"{prefix}_"
1059
1141
  for key, tool in (await server.get_tools()).items():
1060
1142
  self._tool_manager.add_tool(tool, key=f"{tool_prefix}{key}")
1061
1143
 
1062
1144
  # Import resources and templates from the mounted server
1063
- resource_prefix = f"{prefix}{resource_separator}"
1064
- _validate_resource_prefix(resource_prefix)
1065
1145
  for key, resource in (await server.get_resources()).items():
1066
- self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
1146
+ prefixed_key = add_resource_prefix(key, prefix, self.resource_prefix_format)
1147
+ self._resource_manager.add_resource(resource, key=prefixed_key)
1148
+
1067
1149
  for key, template in (await server.get_resource_templates()).items():
1068
- self._resource_manager.add_template(template, key=f"{resource_prefix}{key}")
1150
+ prefixed_key = add_resource_prefix(key, prefix, self.resource_prefix_format)
1151
+ self._resource_manager.add_template(template, key=prefixed_key)
1069
1152
 
1070
1153
  # Import prompts from the mounted server
1071
- prompt_prefix = f"{prefix}{prompt_separator}"
1154
+ prompt_prefix = f"{prefix}_"
1072
1155
  for key, prompt in (await server.get_prompts()).items():
1073
1156
  self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}")
1074
1157
 
1075
1158
  logger.info(f"Imported server {server.name} with prefix '{prefix}'")
1076
1159
  logger.debug(f"Imported tools with prefix '{tool_prefix}'")
1077
- logger.debug(f"Imported resources with prefix '{resource_prefix}'")
1078
- logger.debug(f"Imported templates with prefix '{resource_prefix}'")
1160
+ logger.debug(f"Imported resources and templates with prefix '{prefix}/'")
1079
1161
  logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
1080
1162
 
1081
1163
  self._cache.clear()
1082
1164
 
1083
1165
  @classmethod
1084
1166
  def from_openapi(
1085
- cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
1167
+ cls,
1168
+ openapi_spec: dict[str, Any],
1169
+ client: httpx.AsyncClient,
1170
+ route_maps: list[RouteMap] | None = None,
1171
+ route_map_fn: OpenAPIRouteMapFn | None = None,
1172
+ mcp_component_fn: OpenAPIComponentFn | None = None,
1173
+ mcp_names: dict[str, str] | None = None,
1174
+ all_routes_as_tools: bool = False,
1175
+ **settings: Any,
1086
1176
  ) -> FastMCPOpenAPI:
1087
1177
  """
1088
1178
  Create a FastMCP server from an OpenAPI specification.
1089
1179
  """
1090
- from .openapi import FastMCPOpenAPI
1180
+ from .openapi import FastMCPOpenAPI, MCPType, RouteMap
1181
+
1182
+ # Deprecated since 2.5.0
1183
+ if all_routes_as_tools:
1184
+ warnings.warn(
1185
+ "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
1186
+ 'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
1187
+ DeprecationWarning,
1188
+ stacklevel=2,
1189
+ )
1190
+
1191
+ if all_routes_as_tools and route_maps:
1192
+ raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
1091
1193
 
1092
- return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
1194
+ elif all_routes_as_tools:
1195
+ route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
1196
+
1197
+ return FastMCPOpenAPI(
1198
+ openapi_spec=openapi_spec,
1199
+ client=client,
1200
+ route_maps=route_maps,
1201
+ route_map_fn=route_map_fn,
1202
+ mcp_component_fn=mcp_component_fn,
1203
+ mcp_names=mcp_names,
1204
+ **settings,
1205
+ )
1093
1206
 
1094
1207
  @classmethod
1095
1208
  def from_fastapi(
1096
- cls, app: Any, name: str | None = None, **settings: Any
1209
+ cls,
1210
+ app: Any,
1211
+ name: str | None = None,
1212
+ route_maps: list[RouteMap] | None = None,
1213
+ route_map_fn: OpenAPIRouteMapFn | None = None,
1214
+ mcp_component_fn: OpenAPIComponentFn | None = None,
1215
+ mcp_names: dict[str, str] | None = None,
1216
+ all_routes_as_tools: bool = False,
1217
+ httpx_client_kwargs: dict[str, Any] | None = None,
1218
+ **settings: Any,
1097
1219
  ) -> FastMCPOpenAPI:
1098
1220
  """
1099
1221
  Create a FastMCP server from a FastAPI application.
1100
1222
  """
1101
1223
 
1102
- from .openapi import FastMCPOpenAPI
1224
+ from .openapi import FastMCPOpenAPI, MCPType, RouteMap
1225
+
1226
+ # Deprecated since 2.5.0
1227
+ if all_routes_as_tools:
1228
+ warnings.warn(
1229
+ "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
1230
+ 'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
1231
+ DeprecationWarning,
1232
+ stacklevel=2,
1233
+ )
1234
+
1235
+ if all_routes_as_tools and route_maps:
1236
+ raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
1237
+
1238
+ elif all_routes_as_tools:
1239
+ route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
1240
+
1241
+ if httpx_client_kwargs is None:
1242
+ httpx_client_kwargs = {}
1243
+ httpx_client_kwargs.setdefault("base_url", "http://fastapi")
1103
1244
 
1104
1245
  client = httpx.AsyncClient(
1105
- transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
1246
+ transport=httpx.ASGITransport(app=app),
1247
+ **httpx_client_kwargs,
1106
1248
  )
1107
1249
 
1108
1250
  name = name or app.title
1109
1251
 
1110
1252
  return FastMCPOpenAPI(
1111
- openapi_spec=app.openapi(), client=client, name=name, **settings
1253
+ openapi_spec=app.openapi(),
1254
+ client=client,
1255
+ name=name,
1256
+ route_maps=route_maps,
1257
+ route_map_fn=route_map_fn,
1258
+ mcp_component_fn=mcp_component_fn,
1259
+ mcp_names=mcp_names,
1260
+ **settings,
1112
1261
  )
1113
1262
 
1114
1263
  @classmethod
@@ -1119,6 +1268,7 @@ class FastMCP(Generic[LifespanResultT]):
1119
1268
  | FastMCP[Any]
1120
1269
  | AnyUrl
1121
1270
  | Path
1271
+ | MCPConfig
1122
1272
  | dict[str, Any]
1123
1273
  | str,
1124
1274
  **settings: Any,
@@ -1155,84 +1305,219 @@ class FastMCP(Generic[LifespanResultT]):
1155
1305
  return cls.as_proxy(client, **settings)
1156
1306
 
1157
1307
 
1158
- def _validate_resource_prefix(prefix: str) -> None:
1159
- valid_resource = "resource://path/to/resource"
1160
- test_case = f"{prefix}{valid_resource}"
1161
- try:
1162
- AnyUrl(test_case)
1163
- except pydantic.ValidationError as e:
1164
- raise ValueError(
1165
- "Resource prefix or separator would result in an "
1166
- f"invalid resource URI (test case was {test_case!r}): {e}"
1167
- )
1168
-
1169
-
1170
1308
  class MountedServer:
1171
1309
  def __init__(
1172
1310
  self,
1173
1311
  prefix: str,
1174
1312
  server: FastMCP[LifespanResultT],
1175
- tool_separator: str | None = None,
1176
- resource_separator: str | None = None,
1177
- prompt_separator: str | None = None,
1178
1313
  ):
1179
- if tool_separator is None:
1180
- tool_separator = "_"
1181
- if resource_separator is None:
1182
- resource_separator = "+"
1183
- if prompt_separator is None:
1184
- prompt_separator = "_"
1185
-
1186
- _validate_resource_prefix(f"{prefix}{resource_separator}")
1187
-
1188
1314
  self.server = server
1189
1315
  self.prefix = prefix
1190
- self.tool_separator = tool_separator
1191
- self.resource_separator = resource_separator
1192
- self.prompt_separator = prompt_separator
1193
1316
 
1194
1317
  async def get_tools(self) -> dict[str, Tool]:
1195
1318
  tools = await self.server.get_tools()
1196
- return {
1197
- f"{self.prefix}{self.tool_separator}{key}": tool
1198
- for key, tool in tools.items()
1199
- }
1319
+ return {f"{self.prefix}_{key}": tool for key, tool in tools.items()}
1200
1320
 
1201
1321
  async def get_resources(self) -> dict[str, Resource]:
1202
1322
  resources = await self.server.get_resources()
1203
1323
  return {
1204
- f"{self.prefix}{self.resource_separator}{key}": resource
1324
+ add_resource_prefix(
1325
+ key, self.prefix, self.server.resource_prefix_format
1326
+ ): resource
1205
1327
  for key, resource in resources.items()
1206
1328
  }
1207
1329
 
1208
1330
  async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
1209
1331
  templates = await self.server.get_resource_templates()
1210
1332
  return {
1211
- f"{self.prefix}{self.resource_separator}{key}": template
1333
+ add_resource_prefix(
1334
+ key, self.prefix, self.server.resource_prefix_format
1335
+ ): template
1212
1336
  for key, template in templates.items()
1213
1337
  }
1214
1338
 
1215
1339
  async def get_prompts(self) -> dict[str, Prompt]:
1216
1340
  prompts = await self.server.get_prompts()
1217
- return {
1218
- f"{self.prefix}{self.prompt_separator}{key}": prompt
1219
- for key, prompt in prompts.items()
1220
- }
1341
+ return {f"{self.prefix}_{key}": prompt for key, prompt in prompts.items()}
1221
1342
 
1222
1343
  def match_tool(self, key: str) -> bool:
1223
- return key.startswith(f"{self.prefix}{self.tool_separator}")
1344
+ return key.startswith(f"{self.prefix}_")
1224
1345
 
1225
1346
  def strip_tool_prefix(self, key: str) -> str:
1226
- return key.removeprefix(f"{self.prefix}{self.tool_separator}")
1347
+ return key.removeprefix(f"{self.prefix}_")
1227
1348
 
1228
1349
  def match_resource(self, key: str) -> bool:
1229
- return key.startswith(f"{self.prefix}{self.resource_separator}")
1350
+ return has_resource_prefix(key, self.prefix, self.server.resource_prefix_format)
1230
1351
 
1231
1352
  def strip_resource_prefix(self, key: str) -> str:
1232
- return key.removeprefix(f"{self.prefix}{self.resource_separator}")
1353
+ return remove_resource_prefix(
1354
+ key, self.prefix, self.server.resource_prefix_format
1355
+ )
1233
1356
 
1234
1357
  def match_prompt(self, key: str) -> bool:
1235
- return key.startswith(f"{self.prefix}{self.prompt_separator}")
1358
+ return key.startswith(f"{self.prefix}_")
1236
1359
 
1237
1360
  def strip_prompt_prefix(self, key: str) -> str:
1238
- return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
1361
+ return key.removeprefix(f"{self.prefix}_")
1362
+
1363
+
1364
+ def add_resource_prefix(
1365
+ uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None
1366
+ ) -> str:
1367
+ """Add a prefix to a resource URI.
1368
+
1369
+ Args:
1370
+ uri: The original resource URI
1371
+ prefix: The prefix to add
1372
+
1373
+ Returns:
1374
+ The resource URI with the prefix added
1375
+
1376
+ Examples:
1377
+ >>> add_resource_prefix("resource://path/to/resource", "prefix")
1378
+ "resource://prefix/path/to/resource" # with new style
1379
+ >>> add_resource_prefix("resource://path/to/resource", "prefix")
1380
+ "prefix+resource://path/to/resource" # with legacy style
1381
+ >>> add_resource_prefix("resource:///absolute/path", "prefix")
1382
+ "resource://prefix//absolute/path" # with new style
1383
+
1384
+ Raises:
1385
+ ValueError: If the URI doesn't match the expected protocol://path format
1386
+ """
1387
+ if not prefix:
1388
+ return uri
1389
+
1390
+ # Get the server settings to check for legacy format preference
1391
+
1392
+ if prefix_format is None:
1393
+ prefix_format = fastmcp.settings.settings.resource_prefix_format
1394
+
1395
+ if prefix_format == "protocol":
1396
+ # Legacy style: prefix+protocol://path
1397
+ return f"{prefix}+{uri}"
1398
+ elif prefix_format == "path":
1399
+ # New style: protocol://prefix/path
1400
+ # Split the URI into protocol and path
1401
+ match = URI_PATTERN.match(uri)
1402
+ if not match:
1403
+ raise ValueError(
1404
+ f"Invalid URI format: {uri}. Expected protocol://path format."
1405
+ )
1406
+
1407
+ protocol, path = match.groups()
1408
+
1409
+ # Add the prefix to the path
1410
+ return f"{protocol}{prefix}/{path}"
1411
+ else:
1412
+ raise ValueError(f"Invalid prefix format: {prefix_format}")
1413
+
1414
+
1415
+ def remove_resource_prefix(
1416
+ uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None
1417
+ ) -> str:
1418
+ """Remove a prefix from a resource URI.
1419
+
1420
+ Args:
1421
+ uri: The resource URI with a prefix
1422
+ prefix: The prefix to remove
1423
+ prefix_format: The format of the prefix to remove
1424
+ Returns:
1425
+ The resource URI with the prefix removed
1426
+
1427
+ Examples:
1428
+ >>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
1429
+ "resource://path/to/resource" # with new style
1430
+ >>> remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
1431
+ "resource://path/to/resource" # with legacy style
1432
+ >>> remove_resource_prefix("resource://prefix//absolute/path", "prefix")
1433
+ "resource:///absolute/path" # with new style
1434
+
1435
+ Raises:
1436
+ ValueError: If the URI doesn't match the expected protocol://path format
1437
+ """
1438
+ if not prefix:
1439
+ return uri
1440
+
1441
+ if prefix_format is None:
1442
+ prefix_format = fastmcp.settings.settings.resource_prefix_format
1443
+
1444
+ if prefix_format == "protocol":
1445
+ # Legacy style: prefix+protocol://path
1446
+ legacy_prefix = f"{prefix}+"
1447
+ if uri.startswith(legacy_prefix):
1448
+ return uri[len(legacy_prefix) :]
1449
+ return uri
1450
+ elif prefix_format == "path":
1451
+ # New style: protocol://prefix/path
1452
+ # Split the URI into protocol and path
1453
+ match = URI_PATTERN.match(uri)
1454
+ if not match:
1455
+ raise ValueError(
1456
+ f"Invalid URI format: {uri}. Expected protocol://path format."
1457
+ )
1458
+
1459
+ protocol, path = match.groups()
1460
+
1461
+ # Check if the path starts with the prefix followed by a /
1462
+ prefix_pattern = f"^{re.escape(prefix)}/(.*?)$"
1463
+ path_match = re.match(prefix_pattern, path)
1464
+ if not path_match:
1465
+ return uri
1466
+
1467
+ # Return the URI without the prefix
1468
+ return f"{protocol}{path_match.group(1)}"
1469
+ else:
1470
+ raise ValueError(f"Invalid prefix format: {prefix_format}")
1471
+
1472
+
1473
+ def has_resource_prefix(
1474
+ uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None
1475
+ ) -> bool:
1476
+ """Check if a resource URI has a specific prefix.
1477
+
1478
+ Args:
1479
+ uri: The resource URI to check
1480
+ prefix: The prefix to look for
1481
+
1482
+ Returns:
1483
+ True if the URI has the specified prefix, False otherwise
1484
+
1485
+ Examples:
1486
+ >>> has_resource_prefix("resource://prefix/path/to/resource", "prefix")
1487
+ True # with new style
1488
+ >>> has_resource_prefix("prefix+resource://path/to/resource", "prefix")
1489
+ True # with legacy style
1490
+ >>> has_resource_prefix("resource://other/path/to/resource", "prefix")
1491
+ False
1492
+
1493
+ Raises:
1494
+ ValueError: If the URI doesn't match the expected protocol://path format
1495
+ """
1496
+ if not prefix:
1497
+ return False
1498
+
1499
+ # Get the server settings to check for legacy format preference
1500
+
1501
+ if prefix_format is None:
1502
+ prefix_format = fastmcp.settings.settings.resource_prefix_format
1503
+
1504
+ if prefix_format == "protocol":
1505
+ # Legacy style: prefix+protocol://path
1506
+ legacy_prefix = f"{prefix}+"
1507
+ return uri.startswith(legacy_prefix)
1508
+ elif prefix_format == "path":
1509
+ # New style: protocol://prefix/path
1510
+ # Split the URI into protocol and path
1511
+ match = URI_PATTERN.match(uri)
1512
+ if not match:
1513
+ raise ValueError(
1514
+ f"Invalid URI format: {uri}. Expected protocol://path format."
1515
+ )
1516
+
1517
+ _, path = match.groups()
1518
+
1519
+ # Check if the path starts with the prefix followed by a /
1520
+ prefix_pattern = f"^{re.escape(prefix)}/"
1521
+ return bool(re.match(prefix_pattern, path))
1522
+ else:
1523
+ raise ValueError(f"Invalid prefix format: {prefix_format}")