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.
- package/CHANGELOG.md +25 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +155 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/discovery/Discovery.d.ts +146 -0
- package/dist/discovery/Discovery.d.ts.map +1 -0
- package/dist/discovery/Discovery.js +60 -0
- package/dist/discovery/Discovery.js.map +1 -0
- package/dist/discovery/OpenApi.d.ts +61 -0
- package/dist/discovery/OpenApi.d.ts.map +1 -0
- package/dist/discovery/OpenApi.js +139 -0
- package/dist/discovery/OpenApi.js.map +1 -0
- package/dist/discovery/Validate.d.ts +10 -0
- package/dist/discovery/Validate.d.ts.map +1 -0
- package/dist/discovery/Validate.js +63 -0
- package/dist/discovery/Validate.js.map +1 -0
- package/dist/discovery/index.d.ts +4 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +4 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/middlewares/elysia.d.ts +52 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +17 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts +13 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +18 -0
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts +19 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +51 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +4 -2
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +10 -3
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts +11 -0
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +15 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts +6 -0
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +56 -80
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts +16 -23
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +19 -83
- package/dist/proxy/Service.js.map +1 -1
- package/dist/proxy/internal/Route.js +1 -1
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/proxy/services/anthropic.d.ts.map +1 -1
- package/dist/proxy/services/anthropic.js +5 -0
- package/dist/proxy/services/anthropic.js.map +1 -1
- package/dist/proxy/services/openai.d.ts.map +1 -1
- package/dist/proxy/services/openai.js +6 -3
- package/dist/proxy/services/openai.js.map +1 -1
- package/dist/proxy/services/stripe.d.ts.map +1 -1
- package/dist/proxy/services/stripe.js +6 -3
- package/dist/proxy/services/stripe.js.map +1 -1
- package/dist/stripe/internal/types.d.ts +3 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +9 -2
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +25 -8
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +8 -0
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.js +1 -1
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +6 -1
- package/src/BodyDigest.test.ts +1 -1
- package/src/Challenge.fuzz.test.ts +121 -0
- package/src/Challenge.test-d.ts +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Credential.fuzz.test.ts +62 -0
- package/src/Credential.test.ts +1 -1
- package/src/Errors.test.ts +1 -1
- package/src/Expires.test.ts +1 -1
- package/src/Method.test.ts +1 -1
- package/src/PaymentRequest.test.ts +1 -1
- package/src/Receipt.test.ts +1 -1
- package/src/Store.test-d.ts +1 -1
- package/src/Store.test.ts +1 -1
- package/src/cli/cli.test.ts +212 -1
- package/src/cli/cli.ts +162 -0
- package/src/client/Mppx.test-d.ts +1 -1
- package/src/client/Mppx.test.ts +1 -1
- package/src/client/Transport.test.ts +1 -1
- package/src/client/internal/Fetch.browser.test.ts +1 -1
- package/src/client/internal/Fetch.test-d.ts +1 -1
- package/src/client/internal/Fetch.test.ts +2 -1
- package/src/discovery/Discovery.test.ts +152 -0
- package/src/discovery/Discovery.ts +72 -0
- package/src/discovery/OpenApi.test.ts +425 -0
- package/src/discovery/OpenApi.ts +224 -0
- package/src/discovery/Validate.test.ts +188 -0
- package/src/discovery/Validate.ts +76 -0
- package/src/discovery/index.ts +3 -0
- package/src/internal/constantTimeEqual.test.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test-d.ts +1 -1
- package/src/mcp-sdk/client/McpClient.test.ts +1 -1
- package/src/mcp-sdk/server/Transport.test.ts +1 -1
- package/src/middlewares/elysia.test.ts +27 -2
- package/src/middlewares/elysia.ts +35 -1
- package/src/middlewares/express.test.ts +35 -7
- package/src/middlewares/express.ts +34 -0
- package/src/middlewares/hono.test.ts +28 -6
- package/src/middlewares/hono.ts +73 -1
- package/src/middlewares/internal/mppx.test.ts +1 -1
- package/src/middlewares/internal/mppx.ts +14 -6
- package/src/middlewares/nextjs.test.ts +31 -6
- package/src/middlewares/nextjs.ts +28 -0
- package/src/proxy/Proxy.test.ts +54 -270
- package/src/proxy/Proxy.ts +71 -93
- package/src/proxy/Service.test.ts +23 -1
- package/src/proxy/Service.ts +40 -86
- package/src/proxy/internal/Headers.test.ts +1 -1
- package/src/proxy/internal/Route.test.ts +9 -1
- package/src/proxy/internal/Route.ts +1 -1
- package/src/proxy/services/anthropic.test.ts +132 -0
- package/src/proxy/services/anthropic.ts +5 -0
- package/src/proxy/services/openai.test.ts +1 -1
- package/src/proxy/services/openai.ts +6 -4
- package/src/proxy/services/stripe.test.ts +132 -0
- package/src/proxy/services/stripe.ts +6 -4
- package/src/server/Mppx.test-d.ts +1 -1
- package/src/server/Mppx.test.ts +2 -1
- package/src/server/NodeListener.test.ts +1 -1
- package/src/server/Request.test.ts +1 -1
- package/src/server/Response.test.ts +1 -1
- package/src/server/Transport.test.ts +1 -1
- package/src/stripe/Charge.integration.test.ts +1 -1
- package/src/stripe/Methods.test.ts +1 -1
- package/src/stripe/client/Charge.test.ts +1 -1
- package/src/stripe/internal/types.ts +5 -1
- package/src/stripe/server/Charge.test.ts +53 -2
- package/src/stripe/server/Charge.ts +12 -4
- package/src/tempo/Attribution.test.ts +1 -1
- package/src/tempo/Methods.test.ts +1 -1
- package/src/tempo/client/ChannelOps.test.ts +6 -3
- package/src/tempo/client/Session.test.ts +5 -2
- package/src/tempo/client/SessionManager.test.ts +1 -1
- package/src/tempo/internal/auto-swap.test.ts +1 -1
- package/src/tempo/internal/defaults.test.ts +1 -1
- package/src/tempo/internal/fee-payer.test.ts +1 -1
- package/src/tempo/server/Charge.test.ts +1 -1
- package/src/tempo/server/Session.test.ts +116 -37
- package/src/tempo/server/Session.ts +32 -11
- package/src/tempo/server/Sse.test.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +24 -1
- package/src/tempo/server/internal/transport.ts +11 -0
- package/src/tempo/session/Chain.test.ts +5 -2
- package/src/tempo/session/Chain.ts +1 -1
- package/src/tempo/session/Channel.test.ts +1 -1
- package/src/tempo/session/ChannelStore.test.ts +1 -1
- package/src/tempo/session/Receipt.test.ts +1 -1
- package/src/tempo/session/Sse.fuzz.test.ts +138 -0
- package/src/tempo/session/Sse.test.ts +1 -1
- package/src/tempo/session/Voucher.test.ts +1 -1
- package/src/viem/Account.test.ts +1 -1
- package/src/viem/Client.test.ts +1 -1
- 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
|
+
}
|
|
@@ -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 '
|
|
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 '
|
|
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'
|
|
@@ -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 '
|
|
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
|
|
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
|
+
}
|