fastmcp 2.4.0__py3-none-any.whl → 2.5.1__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_headers
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,69 @@ 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
+ # Type definitions for the mapping functions
64
+ RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
65
+ ComponentFn = Callable[
66
+ [
67
+ HTTPRoute,
68
+ "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
69
+ ],
70
+ None,
71
+ ]
72
+
73
+
74
+ class MCPType(enum.Enum):
75
+ """Type of FastMCP component to create from a route.
76
+
77
+ Enum values:
78
+ TOOL: Convert the route to a callable Tool
79
+ RESOURCE: Convert the route to a Resource (typically GET endpoints)
80
+ RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
81
+ EXCLUDE: Exclude the route from being converted to any MCP component
82
+ IGNORE: Deprecated, use EXCLUDE instead
83
+ """
84
+
85
+ TOOL = "TOOL"
86
+ RESOURCE = "RESOURCE"
87
+ RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
88
+ # PROMPT = "PROMPT"
89
+ EXCLUDE = "EXCLUDE"
90
+
91
+
92
+ # Keep RouteType as an alias to MCPType for backward compatibility
36
93
  class RouteType(enum.Enum):
37
- """Type of FastMCP component to create from a route."""
94
+ """
95
+ Deprecated: Use MCPType instead.
96
+
97
+ This enum is kept for backward compatibility and will be removed in a future version.
98
+ """
38
99
 
39
100
  TOOL = "TOOL"
40
101
  RESOURCE = "RESOURCE"
41
102
  RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
42
- PROMPT = "PROMPT"
43
103
  IGNORE = "IGNORE"
44
104
 
45
105
 
@@ -47,32 +107,71 @@ class RouteType(enum.Enum):
47
107
  class RouteMap:
48
108
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
109
 
50
- methods: list[HttpMethod] | Literal["*"]
51
- pattern: Pattern[str] | str
52
- route_type: RouteType
110
+ methods: list[HttpMethod] | Literal["*"] = field(default="*")
111
+ pattern: Pattern[str] | str = field(default=r".*")
112
+ mcp_type: MCPType | None = field(default=None)
113
+ route_type: RouteType | MCPType | None = field(default=None)
114
+ tags: set[str] = field(default_factory=set)
115
+
116
+ def __post_init__(self):
117
+ """Validate and process the route map after initialization."""
118
+ # Handle backward compatibility for route_type, deprecated in 2.5.0
119
+ if self.mcp_type is None and self.route_type is not None:
120
+ warnings.warn(
121
+ "The 'route_type' parameter is deprecated and will be removed in a future version. "
122
+ "Use 'mcp_type' instead with the appropriate MCPType value.",
123
+ DeprecationWarning,
124
+ stacklevel=2,
125
+ )
126
+ if isinstance(self.route_type, RouteType):
127
+ warnings.warn(
128
+ "The RouteType class is deprecated and will be removed in a future version. "
129
+ "Use MCPType instead.",
130
+ DeprecationWarning,
131
+ stacklevel=2,
132
+ )
133
+ # Check for the deprecated IGNORE value
134
+ if self.route_type == RouteType.IGNORE:
135
+ warnings.warn(
136
+ "RouteType.IGNORE is deprecated and will be removed in a future version. "
137
+ "Use MCPType.EXCLUDE instead.",
138
+ DeprecationWarning,
139
+ stacklevel=2,
140
+ )
141
+
142
+ # Convert from RouteType to MCPType if needed
143
+ if isinstance(self.route_type, RouteType):
144
+ route_type_name = self.route_type.name
145
+ if route_type_name == "IGNORE":
146
+ route_type_name = "EXCLUDE"
147
+ self.mcp_type = getattr(MCPType, route_type_name)
148
+ else:
149
+ self.mcp_type = self.route_type
150
+ elif self.mcp_type is None:
151
+ raise ValueError("`mcp_type` must be provided")
152
+
153
+ # Set route_type to match mcp_type for backward compatibility
154
+ if self.route_type is None:
155
+ self.route_type = self.mcp_type
53
156
 
54
157
 
55
158
  # Default route mappings as a list, where order determines priority
56
159
  DEFAULT_ROUTE_MAPPINGS = [
57
160
  # GET requests with path parameters go to ResourceTemplate
58
161
  RouteMap(
59
- methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE
162
+ methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
60
163
  ),
61
164
  # GET requests without path parameters go to Resource
62
- RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
165
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
63
166
  # 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
- ),
167
+ RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
69
168
  ]
70
169
 
71
170
 
