fastmcp 2.4.0__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/openapi.py CHANGED
@@ -5,8 +5,10 @@ from __future__ import annotations
5
5
  import enum
6
6
  import json
7
7
  import re
8
+ import warnings
9
+ from collections import Counter
8
10
  from collections.abc import Callable
9
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
10
12
  from re import Pattern
11
13
  from typing import TYPE_CHECKING, Any, Literal
12
14
 
@@ -16,11 +18,13 @@ from pydantic.networks import AnyUrl
16
18
 
17
19
  from fastmcp.exceptions import ToolError
18
20
  from fastmcp.resources import Resource, ResourceTemplate
21
+ from fastmcp.server.dependencies import get_http_request
19
22
  from fastmcp.server.server import FastMCP
20
23
  from fastmcp.tools.tool import Tool, _convert_to_content
21
24
  from fastmcp.utilities import openapi
22
25
  from fastmcp.utilities.logging import get_logger
23
26
  from fastmcp.utilities.openapi import (
27
+ HTTPRoute,
24
28
  _combine_schemas,
25
29
  format_description_with_responses,
26
30
  )
@@ -33,13 +37,88 @@ logger = get_logger(__name__)
33
37
  HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
34
38
 
35
39
 
40
+ def _slugify(text: str) -> str:
41
+ """
42
+ Convert text to a URL-friendly slug format that only contains lowercase
43
+ letters, uppercase letters, numbers, and underscores.
44
+ """
45
+ if not text:
46
+ return ""
47
+
48
+ # Replace spaces and common separators with underscores
49
+ slug = re.sub(r"[\s\-\.]+", "_", text)
50
+
51
+ # Remove non-alphanumeric characters except underscores
52
+ slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
53
+
54
+ # Remove multiple consecutive underscores
55
+ slug = re.sub(r"_+", "_", slug)
56
+
57
+ # Remove leading/trailing underscores
58
+ slug = slug.strip("_")
59
+
60
+ return slug
61
+
62
+
63
+ def _get_mcp_client_headers() -> dict[str, str]:
64
+ """
65
+ Extract headers from the current MCP client HTTP request if available.
66
+
67
+ These headers will take precedence over OpenAPI-defined headers when both are present.
68
+
69
+ Returns:
70
+ Dictionary of header name-value pairs (lowercased names), or empty dict if no HTTP request is active.
71
+ """
72
+ try:
73
+ http_request = get_http_request()
74
+ return {
75
+ name.lower(): str(value) for name, value in http_request.headers.items()
76
+ }
77
+ except RuntimeError:
78
+ # No active HTTP request (e.g., STDIO transport), return empty dict
79
+ return {}
80
+
81
+
82
+ # Type definitions for the mapping functions
83
+ RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
84
+ ComponentFn = Callable[
85
+ [
86
+ HTTPRoute,
87
+ "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
88
+ ],
89
+ None,
90
+ ]
91
+
92
+
93
+ class MCPType(enum.Enum):
94
+ """Type of FastMCP component to create from a route.
95
+
96
+ Enum values:
97
+ TOOL: Convert the route to a callable Tool
98
+ RESOURCE: Convert the route to a Resource (typically GET endpoints)
99
+ RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
100
+ EXCLUDE: Exclude the route from being converted to any MCP component
101
+ IGNORE: Deprecated, use EXCLUDE instead
102
+ """
103
+
104
+ TOOL = "TOOL"
105
+ RESOURCE = "RESOURCE"
106
+ RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
107
+ # PROMPT = "PROMPT"
108
+ EXCLUDE = "EXCLUDE"
109
+
110
+
111
+ # Keep RouteType as an alias to MCPType for backward compatibility
36
112
  class RouteType(enum.Enum):
37
- """Type of FastMCP component to create from a route."""
113
+ """
114
+ Deprecated: Use MCPType instead.
115
+
116
+ This enum is kept for backward compatibility and will be removed in a future version.
117
+ """
38
118
 
39
119
  TOOL = "TOOL"
40
120
  RESOURCE = "RESOURCE"
41
121
  RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
42
- PROMPT = "PROMPT"
43
122
  IGNORE = "IGNORE"
44
123
 
45
124
 
@@ -47,32 +126,71 @@ class RouteType(enum.Enum):
47
126
  class RouteMap:
48
127
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
128
 
