@solvapay/mcp-core 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
@@ -3,16 +3,19 @@
3
3
  Framework-neutral MCP (Model Context Protocol) contracts for the SolvaPay SDK.
4
4
 
5
5
  This package owns the shapes that cross the SolvaPay server ↔ client ↔
6
- adapter boundary: tool names, descriptor builder, paywall `_meta.ui`
7
- envelope, Stripe CSP baseline, bootstrap payload, pure OAuth discovery
8
- JSON builders, JWT bearer helpers.
6
+ adapter boundary: tool names, descriptor builder (for SolvaPay intent
7
+ tools, which advertise `_meta.ui.resourceUri` on `tools/list` per
8
+ SEP-1865; merchant payable tools do NOT), the `BootstrapPayload`
9
+ shape carried on `structuredContent`, Stripe CSP baseline, pure OAuth
10
+ discovery JSON builders, and JWT bearer helpers.
9
11
 
10
12
  It does **not** depend on `@modelcontextprotocol/sdk`,
11
13
  `@modelcontextprotocol/ext-apps`, or any runtime-specific HTTP plumbing.
12
14
  The official `@modelcontextprotocol/*` adapter lives in
13
- [`@solvapay/mcp`](../mcp); Node `(req, res, next)` OAuth middleware lives
14
- in [`@solvapay/mcp-express`](../mcp-express); fetch-first OAuth + the
15
- turnkey handler lives in [`@solvapay/mcp-fetch`](../mcp-fetch).
15
+ [`@solvapay/mcp`](../mcp); Node `(req, res, next)` OAuth middleware ships
16
+ as the [`@solvapay/mcp/express`](../mcp/src/express) subpath export;
17
+ fetch-first OAuth + the turnkey handler ships as the
18
+ [`@solvapay/mcp/fetch`](../mcp/src/fetch) subpath export.
16
19
 
17
20
  ## Install
18
21
 
@@ -26,14 +29,30 @@ pnpm add @solvapay/mcp-core @solvapay/server
26
29
  |---|---|
27
30
  | `MCP_TOOL_NAMES`, `McpToolName` | You're implementing a SolvaPay MCP transport tool on any framework and need the canonical tool names |
28
31
  | `buildSolvaPayDescriptors(opts)` | You're writing an MCP adapter (`fastmcp`, raw JSON-RPC) and want the full SolvaPay tool surface as descriptor objects |
29
- | `buildPayableHandler(solvaPay, ctx, handler)` | You're hand-rolling a paywall-protected tool and need the `_meta.ui` envelope auto-attached on paywall results |
30
- | `paywallToolResult(errOrGate, ctx)` | You have a `PaywallError` (legacy `try/catch`) or a `PaywallStructuredContent` gate from `paywall.decide()` and want to return it with the right `_meta.ui` + `BootstrapPayload` |
31
- | `buildPaywallUiMeta({ resourceUri, toolName })` | You're building the `_meta.ui` envelope yourself |
32
+ | `buildPayableHandler(solvaPay, ctx, handler)` | You're hand-rolling a paywall-protected tool and want the pre-check paywall to return a clean text-only narration on `content[0].text` + the gate on `structuredContent` |
33
+ | `paywallToolResult(errOrGate)` | You have a `PaywallError` (legacy `try/catch`) or a `PaywallStructuredContent` gate from `paywall.decide()` and want a text-only tool result |
32
34
  | `SOLVAPAY_DEFAULT_CSP`, `mergeCsp(overrides)` | You're registering the SolvaPay UI resource and want the Stripe allow-list baked in |
33
35
  | `getOAuthAuthorizationServerResponse(opts)`, `getOAuthProtectedResourceResponse(url)` | You're serving the `.well-known/*` discovery JSON from any runtime |
34
36
  | `buildAuthInfoFromBearer(header, opts)` | You're plugging a raw `Authorization: Bearer …` header into an MCP `authInfo` envelope |
35
37
  | `McpBearerAuthError`, `extractBearerToken`, `decodeJwtPayload`, `getCustomerRefFromJwtPayload`, `getCustomerRefFromBearerAuthHeader` | Low-level JWT bearer parsing — no signature verification (validate upstream first) |
36
38
 