72
171
  def _determine_route_type(
73
172
  route: openapi.HTTPRoute,
74
173
  mappings: list[RouteMap],
75
- ) -> RouteType:
174
+ ) -> MCPType:
76
175
  """
77
176
  Determines the FastMCP component type based on the route and mappings.
78
177
 
@@ -81,7 +180,7 @@ def _determine_route_type(
81
180
  mappings: List of RouteMap objects in priority order
82
181
 
83
182
  Returns:
84
- RouteType for this route
183
+ MCPType for this route
85
184
  """
86
185
  # Check mappings in priority order (first match wins)
87
186
  for route_map in mappings:
@@ -94,20 +193,24 @@ def _determine_route_type(
94
193
  pattern_matches = re.search(route_map.pattern, route.path)
95
194
 
96
195
  if pattern_matches:
196
+ # Check if tags match (if specified)
197
+ # If route_map.tags is empty, tags are not matched
198
+ # If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
199
+ if route_map.tags:
200
+ route_tags_set = set(route.tags or [])
201
+ if not route_map.tags.issubset(route_tags_set):
202
+ # Tags don't match, continue to next mapping
203
+ continue
204
+
205
+ # We know mcp_type is not None here due to post_init validation
206
+ assert route_map.mcp_type is not None
97
207
  logger.debug(
98
- f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}"
208
+ f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
99
209
  )
100
- return route_map.route_type
210
+ return route_map.mcp_type
101
211
 
102
212
  # 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
213
+ return MCPType.TOOL
111
214
 
112
215
 
113
216
  class OpenAPITool(Tool):
@@ -288,13 +391,21 @@ class OpenAPITool(Tool):
288
391
 
289
392
  # Prepare headers - fix typing by ensuring all values are strings
290
393
  headers = {}
394
+
395
+ # Start with OpenAPI-defined header parameters
396
+ openapi_headers = {}
291
397
  for p in self._route.parameters:
292
398
  if (
293
399
  p.location == "header"
294
400
  and p.name in kwargs
295
401
  and kwargs[p.name] is not None
296
402
  ):
297
- headers[p.name] = str(kwargs[p.name])
403
+ openapi_headers[p.name.lower()] = str(kwargs[p.name])
404
+ headers.update(openapi_headers)
405
+
406
+ # Add headers from the current MCP client HTTP request (these take precedence)
407
+ mcp_headers = get_http_headers()
408
+ headers.update(mcp_headers)
298
409
 
299
410
  # Prepare request body
300
411
  json_data = None
@@ -442,10 +553,16 @@ class OpenAPIResource(Resource):
442
553
  if value is not None and value != "":
443
554
  query_params[param.name] = value
444
555
 
556
+ # Prepare headers from MCP client request if available
557
+ headers = {}
558
+ mcp_headers = get_http_headers()
559
+ headers.update(mcp_headers)
560
+
445
561
  response = await self._client.request(
446
562
  method=self._route.method,
447
563
  url=path,
448
564
  params=query_params,
565
+ headers=headers,
449
566
  timeout=self._timeout,
450
567
  )
451
568
 
@@ -546,7 +663,7 @@ class FastMCPOpenAPI(FastMCP):
546
663
 
547
664
  Example:
