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.
- fastapi_webmcp/__init__.py +26 -0
- fastapi_webmcp/application.py +237 -0
- fastapi_webmcp/decorators.py +47 -0
- fastapi_webmcp/discovery.py +368 -0
- fastapi_webmcp/exceptions.py +6 -0
- fastapi_webmcp/models.py +177 -0
- fastapi_webmcp/py.typed +1 -0
- fastapi_webmcp/static/bridge.js +42 -0
- fastapi_webmcp/static/index.html +51 -0
- fastapi_webmcp/static/runtime.js +314 -0
- fastapi_webmcp-0.3.0.dist-info/METADATA +378 -0
- fastapi_webmcp-0.3.0.dist-info/RECORD +14 -0
- fastapi_webmcp-0.3.0.dist-info/WHEEL +4 -0
- fastapi_webmcp-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|