39
+ ## How paywalls work
40
+
41
+ Paywall responses from `buildPayableHandler` / `paywallToolResult` are
42
+ **text-only**. `content[0].text` carries a human narration that names
43
+ the recovery intent tool (`upgrade` / `topup` / `activate_plan`) and
44
+ inlines `gate.checkoutUrl` for terminal-first hosts. `isError` is
45
+ always `false` — a gate is a user-actionable signal, not a tool
46
+ failure. `structuredContent = gate` is still emitted for programmatic
47
+ consumers.
48
+
49
+ The widget iframe is reserved for the three deliberate intent tools
50
+ (`upgrade` / `manage_account` / `topup`), which advertise
51
+ `_meta.ui.resourceUri` on their descriptors. Merchant payable tools
52
+ don't, so hosts don't open an uninvited iframe on a successful data
53
+ call or on a paywall — the LLM narrates the recovery and calls the
54
+ intent tool, which mounts the widget.
55
+
37
56
  ## Typical usage
38
57
 
39
58
  If you're starting a new SolvaPay MCP server on the official
@@ -41,9 +60,9 @@ If you're starting a new SolvaPay MCP server on the official
41
60
  [`@solvapay/mcp`](../mcp), which wraps it with one-call ergonomics.
42
61
 
43
62
  If you need HTTP-level OAuth handlers, pair this package with either
44
- [`@solvapay/mcp-express`](../mcp-express) (Node) or
45
- [`@solvapay/mcp-fetch`](../mcp-fetch) (Deno / Supabase Edge / Cloudflare
46
- Workers / Bun / Next edge).
63
+ [`@solvapay/mcp/express`](../mcp/src/express) (Node) or
64
+ [`@solvapay/mcp/fetch`](../mcp/src/fetch) (Deno / Supabase Edge /
65
+ Cloudflare Workers / Bun / Next edge).
47
66
 
48
67
  If you're writing a new adapter (fastmcp, custom JSON-RPC):
49
68
 
@@ -74,7 +93,7 @@ myAdapter.registerResource(resource)
74
93
  ## See also
75
94
 
76
95
  - [`@solvapay/mcp`](../mcp) — official `@modelcontextprotocol/sdk` + `ext-apps` adapter (`createSolvaPayMcpServer`)
77
- - [`@solvapay/mcp-express`](../mcp-express) — Node `(req, res, next)` OAuth middleware stack
78
- - [`@solvapay/mcp-fetch`](../mcp-fetch) — fetch-first OAuth handlers + turnkey `createSolvaPayMcpFetchHandler`
96
+ - [`@solvapay/mcp/express`](../mcp/src/express) — Node `(req, res, next)` OAuth middleware stack
97
+ - [`@solvapay/mcp/fetch`](../mcp/src/fetch) — fetch-first OAuth handlers + turnkey `createSolvaPayMcpFetchHandler` / `createSolvaPayMcpFetch`
79
98
  - [`@solvapay/server`](../server) — core SDK (paywall, webhooks, `*Core` helpers)
80
99
  - [`@solvapay/react/mcp`](../react) — React provider + views for the MCP App UI shell
