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.
- package/CHANGELOG.md +23 -0
- package/dist/Method.d.ts +5 -2
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +8 -2
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +17 -10
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Request.js +5 -1
- package/dist/server/Request.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +4 -0
- package/dist/server/Transport.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +4 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +20 -10
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +99 -23
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +6 -0
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +4 -0
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +79 -48
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +0 -7
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +84 -13
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +5 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +202 -63
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +1 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +38 -15
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/package.json +2 -2
- package/src/Method.ts +5 -2
- package/src/internal/changeset.test.ts +106 -0
- package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
- package/src/mcp-sdk/server/Transport.test.ts +1 -0
- package/src/mcp-sdk/server/Transport.ts +10 -2
- package/src/proxy/Proxy.test.ts +149 -1
- package/src/server/Mppx.test.ts +120 -0
- package/src/server/Mppx.ts +27 -11
- package/src/server/Request.test.ts +46 -1
- package/src/server/Request.ts +6 -1
- package/src/server/Transport.test.ts +2 -0
- package/src/server/Transport.ts +4 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +13 -0
- package/src/tempo/Methods.ts +23 -16
- package/src/tempo/client/SessionManager.ts +32 -9
- package/src/tempo/internal/fee-payer.test.ts +88 -16
- package/src/tempo/internal/fee-payer.ts +118 -23
- package/src/tempo/server/Charge.test.ts +73 -0
- package/src/tempo/server/Charge.ts +6 -0
- package/src/tempo/server/Session.test.ts +934 -47
- package/src/tempo/server/Session.ts +100 -52
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +321 -10
- package/src/tempo/server/internal/transport.ts +101 -14
- package/src/tempo/session/Chain.test.ts +225 -2
- package/src/tempo/session/Chain.ts +250 -65
- package/src/tempo/session/ChannelStore.test.ts +23 -0
- package/src/tempo/session/ChannelStore.ts +46 -13
- 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
|
+
}
|
|
@@ -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
|
-
...
|
|
103
|
+
...normalizedResponse,
|
|
96
104
|
_meta: {
|
|
97
|
-
...
|
|
105
|
+
...normalizedResponse._meta,
|
|
98
106
|
[core_Mcp.receiptMetaKey]: mcpReceipt,
|
|
99
107
|
},
|
|
100
108
|
}
|