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/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
+ ]