@solvapay/mcp 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -8,10 +8,10 @@ Framework-neutral contracts (tool names, descriptors, paywall meta,
8
8
  OAuth discovery JSON, JWT helpers) live in
9
9
  [`@solvapay/mcp-core`](../mcp-core) so alternative adapters
10
10
  (`fastmcp`, raw JSON-RPC) can reuse the same contract. Runtime-specific
11
- OAuth middleware lives alongside:
11
+ OAuth middleware ships as two subpath exports of this package:
12
12
 
13
- - [`@solvapay/mcp-express`](../mcp-express) — Node `(req, res, next)`.
14
- - [`@solvapay/mcp-fetch`](../mcp-fetch) — Web standards `(req: Request) => Promise<Response>`.
13
+ - [`@solvapay/mcp/express`](./src/express) — Node `(req, res, next)`.
14
+ - [`@solvapay/mcp/fetch`](./src/fetch) — Web standards `(req: Request) => Promise<Response>` + the turnkey `createSolvaPayMcpFetch` factory for Deno / Supabase Edge / Cloudflare Workers / Bun / Next edge / Vercel Functions.
15
15
 
16
16
  ## Install
17
17
 
@@ -78,10 +78,16 @@ branded envelope produced by `ctx.respond(data, options?)`.
78
78
  ```ts
79
79
  handler: async ({ prompt }, ctx) => {
80
80
  const video = await generate(prompt)
81
- // Attach an upsell nudge when the customer is low on credits.
81
+ // Append a text-only nudge when the customer is low on credits.
82
+ // The nudge message is appended to `content[0].text` as a plain
83
+ // suffix — no widget iframe, no `structuredContent` switch. Point
84
+ // the user at the recovery intent tool by name.
82
85
  if (ctx.customer.balance < 500) {
83
86
  return ctx.respond({ videoUrl: video.url }, {
84
- nudge: { kind: 'low-balance', message: 'Running low on credits' },
87
+ nudge: {
88
+ kind: 'low-balance',
89
+ message: 'Running low on credits — call the `topup` tool to add more.',
90
+ },
85
91
  })
86
92
  }
87
93
  return ctx.respond({ videoUrl: video.url })
@@ -95,9 +101,10 @@ full surface. The TL;DR:
95
101
  `balance` / `remaining` / `plan` to branch on usage; call
96
102
  `ctx.customer.fresh()` for a fresh fetch when staleness matters.
97
103
  - `ctx.respond(data, options?)` — returns a branded envelope. `options`
98
- carries `text` (override `content[0].text`), `nudge` (inline upsell
99
- strip), and the reserved `units` (V1.1 variable-unit billing V1
100
- silently ignores).
104
+ carries `text` (override `content[0].text`), `nudge` (the nudge
105
+ message is appended to `content[0].text` as a plain-text suffix
106
+ no widget surface, no `structuredContent` switch), and the reserved
107
+ `units` (V1.1 variable-unit billing — V1 silently ignores).
101
108
  - `ctx.gate(reason?)` — stops handler execution and routes a paywall
102
109
  response through the adapter's `formatGate` channel. Rare — the
103
110
  SDK normally fires the paywall automatically via `payable().mcp()`
@@ -106,12 +113,31 @@ full surface. The TL;DR:
106
113
  surface. V1 queues (emit) or no-ops (progress / signal); V1.1 wires
107
114
  them to SSE and transport cancellation without code changes.
108
115
 
116
+ ## How paywalls work
117
+
118
+ Paywall responses from `registerPayable` tools are **text-only**:
119
+
120
+ - `isError: false`, so hosts don't short-circuit on the error path.
121
+ - `structuredContent = gate` for programmatic consumers.
122
+ - `content[0].text` carries a state-engine-generated narration that
123
+ names the recovery intent tool (`upgrade` for no active plan,
124
+ `topup` for usage-based zero-balance, `activate_plan` for pending
125
+ activation) and inlines `gate.checkoutUrl` for terminal-first
126
+ hosts (Claude Code, CLI MCP clients).
127
+
128
+ The LLM reads that narration, tells the user, and either (a) the
129
+ user clicks the inline URL and completes checkout in the browser, or
130
+ (b) the LLM calls the named intent tool which mounts the SolvaPay
131
+ widget on `McpCheckoutView` / `McpTopupView`. Only those three
132
+ intent tools advertise `_meta.ui.resourceUri` — merchant payable
133
+ tools don't, so no uninvited iframe opens on a silent success.
134
+
109
135
  ## What's in the box
110
136
 
111
137
  | Export | Use when |
112
138
  |---|---|
113
139
  | `createSolvaPayMcpServer(opts)` | You want the batteries-included `McpServer` with every SolvaPay tool registered |
114
- | `registerPayableTool(server, name, opts)` | You want to add a paywall-protected tool to an existing `McpServer`. `_meta.ui` is attached per-result on paywall and nudge responses only, so the iframe opens only when there's something to show. |
140
+ | `registerPayableTool(server, name, opts)` | You want to add a paywall-protected tool to an existing `McpServer`. Paywall / nudge responses are text-only narrations; the widget iframe stays reserved for the three intent tools. |
115
141
 
116
142
  ## Peer dependencies
117
143
 
@@ -0,0 +1,211 @@
1
+ // src/registerPayableTool.ts
2
+ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
+ import {
4
+ buildPayableHandler
5
+ } from "@solvapay/mcp-core";
6
+ function registerPayableTool(server, name, options) {
7
+ const {
8
+ solvaPay,
9
+ schema,
10
+ product,
11
+ title,
12
+ description,
13
+ handler,
14
+ buildBootstrap,
15
+ getCustomerRef,
16
+ meta,
17
+ annotations,
18
+ icons
19
+ } = options;
20
+ const protectedHandler = buildPayableHandler(
21
+ solvaPay,
22
+ { product, buildBootstrap, getCustomerRef },
23
+ handler
24
+ );
25
+ const baseMeta = meta ?? {};
26
+ const baseUi = baseMeta.ui ?? {};
27
+ const hasIcons = icons !== void 0 && icons.length > 0;
28
+ const mergedUi = {
29
+ ...baseUi,
30
+ ...hasIcons ? { icons } : {}
31
+ };
32
+ const hasUi = Object.keys(mergedUi).length > 0;
33
+ const toolMeta = hasUi ? { ...baseMeta, ui: mergedUi } : { ...baseMeta };
34
+ const effectiveAnnotations = {
35
+ readOnlyHint: true,
36
+ openWorldHint: true,
37
+ ...annotations
38
+ };
39
+ const hasUiResource = hasUi && typeof mergedUi.resourceUri === "string";
40
+ const toolConfig = {
41
+ ...title !== void 0 ? { title } : {},
42
+ ...description !== void 0 ? { description } : {},
43
+ ...schema !== void 0 ? { inputSchema: schema } : {},
44
+ ...Object.keys(toolMeta).length > 0 ? { _meta: toolMeta } : {},
45
+ annotations: effectiveAnnotations,
46
+ ...icons !== void 0 && icons.length > 0 ? { icons } : {}
47
+ };
48
+ const toolCallback = async (args, extra) => await protectedHandler(args, extra);
49
+ if (hasUiResource) {
50
+ return registerAppTool(
51
+ server,
52
+ name,
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ toolConfig,
55
+ toolCallback
56
+ );
57
+ }
58
+ return server.registerTool(
59
+ name,
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ toolConfig,
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ toolCallback
64
+ );
65
+ }
66
+
67
+ // src/internal/buildMcpServer.ts
68
+ import {
69
+ registerAppResource,
70
+ registerAppTool as registerAppTool2,
71
+ RESOURCE_MIME_TYPE
72
+ } from "@modelcontextprotocol/ext-apps/server";
73
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
74
+ import {
75
+ applyHideToolsByAudience,
76
+ buildSolvaPayDescriptors,
77
+ deriveIcons
78
+ } from "@solvapay/mcp-core";
79
+ function registerDescriptor(server, tool) {
80
+ const baseMeta = tool.meta ?? {};
81
+ const baseUi = baseMeta.ui ?? {};
82
+ const metaWithIcons = tool.icons && tool.icons.length > 0 ? { ...baseMeta, ui: { ...baseUi, icons: tool.icons } } : baseMeta;
83
+ registerAppTool2(
84
+ server,
85
+ tool.name,
86
+ {
87
+ ...tool.title !== void 0 ? { title: tool.title } : {},
88
+ description: tool.description,
89
+ inputSchema: tool.inputSchema,
90
+ _meta: metaWithIcons,
91
+ ...tool.annotations !== void 0 ? { annotations: tool.annotations } : {},
92
+ ...tool.icons !== void 0 ? { icons: tool.icons } : {}
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ },
95
+ // `SolvaPayCallToolResult` is a structural subset of the official
96
+ // SDK's `CallToolResult`; cast to erase the extra-narrow `resource`
97
+ // block typing the SDK expects on `{ type: 'resource' }` content.
98
+ async (args, extra) => await tool.handler(
99
+ args,
100
+ extra
101
+ )
102
+ );
103
+ }
104
+ function registerPromptDescriptor(server, prompt) {
105
+ const config = { description: prompt.description };
106
+ if (prompt.title !== void 0) config.title = prompt.title;
107
+ if (prompt.argsSchema !== void 0) config.argsSchema = prompt.argsSchema;
108
+ server.registerPrompt(
109
+ prompt.name,
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ config,
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ async (args) => await prompt.handler(args ?? {})
114
+ );
115
+ }
116
+ function registerDocsResource(server, docs) {
117
+ server.registerResource(
118
+ docs.name,
119
+ docs.uri,
120
+ {
121
+ ...docs.title !== void 0 ? { title: docs.title } : {},
122
+ description: docs.description,
123
+ mimeType: docs.mimeType
124
+ },
125
+ async () => ({
126
+ contents: [
127
+ {
128
+ uri: docs.uri,
129
+ mimeType: docs.mimeType,
130
+ text: await docs.readBody()
131
+ }
132
+ ]
133
+ })
134
+ );
135
+ }
136
+ function buildSolvaPayMcpServer(options) {
137
+ const {
138
+ registerPrompts = true,
139
+ registerDocsResources = true,
140
+ serverName,
141
+ serverVersion = "1.0.0",
142
+ hideToolsByAudience: _hideToolsByAudience,
143
+ ...descriptorOptions
144
+ } = options;
145
+ const descriptors = buildSolvaPayDescriptors(descriptorOptions);
146
+ const effectiveServerName = serverName ?? descriptorOptions.branding?.brandName ?? "solvapay-mcp-server";
147
+ const serverIcons = deriveIcons(descriptorOptions.branding);
148
+ const server = new McpServer({
149
+ name: effectiveServerName,
150
+ version: serverVersion,
151
+ ...serverIcons ? { icons: serverIcons } : {}
152
+ });
153
+ for (const tool of descriptors.tools) {
154
+ registerDescriptor(server, tool);
155
+ }
156
+ if (registerPrompts) {
157
+ for (const prompt of descriptors.prompts) {
158
+ registerPromptDescriptor(server, prompt);
159
+ }
160
+ }
161
+ if (registerDocsResources) {
162
+ for (const docs of descriptors.docsResources) {
163
+ registerDocsResource(server, docs);
164
+ }
165
+ }
166
+ const resource = descriptors.resource;
167
+ registerAppResource(
168
+ server,
169
+ resource.uri,
170
+ resource.uri,
171
+ {
172
+ mimeType: RESOURCE_MIME_TYPE,
173
+ _meta: {
174
+ ui: {
175
+ csp: resource.csp,
176
+ // `false` asks the host to skip painting its own outer card /
177
+ // border around the iframe. The widget paints its own frame
178
+ // via `.solvapay-mcp-card`, and `<AppHeader>` renders the
179
+ // merchant mark at the top; a host-painted card on top of
180
+ // that produced a nested-container look (visible on MCP Jam
181
+ // with the earlier `true` default). Hosts that honour the
182
+ // preference (per the MCP Apps spec) now render us flush
183
+ // inside their conversation surface.
184
+ prefersBorder: false
185
+ }
186
+ }
187
+ },
188
+ async () => ({
189
+ contents: [
190
+ {
191
+ uri: resource.uri,
192
+ mimeType: RESOURCE_MIME_TYPE,
193
+ text: await resource.readHtml(),
194
+ _meta: {
195
+ ui: {
196
+ csp: resource.csp,
197
+ prefersBorder: false
198
+ }
199
+ }
200
+ }
201
+ ]
202
+ })
203
+ );
204
+ return { server, descriptors };
205
+ }
206
+
207
+ export {
208
+ applyHideToolsByAudience,
209
+ buildSolvaPayMcpServer,
210
+ registerPayableTool
211
+ };