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
@@ -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 { afterEach, describe, expect, test } from 'vp/test'
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
+ })
@@ -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',
@@ -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 only the economically significant "pinned"
370
- // fields (method, intent, realm, amount, currency, recipient, etc.)
371
- // that MUST be stable across both calls. Fields like `opaque`,
372
- // `digest`, and `expires` don't need explicit pinning here because
373
- // they are set by server config (not derived from the request hook)
374
- // and are already fully covered by the HMAC binding in Tier 1.
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 meta = (h as ConfiguredHandler)._internal
864
- if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
865
- const canonical = meta._canonicalRequest
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 !getPinnedRequestBindingMismatch(canonical, credReq)
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: ['Host', 'example.com', 'Content-Type', 'application/json'],
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
  })
@@ -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'),
@@ -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')}`),