mppx 0.5.13 → 0.5.16

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/Method.d.ts +5 -2
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  6. package/dist/mcp-sdk/server/Transport.js +8 -2
  7. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  8. package/dist/server/Mppx.d.ts.map +1 -1
  9. package/dist/server/Mppx.js +17 -10
  10. package/dist/server/Mppx.js.map +1 -1
  11. package/dist/server/Request.js +5 -1
  12. package/dist/server/Request.js.map +1 -1
  13. package/dist/server/Transport.d.ts.map +1 -1
  14. package/dist/server/Transport.js +4 -0
  15. package/dist/server/Transport.js.map +1 -1
  16. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  17. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/stripe/server/internal/html.gen.js +1 -1
  19. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +4 -2
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  24. package/dist/tempo/client/SessionManager.js +20 -10
  25. package/dist/tempo/client/SessionManager.js.map +1 -1
  26. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  27. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  28. package/dist/tempo/internal/fee-payer.js +99 -23
  29. package/dist/tempo/internal/fee-payer.js.map +1 -1
  30. package/dist/tempo/server/Charge.d.ts.map +1 -1
  31. package/dist/tempo/server/Charge.js +6 -0
  32. package/dist/tempo/server/Charge.js.map +1 -1
  33. package/dist/tempo/server/Session.d.ts +4 -0
  34. package/dist/tempo/server/Session.d.ts.map +1 -1
  35. package/dist/tempo/server/Session.js +79 -48
  36. package/dist/tempo/server/Session.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/dist/tempo/server/internal/transport.d.ts +0 -7
  42. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  43. package/dist/tempo/server/internal/transport.js +84 -13
  44. package/dist/tempo/server/internal/transport.js.map +1 -1
  45. package/dist/tempo/session/Chain.d.ts +5 -0
  46. package/dist/tempo/session/Chain.d.ts.map +1 -1
  47. package/dist/tempo/session/Chain.js +202 -63
  48. package/dist/tempo/session/Chain.js.map +1 -1
  49. package/dist/tempo/session/ChannelStore.d.ts +1 -0
  50. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  51. package/dist/tempo/session/ChannelStore.js +38 -15
  52. package/dist/tempo/session/ChannelStore.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/Method.ts +5 -2
  55. package/src/internal/changeset.test.ts +106 -0
  56. package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
  57. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  58. package/src/mcp-sdk/server/Transport.ts +10 -2
  59. package/src/proxy/Proxy.test.ts +149 -1
  60. package/src/server/Mppx.test.ts +120 -0
  61. package/src/server/Mppx.ts +27 -11
  62. package/src/server/Request.test.ts +46 -1
  63. package/src/server/Request.ts +6 -1
  64. package/src/server/Transport.test.ts +2 -0
  65. package/src/server/Transport.ts +4 -0
  66. package/src/stripe/server/internal/html.gen.ts +1 -1
  67. package/src/tempo/Methods.test.ts +13 -0
  68. package/src/tempo/Methods.ts +23 -16
  69. package/src/tempo/client/SessionManager.ts +32 -9
  70. package/src/tempo/internal/fee-payer.test.ts +88 -16
  71. package/src/tempo/internal/fee-payer.ts +118 -23
  72. package/src/tempo/server/Charge.test.ts +73 -0
  73. package/src/tempo/server/Charge.ts +6 -0
  74. package/src/tempo/server/Session.test.ts +934 -47
  75. package/src/tempo/server/Session.ts +100 -52
  76. package/src/tempo/server/internal/html.gen.ts +1 -1
  77. package/src/tempo/server/internal/transport.test.ts +321 -10
  78. package/src/tempo/server/internal/transport.ts +101 -14
  79. package/src/tempo/session/Chain.test.ts +225 -2
  80. package/src/tempo/session/Chain.ts +250 -65
  81. package/src/tempo/session/ChannelStore.test.ts +23 -0
  82. package/src/tempo/session/ChannelStore.ts +46 -13
  83. package/src/viem/Client.test.ts +52 -1
