webagent-protocol 0.2.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.
- wap/__init__.py +75 -0
- wap/cli/__init__.py +5 -0
- wap/cli/main.py +369 -0
- wap/client/__init__.py +47 -0
- wap/client/dnskey.py +82 -0
- wap/client/exceptions.py +137 -0
- wap/client/resolver.py +361 -0
- wap/client/session.py +621 -0
- wap/mcp/__init__.py +5 -0
- wap/mcp/bridge.py +793 -0
- wap/mcp/safety.py +120 -0
- wap/py.typed +0 -0
- wap/server/__init__.py +29 -0
- wap/server/admission.py +117 -0
- wap/server/app.py +1071 -0
- wap/server/mcp_endpoint.py +305 -0
- wap/server/mcp_import.py +122 -0
- wap/server/middleware.py +111 -0
- wap/server/observability.py +102 -0
- wap/server/rate_limiter.py +90 -0
- wap/server/router.py +358 -0
- wap/server/state.py +117 -0
- wap/spec/__init__.py +75 -0
- wap/spec/conversation.py +231 -0
- wap/spec/crypto.py +216 -0
- wap/spec/jcs.py +132 -0
- wap/spec/models.py +453 -0
- wap/spec/pow.py +230 -0
- wap/storage/__init__.py +30 -0
- wap/storage/base.py +70 -0
- wap/storage/memory.py +115 -0
- wap/storage/meters.py +204 -0
- wap/storage/redis.py +202 -0
- webagent_protocol-0.2.0.dist-info/METADATA +401 -0
- webagent_protocol-0.2.0.dist-info/RECORD +38 -0
- webagent_protocol-0.2.0.dist-info/WHEEL +4 -0
- webagent_protocol-0.2.0.dist-info/entry_points.txt +3 -0
- webagent_protocol-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Serve a WAPServer's capabilities as a standard MCP server (streamable HTTP) at ``/mcp``.
|
|
2
|
+
|
|
3
|
+
This is what makes WAP an *extension* of the Model Context Protocol rather than
|
|
4
|
+
a competitor: the same ``@wap.action`` functions are exposed twice,
|
|
5
|
+
|
|
6
|
+
* as WAP capabilities (signed envelopes, proof-of-work, SSE) at ``/wap/v1/interact``, and
|
|
7
|
+
* as ordinary MCP tools at ``/mcp``, usable by any MCP client with no WAP code.
|
|
8
|
+
|
|
9
|
+
The MCP endpoint keeps WAP's guarantees where MCP has room for them:
|
|
10
|
+
|
|
11
|
+
* every successful ``tools/call`` result carries the business's Ed25519-signed
|
|
12
|
+
reply in ``_meta["io.webagent/signed_reply"]``, verifiable against the key in
|
|
13
|
+
``/.well-known/wap.json``;
|
|
14
|
+
* calls are rate-limited per client IP with the server's policy;
|
|
15
|
+
* proof-of-work can be required (``mcp_require_pow``): the client passes
|
|
16
|
+
``_meta["io.webagent/pow"] = {"seed", "nonce"}`` and a failed call returns a
|
|
17
|
+
fresh challenge in ``_meta["io.webagent/challenge"]``;
|
|
18
|
+
* multi-turn state continues via ``_meta["io.webagent/session_id"]``.
|
|
19
|
+
|
|
20
|
+
Installed with the ``server`` extra (``pip install "webagent-protocol[server]"``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import uuid
|
|
27
|
+
from collections.abc import AsyncIterator
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
from urllib.parse import urlsplit
|
|
31
|
+
|
|
32
|
+
import mcp_types as types
|
|
33
|
+
from mcp.server.lowlevel import Server
|
|
34
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
35
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
36
|
+
from mcp.shared.exceptions import MCPError
|
|
37
|
+
|
|
38
|
+
from .. import __version__
|
|
39
|
+
from ..spec.models import (
|
|
40
|
+
WAP_VERSION,
|
|
41
|
+
WELL_KNOWN_PATH,
|
|
42
|
+
AgentMessage,
|
|
43
|
+
ErrorCode,
|
|
44
|
+
Role,
|
|
45
|
+
authority_host,
|
|
46
|
+
mcp_annotation_hints,
|
|
47
|
+
)
|
|
48
|
+
from .admission import AdmissionRequest
|
|
49
|
+
from .app import WAPProtocolError
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from fastapi import FastAPI
|
|
53
|
+
|
|
54
|
+
from .app import WAPServer
|
|
55
|
+
|
|
56
|
+
EXTENSION_ID = "io.webagent/wap"
|
|
57
|
+
META_PREFIX = "io.webagent/"
|
|
58
|
+
META_SIGNED_REPLY = META_PREFIX + "signed_reply"
|
|
59
|
+
META_SESSION_ID = META_PREFIX + "session_id"
|
|
60
|
+
META_POW = META_PREFIX + "pow"
|
|
61
|
+
META_IDEMPOTENCY_KEY = META_PREFIX + "idempotency_key"
|
|
62
|
+
META_CHALLENGE = META_PREFIX + "challenge"
|
|
63
|
+
META_ERROR = META_PREFIX + "error"
|
|
64
|
+
META_MANIFEST = META_PREFIX + "manifest_url"
|
|
65
|
+
MCP_SESSION_OWNER = "mcp"
|
|
66
|
+
INVALID_PARAMS = -32602
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _protocol_error_result(exc: WAPProtocolError) -> types.CallToolResult:
|
|
70
|
+
extra: dict[str, Any] = {}
|
|
71
|
+
details = dict(exc.details or {})
|
|
72
|
+
challenge = details.pop("challenge", None)
|
|
73
|
+
if challenge is not None:
|
|
74
|
+
extra[META_CHALLENGE] = challenge
|
|
75
|
+
if details:
|
|
76
|
+
extra[META_PREFIX + "details"] = details
|
|
77
|
+
if exc.retry_after is not None:
|
|
78
|
+
extra[META_PREFIX + "retry_after"] = exc.retry_after
|
|
79
|
+
message = exc.message
|
|
80
|
+
if exc.code is ErrorCode.POW_REQUIRED:
|
|
81
|
+
message = (
|
|
82
|
+
f"this tool requires proof-of-work: solve _meta['{META_CHALLENGE}'] and resend with "
|
|
83
|
+
f"_meta['{META_POW}'] = {{'seed': ..., 'nonce': ...}}"
|
|
84
|
+
)
|
|
85
|
+
return _error_result(exc.code.value, message, extra)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _error_result(code: str, message: str, extra_meta: dict[str, Any] | None = None) -> types.CallToolResult:
|
|
89
|
+
return types.CallToolResult(
|
|
90
|
+
content=[types.TextContent(type="text", text=f"[{code}] {message}")],
|
|
91
|
+
is_error=True,
|
|
92
|
+
meta={META_ERROR: {"code": code, "message": message}, **(extra_meta or {})},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MCPEndpoint:
|
|
97
|
+
"""Bridges one :class:`WAPServer` onto an MCP ``Server`` served over streamable HTTP."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, server: WAPServer, path: str = "/mcp") -> None:
|
|
100
|
+
self.wap = server
|
|
101
|
+
self.path = path
|
|
102
|
+
self.mcp = Server(
|
|
103
|
+
server.name,
|
|
104
|
+
version=__version__,
|
|
105
|
+
description=server.description or None,
|
|
106
|
+
instructions=(
|
|
107
|
+
f"Tools published by {server.name} ({server.domain}) via the WebAgent Protocol. "
|
|
108
|
+
f"Results are signed; verify _meta['{META_SIGNED_REPLY}'] against the public key at "
|
|
109
|
+
f"{server.base_url}{WELL_KNOWN_PATH}."
|
|
110
|
+
),
|
|
111
|
+
on_list_tools=self._list_tools,
|
|
112
|
+
on_call_tool=self._call_tool,
|
|
113
|
+
)
|
|
114
|
+
# Declared as an MCP extension (SEP-2133) so clients can detect WAP support at initialization.
|
|
115
|
+
self.mcp.extensions[EXTENSION_ID] = self.extension_settings()
|
|
116
|
+
self._manager: StreamableHTTPSessionManager | None = None
|
|
117
|
+
|
|
118
|
+
def extension_settings(self) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"wap_version": WAP_VERSION,
|
|
121
|
+
"manifest_url": self.wap.base_url + WELL_KNOWN_PATH,
|
|
122
|
+
"public_key": self.wap.public_key,
|
|
123
|
+
"pow_required": self.require_pow,
|
|
124
|
+
"signed_results": True,
|
|
125
|
+
"conversation_policy": self.wap.conversation_policy.as_dict(),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ MCP handlers
|
|
129
|
+
|
|
130
|
+
def tools(self) -> list[types.Tool]:
|
|
131
|
+
tools = []
|
|
132
|
+
for action in self.wap.actions.values():
|
|
133
|
+
cap = action.capability
|
|
134
|
+
output = cap.output_schema if cap.output_schema and cap.output_schema.get("type") == "object" else None
|
|
135
|
+
tools.append(
|
|
136
|
+
types.Tool(
|
|
137
|
+
name=cap.id,
|
|
138
|
+
title=cap.name,
|
|
139
|
+
description=cap.description or cap.name,
|
|
140
|
+
input_schema=cap.input_schema,
|
|
141
|
+
output_schema=output,
|
|
142
|
+
annotations=types.ToolAnnotations(title=cap.name, **mcp_annotation_hints(cap)),
|
|
143
|
+
meta={META_PREFIX + "requires_auth": cap.requires_auth, META_PREFIX + "effects": cap.effects},
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return tools
|
|
147
|
+
|
|
148
|
+
async def _list_tools(self, ctx: Any, params: Any) -> types.ListToolsResult:
|
|
149
|
+
return types.ListToolsResult(tools=self.tools())
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def require_pow(self) -> bool:
|
|
153
|
+
configured = self.wap.mcp_require_pow
|
|
154
|
+
return self.wap.require_pow if configured is None else configured
|
|
155
|
+
|
|
156
|
+
async def _call_tool(self, ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
|
|
157
|
+
server = self.wap
|
|
158
|
+
if params.name not in server.actions:
|
|
159
|
+
raise MCPError(INVALID_PARAMS, f"unknown tool {params.name!r}", {"available": sorted(server.actions)})
|
|
160
|
+
request = getattr(ctx, "request", None)
|
|
161
|
+
ip = request.client.host if request is not None and getattr(request, "client", None) else None
|
|
162
|
+
headers = request.headers if request is not None else {}
|
|
163
|
+
meta: dict[str, Any] = dict(params.meta or {})
|
|
164
|
+
solution = meta.get(META_POW) if isinstance(meta.get(META_POW), dict) else {}
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
await server.limit_ip(ip)
|
|
168
|
+
principal = await server.authenticate(headers.get("authorization"))
|
|
169
|
+
decision, _ = await server.admit(
|
|
170
|
+
AdmissionRequest(
|
|
171
|
+
client_ip=ip,
|
|
172
|
+
agent_key=None,
|
|
173
|
+
principal=principal,
|
|
174
|
+
capability_id=params.name,
|
|
175
|
+
transport="mcp",
|
|
176
|
+
headers=dict(headers),
|
|
177
|
+
),
|
|
178
|
+
pow_seed=str(solution["seed"]) if "seed" in solution else None,
|
|
179
|
+
pow_nonce=str(solution["nonce"]) if "nonce" in solution else None,
|
|
180
|
+
)
|
|
181
|
+
except WAPProtocolError as exc:
|
|
182
|
+
return _protocol_error_result(exc)
|
|
183
|
+
|
|
184
|
+
session_id = str(meta.get(META_SESSION_ID) or "mcp-" + uuid.uuid4().hex)
|
|
185
|
+
idempotency_key = meta.get(META_IDEMPOTENCY_KEY)
|
|
186
|
+
message = AgentMessage(
|
|
187
|
+
session_id=session_id,
|
|
188
|
+
role=Role.USER_AGENT,
|
|
189
|
+
capability_id=params.name,
|
|
190
|
+
structured_data=dict(params.arguments or {}),
|
|
191
|
+
idempotency_key=str(idempotency_key) if idempotency_key else None,
|
|
192
|
+
)
|
|
193
|
+
# Per-session loop guard only: an IP may be shared by many unrelated MCP users.
|
|
194
|
+
turn = server.open_turn(
|
|
195
|
+
message,
|
|
196
|
+
owner_key=MCP_SESSION_OWNER,
|
|
197
|
+
principal_key=None,
|
|
198
|
+
client_ip=ip,
|
|
199
|
+
principal=principal,
|
|
200
|
+
tier=decision.tier,
|
|
201
|
+
conversation_policy=decision.conversation_policy,
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
await turn.open()
|
|
205
|
+
if turn.replay is not None:
|
|
206
|
+
return self._result(turn.replay, session_id, replayed=True)
|
|
207
|
+
assert turn.ctx is not None
|
|
208
|
+
text: list[str] = []
|
|
209
|
+
data: dict[str, Any] = {}
|
|
210
|
+
async for chunk in server.dispatch(turn.ctx):
|
|
211
|
+
if isinstance(chunk, str):
|
|
212
|
+
text.append(chunk)
|
|
213
|
+
else:
|
|
214
|
+
data.update(chunk)
|
|
215
|
+
reply = await turn.complete("".join(text), data or None)
|
|
216
|
+
except WAPProtocolError as exc:
|
|
217
|
+
await turn.fail(exc)
|
|
218
|
+
return _protocol_error_result(exc)
|
|
219
|
+
finally:
|
|
220
|
+
await turn.close()
|
|
221
|
+
return self._result(reply, session_id)
|
|
222
|
+
|
|
223
|
+
def _result(self, reply: AgentMessage, session_id: str, *, replayed: bool = False) -> types.CallToolResult:
|
|
224
|
+
data = reply.structured_data or {}
|
|
225
|
+
content = [types.TextContent(type="text", text=reply.content or json.dumps(data))]
|
|
226
|
+
if data and reply.content:
|
|
227
|
+
content.append(types.TextContent(type="text", text=json.dumps(data, ensure_ascii=False)))
|
|
228
|
+
meta = {
|
|
229
|
+
META_SIGNED_REPLY: reply.model_dump(mode="json"),
|
|
230
|
+
META_SESSION_ID: session_id,
|
|
231
|
+
META_MANIFEST: self.wap.base_url + WELL_KNOWN_PATH,
|
|
232
|
+
}
|
|
233
|
+
if replayed:
|
|
234
|
+
meta[META_PREFIX + "idempotent_replay"] = True
|
|
235
|
+
return types.CallToolResult(content=content, structured_content=data or None, meta=meta)
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------ ASGI
|
|
238
|
+
|
|
239
|
+
def security_settings(self) -> TransportSecuritySettings:
|
|
240
|
+
hosts = {self.wap.domain, authority_host(self.wap.domain)}
|
|
241
|
+
base = urlsplit(self.wap.base_url)
|
|
242
|
+
hosts.update({base.netloc, base.hostname or ""})
|
|
243
|
+
allowed = sorted(h for h in hosts if h)
|
|
244
|
+
allowed += [f"{authority_host(h)}:*" for h in allowed if ":" not in h]
|
|
245
|
+
return TransportSecuritySettings(
|
|
246
|
+
enable_dns_rebinding_protection=True,
|
|
247
|
+
allowed_hosts=sorted(set(allowed)),
|
|
248
|
+
allowed_origins=sorted({f"{base.scheme}://{base.netloc}", f"https://{self.wap.domain}"}),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@asynccontextmanager
|
|
252
|
+
async def running(self) -> AsyncIterator[None]:
|
|
253
|
+
"""Run the MCP session manager; entered from the host application's lifespan."""
|
|
254
|
+
self.mcp.extensions[EXTENSION_ID] = self.extension_settings() # pick up config changed after mount
|
|
255
|
+
manager = StreamableHTTPSessionManager(
|
|
256
|
+
app=self.mcp, json_response=True, stateless=True, security_settings=self.security_settings()
|
|
257
|
+
)
|
|
258
|
+
async with manager.run():
|
|
259
|
+
self._manager = manager
|
|
260
|
+
try:
|
|
261
|
+
yield
|
|
262
|
+
finally:
|
|
263
|
+
self._manager = None
|
|
264
|
+
|
|
265
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
266
|
+
if self._manager is None:
|
|
267
|
+
body = b'{"error":"MCP endpoint is not running; start the app with its ASGI lifespan enabled"}'
|
|
268
|
+
await send(
|
|
269
|
+
{
|
|
270
|
+
"type": "http.response.start",
|
|
271
|
+
"status": 503,
|
|
272
|
+
"headers": [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())],
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
await send({"type": "http.response.body", "body": body})
|
|
276
|
+
return
|
|
277
|
+
await self._manager.handle_request(scope, receive, send)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def mount_mcp(app: FastAPI, server: WAPServer, path: str = "/mcp") -> MCPEndpoint:
|
|
281
|
+
"""Add the MCP endpoint to ``app`` and hook its session manager into the app lifespan."""
|
|
282
|
+
endpoint = MCPEndpoint(server, path)
|
|
283
|
+
app.router.add_route(path, endpoint, methods=["GET", "POST", "DELETE"], include_in_schema=False)
|
|
284
|
+
original = app.router.lifespan_context
|
|
285
|
+
|
|
286
|
+
@asynccontextmanager
|
|
287
|
+
async def lifespan(asgi_app: Any) -> AsyncIterator[Any]:
|
|
288
|
+
async with endpoint.running():
|
|
289
|
+
async with original(asgi_app) as state:
|
|
290
|
+
yield state
|
|
291
|
+
|
|
292
|
+
app.router.lifespan_context = lifespan
|
|
293
|
+
return endpoint
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
__all__ = [
|
|
297
|
+
"EXTENSION_ID",
|
|
298
|
+
"META_CHALLENGE",
|
|
299
|
+
"META_IDEMPOTENCY_KEY",
|
|
300
|
+
"META_POW",
|
|
301
|
+
"META_SESSION_ID",
|
|
302
|
+
"META_SIGNED_REPLY",
|
|
303
|
+
"MCPEndpoint",
|
|
304
|
+
"mount_mcp",
|
|
305
|
+
]
|
wap/server/mcp_import.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Publish an existing MCP server's tools on the open web through WAP.
|
|
2
|
+
|
|
3
|
+
If you already have an MCP server, one call makes its tools discoverable at
|
|
4
|
+
``/.well-known/wap.json``, with signed replies, proof-of-work and rate limits::
|
|
5
|
+
|
|
6
|
+
from mcp.server.mcpserver import MCPServer
|
|
7
|
+
from wap.server import WAPServer
|
|
8
|
+
|
|
9
|
+
tools = MCPServer("inventory")
|
|
10
|
+
|
|
11
|
+
@tools.tool()
|
|
12
|
+
def check_stock(sku: str) -> dict: ...
|
|
13
|
+
|
|
14
|
+
wap = WAPServer(name="Acme", domain="acme.example", private_key=KEY)
|
|
15
|
+
wap.include_mcp(tools) # imported when the app starts
|
|
16
|
+
app = wap.create_app()
|
|
17
|
+
|
|
18
|
+
``source`` is anything the MCP SDK's ``Client`` accepts: an in-process server
|
|
19
|
+
object, a streamable-HTTP URL, or ``StdioServerParameters`` for a subprocess.
|
|
20
|
+
Installed with the ``server`` extra.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from contextlib import AsyncExitStack
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from mcp.client.client import Client
|
|
31
|
+
|
|
32
|
+
from ..spec.models import Capability, ErrorCode
|
|
33
|
+
from .app import ActionContext, ActionResult, WAPProtocolError
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .app import WAPServer
|
|
37
|
+
|
|
38
|
+
_INVALID = re.compile(r"[^a-z0-9_.-]+")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def capability_id_for(tool_name: str, prefix: str = "") -> str:
|
|
42
|
+
"""Map an MCP tool name onto WAP's capability-id grammar (``^[a-z][a-z0-9_.-]{0,63}$``)."""
|
|
43
|
+
ident = _INVALID.sub("_", (prefix + tool_name).lower()).strip("_") or "tool"
|
|
44
|
+
if not ident[0].isalpha():
|
|
45
|
+
ident = "t_" + ident
|
|
46
|
+
return ident[:64]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _text_of(content: list[Any]) -> str:
|
|
50
|
+
return "\n".join(block.text for block in content if getattr(block, "type", None) == "text")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MCPToolSource:
|
|
54
|
+
"""A live connection to an MCP server whose tools are re-published as WAP capabilities."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, source: Any, *, prefix: str = "", include: set[str] | None = None) -> None:
|
|
57
|
+
self.source = source
|
|
58
|
+
self.prefix = prefix
|
|
59
|
+
self.include = include
|
|
60
|
+
self.client: Client | None = None
|
|
61
|
+
self._stack: AsyncExitStack | None = None
|
|
62
|
+
self.capability_ids: list[str] = []
|
|
63
|
+
|
|
64
|
+
async def connect(self) -> Client:
|
|
65
|
+
if self.client is None:
|
|
66
|
+
stack = AsyncExitStack()
|
|
67
|
+
self.client = await stack.enter_async_context(Client(self.source))
|
|
68
|
+
self._stack = stack
|
|
69
|
+
return self.client
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
if self._stack is not None:
|
|
73
|
+
await self._stack.aclose()
|
|
74
|
+
self._stack = None
|
|
75
|
+
self.client = None
|
|
76
|
+
|
|
77
|
+
async def register(self, wap: WAPServer) -> list[str]:
|
|
78
|
+
"""List the source's tools and add each one to ``wap`` as a capability."""
|
|
79
|
+
client = await self.connect()
|
|
80
|
+
registered = []
|
|
81
|
+
for tool in (await client.list_tools()).tools:
|
|
82
|
+
if self.include is not None and tool.name not in self.include:
|
|
83
|
+
continue
|
|
84
|
+
capability_id = capability_id_for(tool.name, self.prefix)
|
|
85
|
+
if capability_id in wap.actions:
|
|
86
|
+
continue
|
|
87
|
+
schema = dict(tool.input_schema or {"type": "object", "properties": {}})
|
|
88
|
+
schema.setdefault("type", "object")
|
|
89
|
+
output = tool.output_schema if tool.output_schema and tool.output_schema.get("type") == "object" else None
|
|
90
|
+
capability = Capability(
|
|
91
|
+
id=capability_id,
|
|
92
|
+
name=(tool.title or tool.name)[:128],
|
|
93
|
+
description=(tool.description or "")[:4096],
|
|
94
|
+
input_schema=schema,
|
|
95
|
+
output_schema=output,
|
|
96
|
+
)
|
|
97
|
+
wap.add_capability(capability, self._handler(tool.name))
|
|
98
|
+
registered.append(capability_id)
|
|
99
|
+
self.capability_ids = registered
|
|
100
|
+
return registered
|
|
101
|
+
|
|
102
|
+
def _handler(self, tool_name: str) -> Any:
|
|
103
|
+
async def call(payload: dict[str, Any], ctx: ActionContext) -> ActionResult:
|
|
104
|
+
if self.client is None:
|
|
105
|
+
raise WAPProtocolError(ErrorCode.ACTION_FAILED, "the upstream MCP server is not connected")
|
|
106
|
+
result = await self.client.call_tool(tool_name, payload)
|
|
107
|
+
text = _text_of(list(result.content or []))
|
|
108
|
+
if result.is_error:
|
|
109
|
+
raise WAPProtocolError(ErrorCode.ACTION_FAILED, text or f"MCP tool {tool_name!r} failed")
|
|
110
|
+
data = result.structured_content
|
|
111
|
+
if data is None:
|
|
112
|
+
# Tools without an output schema (e.g. ``-> dict``) return their result only as JSON text.
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(text)
|
|
115
|
+
except ValueError:
|
|
116
|
+
data = None
|
|
117
|
+
return ActionResult(content=text, data=dict(data) if isinstance(data, dict) else None)
|
|
118
|
+
|
|
119
|
+
return call
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["MCPToolSource", "capability_id_for"]
|
wap/server/middleware.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Attach WAP to existing applications.
|
|
2
|
+
|
|
3
|
+
* :func:`inject_routes` is what :meth:`WAPServer.mount` uses for FastAPI apps:
|
|
4
|
+
it adds the discovery, challenge and interaction routes and stamps every
|
|
5
|
+
WAP response with ``X-WAP-Version``.
|
|
6
|
+
* :class:`WAPDiscoveryMiddleware` is a dependency-light pure ASGI middleware
|
|
7
|
+
for *any* ASGI framework (Starlette, Django, Quart, Litestar...). It serves
|
|
8
|
+
``/.well-known/wap.json`` (and the legacy ``/.well-known/agent.json``) in front of the wrapped application, so a site
|
|
9
|
+
can publish its manifest without routing changes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from ..spec.crypto import canonical_json
|
|
17
|
+
from ..spec.models import HEADER_SIGNATURE, HEADER_VERSION, WAP_VERSION, WELL_KNOWN_PATHS, ErrorResponse
|
|
18
|
+
from .app import WAPProtocolError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
|
|
23
|
+
from .app import WAPServer
|
|
24
|
+
|
|
25
|
+
Scope = dict[str, Any]
|
|
26
|
+
ASGIApp = Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WAPDiscoveryMiddleware:
|
|
30
|
+
"""Serve the signed manifest at the well-known paths in front of any ASGI app."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, app: ASGIApp, server: WAPServer) -> None:
|
|
33
|
+
self.app = app
|
|
34
|
+
self.server = server
|
|
35
|
+
|
|
36
|
+
async def __call__(self, scope: Scope, receive: Any, send: Any) -> None:
|
|
37
|
+
if scope["type"] == "http" and scope.get("path") in WELL_KNOWN_PATHS:
|
|
38
|
+
method = scope.get("method", "GET")
|
|
39
|
+
if method in ("GET", "HEAD"):
|
|
40
|
+
await self._serve_manifest(scope, send, head=method == "HEAD")
|
|
41
|
+
return
|
|
42
|
+
if method == "OPTIONS":
|
|
43
|
+
await self._send(
|
|
44
|
+
send,
|
|
45
|
+
204,
|
|
46
|
+
b"",
|
|
47
|
+
{
|
|
48
|
+
"Allow": "GET, HEAD, OPTIONS",
|
|
49
|
+
"Access-Control-Allow-Origin": "*",
|
|
50
|
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
await self._send(send, 405, b"", {"Allow": "GET, HEAD, OPTIONS"})
|
|
55
|
+
return
|
|
56
|
+
await self.app(scope, receive, send)
|
|
57
|
+
|
|
58
|
+
async def _serve_manifest(self, scope: Scope, send: Any, head: bool) -> None:
|
|
59
|
+
client = scope.get("client")
|
|
60
|
+
ip = client[0] if client else None
|
|
61
|
+
try:
|
|
62
|
+
await self.server.limit_ip(ip)
|
|
63
|
+
except WAPProtocolError as exc:
|
|
64
|
+
body = canonical_json(
|
|
65
|
+
ErrorResponse.build(exc.code, exc.message, retry_after=exc.retry_after).model_dump(
|
|
66
|
+
mode="json", exclude_none=True
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
headers = {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
**exc.headers,
|
|
72
|
+
HEADER_SIGNATURE: self.server.signer.sign(body),
|
|
73
|
+
}
|
|
74
|
+
await self._send(send, 429, body, headers)
|
|
75
|
+
return
|
|
76
|
+
manifest = self.server.manifest()
|
|
77
|
+
body = canonical_json(manifest.model_dump(mode="json"))
|
|
78
|
+
headers = {
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
"Cache-Control": "public, max-age=300",
|
|
81
|
+
"Access-Control-Allow-Origin": "*",
|
|
82
|
+
"ETag": f'"{manifest.signature[:32]}"',
|
|
83
|
+
HEADER_SIGNATURE: self.server.signer.sign(body),
|
|
84
|
+
}
|
|
85
|
+
await self._send(send, 200, b"" if head else body, headers, content_length=len(body))
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
async def _send(
|
|
89
|
+
send: Any, status: int, body: bytes, headers: dict[str, str], content_length: int | None = None
|
|
90
|
+
) -> None:
|
|
91
|
+
raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()]
|
|
92
|
+
raw_headers.append((HEADER_VERSION.lower().encode(), WAP_VERSION.encode()))
|
|
93
|
+
raw_headers.append((b"content-length", str(len(body) if content_length is None else content_length).encode()))
|
|
94
|
+
await send({"type": "http.response.start", "status": status, "headers": raw_headers})
|
|
95
|
+
await send({"type": "http.response.body", "body": body})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def inject_routes(app: FastAPI, server: WAPServer) -> None:
|
|
99
|
+
"""Register WAP endpoints on ``app`` and expose the server as ``app.state.wap_server``."""
|
|
100
|
+
from .router import build_router
|
|
101
|
+
|
|
102
|
+
existing = getattr(app.state, "wap_server", None)
|
|
103
|
+
if existing is not None and existing is not server:
|
|
104
|
+
raise RuntimeError("a different WAPServer is already mounted on this application")
|
|
105
|
+
if existing is server:
|
|
106
|
+
return
|
|
107
|
+
app.include_router(build_router(server))
|
|
108
|
+
app.state.wap_server = server
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["WAPDiscoveryMiddleware", "inject_routes"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Ready-made observers for ``WAPServer(observer=...)``.
|
|
2
|
+
|
|
3
|
+
A WAP server reports what happens to each request as ``observer(event, attributes)``:
|
|
4
|
+
|
|
5
|
+
========================== ==========================================================
|
|
6
|
+
``request.completed`` ``capability``, ``tier``, ``duration_ms``
|
|
7
|
+
``request.failed`` ``code``, ``capability``, ``tier`` (the action raised)
|
|
8
|
+
``request.rejected`` ``code`` (``rate_limited``, ``pow_invalid``, ``loop_detected``,
|
|
9
|
+
``forbidden``, ...), plus ``scope``/``tier``/``reason`` when known
|
|
10
|
+
``request.idempotent_replay`` ``capability``
|
|
11
|
+
``pow.issued`` ``difficulty``
|
|
12
|
+
========================== ==========================================================
|
|
13
|
+
|
|
14
|
+
Use :class:`LoggingObserver` for structured logs, :class:`MetricsObserver` for
|
|
15
|
+
in-process counters and latency percentiles (e.g. to export to Prometheus or
|
|
16
|
+
OpenTelemetry), or :func:`combine` to send events to several observers. Any
|
|
17
|
+
callable with the same signature works; observer errors are logged and never
|
|
18
|
+
affect requests.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import threading
|
|
27
|
+
from collections import Counter, defaultdict, deque
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
Observer = Callable[[str, dict[str, Any]], None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LoggingObserver:
|
|
35
|
+
"""Emit one structured log line (JSON attributes) per event."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, logger: logging.Logger | None = None, level: int = logging.INFO) -> None:
|
|
38
|
+
self.logger = logger or logging.getLogger("wap.events")
|
|
39
|
+
self.level = level
|
|
40
|
+
|
|
41
|
+
def __call__(self, event: str, attributes: dict[str, Any]) -> None:
|
|
42
|
+
level = logging.WARNING if event == "request.rejected" else self.level
|
|
43
|
+
self.logger.log(level, "%s %s", event, json.dumps(attributes, default=str, sort_keys=True))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MetricsObserver:
|
|
47
|
+
"""Thread-safe counters by (event, code/tier/capability) plus latency percentiles per capability."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, max_samples: int = 10_000) -> None:
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self.counters: Counter[tuple[str, str]] = Counter()
|
|
52
|
+
self._latency: dict[str, deque[float]] = defaultdict(lambda: deque(maxlen=max_samples))
|
|
53
|
+
|
|
54
|
+
def __call__(self, event: str, attributes: dict[str, Any]) -> None:
|
|
55
|
+
label = str(attributes.get("code") or attributes.get("capability") or attributes.get("difficulty") or "")
|
|
56
|
+
with self._lock:
|
|
57
|
+
self.counters[(event, label)] += 1
|
|
58
|
+
if event == "request.completed" and "duration_ms" in attributes:
|
|
59
|
+
self._latency[str(attributes.get("capability"))].append(float(attributes["duration_ms"]))
|
|
60
|
+
|
|
61
|
+
def count(self, event: str, label: str | None = None) -> int:
|
|
62
|
+
with self._lock:
|
|
63
|
+
if label is not None:
|
|
64
|
+
return self.counters[(event, label)]
|
|
65
|
+
return sum(v for (e, _), v in self.counters.items() if e == event)
|
|
66
|
+
|
|
67
|
+
def percentile(self, capability: str, q: float) -> float | None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
samples = sorted(self._latency.get(capability, ()))
|
|
70
|
+
if not samples:
|
|
71
|
+
return None
|
|
72
|
+
# Nearest-rank method.
|
|
73
|
+
rank = max(1, math.ceil(q / 100 * len(samples)))
|
|
74
|
+
return samples[min(rank, len(samples)) - 1]
|
|
75
|
+
|
|
76
|
+
def snapshot(self) -> dict[str, Any]:
|
|
77
|
+
"""A JSON-friendly view, e.g. for a ``/metrics`` endpoint of your own."""
|
|
78
|
+
with self._lock:
|
|
79
|
+
counters = {f"{event}{{{label}}}" if label else event: n for (event, label), n in self.counters.items()}
|
|
80
|
+
capabilities = list(self._latency)
|
|
81
|
+
return {
|
|
82
|
+
"counters": counters,
|
|
83
|
+
"latency_ms": {
|
|
84
|
+
cap: {q: self.percentile(cap, float(q[1:])) for q in ("p50", "p95", "p99")} for cap in capabilities
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def combine(*observers: Observer) -> Observer:
|
|
90
|
+
"""Fan one event out to several observers."""
|
|
91
|
+
|
|
92
|
+
def observe(event: str, attributes: dict[str, Any]) -> None:
|
|
93
|
+
for observer in observers:
|
|
94
|
+
try:
|
|
95
|
+
observer(event, attributes)
|
|
96
|
+
except Exception: # noqa: BLE001 - one failing sink must not starve the others
|
|
97
|
+
logging.getLogger("wap.server").exception("observer %r failed for %r", observer, event)
|
|
98
|
+
|
|
99
|
+
return observe
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = ["LoggingObserver", "MetricsObserver", "Observer", "combine"]
|