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/mcp/tools.py ADDED
@@ -0,0 +1,273 @@
1
+ """``register_payable_tool`` and ``register_free_tool`` for the official MCP Python SDK.
2
+
3
+ ```python
4
+ from mcp.server import MCPServer
5
+ from solvapay import create_solvapay
6
+ from solvapay.mcp import ResponseContext, register_payable_tool
7
+
8
+ server = MCPServer("research")
9
+ solvapay = create_solvapay()
10
+
11
+ async def quote(symbol: str, ctx: ResponseContext):
12
+ return ctx.respond({"symbol": symbol, "price": 101.5})
13
+
14
+ register_payable_tool(server, "quote", solvapay=solvapay, handler=quote, product="prd_…")
15
+ ```
16
+
17
+ The tool's input schema comes from the handler's signature, without its
18
+ ``ResponseContext`` parameter. Each call is checked against the customer's
19
+ plan first: a call that may go ahead runs the handler and records the usage;
20
+ one that may not returns the paywall (``isError: false``, the message as text,
21
+ the gate as ``structuredContent``).
22
+
23
+ The customer comes from the verified access token (see ``solvapay.mcp.auth``),
24
+ or from ``get_customer_ref`` when you pass one. A call with no customer is
25
+ refused: unlike the TypeScript SDK, a tool argument is never trusted as the
26
+ customer, and there is no ``anonymous`` fallback.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import inspect
32
+ import typing
33
+ from collections.abc import Awaitable, Callable, Mapping
34
+ from typing import Any
35
+
36
+ from mcp.server.auth.middleware.auth_context import get_access_token
37
+ from mcp.server.mcpserver import Context, MCPServer
38
+ from mcp.types import CallToolResult, ToolAnnotations
39
+
40
+ from ..errors import SolvaPayError
41
+ from ..paywall.decide import FreeLimit, PaywallError
42
+ from ..solvapay import SolvaPay
43
+ from .app import RESOURCE_URI_META_KEY
44
+ from .response import (
45
+ ResponseContext,
46
+ ResponseResult,
47
+ error_result,
48
+ gate_result,
49
+ tool_error_result,
50
+ unwrap,
51
+ )
52
+
53
+ Json = dict[str, Any]
54
+ Handler = Callable[..., Awaitable[ResponseResult]]
55
+ GetCustomerRef = Callable[[Mapping[str, Any], Context], "str | Awaitable[str | None] | None"]
56
+
57
+ PAID_TOOL_HINT = "Paid tool — call `account` for current balance and cost per call."
58
+ _CONTEXT_PARAM = "solvapay_mcp_context"
59
+
60
+
61
+ def free_tool_description_suffix(limit: FreeLimit, shared_with: list[str] | None = None) -> str:
62
+ window = "lifetime" if limit.scope == "lifetime" else f"{limit.window_days} days"
63
+ base = (
64
+ f"Free tool — {limit.cap} calls per {window}, then a plan is required. "
65
+ "Call `account` for remaining usage."
66
+ )
67
+ if not shared_with:
68
+ return base
69
+ names = ", ".join(f"`{name}`" for name in shared_with)
70
+ return f"{base} Shares a free allowance with {names}."
71
+
72
+
73
+ def _context_param(handler: Callable[..., Any], hints: Mapping[str, Any]) -> str:
74
+ for name, param in inspect.signature(handler).parameters.items():
75
+ if hints.get(name) is ResponseContext or name == "ctx":
76
+ if param.kind not in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY):
77
+ break
78
+ return name
79
+ raise SolvaPayError(
80
+ f"{getattr(handler, '__name__', 'handler')} needs a `ctx: ResponseContext` parameter",
81
+ code="invalid_handler",
82
+ )
83
+
84
+
85
+ async def default_customer_ref(_args: Mapping[str, Any], _context: Context) -> str | None:
86
+ """The customer from the verified access token, if there is one."""
87
+ token = get_access_token()
88
+ if token is None:
89
+ return None
90
+ ref = token.subject or (token.claims or {}).get("customer_ref")
91
+ return ref if isinstance(ref, str) and ref else None
92
+
93
+
94
+ def _register(
95
+ server: MCPServer,
96
+ name: str,
97
+ *,
98
+ solvapay: SolvaPay,
99
+ handler: Handler,
100
+ product: str | None,
101
+ description: str,
102
+ title: str | None,
103
+ annotations: ToolAnnotations | None,
104
+ meta: Json | None,
105
+ get_customer_ref: GetCustomerRef | None,
106
+ free_limit: FreeLimit | None,
107
+ ) -> None:
108
+ if not inspect.iscoroutinefunction(handler):
109
+ raise SolvaPayError(f"The handler for {name} must be async", code="invalid_handler")
110
+ resolved_product = solvapay.require_product(product)
111
+ # include_extras keeps `Annotated[str, Field(description=…)]`, so hosts see the description.
112
+ hints = typing.get_type_hints(handler, include_extras=True)
113
+ ctx_name = _context_param(handler, hints)
114
+ resolve_customer = get_customer_ref or default_customer_ref
115
+
116
+ async def check_limits(body: Json) -> Json:
117
+ response = await solvapay.api.check_limits(body=body)
118
+ return response.model_dump(mode="json", by_alias=True, exclude_unset=True)
119
+
120
+ async def tool(**kwargs: Any) -> CallToolResult:
121
+ context: Context = kwargs.pop(_CONTEXT_PARAM)
122
+ customer = resolve_customer(kwargs, context)
123
+ customer_ref = await customer if inspect.isawaitable(customer) else customer
124
+ if not customer_ref:
125
+ if free_limit is not None:
126
+ # registerFreeTool's answer, before the paywall runs.
127
+ return tool_error_result(
128
+ "Unauthorized", 401, "customer_ref missing from MCP auth context"
129
+ )
130
+ return error_result(
131
+ f"{name} needs a signed-in customer: connect to this server with OAuth first."
132
+ )
133
+ try:
134
+ decision = await solvapay.paywall.decide(
135
+ customer_ref=customer_ref,
136
+ product=resolved_product,
137
+ free_limit=free_limit,
138
+ tool_name=name,
139
+ )
140
+ except SolvaPayError as error:
141
+ return error_result(error.message)
142
+ if decision.gate is not None:
143
+ return gate_result(decision.gate)
144
+
145
+ response_context = ResponseContext(
146
+ customer_ref=decision.customer_ref,
147
+ limits=decision.limits,
148
+ product=resolved_product,
149
+ check_limits=check_limits,
150
+ free_limit=free_limit,
151
+ )
152
+ try:
153
+ result = await solvapay.paywall.run_allow(
154
+ decision,
155
+ lambda: handler(**kwargs, **{ctx_name: response_context}),
156
+ product=resolved_product,
157
+ free_limit=free_limit,
158
+ tool_name=name,
159
+ )
160
+ except PaywallError as gate:
161
+ return gate_result(gate.gate)
162
+ except Exception as error: # the TypeScript adapter's formatError, as a tool error
163
+ return error_result(str(error))
164
+ return unwrap(result)
165
+
166
+ # The schema MCPServer derives from this signature: the handler's own
167
+ # parameters, without the ResponseContext, plus MCP's Context.
168
+ signature = inspect.signature(handler)
169
+ params = [
170
+ p.replace(annotation=hints.get(p.name, p.annotation))
171
+ for p in signature.parameters.values()
172
+ if p.name != ctx_name
173
+ ]
174
+ params.append(
175
+ inspect.Parameter(_CONTEXT_PARAM, inspect.Parameter.KEYWORD_ONLY, annotation=Context)
176
+ )
177
+ tool.__signature__ = signature.replace( # type: ignore[attr-defined]
178
+ parameters=params, return_annotation=CallToolResult
179
+ )
180
+ tool.__annotations__ = {p.name: p.annotation for p in params} | {"return": CallToolResult}
181
+ tool.__name__ = name
182
+ tool.__doc__ = handler.__doc__
183
+
184
+ server.add_tool(
185
+ tool,
186
+ name=name,
187
+ title=title,
188
+ description=description,
189
+ # Read-only and open-world unless the merchant says otherwise.
190
+ annotations=ToolAnnotations.model_validate(
191
+ {
192
+ "readOnlyHint": True,
193
+ "openWorldHint": True,
194
+ **(annotations.model_dump(by_alias=True, exclude_none=True) if annotations else {}),
195
+ }
196
+ ),
197
+ meta=_with_resource_uri_key(meta),
198
+ structured_output=False,
199
+ )
200
+
201
+
202
+ def _with_resource_uri_key(meta: Json | None) -> Json | None:
203
+ """``registerAppTool``: a tool that names a UI resource also carries the flat
204
+ ``ui/resourceUri`` key hosts older than the 2026-01-26 MCP Apps spec read."""
205
+ ui = (meta or {}).get("ui")
206
+ resource_uri = ui.get("resourceUri") if isinstance(ui, dict) else None
207
+ if meta is None or not isinstance(resource_uri, str) or RESOURCE_URI_META_KEY in meta:
208
+ return meta
209
+ return {**meta, RESOURCE_URI_META_KEY: resource_uri}
210
+
211
+
212
+ def _with_hint(description: str | None, hint: str) -> str:
213
+ return f"{description.rstrip()} {hint}" if description else hint
214
+
215
+
216
+ def register_payable_tool(
217
+ server: MCPServer,
218
+ name: str,
219
+ *,
220
+ solvapay: SolvaPay,
221
+ handler: Handler,
222
+ product: str | None = None,
223
+ description: str | None = None,
224
+ title: str | None = None,
225
+ annotations: ToolAnnotations | None = None,
226
+ meta: Json | None = None,
227
+ get_customer_ref: GetCustomerRef | None = None,
228
+ ) -> None:
229
+ """Registers a paid tool: each call is checked against the customer's plan."""
230
+ _register(
231
+ server,
232
+ name,
233
+ solvapay=solvapay,
234
+ handler=handler,
235
+ product=product,
236
+ description=_with_hint(description, PAID_TOOL_HINT),
237
+ title=title,
238
+ annotations=annotations,
239
+ meta=meta,
240
+ get_customer_ref=get_customer_ref,
241
+ free_limit=None,
242
+ )
243
+
244
+
245
+ def register_free_tool(
246
+ server: MCPServer,
247
+ name: str,
248
+ *,
249
+ solvapay: SolvaPay,
250
+ handler: Handler,
251
+ limit: FreeLimit,
252
+ product: str | None = None,
253
+ description: str | None = None,
254
+ title: str | None = None,
255
+ annotations: ToolAnnotations | None = None,
256
+ meta: Json | None = None,
257
+ get_customer_ref: GetCustomerRef | None = None,
258
+ shared_with: list[str] | None = None,
259
+ ) -> None:
260
+ """Registers a free tool: ``limit.cap`` calls per window, then a plan is required."""
261
+ _register(
262
+ server,
263
+ name,
264
+ solvapay=solvapay,
265
+ handler=handler,
266
+ product=product,
267
+ description=_with_hint(description, free_tool_description_suffix(limit, shared_with)),
268
+ title=title,
269
+ annotations=annotations,
270
+ meta=meta,
271
+ get_customer_ref=get_customer_ref,
272
+ free_limit=limit,
273
+ )