@@ -0,0 +1,634 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import * as http from 'node:http'
3
+
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
5
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
6
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
9
+ import { session as tempo_session_client, tempo as tempo_client } from 'mppx/client'
10
+ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
11
+ import type { Address } from 'viem'
12
+ import { readContract } from 'viem/actions'
13
+ import { Actions, Addresses } from 'viem/tempo'
14
+ import { beforeAll, describe, expect, test } from 'vp/test'
15
+ import { nodeEnv } from '~test/config.js'
16
+ import { deployEscrow, signTopUpChannel } from '~test/tempo/session.js'
17
+ import { accounts, asset, client as testClient, fundAccount } from '~test/tempo/viem.js'
18
+
19
+ import * as Credential from '../../Credential.js'
20
+ import * as core_Mcp from '../../Mcp.js'
21
+ import * as Store from '../../Store.js'
22
+ import * as ChannelStore from '../../tempo/session/ChannelStore.js'
23
+ import type { SessionReceipt } from '../../tempo/session/Types.js'
24
+ import * as McpServer_transport from '../server/Transport.js'
25
+ import * as McpClient from './McpClient.js'
26
+
27
+ const realm = 'api.example.com'
28
+ const secretKey = 'test-secret-key'
29
+ const chargeAmountRaw = 1_000_000n
30
+ const doubleSessionAmountRaw = chargeAmountRaw * 2n
31
+ const topUpAmountRaw = chargeAmountRaw * 3n
32
+
33
+ let escrowContract: Address
34
+
35
+ beforeAll(async () => {
36
+ escrowContract = await deployEscrow()
37
+ await fundAccount({ address: accounts[4].address, token: Addresses.pathUsd })
38
+ await fundAccount({ address: accounts[4].address, token: asset })
39
+ await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
40
+ await fundAccount({ address: accounts[2].address, token: asset })
41
+ }, 60_000)
42
+
43
+ describe.runIf(nodeEnv === 'localnet')('McpClient.wrap integration', () => {
44
+ const scenarios: readonly Scenario[] = [
45
+ {
46
+ name: 'charge intent settles a paid MCP tool against the live chain',
47
+ async run(harness: Harness) {
48
+ const beforeBalance = await getTokenBalance(accounts[0].address)
49
+
50
+ const first = await harness.mcp.callTool({ name: 'charge_tool', arguments: {} })
51
+ const second = await harness.mcp.callTool({ name: 'charge_tool', arguments: {} })
52
+
53
+ const afterBalance = await getTokenBalance(accounts[0].address)
54
+
55
+ expect(first.content).toEqual([{ type: 'text', text: 'charge tool executed' }])
56
+ expect(second.content).toEqual([{ type: 'text', text: 'charge tool executed' }])
57
+ expect(first.receipt?.status).toBe('success')
58
+ expect(second.receipt?.status).toBe('success')
59
+ expect(first.receipt?.method).toBe('tempo')
60
+ expect(second.receipt?.method).toBe('tempo')
61
+ expect(first.receipt?.reference).toMatch(/^0x[0-9a-f]+$/)
62
+ expect(second.receipt?.reference).toMatch(/^0x[0-9a-f]+$/)
63
+ expect(second.receipt?.reference).not.toBe(first.receipt?.reference)
64
+ expect(afterBalance - beforeBalance).toBe(chargeAmountRaw * 2n)
65
+ },
66
+ },
67
+ {
68
+ name: 'session intent reuses one live channel and advances cumulative metering',
69
+ async run(harness: Harness) {
70
+ const first = await harness.mcp.callTool({ name: 'session_tool', arguments: {} })
71
+ const second = await harness.mcp.callTool({ name: 'session_tool', arguments: {} })
72
+
73
+ const firstReceipt = first.receipt as SessionReceipt | undefined
74
+ const secondReceipt = second.receipt as SessionReceipt | undefined
75
+
76
+ expect(first.content).toEqual([{ type: 'text', text: 'session tool executed' }])
77
+ expect(second.content).toEqual([{ type: 'text', text: 'session tool executed' }])
78
+ expect(firstReceipt?.intent).toBe('session')
79
+ expect(secondReceipt?.intent).toBe('session')
80
+ expect(firstReceipt?.channelId).toMatch(/^0x[0-9a-f]{64}$/)
81
+ expect(secondReceipt?.channelId).toBe(firstReceipt?.channelId)
82
+ expect(firstReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
83
+ expect(secondReceipt?.acceptedCumulative).toBe((chargeAmountRaw * 2n).toString())
84
+
85
+ const channel = await harness.sessionStore.getChannel(secondReceipt!.channelId)
86
+ expect(channel?.highestVoucherAmount).toBe(chargeAmountRaw * 2n)
87
+ expect(channel?.highestVoucher?.channelId).toBe(secondReceipt?.channelId)
88
+ },
89
+ },
90
+ {
91
+ name: 'one live MCP server can serve charge and session tools in the same client session',
92
+ async run(harness: Harness) {
93
+ const chargeResult = await harness.mcp.callTool({ name: 'charge_tool', arguments: {} })
94
+ const sessionResult = await harness.mcp.callTool({ name: 'session_tool', arguments: {} })
95
+
96
+ const sessionReceipt = sessionResult.receipt as SessionReceipt | undefined
97
+
98
+ expect(chargeResult.content).toEqual([{ type: 'text', text: 'charge tool executed' }])
99
+ expect(chargeResult.receipt?.status).toBe('success')
100
+ expect(chargeResult.receipt?.reference).toMatch(/^0x[0-9a-f]+$/)
101
+ expect(sessionResult.content).toEqual([{ type: 'text', text: 'session tool executed' }])
102
+ expect(sessionReceipt?.intent).toBe('session')
103
+ expect(sessionReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
104
+
105
+ const channel = await harness.sessionStore.getChannel(sessionReceipt!.channelId)
106
+ expect(channel?.highestVoucherAmount).toBe(chargeAmountRaw)
107
+ },
108
+ },
109
+ {
110
+ name: 'session intent reuses one live channel across multiple MCP tools with different costs',
111
+ async run(harness: Harness) {
112
+ const first = await harness.mcp.callTool({ name: 'session_tool', arguments: {} })
113
+ const second = await harness.mcp.callTool({ name: 'session_tool_double', arguments: {} })
114
+ const third = await harness.mcp.callTool({ name: 'session_tool', arguments: {} })
115
+
116
+ const firstReceipt = first.receipt as SessionReceipt | undefined
117
+ const secondReceipt = second.receipt as SessionReceipt | undefined
118
+ const thirdReceipt = third.receipt as SessionReceipt | undefined
119
+
120
+ expect(first.content).toEqual([{ type: 'text', text: 'session tool executed' }])
121
+ expect(second.content).toEqual([{ type: 'text', text: 'session double tool executed' }])
122
+ expect(third.content).toEqual([{ type: 'text', text: 'session tool executed' }])
123
+ expect(secondReceipt?.channelId).toBe(firstReceipt?.channelId)
124
+ expect(thirdReceipt?.channelId).toBe(firstReceipt?.channelId)
125
+ expect(firstReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
126
+ expect(secondReceipt?.acceptedCumulative).toBe(
127
+ (chargeAmountRaw + doubleSessionAmountRaw).toString(),
128
+ )
129
+ expect(thirdReceipt?.acceptedCumulative).toBe(
130
+ (chargeAmountRaw * 2n + doubleSessionAmountRaw).toString(),
131
+ )
132
+
133
+ const channel = await harness.sessionStore.getChannel(thirdReceipt!.channelId)
134
+ expect(channel?.highestVoucherAmount).toBe(chargeAmountRaw * 2n + doubleSessionAmountRaw)
135
+ },
136
+ },
137
+ {
138
+ name: 'session intent accepts replayed vouchers without advancing cumulative state',
139
+ async run(harness: Harness) {
140
+ const openChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
141
+ const openCredential = await harness.sessionMethod.createCredential({
142
+ challenge: openChallenge,
143
+ context: {},
144
+ })
145
+ const opened = await callToolWithCredential(
146
+ harness.sdkClient,
147
+ 'session_tool',
148
+ openCredential,
149
+ )
150
+
151
+ const openReceipt = opened.receipt as SessionReceipt | undefined
152
+ expect(openReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
153
+
154
+ const voucherChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
155
+ const replayedCumulativeRaw = (chargeAmountRaw * 3n).toString()
156
+ const voucherCredential = await harness.sessionMethod.createCredential({
157
+ challenge: voucherChallenge,
158
+ context: {
159
+ action: 'voucher',
160
+ channelId: openReceipt!.channelId,
161
+ cumulativeAmountRaw: replayedCumulativeRaw,
162
+ },
163
+ })
164
+ const firstVoucher = await callToolWithCredential(
165
+ harness.sdkClient,
166
+ 'session_tool',
167
+ voucherCredential,
168
+ )
169
+ const replayedVoucher = await callToolWithCredential(
170
+ harness.sdkClient,
171
+ 'session_tool',
172
+ voucherCredential,
173
+ )
174
+
175
+ const firstReceipt = firstVoucher.receipt as SessionReceipt | undefined
176
+ const replayReceipt = replayedVoucher.receipt as SessionReceipt | undefined
177
+
178
+ expect(firstVoucher.content).toEqual([{ type: 'text', text: 'session tool executed' }])
179
+ expect(replayedVoucher.content).toEqual([{ type: 'text', text: 'session tool executed' }])
180
+ expect(firstReceipt?.channelId).toBe(openReceipt?.channelId)
181
+ expect(replayReceipt?.channelId).toBe(openReceipt?.channelId)
182
+ expect(firstReceipt?.acceptedCumulative).toBe(replayedCumulativeRaw)
183
+ expect(replayReceipt?.acceptedCumulative).toBe(replayedCumulativeRaw)
184
+
185
+ const channel = await harness.sessionStore.getChannel(openReceipt!.channelId)
186
+ expect(channel?.highestVoucherAmount).toBe(chargeAmountRaw * 3n)
187
+ },
188
+ },
189
+ {
190
+ name: 'session intent rejects replaying a credential across a different MCP tool',
191
+ async run(harness: Harness) {
192
+ const openChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
193
+ const openCredential = await harness.sessionMethod.createCredential({
194
+ challenge: openChallenge,
195
+ context: {},
196
+ })
197
+ const opened = await callToolWithCredential(
198
+ harness.sdkClient,
199
+ 'session_tool',
200
+ openCredential,
201
+ )
202
+
203
+ const openReceipt = opened.receipt as SessionReceipt | undefined
204
+ expect(openReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
205
+
206
+ const mismatch = await getPaymentRequiredError(
207
+ harness.sdkClient,
208
+ 'session_tool_double',
209
+ openCredential,
210
+ )
211
+
212
+ expect(mismatch.data.problem?.type).toBe(
213
+ 'https://paymentauth.org/problems/invalid-challenge',
214
+ )
215
+ expect(mismatch.data.challenges).toHaveLength(1)
216
+ expect(mismatch.data.challenges[0]?.method).toBe('tempo')
217
+ expect(mismatch.data.challenges[0]?.intent).toBe('session')
218
+ expect(mismatch.data.challenges[0]?.request.amount).toBe(doubleSessionAmountRaw.toString())
219
+
220
+ const channel = await harness.sessionStore.getChannel(openReceipt!.channelId)
221
+ expect(channel?.highestVoucherAmount).toBe(chargeAmountRaw)
222
+ },
223
+ },
224
+ {
225
+ name: 'session intent can top up a live MCP channel and continue metering on the same channel',
226
+ sessionFeePayer: true,
227
+ sessionMaxDeposit: '2',
228
+ async run(harness: Harness) {
229
+ const openChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
230
+ const openCredential = await harness.sessionMethod.createCredential({
231
+ challenge: openChallenge,
232
+ context: {},
233
+ })
234
+ const opened = await callToolWithCredential(
235
+ harness.sdkClient,
236
+ 'session_tool',
237
+ openCredential,
238
+ )
239
+
240
+ const openReceipt = opened.receipt as SessionReceipt | undefined
241
+ expect(opened.content).toEqual([{ type: 'text', text: 'session tool executed' }])
242
+ expect(openReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
243
+
244
+ const voucherChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
245
+ const voucherCredential = await harness.sessionMethod.createCredential({
246
+ challenge: voucherChallenge,
247
+ context: {},
248
+ })
249
+ const metered = await callToolWithCredential(
250
+ harness.sdkClient,
251
+ 'session_tool',
252
+ voucherCredential,
253
+ )
254
+
255
+ const meteredReceipt = metered.receipt as SessionReceipt | undefined
256
+ expect(metered.content).toEqual([{ type: 'text', text: 'session tool executed' }])
257
+ expect(meteredReceipt?.channelId).toBe(openReceipt?.channelId)
258
+ expect(meteredReceipt?.acceptedCumulative).toBe((chargeAmountRaw * 2n).toString())
259
+
260
+ const { serializedTransaction } = await signTopUpChannel({
261
+ escrow: escrowContract,
262
+ feePayer: true,
263
+ payer: accounts[2],
264
+ channelId: openReceipt!.channelId,
265
+ token: asset,
266
+ amount: topUpAmountRaw,
267
+ })
268
+ const topUpChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
269
+ const topUpCredential = await harness.sessionMethod.createCredential({
270
+ challenge: topUpChallenge,
271
+ context: {
272
+ action: 'topUp',
273
+ additionalDepositRaw: topUpAmountRaw.toString(),
274
+ channelId: openReceipt!.channelId,
275
+ transaction: serializedTransaction,
276
+ },
277
+ })
278
+ const toppedUp = await callToolWithCredential(
279
+ harness.sdkClient,
280
+ 'session_tool',
281
+ topUpCredential,
282
+ )
283
+
284
+ const topUpReceipt = toppedUp.receipt as SessionReceipt | undefined
285
+ expect(toppedUp.content).toEqual([])
286
+ expect(topUpReceipt?.channelId).toBe(openReceipt?.channelId)
287
+ expect(topUpReceipt?.acceptedCumulative).toBe((chargeAmountRaw * 2n).toString())
288
+ expect(topUpReceipt?.spent).toBe(meteredReceipt?.spent)
289
+ expect(topUpReceipt?.units).toBe(meteredReceipt?.units)
290
+
291
+ const afterTopUpChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
292
+ const afterTopUpCredential = await harness.sessionMethod.createCredential({
293
+ challenge: afterTopUpChallenge,
294
+ context: {},
295
+ })
296
+ const resumed = await callToolWithCredential(
297
+ harness.sdkClient,
298
+ 'session_tool',
299
+ afterTopUpCredential,
300
+ )
301
+
302
+ const resumedReceipt = resumed.receipt as SessionReceipt | undefined
303
+ expect(resumed.content).toEqual([{ type: 'text', text: 'session tool executed' }])
304
+ expect(resumedReceipt?.channelId).toBe(openReceipt?.channelId)
305
+ expect(resumedReceipt?.acceptedCumulative).toBe((chargeAmountRaw * 3n).toString())
306
+
307
+ const channel = await harness.sessionStore.getChannel(openReceipt!.channelId)
308
+ expect(channel?.deposit).toBe(chargeAmountRaw * 2n + topUpAmountRaw)
309
+ expect(channel?.highestVoucherAmount).toBe(chargeAmountRaw * 3n)
310
+ expect(channel?.spent).toBeGreaterThanOrEqual(BigInt(topUpReceipt!.spent))
311
+ expect(channel?.units).toBeGreaterThanOrEqual(topUpReceipt?.units ?? 0)
312
+ },
313
+ },
314
+ {
315
+ name: 'session intent can close a live MCP channel and reopen on the next request',
316
+ async run(harness: Harness) {
317
+ const openChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
318
+ const openCredential = await harness.sessionMethod.createCredential({
319
+ challenge: openChallenge,
320
+ context: {},
321
+ })
322
+ const opened = await callToolWithCredential(
323
+ harness.sdkClient,
324
+ 'session_tool',
325
+ openCredential,
326
+ )
327
+
328
+ const openReceipt = opened.receipt as SessionReceipt | undefined
329
+ expect(openReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
330
+
331
+ const voucherChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
332
+ const voucherCredential = await harness.sessionMethod.createCredential({
333
+ challenge: voucherChallenge,
334
+ context: {},
335
+ })
336
+ const metered = await callToolWithCredential(
337
+ harness.sdkClient,
338
+ 'session_tool',
339
+ voucherCredential,
340
+ )
341
+
342
+ const meteredReceipt = metered.receipt as SessionReceipt | undefined
343
+ expect(meteredReceipt?.channelId).toBe(openReceipt?.channelId)
344
+ expect(meteredReceipt?.acceptedCumulative).toBe((chargeAmountRaw * 2n).toString())
345
+
346
+ const closeChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
347
+ const closeCredential = await harness.sessionMethod.createCredential({
348
+ challenge: closeChallenge,
349
+ context: {
350
+ action: 'close',
351
+ channelId: openReceipt!.channelId,
352
+ cumulativeAmountRaw: (chargeAmountRaw * 2n).toString(),
353
+ },
354
+ })
355
+ const closed = await callToolWithCredential(
356
+ harness.sdkClient,
357
+ 'session_tool',
358
+ closeCredential,
359
+ )
360
+
361
+ const closeReceipt = closed.receipt as SessionReceipt | undefined
362
+ expect(closed.content).toEqual([])
363
+ expect(closeReceipt?.channelId).toBe(openReceipt?.channelId)
364
+ expect(closeReceipt?.acceptedCumulative).toBe((chargeAmountRaw * 2n).toString())
365
+ expect(closeReceipt?.txHash).toMatch(/^0x[0-9a-f]+$/)
366
+
367
+ const closedChannel = await harness.sessionStore.getChannel(openReceipt!.channelId)
368
+ expect(closedChannel?.finalized).toBe(true)
369
+
370
+ const reopenedChallenge = await getPaymentChallenge(harness.sdkClient, 'session_tool')
371
+ const reopenedCredential = await harness.sessionMethod.createCredential({
372
+ challenge: reopenedChallenge,
373
+ context: {},
374
+ })
375
+ const reopened = await callToolWithCredential(
376
+ harness.sdkClient,
377
+ 'session_tool',
378
+ reopenedCredential,
379
+ )
380
+
381
+ const reopenedReceipt = reopened.receipt as SessionReceipt | undefined
382
+ expect(reopened.content).toEqual([{ type: 'text', text: 'session tool executed' }])
383
+ expect(reopenedReceipt?.acceptedCumulative).toBe(chargeAmountRaw.toString())
384
+ expect(reopenedReceipt?.channelId).not.toBe(openReceipt?.channelId)
385
+ },
386
+ },
387
+ ]
388
+
389
+ for (const scenario of scenarios) {
390
+ test(
391
+ scenario.name,
392
+ async () => {
393
+ const harness = await createHarness({
394
+ sessionFeePayer: scenario.sessionFeePayer,
395
+ sessionMaxDeposit: scenario.sessionMaxDeposit,
396
+ })
397
+
398
+ try {
399
+ await scenario.run(harness)
400
+ } finally {
401
+ await harness.close()
402
+ }
403
+ },
404
+ 30_000,
405
+ )
406
+ }
407
+ })
408
+
409
+ type WrappedClient = {
410
+ callTool: (
411
+ params: { name: string; arguments?: Record<string, unknown>; _meta?: Record<string, unknown> },
412
+ options?: { context?: unknown; timeout?: number },
413
+ ) => Promise<McpClient.CallToolResult>
414
+ }
415
+
416
+ type SessionMethod = ReturnType<typeof tempo_session_client>
417
+ type SessionChallenge = Parameters<SessionMethod['createCredential']>[0]['challenge']
418
+ type PaymentRequiredMcpError = Error & {
419
+ data: {
420
+ challenges: SessionChallenge[]
421
+ problem?: { type?: string | undefined } | undefined
422
+ }
423
+ }
424
+
425
+ type Scenario = {
426
+ name: string
427
+ run: (harness: Harness) => Promise<void>
428
+ sessionFeePayer?: boolean | undefined
429
+ sessionMaxDeposit?: string | undefined
430
+ }
431
+
432
+ type Harness = {
433
+ close: () => Promise<void>
434
+ mcp: WrappedClient
435
+ sdkClient: Client
436
+ sessionMethod: SessionMethod
437
+ sessionStore: ChannelStore.ChannelStore
438
+ }
439
+
440
+ async function createHarness(options?: {
441
+ sessionFeePayer?: boolean | undefined
442
+ sessionDeposit?: string | undefined
443
+ sessionMaxDeposit?: string | undefined
444
+ }): Promise<Harness> {
445
+ const sessionBackingStore = Store.memory()
446
+ const sessionStore = ChannelStore.fromStore(sessionBackingStore)
447
+ const [chargeMethod] = tempo_client({
448
+ account: accounts[1],
449
+ getClient: () => testClient,
450
+ })
451
+ const sessionMethod = tempo_session_client({
452
+ account: accounts[2],
453
+ escrowContract,
454
+ getClient: () => testClient,
455
+ ...(options?.sessionMaxDeposit
456
+ ? { maxDeposit: options.sessionMaxDeposit }
457
+ : { deposit: options?.sessionDeposit ?? '5' }),
458
+ })
459
+
460
+ const payment = Mppx_server.create({
461
+ methods: [
462
+ tempo_server.charge({
463
+ account: accounts[0],
464
+ currency: asset,
465
+ getClient: () => testClient,
466
+ }),
467
+ tempo_server.session({
468
+ account: accounts[0],
469
+ currency: asset,
470
+ escrowContract,
471
+ getClient: () => testClient,
472
+ store: sessionBackingStore,
473
+ ...(options?.sessionFeePayer ? { feePayer: accounts[4] } : {}),
474
+ }),
475
+ ],
476
+ realm,
477
+ secretKey,
478
+ transport: McpServer_transport.mcpSdk(),
479
+ })
480
+
481
+ const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' })
482
+
483
+ mcpServer.registerTool('charge_tool', { description: 'Charge metered tool' }, async (extra) => {
484
+ const result = await (payment.charge({ amount: '1' }) as (input: unknown) => Promise<any>)(
485
+ extra,
486
+ )
487
+ if (result.status === 402) throw result.challenge
488
+
489
+ return result.withReceipt({
490
+ content: [{ type: 'text' as const, text: 'charge tool executed' }],
491
+ }) as never
492
+ })
493
+
494
+ mcpServer.registerTool('session_tool', { description: 'Session metered tool' }, async (extra) => {
495
+ const result = await (
496
+ payment.session({ amount: '1', suggestedDeposit: '5', unitType: 'tool-call' }) as (
497
+ input: unknown,
498
+ ) => Promise<any>
499
+ )(extra)
500
+ if (result.status === 402) throw result.challenge
501
+
502
+ return (result as { withReceipt: (response: unknown) => unknown }).withReceipt({
503
+ content: [{ type: 'text' as const, text: 'session tool executed' }],
504
+ }) as never
505
+ })
506
+
507
+ mcpServer.registerTool(
508
+ 'session_tool_double',
509
+ { description: 'Session metered tool charging two units' },
510
+ async (extra) => {
511
+ const result = await (
512
+ payment.session({ amount: '2', suggestedDeposit: '5', unitType: 'tool-call' }) as (
513
+ input: unknown,
514
+ ) => Promise<any>
515
+ )(extra)
516
+ if (result.status === 402) throw result.challenge
517
+
518
+ return (result as { withReceipt: (response: unknown) => unknown }).withReceipt({
519
+ content: [{ type: 'text' as const, text: 'session double tool executed' }],
520
+ }) as never
521
+ },
522
+ )
523
+
524
+ const app = createMcpExpressApp()
525
+ const serverTransport = new StreamableHTTPServerTransport({
526
+ sessionIdGenerator: randomUUID,
527
+ })
528
+
529
+ await mcpServer.connect(serverTransport as never)
530
+
531
+ app.all('/mcp', (req, res) => {
532
+ void (async () => {
533
+ try {
534
+ await serverTransport.handleRequest(req, res, req.body)
535
+ } catch (error) {
536
+ console.error('MCP integration route failed', error)
537
+ if (!res.headersSent) res.status(500).json({ error: String(error) })
538
+ }
539
+ })()
540
+ })
541
+
542
+ const httpServer = await createMcpHttpServer(app)
543
+ const sdkClient = new Client({ name: 'test-client', version: '1.0.0' })
544
+ const clientTransport = new StreamableHTTPClientTransport(new URL(`${httpServer.url}/mcp`))
545
+ await sdkClient.connect(clientTransport as never)
546
+
547
+ const mcp = McpClient.wrap(sdkClient, {
548
+ methods: [chargeMethod, sessionMethod],
549
+ })
550
+
551
+ return {
552
+ async close() {
553
+ httpServer.close()
554
+ await Promise.allSettled([sdkClient.close(), mcpServer.close(), serverTransport.close()])
555
+ },
556
+ mcp,
557
+ sdkClient,
558
+ sessionMethod,
559
+ sessionStore,
560
+ }
561
+ }
562
+
563
+ async function getPaymentChallenge(client: Client, toolName: string): Promise<SessionChallenge> {
564
+ try {
565
+ await client.callTool({ name: toolName, arguments: {} })
566
+ } catch (error) {
567
+ if (!McpClient.isPaymentRequiredError(error)) throw error
568
+
569
+ const challenge = error.data.challenges.find(
570
+ (challenge) => challenge.method === 'tempo' && challenge.intent === 'session',
571
+ )
572
+ if (!challenge)
573
+ throw new Error(`No tempo.session challenge returned for ${toolName}`, { cause: error })
574
+ return challenge as SessionChallenge
575
+ }
576
+
577
+ throw new Error(`Expected ${toolName} to require payment`)
578
+ }
579
+
580
+ async function callToolWithCredential(
581
+ client: Client,
582
+ toolName: string,
583
+ serializedCredential: string,
584
+ ): Promise<McpClient.CallToolResult> {
585
+ const result = await client.callTool({
586
+ name: toolName,
587
+ arguments: {},
588
+ _meta: {
589
+ [core_Mcp.credentialMetaKey]: Credential.deserialize(serializedCredential),
590
+ },
591
+ })
592
+
593
+ return {
594
+ ...result,
595
+ receipt: result._meta?.[core_Mcp.receiptMetaKey] as McpClient.CallToolResult['receipt'],
596
+ }
597
+ }
598
+
599
+ async function getPaymentRequiredError(
600
+ client: Client,
601
+ toolName: string,
602
+ serializedCredential: string,
603
+ ): Promise<PaymentRequiredMcpError> {
604
+ try {
605
+ await callToolWithCredential(client, toolName, serializedCredential)
606
+ } catch (error) {
607
+ if (!McpClient.isPaymentRequiredError(error)) throw error
608
+ return error as unknown as PaymentRequiredMcpError
609
+ }
610
+
611
+ throw new Error(`Expected ${toolName} to return a payment-required error`)
612
+ }
613
+
614
+ async function getTokenBalance(account: Address): Promise<bigint> {
615
+ return readContract(
616
+ testClient,
617
+ Actions.token.getBalance.call({ account, token: asset }) as never,
618
+ ) as Promise<bigint>
619
+ }
620
+
621
+ async function createMcpHttpServer(handler: http.RequestListener) {
622
+ const server = http.createServer(handler)
623
+ await new Promise<void>((resolve) => server.listen(0, resolve))
624
+ const { port } = server.address() as { port: number }
625
+
626
+ return {
627
+ close() {
628
+ server.closeAllConnections?.()
629
+ server.closeIdleConnections?.()
630
+ server.close(() => {})
631
+ },
632
+ url: `http://127.0.0.1:${port}`,
633
+ }
634
+ }
@@ -31,6 +31,7 @@ describe('mcpSdk', () => {
31
31
  const transport = mcpSdk()
32
32
 
33
33
  expect(await transport.captureRequest?.({})).toEqual({
34
+ hasBody: true,
34
35
  headers: new Headers(),
35
36
  method: 'POST',
36
37
  url: new URL('mcp://request/sdk'),
@@ -53,6 +53,9 @@ export function mcpSdk(): McpSdk {
53
53
 
54
54
  captureRequest() {
55
55
  return {
56
+ // MCP tool invocations are application content requests even though
57
+ // they do not carry HTTP body headers on the transport boundary.
58
+ hasBody: true,
56
59
  headers: new Headers(),
57
60
  method: 'POST',
58
61
  url: new URL('mcp://request/sdk'),
@@ -91,10 +94,15 @@ export function mcpSdk(): McpSdk {
91
94
  challengeId,
92
95
  }
93
96
 
97
+ const normalizedResponse =
98
+ response instanceof globalThis.Response
99
+ ? ({ content: [] } as CallToolResult)
100
+ : (response as CallToolResult)
101
+
94
102
  return {
95
- ...response,
103
+ ...normalizedResponse,
96
104
  _meta: {
97
- ...response._meta,
105
+ ...normalizedResponse._meta,
98
106
  [core_Mcp.receiptMetaKey]: mcpReceipt,
99
107
  },
100
108
  }