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 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: ${report.directEndpoint ? 'direct endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document')}`,
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
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.31",
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": {