package/dist/index.cjs CHANGED
@@ -43,16 +43,17 @@ __export(index_exports, {
43
43
  TOOL_FOR_VIEW: () => TOOL_FOR_VIEW,
44
44
  VIEW_FOR_OPEN_TOOL: () => VIEW_FOR_OPEN_TOOL,
45
45
  VIEW_FOR_TOOL: () => VIEW_FOR_TOOL,
46
+ applyHideToolsByAudience: () => applyHideToolsByAudience,
46
47
  balanceSummary: () => balanceSummary,
47
48
  buildAuthInfoFromBearer: () => buildAuthInfoFromBearer,
48
49
  buildPayableHandler: () => buildPayableHandler,
49
- buildPaywallUiMeta: () => buildPaywallUiMeta,
50
50
  buildSolvaPayDescriptors: () => buildSolvaPayDescriptors,
51
51
  buildSolvaPayPrompts: () => buildSolvaPayPrompts,
52
52
  buildSolvaPayRequest: () => buildSolvaPayRequest,
53
53
  createBuildBootstrapPayload: () => createBuildBootstrapPayload,
54
54
  decodeJwtPayload: () => decodeJwtPayload,
55
55
  defaultGetCustomerRef: () => defaultGetCustomerRef,
56
+ deriveIcons: () => deriveIcons,
56
57
  enrichPurchase: () => enrichPurchase,
57
58
  extractBearerToken: () => extractBearerToken,
58
59
  getCustomerRefFromBearerAuthHeader: () => getCustomerRefFromBearerAuthHeader,
@@ -95,9 +96,7 @@ var MCP_TOOL_NAMES = {
95
96
  var SOLVAPAY_MCP_VIEW_KINDS = [
96
97
  "checkout",
97
98
  "account",
98
- "topup",
99
- "paywall",
100
- "nudge"
99
+ "topup"
101
100
  ];
102
101
  var TOOL_FOR_VIEW = {
103
102
  checkout: "upgrade",
@@ -443,41 +442,19 @@ function previewJson(value, max = 400) {
443
442
  }
444
443
  }
445
444
 
446
- // src/paywall-meta.ts
447
- function buildPaywallUiMeta(input) {
448
- return {
449
- ui: {
450
- resourceUri: input.resourceUri
451
- }
452
- };
453
- }
454
-
455
445
  // src/paywallToolResult.ts
456
446
  var import_server = require("@solvapay/server");
457
- async function paywallToolResult(errOrGate, ctx) {
447
+ async function paywallToolResult(errOrGate, _ctx = {}) {
458
448
  const paywallContent = errOrGate instanceof import_server.PaywallError ? errOrGate.structuredContent : errOrGate;
459
449
  const narrationText = errOrGate instanceof import_server.PaywallError ? errOrGate.message : paywallContent.message;
460
- let structuredContent;
461
- if (ctx.buildBootstrap) {
462
- const bootstrap = await ctx.buildBootstrap("paywall", ctx.extra, {
463
- paywall: paywallContent
464
- });
465
- structuredContent = bootstrap;
466
- } else {
467
- structuredContent = paywallContent;
468
- }
469
450
  return {
470
451
  // Deliberately `false`: paywall is a user-actionable gate, not a
471
- // tool failure. Hosts short-circuit on `isError: true` and render
472
- // the error text instead of opening the UI resource advertised by
473
- // `_meta.ui`, which meant the paywall widget never appeared on
474
- // MCPJam / Claude Desktop / ChatGPT Apps. The `structuredContent`
475
- // and `content[0].text` still carry the gate reason so the LLM
476
- // can narrate it, while `_meta.ui` triggers the widget.
452
+ // tool failure. The LLM narrates the recovery from
453
+ // `content[0].text` and the structured gate content on
454
+ // `structuredContent` is available for programmatic consumers.
477
455
  isError: false,
478
456
  content: [{ type: "text", text: narrationText }],
479
- structuredContent,
480
- _meta: buildPaywallUiMeta({ resourceUri: ctx.resourceUri })
457
+ structuredContent: paywallContent
481
458
  };
482
459
  }
483
460
 
@@ -507,6 +484,30 @@ function mergeCsp(overrides) {
507
484
  };
508
485
  }
509
486
 
487
+ // src/hideToolsByAudience.ts
488
+ function applyHideToolsByAudience(server, audiences) {
489
+ if (!audiences || audiences.length === 0) return;
490
+ const hidden = new Set(audiences);
491
+ const inner = server.server;
492
+ if (!inner || typeof inner !== "object" || !(inner._requestHandlers instanceof Map)) {
493
+ return;
494
+ }
495
+ const handlers = inner._requestHandlers;
496
+ const original = handlers.get("tools/list");
497
+ if (!original) return;
498
+ handlers.set("tools/list", async (req, extra) => {
499
+ const res = await original(req, extra);
500
+ const tools = Array.isArray(res?.tools) ? res.tools : [];
501
+ return {
502
+ ...res,
503
+ tools: tools.filter((t) => {
504
+ const audience = t?._meta?.audience;
505
+ return !hidden.has(typeof audience === "string" ? audience : "");
506
+ })
507
+ };
508
+ });
509
+ }
510
+
510
511
  // src/descriptors.ts
511
512
  var import_server3 = require("@solvapay/server");
512
513
  var import_zod = require("zod");
@@ -531,7 +532,7 @@ function createBuildBootstrapPayload(options) {
531
532
  };
532
533
  const buildRequest = (extra) => buildSolvaPayRequest(extra, { getCustomerRef });
533
534
  const productQueryRequest = () => buildSolvaPayRequest(void 0, { query: { productRef }, getCustomerRef: () => null });
534
- return async (view, extra, extras = {}) => {
535
+ return async (view, extra) => {
535
536
  const customerRef = getCustomerRef(extra);
536
537
  const wrapError = (promise) => promise.catch((err) => ({
537
538
  error: err instanceof Error ? err.message : String(err),
@@ -578,7 +579,7 @@ function createBuildBootstrapPayload(options) {
578
579
  balance: okOrNull(balanceResult),
579
580
  usage: okOrNull(usageResult)
580
581
  } : null;
581
- const payload = {
582
+ return {
582
583
  view,
583
584
  productRef,
584
585
  stripePublishableKey,
@@ -588,8 +589,6 @@ function createBuildBootstrapPayload(options) {
588
589
  plans,
589
590
  customer
590
591
  };
591
- if (extras.paywall) payload.paywall = extras.paywall;
592
- return payload;
593
592
  };
594
593
  }
595
594
 
@@ -623,9 +622,13 @@ with clickable URLs for text-only hosts.
623
622
 
624
623
  Each intent tool returns a full \`BootstrapPayload\` (merchant + product + plans
625
624
  + customer snapshot) so the embedded UI never fires per-view read calls. When
626
- a paywalled data tool hits the usage limit, its response carries the same
627
- payload with \`view: "paywall"\`, letting the host open the UI directly on the
628
- paywall screen \u2014 no second tool call required.
625
+ a paywalled data tool hits the usage limit, its response is plain text: the
626
+ \`content[0].text\` narration names the recovery intent tool
627
+ (\`upgrade\` / \`topup\` / \`activate_plan\`) and inlines \`gate.checkoutUrl\` for
628
+ terminal-first hosts. The structured gate rides on \`structuredContent\` for
629
+ programmatic consumers. No widget iframe opens for a gate \u2014 the LLM reads the
630
+ narration and, if the user agrees, calls the named intent tool which mounts
631
+ the checkout / topup / account surface.
629
632
 
630
633
  Auth is handled by the SolvaPay OAuth bridge (see \`createMcpOAuthBridge\`). The
631
634
  bridge injects \`customer_ref\` onto every authenticated request; tools that
@@ -1138,7 +1141,7 @@ function buildResponseContext(params) {
1138
1141
 
1139
1142
  // src/payable-handler.ts
1140
1143
  function buildPayableHandler(solvaPay, ctx, handler) {
1141
- const { product, resourceUri, buildBootstrap, getCustomerRef } = ctx;
1144
+ const { product, getCustomerRef } = ctx;
1142
1145
  const wrappedBusinessLogic = async (args, handlerContext) => {
1143
1146
  const limits = handlerContext?.limits ?? null;
1144
1147
  const customerRef = handlerContext?.customerRef ?? "";
@@ -1154,60 +1157,44 @@ function buildPayableHandler(solvaPay, ctx, handler) {
1154
1157
  return async (args, extra) => {
1155
1158
  const result = await protectedHandler(args, extra);
1156
1159
  if ((0, import_server5.isPaywallStructuredContent)(result.structuredContent)) {
1157
- const existingMeta = typeof result._meta === "object" && result._meta !== null ? result._meta : {};
1158
- const gateContent = result.structuredContent;
1159
- const structuredContent = buildBootstrap ? await buildBootstrap("paywall", extra, {
1160
- paywall: gateContent
1161
- }) : result.structuredContent;
1162
1160
  return {
1163
1161
  ...result,
1164
1162
  isError: false,
1165
- structuredContent,
1166
- _meta: {
1167
- ...existingMeta,
1168
- ...buildPaywallUiMeta({ resourceUri })
1169
- }
1163
+ structuredContent: result.structuredContent
1170
1164
  };
1171
1165
  }
1172
1166
  if (result.isError) {
1173
1167
  return result;
1174
1168
  }
1175
1169
  const envelope = assertResponseResult(result.structuredContent);
1176
- return unwrapResponseEnvelope(
1177
- result,
1178
- envelope,
1179
- resourceUri,
1180
- buildBootstrap,
1181
- extra
1182
- );
1170
+ return unwrapResponseEnvelope(result, envelope, extra);
1183
1171
  };
1184
1172
  }
1185
- async function unwrapResponseEnvelope(adapterResult, envelope, resourceUri, buildBootstrap, extra) {
1173
+ async function unwrapResponseEnvelope(adapterResult, envelope, _extra) {
1186
1174
  const { data, options, emittedBlocks } = envelope;
1187
1175
  const textOverride = options?.text;
1188
1176
  const nudge = options?.nudge;
1189
- const primaryText = typeof textOverride === "string" ? textOverride : JSON.stringify(data);
1177
+ const baseText = typeof textOverride === "string" ? textOverride : JSON.stringify(data);
1178
+ let primaryText = baseText;
1179
+ if (nudge) {
1180
+ const nudgeText = nudge.message && nudge.message.length > 0 ? nudge.message : (0, import_server5.buildNudgeMessage)(
1181
+ // `buildNudgeMessage` only reads the state kind to pick
1182
+ // copy; for merchant-supplied nudges we don't have a
1183
+ // `LimitResponseWithPlan` in hand here, so we defer to
1184
+ // the nudge's own kind → state mapping. `low-balance` →
1185
+ // topup, everything else → upgrade.
1186
+ nudge.kind === "low-balance" ? { kind: "topup_required" } : { kind: "upgrade_required" },
1187
+ null
1188
+ );
1189
+ primaryText = baseText.length > 0 ? `${baseText}
1190
+
1191
+ ${nudgeText}` : nudgeText;
1192
+ }
1190
1193
  const content = [
1191
1194
  ...emittedBlocks ?? [],
1192
1195
  { type: "text", text: primaryText }
1193
1196
  ];
1194
1197
  const existingMeta = typeof adapterResult._meta === "object" && adapterResult._meta !== null ? adapterResult._meta : {};
1195
- if (nudge) {
1196
- const structuredContent = buildBootstrap ? {
1197
- ...await buildBootstrap("nudge", extra),
1198
- nudge,
1199
- data
1200
- } : data;
1201
- return {
1202
- ...adapterResult,
1203
- content,
1204
- structuredContent,
1205
- _meta: {
1206
- ...existingMeta,
1207
- ...buildNudgeUiMeta({ resourceUri, nudge })
1208
- }
1209
- };
1210
- }
1211
1198
  return {
1212
1199
  ...adapterResult,
1213
1200
  content,
@@ -1215,9 +1202,6 @@ async function unwrapResponseEnvelope(adapterResult, envelope, resourceUri, buil
1215
1202
  ...Object.keys(existingMeta).length > 0 ? { _meta: existingMeta } : {}
1216
1203
  };
1217
1204
  }
1218
- function buildNudgeUiMeta(input) {
1219
- return { ui: { resourceUri: input.resourceUri, nudge: input.nudge } };
1220
- }
1221
1205
 
1222
1206
  // src/oauth-discovery.ts
1223
1207
  var DEFAULT_OAUTH_PATHS = {
@@ -1364,16 +1348,17 @@ function buildAuthInfoFromBearer(authorization, options = {}) {
1364
1348
  TOOL_FOR_VIEW,
1365
1349
  VIEW_FOR_OPEN_TOOL,
1366
1350
  VIEW_FOR_TOOL,
1351
+ applyHideToolsByAudience,
1367
1352
  balanceSummary,
1368
1353
  buildAuthInfoFromBearer,
1369
1354
  buildPayableHandler,
1370
- buildPaywallUiMeta,
1371
1355
  buildSolvaPayDescriptors,
1372
1356
  buildSolvaPayPrompts,
1373
1357
  buildSolvaPayRequest,
1374
1358
  createBuildBootstrapPayload,
1375
1359
  decodeJwtPayload,
1376
1360
  defaultGetCustomerRef,
1361
+ deriveIcons,
1377
1362
  enrichPurchase,
1378
1363
  extractBearerToken,
1379
1364
  getCustomerRefFromBearerAuthHeader,