mppx 0.4.9 → 0.4.11

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 (165) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +155 -0
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/discovery/Discovery.d.ts +146 -0
  6. package/dist/discovery/Discovery.d.ts.map +1 -0
  7. package/dist/discovery/Discovery.js +60 -0
  8. package/dist/discovery/Discovery.js.map +1 -0
  9. package/dist/discovery/OpenApi.d.ts +61 -0
  10. package/dist/discovery/OpenApi.d.ts.map +1 -0
  11. package/dist/discovery/OpenApi.js +139 -0
  12. package/dist/discovery/OpenApi.js.map +1 -0
  13. package/dist/discovery/Validate.d.ts +10 -0
  14. package/dist/discovery/Validate.d.ts.map +1 -0
  15. package/dist/discovery/Validate.js +63 -0
  16. package/dist/discovery/Validate.js.map +1 -0
  17. package/dist/discovery/index.d.ts +4 -0
  18. package/dist/discovery/index.d.ts.map +1 -0
  19. package/dist/discovery/index.js +4 -0
  20. package/dist/discovery/index.js.map +1 -0
  21. package/dist/middlewares/elysia.d.ts +52 -1
  22. package/dist/middlewares/elysia.d.ts.map +1 -1
  23. package/dist/middlewares/elysia.js +17 -0
  24. package/dist/middlewares/elysia.js.map +1 -1
  25. package/dist/middlewares/express.d.ts +13 -1
  26. package/dist/middlewares/express.d.ts.map +1 -1
  27. package/dist/middlewares/express.js +18 -0
  28. package/dist/middlewares/express.js.map +1 -1
  29. package/dist/middlewares/hono.d.ts +19 -1
  30. package/dist/middlewares/hono.d.ts.map +1 -1
  31. package/dist/middlewares/hono.js +51 -0
  32. package/dist/middlewares/hono.js.map +1 -1
  33. package/dist/middlewares/internal/mppx.d.ts +4 -2
  34. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  35. package/dist/middlewares/internal/mppx.js +10 -3
  36. package/dist/middlewares/internal/mppx.js.map +1 -1
  37. package/dist/middlewares/nextjs.d.ts +11 -0
  38. package/dist/middlewares/nextjs.d.ts.map +1 -1
  39. package/dist/middlewares/nextjs.js +15 -0
  40. package/dist/middlewares/nextjs.js.map +1 -1
  41. package/dist/proxy/Proxy.d.ts +6 -0
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +56 -80
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts +16 -23
  46. package/dist/proxy/Service.d.ts.map +1 -1
  47. package/dist/proxy/Service.js +19 -83
  48. package/dist/proxy/Service.js.map +1 -1
  49. package/dist/proxy/internal/Route.js +1 -1
  50. package/dist/proxy/internal/Route.js.map +1 -1
  51. package/dist/proxy/services/anthropic.d.ts.map +1 -1
  52. package/dist/proxy/services/anthropic.js +5 -0
  53. package/dist/proxy/services/anthropic.js.map +1 -1
  54. package/dist/proxy/services/openai.d.ts.map +1 -1
  55. package/dist/proxy/services/openai.js +6 -3
  56. package/dist/proxy/services/openai.js.map +1 -1
  57. package/dist/proxy/services/stripe.d.ts.map +1 -1
  58. package/dist/proxy/services/stripe.js +6 -3
  59. package/dist/proxy/services/stripe.js.map +1 -1
  60. package/dist/stripe/internal/types.d.ts +3 -0
  61. package/dist/stripe/internal/types.d.ts.map +1 -1
  62. package/dist/stripe/server/Charge.d.ts.map +1 -1
  63. package/dist/stripe/server/Charge.js +9 -2
  64. package/dist/stripe/server/Charge.js.map +1 -1
  65. package/dist/tempo/server/Session.d.ts.map +1 -1
  66. package/dist/tempo/server/Session.js +25 -8
  67. package/dist/tempo/server/Session.js.map +1 -1
  68. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  69. package/dist/tempo/server/internal/transport.js +8 -0
  70. package/dist/tempo/server/internal/transport.js.map +1 -1
  71. package/dist/tempo/session/Chain.js +1 -1
  72. package/dist/tempo/session/Chain.js.map +1 -1
  73. package/package.json +6 -1
  74. package/src/BodyDigest.test.ts +1 -1
  75. package/src/Challenge.fuzz.test.ts +121 -0
  76. package/src/Challenge.test-d.ts +1 -1
  77. package/src/Challenge.test.ts +1 -1
  78. package/src/Credential.fuzz.test.ts +62 -0
  79. package/src/Credential.test.ts +1 -1
  80. package/src/Errors.test.ts +1 -1
  81. package/src/Expires.test.ts +1 -1
  82. package/src/Method.test.ts +1 -1
  83. package/src/PaymentRequest.test.ts +1 -1
  84. package/src/Receipt.test.ts +1 -1
  85. package/src/Store.test-d.ts +1 -1
  86. package/src/Store.test.ts +1 -1
  87. package/src/cli/cli.test.ts +212 -1
  88. package/src/cli/cli.ts +162 -0
  89. package/src/client/Mppx.test-d.ts +1 -1
  90. package/src/client/Mppx.test.ts +1 -1
  91. package/src/client/Transport.test.ts +1 -1
  92. package/src/client/internal/Fetch.browser.test.ts +1 -1
  93. package/src/client/internal/Fetch.test-d.ts +1 -1
  94. package/src/client/internal/Fetch.test.ts +2 -1
  95. package/src/discovery/Discovery.test.ts +152 -0
  96. package/src/discovery/Discovery.ts +72 -0
  97. package/src/discovery/OpenApi.test.ts +425 -0
  98. package/src/discovery/OpenApi.ts +224 -0
  99. package/src/discovery/Validate.test.ts +188 -0
  100. package/src/discovery/Validate.ts +76 -0
  101. package/src/discovery/index.ts +3 -0
  102. package/src/internal/constantTimeEqual.test.ts +1 -1
  103. package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
  104. package/src/mcp-sdk/client/McpClient.test.ts +1 -1
  105. package/src/mcp-sdk/server/Transport.test.ts +1 -1
  106. package/src/middlewares/elysia.test.ts +27 -2
  107. package/src/middlewares/elysia.ts +35 -1
  108. package/src/middlewares/express.test.ts +35 -7
  109. package/src/middlewares/express.ts +34 -0
  110. package/src/middlewares/hono.test.ts +28 -6
  111. package/src/middlewares/hono.ts +73 -1
  112. package/src/middlewares/internal/mppx.test.ts +1 -1
  113. package/src/middlewares/internal/mppx.ts +14 -6
  114. package/src/middlewares/nextjs.test.ts +31 -6
  115. package/src/middlewares/nextjs.ts +28 -0
  116. package/src/proxy/Proxy.test.ts +54 -270
  117. package/src/proxy/Proxy.ts +71 -93
  118. package/src/proxy/Service.test.ts +23 -1
  119. package/src/proxy/Service.ts +40 -86
  120. package/src/proxy/internal/Headers.test.ts +1 -1
  121. package/src/proxy/internal/Route.test.ts +9 -1
  122. package/src/proxy/internal/Route.ts +1 -1
  123. package/src/proxy/services/anthropic.test.ts +132 -0
  124. package/src/proxy/services/anthropic.ts +5 -0
  125. package/src/proxy/services/openai.test.ts +1 -1
  126. package/src/proxy/services/openai.ts +6 -4
  127. package/src/proxy/services/stripe.test.ts +132 -0
  128. package/src/proxy/services/stripe.ts +6 -4
  129. package/src/server/Mppx.test-d.ts +1 -1
  130. package/src/server/Mppx.test.ts +2 -1
  131. package/src/server/NodeListener.test.ts +1 -1
  132. package/src/server/Request.test.ts +1 -1
  133. package/src/server/Response.test.ts +1 -1
  134. package/src/server/Transport.test.ts +1 -1
  135. package/src/stripe/Charge.integration.test.ts +1 -1
  136. package/src/stripe/Methods.test.ts +1 -1
  137. package/src/stripe/client/Charge.test.ts +1 -1
  138. package/src/stripe/internal/types.ts +5 -1
  139. package/src/stripe/server/Charge.test.ts +53 -2
  140. package/src/stripe/server/Charge.ts +12 -4
  141. package/src/tempo/Attribution.test.ts +1 -1
  142. package/src/tempo/Methods.test.ts +1 -1
  143. package/src/tempo/client/ChannelOps.test.ts +6 -3
  144. package/src/tempo/client/Session.test.ts +5 -2
  145. package/src/tempo/client/SessionManager.test.ts +1 -1
  146. package/src/tempo/internal/auto-swap.test.ts +1 -1
  147. package/src/tempo/internal/defaults.test.ts +1 -1
  148. package/src/tempo/internal/fee-payer.test.ts +1 -1
  149. package/src/tempo/server/Charge.test.ts +1 -1
  150. package/src/tempo/server/Session.test.ts +116 -37
  151. package/src/tempo/server/Session.ts +32 -11
  152. package/src/tempo/server/Sse.test.ts +1 -1
  153. package/src/tempo/server/internal/transport.test.ts +24 -1
  154. package/src/tempo/server/internal/transport.ts +11 -0
  155. package/src/tempo/session/Chain.test.ts +5 -2
  156. package/src/tempo/session/Chain.ts +1 -1
  157. package/src/tempo/session/Channel.test.ts +1 -1
  158. package/src/tempo/session/ChannelStore.test.ts +1 -1
  159. package/src/tempo/session/Receipt.test.ts +1 -1
  160. package/src/tempo/session/Sse.fuzz.test.ts +138 -0
  161. package/src/tempo/session/Sse.test.ts +1 -1
  162. package/src/tempo/session/Voucher.test.ts +1 -1
  163. package/src/viem/Account.test.ts +1 -1
  164. package/src/viem/Client.test.ts +1 -1
  165. package/src/zod.test.ts +147 -0
