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/app.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""The SolvaPay MCP App: the checkout, account and top-up widget hosts open in an iframe.
|
|
2
|
+
|
|
3
|
+
Ported from ``@solvapay/mcp-core`` (``descriptors.ts``, ``csp.ts``,
|
|
4
|
+
``hideToolsByAudience.ts``) and ``@solvapay/mcp`` (``buildMcpServer.ts``).
|
|
5
|
+
|
|
6
|
+
The widget is the TypeScript SDK's: ``mcp-app/`` builds
|
|
7
|
+
``@solvapay/react``'s MCP views into one ``mcp-app.html``, which this package
|
|
8
|
+
ships (``scripts/sync_widget.py`` keeps the copy current). It holds nothing
|
|
9
|
+
merchant-specific; the ``account`` tool's bootstrap payload carries the
|
|
10
|
+
merchant, product, plans and return URL at runtime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
18
|
+
from functools import cache
|
|
19
|
+
from importlib import resources
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
|
24
|
+
from mcp.server.mcpserver.resources import TextResource
|
|
25
|
+
from mcp.types import ListToolsResult
|
|
26
|
+
|
|
27
|
+
from ..errors import SolvaPayError
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("solvapay.mcp")
|
|
30
|
+
|
|
31
|
+
Json = dict[str, Any]
|
|
32
|
+
|
|
33
|
+
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
|
|
34
|
+
"""The MIME type every MCP Apps UI resource is served as."""
|
|
35
|
+
|
|
36
|
+
RESOURCE_URI_META_KEY = "ui/resourceUri"
|
|
37
|
+
"""The flat ``_meta`` key hosts older than the 2026-01-26 MCP Apps spec read."""
|
|
38
|
+
|
|
39
|
+
BOOTSTRAP_URI = "solvapay://bootstrap.json"
|
|
40
|
+
BOOTSTRAP_MIME_TYPE = "application/json"
|
|
41
|
+
|
|
42
|
+
WIDGET_FILE = "mcp-app.html"
|
|
43
|
+
|
|
44
|
+
DEFAULT_CSP: dict[str, list[str]] = {
|
|
45
|
+
"resourceDomains": [
|
|
46
|
+
"https://js.stripe.com",
|
|
47
|
+
"https://*.stripe.com",
|
|
48
|
+
"https://b.stripecdn.com",
|
|
49
|
+
# Claude injects Anthropic Sans @font-face CSS from here.
|
|
50
|
+
"https://assets.claude.ai",
|
|
51
|
+
],
|
|
52
|
+
"connectDomains": [
|
|
53
|
+
"https://api.stripe.com",
|
|
54
|
+
"https://m.stripe.com",
|
|
55
|
+
"https://r.stripe.com",
|
|
56
|
+
"https://q.stripe.com",
|
|
57
|
+
"https://errors.stripe.com",
|
|
58
|
+
],
|
|
59
|
+
"frameDomains": ["https://js.stripe.com", "https://hooks.stripe.com"],
|
|
60
|
+
}
|
|
61
|
+
"""``SOLVAPAY_DEFAULT_CSP``: Stripe Elements, its 3-D Secure frame, and host fonts."""
|
|
62
|
+
|
|
63
|
+
CSP_KEYS = ("resourceDomains", "connectDomains", "frameDomains")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _origin(url: str | None) -> str | None:
|
|
67
|
+
if not url:
|
|
68
|
+
return None
|
|
69
|
+
parts = urlsplit(url)
|
|
70
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
71
|
+
return None
|
|
72
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def merge_csp(
|
|
76
|
+
overrides: Mapping[str, Sequence[str]] | None = None, api_base_url: str | None = None
|
|
77
|
+
) -> dict[str, list[str]]:
|
|
78
|
+
"""``mergeCsp``: the Stripe baseline plus ``overrides``, plus the SolvaPay API origin in
|
|
79
|
+
``resourceDomains`` and ``connectDomains`` (merchant branding images, API calls)."""
|
|
80
|
+
unknown = set(overrides or {}) - set(CSP_KEYS)
|
|
81
|
+
if unknown:
|
|
82
|
+
raise SolvaPayError(
|
|
83
|
+
f"Unknown CSP keys: {', '.join(sorted(unknown))}. Use {', '.join(CSP_KEYS)}.",
|
|
84
|
+
code="invalid_argument",
|
|
85
|
+
)
|
|
86
|
+
api_origin = _origin(api_base_url)
|
|
87
|
+
|
|
88
|
+
def merge(key: str, *extras: Sequence[str] | None) -> list[str]:
|
|
89
|
+
merged = list(DEFAULT_CSP[key])
|
|
90
|
+
for extra in extras:
|
|
91
|
+
for domain in extra or ():
|
|
92
|
+
if domain not in merged:
|
|
93
|
+
merged.append(domain)
|
|
94
|
+
return merged
|
|
95
|
+
|
|
96
|
+
api = [api_origin] if api_origin else None
|
|
97
|
+
return {
|
|
98
|
+
"resourceDomains": merge("resourceDomains", (overrides or {}).get("resourceDomains"), api),
|
|
99
|
+
"connectDomains": merge("connectDomains", (overrides or {}).get("connectDomains"), api),
|
|
100
|
+
"frameDomains": merge("frameDomains", (overrides or {}).get("frameDomains")),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cache
|
|
105
|
+
def widget_html() -> str:
|
|
106
|
+
"""The widget this package ships. A missing file is a broken install, never a text-only
|
|
107
|
+
fallback."""
|
|
108
|
+
try:
|
|
109
|
+
return resources.files("solvapay.mcp").joinpath(WIDGET_FILE).read_text(encoding="utf-8")
|
|
110
|
+
except FileNotFoundError as error:
|
|
111
|
+
raise SolvaPayError(
|
|
112
|
+
f"solvapay/mcp/{WIDGET_FILE} is missing from this installation; reinstall solvapay",
|
|
113
|
+
code="widget_missing",
|
|
114
|
+
) from error
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_URI_HOST = re.compile(r"[^a-z0-9-]+")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def default_resource_uri(server_name: str) -> str:
|
|
121
|
+
"""``ui://<server name>/mcp-app.html``, the name made safe for a URI host."""
|
|
122
|
+
host = _URI_HOST.sub("-", server_name.lower()).strip("-")
|
|
123
|
+
if not host:
|
|
124
|
+
raise SolvaPayError(
|
|
125
|
+
"The server name has no letters or digits to build a ui:// URI from; pass resource_uri",
|
|
126
|
+
code="invalid_argument",
|
|
127
|
+
)
|
|
128
|
+
return f"ui://{host}/{WIDGET_FILE}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def tool_ui_meta(resource_uri: str) -> Json:
|
|
132
|
+
"""The ``_meta`` a tool that opens the widget lists with (``registerAppTool``)."""
|
|
133
|
+
return {"ui": {"resourceUri": resource_uri}, RESOURCE_URI_META_KEY: resource_uri}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def transport_tool_meta(resource_uri: str) -> Json:
|
|
137
|
+
"""The ``_meta`` of a tool only the widget calls.
|
|
138
|
+
|
|
139
|
+
``ui.visibility: ['app']`` keeps it out of the model's tool list on MCP Apps
|
|
140
|
+
hosts; ``audience: 'ui'`` is what ``hide_tools_by_audience`` filters on;
|
|
141
|
+
ChatGPT needs ``openai/widgetAccessible`` to let the iframe call it and
|
|
142
|
+
reads ``openai/visibility`` (default public) to hide it from the model.
|
|
143
|
+
"""
|
|
144
|
+
return {
|
|
145
|
+
"ui": {"resourceUri": resource_uri, "visibility": ["app"]},
|
|
146
|
+
"audience": "ui",
|
|
147
|
+
"openai/widgetAccessible": True,
|
|
148
|
+
"openai/visibility": "private",
|
|
149
|
+
RESOURCE_URI_META_KEY: resource_uri,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def app_resource(resource_uri: str, csp: Mapping[str, list[str]], html: str) -> TextResource:
|
|
154
|
+
"""The widget as a ``ui://`` resource. Its ``_meta.ui`` is listed and read alike."""
|
|
155
|
+
if not resource_uri.startswith("ui://"):
|
|
156
|
+
raise SolvaPayError(
|
|
157
|
+
f"resource_uri must use the ui:// scheme, got {resource_uri!r}", code="invalid_argument"
|
|
158
|
+
)
|
|
159
|
+
return TextResource(
|
|
160
|
+
uri=resource_uri,
|
|
161
|
+
name=resource_uri,
|
|
162
|
+
mime_type=RESOURCE_MIME_TYPE,
|
|
163
|
+
# The host owns the outer frame; the widget paints none.
|
|
164
|
+
meta={"ui": {"csp": dict(csp), "prefersBorder": True}},
|
|
165
|
+
text=html,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---- hide_tools_by_audience ---------------------------------------------------
|
|
170
|
+
|
|
171
|
+
BypassWhen = Callable[[ServerRequestContext[Any, Any]], bool]
|
|
172
|
+
|
|
173
|
+
_CHATGPT_CLIENT = re.compile(r"openai-mcp", re.IGNORECASE)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _user_agent(ctx: ServerRequestContext[Any, Any]) -> str | None:
|
|
177
|
+
headers = getattr(ctx.request, "headers", None)
|
|
178
|
+
value = headers.get("user-agent") if headers is not None else None
|
|
179
|
+
return value if isinstance(value, str) else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_chatgpt_request(ctx: ServerRequestContext[Any, Any]) -> bool:
|
|
183
|
+
"""``defaultIsChatGptRequest``: the User-Agent, else the client name from ``initialize``,
|
|
184
|
+
names ChatGPT's MCP client."""
|
|
185
|
+
user_agent = _user_agent(ctx)
|
|
186
|
+
if user_agent and _CHATGPT_CLIENT.search(user_agent):
|
|
187
|
+
return True
|
|
188
|
+
params = getattr(ctx.session, "client_params", None)
|
|
189
|
+
client_info = getattr(params, "client_info", None)
|
|
190
|
+
name = getattr(client_info, "name", None)
|
|
191
|
+
return isinstance(name, str) and _CHATGPT_CLIENT.search(name) is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class HideToolsByAudience:
|
|
195
|
+
"""Server middleware that leaves tools whose ``_meta.audience`` is one of ``audiences``
|
|
196
|
+
out of ``tools/list``. They stay callable, so the widget still reaches them.
|
|
197
|
+
|
|
198
|
+
ChatGPT checks an iframe's ``tools/call`` against the listed tools, so a hidden
|
|
199
|
+
tool would fail there; ``bypass_when`` (ChatGPT by default) gets the full list.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, audiences: Sequence[str], bypass_when: BypassWhen | None = None) -> None:
|
|
203
|
+
self.hidden = frozenset(audiences)
|
|
204
|
+
self.bypass_when = bypass_when or is_chatgpt_request
|
|
205
|
+
self._warned: set[str] = set()
|
|
206
|
+
|
|
207
|
+
async def __call__(
|
|
208
|
+
self, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
|
209
|
+
) -> HandlerResult:
|
|
210
|
+
result = await call_next(ctx)
|
|
211
|
+
if ctx.method != "tools/list" or not self.hidden:
|
|
212
|
+
return result
|
|
213
|
+
if self.bypass_when(ctx):
|
|
214
|
+
user_agent = _user_agent(ctx)
|
|
215
|
+
context = f"ua={user_agent}" if user_agent else "no user-agent"
|
|
216
|
+
if context not in self._warned:
|
|
217
|
+
self._warned.add(context)
|
|
218
|
+
logger.warning(
|
|
219
|
+
"hide_tools_by_audience bypassed (%s); returning the full tools/list", context
|
|
220
|
+
)
|
|
221
|
+
return result
|
|
222
|
+
# The dispatcher hands middleware the wire form (a dict); a direct call may
|
|
223
|
+
# still see the model.
|
|
224
|
+
if isinstance(result, ListToolsResult):
|
|
225
|
+
tools = [
|
|
226
|
+
tool
|
|
227
|
+
for tool in result.tools
|
|
228
|
+
if (tool.meta or {}).get("audience") not in self.hidden
|
|
229
|
+
]
|
|
230
|
+
return result.model_copy(update={"tools": tools})
|
|
231
|
+
if isinstance(result, dict) and isinstance(result.get("tools"), list):
|
|
232
|
+
return {
|
|
233
|
+
**result,
|
|
234
|
+
"tools": [
|
|
235
|
+
tool
|
|
236
|
+
for tool in result["tools"]
|
|
237
|
+
if (tool.get("_meta") or {}).get("audience") not in self.hidden
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
raise SolvaPayError(
|
|
241
|
+
f"tools/list answered with {type(result).__name__}, not a tools list",
|
|
242
|
+
code="unexpected_result",
|
|
243
|
+
)
|