fastapi-webmcp 0.3.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.
@@ -0,0 +1,26 @@
1
+ from .application import FastAPIWebMCP
2
+ from .decorators import webmcp_tool
3
+ from .exceptions import FastAPIWebMCPError, RouteConversionError
4
+ from .models import (
5
+ ClientTool,
6
+ RequestTool,
7
+ StaticTool,
8
+ WebMCPManifest,
9
+ client_tool,
10
+ static_tool,
11
+ )
12
+
13
+ __all__ = [
14
+ "FastAPIWebMCP",
15
+ "FastAPIWebMCPError",
16
+ "ClientTool",
17
+ "RequestTool",
18
+ "RouteConversionError",
19
+ "StaticTool",
20
+ "WebMCPManifest",
21
+ "client_tool",
22
+ "static_tool",
23
+ "webmcp_tool",
24
+ ]
25
+
26
+ __version__ = "0.3.0"
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
4
+ from importlib.resources import files
5
+ from typing import Any, TypeAlias
6
+
7
+ from fastapi import Depends, FastAPI, Request, Response, params
8
+
9
+ from .discovery import AUTHENTICATION_HEADERS, TOOL_NAME_PATTERN, ToolDiscovery
10
+ from .exceptions import FastAPIWebMCPError, RouteConversionError
11
+ from .models import (
12
+ BrowserTool,
13
+ ClientTool,
14
+ RequestTool,
15
+ StaticTool,
16
+ ToolCredentials,
17
+ WebMCPManifest,
18
+ )
19
+
20
+ ManifestProviderResult: TypeAlias = WebMCPManifest | Collection[BrowserTool]
21
+ ManifestProvider: TypeAlias = Callable[
22
+ ..., ManifestProviderResult | Awaitable[ManifestProviderResult]
23
+ ]
24
+
25
+
26
+ class FastAPIWebMCP:
27
+ """Expose selected FastAPI routes and page tools to browser agents."""
28
+
29
+ def __init__(
30
+ self,
31
+ app: FastAPI,
32
+ *,
33
+ include_operations: Collection[str] = (),
34
+ include_tags: Collection[str] = (),
35
+ exclude_operations: Collection[str] = (),
36
+ expose_all: bool = False,
37
+ credentials: ToolCredentials = "omit",
38
+ ) -> None:
39
+ if credentials not in {"omit", "same-origin"}:
40
+ raise FastAPIWebMCPError("credentials must be either 'omit' or 'same-origin'")
41
+ self.app = app
42
+ self.credentials = credentials
43
+ self.discovery = ToolDiscovery(
44
+ app,
45
+ include_operations=include_operations,
46
+ include_tags=include_tags,
47
+ exclude_operations=exclude_operations,
48
+ expose_all=expose_all,
49
+ )
50
+ self._additional_tools: list[BrowserTool] = []
51
+ self._page_path: str | None = None
52
+
53
+ @classmethod
54
+ def from_fastapi(
55
+ cls,
56
+ app: FastAPI,
57
+ *,
58
+ include_operations: Collection[str] = (),
59
+ include_tags: Collection[str] = (),
60
+ exclude_operations: Collection[str] = (),
61
+ expose_all: bool = False,
62
+ credentials: ToolCredentials = "omit",
63
+ ) -> FastAPIWebMCP:
64
+ return cls(
65
+ app,
66
+ include_operations=include_operations,
67
+ include_tags=include_tags,
68
+ exclude_operations=exclude_operations,
69
+ expose_all=expose_all,
70
+ credentials=credentials,
71
+ )
72
+
73
+ def add_tool(self, tool: BrowserTool) -> BrowserTool:
74
+ """Add a static or client tool to every default manifest."""
75
+
76
+ candidate = [*self._additional_tools, tool]
77
+ self._validate_tools(candidate)
78
+ self._additional_tools.append(tool)
79
+ return tool
80
+
81
+ def tools(self) -> list[BrowserTool]:
82
+ tools: list[BrowserTool] = [*self.discovery.discover(), *self._additional_tools]
83
+ self._validate_tools(tools)
84
+ return tools
85
+
86
+ def manifest(
87
+ self,
88
+ *,
89
+ root_path: str = "",
90
+ tools: Collection[BrowserTool] | None = None,
91
+ context: Mapping[str, Any] | None = None,
92
+ ) -> dict[str, Any]:
93
+ selected = list(self.tools() if tools is None else tools)
94
+ self._validate_tools(selected)
95
+ result: dict[str, Any] = {
96
+ "version": 1,
97
+ "basePath": self._normalize_root_path(root_path),
98
+ "credentials": self.credentials,
99
+ "tools": [tool.as_dict() for tool in selected],
100
+ }
101
+ if context:
102
+ result["context"] = dict(context)
103
+ return result
104
+
105
+ def mount(
106
+ self,
107
+ *,
108
+ page: str = "/webmcp",
109
+ manifest_provider: ManifestProvider | None = None,
110
+ dependencies: Sequence[params.Depends] = (),
111
+ ) -> None:
112
+ """Mount the packaged page, runtime, and a possibly protected dynamic manifest."""
113
+
114
+ if self._page_path is not None:
115
+ raise FastAPIWebMCPError(f"fastapi-webmcp is already mounted at {self._page_path}")
116
+ if not hasattr(self.app, "frontend"):
117
+ raise FastAPIWebMCPError("FastAPI.app.frontend() is required; install fastapi>=0.141.1")
118
+
119
+ page_path = self._normalize_page_path(page)
120
+ manifest_path = f"{page_path}/manifest.json"
121
+
122
+ def manifest_payload(
123
+ request: Request,
124
+ page_manifest: WebMCPManifest | None = None,
125
+ ) -> dict[str, Any]:
126
+ return self.manifest(
127
+ root_path=request.scope.get("root_path", ""),
128
+ tools=page_manifest.tools if page_manifest is not None else None,
129
+ context=page_manifest.context if page_manifest is not None else None,
130
+ )
131
+
132
+ manifest_endpoint: Callable[..., Awaitable[dict[str, Any]]]
133
+ if manifest_provider is None:
134
+
135
+ async def serve_default_manifest(
136
+ request: Request,
137
+ response: Response,
138
+ ) -> dict[str, Any]:
139
+ response.headers["Cache-Control"] = "no-store"
140
+ return manifest_payload(request)
141
+
142
+ manifest_endpoint = serve_default_manifest
143
+ else:
144
+ provider = manifest_provider
145
+ provider_dependency: Any = Depends(provider)
146
+
147
+ async def serve_dynamic_manifest(
148
+ request: Request,
149
+ response: Response,
150
+ provided: ManifestProviderResult = provider_dependency,
151
+ ) -> dict[str, Any]:
152
+ if provided is None:
153
+ raise FastAPIWebMCPError("manifest provider returned None")
154
+ response.headers["Cache-Control"] = "no-store"
155
+ return manifest_payload(request, self._page_manifest(provided))
156
+
157
+ manifest_endpoint = serve_dynamic_manifest
158
+
159
+ self.app.add_api_route(
160
+ manifest_path,
161
+ manifest_endpoint,
162
+ methods=["GET"],
163
+ dependencies=list(dependencies),
164
+ include_in_schema=False,
165
+ name="fastapi_webmcp_manifest",
166
+ )
167
+ static_directory = files("fastapi_webmcp").joinpath("static")
168
+ self.app.frontend(
169
+ page_path,
170
+ directory=str(static_directory),
171
+ fallback="index.html",
172
+ )
173
+ self._page_path = page_path
174
+
175
+ def _page_manifest(
176
+ self,
177
+ provided: ManifestProviderResult,
178
+ ) -> WebMCPManifest:
179
+ if isinstance(provided, WebMCPManifest):
180
+ return provided
181
+ return WebMCPManifest(tools=provided)
182
+
183
+ def _validate_tools(self, tools: Collection[BrowserTool]) -> None:
184
+ names: set[str] = set()
185
+ for tool in tools:
186
+ if not TOOL_NAME_PATTERN.fullmatch(tool.name):
187
+ raise RouteConversionError(
188
+ f"tool name {tool.name!r} must match {TOOL_NAME_PATTERN.pattern}"
189
+ )
190
+ if tool.name in names:
191
+ raise RouteConversionError(f"duplicate WebMCP tool name: {tool.name}")
192
+ names.add(tool.name)
193
+ schema = tool.input_schema
194
+ if schema.get("type") != "object":
195
+ raise RouteConversionError(f"tool {tool.name!r} input schema must be an object")
196
+ properties = schema.get("properties")
197
+ required = schema.get("required")
198
+ if not isinstance(properties, Mapping) or not isinstance(required, list):
199
+ raise RouteConversionError(
200
+ f"tool {tool.name!r} input schema needs properties and required"
201
+ )
202
+ unknown_required = [
203
+ name for name in required if not isinstance(name, str) or name not in properties
204
+ ]
205
+ if unknown_required:
206
+ required_names = ", ".join(sorted(str(name) for name in unknown_required))
207
+ raise RouteConversionError(
208
+ f"tool {tool.name!r} requires unknown input properties: {required_names}"
209
+ )
210
+ if isinstance(tool, ClientTool) and not TOOL_NAME_PATTERN.fullmatch(tool.action):
211
+ raise RouteConversionError(
212
+ f"client action {tool.action!r} must match {TOOL_NAME_PATTERN.pattern}"
213
+ )
214
+ if isinstance(tool, RequestTool):
215
+ authentication_headers = sorted(
216
+ header_name
217
+ for _, header_name in tool.request.header_params
218
+ if header_name.casefold() in AUTHENTICATION_HEADERS
219
+ )
220
+ if authentication_headers:
221
+ header_names = ", ".join(authentication_headers)
222
+ raise RouteConversionError(
223
+ f"tool {tool.name!r} cannot expose authentication headers "
224
+ f"as tool inputs: {header_names}"
225
+ )
226
+ if isinstance(tool, StaticTool) and not isinstance(tool.text, str):
227
+ raise RouteConversionError(f"static tool {tool.name!r} text must be a string")
228
+
229
+ def _normalize_page_path(self, page: str) -> str:
230
+ normalized = "/" + page.strip("/")
231
+ if normalized == "/":
232
+ raise FastAPIWebMCPError("the WebMCP page cannot replace the application root")
233
+ return normalized
234
+
235
+ def _normalize_root_path(self, root_path: str) -> str:
236
+ stripped = root_path.strip("/")
237
+ return f"/{stripped}" if stripped else ""
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import TypeVar
5
+
6
+ from .models import ToolMetadata
7
+
8
+ F = TypeVar("F", bound=Callable[..., object])
9
+ METADATA_ATTRIBUTE = "__fastapi_webmcp_tool__"
10
+
11
+
12
+ def webmcp_tool(
13
+ *,
14
+ name: str | None = None,
15
+ description: str | None = None,
16
+ read_only: bool | None = None,
17
+ untrusted_content: bool = True,
18
+ headers: Mapping[str, str] | None = None,
19
+ ) -> Callable[[F], F]:
20
+ """Mark a FastAPI endpoint for exposure as an in-page WebMCP tool.
21
+
22
+ Place this below the FastAPI route decorator so it is applied to the
23
+ endpoint before FastAPI registers the function::
24
+
25
+ @app.get("/items", operation_id="list_items")
26
+ @webmcp_tool(read_only=True)
27
+ async def list_items(): ...
28
+ """
29
+
30
+ metadata = ToolMetadata(
31
+ name=name,
32
+ description=description,
33
+ read_only=read_only,
34
+ untrusted_content=untrusted_content,
35
+ header_params=tuple((headers or {}).items()),
36
+ )
37
+
38
+ def decorator(function: F) -> F:
39
+ setattr(function, METADATA_ATTRIBUTE, metadata)
40
+ return function
41
+
42
+ return decorator
43
+
44
+
45
+ def metadata_for(function: Callable[..., object]) -> ToolMetadata | None:
46
+ value = getattr(function, METADATA_ATTRIBUTE, None)
47
+ return value if isinstance(value, ToolMetadata) else None
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Collection, Mapping
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.routing import APIRoute
10
+
11
+ from .decorators import metadata_for
12
+ from .exceptions import RouteConversionError
13
+ from .models import RequestMapping, RequestTool, ToolMetadata
14
+
15
+ HTTP_METHODS = ("get", "post", "put", "patch", "delete")
16
+ TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
17
+ AUTHENTICATION_HEADERS = frozenset({"authorization", "cookie", "proxy-authorization"})
18
+
19
+
20
+ class ToolDiscovery:
21
+ def __init__(
22
+ self,
23
+ app: FastAPI,
24
+ *,
25
+ include_operations: Collection[str] = (),
26
+ include_tags: Collection[str] = (),
27
+ exclude_operations: Collection[str] = (),
28
+ expose_all: bool = False,
29
+ ) -> None:
30
+ self.app = app
31
+ self.include_operations = frozenset(include_operations)
32
+ self.include_tags = frozenset(include_tags)
33
+ self.exclude_operations = frozenset(exclude_operations)
34
+ self.expose_all = expose_all
35
+
36
+ def discover(self) -> list[RequestTool]:
37
+ specification = self.app.openapi()
38
+ route_index = self._route_index()
39
+ tools: list[RequestTool] = []
40
+ names: set[str] = set()
41
+
42
+ for path, path_item in specification.get("paths", {}).items():
43
+ if not isinstance(path_item, Mapping):
44
+ continue
45
+ for method in HTTP_METHODS:
46
+ operation = path_item.get(method)
47
+ if not isinstance(operation, Mapping):
48
+ continue
49
+ operation_id = operation.get("operationId")
50
+ if not isinstance(operation_id, str):
51
+ continue
52
+ route = route_index.get((path, method.upper()))
53
+ metadata = metadata_for(route.endpoint) if route is not None else None
54
+ if not self._selected(operation_id, operation, metadata):
55
+ continue
56
+
57
+ tool = self._build_tool(
58
+ specification=specification,
59
+ path=path,
60
+ path_item=path_item,
61
+ method=method.upper(),
62
+ operation_id=operation_id,
63
+ operation=operation,
64
+ metadata=metadata,
65
+ )
66
+ if tool.name in names:
67
+ raise RouteConversionError(f"duplicate WebMCP tool name: {tool.name}")
68
+ names.add(tool.name)
69
+ tools.append(tool)
70
+
71
+ return tools
72
+
73
+ def _route_index(self) -> dict[tuple[str, str], APIRoute]:
74
+ index: dict[tuple[str, str], APIRoute] = {}
75
+ for route in self.app.routes:
76
+ if not isinstance(route, APIRoute) or not route.include_in_schema:
77
+ continue
78
+ for method in route.methods or ():
79
+ index[(route.path_format, method.upper())] = route
80
+ return index
81
+
82
+ def _selected(
83
+ self,
84
+ operation_id: str,
85
+ operation: Mapping[str, Any],
86
+ metadata: ToolMetadata | None,
87
+ ) -> bool:
88
+ if operation_id in self.exclude_operations:
89
+ return False
90
+ tags = {tag for tag in operation.get("tags", []) if isinstance(tag, str)}
91
+ return (
92
+ metadata is not None
93
+ or self.expose_all
94
+ or operation_id in self.include_operations
95
+ or bool(tags & self.include_tags)
96
+ )
97
+
98
+ def _build_tool(
99
+ self,
100
+ *,
101
+ specification: Mapping[str, Any],
102
+ path: str,
103
+ path_item: Mapping[str, Any],
104
+ method: str,
105
+ operation_id: str,
106
+ operation: Mapping[str, Any],
107
+ metadata: ToolMetadata | None,
108
+ ) -> RequestTool:
109
+ name = metadata.name if metadata and metadata.name else operation_id
110
+ if not TOOL_NAME_PATTERN.fullmatch(name):
111
+ raise RouteConversionError(f"tool name {name!r} must match {TOOL_NAME_PATTERN.pattern}")
112
+
113
+ description = self._description(operation_id, operation, metadata)
114
+ input_schema, mapping = self._input_contract(
115
+ specification=specification,
116
+ path=path,
117
+ path_item=path_item,
118
+ method=method,
119
+ operation=operation,
120
+ metadata=metadata,
121
+ )
122
+ read_only = (
123
+ metadata.read_only
124
+ if metadata is not None and metadata.read_only is not None
125
+ else method in {"GET", "HEAD"}
126
+ )
127
+ untrusted_content = metadata.untrusted_content if metadata is not None else True
128
+ return RequestTool(
129
+ name=name,
130
+ description=description,
131
+ input_schema=input_schema,
132
+ read_only=read_only,
133
+ untrusted_content=untrusted_content,
134
+ request=mapping,
135
+ )
136
+
137
+ def _description(
138
+ self,
139
+ operation_id: str,
140
+ operation: Mapping[str, Any],
141
+ metadata: ToolMetadata | None,
142
+ ) -> str:
143
+ if metadata is not None and metadata.description:
144
+ return metadata.description
145
+ summary = operation.get("summary")
146
+ if isinstance(summary, str) and summary.strip():
147
+ return summary.strip()
148
+ description = operation.get("description")
149
+ if isinstance(description, str) and description.strip():
150
+ return description.strip().split("\n\n", 1)[0]
151
+ return operation_id.replace("_", " ").strip().capitalize()
152
+
153
+ def _input_contract(
154
+ self,
155
+ *,
156
+ specification: Mapping[str, Any],
157
+ path: str,
158
+ path_item: Mapping[str, Any],
159
+ method: str,
160
+ operation: Mapping[str, Any],
161
+ metadata: ToolMetadata | None,
162
+ ) -> tuple[dict[str, Any], RequestMapping]:
163
+ properties: dict[str, Any] = {}
164
+ required: list[str] = []
165
+ path_params: list[str] = []
166
+ query_params: list[str] = []
167
+ body_params: list[str] = []
168
+ body_value_param: str | None = None
169
+ header_params: list[tuple[str, str]] = []
170
+ configured_headers = dict(metadata.header_params) if metadata is not None else {}
171
+ authentication_headers = sorted(
172
+ header_name
173
+ for header_name in configured_headers.values()
174
+ if header_name.casefold() in AUTHENTICATION_HEADERS
175
+ )
176
+ if authentication_headers:
177
+ names = ", ".join(authentication_headers)
178
+ raise RouteConversionError(
179
+ f"{method} {path} cannot expose authentication headers as tool inputs: {names}"
180
+ )
181
+ matched_headers: set[str] = set()
182
+
183
+ parameters = [*path_item.get("parameters", []), *operation.get("parameters", [])]
184
+ for raw_parameter in parameters:
185
+ parameter = self._resolve_object(raw_parameter, specification)
186
+ name = parameter.get("name")
187
+ location = parameter.get("in")
188
+ if not isinstance(name, str) or not isinstance(location, str):
189
+ continue
190
+ if location == "cookie":
191
+ # Browser credentials own cookies; agents must never supply them.
192
+ continue
193
+ if location == "header":
194
+ if name.casefold() in AUTHENTICATION_HEADERS:
195
+ # Application code supplies authentication headers at runtime.
196
+ continue
197
+ tool_name = next(
198
+ (
199
+ input_name
200
+ for input_name, header_name in configured_headers.items()
201
+ if header_name.casefold() == name.casefold()
202
+ ),
203
+ None,
204
+ )
205
+ if tool_name is not None:
206
+ schema = self._resolve_schema(parameter.get("schema", {}), specification)
207
+ if isinstance(parameter.get("description"), str):
208
+ schema.setdefault("description", parameter["description"])
209
+ self._add_property(
210
+ properties,
211
+ tool_name,
212
+ schema,
213
+ method=method,
214
+ path=path,
215
+ )
216
+ if parameter.get("required") and tool_name not in required:
217
+ required.append(tool_name)
218
+ header_params.append((tool_name, name))
219
+ matched_headers.add(tool_name)
220
+ continue
221
+ if location not in {"path", "query"}:
222
+ if parameter.get("required"):
223
+ raise RouteConversionError(
224
+ f"{method} {path} requires unsupported {location} parameter {name!r}"
225
+ )
226
+ continue
227
+ schema = self._resolve_schema(parameter.get("schema", {}), specification)
228
+ if isinstance(parameter.get("description"), str):
229
+ schema.setdefault("description", parameter["description"])
230
+ self._add_property(properties, name, schema, method=method, path=path)
231
+ if parameter.get("required") and name not in required:
232
+ required.append(name)
233
+ (path_params if location == "path" else query_params).append(name)
234
+
235
+ unmatched_headers = set(configured_headers) - matched_headers
236
+ if unmatched_headers:
237
+ names = ", ".join(sorted(unmatched_headers))
238
+ raise RouteConversionError(
239
+ f"{method} {path} declares WebMCP header inputs not found in OpenAPI: {names}"
240
+ )
241
+
242
+ request_body = operation.get("requestBody")
243
+ if request_body is not None:
244
+ body = self._resolve_object(request_body, specification)
245
+ media_type = self._json_media_type(body.get("content", {}))
246
+ if media_type is None:
247
+ raise RouteConversionError(f"{method} {path} has no application/json request body")
248
+ body_schema = self._resolve_schema(media_type.get("schema", {}), specification)
249
+ if body_schema.get("type") == "object" and isinstance(
250
+ body_schema.get("properties"), Mapping
251
+ ):
252
+ for name, schema in body_schema["properties"].items():
253
+ if not isinstance(name, str) or not isinstance(schema, Mapping):
254
+ continue
255
+ self._add_property(
256
+ properties,
257
+ name,
258
+ deepcopy(dict(schema)),
259
+ method=method,
260
+ path=path,
261
+ )
262
+ body_params.append(name)
263
+ for name in body_schema.get("required", []):
264
+ if isinstance(name, str) and name not in required:
265
+ required.append(name)
266
+ else:
267
+ body_value_param = "body"
268
+ self._add_property(
269
+ properties,
270
+ body_value_param,
271
+ body_schema,
272
+ method=method,
273
+ path=path,
274
+ )
275
+ if body.get("required"):
276
+ required.append(body_value_param)
277
+
278
+ return (
279
+ {
280
+ "type": "object",
281
+ "properties": properties,
282
+ "required": required,
283
+ "additionalProperties": False,
284
+ },
285
+ RequestMapping(
286
+ method=method,
287
+ path=path,
288
+ path_params=tuple(path_params),
289
+ query_params=tuple(query_params),
290
+ body_params=tuple(body_params),
291
+ body_value_param=body_value_param,
292
+ header_params=tuple(header_params),
293
+ ),
294
+ )
295
+
296
+ def _add_property(
297
+ self,
298
+ properties: dict[str, Any],
299
+ name: str,
300
+ schema: dict[str, Any],
301
+ *,
302
+ method: str,
303
+ path: str,
304
+ ) -> None:
305
+ if name in properties:
306
+ raise RouteConversionError(
307
+ f"{method} {path} maps more than one input to property {name!r}"
308
+ )
309
+ properties[name] = schema
310
+
311
+ def _json_media_type(self, content: Any) -> Mapping[str, Any] | None:
312
+ if not isinstance(content, Mapping):
313
+ return None
314
+ direct = content.get("application/json")
315
+ if isinstance(direct, Mapping):
316
+ return direct
317
+ for media_type, value in content.items():
318
+ if (
319
+ isinstance(media_type, str)
320
+ and media_type.endswith("+json")
321
+ and isinstance(value, Mapping)
322
+ ):
323
+ return value
324
+ return None
325
+
326
+ def _resolve_object(self, value: Any, specification: Mapping[str, Any]) -> dict[str, Any]:
327
+ resolved = self._resolve_schema(value, specification)
328
+ if not isinstance(resolved, dict):
329
+ raise RouteConversionError("OpenAPI object did not resolve to a mapping")
330
+ return resolved
331
+
332
+ def _resolve_schema(
333
+ self,
334
+ value: Any,
335
+ specification: Mapping[str, Any],
336
+ stack: tuple[str, ...] = (),
337
+ ) -> Any:
338
+ if isinstance(value, list):
339
+ return [self._resolve_schema(item, specification, stack) for item in value]
340
+ if not isinstance(value, Mapping):
341
+ return deepcopy(value)
342
+
343
+ reference = value.get("$ref")
344
+ if isinstance(reference, str):
345
+ if not reference.startswith("#/"):
346
+ raise RouteConversionError(
347
+ f"external OpenAPI reference is unsupported: {reference}"
348
+ )
349
+ if reference in stack:
350
+ raise RouteConversionError(f"cyclic OpenAPI schema is unsupported: {reference}")
351
+ target: Any = specification
352
+ for part in reference[2:].split("/"):
353
+ key = part.replace("~1", "/").replace("~0", "~")
354
+ if not isinstance(target, Mapping) or key not in target:
355
+ raise RouteConversionError(f"unresolved OpenAPI reference: {reference}")
356
+ target = target[key]
357
+ resolved = self._resolve_schema(target, specification, (*stack, reference))
358
+ if not isinstance(resolved, dict):
359
+ raise RouteConversionError(f"OpenAPI reference is not an object: {reference}")
360
+ siblings = {key: item for key, item in value.items() if key != "$ref"}
361
+ resolved.update(self._resolve_schema(siblings, specification, stack))
362
+ return resolved
363
+
364
+ return {
365
+ key: self._resolve_schema(item, specification, stack)
366
+ for key, item in value.items()
367
+ if key not in {"$defs", "definitions"}
368
+ }
@@ -0,0 +1,6 @@
1
+ class FastAPIWebMCPError(RuntimeError):
2
+ """Base error raised by fastapi-webmcp."""
3
+
4
+
5
+ class RouteConversionError(FastAPIWebMCPError):
6
+ """A selected FastAPI route cannot be represented as a browser tool."""