@@ -0,0 +1,224 @@
1
+ import type * as Method from '../Method.js'
2
+ import type { ServiceInfo } from './Discovery.js'
3
+
4
+ export type DiscoveryHandler = ((...args: any[]) => unknown) & {
5
+ _internal?: {
6
+ _canonicalRequest: Record<string, unknown>
7
+ intent: string
8
+ name: string
9
+ }
10
+ }
11
+
12
+ export type LegacyRouteConfig = {
13
+ intent: string
14
+ method: string
15
+ options: Record<string, unknown>
16
+ path: string
17
+ requestBody?: Record<string, unknown>
18
+ summary?: string
19
+ }
20
+
21
+ export type HandlerRouteConfig = {
22
+ handler: DiscoveryHandler
23
+ method: string
24
+ path: string
25
+ requestBody?: Record<string, unknown>
26
+ summary?: string
27
+ }
28
+
29
+ export type RouteConfig = HandlerRouteConfig | LegacyRouteConfig
30
+
31
+ export type GenerateConfig = {
32
+ info?: { title?: string; version?: string } | undefined
33
+ routes: RouteConfig[]
34
+ serviceInfo?: ServiceInfo | undefined
35
+ }
36
+
37
+ export type GenerateProxyConfig = {
38
+ basePath?: string | undefined
39
+ info?: { title?: string; version?: string } | undefined
40
+ routes: Array<{
41
+ method: string
42
+ path: string
43
+ payment: Record<string, unknown> | null
44
+ requestBody?: Record<string, unknown>
45
+ summary?: string
46
+ }>
47
+ serviceInfo?: ServiceInfo | undefined
48
+ }
49
+
50
+ type ResolvedRoute = {
51
+ method: string
52
+ path: string
53
+ payment: Record<string, unknown> | null
54
+ requestBody?: Record<string, unknown>
55
+ summary?: string
56
+ }
57
+
58
+ /**
59
+ * Generates an OpenAPI 3.1.0 discovery document from an mppx instance
60
+ * and route configuration.
61
+ */
62
+ export function generate(
63
+ mppx: { methods: readonly Method.AnyServer[]; realm: string },
64
+ config: GenerateConfig,
65
+ ): Record<string, unknown> {
66
+ const methods = mppx.methods
67
+ const methodsByKey = new Map<string, Method.AnyServer>()
68
+ const intentCount: Record<string, number> = {}
69
+
70
+ for (const mi of methods) {
71
+ methodsByKey.set(`${mi.name}/${mi.intent}`, mi)
72
+ intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
73
+ }
74
+ for (const mi of methods) {
75
+ if (intentCount[mi.intent] === 1) methodsByKey.set(mi.intent, mi)
76
+ }
77
+
78
+ const routes = config.routes.map((route) => resolveRoute(route, methodsByKey))
79
+ return createDocument({
80
+ info: {
81
+ title: config.info?.title ?? mppx.realm,
82
+ version: config.info?.version ?? '1.0.0',
83
+ },
84
+ routes,
85
+ serviceInfo: config.serviceInfo,
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Generates an OpenAPI 3.1.0 discovery document for a proxy surface.
91
+ */
92
+ export function generateProxy(config: GenerateProxyConfig): Record<string, unknown> {
93
+ const routes = config.routes.map((route) => ({
94
+ ...route,
95
+ path: withBasePath(config.basePath, route.path),
96
+ }))
97
+
98
+ return createDocument({
99
+ info: {
100
+ title: config.info?.title ?? 'API Proxy',
101
+ version: config.info?.version ?? '1.0.0',
102
+ },
103
+ routes,
104
+ serviceInfo: config.serviceInfo,
105
+ })
106
+ }
107
+
108
+ function createDocument(config: {
109
+ info: { title: string; version: string }
110
+ routes: ResolvedRoute[]
111
+ serviceInfo?: ServiceInfo | undefined
112
+ }) {
113
+ const paths: Record<string, Record<string, unknown>> = {}
114
+
115
+ for (const route of config.routes) {
116
+ const method = route.method.toLowerCase()
117
+ const operation: Record<string, unknown> = {
118
+ responses: {
119
+ ...(route.payment ? { '402': { description: 'Payment Required' } } : {}),
120
+ '200': { description: 'Successful response' },
121
+ },
122
+ }
123
+
124
+ if (route.payment) operation['x-payment-info'] = route.payment
125
+ if (route.summary) operation.summary = route.summary
126
+ if (route.requestBody) operation.requestBody = route.requestBody
127
+
128
+ if (!paths[route.path]) paths[route.path] = {}
129
+ paths[route.path]![method] = operation
130
+ }
131
+
132
+ const doc: Record<string, unknown> = {
133
+ info: config.info,
134
+ openapi: '3.1.0',
135
+ paths,
136
+ }
137
+ if (config.serviceInfo) doc['x-service-info'] = config.serviceInfo
138
+ return doc
139
+ }
140
+
141
+ function resolveRoute(
142
+ route: RouteConfig,
143
+ methodsByKey: Map<string, Method.AnyServer>,
144
+ ): ResolvedRoute {
145
+ if ('handler' in route) {
146
+ const internal = route.handler._internal
147
+ if (!internal)
148
+ throw new Error(
149
+ `Route ${route.method.toUpperCase()} ${route.path} is missing discovery metadata`,
150
+ )
151
+ return {
152
+ method: route.method,
153
+ path: route.path,
154
+ payment: paymentInfoFromCanonical({
155
+ canonicalRequest: internal._canonicalRequest,
156
+ intent: internal.intent,
157
+ method: internal.name,
158
+ }),
159
+ ...(route.requestBody ? { requestBody: route.requestBody } : {}),
160
+ ...(route.summary ? { summary: route.summary } : {}),
161
+ }
162
+ }
163
+
164
+ const mi = methodsByKey.get(route.intent)
165
+ if (!mi) {
166
+ throw new Error(
167
+ `Unknown intent "${route.intent}" for route ${route.method.toUpperCase()} ${route.path}. Available: ${[...methodsByKey.keys()].join(', ')}`,
168
+ )
169
+ }
170
+
171
+ return {
172
+ method: route.method,
173
+ path: route.path,
174
+ payment: paymentInfoFromCanonical({
175
+ canonicalRequest: route.options,
176
+ intent: mi.intent,
177
+ method: mi.name,
178
+ }),
179
+ ...(route.requestBody ? { requestBody: route.requestBody } : {}),
180
+ ...(route.summary ? { summary: route.summary } : {}),
181
+ }
182
+ }
183
+
184
+ function paymentInfoFromCanonical(route: {
185
+ canonicalRequest: Record<string, unknown>
186
+ intent: string
187
+ method: string
188
+ }) {
189
+ const { canonicalRequest, intent, method } = route
190
+ const methodDetails = (canonicalRequest.methodDetails ?? {}) as Record<string, unknown>
191
+
192
+ const amount = pickString(canonicalRequest.amount) ?? pickString(methodDetails.amount) ?? null
193
+ const currency = pickString(canonicalRequest.currency) ?? pickString(methodDetails.currency)
194
+ const description = pickString(canonicalRequest.description)
195
+
196
+ const base: Record<string, unknown> = {
197
+ amount,
198
+ ...(currency ? { currency } : {}),
199
+ ...(description ? { description } : {}),
200
+ intent,
201
+ method,
202
+ }
203
+
204
+ // Forward any extra canonical params that aren't already covered.
205
+ const reserved = new Set(['amount', 'currency', 'description', 'methodDetails'])
206
+ for (const [key, value] of Object.entries(canonicalRequest)) {
207
+ if (!reserved.has(key) && value !== undefined) base[key] = value
208
+ }
209
+
210
+ return base
211
+ }
212
+
213
+ function pickString(value: unknown) {
214
+ return typeof value === 'string' ? value : undefined
215
+ }
216
+
217
+ function withBasePath(basePath: string | undefined, path: string) {
218
+ if (!basePath) return path
219
+ const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`
220
+ const trimmedBasePath = normalizedBasePath.endsWith('/')
221
+ ? normalizedBasePath.slice(0, -1)
222
+ : normalizedBasePath
223
+ return `${trimmedBasePath}${path}`
224
+ }
@@ -0,0 +1,188 @@
1
+ import { validate } from './Validate.js'
2
+
3
+ function makeDoc(overrides: Record<string, unknown> = {}) {
4
+ return {
5
+ info: { title: 'Test', version: '1.0.0' },
6
+ openapi: '3.1.0',
7
+ paths: {
8
+ '/search': {
9
+ post: {
10
+ 'x-payment-info': {
11
+ amount: '100',
12
+ intent: 'charge',
13
+ method: 'tempo',
14
+ },
15
+ requestBody: {
16
+ content: { 'application/json': { schema: { type: 'object' } } },
17
+ },
18
+ responses: {
19
+ '200': { description: 'OK' },
20
+ '402': { description: 'Payment Required' },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ describe('validate', () => {
30
+ test('returns no errors for a valid document', () => {
31
+ const errors = validate(makeDoc())
32
+ expect(errors.filter((error) => error.severity === 'error')).toHaveLength(0)
33
+ })
34
+
35
+ test('returns error for missing 402 response', () => {
36
+ const errors = validate(
37
+ makeDoc({
38
+ paths: {
39
+ '/search': {
40
+ post: {
41
+ 'x-payment-info': {
42
+ amount: '100',
43
+ intent: 'charge',
44
+ method: 'tempo',
45
+ },
46
+ requestBody: {},
47
+ responses: {
48
+ '200': { description: 'OK' },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ }),
54
+ )
55
+
56
+ expect(errors.find((error) => error.severity === 'error')?.message).toContain('402')
57
+ })
58
+
59
+ test('returns warning for missing requestBody', () => {
60
+ const errors = validate(
61
+ makeDoc({
62
+ paths: {
63
+ '/search': {
64
+ post: {
65
+ 'x-payment-info': {
66
+ amount: '100',
67
+ intent: 'charge',
68
+ method: 'tempo',
69
+ },
70
+ responses: {
71
+ '200': { description: 'OK' },
72
+ '402': { description: 'Payment Required' },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ }),
78
+ )
79
+
80
+ expect(errors.find((error) => error.severity === 'warning')?.message).toContain('requestBody')
81
+ })
82
+
83
+ test('returns structural errors for invalid top-level document', () => {
84
+ const errors = validate({ openapi: '3.1.0' })
85
+ expect(errors.length).toBeGreaterThan(0)
86
+ expect(errors[0]!.severity).toBe('error')
87
+ })
88
+
89
+ test('returns errors for invalid extension values', () => {
90
+ const errors = validate(
91
+ makeDoc({
92
+ 'x-service-info': {
93
+ docs: { homepage: 'not-a-uri' },
94
+ },
95
+ paths: {
96
+ '/search': {
97
+ post: {
98
+ 'x-payment-info': {
99
+ amount: '01',
100
+ intent: 'subscribe',
101
+ method: 'tempo',
102
+ },
103
+ responses: {
104
+ '402': { description: 'Payment Required' },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ }),
110
+ )
111
+
112
+ expect(errors.some((error) => error.severity === 'error')).toBe(true)
113
+ })
114
+
115
+ test('ignores path-item-level fields like summary and parameters', () => {
116
+ const errors = validate(
117
+ makeDoc({
118
+ paths: {
119
+ '/search': {
120
+ summary: 'Search endpoints',
121
+ parameters: [{ name: 'q', in: 'query' }],
122
+ 'x-custom': 'value',
123
+ post: {
124
+ 'x-payment-info': {
125
+ amount: '100',
126
+ intent: 'charge',
127
+ method: 'tempo',
128
+ },
129
+ requestBody: {
130
+ content: { 'application/json': { schema: { type: 'object' } } },
131
+ },
132
+ responses: {
133
+ '200': { description: 'OK' },
134
+ '402': { description: 'Payment Required' },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ }),
140
+ )
141
+
142
+ expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0)
143
+ })
144
+
145
+ test('validates proxy-generated docs with relative llms path', () => {
146
+ const errors = validate({
147
+ info: { title: 'API Proxy', version: '1.0.0' },
148
+ openapi: '3.1.0',
149
+ paths: {},
150
+ 'x-service-info': {
151
+ categories: ['gateway'],
152
+ docs: {
153
+ apiReference: 'https://example.com/api',
154
+ homepage: 'https://example.com',
155
+ llms: '/llms.txt',
156
+ },
157
+ },
158
+ })
159
+
160
+ expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0)
161
+ })
162
+
163
+ test('accepts x-payment-info with unknown fields', () => {
164
+ const errors = validate({
165
+ info: { title: 'Test', version: '1.0.0' },
166
+ openapi: '3.1.0',
167
+ paths: {
168
+ '/api/call': {
169
+ post: {
170
+ 'x-payment-info': {
171
+ price: '0.54',
172
+ pricingMode: 'fixed',
173
+ protocols: ['x402', 'mpp'],
174
+ },
175
+ requestBody: {
176
+ content: { 'application/json': { schema: { type: 'object' } } },
177
+ },
178
+ responses: {
179
+ '200': { description: 'OK' },
180
+ '402': { description: 'Payment Required' },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ })
186
+ expect(errors.filter((e) => e.severity === 'error')).toHaveLength(0)
187
+ })
188
+ })
@@ -0,0 +1,76 @@
1
+ import { DiscoveryDocument, PaymentInfo } from './Discovery.js'
2
+
3
+ export type ValidationError = {
4
+ message: string
5
+ path: string
6
+ severity: 'error' | 'warning'
7
+ }
8
+
9
+ /**
10
+ * Validates a discovery document structurally and semantically.
11
+ */
12
+ export function validate(doc: unknown): ValidationError[] {
13
+ const errors: ValidationError[] = []
14
+
15
+ const result = DiscoveryDocument.safeParse(doc)
16
+ if (!result.success) {
17
+ for (const issue of result.error.issues) {
18
+ errors.push({
19
+ message: issue.message,
20
+ path: issue.path.map(String).join('.') || '(root)',
21
+ severity: 'error',
22
+ })
23
+ }
24
+ return errors
25
+ }
26
+
27
+ const parsed = result.data
28
+ const paths = parsed.paths
29
+ if (!paths) return errors
30
+
31
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
32
+ for (const [method, operation] of Object.entries(pathItem as Record<string, unknown>)) {
33
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) continue
34
+ const op = operation as Record<string, unknown>
35
+
36
+ const opPath = `paths.${pathKey}.${method}`
37
+ const rawPaymentInfo = op['x-payment-info']
38
+ if (!rawPaymentInfo) continue
39
+
40
+ const paymentResult = PaymentInfo.safeParse(rawPaymentInfo)
41
+ if (!paymentResult.success) {
42
+ for (const issue of paymentResult.error.issues) {
43
+ errors.push({
44
+ message: issue.message,
45
+ path: `${opPath}.x-payment-info.${issue.path.map(String).join('.')}`,
46
+ severity: 'error',
47
+ })
48
+ }
49
+ continue
50
+ }
51
+
52
+ const responses = op.responses as Record<string, unknown> | undefined
53
+ if (!responses || !('402' in responses)) {
54
+ errors.push({
55
+ message: 'Operation with x-payment-info MUST have a 402 response',
56
+ path: `${opPath}.responses`,
57
+ severity: 'error',
58
+ })
59
+ }
60
+
61
+ const methodUpper = method.toUpperCase()
62
+ if (
63
+ !op.requestBody &&
64
+ (methodUpper === 'POST' || methodUpper === 'PUT' || methodUpper === 'PATCH')
65
+ ) {
66
+ errors.push({
67
+ message: 'Operation with x-payment-info has no requestBody',
68
+ path: opPath,
69
+ severity: 'warning',
70
+ })
71
+ }
72
+ }
73
+ }
74
+
75
+ return errors
76
+ }
@@ -0,0 +1,3 @@
1
+ export * from './Discovery.js'
2
+ export * from './OpenApi.js'
3
+ export * from './Validate.js'
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
2
 
3
3
  import { constantTimeEqual } from './constantTimeEqual.js'
4
4
 
@@ -1,7 +1,7 @@
1
1
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
2
  import { tempo } from 'mppx/client'
3
3
  import type { Account } from 'viem'
4
- import { describe, expectTypeOf, test } from 'vitest'
4
+ import { describe, expectTypeOf, test } from 'vp/test'
5
5
 
6
6
  import * as McpClient from './McpClient.js'
7
7
 
@@ -6,7 +6,7 @@ import { Challenge, Mcp as core_Mcp } from 'mppx'
6
6
  import { tempo as tempo_client } from 'mppx/client'
7
7
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
8
8
  import { createClient } from 'viem'
9
- import { afterEach, beforeEach, describe, expect, test } from 'vitest'
9
+ import { afterEach, beforeEach, describe, expect, test } from 'vp/test'
10
10
  import { accounts, asset, chain, http, client as testClient } from '~test/tempo/viem.js'
11
11
 
12
12
  import * as McpServer_transport from '../server/Transport.js'
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test } from 'vp/test'
2
2
 
3
3
  import type { Challenge } from '../../Challenge.js'
4
4
  import type { Credential } from '../../Credential.js'
@@ -3,9 +3,9 @@ import * as http from 'node:http'
3
3
  import { Elysia } from 'elysia'
4
4
  import { Receipt } from 'mppx'
5
5
  import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
6
- import { Mppx } from 'mppx/elysia'
6
+ import { Mppx, discovery } from 'mppx/elysia'
7
7
  import { tempo as tempo_server } from 'mppx/server'
8
- import { describe, expect, test } from 'vitest'
8
+ import { describe, expect, test } from 'vp/test'
9
9
  import { accounts, asset, client } from '~test/tempo/viem.js'
10
10
 
11
11
  function createServer(app: Elysia<any, any, any, any, any, any, any>) {
@@ -87,4 +87,29 @@ describe('charge', () => {
87
87
 
88
88
  server.close()
89
89
  })
90
+
91
+ test('serves /openapi.json from discovery plugin', async () => {
92
+ const app = new Elysia().use(
93
+ discovery(mppx, {
94
+ info: { title: 'Elysia API', version: '1.0.0' },
95
+ routes: [{ handler: mppx.charge({ amount: '1' }), method: 'get', path: '/' }],
96
+ }),
97
+ )
98
+
99
+ const server = await createServer(app)
100
+ const response = await globalThis.fetch(`${server.url}/openapi.json`)
101
+ expect(response.status).toBe(200)
102
+ expect(response.headers.get('cache-control')).toBe('public, max-age=300')
103
+
104
+ const body = (await response.json()) as Record<string, any>
105
+ expect(body.info).toEqual({ title: 'Elysia API', version: '1.0.0' })
106
+ expect(body.paths['/'].get['x-payment-info']).toMatchObject({
107
+ amount: '1000000',
108
+ currency: asset,
109
+ intent: 'charge',
110
+ method: 'tempo',
111
+ })
112
+
113
+ server.close()
114
+ })
90
115
  })
@@ -1,5 +1,6 @@
1
- import type { Context } from 'elysia'
1
+ import { Elysia, type Context } from 'elysia'
2
2
 
3
+ import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js'
3
4
  import * as Mppx_core from '../server/Mppx.js'
4
5
  import * as Mppx_internal from './internal/mppx.js'
5
6
 
@@ -68,3 +69,36 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
68
69
  if (header) set.headers['Payment-Receipt'] = header
69
70
  }
70
71
  }
72
+
73
+ export type DiscoveryConfig = Omit<GenerateConfig, 'routes'> & {
74
+ path?: string
75
+ routes?: RouteConfig[]
76
+ }
77
+
78
+ const discoveryHeaders = { 'Cache-Control': 'public, max-age=300' }
79
+
80
+ /**
81
+ * Returns an Elysia plugin that serves an OpenAPI discovery document.
82
+ */
83
+ export function discovery(
84
+ mppx: { methods: readonly Mppx_internal.AnyServer[]; realm: string },
85
+ config: DiscoveryConfig = {},
86
+ ) {
87
+ const mountPath = config.path ?? '/openapi.json'
88
+
89
+ const cached = JSON.stringify(
90
+ generate(mppx, {
91
+ ...(config.info ? { info: config.info } : {}),
92
+ routes: config.routes ?? [],
93
+ ...(config.serviceInfo ? { serviceInfo: config.serviceInfo } : {}),
94
+ }),
95
+ )
96
+
97
+ return new Elysia().get(
98
+ mountPath,
99
+ () =>
100
+ new Response(cached, {
101
+ headers: { ...discoveryHeaders, 'Content-Type': 'application/json' },
102
+ }),
103
+ )
104
+ }