@frihet/mcp-server 2.0.1 → 2.0.2

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.
@@ -0,0 +1,612 @@
1
+ /**
2
+ * Machine Payments Protocol (MPP) Sell-Side Sandbox Proof (#269 / Frihet-ERP #3127).
3
+ *
4
+ * Implements M0–M4 sandbox eligibility, HTTP 402 challenge/credential/receipt
5
+ * state machine (`challenged` -> `pending` -> `verified` -> `delivered` |
6
+ * `failed_delivery_recovery`), and MCP JSON-RPC transport binding (`-32042` /
7
+ * `-32043`, `_meta['org.paymentauth/credential']`, `_meta['org.paymentauth/receipt']`).
8
+ *
9
+ * Strictly gated behind `FRIHET_MPP_EXPERIMENTAL=true` and `profile_test_*`
10
+ * test profiles. Frozen public connector profiles (`ChatGPT`, `Claude`,
11
+ * `Google`, `Muse`) are strictly isolated and never modified.
12
+ */
13
+ import { z } from "zod/v4";
14
+ import { READ_ONLY_ANNOTATIONS } from "./tools/shared.js";
15
+ export const MPP_PROTOCOL_VERSION = "2026-04-sandbox";
16
+ export const MPP_MCP_PAYMENT_REQUIRED_CODE = -32042;
17
+ export const MPP_MCP_VERIFICATION_FAILED_CODE = -32043;
18
+ export const MPP_META_CREDENTIAL_KEY = "org.paymentauth/credential";
19
+ export const MPP_META_RECEIPT_KEY = "org.paymentauth/receipt";
20
+ export const MPP_SPT_SUPPORTED_REGIONS = ["US", "CA", "ES", "SE"];
21
+ export const MPP_SPT_MIN_AMOUNT_MINOR_USD = 50; // $0.50 USD
22
+ export const MPP_USDC_MIN_AMOUNT_MINOR = 1; // $0.01 USDC
23
+ /**
24
+ * Deterministic, runtime-neutral FNV-1a 64-bit hex fingerprint for sandbox
25
+ * payment artifacts so raw secrets, SPT tokens, or wallet signatures are never
26
+ * stored or echoed in receipts or logs.
27
+ */
28
+ export function fingerprintMppArtifact(kind, rawValue) {
29
+ let h1 = 0xdeadbeef ^ kind.length;
30
+ let h2 = 0x41c6ce57 ^ rawValue.length;
31
+ const input = `${kind}:${rawValue}`;
32
+ for (let i = 0; i < input.length; i++) {
33
+ const ch = input.charCodeAt(i);
34
+ h1 = Math.imul(h1 ^ ch, 2654435761);
35
+ h2 = Math.imul(h2 ^ ch, 1597334677);
36
+ }
37
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
38
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
39
+ const hex1 = (h1 >>> 0).toString(16).padStart(8, "0");
40
+ const hex2 = (h2 >>> 0).toString(16).padStart(8, "0");
41
+ return `fp_${kind}_${hex1}${hex2}`;
42
+ }
43
+ /**
44
+ * M0 — Evaluates whether an operation is eligible for the MPP sell-side sandbox.
45
+ */
46
+ export function evaluateMppSandboxEligibility(input) {
47
+ const env = input.env ?? {};
48
+ const normalizedCurrency = input.currency.trim().toUpperCase();
49
+ const normalizedRegion = input.region.trim().toUpperCase();
50
+ if (env.FRIHET_MPP_EXPERIMENTAL !== "true") {
51
+ return {
52
+ eligible: false,
53
+ reasonCode: "MPP_EXPERIMENTAL_DISABLED",
54
+ reason: "FRIHET_MPP_EXPERIMENTAL=true is required to enable the MPP sell-side sandbox.",
55
+ minimumAmountMinor: 0,
56
+ normalizedCurrency,
57
+ };
58
+ }
59
+ const modes = input.activeConnectorModes;
60
+ if (modes?.openaiMode || modes?.claudeMode || modes?.googleMode || modes?.museMode) {
61
+ return {
62
+ eligible: false,
63
+ reasonCode: "MPP_FROZEN_PROFILE_FORBIDDEN",
64
+ reason: "MPP experimental bindings are strictly excluded from frozen public connector profiles (ChatGPT, Claude, Google, Muse).",
65
+ minimumAmountMinor: 0,
66
+ normalizedCurrency,
67
+ };
68
+ }
69
+ if (!input.profileId.startsWith("profile_test_")) {
70
+ return {
71
+ eligible: false,
72
+ reasonCode: "MPP_TEST_PROFILE_REQUIRED",
73
+ reason: "MPP sandbox requires a test profile with the 'profile_test_' prefix.",
74
+ minimumAmountMinor: 0,
75
+ normalizedCurrency,
76
+ };
77
+ }
78
+ if (input.effect !== "read") {
79
+ return {
80
+ eligible: false,
81
+ reasonCode: "MPP_MUTATING_RESOURCE_UNSUPPORTED",
82
+ reason: "Only read-only, non-fiscal resources may participate in the MPP sell-side sandbox.",
83
+ minimumAmountMinor: 0,
84
+ normalizedCurrency,
85
+ };
86
+ }
87
+ if (input.paymentMethod === "stripe_spt") {
88
+ if (!MPP_SPT_SUPPORTED_REGIONS.includes(normalizedRegion)) {
89
+ return {
90
+ eligible: false,
91
+ reasonCode: "MPP_SPT_UNSUPPORTED_REGION",
92
+ reason: `Stripe Shared Payment Token (SPT) is only supported in ${MPP_SPT_SUPPORTED_REGIONS.join(", ")} (received '${normalizedRegion}').`,
93
+ minimumAmountMinor: MPP_SPT_MIN_AMOUNT_MINOR_USD,
94
+ normalizedCurrency,
95
+ };
96
+ }
97
+ if (normalizedCurrency !== "USD") {
98
+ return {
99
+ eligible: false,
100
+ reasonCode: "MPP_SPT_CURRENCY_MISMATCH",
101
+ reason: `Stripe SPT sandbox requires USD currency code (received '${normalizedCurrency}').`,
102
+ minimumAmountMinor: MPP_SPT_MIN_AMOUNT_MINOR_USD,
103
+ normalizedCurrency,
104
+ };
105
+ }
106
+ if (!Number.isInteger(input.amountMinor) || input.amountMinor < MPP_SPT_MIN_AMOUNT_MINOR_USD) {
107
+ return {
108
+ eligible: false,
109
+ reasonCode: "MPP_SPT_BELOW_MINIMUM",
110
+ reason: `Stripe SPT requires at least ${MPP_SPT_MIN_AMOUNT_MINOR_USD} minor units ($0.50 USD).`,
111
+ minimumAmountMinor: MPP_SPT_MIN_AMOUNT_MINOR_USD,
112
+ normalizedCurrency,
113
+ };
114
+ }
115
+ return {
116
+ eligible: true,
117
+ reasonCode: "ELIGIBLE",
118
+ reason: "Eligible for Stripe SPT sell-side sandbox.",
119
+ minimumAmountMinor: MPP_SPT_MIN_AMOUNT_MINOR_USD,
120
+ normalizedCurrency,
121
+ };
122
+ }
123
+ // tempo_usdc
124
+ if (input.stablecoinApprovalGranted !== true) {
125
+ return {
126
+ eligible: false,
127
+ reasonCode: "MPP_STABLECOIN_APPROVAL_REQUIRED",
128
+ reason: "Stablecoin (USDC on Tempo) requires explicit Stripe stablecoin approval gate (stablecoinApprovalGranted=true).",
129
+ minimumAmountMinor: MPP_USDC_MIN_AMOUNT_MINOR,
130
+ normalizedCurrency,
131
+ };
132
+ }
133
+ if (normalizedCurrency !== "USDC" && normalizedCurrency !== "USD") {
134
+ return {
135
+ eligible: false,
136
+ reasonCode: "MPP_STABLECOIN_CURRENCY_MISMATCH",
137
+ reason: `Tempo stablecoin sandbox requires USDC/USD currency code (received '${normalizedCurrency}').`,
138
+ minimumAmountMinor: MPP_USDC_MIN_AMOUNT_MINOR,
139
+ normalizedCurrency,
140
+ };
141
+ }
142
+ if (!Number.isInteger(input.amountMinor) || input.amountMinor < MPP_USDC_MIN_AMOUNT_MINOR) {
143
+ return {
144
+ eligible: false,
145
+ reasonCode: "MPP_STABLECOIN_BELOW_MINIMUM",
146
+ reason: `Tempo USDC requires at least ${MPP_USDC_MIN_AMOUNT_MINOR} minor unit ($0.01).`,
147
+ minimumAmountMinor: MPP_USDC_MIN_AMOUNT_MINOR,
148
+ normalizedCurrency,
149
+ };
150
+ }
151
+ return {
152
+ eligible: true,
153
+ reasonCode: "ELIGIBLE",
154
+ reason: "Eligible for Tempo USDC sell-side sandbox.",
155
+ minimumAmountMinor: MPP_USDC_MIN_AMOUNT_MINOR,
156
+ normalizedCurrency,
157
+ };
158
+ }
159
+ /**
160
+ * M1 / M2 / M3 — Stateful sell-side sandbox coordinator enforcing:
161
+ * 1. RBAC 401 -> 403 pre-condition before any 402 Payment Required challenge.
162
+ * 2. Challenge binding (`WWW-Authenticate: Payment` + `Cache-Control: no-store`).
163
+ * 3. Coexistence of `Authorization: Bearer` and `Payment-Authorization: Payment`.
164
+ * 4. Anti-substitution & anti-replay guards across workspace, resource, digest, and amount.
165
+ * 5. State machine: `challenged` -> `pending` -> `verified` -> `delivered` | `failed_delivery_recovery`.
166
+ * 6. Sanitized `Payment-Receipt` (`Cache-Control: private`) and MCP `_meta['org.paymentauth/receipt']`.
167
+ */
168
+ export class MppSandboxCoordinator {
169
+ attemptsByChallengeId = new Map();
170
+ challengeIdByCredentialId = new Map();
171
+ env;
172
+ activeConnectorModes;
173
+ constructor(options = {}) {
174
+ this.env = options.env ?? {};
175
+ this.activeConnectorModes = options.activeConnectorModes;
176
+ }
177
+ getAttempt(challengeId) {
178
+ return this.attemptsByChallengeId.get(challengeId);
179
+ }
180
+ async handleRequest(params) {
181
+ const nowIso = params.nowIso ?? "2026-11-02T00:00:00.000Z";
182
+ const { principal, targetResource, credential } = params;
183
+ const resource = targetResource;
184
+ // 1. Strict 401 authentication pre-condition (before any 402 challenge)
185
+ if (!principal.bearerTokenPresent ||
186
+ !principal.authenticatedSubjectId?.trim() ||
187
+ !principal.workspaceUid?.trim()) {
188
+ return {
189
+ status: 401,
190
+ headers: {
191
+ "WWW-Authenticate": 'Bearer realm="frihet-api", error="invalid_token"',
192
+ "Cache-Control": "no-store",
193
+ },
194
+ body: {
195
+ error: "AUTHENTICATION_REQUIRED",
196
+ message: "Valid Bearer authentication is required before evaluating MPP resource access.",
197
+ },
198
+ };
199
+ }
200
+ // 2. Strict 403 tenant/RBAC authorization pre-condition (before any 402 challenge)
201
+ if (principal.workspaceUid !== resource.workspaceUid ||
202
+ !principal.hasReadPermission) {
203
+ return {
204
+ status: 403,
205
+ headers: {
206
+ "Cache-Control": "no-store",
207
+ },
208
+ body: {
209
+ error: "ACCESS_DENIED",
210
+ message: "Caller does not have read permission for the target workspace resource.",
211
+ },
212
+ };
213
+ }
214
+ // 3. M0 Sandbox Eligibility check
215
+ const eligibility = evaluateMppSandboxEligibility({
216
+ env: this.env,
217
+ profileId: resource.profileId,
218
+ region: resource.region,
219
+ paymentMethod: resource.paymentMethod,
220
+ amountMinor: resource.amountMinor,
221
+ currency: resource.currency,
222
+ effect: resource.effect,
223
+ stablecoinApprovalGranted: resource.stablecoinApprovalGranted,
224
+ activeConnectorModes: this.activeConnectorModes,
225
+ });
226
+ if (!eligibility.eligible) {
227
+ return {
228
+ status: 403,
229
+ headers: {
230
+ "Cache-Control": "no-store",
231
+ },
232
+ body: {
233
+ error: eligibility.reasonCode,
234
+ message: eligibility.reason,
235
+ },
236
+ };
237
+ }
238
+ // 4. Issue HTTP 402 / MCP -32042 Challenge when no payment credential is provided
239
+ if (!credential) {
240
+ const challengeMaterial = `${resource.workspaceUid}:${resource.resourceId}:${resource.requestDigest}:${resource.amountMinor}:${eligibility.normalizedCurrency}:${resource.paymentMethod}:${nowIso}`;
241
+ const challengeFingerprint = fingerprintMppArtifact("challenge", challengeMaterial);
242
+ const challengeId = `chk_${challengeFingerprint.slice(13, 29)}`;
243
+ const expiresAt = new Date(Date.parse(nowIso) + 5 * 60 * 1000).toISOString();
244
+ const challenge = {
245
+ challengeId,
246
+ protocolVersion: MPP_PROTOCOL_VERSION,
247
+ workspaceUid: resource.workspaceUid,
248
+ resourceId: resource.resourceId,
249
+ realm: resource.realm,
250
+ requestDigest: resource.requestDigest,
251
+ paymentMethod: resource.paymentMethod,
252
+ amountMinor: resource.amountMinor,
253
+ currency: eligibility.normalizedCurrency,
254
+ issuedAt: nowIso,
255
+ expiresAt,
256
+ challengeFingerprint,
257
+ };
258
+ this.attemptsByChallengeId.set(challengeId, {
259
+ challenge,
260
+ state: "challenged",
261
+ recoveryDisposition: "none",
262
+ });
263
+ const wwwAuth = `Payment realm="${resource.realm}", ` +
264
+ `challenge_id="${challengeId}", ` +
265
+ `method="${resource.paymentMethod}", ` +
266
+ `amount_minor="${resource.amountMinor}", ` +
267
+ `currency="${eligibility.normalizedCurrency}", ` +
268
+ `expires_at="${expiresAt}"`;
269
+ return {
270
+ status: 402,
271
+ headers: {
272
+ "WWW-Authenticate": wwwAuth,
273
+ "Cache-Control": "no-store",
274
+ },
275
+ body: {
276
+ error: "PAYMENT_REQUIRED",
277
+ challenge: {
278
+ challengeId: challenge.challengeId,
279
+ protocolVersion: challenge.protocolVersion,
280
+ realm: challenge.realm,
281
+ resourceId: challenge.resourceId,
282
+ requestDigest: challenge.requestDigest,
283
+ paymentMethod: challenge.paymentMethod,
284
+ amountMinor: challenge.amountMinor,
285
+ currency: challenge.currency,
286
+ expiresAt: challenge.expiresAt,
287
+ },
288
+ },
289
+ mcpErrorCode: MPP_MCP_PAYMENT_REQUIRED_CODE,
290
+ mcpMeta: {
291
+ [MPP_META_CREDENTIAL_KEY]: {
292
+ challengeId: challenge.challengeId,
293
+ realm: challenge.realm,
294
+ paymentMethod: challenge.paymentMethod,
295
+ amountMinor: challenge.amountMinor,
296
+ currency: challenge.currency,
297
+ expiresAt: challenge.expiresAt,
298
+ },
299
+ },
300
+ };
301
+ }
302
+ // 5. Validate Credential against bound Challenge
303
+ const attempt = this.attemptsByChallengeId.get(credential.challengeId);
304
+ if (!attempt) {
305
+ return {
306
+ status: 402,
307
+ headers: {
308
+ "WWW-Authenticate": `Payment realm="${resource.realm}", error="challenge_not_found"`,
309
+ "Cache-Control": "no-store",
310
+ },
311
+ body: {
312
+ error: "CHALLENGE_NOT_FOUND",
313
+ message: "Payment credential references an unknown challengeId.",
314
+ },
315
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
316
+ };
317
+ }
318
+ if (Date.parse(nowIso) > Date.parse(attempt.challenge.expiresAt)) {
319
+ return {
320
+ status: 402,
321
+ headers: {
322
+ "WWW-Authenticate": `Payment realm="${resource.realm}", error="challenge_expired"`,
323
+ "Cache-Control": "no-store",
324
+ },
325
+ body: {
326
+ error: "CHALLENGE_EXPIRED",
327
+ message: "Payment challenge has expired.",
328
+ },
329
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
330
+ };
331
+ }
332
+ // Anti-substitution check: credential & request must match bound challenge
333
+ const bound = attempt.challenge;
334
+ if (credential.workspaceUid !== bound.workspaceUid ||
335
+ credential.resourceId !== bound.resourceId ||
336
+ credential.requestDigest !== bound.requestDigest ||
337
+ credential.amountMinor !== bound.amountMinor ||
338
+ credential.currency.trim().toUpperCase() !== bound.currency ||
339
+ credential.paymentMethod !== bound.paymentMethod ||
340
+ resource.workspaceUid !== bound.workspaceUid ||
341
+ resource.resourceId !== bound.resourceId ||
342
+ resource.requestDigest !== bound.requestDigest ||
343
+ resource.amountMinor !== bound.amountMinor ||
344
+ eligibility.normalizedCurrency !== bound.currency) {
345
+ return {
346
+ status: 402,
347
+ headers: {
348
+ "WWW-Authenticate": `Payment realm="${resource.realm}", error="challenge_substitution_rejected"`,
349
+ "Cache-Control": "no-store",
350
+ },
351
+ body: {
352
+ error: "CHALLENGE_SUBSTITUTION_REJECTED",
353
+ message: "Payment credential does not match the bound challenge workspace, resource, digest, currency, or amount.",
354
+ },
355
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
356
+ };
357
+ }
358
+ // Anti-replay check across distinct challenges
359
+ const existingChallengeForCredential = this.challengeIdByCredentialId.get(credential.credentialId);
360
+ if (existingChallengeForCredential !== undefined &&
361
+ existingChallengeForCredential !== credential.challengeId) {
362
+ return {
363
+ status: 402,
364
+ headers: {
365
+ "WWW-Authenticate": `Payment realm="${resource.realm}", error="credential_replayed"`,
366
+ "Cache-Control": "no-store",
367
+ },
368
+ body: {
369
+ error: "CREDENTIAL_REPLAY_REJECTED",
370
+ message: "Payment credentialId was already bound to a different challengeId.",
371
+ },
372
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
373
+ };
374
+ }
375
+ // Idempotent replay of an already-delivered attempt with the same credentialId
376
+ if (attempt.state === "delivered" &&
377
+ attempt.credentialId === credential.credentialId &&
378
+ attempt.receipt &&
379
+ attempt.cachedDeliveryPayload) {
380
+ return {
381
+ status: 200,
382
+ headers: {
383
+ "Payment-Receipt": attempt.receipt.receiptId,
384
+ "Cache-Control": "private",
385
+ },
386
+ body: {
387
+ data: attempt.cachedDeliveryPayload,
388
+ receipt: attempt.receipt,
389
+ replayedIdempotent: true,
390
+ },
391
+ mcpMeta: {
392
+ [MPP_META_RECEIPT_KEY]: attempt.receipt,
393
+ },
394
+ };
395
+ }
396
+ if (attempt.credentialId !== undefined &&
397
+ attempt.credentialId !== credential.credentialId) {
398
+ return {
399
+ status: 402,
400
+ headers: {
401
+ "WWW-Authenticate": `Payment realm="${resource.realm}", error="credential_mismatch"`,
402
+ "Cache-Control": "no-store",
403
+ },
404
+ body: {
405
+ error: "CREDENTIAL_MISMATCH",
406
+ message: "Challenge is already bound to a different credentialId.",
407
+ },
408
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
409
+ };
410
+ }
411
+ // Bind credential fingerprints (never persisting raw token/secret)
412
+ const credentialFingerprint = fingerprintMppArtifact("credential", `${credential.credentialId}:${credential.rawCredentialToken}`);
413
+ const settlementFingerprint = fingerprintMppArtifact("settlement", credential.settlementReference);
414
+ this.challengeIdByCredentialId.set(credential.credentialId, credential.challengeId);
415
+ attempt.credentialId = credential.credentialId;
416
+ attempt.credentialFingerprint = credentialFingerprint;
417
+ attempt.settlementFingerprint = settlementFingerprint;
418
+ // 6. Settlement finality check
419
+ if (credential.reportedFinality === "pending") {
420
+ attempt.state = "pending";
421
+ return {
422
+ status: 402,
423
+ headers: {
424
+ "WWW-Authenticate": `Payment realm="${resource.realm}", challenge_id="${bound.challengeId}", error="settlement_pending"`,
425
+ "Cache-Control": "no-store",
426
+ },
427
+ body: {
428
+ error: "PAYMENT_SETTLEMENT_PENDING",
429
+ state: "pending",
430
+ message: "Payment authorization observed, but settlement is still pending. Resource delivery is withheld until settled.",
431
+ },
432
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
433
+ };
434
+ }
435
+ if (credential.reportedFinality === "declined") {
436
+ attempt.state = "challenged";
437
+ return {
438
+ status: 402,
439
+ headers: {
440
+ "WWW-Authenticate": `Payment realm="${resource.realm}", challenge_id="${bound.challengeId}", error="payment_declined"`,
441
+ "Cache-Control": "no-store",
442
+ },
443
+ body: {
444
+ error: "PAYMENT_VERIFICATION_FAILED",
445
+ state: "challenged",
446
+ message: "Payment credential settlement was declined.",
447
+ },
448
+ mcpErrorCode: MPP_MCP_VERIFICATION_FAILED_CODE,
449
+ };
450
+ }
451
+ // Transition to `verified`
452
+ attempt.state = "verified";
453
+ // 7. Execute read-only resource and handle post-settlement failure recovery
454
+ try {
455
+ const payload = await params.executeReadResource();
456
+ const receiptHash = fingerprintMppArtifact("receipt", `${bound.challengeId}:${credentialFingerprint}:${settlementFingerprint}:${bound.requestDigest}`);
457
+ const receipt = {
458
+ receiptId: `rcpt_${receiptHash.slice(11, 27)}`,
459
+ challengeId: bound.challengeId,
460
+ protocolVersion: MPP_PROTOCOL_VERSION,
461
+ sandbox: true,
462
+ paymentMethod: bound.paymentMethod,
463
+ amountMinor: bound.amountMinor,
464
+ currency: bound.currency,
465
+ resourceId: bound.resourceId,
466
+ requestDigest: bound.requestDigest,
467
+ credentialFingerprint,
468
+ settlementFingerprint,
469
+ settledAt: credential.observedAt,
470
+ accountingDisposition: "unmapped_sandbox",
471
+ };
472
+ attempt.state = "delivered";
473
+ attempt.receipt = receipt;
474
+ attempt.cachedDeliveryPayload = payload;
475
+ attempt.recoveryDisposition = "none";
476
+ attempt.lastErrorCode = undefined;
477
+ return {
478
+ status: 200,
479
+ headers: {
480
+ "Payment-Receipt": receipt.receiptId,
481
+ "Cache-Control": "private",
482
+ },
483
+ body: {
484
+ data: payload,
485
+ receipt,
486
+ },
487
+ mcpMeta: {
488
+ [MPP_META_RECEIPT_KEY]: receipt,
489
+ },
490
+ };
491
+ }
492
+ catch (err) {
493
+ attempt.state = "failed_delivery_recovery";
494
+ attempt.recoveryDisposition = "refund_or_idempotent_retry_required";
495
+ attempt.lastErrorCode = err instanceof Error ? err.message : "RESOURCE_EXECUTION_FAILED";
496
+ return {
497
+ status: 502,
498
+ headers: {
499
+ "Cache-Control": "no-store",
500
+ },
501
+ body: {
502
+ error: "DELIVERY_FAILED_AFTER_VERIFICATION",
503
+ state: "failed_delivery_recovery",
504
+ challengeId: bound.challengeId,
505
+ receiptIssued: false,
506
+ recoveryDisposition: attempt.recoveryDisposition,
507
+ accountingDisposition: "unmapped_sandbox",
508
+ message: "Payment was verified in sandbox, but downstream read resource execution failed. No receipt was issued; retry with the same credentialId and challengeId to recover without re-charging.",
509
+ },
510
+ };
511
+ }
512
+ }
513
+ }
514
+ /**
515
+ * M2 — Optional experimental MCP tool registration for local/sandbox verification.
516
+ * Strictly no-ops unless `env.FRIHET_MPP_EXPERIMENTAL === "true"` and no frozen
517
+ * connector profile (`openaiMode`, `claudeMode`, `googleMode`, `museMode`) is active.
518
+ */
519
+ export function attachExperimentalMppSandboxTool(
520
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
521
+ server, options = {}) {
522
+ const env = options.env ?? {};
523
+ if (env.FRIHET_MPP_EXPERIMENTAL !== "true") {
524
+ return undefined;
525
+ }
526
+ if (options.openaiMode || options.claudeMode || options.googleMode || options.museMode) {
527
+ return undefined;
528
+ }
529
+ const coordinator = new MppSandboxCoordinator({
530
+ env,
531
+ activeConnectorModes: {
532
+ openaiMode: options.openaiMode,
533
+ claudeMode: options.claudeMode,
534
+ googleMode: options.googleMode,
535
+ museMode: options.museMode,
536
+ },
537
+ });
538
+ server.registerTool("mpp_sandbox_verify_read", {
539
+ title: "MPP Sandbox Read Verification (Experimental)",
540
+ description: "Experimental sandbox-only read tool verifying Machine Payments Protocol (MPP) M0–M4 challenge, credential, and receipt semantics. Never enabled on public connector profiles.",
541
+ annotations: READ_ONLY_ANNOTATIONS,
542
+ inputSchema: {
543
+ profileId: z.string().describe("Test profile ID (must start with profile_test_)"),
544
+ workspaceUid: z.string().describe("Target sandbox workspace UID"),
545
+ resourceId: z.string().describe("Read-only resource identifier"),
546
+ requestDigest: z.string().describe("Deterministic digest of the read request"),
547
+ region: z.string().describe("Merchant ISO country code (US, CA, ES, SE for SPT)"),
548
+ paymentMethod: z.enum(["stripe_spt", "tempo_usdc"]).describe("Sandbox payment method"),
549
+ amountMinor: z.number().int().positive().describe("Amount in minor units"),
550
+ currency: z.string().describe("Currency code (USD or USDC)"),
551
+ stablecoinApprovalGranted: z.boolean().optional().describe("Whether Stripe stablecoin approval gate is granted"),
552
+ credential: z
553
+ .object({
554
+ challengeId: z.string(),
555
+ credentialId: z.string(),
556
+ rawCredentialToken: z.string(),
557
+ settlementReference: z.string(),
558
+ reportedFinality: z.enum(["settled", "pending", "declined"]),
559
+ observedAt: z.string(),
560
+ })
561
+ .optional()
562
+ .describe("Optional MPP payment credential (_meta['org.paymentauth/credential'])"),
563
+ },
564
+ }, async (args) => {
565
+ const response = await coordinator.handleRequest({
566
+ principal: {
567
+ authenticatedSubjectId: "sandbox_subject_test",
568
+ workspaceUid: args.workspaceUid,
569
+ hasReadPermission: true,
570
+ bearerTokenPresent: true,
571
+ },
572
+ targetResource: {
573
+ workspaceUid: args.workspaceUid,
574
+ resourceId: args.resourceId,
575
+ realm: "mcp.frihet.io/sandbox",
576
+ effect: "read",
577
+ requestDigest: args.requestDigest,
578
+ amountMinor: args.amountMinor,
579
+ currency: args.currency,
580
+ paymentMethod: args.paymentMethod,
581
+ profileId: args.profileId,
582
+ region: args.region,
583
+ stablecoinApprovalGranted: args.stablecoinApprovalGranted,
584
+ },
585
+ credential: args.credential
586
+ ? {
587
+ ...args.credential,
588
+ workspaceUid: args.workspaceUid,
589
+ resourceId: args.resourceId,
590
+ requestDigest: args.requestDigest,
591
+ amountMinor: args.amountMinor,
592
+ currency: args.currency,
593
+ paymentMethod: args.paymentMethod,
594
+ }
595
+ : undefined,
596
+ executeReadResource: async () => ({
597
+ resourceId: args.resourceId,
598
+ workspaceUid: args.workspaceUid,
599
+ sandboxRead: true,
600
+ summary: "Sandbox read-only resource delivered under verified MPP receipt.",
601
+ }),
602
+ });
603
+ return {
604
+ content: [{ type: "text", text: JSON.stringify(response.body, null, 2) }],
605
+ structuredContent: response.body,
606
+ isError: response.status !== 200,
607
+ ...(response.mcpMeta ? { _meta: response.mcpMeta } : {}),
608
+ };
609
+ });
610
+ return coordinator;
611
+ }
612
+ //# sourceMappingURL=mpp-sandbox.js.map