solvapay 0.1.0.dev4__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.
- solvapay/__init__.py +26 -0
- solvapay/_currencies.py +170 -0
- solvapay/_generated/__init__.py +1 -0
- solvapay/_generated/models.py +3643 -0
- solvapay/_generated/operations.py +1894 -0
- solvapay/_http.py +243 -0
- solvapay/_js.py +114 -0
- solvapay/_tax_ids.py +442 -0
- solvapay/agents/__init__.py +67 -0
- solvapay/agents/calls.py +302 -0
- solvapay/agents/identity.py +293 -0
- solvapay/agents/mcp.py +614 -0
- solvapay/agents/oauth.py +105 -0
- solvapay/business_details.py +124 -0
- solvapay/client.py +107 -0
- solvapay/errors.py +33 -0
- solvapay/mcp/__init__.py +90 -0
- solvapay/mcp/app.py +243 -0
- solvapay/mcp/auth.py +604 -0
- solvapay/mcp/bootstrap.py +494 -0
- solvapay/mcp/intent.py +474 -0
- solvapay/mcp/mcp-app.html +119 -0
- solvapay/mcp/narrate.py +1019 -0
- solvapay/mcp/response.py +220 -0
- solvapay/mcp/server.py +343 -0
- solvapay/mcp/tools.py +273 -0
- solvapay/mcp/transport.py +550 -0
- solvapay/money.py +61 -0
- solvapay/paywall/__init__.py +25 -0
- solvapay/paywall/decide.py +450 -0
- solvapay/paywall/gate.py +176 -0
- solvapay/paywall/state.py +371 -0
- solvapay/pricing.py +281 -0
- solvapay/py.typed +0 -0
- solvapay/solvapay.py +173 -0
- solvapay/webhooks.py +93 -0
- solvapay-0.1.0.dev4.dist-info/METADATA +183 -0
- solvapay-0.1.0.dev4.dist-info/RECORD +39 -0
- solvapay-0.1.0.dev4.dist-info/WHEEL +4 -0
solvapay/agents/mcp.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""An MCP server paid by SolvaPay agents, ported from ``@solvapay/agents/mcp``.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install 'solvapay[agents,mcp]'``. Requests on the MCP path
|
|
4
|
+
whose bearer token is an agent key, or a token SolvaPay's agent issuer signed,
|
|
5
|
+
are verified and served by an MCP server built for that agent. Each paid tool
|
|
6
|
+
call is authorized before it runs and completed at the cost it reports. Every
|
|
7
|
+
other request is the merchant's: the route answers ``None`` and the merchant's
|
|
8
|
+
own app handles it.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from solvapay.agents import Money
|
|
13
|
+
from solvapay.agents.mcp import PaidTool, PaidToolRun, create_agent_mcp_route
|
|
14
|
+
|
|
15
|
+
class Question(BaseModel):
|
|
16
|
+
question: str
|
|
17
|
+
|
|
18
|
+
async def answer(args: Question, call) -> PaidToolRun:
|
|
19
|
+
reply = await upstream(args.question)
|
|
20
|
+
return PaidToolRun(status=200, cost=(reply.cost, "USD"), result=text_result(reply.text))
|
|
21
|
+
|
|
22
|
+
route = create_agent_mcp_route(
|
|
23
|
+
issuer="https://api.solvapay.com/v1/agent",
|
|
24
|
+
agent_layer=layer,
|
|
25
|
+
calls=calls,
|
|
26
|
+
mcp_path="/mcp",
|
|
27
|
+
server_name="research",
|
|
28
|
+
tools=lambda server, paid, _call: paid(PaidTool(
|
|
29
|
+
name="answer", description="Answers a question.", input=Question,
|
|
30
|
+
item=lambda _a: "answer-v1",
|
|
31
|
+
estimate=lambda _a: Money("0.01", "USD"),
|
|
32
|
+
prompt=lambda a: a.question,
|
|
33
|
+
run=answer,
|
|
34
|
+
)),
|
|
35
|
+
)
|
|
36
|
+
app = route.middleware(merchant_app) # or: response = await route(request)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A refusal is a tool error (``isError: true``) whose text is the sentence for the
|
|
40
|
+
agent's human, and whose ``structuredContent.refusal.reason`` says which refusal
|
|
41
|
+
it was, so an agent can act on it without parsing the text.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import inspect
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import re
|
|
50
|
+
import uuid
|
|
51
|
+
from collections.abc import Awaitable, Callable
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
from decimal import Decimal
|
|
54
|
+
from typing import Annotated, Any, Generic, TypeVar
|
|
55
|
+
|
|
56
|
+
import anyio
|
|
57
|
+
import jwt
|
|
58
|
+
from mcp.server.mcpserver import MCPServer
|
|
59
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
60
|
+
from mcp.types import CallToolResult, TextContent, ToolAnnotations
|
|
61
|
+
from pydantic import BaseModel, Field
|
|
62
|
+
from starlette.requests import Request
|
|
63
|
+
from starlette.responses import JSONResponse, Response
|
|
64
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
65
|
+
|
|
66
|
+
from ..errors import SolvaPayError
|
|
67
|
+
from .calls import AgentCall, AgentCalls, CallResult, Money, Refused, prompt_facts
|
|
68
|
+
from .identity import (
|
|
69
|
+
AgentLayer,
|
|
70
|
+
AgentRejected,
|
|
71
|
+
NotAgent,
|
|
72
|
+
RejectionReason,
|
|
73
|
+
VerifiedAgent,
|
|
74
|
+
is_agent_key,
|
|
75
|
+
)
|
|
76
|
+
from .oauth import (
|
|
77
|
+
canonical_resource,
|
|
78
|
+
challenge_header,
|
|
79
|
+
protected_resource_metadata,
|
|
80
|
+
protected_resource_metadata_path,
|
|
81
|
+
protected_resource_metadata_url,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger("solvapay.agents")
|
|
85
|
+
|
|
86
|
+
ArgsT = TypeVar("ArgsT", bound=BaseModel)
|
|
87
|
+
|
|
88
|
+
UNREACHABLE = "The service behind this tool could not be reached. Nothing was charged."
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class AgentRequest:
|
|
93
|
+
"""The agent a request was verified as, and the token it presented."""
|
|
94
|
+
|
|
95
|
+
request: Request
|
|
96
|
+
agent: VerifiedAgent
|
|
97
|
+
agent_token: str
|
|
98
|
+
"""As presented; SolvaPay verifies it again on every authorize."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class PaidCall:
|
|
103
|
+
"""What a paid tool's ``run`` gets besides its arguments."""
|
|
104
|
+
|
|
105
|
+
call_ref: str
|
|
106
|
+
request_id: str
|
|
107
|
+
agent: VerifiedAgent
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class PaidToolRun:
|
|
112
|
+
"""How a paid tool's upstream call went."""
|
|
113
|
+
|
|
114
|
+
status: int
|
|
115
|
+
"""The status the upstream answered with; 400 and above charges nothing."""
|
|
116
|
+
cost: tuple[float | Decimal | str, str] | None
|
|
117
|
+
"""What the upstream reported it cost, ``(amount, currency)``, or None."""
|
|
118
|
+
result: CallToolResult
|
|
119
|
+
"""What the agent gets back."""
|
|
120
|
+
interrupted: str | None = None
|
|
121
|
+
"""Set when the answer broke before it finished."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class PaidTool(Generic[ArgsT]):
|
|
126
|
+
name: str
|
|
127
|
+
description: str
|
|
128
|
+
input: type[ArgsT]
|
|
129
|
+
"""The tool's arguments as a pydantic model; MCP validates calls against it."""
|
|
130
|
+
item: Callable[[ArgsT], str]
|
|
131
|
+
"""The merchant's label for what the call buys (a model id, a tool name)."""
|
|
132
|
+
estimate: Callable[[ArgsT], Money]
|
|
133
|
+
"""What the call may cost, in the merchant's currency."""
|
|
134
|
+
run: Callable[[ArgsT, PaidCall], Awaitable[PaidToolRun]]
|
|
135
|
+
"""Calls the upstream. Raising means it was never reached: released, nothing charged."""
|
|
136
|
+
title: str | None = None
|
|
137
|
+
annotations: ToolAnnotations | None = None
|
|
138
|
+
prompt: Callable[[ArgsT], str | None] | None = None
|
|
139
|
+
"""The text whose fingerprint shows repeats (loops); its end goes only when allowed."""
|
|
140
|
+
unreachable_message: str | None = None
|
|
141
|
+
"""What the agent reads when ``run`` raises; says nothing was charged."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class ProtectedResource:
|
|
146
|
+
"""Turns OAuth sign-in on for hosts such as Claude and ChatGPT."""
|
|
147
|
+
|
|
148
|
+
resource: str
|
|
149
|
+
"""Canonical MCP URL, with its path. Becomes ``resource`` and the expected ``aud``."""
|
|
150
|
+
authorization_server: str | None = None
|
|
151
|
+
"""Defaults to the agent issuer."""
|
|
152
|
+
documentation: str | None = None
|
|
153
|
+
metadata_path: str | None = None
|
|
154
|
+
"""Default: ``/.well-known/oauth-protected-resource`` + the resource path."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
Paid = Callable[[PaidTool[Any]], None]
|
|
158
|
+
RegisterTools = Callable[[MCPServer, Paid, "AgentRequest | None"], None]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---- tool registration ------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _tool_function(
|
|
165
|
+
tool: PaidTool[Any], body: Callable[[Any], Awaitable[CallToolResult]]
|
|
166
|
+
) -> Callable[..., Awaitable[CallToolResult]]:
|
|
167
|
+
"""A function whose signature is ``tool.input``'s fields, for MCPServer's schema."""
|
|
168
|
+
params = []
|
|
169
|
+
for name, field in tool.input.model_fields.items():
|
|
170
|
+
annotation: Any = field.annotation
|
|
171
|
+
if field.description:
|
|
172
|
+
annotation = Annotated[annotation, Field(description=field.description)]
|
|
173
|
+
params.append(
|
|
174
|
+
inspect.Parameter(
|
|
175
|
+
name,
|
|
176
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
177
|
+
annotation=annotation,
|
|
178
|
+
default=inspect.Parameter.empty if field.is_required() else field.default,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def call(**kwargs: Any) -> CallToolResult:
|
|
183
|
+
return await body(tool.input.model_validate(kwargs))
|
|
184
|
+
|
|
185
|
+
call.__signature__ = inspect.Signature(params, return_annotation=CallToolResult) # type: ignore[attr-defined]
|
|
186
|
+
call.__annotations__ = {p.name: p.annotation for p in params} | {"return": CallToolResult}
|
|
187
|
+
call.__name__ = tool.name
|
|
188
|
+
return call
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _add(server: MCPServer, tool: PaidTool[Any], fn: Callable[..., Any]) -> None:
|
|
192
|
+
"""Without a title the name stands in; without annotations it is neither read-only nor
|
|
193
|
+
destructive (a paid call is never read-only in the money sense)."""
|
|
194
|
+
server.add_tool(
|
|
195
|
+
fn,
|
|
196
|
+
name=tool.name,
|
|
197
|
+
title=tool.title or tool.name,
|
|
198
|
+
description=tool.description,
|
|
199
|
+
annotations=tool.annotations
|
|
200
|
+
or ToolAnnotations(read_only_hint=False, destructive_hint=False),
|
|
201
|
+
structured_output=False,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def register_paid_tool(
|
|
206
|
+
server: MCPServer, *, calls: AgentCalls, call: AgentRequest, tool: PaidTool[Any]
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Registers one paid tool: authorize, run, complete; a refusal becomes a tool error."""
|
|
209
|
+
|
|
210
|
+
async def body(args: Any) -> CallToolResult:
|
|
211
|
+
request_id = str(uuid.uuid4())
|
|
212
|
+
text = tool.prompt(args) if tool.prompt else None
|
|
213
|
+
begun = await calls.begin(
|
|
214
|
+
AgentCall(
|
|
215
|
+
request_id=request_id,
|
|
216
|
+
agent_token=call.agent_token,
|
|
217
|
+
agent=call.agent,
|
|
218
|
+
item=tool.item(args),
|
|
219
|
+
estimate=tool.estimate(args),
|
|
220
|
+
prompt=None if text is None else prompt_facts(text),
|
|
221
|
+
log_fields={"tool": tool.name},
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
if isinstance(begun, Refused):
|
|
225
|
+
return CallToolResult(
|
|
226
|
+
is_error=True,
|
|
227
|
+
content=[TextContent(type="text", text=begun.message)],
|
|
228
|
+
structured_content={"refusal": {"reason": begun.reason, "message": begun.message}},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
run = await tool.run(
|
|
233
|
+
args, PaidCall(call_ref=begun.call_ref, request_id=request_id, agent=call.agent)
|
|
234
|
+
)
|
|
235
|
+
except Exception as error:
|
|
236
|
+
await begun.release("upstream_unreachable")
|
|
237
|
+
logger.error(
|
|
238
|
+
"call.upstream_failed %s",
|
|
239
|
+
{"requestId": request_id, "tool": tool.name, "callRef": begun.call_ref,
|
|
240
|
+
"error": str(error)},
|
|
241
|
+
) # fmt: skip
|
|
242
|
+
return CallToolResult(
|
|
243
|
+
is_error=True,
|
|
244
|
+
content=[TextContent(type="text", text=tool.unreachable_message or UNREACHABLE)],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
completed = await begun.finish(
|
|
248
|
+
CallResult(status=run.status, cost=run.cost, interrupted=run.interrupted)
|
|
249
|
+
)
|
|
250
|
+
logger.info(
|
|
251
|
+
"call.completed %s",
|
|
252
|
+
{
|
|
253
|
+
"requestId": request_id,
|
|
254
|
+
"tool": tool.name,
|
|
255
|
+
"agentRef": call.agent.agent_ref,
|
|
256
|
+
"userRef": call.agent.principal_ref,
|
|
257
|
+
"callRef": begun.call_ref,
|
|
258
|
+
"item": begun.item,
|
|
259
|
+
"estimate": f"{begun.estimate.amount} {begun.estimate.currency}",
|
|
260
|
+
"status": run.status,
|
|
261
|
+
"cost": run.cost,
|
|
262
|
+
# None when SolvaPay could not be told; the SDK logged why.
|
|
263
|
+
"completed": None
|
|
264
|
+
if completed is None
|
|
265
|
+
else {"source": completed.source, "amount": completed.amount.model_dump()},
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
return run.result
|
|
269
|
+
|
|
270
|
+
_add(server, tool, _tool_function(tool, body))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _list_paid_tool(server: MCPServer, tool: PaidTool[Any], names: set[str]) -> None:
|
|
274
|
+
"""Lists a paid tool for a host that has not signed in; the route never lets it run."""
|
|
275
|
+
names.add(tool.name)
|
|
276
|
+
|
|
277
|
+
async def body(_args: Any) -> CallToolResult:
|
|
278
|
+
raise SolvaPayError(
|
|
279
|
+
f"Paid tool {tool.name} reached without an agent: the route should have challenged",
|
|
280
|
+
code="agent_route_misconfigured",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
_add(server, tool, _tool_function(tool, body))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- serving ----------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _serve(server: MCPServer, request: Request, body: bytes) -> Response:
|
|
290
|
+
"""Answers one MCP request (JSON, stateless) from ``server``, built for this request."""
|
|
291
|
+
if request.method != "POST":
|
|
292
|
+
# Stateless and JSON-only: no event stream to open (GET), no session to end
|
|
293
|
+
# (DELETE). The transport would otherwise hold a GET open forever.
|
|
294
|
+
return JSONResponse(
|
|
295
|
+
{
|
|
296
|
+
"jsonrpc": "2.0",
|
|
297
|
+
"id": None,
|
|
298
|
+
"error": {"code": -32000, "message": "Method not allowed."},
|
|
299
|
+
},
|
|
300
|
+
status_code=405,
|
|
301
|
+
)
|
|
302
|
+
server.streamable_http_app(
|
|
303
|
+
json_response=True,
|
|
304
|
+
stateless_http=True,
|
|
305
|
+
# The merchant's app owns host checks; this server is reached only through it.
|
|
306
|
+
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
|
307
|
+
)
|
|
308
|
+
manager = server.session_manager
|
|
309
|
+
sent_body = False
|
|
310
|
+
done = anyio.Event()
|
|
311
|
+
status = 500
|
|
312
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
313
|
+
chunks: list[bytes] = []
|
|
314
|
+
|
|
315
|
+
async def receive() -> Message:
|
|
316
|
+
nonlocal sent_body
|
|
317
|
+
if not sent_body:
|
|
318
|
+
sent_body = True
|
|
319
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
320
|
+
await done.wait()
|
|
321
|
+
return {"type": "http.disconnect"}
|
|
322
|
+
|
|
323
|
+
async def send(message: Message) -> None:
|
|
324
|
+
nonlocal status, headers
|
|
325
|
+
if message["type"] == "http.response.start":
|
|
326
|
+
status = message["status"]
|
|
327
|
+
headers = list(message.get("headers", []))
|
|
328
|
+
elif message["type"] == "http.response.body":
|
|
329
|
+
chunks.append(message.get("body", b""))
|
|
330
|
+
if not message.get("more_body", False):
|
|
331
|
+
done.set()
|
|
332
|
+
|
|
333
|
+
async with manager.run():
|
|
334
|
+
await manager.handle_request(request.scope, receive, send)
|
|
335
|
+
done.set()
|
|
336
|
+
response = Response(content=b"".join(chunks), status_code=status)
|
|
337
|
+
response.raw_headers = headers
|
|
338
|
+
return response
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _json_body(body: bytes) -> Any:
|
|
342
|
+
try:
|
|
343
|
+
return json.loads(body)
|
|
344
|
+
except ValueError:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _tool_calls(method: str, body: bytes) -> list[tuple[Any, str]]:
|
|
349
|
+
"""The ``tools/call`` requests in a JSON-RPC body (one, or a batch): ``(id, name)``."""
|
|
350
|
+
if method != "POST":
|
|
351
|
+
return []
|
|
352
|
+
parsed = _json_body(body)
|
|
353
|
+
messages = parsed if isinstance(parsed, list) else [parsed]
|
|
354
|
+
out = []
|
|
355
|
+
for message in messages:
|
|
356
|
+
if not isinstance(message, dict) or message.get("method") != "tools/call":
|
|
357
|
+
continue
|
|
358
|
+
params = message.get("params")
|
|
359
|
+
name = params.get("name") if isinstance(params, dict) else None
|
|
360
|
+
if isinstance(name, str):
|
|
361
|
+
out.append((message.get("id"), name))
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _json_rpc_id(body: bytes) -> Any:
|
|
366
|
+
parsed = _json_body(body)
|
|
367
|
+
return parsed.get("id") if isinstance(parsed, dict) else None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _agent_bearer(request: Request, issuer: str) -> str | None:
|
|
371
|
+
"""The bearer, when it is an agent key or SolvaPay's agent issuer signed it (read
|
|
372
|
+
unverified, only to route)."""
|
|
373
|
+
match = re.match(r"^Bearer\s+(\S+)$", request.headers.get("authorization", ""), re.I)
|
|
374
|
+
if not match:
|
|
375
|
+
return None
|
|
376
|
+
token = match.group(1)
|
|
377
|
+
if is_agent_key(token):
|
|
378
|
+
return token
|
|
379
|
+
try:
|
|
380
|
+
claims = jwt.decode(token, options={"verify_signature": False})
|
|
381
|
+
except jwt.PyJWTError:
|
|
382
|
+
return None # Not a JWT at all: the merchant's own handler answers it.
|
|
383
|
+
return token if claims.get("iss") == issuer else None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _metadata_path(resource: ProtectedResource) -> str:
|
|
387
|
+
return protected_resource_metadata_path(resource.resource, resource.metadata_path)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def auth_challenge(resource: ProtectedResource, description: str, json_rpc_id: Any) -> Response:
|
|
391
|
+
"""A 401 JSON-RPC error with the challenge: the host signs the owner in and retries."""
|
|
392
|
+
metadata_url = protected_resource_metadata_url(resource.resource, resource.metadata_path)
|
|
393
|
+
return JSONResponse(
|
|
394
|
+
{"jsonrpc": "2.0", "id": json_rpc_id, "error": {"code": -32001, "message": description}},
|
|
395
|
+
status_code=401,
|
|
396
|
+
headers={
|
|
397
|
+
"WWW-Authenticate": challenge_header(
|
|
398
|
+
resource=resource.resource, description=description, metadata_url=metadata_url
|
|
399
|
+
),
|
|
400
|
+
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _agent_token_rejected(reason: RejectionReason) -> Response:
|
|
406
|
+
"""401 for a token that did not verify; 403 for the other environment, as SolvaPay."""
|
|
407
|
+
return JSONResponse(
|
|
408
|
+
{
|
|
409
|
+
"jsonrpc": "2.0",
|
|
410
|
+
"id": None,
|
|
411
|
+
"error": {"code": -32001, "message": f"The agent token was not accepted ({reason})."},
|
|
412
|
+
},
|
|
413
|
+
status_code=403 if reason == "environment_mismatch" else 401,
|
|
414
|
+
headers={"WWW-Authenticate": f'Bearer error="invalid_token", error_description="{reason}"'},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class AgentMcpRoute:
|
|
419
|
+
"""``await route(request)``: a response for an agent's request, else None.
|
|
420
|
+
``route.middleware(app)`` puts it in front of the merchant's ASGI app."""
|
|
421
|
+
|
|
422
|
+
def __init__(
|
|
423
|
+
self,
|
|
424
|
+
*,
|
|
425
|
+
issuer: str,
|
|
426
|
+
agent_layer: AgentLayer,
|
|
427
|
+
calls: AgentCalls,
|
|
428
|
+
mcp_path: str,
|
|
429
|
+
server_name: str,
|
|
430
|
+
tools: RegisterTools,
|
|
431
|
+
server_version: str = "0.1.0",
|
|
432
|
+
protected_resource: ProtectedResource | None = None,
|
|
433
|
+
lazy_auth: bool = True,
|
|
434
|
+
free_tools: Callable[[MCPServer], None] | None = None,
|
|
435
|
+
) -> None:
|
|
436
|
+
self.issuer = issuer
|
|
437
|
+
self.agent_layer = agent_layer
|
|
438
|
+
self.calls = calls
|
|
439
|
+
self.mcp_path = mcp_path
|
|
440
|
+
self.server_name = server_name
|
|
441
|
+
self.server_version = server_version
|
|
442
|
+
self.tools = tools
|
|
443
|
+
self.lazy_auth = lazy_auth
|
|
444
|
+
self.free_tools = free_tools
|
|
445
|
+
self.resource: ProtectedResource | None = None
|
|
446
|
+
self.metadata_paths: set[str] = set()
|
|
447
|
+
if protected_resource is not None:
|
|
448
|
+
self.resource = ProtectedResource(
|
|
449
|
+
resource=canonical_resource(protected_resource.resource),
|
|
450
|
+
authorization_server=protected_resource.authorization_server or issuer,
|
|
451
|
+
documentation=protected_resource.documentation,
|
|
452
|
+
metadata_path=protected_resource.metadata_path,
|
|
453
|
+
)
|
|
454
|
+
self.metadata_paths = {
|
|
455
|
+
_metadata_path(self.resource),
|
|
456
|
+
f"{mcp_path}/.well-known/oauth-protected-resource",
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
def _server(self) -> MCPServer:
|
|
460
|
+
server = MCPServer(name=self.server_name, version=self.server_version)
|
|
461
|
+
if self.free_tools:
|
|
462
|
+
self.free_tools(server)
|
|
463
|
+
return server
|
|
464
|
+
|
|
465
|
+
async def __call__(self, request: Request) -> Response | None:
|
|
466
|
+
path = request.url.path
|
|
467
|
+
resource = self.resource
|
|
468
|
+
if resource and request.method == "GET" and path in self.metadata_paths:
|
|
469
|
+
return JSONResponse(
|
|
470
|
+
protected_resource_metadata(
|
|
471
|
+
resource=resource.resource,
|
|
472
|
+
authorization_server=resource.authorization_server or self.issuer,
|
|
473
|
+
server_name=self.server_name,
|
|
474
|
+
documentation=resource.documentation,
|
|
475
|
+
),
|
|
476
|
+
headers={
|
|
477
|
+
"Cache-Control": "public, max-age=300",
|
|
478
|
+
"Access-Control-Allow-Origin": "*",
|
|
479
|
+
},
|
|
480
|
+
)
|
|
481
|
+
if path != self.mcp_path:
|
|
482
|
+
return None
|
|
483
|
+
body = await request.body()
|
|
484
|
+
token = _agent_bearer(request, self.issuer)
|
|
485
|
+
if token is None:
|
|
486
|
+
return await self._anonymous(resource, request, body) if resource else None
|
|
487
|
+
|
|
488
|
+
identity = await self.agent_layer.identify(token)
|
|
489
|
+
if isinstance(identity, AgentRejected):
|
|
490
|
+
logger.info(
|
|
491
|
+
"call.rejected %s",
|
|
492
|
+
{"reason": f"agent_token_{identity.reason}", "detail": identity.detail},
|
|
493
|
+
)
|
|
494
|
+
# The other environment is not fixed by signing in again here.
|
|
495
|
+
if resource and identity.reason != "environment_mismatch":
|
|
496
|
+
description = (
|
|
497
|
+
"The sign-in has expired: sign in again"
|
|
498
|
+
if identity.reason == "expired"
|
|
499
|
+
else "The agent token was not accepted: sign in again"
|
|
500
|
+
)
|
|
501
|
+
return auth_challenge(resource, description, _json_rpc_id(body))
|
|
502
|
+
return _agent_token_rejected(identity.reason)
|
|
503
|
+
if isinstance(identity, NotAgent):
|
|
504
|
+
raise SolvaPayError(
|
|
505
|
+
"An agent key reached the agent route, but its agent layer has no verify_key"
|
|
506
|
+
if is_agent_key(token)
|
|
507
|
+
else "A token from the agent issuer was not read as one",
|
|
508
|
+
code="agent_route_misconfigured",
|
|
509
|
+
)
|
|
510
|
+
call = AgentRequest(request=request, agent=identity, agent_token=token)
|
|
511
|
+
server = self._server()
|
|
512
|
+
self.tools(
|
|
513
|
+
server,
|
|
514
|
+
lambda tool: register_paid_tool(server, calls=self.calls, call=call, tool=tool),
|
|
515
|
+
call,
|
|
516
|
+
)
|
|
517
|
+
return await _serve(server, request, body)
|
|
518
|
+
|
|
519
|
+
async def _anonymous(
|
|
520
|
+
self, resource: ProtectedResource, request: Request, body: bytes
|
|
521
|
+
) -> Response | None:
|
|
522
|
+
"""No agent token while OAuth is on. A bearer that is not ours is the merchant's own
|
|
523
|
+
auth: its handler answers unless it is a paid call. With no bearer at all the route
|
|
524
|
+
answers itself, from a server that lists every tool and runs only the free ones."""
|
|
525
|
+
paid_names: set[str] = set()
|
|
526
|
+
server = self._server()
|
|
527
|
+
self.tools(server, lambda tool: _list_paid_tool(server, tool, paid_names), None)
|
|
528
|
+
tool_calls = _tool_calls(request.method, body)
|
|
529
|
+
needing = [id_ for id_, name in tool_calls if not self.lazy_auth or name in paid_names]
|
|
530
|
+
if needing:
|
|
531
|
+
# The call that needs sign-in, which in a batch may not be the first.
|
|
532
|
+
return auth_challenge(resource, "Sign in to pay for this tool", needing[0])
|
|
533
|
+
if re.match(r"^Bearer\s", request.headers.get("authorization", ""), re.I):
|
|
534
|
+
return None
|
|
535
|
+
return await _serve(server, request, body)
|
|
536
|
+
|
|
537
|
+
def middleware(self, app: ASGIApp) -> ASGIApp:
|
|
538
|
+
"""An ASGI app that answers agents' requests and passes the rest to ``app``."""
|
|
539
|
+
|
|
540
|
+
async def asgi(scope: Scope, receive: Receive, send: Send) -> None:
|
|
541
|
+
if scope["type"] != "http":
|
|
542
|
+
await app(scope, receive, send)
|
|
543
|
+
return
|
|
544
|
+
request = Request(scope, receive)
|
|
545
|
+
response = await self(request)
|
|
546
|
+
if response is not None:
|
|
547
|
+
await response(scope, receive, send)
|
|
548
|
+
return
|
|
549
|
+
if scope["path"] != self.mcp_path:
|
|
550
|
+
await app(scope, receive, send)
|
|
551
|
+
return
|
|
552
|
+
# The route read the body to look at it; hand the app the same bytes.
|
|
553
|
+
body = await request.body()
|
|
554
|
+
replayed = False
|
|
555
|
+
|
|
556
|
+
async def replay() -> Message:
|
|
557
|
+
nonlocal replayed
|
|
558
|
+
if not replayed:
|
|
559
|
+
replayed = True
|
|
560
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
561
|
+
return await receive()
|
|
562
|
+
|
|
563
|
+
await app(scope, replay, send)
|
|
564
|
+
|
|
565
|
+
return asgi
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def create_agent_mcp_route(
|
|
569
|
+
*,
|
|
570
|
+
issuer: str,
|
|
571
|
+
agent_layer: AgentLayer,
|
|
572
|
+
calls: AgentCalls,
|
|
573
|
+
mcp_path: str,
|
|
574
|
+
server_name: str,
|
|
575
|
+
tools: RegisterTools,
|
|
576
|
+
server_version: str = "0.1.0",
|
|
577
|
+
protected_resource: ProtectedResource | None = None,
|
|
578
|
+
lazy_auth: bool = True,
|
|
579
|
+
free_tools: Callable[[MCPServer], None] | None = None,
|
|
580
|
+
) -> AgentMcpRoute:
|
|
581
|
+
"""A route for the merchant's app: a response for an agent's request, else None.
|
|
582
|
+
|
|
583
|
+
``tools(server, paid, call)`` registers the merchant's tools, with ``paid`` for the
|
|
584
|
+
paid ones. ``call`` is None only when tools are listed for a host that has not
|
|
585
|
+
signed in yet (``protected_resource`` set); a paid call is then answered with the
|
|
586
|
+
sign-in challenge. ``lazy_auth=False`` challenges every ``tools/call`` without a
|
|
587
|
+
token, while ``initialize`` and ``tools/list`` still answer. ``free_tools``
|
|
588
|
+
registers tools anyone may call, signed in or not.
|
|
589
|
+
"""
|
|
590
|
+
return AgentMcpRoute(
|
|
591
|
+
issuer=issuer,
|
|
592
|
+
agent_layer=agent_layer,
|
|
593
|
+
calls=calls,
|
|
594
|
+
mcp_path=mcp_path,
|
|
595
|
+
server_name=server_name,
|
|
596
|
+
tools=tools,
|
|
597
|
+
server_version=server_version,
|
|
598
|
+
protected_resource=protected_resource,
|
|
599
|
+
lazy_auth=lazy_auth,
|
|
600
|
+
free_tools=free_tools,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
__all__ = [
|
|
605
|
+
"AgentMcpRoute",
|
|
606
|
+
"AgentRequest",
|
|
607
|
+
"PaidCall",
|
|
608
|
+
"PaidTool",
|
|
609
|
+
"PaidToolRun",
|
|
610
|
+
"ProtectedResource",
|
|
611
|
+
"auth_challenge",
|
|
612
|
+
"create_agent_mcp_route",
|
|
613
|
+
"register_paid_tool",
|
|
614
|
+
]
|