x402-surface-check 0.2.31 → 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 +88 -4
- 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
|
|
@@ -665,6 +665,68 @@ async function fetchDocument(url) {
|
|
|
665
665
|
}
|
|
666
666
|
}
|
|
667
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
|
+
|
|
668
730
|
async function probeEndpoint(entry, origin) {
|
|
669
731
|
const method = entry.method ?? 'POST'
|
|
670
732
|
const response = await fetch(entry.url, {
|
|
@@ -997,24 +1059,28 @@ function isMutatingMethod(method) {
|
|
|
997
1059
|
function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
|
|
998
1060
|
const document = documentResult.body.json ?? {}
|
|
999
1061
|
const findings = []
|
|
1062
|
+
const mcpToolCount = options.mcpCatalog?.tools?.length ?? 0
|
|
1000
1063
|
const networks = valueList(document.networks)
|
|
1001
1064
|
const challengeNetworks = new Set()
|
|
1002
1065
|
const challengesByEntry = new Map(challengeResults.map(result => [entryKey(result), result]))
|
|
1003
1066
|
|
|
1004
|
-
if (documentResult.status < 200 || documentResult.status >= 300) {
|
|
1067
|
+
if ((documentResult.status < 200 || documentResult.status >= 300) && mcpToolCount === 0) {
|
|
1005
1068
|
findings.push(`P1 - Document returned HTTP ${documentResult.status}; expected a successful JSON response.`)
|
|
1006
1069
|
}
|
|
1007
1070
|
|
|
1008
|
-
if (!documentResult.body.json) {
|
|
1071
|
+
if (!documentResult.body.json && mcpToolCount === 0) {
|
|
1009
1072
|
findings.push(`P1 - Document did not return parseable JSON; content begins: ${documentResult.body.text.slice(0, 80).replace(/\s+/g, ' ')}.`)
|
|
1010
1073
|
}
|
|
1011
1074
|
else {
|
|
1012
1075
|
findings.push(...publicUrlCredentialFindings(document))
|
|
1013
1076
|
}
|
|
1014
1077
|
|
|
1015
|
-
if (entries.length === 0) {
|
|
1078
|
+
if (entries.length === 0 && mcpToolCount === 0) {
|
|
1016
1079
|
findings.push('P1 - Document does not expose any manifest, OpenAPI, item, category, or resource endpoints for no-payment probes.')
|
|
1017
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
|
+
}
|
|
1018
1084
|
|
|
1019
1085
|
for (const result of challengeResults) {
|
|
1020
1086
|
const summary = challengeSummary(result)
|
|
@@ -1236,6 +1302,9 @@ function referenceGuides(findings) {
|
|
|
1236
1302
|
|
|
1237
1303
|
function formatMarkdown(report) {
|
|
1238
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'))
|
|
1239
1308
|
const challengeRows = report.challenges.map(result => {
|
|
1240
1309
|
const summary = challengeSummary(result)
|
|
1241
1310
|
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl || '-'} |`
|
|
@@ -1261,7 +1330,7 @@ function formatMarkdown(report) {
|
|
|
1261
1330
|
'## Document',
|
|
1262
1331
|
'',
|
|
1263
1332
|
`- Status: ${report.document.status}`,
|
|
1264
|
-
`- Type: ${
|
|
1333
|
+
`- Type: ${documentType}`,
|
|
1265
1334
|
`- Agent: ${document.agent?.name ?? '-'}`,
|
|
1266
1335
|
`- Wallet: ${document.agent?.wallet ?? '-'}`,
|
|
1267
1336
|
`- Facilitator: ${displayMetadataValue(document.facilitator)}`,
|
|
@@ -1269,6 +1338,16 @@ function formatMarkdown(report) {
|
|
|
1269
1338
|
`- Capabilities: ${capabilityList(document.capabilities).join(', ') || '-'}`,
|
|
1270
1339
|
`- Probed endpoints: ${report.entries.length}`,
|
|
1271
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
|
+
] : []),
|
|
1272
1351
|
'## No-Payment Challenge Map',
|
|
1273
1352
|
'',
|
|
1274
1353
|
'| Endpoint | Method | HTTP | Protocol | Price | Network | Resource URL |',
|
|
@@ -1338,6 +1417,9 @@ async function runCheck(options) {
|
|
|
1338
1417
|
}
|
|
1339
1418
|
|
|
1340
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
|
|
1341
1423
|
const challenges = []
|
|
1342
1424
|
const preflights = []
|
|
1343
1425
|
|
|
@@ -1354,10 +1436,12 @@ async function runCheck(options) {
|
|
|
1354
1436
|
findings: [],
|
|
1355
1437
|
origin,
|
|
1356
1438
|
challenges,
|
|
1439
|
+
mcpCatalog,
|
|
1357
1440
|
preflights,
|
|
1358
1441
|
sourceDocument,
|
|
1359
1442
|
}
|
|
1360
1443
|
report.findings = findingList(document, challenges, preflights, entries, {
|
|
1444
|
+
mcpCatalog,
|
|
1361
1445
|
strictCache: options.strictCache,
|
|
1362
1446
|
strictProof: options.strictProof,
|
|
1363
1447
|
})
|