50
- methods: list[HttpMethod] | Literal["*"]
51
- pattern: Pattern[str] | str
52
- route_type: RouteType
129
+ methods: list[HttpMethod] | Literal["*"] = field(default="*")
130
+ pattern: Pattern[str] | str = field(default=r".*")
131
+ mcp_type: MCPType | None = field(default=None)
132
+ route_type: RouteType | MCPType | None = field(default=None)
133
+ tags: set[str] = field(default_factory=set)
134
+
135
+ def __post_init__(self):
136
+ """Validate and process the route map after initialization."""
137
+ # Handle backward compatibility for route_type, deprecated in 2.5.0
138
+ if self.mcp_type is None and self.route_type is not None:
139
+ warnings.warn(
140
+ "The 'route_type' parameter is deprecated and will be removed in a future version. "
141
+ "Use 'mcp_type' instead with the appropriate MCPType value.",
142
+ DeprecationWarning,
143
+ stacklevel=2,
144
+ )
145
+ if isinstance(self.route_type, RouteType):
146
+ warnings.warn(
147
+ "The RouteType class is deprecated and will be removed in a future version. "
148
+ "Use MCPType instead.",
149
+ DeprecationWarning,
150
+ stacklevel=2,
151
+ )
152
+ # Check for the deprecated IGNORE value
153
+ if self.route_type == RouteType.IGNORE:
154
+ warnings.warn(
155
+ "RouteType.IGNORE is deprecated and will be removed in a future version. "
156
+ "Use MCPType.EXCLUDE instead.",
157
+ DeprecationWarning,
158
+ stacklevel=2,
159
+ )
160
+
161
+ # Convert from RouteType to MCPType if needed
162
+ if isinstance(self.route_type, RouteType):
163
+ route_type_name = self.route_type.name
164
+ if route_type_name == "IGNORE":
165
+ route_type_name = "EXCLUDE"
166
+ self.mcp_type = getattr(MCPType, route_type_name)
167
+ else:
168
+ self.mcp_type = self.route_type
169
+ elif self.mcp_type is None:
170
+ raise ValueError("`mcp_type` must be provided")
171
+
172
+ # Set route_type to match mcp_type for backward compatibility
173
+ if self.route_type is None:
174
+ self.route_type = self.mcp_type
53
175
 
54
176
 
55
177
  # Default route mappings as a list, where order determines priority
56
178
  DEFAULT_ROUTE_MAPPINGS = [
57
179
  # GET requests with path parameters go to ResourceTemplate
58
180
  RouteMap(
59
- methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE
181
+ methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
60
182
  ),
61
183
  # GET requests without path parameters go to Resource
62
- RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
184
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
63
185
  # All other HTTP methods go to Tool
64
- RouteMap(
65
- methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
66
- pattern=r".*",
67
- route_type=RouteType.TOOL,
68
- ),
186
+ RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
69
187
  ]
70
188
 
71
189
 
72
190
  def _determine_route_type(
73
191
  route: openapi.HTTPRoute,
74
192
  mappings: list[RouteMap],
75
- ) -> RouteType:
193
+ ) -> MCPType:
76
194
  """
77
195
  Determines the FastMCP component type based on the route and mappings.
78
196
 
@@ -81,7 +199,7 @@ def _determine_route_type(
81
199
  mappings: List of RouteMap objects in priority order
82
200
 
83
201
  Returns:
84
- RouteType for this route
202
+ MCPType for this route
85
203
  """
86
204
  # Check mappings in priority order (first match wins)
87
205
  for route_map in mappings:
@@ -94,20 +212,24 @@ def _determine_route_type(
94
212
  pattern_matches = re.search(route_map.pattern, route.path)
95
213
 
96
214
  if pattern_matches:
215
+ # Check if tags match (if specified)
216
+ # If route_map.tags is empty, tags are not matched
217
+ # If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
218
+ if route_map.tags:
219
+ route_tags_set = set(route.tags or [])
220
+ if not route_map.tags.issubset(route_tags_set):
221
+ # Tags don't match, continue to next mapping
222
+ continue
223
+
224
+ # We know mcp_type is not None here due to post_init validation
225
+ assert route_map.mcp_type is not None
97
226
  logger.debug(
98
- f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}"
227
+ f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
99
228
  )
100
- return route_map.route_type
229
+ return route_map.mcp_type
101
230
 
102
231
  # Default fallback
103
- return RouteType.TOOL
104
-
105
-
106
- # Placeholder function to provide function metadata
107
- async def _openapi_passthrough(*args, **kwargs):
108
- """Placeholder function for OpenAPI endpoints."""
109
- # This is kept for metadata generation purposes
110
- pass
232
+ return MCPType.TOOL
111
233
 