548
665
  ```python
549
- from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteType
666
+ from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
550
667
  import httpx
551
668
 
552
669
  # Define custom route mappings
@@ -555,17 +672,17 @@ class FastMCPOpenAPI(FastMCP):
555
672
  RouteMap(
556
673
  methods=["GET", "POST", "PATCH"],
557
674
  pattern=r".*/users/.*",
558
- route_type=RouteType.RESOURCE_TEMPLATE
675
+ mcp_type=MCPType.RESOURCE_TEMPLATE
559
676
  ),
560
677
  # Map all analytics endpoints to Tool
561
678
  RouteMap(
562
679
  methods=["GET"],
563
680
  pattern=r".*/analytics/.*",
564
- route_type=RouteType.TOOL
681
+ mcp_type=MCPType.TOOL
565
682
  ),
566
683
  ]
567
684
 
568
- # Create server with custom mappings
685
+ # Create server with custom mappings and route mapper
569
686
  server = FastMCPOpenAPI(
570
687
  openapi_spec=spec,
571
688
  client=httpx.AsyncClient(),
@@ -581,6 +698,9 @@ class FastMCPOpenAPI(FastMCP):
581
698
  client: httpx.AsyncClient,
582
699
  name: str | None = None,
583
700
  route_maps: list[RouteMap] | None = None,
701
+ route_map_fn: RouteMapFn | None = None,
702
+ mcp_component_fn: ComponentFn | None = None,
703
+ mcp_names: dict[str, str] | None = None,
584
704
  timeout: float | None = None,
585
705
  **settings: Any,
586
706
  ):
@@ -592,6 +712,17 @@ class FastMCPOpenAPI(FastMCP):
592
712
  client: httpx AsyncClient for making HTTP requests
593
713
  name: Optional name for the server
594
714
  route_maps: Optional list of RouteMap objects defining route mappings
715
+ route_map_fn: Optional callable for advanced route type mapping.
716
+ Receives (route, mcp_type) and returns MCPType or None.
717
+ Called on every route, including excluded ones.
718
+ mcp_component_fn: Optional callable for component customization.
719
+ Receives (route, component) and can modify the component in-place.
720
+ Called on every created component.
721
+ mcp_names: Optional dictionary mapping operationId to desired component names.
722
+ If an operationId is not in the dictionary, falls back to using the
723
+ operationId up to the first double underscore. If no operationId exists,
724
+ falls back to slugified summary or path-based naming.
725
+ All names are truncated to 56 characters maximum.
595
726
  timeout: Optional timeout (in seconds) for all requests
596
727
  **settings: Additional settings for FastMCP
597
728
  """
@@ -599,6 +730,18 @@ class FastMCPOpenAPI(FastMCP):
599
730
 
600
731
  self._client = client
601
732
  self._timeout = timeout
733
+ self._route_map_fn = route_map_fn
734
+ self._mcp_component_fn = mcp_component_fn
735
+ self._mcp_names = mcp_names or {}
736
+
737
+ # Keep track of names to detect collisions
738
+ self._used_names = {
739
+ "tool": Counter(),
740
+ "resource": Counter(),
741
+ "resource_template": Counter(),
742
+ "prompt": Counter(),
743
+ }
744
+
602
745
  http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
603
746
 
604
747
  # Process routes
@@ -607,34 +750,97 @@ class FastMCPOpenAPI(FastMCP):
607
750
  # Determine route type based on mappings or default rules
608
751
  route_type = _determine_route_type(route, route_maps)
609
752
 
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}")
753
+ # Call route_map_fn if provided
754
+ if self._route_map_fn is not None:
755
+ try:
756
+ result = self._route_map_fn(route, route_type)
757
+ if result is not None:
758
+ route_type = result
759
+ logger.debug(
760
+ f"Route {route.method} {route.path} mapping customized by route_map_fn: "
761
+ f"type={route_type.name}"
762
+ )
763
+ except Exception as e:
764
+ logger.warning(
765
+ f"Error in route_map_fn for {route.method} {route.path}: {e}. "
766
+ f"Using default values."
767
+ )
768
+
769
+ # Generate a default name from the route
770
+ component_name = self._generate_default_name(route, route_type)
771
+
772
+ if route_type == MCPType.TOOL:
773
+ self._create_openapi_tool(route, component_name)
774
+ elif route_type == MCPType.RESOURCE:
775
+ self._create_openapi_resource(route, component_name)
776
+ elif route_type == MCPType.RESOURCE_TEMPLATE:
777
+ self._create_openapi_template(route, component_name)
778
+ elif route_type == MCPType.EXCLUDE:
779
+ logger.info(f"Excluding route: {route.method} {route.path}")
631
780
 
632
781
  logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
633
782
 
634
- def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str):
783
+ def _generate_default_name(
784
+ self, route: openapi.HTTPRoute, mcp_type: MCPType
785
+ ) -> str:
786
+ """Generate a default name from the route using the configured strategy."""
787
+ name = ""
788
+
789
+ # First check if there's a custom mapping for this operationId
790
+ if route.operation_id:
791
+ if route.operation_id in self._mcp_names:
792
+ name = self._mcp_names[route.operation_id]
793
+ else:
794
+ # If there's a double underscore in the operationId, use the first part
795
+ name = route.operation_id.split("__")[0]
796
+ else:
797
+ name = route.summary or f"{route.method}_{route.path}"
798
+
799
+ name = _slugify(name)
800
+
801
+ # Truncate to 56 characters maximum
802
+ if len(name) > 56:
803
+ name = name[:56]
804
+
805
+ return name
806
+
807
+ def _get_unique_name(
808
+ self,
809
+ name: str,
810
+ component_type: Literal["tool", "resource", "resource_template", "prompt"],
811
+ ) -> str:
812
+ """
813
+ Ensure the name is unique within its component type by appending numbers if needed.
814
+
815
+ Args:
816
+ name: The proposed name
817
+ component_type: The type of component ("tools", "resources", or "templates")
818
+
819
+ Returns:
820
+ str: A unique name for the component
821
+ """
822
+ # Check if the name is already used
823
+ self._used_names[component_type][name] += 1
824
+ if self._used_names[component_type][name] == 1:
825
+ return name
826
+
827
+ else:
828
+ # Create the new name
829
+ new_name = f"{name}_{self._used_names[component_type][name]}"
830
+ logger.debug(
831
+ f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
832
+ f"Using '{new_name}' instead."
833
+ )
834
+
835
+ return new_name
836
+
837
+ def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
635
838
  """Creates and registers an OpenAPITool with enhanced description."""
636
839
  combined_schema = _combine_schemas(route)
637
- tool_name = operation_id
840
+
841
+ # Get a unique tool name
842
+ tool_name = self._get_unique_name(name, "tool")
843
+
638
844
  base_description = (
639
845
  route.description
640
846
  or route.summary
@@ -658,16 +864,30 @@ class FastMCPOpenAPI(FastMCP):
658
864
  tags=set(route.tags or []),
659
865
  timeout=self._timeout,
660
866
  )
867
+
868
+ # Call component_fn if provided
869
+ if self._mcp_component_fn is not None:
870
+ try:
871
+ self._mcp_component_fn(route, tool)
872
+ logger.debug(f"Tool {tool_name} customized by component_fn")
873
+ except Exception as e:
874
+ logger.warning(
875
+ f"Error in component_fn for tool {tool_name}: {e}. "
876
+ f"Using component as-is."
877
+ )
878
+
661
879
  # Register the tool by directly assigning to the tools dictionary
662
880
  self._tool_manager._tools[tool_name] = tool
663
881
  logger.debug(
664
882
  f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
665
883
  )
666
884
 
667
- def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
885
+ def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
668
886
  """Creates and registers an OpenAPIResource with enhanced description."""
669
- resource_name = operation_id
670
- resource_uri = f"resource://openapi/{resource_name}"
887
+ # Get a unique resource name
888
+ resource_name = self._get_unique_name(name, "resource")
889
+
890
+ resource_uri = f"resource://{resource_name}"
671
891
  base_description = (
672
892
  route.description or route.summary or f"Represents {route.path}"
673
893
  )
@@ -689,19 +909,33 @@ class FastMCPOpenAPI(FastMCP):
689
909
  tags=set(route.tags or []),
690
910
  timeout=self._timeout,
691
911
  )
912
+
913
+ # Call component_fn if provided
914
+ if self._mcp_component_fn is not None:
915
+ try:
916
+ self._mcp_component_fn(route, resource)
917
+ logger.debug(f"Resource {resource_uri} customized by component_fn")
918
+ except Exception as e:
919
+ logger.warning(
920
+ f"Error in component_fn for resource {resource_uri}: {e}. "
921
+ f"Using component as-is."
922
+ )
923
+
692
924
  # Register the resource by directly assigning to the resources dictionary
693
925
  self._resource_manager._resources[str(resource.uri)] = resource
694
926
  logger.debug(
695
927
  f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
696
928
  )
697
929
 
698
- def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
930
+ def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
699
931
  """Creates and registers an OpenAPIResourceTemplate with enhanced description."""
700
- template_name = operation_id
932
+ # Get a unique template name
933
+ template_name = self._get_unique_name(name, "resource_template")
934
+
701
935
  path_params = [p.name for p in route.parameters if p.location == "path"]
702
936
  path_params.sort() # Sort for consistent URIs
703
937
 
704
- uri_template_str = f"resource://openapi/{template_name}"
938
+ uri_template_str = f"resource://{template_name}"
705
939
  if path_params:
706
940
  uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
707
941
 
@@ -749,13 +983,20 @@ class FastMCPOpenAPI(FastMCP):
749
983
  tags=set(route.tags or []),
750
984
  timeout=self._timeout,
751
985
  )
986
+
987
+ # Call component_fn if provided
988
+ if self._mcp_component_fn is not None:
989
+ try:
990
+ self._mcp_component_fn(route, template)
991
+ logger.debug(f"Template {uri_template_str} customized by component_fn")
992
+ except Exception as e:
993
+ logger.warning(
994
+ f"Error in component_fn for template {uri_template_str}: {e}. "
995
+ f"Using component as-is."
996
+ )
997
+
752
998
  # Register the template by directly assigning to the templates dictionary
753
999
  self._resource_manager._templates[uri_template_str] = template
754
1000
  logger.debug(
755
1001
  f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
756
1002
  )
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