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
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { Challenge, Credential, Method, Receipt, z } from 'mppx'
|
|
2
2
|
import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
|
-
import {
|
|
4
|
+
import type { Address } from 'viem'
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, test } from 'vp/test'
|
|
6
|
+
import { nodeEnv } from '~test/config.js'
|
|
5
7
|
import * as Http from '~test/Http.js'
|
|
8
|
+
import { deployEscrow } from '~test/tempo/session.js'
|
|
6
9
|
import { accounts, asset, client } from '~test/tempo/viem.js'
|
|
7
10
|
|
|
11
|
+
import { sessionManager } from '../tempo/client/SessionManager.js'
|
|
12
|
+
import { deserializeSessionReceipt } from '../tempo/session/Receipt.js'
|
|
8
13
|
import * as ApiProxy from './Proxy.js'
|
|
9
14
|
import * as Service from './Service.js'
|
|
10
15
|
import { anthropic } from './services/anthropic.js'
|
|
11
16
|
import { openai } from './services/openai.js'
|
|
12
17
|
|
|
13
18
|
const secretKey = 'test-secret-key'
|
|
19
|
+
const isLocalnet = nodeEnv === 'localnet'
|
|
14
20
|
|
|
15
21
|
const mppx_server = Mppx_server.create({
|
|
16
22
|
methods: [
|
|
@@ -36,6 +42,12 @@ const mppx_client = Mppx_client.create({
|
|
|
36
42
|
|
|
37
43
|
let upstream: Awaited<ReturnType<typeof Http.createServer>> | undefined
|
|
38
44
|
let proxyServer: Awaited<ReturnType<typeof Http.createServer>> | undefined
|
|
45
|
+
let sessionEscrow: Address
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
if (!isLocalnet) return
|
|
49
|
+
sessionEscrow = await deployEscrow()
|
|
50
|
+
})
|
|
39
51
|
|
|
40
52
|
afterEach(() => {
|
|
41
53
|
upstream?.close()
|
|
@@ -696,3 +708,139 @@ describe('create', () => {
|
|
|
696
708
|
expect(await res.json()).toEqual({ search: '?q=hello&limit=10' })
|
|
697
709
|
})
|
|
698
710
|
})
|
|
711
|
+
|
|
712
|
+
describe.runIf(isLocalnet)('plain HTTP session proxy', () => {
|
|
713
|
+
test('charges proxied content requests and keeps management POSTs off the upstream', async () => {
|
|
714
|
+
let upstreamRequests = 0
|
|
715
|
+
upstream = await createUpstream((req) => {
|
|
716
|
+
upstreamRequests += 1
|
|
717
|
+
return Response.json({
|
|
718
|
+
method: req.method,
|
|
719
|
+
path: new URL(req.url).pathname,
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
const sessionHandler = Mppx_server.create({
|
|
724
|
+
methods: [
|
|
725
|
+
tempo_server.session({
|
|
726
|
+
account: accounts[0],
|
|
727
|
+
currency: asset,
|
|
728
|
+
escrowContract: sessionEscrow,
|
|
729
|
+
getClient: () => client,
|
|
730
|
+
chainId: client.chain!.id,
|
|
731
|
+
}),
|
|
732
|
+
],
|
|
733
|
+
secretKey,
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
const proxy = ApiProxy.create({
|
|
737
|
+
services: [
|
|
738
|
+
Service.from('api', {
|
|
739
|
+
baseUrl: upstream.url,
|
|
740
|
+
routes: {
|
|
741
|
+
'GET /v1/scrape': sessionHandler.session({
|
|
742
|
+
amount: '1',
|
|
743
|
+
decimals: 6,
|
|
744
|
+
unitType: 'page',
|
|
745
|
+
}),
|
|
746
|
+
},
|
|
747
|
+
}),
|
|
748
|
+
],
|
|
749
|
+
})
|
|
750
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
751
|
+
|
|
752
|
+
const manager = sessionManager({
|
|
753
|
+
account: accounts[1],
|
|
754
|
+
client,
|
|
755
|
+
escrowContract: sessionEscrow,
|
|
756
|
+
fetch: globalThis.fetch,
|
|
757
|
+
maxDeposit: '3',
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
const first = await manager.fetch(`${proxyServer.url}/api/v1/scrape`)
|
|
761
|
+
expect(first.status).toBe(200)
|
|
762
|
+
expect(await first.json()).toEqual({ method: 'GET', path: '/v1/scrape' })
|
|
763
|
+
expect(first.receipt?.spent).toBe('1000000')
|
|
764
|
+
expect(first.receipt?.units).toBe(1)
|
|
765
|
+
|
|
766
|
+
const second = await manager.fetch(`${proxyServer.url}/api/v1/scrape`)
|
|
767
|
+
expect(second.status).toBe(200)
|
|
768
|
+
expect(await second.json()).toEqual({ method: 'GET', path: '/v1/scrape' })
|
|
769
|
+
expect(second.receipt?.spent).toBe('2000000')
|
|
770
|
+
expect(second.receipt?.units).toBe(2)
|
|
771
|
+
|
|
772
|
+
const closeReceipt = await manager.close()
|
|
773
|
+
expect(closeReceipt?.status).toBe('success')
|
|
774
|
+
expect(closeReceipt?.spent).toBe('2000000')
|
|
775
|
+
expect(upstreamRequests).toBe(2)
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
test('attaches receipts to proxied error responses and rejects same-voucher replay', async () => {
|
|
779
|
+
upstream = await createUpstream(() =>
|
|
780
|
+
Response.json({ error: 'upstream failed' }, { status: 500 }),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
const sessionHandler = Mppx_server.create({
|
|
784
|
+
methods: [
|
|
785
|
+
tempo_server.session({
|
|
786
|
+
account: accounts[0],
|
|
787
|
+
currency: asset,
|
|
788
|
+
escrowContract: sessionEscrow,
|
|
789
|
+
getClient: () => client,
|
|
790
|
+
chainId: client.chain!.id,
|
|
791
|
+
}),
|
|
792
|
+
],
|
|
793
|
+
secretKey,
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
const proxy = ApiProxy.create({
|
|
797
|
+
services: [
|
|
798
|
+
Service.from('api', {
|
|
799
|
+
baseUrl: upstream.url,
|
|
800
|
+
routes: {
|
|
801
|
+
'GET /v1/scrape': sessionHandler.session({
|
|
802
|
+
amount: '1',
|
|
803
|
+
decimals: 6,
|
|
804
|
+
unitType: 'page',
|
|
805
|
+
}),
|
|
806
|
+
},
|
|
807
|
+
}),
|
|
808
|
+
],
|
|
809
|
+
})
|
|
810
|
+
proxyServer = await Http.createServer(proxy.listener)
|
|
811
|
+
|
|
812
|
+
const sessionClient = Mppx_client.create({
|
|
813
|
+
polyfill: false,
|
|
814
|
+
methods: [
|
|
815
|
+
tempo_client({
|
|
816
|
+
account: accounts[1],
|
|
817
|
+
getClient: () => client,
|
|
818
|
+
maxDeposit: '2',
|
|
819
|
+
}),
|
|
820
|
+
],
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
const challengeResponse = await fetch(`${proxyServer.url}/api/v1/scrape`)
|
|
824
|
+
expect(challengeResponse.status).toBe(402)
|
|
825
|
+
|
|
826
|
+
const authorization = await sessionClient.createCredential(challengeResponse)
|
|
827
|
+
|
|
828
|
+
const first = await fetch(`${proxyServer.url}/api/v1/scrape`, {
|
|
829
|
+
headers: { Authorization: authorization },
|
|
830
|
+
})
|
|
831
|
+
expect(first.status).toBe(500)
|
|
832
|
+
expect(await first.json()).toEqual({ error: 'upstream failed' })
|
|
833
|
+
|
|
834
|
+
const receiptHeader = first.headers.get('Payment-Receipt')
|
|
835
|
+
expect(receiptHeader).toBeTruthy()
|
|
836
|
+
const receipt = deserializeSessionReceipt(receiptHeader!)
|
|
837
|
+
expect(receipt.spent).toBe('1000000')
|
|
838
|
+
expect(receipt.units).toBe(1)
|
|
839
|
+
|
|
840
|
+
const replay = await fetch(`${proxyServer.url}/api/v1/scrape`, {
|
|
841
|
+
headers: { Authorization: authorization },
|
|
842
|
+
})
|
|
843
|
+
expect(replay.status).toBe(402)
|
|
844
|
+
expect(replay.headers.get('Payment-Receipt')).toBeNull()
|
|
845
|
+
})
|
|
846
|
+
})
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -198,6 +198,7 @@ describe('request handler', () => {
|
|
|
198
198
|
captureCount++
|
|
199
199
|
return (
|
|
200
200
|
baseTransport.captureRequest?.(request) ?? {
|
|
201
|
+
hasBody: request.body !== null,
|
|
201
202
|
headers: new Headers(request.headers),
|
|
202
203
|
method: request.method,
|
|
203
204
|
url: new URL(request.url),
|
|
@@ -1389,6 +1390,38 @@ describe('compose', () => {
|
|
|
1389
1390
|
expect(result.status).toBe(200)
|
|
1390
1391
|
})
|
|
1391
1392
|
|
|
1393
|
+
test('dispatches correctly with same name/intent and same economics but different meta', async () => {
|
|
1394
|
+
const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
|
|
1395
|
+
|
|
1396
|
+
const handle = mppx.compose(
|
|
1397
|
+
[alphaMethod, { ...challengeOpts, meta: { route: 'a' } }],
|
|
1398
|
+
[alphaMethod, { ...challengeOpts, meta: { route: 'b' } }],
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
1402
|
+
expect(firstResult.status).toBe(402)
|
|
1403
|
+
if (firstResult.status !== 402) throw new Error()
|
|
1404
|
+
|
|
1405
|
+
const challenges = Challenge.fromResponseList(firstResult.challenge)
|
|
1406
|
+
expect(challenges).toHaveLength(2)
|
|
1407
|
+
expect(challenges[0]?.opaque).toEqual({ route: 'a' })
|
|
1408
|
+
expect(challenges[1]?.opaque).toEqual({ route: 'b' })
|
|
1409
|
+
|
|
1410
|
+
const secondChallenge = challenges[1]!
|
|
1411
|
+
const credential = Credential.from({
|
|
1412
|
+
challenge: secondChallenge,
|
|
1413
|
+
payload: { token: 'valid' },
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
const result = await handle(
|
|
1417
|
+
new Request('https://example.com/resource', {
|
|
1418
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1419
|
+
}),
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
expect(result.status).toBe(200)
|
|
1423
|
+
})
|
|
1424
|
+
|
|
1392
1425
|
describe('html', () => {
|
|
1393
1426
|
const htmlOptionsA = {
|
|
1394
1427
|
config: { providerA: true },
|
|
@@ -1979,6 +2012,93 @@ describe('cross-route credential replay via scope binding flaw', () => {
|
|
|
1979
2012
|
expect(result.status).toBe(402)
|
|
1980
2013
|
})
|
|
1981
2014
|
|
|
2015
|
+
test('rejects same-economics credential replayed across sibling routes with different meta', async () => {
|
|
2016
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
|
|
2017
|
+
|
|
2018
|
+
const routeA = handler.charge({
|
|
2019
|
+
amount: '0.01',
|
|
2020
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2021
|
+
decimals: 6,
|
|
2022
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
2023
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2024
|
+
meta: { route: 'a' },
|
|
2025
|
+
})
|
|
2026
|
+
const routeB = handler.charge({
|
|
2027
|
+
amount: '0.01',
|
|
2028
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2029
|
+
decimals: 6,
|
|
2030
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
2031
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2032
|
+
meta: { route: 'b' },
|
|
2033
|
+
})
|
|
2034
|
+
|
|
2035
|
+
const routeAChallengeResult = await routeA(new Request('https://example.com/a'))
|
|
2036
|
+
expect(routeAChallengeResult.status).toBe(402)
|
|
2037
|
+
if (routeAChallengeResult.status !== 402) throw new Error()
|
|
2038
|
+
|
|
2039
|
+
const routeBChallengeResult = await routeB(new Request('https://example.com/b'))
|
|
2040
|
+
expect(routeBChallengeResult.status).toBe(402)
|
|
2041
|
+
if (routeBChallengeResult.status !== 402) throw new Error()
|
|
2042
|
+
|
|
2043
|
+
const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
|
|
2044
|
+
const routeBChallenge = Challenge.fromResponse(routeBChallengeResult.challenge)
|
|
2045
|
+
|
|
2046
|
+
expect(routeAChallenge.opaque).toEqual({ route: 'a' })
|
|
2047
|
+
expect(routeBChallenge.opaque).toEqual({ route: 'b' })
|
|
2048
|
+
|
|
2049
|
+
const credential = Credential.from({
|
|
2050
|
+
challenge: routeAChallenge,
|
|
2051
|
+
payload: { token: 'valid' },
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
const result = await routeB(
|
|
2055
|
+
new Request('https://example.com/b', {
|
|
2056
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
2057
|
+
}),
|
|
2058
|
+
)
|
|
2059
|
+
|
|
2060
|
+
expect(result.status).toBe(402)
|
|
2061
|
+
})
|
|
2062
|
+
|
|
2063
|
+
test('rejects same-economics credential replayed across sibling routes when meta differs only by case', async () => {
|
|
2064
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
|
|
2065
|
+
|
|
2066
|
+
const routeA = handler.charge({
|
|
2067
|
+
amount: '0.01',
|
|
2068
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2069
|
+
decimals: 6,
|
|
2070
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
2071
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2072
|
+
meta: { route: '0xAbC123' },
|
|
2073
|
+
})
|
|
2074
|
+
const routeB = handler.charge({
|
|
2075
|
+
amount: '0.01',
|
|
2076
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2077
|
+
decimals: 6,
|
|
2078
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
2079
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2080
|
+
meta: { route: '0xabc123' },
|
|
2081
|
+
})
|
|
2082
|
+
|
|
2083
|
+
const routeAChallengeResult = await routeA(new Request('https://example.com/a'))
|
|
2084
|
+
expect(routeAChallengeResult.status).toBe(402)
|
|
2085
|
+
if (routeAChallengeResult.status !== 402) throw new Error()
|
|
2086
|
+
|
|
2087
|
+
const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
|
|
2088
|
+
const credential = Credential.from({
|
|
2089
|
+
challenge: routeAChallenge,
|
|
2090
|
+
payload: { token: 'valid' },
|
|
2091
|
+
})
|
|
2092
|
+
|
|
2093
|
+
const result = await routeB(
|
|
2094
|
+
new Request('https://example.com/b', {
|
|
2095
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
2096
|
+
}),
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
expect(result.status).toBe(402)
|
|
2100
|
+
})
|
|
2101
|
+
|
|
1982
2102
|
test('rejects credential with mismatched method field', async () => {
|
|
1983
2103
|
const otherMethod = Method.from({
|
|
1984
2104
|
name: 'other',
|
package/src/server/Mppx.ts
CHANGED
|
@@ -366,12 +366,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
366
366
|
// matches *this route's current configuration* when the request
|
|
367
367
|
// hook transforms fields between calls.
|
|
368
368
|
//
|
|
369
|
-
// This check compares
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// and
|
|
369
|
+
// This check compares the fields that MUST be stable across both
|
|
370
|
+
// calls. That includes the economically significant request fields
|
|
371
|
+
// plus `opaque`, which can carry route-scoping metadata (for example,
|
|
372
|
+
// sibling route identity) that must not be replayable across handlers.
|
|
373
|
+
// `expires` still is not pinned here because its default is generated
|
|
374
|
+
// per invocation, and `digest` is already bound by the echoed HMAC.
|
|
375
375
|
{
|
|
376
376
|
const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge)
|
|
377
377
|
if (mismatch) {
|
|
@@ -561,12 +561,14 @@ async function captureRequest(
|
|
|
561
561
|
|
|
562
562
|
function captureRequestFromInput(input: unknown): Method.CapturedRequest {
|
|
563
563
|
const source = input as {
|
|
564
|
+
body?: unknown
|
|
564
565
|
headers?: HeadersInit | undefined
|
|
565
566
|
method?: string | undefined
|
|
566
567
|
url?: string | URL | undefined
|
|
567
568
|
}
|
|
568
569
|
|
|
569
570
|
return {
|
|
571
|
+
hasBody: source.body !== undefined && source.body !== null,
|
|
570
572
|
headers: new Headers(source.headers),
|
|
571
573
|
method: source.method ?? 'POST',
|
|
572
574
|
url: Transport.safeUrl(source.url),
|
|
@@ -580,7 +582,7 @@ const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields
|
|
|
580
582
|
type CoreBindingField = (typeof coreBindingFields)[number]
|
|
581
583
|
type MethodBindingField = (typeof methodBindingFields)[number]
|
|
582
584
|
type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number]
|
|
583
|
-
type PinnedChallengeField = 'method' | 'intent' | 'realm' | PinnedRequestBindingField
|
|
585
|
+
type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField
|
|
584
586
|
|
|
585
587
|
/**
|
|
586
588
|
* Compares only the fields that MUST be stable across request-hook transforms.
|
|
@@ -601,6 +603,8 @@ function getPinnedChallengeMismatch(
|
|
|
601
603
|
if (actualChallenge[field] !== expectedChallenge[field]) return field
|
|
602
604
|
}
|
|
603
605
|
|
|
606
|
+
if (!opaqueValuesMatch(expectedChallenge.opaque, actualChallenge.opaque)) return 'opaque'
|
|
607
|
+
|
|
604
608
|
return getPinnedRequestBindingMismatch(
|
|
605
609
|
expectedChallenge.request as Record<string, unknown>,
|
|
606
610
|
actualChallenge.request as Record<string, unknown>,
|
|
@@ -683,6 +687,13 @@ function normalizeComparable(value: unknown): unknown {
|
|
|
683
687
|
return typeof value === 'string' ? normalizeHex(value) : value
|
|
684
688
|
}
|
|
685
689
|
|
|
690
|
+
function opaqueValuesMatch(
|
|
691
|
+
expected: Record<string, string> | undefined,
|
|
692
|
+
actual: Record<string, string> | undefined,
|
|
693
|
+
): boolean {
|
|
694
|
+
return isDeepStrictEqual(expected, actual)
|
|
695
|
+
}
|
|
696
|
+
|
|
686
697
|
type CoreBinding = {
|
|
687
698
|
[field in CoreBindingField]?: string
|
|
688
699
|
}
|
|
@@ -739,6 +750,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
|
|
|
739
750
|
name: string
|
|
740
751
|
intent: string
|
|
741
752
|
html: Html.Options | undefined
|
|
753
|
+
meta?: Record<string, string> | undefined
|
|
742
754
|
_canonicalRequest: Record<string, unknown>
|
|
743
755
|
}
|
|
744
756
|
}
|
|
@@ -860,11 +872,15 @@ export function compose(
|
|
|
860
872
|
// transformed fields (e.g. amount with decimals) match correctly.
|
|
861
873
|
// Also checks inside methodDetails for fields moved there by transforms.
|
|
862
874
|
const candidates = handlers.filter((h) => {
|
|
863
|
-
const
|
|
864
|
-
if (!
|
|
865
|
-
|
|
875
|
+
const internal = (h as ConfiguredHandler)._internal
|
|
876
|
+
if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
|
|
877
|
+
return false
|
|
878
|
+
const canonical = internal._canonicalRequest
|
|
866
879
|
if (!canonical) return true
|
|
867
|
-
return
|
|
880
|
+
return (
|
|
881
|
+
!getPinnedRequestBindingMismatch(canonical, credReq) &&
|
|
882
|
+
opaqueValuesMatch(internal.meta, credential.challenge.opaque)
|
|
883
|
+
)
|
|
868
884
|
})
|
|
869
885
|
|
|
870
886
|
const match =
|
|
@@ -112,7 +112,14 @@ describe('fromNodeListener', () => {
|
|
|
112
112
|
test('streams body for POST requests', async () => {
|
|
113
113
|
const [req, res] = createMockRequest({
|
|
114
114
|
method: 'POST',
|
|
115
|
-
rawHeaders: [
|
|
115
|
+
rawHeaders: [
|
|
116
|
+
'Host',
|
|
117
|
+
'example.com',
|
|
118
|
+
'Content-Length',
|
|
119
|
+
'17',
|
|
120
|
+
'Content-Type',
|
|
121
|
+
'application/json',
|
|
122
|
+
],
|
|
116
123
|
})
|
|
117
124
|
|
|
118
125
|
const request = Request.fromNodeListener(req, res)
|
|
@@ -126,4 +133,42 @@ describe('fromNodeListener', () => {
|
|
|
126
133
|
const body = await request.text()
|
|
127
134
|
expect(body).toBe('{"hello":"world"}')
|
|
128
135
|
})
|
|
136
|
+
|
|
137
|
+
test('does not attach a body stream for empty POST requests', () => {
|
|
138
|
+
const [req, res] = createMockRequest({
|
|
139
|
+
method: 'POST',
|
|
140
|
+
rawHeaders: ['Host', 'example.com'],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const request = Request.fromNodeListener(req, res)
|
|
144
|
+
|
|
145
|
+
expect(request.body).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('does not attach a body stream for content-length: 0', () => {
|
|
149
|
+
const [req, res] = createMockRequest({
|
|
150
|
+
method: 'POST',
|
|
151
|
+
rawHeaders: ['Host', 'example.com', 'Content-Length', '0'],
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const request = Request.fromNodeListener(req, res)
|
|
155
|
+
|
|
156
|
+
expect(request.body).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('attaches a body stream for chunked POST requests', async () => {
|
|
160
|
+
const [req, res] = createMockRequest({
|
|
161
|
+
method: 'POST',
|
|
162
|
+
rawHeaders: ['Host', 'example.com', 'Transfer-Encoding', 'chunked'],
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const request = Request.fromNodeListener(req, res)
|
|
166
|
+
|
|
167
|
+
setImmediate(() => {
|
|
168
|
+
req.emit('data', Buffer.from('hello'))
|
|
169
|
+
req.emit('end')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(await request.text()).toBe('hello')
|
|
173
|
+
})
|
|
129
174
|
})
|
package/src/server/Request.ts
CHANGED
|
@@ -94,7 +94,7 @@ export function fromNodeListener(
|
|
|
94
94
|
signal: controller.signal,
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
97
|
+
if (method !== 'GET' && method !== 'HEAD' && hasBody(headers)) {
|
|
98
98
|
init.body = new ReadableStream({
|
|
99
99
|
start(c) {
|
|
100
100
|
req.on('data', (chunk: Buffer) => {
|
|
@@ -111,6 +111,11 @@ export function fromNodeListener(
|
|
|
111
111
|
return new Request(url, init)
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
function hasBody(headers: Headers): boolean {
|
|
115
|
+
const contentLength = headers.get('content-length')
|
|
116
|
+
return (contentLength !== null && contentLength !== '0') || headers.has('transfer-encoding')
|
|
117
|
+
}
|
|
118
|
+
|
|
114
119
|
function normalizeRequestTarget(url: string | undefined): string {
|
|
115
120
|
if (!url) return '/'
|
|
116
121
|
|
|
@@ -43,6 +43,7 @@ describe('http', () => {
|
|
|
43
43
|
|
|
44
44
|
const captured = await transport.captureRequest?.(request)
|
|
45
45
|
expect(captured).toEqual({
|
|
46
|
+
hasBody: false,
|
|
46
47
|
headers: new Headers(request.headers),
|
|
47
48
|
method: 'POST',
|
|
48
49
|
url: new URL('https://example.com/resource?foo=bar'),
|
|
@@ -431,6 +432,7 @@ describe('mcp', () => {
|
|
|
431
432
|
const transport = Transport.mcp()
|
|
432
433
|
|
|
433
434
|
expect(await transport.captureRequest?.(mcpRequest)).toEqual({
|
|
435
|
+
hasBody: true,
|
|
434
436
|
headers: new Headers(),
|
|
435
437
|
method: 'POST',
|
|
436
438
|
url: new URL('mcp://request/tools%2Fcall'),
|
package/src/server/Transport.ts
CHANGED
|
@@ -125,6 +125,7 @@ export function http(): Http {
|
|
|
125
125
|
|
|
126
126
|
captureRequest(request) {
|
|
127
127
|
return {
|
|
128
|
+
hasBody: request.body !== null,
|
|
128
129
|
headers: new Headers(request.headers),
|
|
129
130
|
method: request.method,
|
|
130
131
|
url: safeUrl(request.url),
|
|
@@ -221,6 +222,9 @@ export function mcp() {
|
|
|
221
222
|
|
|
222
223
|
captureRequest(request) {
|
|
223
224
|
return {
|
|
225
|
+
// MCP tool invocations are application content requests even though
|
|
226
|
+
// they do not carry HTTP body headers on the transport boundary.
|
|
227
|
+
hasBody: true,
|
|
224
228
|
headers: new Headers(),
|
|
225
229
|
method: 'POST',
|
|
226
230
|
url: new URL(`mcp://request/${encodeURIComponent(request.method ?? 'unknown')}`),
|