Skip to content

Commit c6a4a6f

Browse files
committed
Parse marketplace skill catalogs
1 parent b7ce88c commit c6a4a6f

5 files changed

Lines changed: 113 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
2020

2121
## What It Checks
2222

23-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, string-valued endpoint maps, `tools` maps, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
23+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, marketplace `skills[]` / `catalog.skills[]`, object-valued `endpoints`, string-valued endpoint maps, `tools` maps, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
2424
- Streamable HTTP MCP tool catalogs via safe JSON-RPC `tools/list` probes with `Accept: application/json, text/event-stream`
2525
- Object-valued manifest endpoint query examples, public catalog/discovery GETs, and payment-bearing two-phase operations without treating expected public catalog reads as failed payment gates
2626
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, string `discovery` links, nested `discovery.x402_json` / OpenAPI links, or manifest-level OpenAPI links

bin/x402-surface-check.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,24 @@ function manifestEndpointBody(endpoint, document) {
358358
return exampleValue(body, document)
359359
}
360360

361+
function marketplaceSkillBody(skill) {
362+
return skill?.example?.input
363+
?? skill?.exampleInput
364+
?? skill?.input?.example
365+
?? skill?.input?.safe_example
366+
?? skill?.input?.safeExample
367+
?? manifestEndpointBody(skill, {})
368+
}
369+
370+
function marketplaceSkillPriceUsd(skill) {
371+
const value = skill?.price?.amount
372+
?? skill?.price?.usd
373+
?? skill?.priceUsd
374+
?? skill?.price_usd
375+
?? skill?.pricePerCall
376+
return numberFromDecimal(value)
377+
}
378+
361379
function manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl) {
362380
const url = new URL(endpointUrl(rawPath, baseUrl, sourceUrl))
363381
const parameters = endpoint?.parameters
@@ -404,6 +422,29 @@ function endpointEntries(document, sourceUrl, limit) {
404422
}
405423
}
406424

425+
const marketplaceSkillLists = [
426+
document.skills,
427+
document.services,
428+
document.catalog?.skills,
429+
document.catalog?.services,
430+
].filter(Array.isArray)
431+
432+
for (const skillList of marketplaceSkillLists) {
433+
for (const skill of skillList) {
434+
if (!skill || typeof skill !== 'object') continue
435+
const rawPath = skill.endpoint ?? skill.url ?? skill.path
436+
if (!rawPath) continue
437+
if (redactedCredentialUrl(rawPath)) continue
438+
entries.push({
439+
name: skill.slug ?? skill.id ?? skill.name ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
440+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
441+
method: String(skill.method ?? 'POST').toUpperCase(),
442+
expectedPriceUsd: marketplaceSkillPriceUsd(skill),
443+
requestBody: marketplaceSkillBody(skill),
444+
})
445+
}
446+
}
447+
407448
if (Array.isArray(document.endpoints)) {
408449
for (const endpoint of document.endpoints) {
409450
const rawPath = endpoint?.url ?? endpoint?.endpoint ?? endpoint?.path

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-surface-check",
3-
"version": "0.2.38",
3+
"version": "0.2.39",
44
"description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
55
"type": "module",
66
"bin": {

test/smoke.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,27 @@ data: ${JSON.stringify({
455455
return
456456
}
457457

458+
if (request.url === '/skill-catalog.json') {
459+
response.setHeader('content-type', 'application/json')
460+
response.end(JSON.stringify({
461+
catalog: {
462+
version: '1.0',
463+
protocol: 'x402',
464+
skills: [{
465+
id: 'fixture-skill',
466+
name: 'Fixture Skill',
467+
endpoint: `${serverUrl}/catalog-skill`,
468+
method: 'POST',
469+
price: { amount: '0.003', currency: 'USD', asset: 'USDC' },
470+
example: {
471+
input: { url: 'https://example.com/article' },
472+
},
473+
}],
474+
},
475+
}))
476+
return
477+
}
478+
458479
if (request.url === '/string-paid' || request.url === '/string-post' || request.url === '/tool-paid') {
459480
response.statusCode = 402
460481
response.setHeader('content-type', 'application/json')
@@ -474,6 +495,40 @@ data: ${JSON.stringify({
474495
return
475496
}
476497

498+
if (request.url === '/catalog-skill') {
499+
let body = ''
500+
request.on('data', chunk => {
501+
body += chunk
502+
})
503+
request.on('end', () => {
504+
const parsed = body ? JSON.parse(body) : {}
505+
if (parsed.url !== 'https://example.com/article') {
506+
response.statusCode = 400
507+
response.setHeader('content-type', 'application/json')
508+
response.end(JSON.stringify({ error: 'missing catalog skill example' }))
509+
return
510+
}
511+
response.statusCode = 402
512+
response.setHeader('content-type', 'application/json')
513+
response.setHeader('access-control-allow-origin', '*')
514+
response.setHeader('cache-control', 'no-store')
515+
response.end(JSON.stringify({
516+
x402Version: 2,
517+
error: 'Payment required',
518+
accepts: [{
519+
scheme: 'exact',
520+
network: 'eip155:8453',
521+
amount: '3000',
522+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
523+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
524+
resource: `${serverUrl}/catalog-skill`,
525+
maxTimeoutSeconds: 60,
526+
}],
527+
}))
528+
})
529+
return
530+
}
531+
477532
if (request.url === '/registry-leak.json') {
478533
response.setHeader('content-type', 'application/json')
479534
response.end(JSON.stringify({
@@ -1383,6 +1438,19 @@ try {
13831438
assert.match(resourceManifest.stdout, /eip155:8453/)
13841439
assert.doesNotMatch(resourceManifest.stdout, /Document does not expose/)
13851440

1441+
const skillCatalog = await execFileAsync('node', [
1442+
'bin/x402-surface-check.mjs',
1443+
`${serverUrl}/skill-catalog.json`,
1444+
'--origin',
1445+
'https://example.com',
1446+
], { cwd: new URL('..', import.meta.url) })
1447+
1448+
assert.match(skillCatalog.stdout, /fixture-skill/)
1449+
assert.match(skillCatalog.stdout, /catalog-skill/)
1450+
assert.match(skillCatalog.stdout, /\$0\.003/)
1451+
assert.doesNotMatch(skillCatalog.stdout, /validation HTTP 400/)
1452+
assert.doesNotMatch(skillCatalog.stdout, /Document does not expose/)
1453+
13861454
const registryLeak = await execFileAsync('node', [
13871455
'bin/x402-surface-check.mjs',
13881456
`${serverUrl}/registry-leak.json`,

0 commit comments

Comments
 (0)