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 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: ${report.directEndpoint ? 'direct endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document')}`,
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
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {