mcplane 0.4.1 → 0.5.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +109 -19
  3. package/dist/agent-plugins.d.ts +6 -0
  4. package/dist/agent-plugins.js +76 -0
  5. package/dist/cdp.d.ts +49 -0
  6. package/dist/cdp.js +253 -0
  7. package/dist/checks/ard.d.ts +67 -0
  8. package/dist/checks/ard.js +215 -0
  9. package/dist/checks/auth.js +2 -1
  10. package/dist/checks/chain.d.ts +45 -0
  11. package/dist/checks/chain.js +171 -0
  12. package/dist/checks/listing.js +23 -14
  13. package/dist/checks/oauth.d.ts +45 -0
  14. package/dist/checks/oauth.js +64 -0
  15. package/dist/checks/readiness.d.ts +58 -0
  16. package/dist/checks/readiness.js +230 -0
  17. package/dist/checks/tools.js +120 -7
  18. package/dist/checks/vercel.d.ts +27 -0
  19. package/dist/checks/vercel.js +153 -0
  20. package/dist/cli.js +54 -6
  21. package/dist/core.d.ts +1 -0
  22. package/dist/core.js +21 -5
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +17 -7
  26. package/dist/evals.d.ts +34 -0
  27. package/dist/evals.js +75 -0
  28. package/dist/extras.js +1 -1
  29. package/dist/images.d.ts +21 -0
  30. package/dist/images.js +99 -0
  31. package/dist/listings.d.ts +14 -0
  32. package/dist/listings.js +39 -0
  33. package/dist/mcp-client.d.ts +1 -5
  34. package/dist/mcp-client.js +8 -1
  35. package/dist/mcp-server.js +21 -11
  36. package/dist/packs.d.ts +53 -18
  37. package/dist/packs.js +426 -92
  38. package/dist/report.js +30 -13
  39. package/dist/skills.d.ts +21 -0
  40. package/dist/skills.js +180 -0
  41. package/dist/submissions.js +2 -1
  42. package/dist/types.d.ts +37 -4
  43. package/dist/types.js +1 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/video.d.ts +58 -0
  47. package/dist/video.js +190 -0
  48. package/dist/zip.d.ts +16 -2
  49. package/dist/zip.js +65 -5
  50. package/package.json +4 -1
  51. package/schema.json +86 -12
  52. package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
  53. package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
  54. package/schemas/agent-plugins-1.0.0/plugin.schema.json +65 -0
