Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
48020ed
feat: add openapi-first discovery tooling
brendanjryan Mar 24, 2026
441fa2b
test: use inline snapshots as golden output for OpenApi.generate
brendanjryan Mar 25, 2026
b29aff0
feat: accept custom intents and forward extra params in x-payment-info
brendanjryan Mar 25, 2026
729d00c
refactor: remove back-compat shims and make schemas extension-permissive
brendanjryan Mar 25, 2026
4b79a7c
perf: generate discovery documents once at startup, serve cached JSON
brendanjryan Mar 25, 2026
6d08b04
refactor: cleanup pass — passthrough payments, collapse docsLlmsUrl, …
brendanjryan Mar 25, 2026
32a3ee3
fix: allow relative doc URIs and model path items with explicit HTTP …
brendanjryan Mar 25, 2026
c0f70d1
refactor: simplify validator to skip non-object path-item entries
brendanjryan Mar 25, 2026
b714bd5
style: fix oxfmt formatting
brendanjryan Mar 25, 2026
ca62ddb
fix: align Wrap _internal type with DiscoveryHandler for exactOptiona…
brendanjryan Mar 25, 2026
cbf79f5
fix: exit code 1 for missing file in discover validate
brendanjryan Mar 25, 2026
4001f17
fix: use global test imports instead of vitest
brendanjryan Mar 25, 2026
5fb82e0
feat(cli): add `mppx discover generate` command
brendanjryan Mar 25, 2026
3982335
test(cli): add discover generate tests
brendanjryan Mar 25, 2026
c5ed3fa
style: format cli files
brendanjryan Mar 25, 2026
b3b5f68
fix: remove vitest import from Discovery.test.ts
brendanjryan Mar 25, 2026
c7948b6
fix: review feedback — paymentOf null, basePath boundary, validate gu…
brendanjryan Mar 25, 2026
e264ba5
style: fix formatting in cli.test.ts and Route.test.ts
brendanjryan Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/discovery-openapi-consolidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mppx': minor
---

Add OpenAPI-first discovery tooling via `mppx/discovery`, framework `discovery()` helpers, and `mppx discover validate`.

This also changes `mppx/proxy` discovery routes:

- `GET /openapi.json` is now the canonical machine-readable discovery document.
- `GET /llms.txt` remains available as the text-friendly discovery view.
- Legacy `/discover*` routes now return `410 Gone`.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Canonical specs live at [tempoxyz/payment-auth-spec](https://github.com/tempoxyz
| **Intent** | [draft-payment-intent-session-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/intents/draft-payment-intent-session-00.md) | Pay-as-you-go streaming payments |
| **Method** | [draft-tempo-charge-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/methods/tempo/draft-tempo-charge-00.md) | TIP-20 token transfers on Tempo |
| **Method** | [draft-tempo-session-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/methods/tempo/draft-tempo-session-00.md) | Tempo payment channels for streaming |
| **Extension** | [draft-payment-discovery-00](https://github.com/tempoxyz/payment-auth-spec/blob/main/specs/extensions/draft-payment-discovery-00.md) | `/.well-known/payment` discovery |
| **Extension** | [draft-payment-discovery-00](https://paymentauth.org/draft-payment-discovery-00.html) | OpenAPI-first discovery via `/openapi.json` |

### Key Protocol Details

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@
"src": "./src/client/index.ts",
"default": "./dist/client/index.js"
},
"./discovery": {
"types": "./dist/discovery/index.d.ts",
"src": "./src/discovery/index.ts",
"default": "./dist/discovery/index.js"
},
"./mcp-sdk/client": {
"types": "./dist/mcp-sdk/client/index.d.ts",
"src": "./src/mcp-sdk/client/index.ts",
Expand Down
211 changes: 211 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,217 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
return { output, stderr, exitCode }
}

describe('discover validate', () => {
test('validates a local discovery document', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-'))
const file = path.join(dir, 'openapi.json')
fs.writeFileSync(
file,
JSON.stringify({
info: { title: 'Test', version: '1.0.0' },
openapi: '3.1.0',
paths: {
'/search': {
post: {
'x-payment-info': {
amount: '100',
intent: 'charge',
method: 'tempo',
},
requestBody: {
content: { 'application/json': { schema: { type: 'object' } } },
},
responses: {
'200': { description: 'OK' },
'402': { description: 'Payment Required' },
},
},
},
},
}),
)

const { output, exitCode } = await serve(['discover', 'validate', file])
expect(exitCode).toBeUndefined()
expect(output).toContain('Discovery document is valid.')
})

test('returns non-zero for invalid discovery documents', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-'))
const file = path.join(dir, 'openapi.json')
fs.writeFileSync(
file,
JSON.stringify({
info: { title: 'Test', version: '1.0.0' },
openapi: '3.1.0',
paths: {
'/search': {
post: {
'x-payment-info': {
amount: '100',
intent: 'charge',
method: 'tempo',
},
responses: {
'200': { description: 'OK' },
},
},
},
},
}),
)

const { output, exitCode } = await serve(['discover', 'validate', file])
expect(exitCode).toBe(1)
expect(output).toContain('[error]')
expect(output).toContain('402')
})

test(
'validates remote discovery documents and reports warnings',
{ timeout: 20_000 },
async () => {
const body = JSON.stringify({
info: { title: 'Test', version: '1.0.0' },
openapi: '3.1.0',
paths: {
'/search': {
post: {
'x-payment-info': {
amount: '100',
intent: 'charge',
method: 'tempo',
},
responses: {
'200': { description: 'OK' },
'402': { description: 'Payment Required' },
},
},
},
},
})
const server = await Http.createServer((_req, res) => {
res.setHeader('Content-Type', 'application/json')
res.end(body)
})

try {
const { output, exitCode } = await serve(['discover', 'validate', server.url])
expect(exitCode).toBeUndefined()
expect(output).toContain('[warning]')
expect(output).toContain('requestBody')
expect(output).toContain('valid with 1 warning')
} finally {
server.close()
}
},
)

test(
'rejects oversized discovery documents via content-length',
{ timeout: 20_000 },
async () => {
const server = await Http.createServer((_req, res) => {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Length', String(11 * 1024 * 1024))
res.end('{}')
})

try {
const { exitCode, output } = await serve(['discover', 'validate', server.url])
expect(exitCode).toBe(1)
expect(output).toContain('10 MB')
} finally {
server.close()
}
},
)
})

describe('discover generate', () => {
test('generates from a pre-built OpenAPI document module', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
const mod = path.join(dir, 'doc.mjs')
fs.writeFileSync(
mod,
`export default ${JSON.stringify({
openapi: '3.1.0',
info: { title: 'Test', version: '1.0.0' },
paths: {
'/pay': {
post: {
'x-payment-info': { amount: '100', intent: 'charge', method: 'tempo' },
responses: {
'200': { description: 'OK' },
'402': { description: 'Payment Required' },
},
},
},
},
})}`,
)

const { output, exitCode } = await serve(['discover', 'generate', mod])
expect(exitCode).toBeUndefined()
const doc = JSON.parse(output)
expect(doc.openapi).toBe('3.1.0')
expect(doc.paths['/pay'].post['x-payment-info'].amount).toBe('100')

fs.rmSync(dir, { recursive: true, force: true })
})

test('writes to file with --output', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
const mod = path.join(dir, 'doc.mjs')
const outFile = path.join(dir, 'openapi.json')
fs.writeFileSync(
mod,
`export default ${JSON.stringify({
openapi: '3.1.0',
info: { title: 'Test', version: '1.0.0' },
paths: {},
})}`,
)

const { output, stderr, exitCode } = await serve([
'discover',
'generate',
mod,
'--output',
outFile,
])
expect(exitCode).toBeUndefined()
expect(output).toBe('')
expect(stderr).toContain(outFile)
const written = JSON.parse(fs.readFileSync(outFile, 'utf-8'))
expect(written.openapi).toBe('3.1.0')

fs.rmSync(dir, { recursive: true, force: true })
})

test('errors when module not found', async () => {
const { output, exitCode } = await serve([
'discover',
'generate',
'/tmp/nonexistent-mppx-module.mjs',
])
expect(exitCode).toBe(1)
expect(output).toContain('MODULE_NOT_FOUND')
})

test('errors when module has no mppx or openapi export', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
const mod = path.join(dir, 'bad.mjs')
fs.writeFileSync(mod, 'export default { foo: "bar" }')

const { output, exitCode } = await serve(['discover', 'generate', mod])
expect(exitCode).toBe(1)
expect(output).toContain('INVALID_MODULE')

fs.rmSync(dir, { recursive: true, force: true })
})
})

describe('basic charge (examples/basic)', () => {
test('happy path: makes payment and receives response', { timeout: 120_000 }, async () => {
const { Actions } = await import('viem/tempo')
Expand Down
Loading
Loading