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