package/dist/cdp.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Just enough of the Chrome DevTools Protocol to drive an Electron app: one
3
+ * WebSocket, requests matched by id, events by name.
4
+ *
5
+ * The WebSocket is a few lines of RFC 6455 over node:http's upgrade rather than
6
+ * Node's built-in one: ChatGPT doesn't always answer a close frame, and the
7
+ * built-in client then holds the process open with no way to drop the socket.
8
+ * It also keeps mcplane on Node 20.
9
+ */
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+ import { get, request } from 'node:http';
12
+ /** The debugging port's target list. Plain http with no keep-alive, so nothing holds the process open afterwards. */
13
+ export function listTargets(port, timeoutMs = 2_000) {
14
+ return new Promise((resolve, reject) => {
15
+ const req = get({ host: '127.0.0.1', port, path: '/json/list', agent: false, timeout: timeoutMs }, (res) => {
16
+ let body = '';
17
+ res.setEncoding('utf8');
18
+ res.on('data', (d) => (body += d));
19
+ res.on('end', () => {
20
+ try {
21
+ if (res.statusCode !== 200)
22
+ throw new Error(`DevTools port ${port} answered HTTP ${res.statusCode}`);
23
+ resolve(JSON.parse(body));
24
+ }
25
+ catch (e) {
26
+ reject(e);
27
+ }
28
+ });
29
+ });
30
+ req.on('timeout', () => req.destroy(new Error(`DevTools port ${port} didn't answer`)));
31
+ req.on('error', reject);
32
+ });
33
+ }
34
+ /**
35
+ * The app's main window: the page target at exactly `url` (ChatGPT desktop: app://-/index.html).
36
+ * Its other windows (app://-/index.html?initialRoute=…), MCP App iframes, webviews and
37
+ * workers are separate targets and never match.
38
+ */
39
+ export function pickMainWindow(targets, url) {
40
+ return targets.find((t) => t.type === 'page' && t.url === url && t.webSocketDebuggerUrl);
41
+ }
42
+ /* ---------------- WebSocket frames (RFC 6455) ---------------- */
43
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
44
+ const OP = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };
45
+ /** One client-to-server frame: final, masked as the RFC requires of clients. */
46
+ export function encodeFrame(payload, opcode = OP.text, mask = randomBytes(4)) {
47
+ const len = payload.length;
48
+ const head = len < 126 ? 2 : len < 65_536 ? 4 : 10;
49
+ const out = Buffer.alloc(head + 4 + len);
50
+ out[0] = 0x80 | opcode;
51
+ if (len < 126)
52
+ out[1] = 0x80 | len;
53
+ else if (len < 65_536) {
54
+ out[1] = 0x80 | 126;
55
+ out.writeUInt16BE(len, 2);
56
+ }
57
+ else {
58
+ out[1] = 0x80 | 127;
59
+ out.writeBigUInt64BE(BigInt(len), 2);
60
+ }
61
+ mask.copy(out, head);
62
+ for (let i = 0; i < len; i++)
63
+ out[head + 4 + i] = payload[i] ^ mask[i & 3];
64
+ return out;
65
+ }
66
+ /** Takes every complete frame off the front of `buf`; `rest` is the partial frame still arriving. */
67
+ export function decodeFrames(buf) {
68
+ const frames = [];
69
+ let off = 0;
70
+ for (;;) {
71
+ if (buf.length - off < 2)
72
+ break;
73
+ const b0 = buf[off];
74
+ const b1 = buf[off + 1];
75
+ let len = b1 & 0x7f;
76
+ let pos = off + 2;
77
+ if (len === 126) {
78
+ if (buf.length - pos < 2)
79
+ break;
80
+ len = buf.readUInt16BE(pos);
81
+ pos += 2;
82
+ }
83
+ else if (len === 127) {
84
+ if (buf.length - pos < 8)
85
+ break;
86
+ len = Number(buf.readBigUInt64BE(pos));
87
+ pos += 8;
88
+ }
89
+ let mask = null;
90
+ if (b1 & 0x80) {
91
+ if (buf.length - pos < 4)
92
+ break;
93
+ mask = buf.subarray(pos, pos + 4);
94
+ pos += 4;
95
+ }
96
+ if (buf.length - pos < len)
97
+ break;
98
+ let payload = buf.subarray(pos, pos + len);
99
+ if (mask) {
100
+ payload = Buffer.from(payload);
101
+ for (let i = 0; i < payload.length; i++)
102
+ payload[i] ^= mask[i & 3];
103
+ }
104
+ frames.push({ fin: (b0 & 0x80) !== 0, opcode: b0 & 0x0f, payload });
105
+ off = pos + len;
106
+ }
107
+ return { frames, rest: buf.subarray(off) };
108
+ }
109
+ export class Cdp {
110
+ socket;
111
+ next = 0;
112
+ pending = new Map();
113
+ listeners = new Map();
114
+ closed = false;
115
+ buf = Buffer.alloc(0);
116
+ parts = [];
117
+ constructor(socket, head) {
118
+ this.socket = socket;
119
+ socket.setNoDelay(true);
120
+ socket.on('data', (d) => this.data(d));
121
+ socket.on('error', () => null); // "close" follows
122
+ socket.on('close', () => this.ended('The DevTools connection closed (did ChatGPT quit?)'));
123
+ if (head.length)
124
+ this.data(head);
125
+ }
126
+ /** Opens a DevTools WebSocket URL (ws://127.0.0.1:<port>/devtools/page/<id>). */
127
+ static connect(url, timeoutMs = 10_000) {
128
+ const u = new URL(url);
129
+ const key = randomBytes(16).toString('base64');
130
+ return new Promise((resolve, reject) => {
131
+ const req = request({
132
+ host: u.hostname,
133
+ port: u.port,
134
+ path: u.pathname + u.search,
135
+ agent: false,
136
+ timeout: timeoutMs,
137
+ headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': key },
138
+ });
139
+ req.on('upgrade', (res, socket, head) => {
140
+ socket.setTimeout(0);
141
+ if (res.headers['sec-websocket-accept'] !== createHash('sha1').update(key + GUID).digest('base64')) {
142
+ socket.destroy();
143
+ reject(new Error(`${url} isn't a WebSocket endpoint`));
144
+ return;
145
+ }
146
+ resolve(new Cdp(socket, head));
147
+ });
148
+ req.on('response', (res) => {
149
+ res.resume();
150
+ reject(new Error(`DevTools refused the connection (HTTP ${res.statusCode})`));
151
+ });
152
+ req.on('timeout', () => req.destroy(new Error(`Couldn't connect to ${url}`)));
153
+ req.on('error', reject);
154
+ req.end();
155
+ });
156
+ }
157
+ send(method, params = {}, timeoutMs = 30_000) {
158
+ if (this.closed)
159
+ return Promise.reject(new Error('The DevTools connection is closed'));
160
+ const id = ++this.next;
161
+ return new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => {
163
+ this.pending.delete(id);
164
+ reject(new Error(`${method} timed out`));
165
+ }, timeoutMs);
166
+ this.pending.set(id, {
167
+ method,
168
+ resolve: (v) => {
169
+ clearTimeout(timer);
170
+ resolve(v);
171
+ },
172
+ reject: (e) => {
173
+ clearTimeout(timer);
174
+ reject(e);
175
+ },
176
+ });
177
+ this.socket.write(encodeFrame(Buffer.from(JSON.stringify({ id, method, params }))));
178
+ });
179
+ }
180
+ /** Runs an expression in the page and returns its JSON value. */
181
+ async evaluate(expression) {
182
+ const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
183
+ if (r.exceptionDetails)
184
+ throw new Error(`Page script failed: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
185
+ return r.result?.value;
186
+ }
187
+ /** Subscribes to an event; returns the unsubscribe function. */
188
+ on(event, fn) {
189
+ const set = this.listeners.get(event) ?? new Set();
190
+ set.add(fn);
191
+ this.listeners.set(event, set);
192
+ return () => set.delete(fn);
193
+ }
194
+ /** Sends a close frame and drops the socket without waiting for an answer. */
195
+ close() {
196
+ if (this.closed)
197
+ return;
198
+ this.closed = true;
199
+ this.socket.end(encodeFrame(Buffer.alloc(0), OP.close), () => this.socket.destroy());
200
+ this.ended('The DevTools connection is closed');
201
+ }
202
+ ended(why) {
203
+ this.closed = true;
204
+ for (const p of this.pending.values())
205
+ p.reject(new Error(why));
206
+ this.pending.clear();
207
+ }
208
+ data(chunk) {
209
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
210
+ const { frames, rest } = decodeFrames(this.buf);
211
+ this.buf = rest;
212
+ for (const f of frames) {
213
+ if (f.opcode === OP.ping) {
214
+ if (!this.closed)
215
+ this.socket.write(encodeFrame(f.payload, OP.pong));
216
+ }
217
+ else if (f.opcode === OP.close) {
218
+ this.close();
219
+ }
220
+ else if (f.opcode === OP.text || f.opcode === OP.binary || f.opcode === OP.continuation) {
221
+ this.parts.push(f.payload);
222
+ if (f.fin) {
223
+ const message = Buffer.concat(this.parts).toString('utf8');
224
+ this.parts = [];
225
+ this.receive(message);
226
+ }
227
+ }
228
+ }
229
+ }
230
+ receive(raw) {
231
+ let m;
232
+ try {
233
+ m = JSON.parse(raw);
234
+ }
235
+ catch {
236
+ return;
237
+ }
238
+ if (typeof m.id === 'number') {
239
+ const p = this.pending.get(m.id);
240
+ if (!p)
241
+ return;
242
+ this.pending.delete(m.id);
243
+ if (m.error)
244
+ p.reject(new Error(`${p.method}: ${m.error.message}`));
245
+ else
246
+ p.resolve(m.result);
247
+ return;
248
+ }
249
+ if (m.method)
250
+ for (const fn of this.listeners.get(m.method) ?? [])
251
+ fn(m.params);
252
+ }
253
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Agentic Resource Discovery (ARD v0.91, a proposal dated 26 Aug 2026): a manifest of entries at
3
+ * /.well-known/ard.json that tells agents and registries what a domain offers, MCP servers included.
4
+ * The predecessor path was /.well-known/ai-catalog.json, and that is what Lighthouse 13.5's
5
+ * ard-schema audit still reads (after a robots.txt Agentmap line or a rel="ai-catalog" link), with
6
+ * a port of the ARD conformance tester and the older ai-catalog schema: specVersion "1.0" required,
7
+ * nothing but specVersion, host and entries at the top level.
8
+ *
9
+ * Spec: https://agenticresourcediscovery.org/spec/ (https://github.com/ards-project/ard-spec, spec/ard.md)
10
+ * Lighthouse: https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/audits/agentic/ard-schema.js
11
+ */
12
+ import type { Check, Manifest } from '../types.js';
13
+ export declare const ARD_PATH = "/.well-known/ard.json";
14
+ export declare const LEGACY_PATH = "/.well-known/ai-catalog.json";
15
+ export declare const MCP_CARD_TYPE = "application/mcp-server-card+json";
16
+ export interface ArdIssues {
17
+ /** Against ARD v0.91: the entry schema and its required terms. */
18
+ errors: string[];
19
+ /** Soft: representativeQueries outside 2 to 5, types Lighthouse doesn't know. */
20
+ warnings: string[];
21
+ /** What only Lighthouse 13.5 rejects, from its stricter ai-catalog schema. */
22
+ lighthouse: string[];
23
+ }
24
+ export declare function validateArd(doc: unknown): ArdIssues;
25
+ /** An entry that points at this MCP server: its URL, a server card on the same site (often the apex, beside api.), or inline data naming the URL. */
26
+ export declare function describesServer(e: any, serverUrl: string): boolean;
27
+ export interface ArdFetched {
28
+ origin: string;
29
+ ard: unknown;
30
+ legacy: unknown;
31
+ /** The robots.txt Agentmap URL, if any, and what it served. */
32
+ agentmap?: {
33
+ url: string;
34
+ doc: unknown;
35
+ };
36
+ }
37
+ /** The checks, from what was fetched. undefined means nothing usable was there. */
38
+ export declare function ardFindings(serverUrl: string, f: ArdFetched): Check[];
39
+ /** Looks on the website's origin, then the MCP server's. */
40
+ export declare function ardChecks(m: Manifest): Promise<Check[]>;
41
+ /**
42
+ * A manifest for /.well-known/ard.json that Lighthouse 13.5 also accepts: one entry for the MCP
43
+ * server, pointing at its server card when there is one. representativeQueries come from your
44
+ * prompts and test cases, capabilities from your tool names.
45
+ */
46
+ export declare function ardManifest(m: Manifest, o?: {
47
+ tools?: string[];
48
+ serverCard?: string;
49
+ }): {
50
+ specVersion: string;
51
+ host: {
52
+ logoUrl?: string | undefined;
53
+ documentationUrl?: string | undefined;
54
+ displayName: string;
55
+ };
56
+ entries: {
57
+ version?: string | undefined;
58
+ tags?: string[] | undefined;
59
+ capabilities?: string[] | undefined;
60
+ representativeQueries?: string[] | undefined;
61
+ description?: string | undefined;
62
+ identifier: string;
63
+ displayName: string;
64
+ type: string;
65
+ url: string;
66
+ }[];
67
+ };
@@ -0,0 +1,215 @@
1
+ export const ARD_PATH = '/.well-known/ard.json';
2
+ export const LEGACY_PATH = '/.well-known/ai-catalog.json';
3
+ export const MCP_CARD_TYPE = 'application/mcp-server-card+json';
4
+ /** spec/schemas/ard-entry.schema.json: urn:air:<publisher>:<namespace>:<name>. */
5
+ const URN = /^urn:air:[a-zA-Z0-9.-]+(:[a-zA-Z0-9._-]+)+$/;
6
+ /** The types Lighthouse 13.5 calls standard; others get a low-severity warning there. */
7
+ const STANDARD_TYPES = [
8
+ 'application/ai-catalog+json',
9
+ 'application/agent-card+json',
10
+ 'application/a2a-agent-card+json',
11
+ MCP_CARD_TYPE,
12
+ 'application/agent-skills+zip',
13
+ 'application/agent-skills+gzip',
14
+ 'text/markdown; profile="urn:air:agent-skills"',
15
+ 'application/ai-registry',
16
+ 'application/ai-registry+json',
17
+ ];
18
+ /** The ai-catalog schema allows only these in host. */
19
+ const HOST_KEYS = ['displayName', 'identifier', 'documentationUrl', 'logoUrl', 'trustManifest'];
20
+ export function validateArd(doc) {
21
+ const out = { errors: [], warnings: [], lighthouse: [] };
22
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
23
+ return { ...out, errors: ['not a JSON object'] };
24
+ const d = doc;
25
+ if (!Array.isArray(d.entries))
26
+ out.errors.push(d.entries === undefined ? 'no "entries" array' : '"entries" is not an array');
27
+ if (d.specVersion !== '1.0')
28
+ out.lighthouse.push(d.specVersion === undefined ? 'no specVersion' : `specVersion "${d.specVersion}"`);
29
+ const extra = Object.keys(d).filter((k) => !['specVersion', 'host', 'entries'].includes(k));
30
+ if (extra.length)
31
+ out.lighthouse.push(`top-level ${extra.join(', ')}`);
32
+ if (d.host !== undefined) {
33
+ if (!d.host || typeof d.host !== 'object' || !d.host.displayName)
34
+ out.lighthouse.push('host has no displayName');
35
+ const hostExtra = Object.keys(d.host ?? {}).filter((k) => !HOST_KEYS.includes(k));
36
+ if (hostExtra.length)
37
+ out.lighthouse.push(`host.${hostExtra.join(', host.')}`);
38
+ }
39
+ (Array.isArray(d.entries) ? d.entries : []).forEach((e, i) => {
40
+ const label = e?.displayName || e?.identifier || `entry ${i + 1}`;
41
+ if (!e || typeof e !== 'object') {
42
+ out.errors.push(`${label}: not an object`);
43
+ return;
44
+ }
45
+ if (!e.identifier)
46
+ out.errors.push(`${label}: no identifier`);
47
+ else if (!URN.test(e.identifier))
48
+ out.errors.push(`${label}: identifier "${e.identifier}" isn't urn:air:<publisher>:<namespace>:<name>`);
49
+ if (!e.displayName)
50
+ out.errors.push(`${label}: no displayName`);
51
+ if (!e.type)
52
+ out.errors.push(`${label}: no type`);
53
+ else if (!STANDARD_TYPES.includes(e.type))
54
+ out.warnings.push(`${label}: Lighthouse doesn't know type "${e.type}"`);
55
+ if ((e.url !== undefined) === (e.data !== undefined))
56
+ out.errors.push(`${label}: needs exactly one of url and data`);
57
+ const q = e.representativeQueries;
58
+ if (q === undefined)
59
+ out.warnings.push(`${label}: no representativeQueries`);
60
+ else if (!Array.isArray(q) || q.some((x) => typeof x !== 'string'))
61
+ out.errors.push(`${label}: representativeQueries must be strings`);
62
+ else if (q.length < 2 || q.length > 5)
63
+ out.warnings.push(`${label}: ${q.length} representativeQueries`);
64
+ if (e.trustManifest !== undefined && !e.trustManifest?.identity)
65
+ out.errors.push(`${label}: trustManifest has no identity`);
66
+ });
67
+ return out;
68
+ }
69
+ const bare = (u) => (u ?? '').replace(/\/+$/, '').toLowerCase();
70
+ /** The last two labels of a hostname: enough to tell api.example.com and example.com are one site. */
71
+ const site = (u) => {
72
+ try {
73
+ return new URL(u).hostname.split('.').slice(-2).join('.');
74
+ }
75
+ catch {
76
+ return '';
77
+ }
78
+ };
79
+ /** An entry that points at this MCP server: its URL, a server card on the same site (often the apex, beside api.), or inline data naming the URL. */
80
+ export function describesServer(e, serverUrl) {
81
+ if (!e || typeof e !== 'object')
82
+ return false;
83
+ if (bare(e.url) === bare(serverUrl))
84
+ return true;
85
+ if (typeof e.url === 'string' && e.type === MCP_CARD_TYPE)
86
+ return site(e.url) === site(serverUrl);
87
+ return e.data !== undefined && JSON.stringify(e.data).includes(serverUrl);
88
+ }
89
+ /** The checks, from what was fetched. undefined means nothing usable was there. */
90
+ export function ardFindings(serverUrl, f) {
91
+ const checks = [];
92
+ const found = f.ard ?? f.legacy;
93
+ const where = `${f.origin}${f.ard !== undefined ? ARD_PATH : LEGACY_PATH}`;
94
+ checks.push(f.ard !== undefined
95
+ ? { id: 'ard.path', level: 'pass', title: 'The ARD manifest is at /.well-known/ard.json', detail: where }
96
+ : {
97
+ id: 'ard.path',
98
+ level: 'warn',
99
+ title: 'The ARD manifest is at /.well-known/ard.json',
100
+ detail: `only at ${where}, the predecessor path`,
101
+ fix: `ARD v0.91 consumers must read ${ARD_PATH} and needn't read ai-catalog.json. Serve the same file at both while Lighthouse 13.5 still reads ai-catalog.json.`,
102
+ });
103
+ const v = validateArd(found);
104
+ const entries = Array.isArray(found?.entries) ? found.entries : [];
105
+ checks.push(v.errors.length
106
+ ? {
107
+ id: 'ard.manifest',
108
+ level: 'warn',
109
+ title: 'The ARD manifest is valid',
110
+ detail: v.errors.slice(0, 4).join('; ') + (v.errors.length > 4 ? `; ${v.errors.length - 4} more` : ''),
111
+ fix: 'Each entry needs identifier (urn:air:<publisher>:<namespace>:<name>), displayName, type and exactly one of url or data (ARD v0.91 §4.2). "mcplane pack ard" writes a valid one.',
112
+ }
113
+ : { id: 'ard.manifest', level: 'pass', title: 'The ARD manifest is valid', detail: `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}${v.warnings.length ? `; ${v.warnings.join('; ')}` : ''}` });
114
+ // Lighthouse reads the Agentmap target, else ai-catalog.json (a rel="ai-catalog" link in the page also counts; not read here).
115
+ const lhUrl = f.agentmap?.url ?? (f.legacy !== undefined ? `${f.origin}${LEGACY_PATH}` : null);
116
+ const lhDoc = f.agentmap ? f.agentmap.doc : f.legacy;
117
+ if (!lhUrl) {
118
+ checks.push({
119
+ id: 'ard.lighthouse',
120
+ level: 'info',
121
+ title: 'Lighthouse 13.5 can find the ARD manifest',
122
+ detail: `no robots.txt Agentmap line and nothing at ${LEGACY_PATH}`,
123
+ fix: `Add "Agentmap: ${where}" to robots.txt. Lighthouse 13.5 reads an Agentmap line, a rel="ai-catalog" link or ${LEGACY_PATH}, and otherwise marks its ARD audit not applicable.`,
124
+ });
125
+ }
126
+ else {
127
+ const lh = validateArd(lhDoc);
128
+ const bad = lhDoc === undefined ? [`nothing usable at ${lhUrl}`] : [...lh.errors, ...lh.lighthouse];
129
+ checks.push(bad.length
130
+ ? {
131
+ id: 'ard.lighthouse',
132
+ level: 'warn',
133
+ title: 'Lighthouse 13.5 accepts the ARD manifest',
134
+ detail: `${lhUrl}: ${bad.slice(0, 4).join('; ')}`,
135
+ fix: 'Lighthouse validates against the older ai-catalog schema: specVersion "1.0", nothing but specVersion, host and entries at the top level, and host limited to displayName, identifier, documentationUrl, logoUrl and trustManifest. ARD v0.91 ignores extra top-level members, so meeting both costs nothing.',
136
+ }
137
+ : { id: 'ard.lighthouse', level: 'pass', title: 'Lighthouse 13.5 accepts the ARD manifest', detail: lhUrl });
138
+ }
139
+ const mine = entries.find((e) => describesServer(e, serverUrl));
140
+ checks.push(mine
141
+ ? { id: 'ard.entry', level: 'pass', title: 'The ARD manifest lists this MCP server', detail: mine.identifier }
142
+ : { id: 'ard.entry', level: 'info', title: 'The ARD manifest lists this MCP server', detail: `no entry points at ${serverUrl} or its server card`, fix: `Add an entry with type ${MCP_CARD_TYPE}. "mcplane pack ard" writes one.` });
143
+ return checks;
144
+ }
145
+ const getJson = async (url) => {
146
+ const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'mcplane' }, redirect: 'follow', signal: AbortSignal.timeout(10_000) }).catch(() => null);
147
+ if (!r?.ok)
148
+ return undefined;
149
+ try {
150
+ return JSON.parse(await r.text()) ?? undefined;
151
+ }
152
+ catch {
153
+ // A catch-all page that answers every path with HTML isn't a manifest.
154
+ return undefined;
155
+ }
156
+ };
157
+ /** Looks on the website's origin, then the MCP server's. */
158
+ export async function ardChecks(m) {
159
+ const origins = [...new Set([m.links?.website, m.server.url].filter((u) => !!u).map((u) => new URL(u).origin))];
160
+ for (const origin of origins) {
161
+ const [ard, legacy, robots] = await Promise.all([
162
+ getJson(`${origin}${ARD_PATH}`),
163
+ getJson(`${origin}${LEGACY_PATH}`),
164
+ fetch(`${origin}/robots.txt`, { headers: { 'user-agent': 'mcplane' }, signal: AbortSignal.timeout(10_000) })
165
+ .then((r) => (r.ok ? r.text() : ''))
166
+ .catch(() => ''),
167
+ ]);
168
+ if (ard === undefined && legacy === undefined)
169
+ continue;
170
+ const line = robots.match(/^\s*Agentmap:\s*(\S+)/im)?.[1];
171
+ let agentmap;
172
+ if (line) {
173
+ const url = new URL(line, origin).href;
174
+ agentmap = { url, doc: url === `${origin}${ARD_PATH}` ? ard : url === `${origin}${LEGACY_PATH}` ? legacy : await getJson(url) };
175
+ }
176
+ return ardFindings(m.server.url, { origin, ard, legacy, agentmap });
177
+ }
178
+ return [
179
+ {
180
+ id: 'ard.path',
181
+ level: 'info',
182
+ title: 'The ARD manifest is at /.well-known/ard.json',
183
+ detail: `nothing at ${origins.map((o) => `${o}${ARD_PATH}`).join(' or ')}`,
184
+ fix: '"mcplane pack ard" writes one. ARD (Agentic Resource Discovery, v0.91, a proposal) is how agents and registries find what a domain offers, and Lighthouse 13.5 audits it.',
185
+ },
186
+ ];
187
+ }
188
+ /**
189
+ * A manifest for /.well-known/ard.json that Lighthouse 13.5 also accepts: one entry for the MCP
190
+ * server, pointing at its server card when there is one. representativeQueries come from your
191
+ * prompts and test cases, capabilities from your tool names.
192
+ */
193
+ export function ardManifest(m, o = {}) {
194
+ const publisher = new URL(m.links?.website ?? m.server.url).hostname.replace(/^www\./, '');
195
+ const queries = [...new Set([...(m.prompts ?? []), ...(m.tests?.positive ?? []).map((t) => t.prompt)].map((q) => q.trim()).filter((q) => q && q.length <= 160))].slice(0, 5);
196
+ const tools = o.tools?.length ? o.tools : [...new Set((m.tests?.positive ?? []).flatMap((t) => t.tools))];
197
+ const description = m.oneLiner || m.description?.split(/\n\s*\n/)[0];
198
+ return {
199
+ specVersion: '1.0',
200
+ host: { displayName: m.author?.name || m.title, ...(m.links?.docs ? { documentationUrl: m.links.docs } : {}), ...(m.icon ? { logoUrl: m.icon } : {}) },
201
+ entries: [
202
+ {
203
+ identifier: `urn:air:${publisher}:server:${m.name}`,
204
+ displayName: m.title,
205
+ type: MCP_CARD_TYPE,
206
+ url: o.serverCard ?? m.server.url,
207
+ ...(description ? { description } : {}),
208
+ ...(queries.length ? { representativeQueries: queries } : {}),
209
+ ...(tools.length ? { capabilities: tools } : {}),
210
+ ...(m.category ? { tags: [m.category] } : {}),
211
+ ...(m.version ? { version: m.version } : {}),
212
+ },
213
+ ],
214
+ };
215
+ }
@@ -1,3 +1,4 @@
1
+ import { VERSION } from '../version.js';
1
2
  const REDIRECTS = {
2
3
  chatgpt: ['https://chatgpt.com/connector_platform_oauth_redirect', 'https://platform.openai.com/apps-manage/oauth'],
3
4
  claude: ['https://claude.ai/api/mcp/auth_callback'],
@@ -14,7 +15,7 @@ export async function authChecks(url, opts = {}) {
14
15
  const bare = await fetch(url, {
15
16
  method: 'POST',
16
17
  headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'user-agent': 'mcplane' },
17
- body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcplane","version":"0.1.0"}}}',
18
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mcplane', version: VERSION } } }),
18
19
  }).catch(() => null);
19
20
  const www = bare?.headers.get('www-authenticate') ?? '';
20
21
  if (bare?.status === 401 && /resource_metadata=/i.test(www)) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The sign-in chain a reviewer walks: the authorization endpoint, the page it sends them to, and the
3
+ * identity-provider hosts that page loads. Plain GETs, as a browser would send them: nothing signs in
4
+ * and no client is registered, so an authorization server that checks client_id first stops the walk
5
+ * at its own error, and server.signIn (or --sign-in) names the pages to check from there.
6
+ *
7
+ * A bot challenge (Cloudflare's cf-mitigated: challenge) or a 403 anywhere on the chain leaves the
8
+ * OAuth window blank or erroring, often only from the data-centre networks reviewers use, while every
9
+ * check on the MCP server itself passes.
10
+ */
11
+ import type { Check, Manifest } from '../types.js';
12
+ import { type Discovery } from './oauth.js';
13
+ export interface Hop {
14
+ url: string;
15
+ status: number;
16
+ /** What walled it off, if anything. */
17
+ wall: string | null;
18
+ location?: string;
19
+ contentType?: string;
20
+ /** A short reason from the body of an error answer (invalid_client, …). */
21
+ said?: string;
22
+ error?: string;
23
+ }
24
+ /** A bot challenge or a refusal, read from the response the way a browser would get it. */
25
+ export declare function wallOf(status: number, headers: Headers): string | null;
26
+ /** Sign-in pages and identity-provider URLs a page links to or loads. */
27
+ export declare function authLinks(html: string, base: string): string[];
28
+ /** Requests left for one chain, so a long redirect loop or a page full of links can't run away (Workers cap subrequests). */
29
+ export interface Budget {
30
+ left: number;
31
+ }
32
+ /** GETs a URL and follows its redirects by hand, recording every hop. Never follows into the client's redirect URI. */
33
+ export declare function walk(start: string, budget?: Budget): Promise<{
34
+ hops: Hop[];
35
+ html?: string;
36
+ last: string;
37
+ }>;
38
+ /** The authorization request a client would open, for a client that isn't registered. */
39
+ export declare function authorizeUrl(endpoint: string, md: Record<string, any>, resource: string): string;
40
+ /** The chain's hops as one check. Exported for tests. */
41
+ export declare function chainCheck(start: Hop[], pages: Hop[][], opts: {
42
+ stoppedEarly: boolean;
43
+ seeded: boolean;
44
+ }): Check;
45
+ export declare function chainChecks(m: Manifest, d: Discovery | null): Promise<Check[]>;