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