112
234
 
113
235
  class OpenAPITool(Tool):
@@ -288,13 +410,21 @@ class OpenAPITool(Tool):
288
410
 
289
411
  # Prepare headers - fix typing by ensuring all values are strings
290
412
  headers = {}
413
+
414
+ # Start with OpenAPI-defined header parameters
415
+ openapi_headers = {}
291
416
  for p in self._route.parameters:
292
417
  if (
293
418
  p.location == "header"
294
419
  and p.name in kwargs
295
420
  and kwargs[p.name] is not None
296
421
  ):
297
- headers[p.name] = str(kwargs[p.name])
422
+ openapi_headers[p.name.lower()] = str(kwargs[p.name])
423
+ headers.update(openapi_headers)
424
+
425
+ # Add headers from the current MCP client HTTP request (these take precedence)
426
+ mcp_headers = _get_mcp_client_headers()
427
+ headers.update(mcp_headers)
298
428
 
299
429
  # Prepare request body
300
430
  json_data = None
@@ -442,10 +572,16 @@ class OpenAPIResource(Resource):
442
572
  if value is not None and value != "":
443
573
  query_params[param.name] = value
444
574
 
575
+ # Prepare headers from MCP client request if available
576
+ headers = {}
577
+ mcp_headers = _get_mcp_client_headers()
578
+ headers.update(mcp_headers)
579
+
445
580
  response = await self._client.request(
446
581
  method=self._route.method,
447
582
  url=path,
448
583
  params=query_params,
584
+ headers=headers,
449
585
  timeout=self._timeout,
450
586
  )
451
587
 
@@ -546,7 +682,7 @@ class FastMCPOpenAPI(FastMCP):
546
682
 
547
683
  Example:
548
684
  ```python
549
- from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteType
685
+ from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
550
686
  import httpx
551
687
 
552
688
  # Define custom route mappings
@@ -555,17 +691,17 @@ class FastMCPOpenAPI(FastMCP):
555
691
  RouteMap(
556
692
  methods=["GET", "POST", "PATCH"],
557
693
  pattern=r".*/users/.*",
558
- route_type=RouteType.RESOURCE_TEMPLATE
694
+ mcp_type=MCPType.RESOURCE_TEMPLATE
559
695
  ),
560
696
  # Map all analytics endpoints to Tool
561
697
  RouteMap(
562
698
  methods=["GET"],
563
699
  pattern=r".*/analytics/.*",
564
- route_type=RouteType.TOOL
700
+ mcp_type=MCPType.TOOL
565
701
  ),
566
702
  ]
567
703
 
568
- # Create server with custom mappings
704
+ # Create server with custom mappings and route mapper
569
705
  server = FastMCPOpenAPI(
570
706
  openapi_spec=spec,
571
707
  client=httpx.AsyncClient(),
@@ -581,6 +717,9 @@ class FastMCPOpenAPI(FastMCP):
581
717
  client: httpx.AsyncClient,
582
718
  name: str | None = None,
583
719
  route_maps: list[RouteMap] | None = None,
720
+ route_map_fn: RouteMapFn | None = None,
721
+ mcp_component_fn: ComponentFn | None = None,
722
+ mcp_names: dict[str, str] | None = None,
584
723
  timeout: float | None = None,
585
724
  **settings: Any,
586
725
  ):
@@ -592,6 +731,17 @@ class FastMCPOpenAPI(FastMCP):
592
731
  client: httpx AsyncClient for making HTTP requests
593
732
  name: Optional name for the server
594
733
  route_maps: Optional list of RouteMap objects defining route mappings
734
+ route_map_fn: Optional callable for advanced route type mapping.
735
+ Receives (route, mcp_type) and returns MCPType or None.
736
+ Called on every route, including excluded ones.
737
+ mcp_component_fn: Optional callable for component customization.
738
+ Receives (route, component) and can modify the component in-place.
739
+ Called on every created component.
740
+ mcp_names: Optional dictionary mapping operationId to desired component names.
741
+ If an operationId is not in the dictionary, falls back to using the
742
+ operationId up to the first double underscore. If no operationId exists,
743
+ falls back to slugified summary or path-based naming.
744
+ All names are truncated to 56 characters maximum.
595
745
  timeout: Optional timeout (in seconds) for all requests
596
746
  **settings: Additional settings for FastMCP
597
747
  """
@@ -599,6 +749,18 @@ class FastMCPOpenAPI(FastMCP):
599
749
 
600
750
  self._client = client
601
751
  self._timeout = timeout
752
+ self._route_map_fn = route_map_fn
753
+ self._mcp_component_fn = mcp_component_fn
754
+ self._mcp_names = mcp_names or {}
755
+
756
+ # Keep track of names to detect collisions
757
+ self._used_names = {
758
+ "tool": Counter(),
759
+ "resource": Counter(),
760
+ "resource_template": Counter(),
761
+ "prompt": Counter(),
762
+ }
763
+
602
764
  http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
603
765
 
604
766
  # Process routes
@@ -607,34 +769,97 @@ class FastMCPOpenAPI(FastMCP):
607
769
  # Determine route type based on mappings or default rules
608
770
  route_type = _determine_route_type(route, route_maps)
609
771
 
610
- # Use operation_id if available, otherwise generate a name
611
- operation_id = route.operation_id
612
- if not operation_id:
613
- # Generate operation ID from method and path
614
- path_parts = route.path.strip("/").split("/")
615
- path_name = "_".join(p for p in path_parts if not p.startswith("{"))
616
- operation_id = f"{route.method.lower()}_{path_name}"
617
-
618
- if route_type == RouteType.TOOL:
619
- self._create_openapi_tool(route, operation_id)
620
- elif route_type == RouteType.RESOURCE:
621
- self._create_openapi_resource(route, operation_id)
622
- elif route_type == RouteType.RESOURCE_TEMPLATE:
623
- self._create_openapi_template(route, operation_id)
624
- elif route_type == RouteType.PROMPT:
625
- # Not implemented yet
626
- logger.warning(
627
- f"PROMPT route type not implemented: {route.method} {route.path}"
628
- )
629
- elif route_type == RouteType.IGNORE:
630
- logger.info(f"Ignoring route: {route.method} {route.path}")
772
+ # Call route_map_fn if provided
773
+ if self._route_map_fn is not None:
774
+ try:
775
+ result = self._route_map_fn(route, route_type)
776
+ if result is not None:
777
+ route_type = result
778
+ logger.debug(
779
+ f"Route {route.method} {route.path} mapping customized by route_map_fn: "
780
+ f"type={route_type.name}"
781
+ )
782
+ except Exception as e:
783
+ logger.warning(
784
+ f"Error in route_map_fn for {route.method} {route.path}: {e}. "
785
+ f"Using default values."
786
+ )
787
+
788
+ # Generate a default name from the route
789
+ component_name = self._generate_default_name(route, route_type)
790
+
791
+ if route_type == MCPType.TOOL:
792
+ self._create_openapi_tool(route, component_name)
793
+ elif route_type == MCPType.RESOURCE:
794
+ self._create_openapi_resource(route, component_name)
795
+ elif route_type == MCPType.RESOURCE_TEMPLATE:
796
+ self._create_openapi_template(route, component_name)
797
+ elif route_type == MCPType.EXCLUDE:
798
+ logger.info(f"Excluding route: {route.method} {route.path}")
631
799
 
632
800
  logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
633
801
 
634
- def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str):
802
+ def _generate_default_name(
803
+ self, route: openapi.HTTPRoute, mcp_type: MCPType
804
+ ) -> str:
805
+ """Generate a default name from the route using the configured strategy."""
806
+ name = ""
807
+
808
+ # First check if there's a custom mapping for this operationId
809
+ if route.operation_id:
810
+ if route.operation_id in self._mcp_names:
811
+ name = self._mcp_names[route.operation_id]
812
+ else:
813
+ # If there's a double underscore in the operationId, use the first part
814
+ name = route.operation_id.split("__")[0]
815
+ else:
816
+ name = route.summary or f"{route.method}_{route.path}"
817
+
818
+ name = _slugify(name)
819
+
820
+ # Truncate to 56 characters maximum
821
+ if len(name) > 56:
822
+ name = name[:56]
823
+
824
+ return name
825
+
826
+ def _get_unique_name(
827
+ self,
828
+ name: str,
829
+ component_type: Literal["tool", "resource", "resource_template", "prompt"],
830
+ ) -> str:
831
+ """
832
+ Ensure the name is unique within its component type by appending numbers if needed.
833
+
834
+ Args:
835
+ name: The proposed name
836
+ component_type: The type of component ("tools", "resources", or "templates")
837
+
838
+ Returns:
839
+ str: A unique name for the component
840
+ """
841
+ # Check if the name is already used
842
+ self._used_names[component_type][name] += 1
843
+ if self._used_names[component_type][name] == 1:
844
+ return name
845
+
846
+ else:
847
+ # Create the new name
848
+ new_name = f"{name}_{self._used_names[component_type][name]}"
849
+ logger.debug(
850
+ f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
851
+ f"Using '{new_name}' instead."
852
+ )
853
+
854
+ return new_name
855
+
856
+ def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
635
857
  """Creates and registers an OpenAPITool with enhanced description."""
636
858
  combined_schema = _combine_schemas(route)
637
- tool_name = operation_id
859
+
860
+ # Get a unique tool name
861
+ tool_name = self._get_unique_name(name, "tool")
862
+
638
863
  base_description = (
639
864
  route.description
640
865
  or route.summary
@@ -658,16 +883,30 @@ class FastMCPOpenAPI(FastMCP):
658
883
  tags=set(route.tags or []),
659
884
  timeout=self._timeout,
660
885
  )
886
+
887
+ # Call component_fn if provided
888
+ if self._mcp_component_fn is not None:
889
+ try:
890
+ self._mcp_component_fn(route, tool)
891
+ logger.debug(f"Tool {tool_name} customized by component_fn")
892
+ except Exception as e:
893
+ logger.warning(
894
+ f"Error in component_fn for tool {tool_name}: {e}. "
895
+ f"Using component as-is."
896
+ )
897
+
661
898
  # Register the tool by directly assigning to the tools dictionary
662
899
  self._tool_manager._tools[tool_name] = tool
663
900
  logger.debug(
664
901
  f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
665
902
  )
666
903
 
667
- def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
904
+ def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
668
905
  """Creates and registers an OpenAPIResource with enhanced description."""
669
- resource_name = operation_id
670
- resource_uri = f"resource://openapi/{resource_name}"
906
+ # Get a unique resource name
907
+ resource_name = self._get_unique_name(name, "resource")
908
+
909
+ resource_uri = f"resource://{resource_name}"
671
910
  base_description = (
672
911
  route.description or route.summary or f"Represents {route.path}"
673
912
  )
@@ -689,19 +928,33 @@ class FastMCPOpenAPI(FastMCP):
689
928
  tags=set(route.tags or []),
690
929
  timeout=self._timeout,
691
930
  )
931
+
932
+ # Call component_fn if provided
933
+ if self._mcp_component_fn is not None:
934
+ try:
935
+ self._mcp_component_fn(route, resource)
936
+ logger.debug(f"Resource {resource_uri} customized by component_fn")
937
+ except Exception as e:
938
+ logger.warning(
939
+ f"Error in component_fn for resource {resource_uri}: {e}. "
940
+ f"Using component as-is."
941
+ )
942
+
692
943
  # Register the resource by directly assigning to the resources dictionary
693
944
  self._resource_manager._resources[str(resource.uri)] = resource
694
945
  logger.debug(
695
946
  f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
696
947
  )
697
948
 
698
- def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
949
+ def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
699
950
  """Creates and registers an OpenAPIResourceTemplate with enhanced description."""
700
- template_name = operation_id
951
+ # Get a unique template name
952
+ template_name = self._get_unique_name(name, "resource_template")
953
+
701
954
  path_params = [p.name for p in route.parameters if p.location == "path"]
702
955
  path_params.sort() # Sort for consistent URIs
703
956
 
704
- uri_template_str = f"resource://openapi/{template_name}"
957
+ uri_template_str = f"resource://{template_name}"
705
958
  if path_params:
706
959
  uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
707
960
 
@@ -749,13 +1002,20 @@ class FastMCPOpenAPI(FastMCP):
749
1002
  tags=set(route.tags or []),
750
1003
  timeout=self._timeout,
751
1004
  )
1005
+
1006
+ # Call component_fn if provided
1007
+ if self._mcp_component_fn is not None:
1008
+ try:
1009
+ self._mcp_component_fn(route, template)
1010
+ logger.debug(f"Template {uri_template_str} customized by component_fn")
1011
+ except Exception as e:
1012
+ logger.warning(
1013
+ f"Error in component_fn for template {uri_template_str}: {e}. "
1014
+ f"Using component as-is."
1015
+ )
1016
+
752
1017
  # Register the template by directly assigning to the templates dictionary
753
1018
  self._resource_manager._templates[uri_template_str] = template
754
1019
  logger.debug(
755
1020
  f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
756
1021
  )
757
-
758
- async def _mcp_call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
759
- """Override the call_tool method to return the raw result without converting to content."""
760
- result = await self._tool_manager.call_tool(name, arguments)
761
- return result