#!/usr/bin/env node /* * check-mcp-endpoints.mjs * * Ask a vendor directly whether it runs an MCP server, instead of trusting a list. * * For each vendor it does three things: * 1. Sends a real MCP `initialize` request over Streamable HTTP. * 2. Reads the RFC 9728 protected-resource document, which says how many * permission scopes the server will let you pick from. * 3. Checks whether the endpoint appears anywhere in the official registry. * * Step 3 is the interesting one. When I ran this on 22 August 2026, ten vendors * with live production servers were absent from the registry entirely: HubSpot, * Xero, Square, Calendly, Intercom, Pipedrive, Asana, Canva, Sentry and * Squarespace. The registry is a cross-check, not an index. * * Nothing here authenticates. No token is sent, none is needed, and the script * cannot touch your data even if you point it at a server you own. * * Usage: * node check-mcp-endpoints.mjs # the built-in vendor list * node check-mcp-endpoints.mjs --add acme=https://mcp.acme.com/mcp * node check-mcp-endpoints.mjs --only stripe,xero * node check-mcp-endpoints.mjs --registry # also cross-check the registry (slow, ~800 calls) * node check-mcp-endpoints.mjs --json # machine-readable output * * Requires Node 18 or later. No dependencies. * * Companion to https://jwatte.com/blog/mcp-servers-for-small-business/ * Free to copy, fork and reuse. */ const UA = 'mcp-endpoint-check (+https://jwatte.com)'; const TIMEOUT_MS = 15000; // The address you would guess for each vendor. Guessing is the point: if a // business owner cannot find it in ten seconds, the vendor has not published it // anywhere useful. Add your own with --add. const VENDORS = { stripe: 'https://mcp.stripe.com', paypal: 'https://mcp.paypal.com/mcp', square: 'https://mcp.squareup.com/mcp', notion: 'https://mcp.notion.com/mcp', airtable: 'https://mcp.airtable.com/mcp', cloudflare: 'https://mcp.cloudflare.com/mcp', hubspot: 'https://mcp.hubspot.com/anthropic', xero: 'https://mcp.xero.com/mcp', pipedrive: 'https://mcp.pipedrive.com/mcp', intercom: 'https://mcp.intercom.com/mcp', calendly: 'https://mcp.calendly.com/', asana: 'https://mcp.asana.com/sse', atlassian: 'https://mcp.atlassian.com/v1/mcp', linear: 'https://mcp.linear.app/mcp', canva: 'https://mcp.canva.com/mcp', sentry: 'https://mcp.sentry.dev/mcp', wix: 'https://mcp.wix.com/mcp', webflow: 'https://mcp.webflow.com/sse', squarespace: 'https://mcp.squarespace.com/mcp', quickbooks: 'https://mcp.intuit.com/mcp', zendesk: 'https://mcp.zendesk.com/mcp', twilio: 'https://mcp.twilio.com/mcp', mailchimp: 'https://mcp.mailchimp.com/mcp', shopify: 'https://mcp.shopify.com/mcp', }; const argv = process.argv.slice(2); const flag = (name) => argv.includes(`--${name}`); const value = (name) => { const i = argv.indexOf(`--${name}`); return i === -1 ? null : argv[i + 1]; }; const targets = { ...VENDORS }; for (let i = 0; i < argv.length; i++) { if (argv[i] !== '--add') continue; const pair = argv[i + 1] || ''; const eq = pair.indexOf('='); if (eq === -1) { console.error(`--add needs name=url, got "${pair}"`); process.exit(2); } targets[pair.slice(0, eq)] = pair.slice(eq + 1); } const only = (value('only') || '').split(',').map((s) => s.trim()).filter(Boolean); const names = only.length ? only.filter((n) => targets[n]) : Object.keys(targets); if (only.length && names.length !== only.length) { const missing = only.filter((n) => !targets[n]); console.error(`unknown vendor(s): ${missing.join(', ')}`); process.exit(2); } const INIT = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mcp-endpoint-check', version: '1.0' }, }, }; async function handshake(url) { try { const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'user-agent': UA, }, body: JSON.stringify(INIT), redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT_MS), }); const auth = r.headers.get('www-authenticate') || ''; const text = (await r.text()).slice(0, 4000); // A 200 with a protocolVersion means an open server: it wants no credential. if (r.status === 200 && text.includes('protocolVersion')) { let serverName = ''; try { serverName = JSON.parse(text.match(/\{[\s\S]*\}/)[0]).result?.serverInfo?.name || ''; } catch {} return { state: 'open', status: r.status, note: serverName || 'answered without authentication' }; } // A 401 carrying a Bearer challenge is the normal, healthy answer. if (r.status === 401 && /bearer/i.test(auth)) { return { state: 'live', status: r.status, note: 'OAuth challenge' }; } if (r.status === 401) return { state: 'live', status: r.status, note: 'authentication required' }; if (r.status === 403) return { state: 'blocked', status: r.status, note: 'refused this request' }; if (r.status === 404) return { state: 'none', status: r.status, note: 'nothing at this address' }; return { state: 'unclear', status: r.status, note: text.slice(0, 60).replace(/\s+/g, ' ') }; } catch (e) { const msg = String(e.message || e); // A name that does not resolve and a name that refuses a connection look the // same from here. Both mean you cannot use it, which is the answer you needed. return { state: 'none', status: 0, note: /timeout|abort/i.test(msg) ? 'no answer in time' : 'did not connect' }; } } async function scopes(url) { const base = new URL(url); // Both locations, not the first that answers. Square publishes two of these // documents and they disagree: the path-specific one lists nothing, the one at // the root lists 49 scopes. Stopping at the first hit would have under-reported // the vendor that offers the finest-grained permissions of the lot. const paths = [ '/.well-known/oauth-protected-resource' + base.pathname.replace(/\/$/, ''), '/.well-known/oauth-protected-resource', ]; const found = []; for (const p of paths) { try { const r = await fetch(base.origin + p, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(TIMEOUT_MS), }); if (r.status !== 200) continue; const j = await r.json(); found.push({ path: p, resource: j.resource || '', count: (j.scopes_supported || []).length, scopes: j.scopes_supported || [], authz: (j.authorization_servers || [])[0] || '' }); } catch { /* try the next path */ } } if (!found.length) return null; const best = found.reduce((a, b) => (b.count > a.count ? b : a)); const disagree = found.length > 1 && found.some((f) => f.count !== found[0].count); // Counting scopes answers the wrong question. What a business wants to know is // whether it can connect this thing without granting the power to change data. // Classifying by name is rough, and deliberately so: treat it as a prompt to go // read the actual strings, which --json prints. const list = best.scopes || []; const read = list.filter((x) => /(^|[._:-])(read|view|readonly|read_only)([._:-]|$)/i.test(x)).length; const write = list.filter((x) => /(write|manage|create|update|delete|admin|full)/i.test(x)).length; return { resource: best.resource, count: best.count, disagree, list, read, write, authz: best.authz }; } // Walking the whole registry costs ~800 requests, so it is opt-in. It collects // every remote endpoint hostname, which is the only way to prove absence: the // registry's keyword search will not find a server whose name does not match. async function registryHosts() { const hosts = new Set(); let cursor = '', pages = 0; process.stderr.write(' walking the registry'); while (true) { // version=latest returns one record per server instead of every published // version, which cuts this from 794 requests to 243 (about 11 seconds). It // yields a slightly smaller hostname set than a full walk (9,454 vs 9,875 on // 22 Aug 2026) because retired endpoints from older versions drop out. That // makes this the stricter test for "listed today", and it returns the same // ten absent vendors either way. const url = 'https://registry.modelcontextprotocol.io/v0/servers?limit=100&version=latest' + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''); let j; try { const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(30000) }); j = await r.json(); } catch { break; } const rows = j.servers || []; if (!rows.length) break; pages++; if (pages % 100 === 0) process.stderr.write('.'); for (const row of rows) { for (const rm of row.server?.remotes || []) { try { hosts.add(new URL(rm.url).hostname.toLowerCase()); } catch {} } } cursor = j.metadata?.nextCursor || j.metadata?.next_cursor || ''; if (!cursor) break; } process.stderr.write(` ${pages} pages, ${hosts.size} hostnames\n`); return hosts; } const listed = flag('registry') ? await registryHosts() : null; const results = []; for (const name of names) { const url = targets[name]; const hs = await handshake(url); const sc = (hs.state === 'live' || hs.state === 'open') ? await scopes(url) : null; let inRegistry = null, viaSibling = false; if (listed) { const host = (() => { try { return new URL(url).hostname.toLowerCase(); } catch { return ''; } })(); if (host) { inRegistry = listed.has(host); // Exact-hostname matching alone is too strict and will mislabel a vendor. // Cloudflare registers docs.mcp.cloudflare.com and two siblings, but not the // bare mcp.cloudflare.com this script probes, so a strict match reported a // listed vendor as absent. Fall back to the registrable domain. if (!inRegistry) { const domain = host.split('.').slice(-2).join('.'); for (const h of listed) { if (h === domain || h.endsWith('.' + domain)) { inRegistry = true; viaSibling = true; break; } } } } } results.push({ vendor: name, url, ...hs, scopes: sc ? sc.count : null, scopeList: sc ? sc.list : null, read: sc ? sc.read : null, write: sc ? sc.write : null, authServer: sc ? sc.authz : null, scopesDisagree: sc ? !!sc.disagree : false, inRegistry, viaSibling }); } if (flag('json')) { console.log(JSON.stringify(results, null, 2)); } else { const LABEL = { live: 'LIVE ', open: 'OPEN ', blocked: 'BLOCKED ', none: 'none ', unclear: 'unclear ', }; console.log(''); console.log(' vendor state scopes r/w registry detail'); console.log(' ' + '-'.repeat(80)); for (const r of results) { const sc = r.scopes === null ? ' .' : (String(r.scopes) + (r.scopesDisagree ? '*' : '')).padStart(6); const rw = r.scopes === null ? ' . ' : r.write === 0 && r.read > 0 ? ' RO ' : `${r.read}/${r.write}`.padStart(5); const reg = r.inRegistry === null ? ' . ' : r.viaSibling ? ' listed~' : r.inRegistry ? ' listed ' : ' ABSENT '; console.log(` ${r.vendor.padEnd(13)} ${LABEL[r.state]}${sc} ${rw} ${reg} ${r.note}`); } console.log(''); const live = results.filter((r) => r.state === 'live' || r.state === 'open'); console.log(` ${live.length} of ${results.length} vendors answered.`); if (listed) { const absent = live.filter((r) => r.inRegistry === false); console.log(` ${absent.length} of those are absent from the official registry: ${absent.map((r) => r.vendor).join(', ') || 'none'}`); } else { console.log(' Re-run with --registry to check which of these the official registry actually lists.'); } console.log(''); console.log(' LIVE the vendor runs a server and wants you to sign in. Normal.'); console.log(' OPEN it answered with no credential at all. Read what it does before using it.'); console.log(' BLOCKED something is there but it refused. Not usable, and not proof of a product.'); console.log(' none nothing at the guessable address. Check the vendor docs before concluding.'); console.log(''); console.log(' r/w is read-flavoured / write-flavoured scopes. RO means it offers NO write scope at all,'); console.log(' which is the best answer available: the vendor did not build the door. Xero reads RO.'); console.log(' A high scope count is good news: it means you can grant something narrow.'); console.log(' A dot is not "no control": some vendors scope through restricted API keys instead.'); console.log(' A * means the vendor publishes two scope documents that disagree; the higher one is shown.'); console.log(' listed~ means the registry lists the vendor at a different hostname than the one probed.'); console.log(''); }