x402-surface-check 0.2.30 → 0.2.32
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/README.md +1 -0
- package/bin/x402-surface-check.mjs +110 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
19
19
|
## What It Checks
|
|
20
20
|
|
|
21
21
|
- 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
|
|
22
|
+
- Streamable HTTP MCP tool catalogs via safe JSON-RPC `tools/list` probes with `Accept: application/json, text/event-stream`
|
|
22
23
|
- 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
|
|
23
24
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, string `discovery` links, nested `discovery.x402_json` / OpenAPI links, or manifest-level OpenAPI links
|
|
24
25
|
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
|
|
@@ -343,7 +343,7 @@ function manifestEndpointPaymentSignal(endpoint) {
|
|
|
343
343
|
if (!endpoint || typeof endpoint !== 'object') return 0
|
|
344
344
|
if (Number(endpoint.phase1_response?.status) === 402) return 2
|
|
345
345
|
if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
|
|
346
|
-
if (/^\$?\d+(\.\d+)?/.test(String(endpoint.price ?? endpoint.cost ?? endpoint.amount ?? ''))) return 1
|
|
346
|
+
if (/^\$?\d+(\.\d+)?/.test(String(endpoint.price ?? endpoint.priceUsd ?? endpoint.price_usd ?? endpoint.cost ?? endpoint.amount ?? ''))) return 1
|
|
347
347
|
if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
|
|
348
348
|
if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
|
|
349
349
|
return 0
|
|
@@ -415,6 +415,27 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
415
415
|
})
|
|
416
416
|
}
|
|
417
417
|
}
|
|
418
|
+
if (Array.isArray(document.routes)) {
|
|
419
|
+
for (const route of document.routes) {
|
|
420
|
+
if (!route || typeof route !== 'object') continue
|
|
421
|
+
const exampleCall = Array.isArray(route.exampleCalls)
|
|
422
|
+
? route.exampleCalls.find(call => call?.url)
|
|
423
|
+
: undefined
|
|
424
|
+
const rawPath = exampleCall?.url ?? route.url ?? route.endpoint ?? route.path
|
|
425
|
+
if (!rawPath) continue
|
|
426
|
+
const method = String(route.method ?? exampleCall?.method ?? 'GET').toUpperCase()
|
|
427
|
+
const paymentSignal = manifestEndpointPaymentSignal(route)
|
|
428
|
+
const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
|
|
429
|
+
if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
|
|
430
|
+
entries.push({
|
|
431
|
+
name: route.id ?? route.reportType ?? route.agentName ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
|
|
432
|
+
url: manifestEndpointUrl(rawPath, route, baseUrl, sourceUrl),
|
|
433
|
+
method,
|
|
434
|
+
requestBody: manifestEndpointBody(route, document),
|
|
435
|
+
publicDiscovery: paymentSignal === 0,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
}
|
|
418
439
|
const endpointMaps = []
|
|
419
440
|
if (!Array.isArray(document.endpoints) && document.endpoints && typeof document.endpoints === 'object') {
|
|
420
441
|
endpointMaps.push(document.endpoints)
|
|
@@ -644,6 +665,68 @@ async function fetchDocument(url) {
|
|
|
644
665
|
}
|
|
645
666
|
}
|
|
646
667
|
|
|
668
|
+
function parseMcpJsonRpcPayload(body) {
|
|
669
|
+
if (body?.json?.result?.tools && Array.isArray(body.json.result.tools)) return body.json
|
|
670
|
+
|
|
671
|
+
const events = String(body?.text ?? '')
|
|
672
|
+
.split(/\r?\n/)
|
|
673
|
+
.map(line => line.match(/^data:\s*(.+)$/)?.[1])
|
|
674
|
+
.filter(Boolean)
|
|
675
|
+
|
|
676
|
+
for (const event of events) {
|
|
677
|
+
try {
|
|
678
|
+
const payload = JSON.parse(event)
|
|
679
|
+
if (payload?.result?.tools && Array.isArray(payload.result.tools)) return payload
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
// Ignore non-JSON SSE data lines.
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return null
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function isLikelyMcpHttpEndpoint(document) {
|
|
690
|
+
const pathname = new URL(document.url).pathname.toLowerCase()
|
|
691
|
+
if (pathname.endsWith('/mcp') || pathname === '/mcp') return true
|
|
692
|
+
const text = `${document.body?.text ?? ''} ${document.headers?.allow ?? ''}`
|
|
693
|
+
return /jsonrpc|mcp|tools\/list|sse not supported|use post|text\/event-stream/i.test(text)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function probeMcpToolCatalog(url, origin) {
|
|
697
|
+
const response = await fetch(url, {
|
|
698
|
+
method: 'POST',
|
|
699
|
+
headers: {
|
|
700
|
+
'user-agent': `x402-surface-check/${packageJson.version}`,
|
|
701
|
+
accept: 'application/json, text/event-stream',
|
|
702
|
+
'content-type': 'application/json',
|
|
703
|
+
...(origin ? { origin } : {}),
|
|
704
|
+
},
|
|
705
|
+
body: JSON.stringify({
|
|
706
|
+
jsonrpc: '2.0',
|
|
707
|
+
id: 1,
|
|
708
|
+
method: 'tools/list',
|
|
709
|
+
params: {},
|
|
710
|
+
}),
|
|
711
|
+
})
|
|
712
|
+
const body = await readText(response)
|
|
713
|
+
const payload = parseMcpJsonRpcPayload(body)
|
|
714
|
+
const tools = Array.isArray(payload?.result?.tools)
|
|
715
|
+
? payload.result.tools.map(tool => ({
|
|
716
|
+
name: String(tool?.name ?? '').trim(),
|
|
717
|
+
description: String(tool?.description ?? '').replace(/\s+/g, ' ').trim(),
|
|
718
|
+
})).filter(tool => tool.name)
|
|
719
|
+
: []
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
status: response.status,
|
|
723
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
724
|
+
body,
|
|
725
|
+
tools,
|
|
726
|
+
url: response.url,
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
647
730
|
async function probeEndpoint(entry, origin) {
|
|
648
731
|
const method = entry.method ?? 'POST'
|
|
649
732
|
const response = await fetch(entry.url, {
|
|
@@ -976,24 +1059,28 @@ function isMutatingMethod(method) {
|
|
|
976
1059
|
function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
|
|
977
1060
|
const document = documentResult.body.json ?? {}
|
|
978
1061
|
const findings = []
|
|
1062
|
+
const mcpToolCount = options.mcpCatalog?.tools?.length ?? 0
|
|
979
1063
|
const networks = valueList(document.networks)
|
|
980
1064
|
const challengeNetworks = new Set()
|
|
981
1065
|
const challengesByEntry = new Map(challengeResults.map(result => [entryKey(result), result]))
|
|
982
1066
|
|
|
983
|
-
if (documentResult.status < 200 || documentResult.status >= 300) {
|
|
1067
|
+
if ((documentResult.status < 200 || documentResult.status >= 300) && mcpToolCount === 0) {
|
|
984
1068
|
findings.push(`P1 - Document returned HTTP ${documentResult.status}; expected a successful JSON response.`)
|
|
985
1069
|
}
|
|
986
1070
|
|
|
987
|
-
if (!documentResult.body.json) {
|
|
1071
|
+
if (!documentResult.body.json && mcpToolCount === 0) {
|
|
988
1072
|
findings.push(`P1 - Document did not return parseable JSON; content begins: ${documentResult.body.text.slice(0, 80).replace(/\s+/g, ' ')}.`)
|
|
989
1073
|
}
|
|
990
1074
|
else {
|
|
991
1075
|
findings.push(...publicUrlCredentialFindings(document))
|
|
992
1076
|
}
|
|
993
1077
|
|
|
994
|
-
if (entries.length === 0) {
|
|
1078
|
+
if (entries.length === 0 && mcpToolCount === 0) {
|
|
995
1079
|
findings.push('P1 - Document does not expose any manifest, OpenAPI, item, category, or resource endpoints for no-payment probes.')
|
|
996
1080
|
}
|
|
1081
|
+
else if (entries.length === 0 && mcpToolCount > 0) {
|
|
1082
|
+
findings.push(`P3 - Streamable HTTP MCP catalog exposes ${mcpToolCount} tools, but no static x402 endpoint examples were available for no-payment challenge probes.`)
|
|
1083
|
+
}
|
|
997
1084
|
|
|
998
1085
|
for (const result of challengeResults) {
|
|
999
1086
|
const summary = challengeSummary(result)
|
|
@@ -1215,6 +1302,9 @@ function referenceGuides(findings) {
|
|
|
1215
1302
|
|
|
1216
1303
|
function formatMarkdown(report) {
|
|
1217
1304
|
const document = report.document.body.json ?? {}
|
|
1305
|
+
const documentType = report.directEndpoint
|
|
1306
|
+
? 'direct endpoint'
|
|
1307
|
+
: (report.mcpCatalog?.tools?.length ? 'Streamable HTTP MCP endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'))
|
|
1218
1308
|
const challengeRows = report.challenges.map(result => {
|
|
1219
1309
|
const summary = challengeSummary(result)
|
|
1220
1310
|
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
@@ -1240,7 +1330,7 @@ function formatMarkdown(report) {
|
|
|
1240
1330
|
'## Document',
|
|
1241
1331
|
'',
|
|
1242
1332
|
`- Status: ${report.document.status}`,
|
|
1243
|
-
`- Type: ${
|
|
1333
|
+
`- Type: ${documentType}`,
|
|
1244
1334
|
`- Agent: ${document.agent?.name ?? '-'}`,
|
|
1245
1335
|
`- Wallet: ${document.agent?.wallet ?? '-'}`,
|
|
1246
1336
|
`- Facilitator: ${displayMetadataValue(document.facilitator)}`,
|
|
@@ -1248,6 +1338,16 @@ function formatMarkdown(report) {
|
|
|
1248
1338
|
`- Capabilities: ${capabilityList(document.capabilities).join(', ') || '-'}`,
|
|
1249
1339
|
`- Probed endpoints: ${report.entries.length}`,
|
|
1250
1340
|
'',
|
|
1341
|
+
...(report.mcpCatalog ? [
|
|
1342
|
+
'## MCP Tool Catalog',
|
|
1343
|
+
'',
|
|
1344
|
+
`- Status: ${report.mcpCatalog.status}`,
|
|
1345
|
+
`- Tools: ${report.mcpCatalog.tools.length}`,
|
|
1346
|
+
...(report.mcpCatalog.tools.length
|
|
1347
|
+
? report.mcpCatalog.tools.slice(0, 12).map(tool => `- \`${tool.name}\`${tool.description ? ` - ${tool.description.slice(0, 160)}` : ''}`)
|
|
1348
|
+
: ['- No MCP tools parsed from the public Streamable HTTP response.']),
|
|
1349
|
+
'',
|
|
1350
|
+
] : []),
|
|
1251
1351
|
'## No-Payment Challenge Map',
|
|
1252
1352
|
'',
|
|
1253
1353
|
'| Endpoint | Method | HTTP | Protocol | Price | Network | Resource URL |',
|
|
@@ -1317,6 +1417,9 @@ async function runCheck(options) {
|
|
|
1317
1417
|
}
|
|
1318
1418
|
|
|
1319
1419
|
const origin = options.origin ?? new URL(document.url).origin
|
|
1420
|
+
const mcpCatalog = !options.endpoint && entries.length === 0 && isLikelyMcpHttpEndpoint(document)
|
|
1421
|
+
? await probeMcpToolCatalog(document.url, origin)
|
|
1422
|
+
: null
|
|
1320
1423
|
const challenges = []
|
|
1321
1424
|
const preflights = []
|
|
1322
1425
|
|
|
@@ -1333,10 +1436,12 @@ async function runCheck(options) {
|
|
|
1333
1436
|
findings: [],
|
|
1334
1437
|
origin,
|
|
1335
1438
|
challenges,
|
|
1439
|
+
mcpCatalog,
|
|
1336
1440
|
preflights,
|
|
1337
1441
|
sourceDocument,
|
|
1338
1442
|
}
|
|
1339
1443
|
report.findings = findingList(document, challenges, preflights, entries, {
|
|
1444
|
+
mcpCatalog,
|
|
1340
1445
|
strictCache: options.strictCache,
|
|
1341
1446
|
strictProof: options.strictProof,
|
|
1342
1447
|
})
|