mcplane 0.4.1 → 0.5.0

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 +27 -0
  2. package/README.md +108 -18
  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 +38 -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 +20 -4
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +10 -3
  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 +48 -18
  37. package/dist/packs.js +380 -90
  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/packs.js CHANGED
@@ -3,14 +3,21 @@
3
3
  * ChatGPT gets the plugin ZIP its portal uploads; the rest get a markdown pack
4
4
  * to paste from.
5
5
  */
6
- import { mkdir, writeFile } from 'node:fs/promises';
6
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
7
  import { dirname, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { ensureLocalDir } from './manifest.js';
9
10
  import { forStore } from './draft.js';
10
11
  import { McpClient } from './mcp-client.js';
11
12
  import { zip } from './zip.js';
12
- const MAX_JUSTIFICATION = 200; // the ChatGPT form cuts longer ones without warning
13
- async function listTools(m, token) {
13
+ import { DESCRIPTION_MAX, MCP_SCHEMA, PLUGIN_SCHEMA, schemaProblems } from './agent-plugins.js';
14
+ import { EXTENSION, iconProblems, imageInfo } from './images.js';
15
+ import { bundleSkills } from './skills.js';
16
+ import { ardManifest, validateArd } from './checks/ard.js';
17
+ import { discover, metadata } from './checks/oauth.js';
18
+ import { CONNECT_CALLBACK, SUBJECTS, clientCreation, vercelChecks } from './checks/vercel.js';
19
+ import { evalSuite, toolId } from './evals.js';
20
+ export async function listTools(m, token) {
14
21
  const c = new McpClient(m.server.url, token ? { authorization: `Bearer ${token}` } : {});
15
22
  const init = await c.initialize();
16
23
  if (init.status >= 400)
@@ -18,32 +25,55 @@ async function listTools(m, token) {
18
25
  const r = await c.request('tools/list');
19
26
  return (r.body?.result?.tools ?? []);
20
27
  }
21
- const firstSentence = (s = '') => (s.match(/^[^.!?]+[.!?]/)?.[0] ?? s).trim();
22
- const VERBS = /^(add|create|get|fetch|list|search|find|update|edit|delete|remove|send|post|publish|mark|set|run|check|look|read|write|upload|download|report|record|submit|start|stop|cancel|return|generate|compare|show|save|share|move|copy|open|close|resolve|restore)\b/i;
23
- /** "Add the user's…" → "adds the user's…"; leaves non-verb openings alone. */
24
- function thirdPerson(s) {
25
- const m = s.match(VERBS);
26
- if (!m)
27
- return null;
28
- const w = m[1].toLowerCase();
29
- const conj = /(s|sh|ch|x|z)$/.test(w) ? `${w}es` : /[^aeiou]y$/.test(w) ? `${w.slice(0, -1)}ies` : `${w}s`;
30
- return conj + s.slice(m[1].length);
28
+ /** A saved tools/list: the result ({ "tools": [...] }), the whole JSON-RPC reply, or just the array. */
29
+ export function parseToolsFile(text, file = 'the tools file') {
30
+ let v;
31
+ try {
32
+ v = JSON.parse(text);
33
+ }
34
+ catch (e) {
35
+ throw new Error(`${file} isn't valid JSON: ${e.message}`);
36
+ }
37
+ const tools = Array.isArray(v) ? v : Array.isArray(v?.tools) ? v.tools : Array.isArray(v?.result?.tools) ? v.result.tools : null;
38
+ if (!tools || tools.some((t) => typeof t?.name !== 'string'))
39
+ throw new Error(`${file} should hold a tools/list result: { "tools": [ { "name": … }, … ] }`);
40
+ return tools;
31
41
  }
32
- const possessive = (name) => (name.endsWith('s') ? `${name}'` : `${name}'s`);
33
- const clip = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1).replace(/\s+\S*$/, '')}…`);
34
- const lower = (s) => s.charAt(0).toLowerCase() + s.slice(1);
35
- /** One-sentence drafts from the declared hints and the tool's own description. Review them: they are a start, not an answer. */
36
- function justify(t, product, own) {
37
- const a = t.annotations ?? {};
38
- const sentence = firstSentence(t.description).replace(/\.$/, '');
39
- const verb = thirdPerson(sentence);
40
- const what = verb ? `it ${verb}` : `the tool: ${lower(sentence)}`;
41
- return {
42
- read_only_justification: clip(own?.readOnly ?? (a.readOnlyHint ? 'Only looks up and returns data; it does not create, change or delete anything.' : `Writes data: ${what}.`), MAX_JUSTIFICATION),
43
- open_world_justification: clip(own?.openWorld ?? (a.openWorldHint ? `Can change public or third-party state: ${what}.` : `Works only within ${possessive(product)} own service; it does not post, publish or send anything elsewhere.`), MAX_JUSTIFICATION),
44
- destructive_justification: clip(own?.destructive ??
45
- (a.destructiveHint ? `Can delete or overwrite data: it ${what}.` : a.readOnlyHint ? 'Read-only, so it cannot delete or overwrite anything.' : 'Only adds or updates the user’s own data; it never deletes or overwrites existing records.'), MAX_JUSTIFICATION),
46
- };
42
+ /**
43
+ * The tool list a pack checks against: a saved file when given, else the live server. A pack that
44
+ * doesn't need the list to build (the ChatGPT ZIP, the Claude form) still builds when neither works.
45
+ */
46
+ async function toolsFor(m, opts) {
47
+ if (opts.tools) {
48
+ const path = resolve(opts.dir ?? process.cwd(), opts.tools);
49
+ const text = await readFile(path, 'utf8').catch(() => {
50
+ throw new Error(`Can't read ${opts.tools}.`);
51
+ });
52
+ return { tools: parseToolsFile(text, opts.tools) };
53
+ }
54
+ try {
55
+ return { tools: await listTools(m, opts.token) };
56
+ }
57
+ catch (e) {
58
+ return { tools: null, why: e.message };
59
+ }
60
+ }
61
+ const withoutTools = (why, checked) => `${why ?? 'No tool list.'} The pack was built without it, so ${checked} weren't checked. --tools <file> takes a saved tools/list result instead.`;
62
+ /**
63
+ * OpenAI no longer asks for hint justifications: its automated review checks the hints and you appeal
64
+ * if it flags one. Justifications already written in mcplane.json are kept as notes for that appeal.
65
+ * With no tool list, every note is kept and the hint values are left out.
66
+ */
67
+ function appealNotes(m, tools) {
68
+ const live = new Map((tools ?? []).map((t) => [t.name, t.annotations ?? {}]));
69
+ return Object.entries(m.justifications ?? {})
70
+ .filter(([name]) => !tools || live.has(name))
71
+ .map(([name, j]) => {
72
+ const a = live.get(name);
73
+ const line = (hint, value, why) => (why ? `\n- ${hint}${a ? ` ${!!value}` : ''}: ${why}` : '');
74
+ return `### ${name}${line('readOnlyHint', a?.readOnlyHint, j.readOnly)}${line('openWorldHint', a?.openWorldHint, j.openWorld)}${line('destructiveHint', a?.destructiveHint, j.destructive)}`;
75
+ })
76
+ .join('\n\n');
47
77
  }
48
78
  /** The categories ChatGPT's package validator accepts, and the old form's names mapped onto them. */
49
79
  const CATEGORIES = ['Productivity', 'Creativity', 'Developer Tools', 'Business & Operations', 'Data & Analytics', 'Communication', 'Education & Research', 'Security', 'Finance', 'Healthcare', 'Travel', 'Entertainment', 'Other'];
@@ -66,40 +96,55 @@ function contrast(a, b) {
66
96
  const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
67
97
  return (x + 0.05) / (y + 0.05);
68
98
  }
69
- /** Downloads an icon and checks it against the package rules: square, 48 to 4096 px, 5 MiB, PNG/JPEG/WebP/SVG. */
70
- async function fetchIcon(url, label, problems) {
71
- const res = await fetch(url, { headers: { 'user-agent': 'mcplane' } }).catch(() => null);
72
- if (!res?.ok) {
73
- problems.push(`${label} ${url} didn't load (HTTP ${res?.status ?? 'error'})`);
74
- return null;
99
+ /**
100
+ * An icon for the package, from a URL or a local file (relative to mcplane.json), checked against the
101
+ * package rules: PNG, JPEG, WebP or SVG by its bytes, square, 48 to 4096 px, at most 5 MiB.
102
+ */
103
+ async function loadIcon(ref, label, dir, problems) {
104
+ let data;
105
+ if (/^https?:\/\//i.test(ref)) {
106
+ const res = await fetch(ref, { headers: { 'user-agent': 'mcplane' }, signal: AbortSignal.timeout(20_000) }).catch(() => null);
107
+ if (!res?.ok) {
108
+ problems.push(`${label} ${ref} didn't load (HTTP ${res?.status ?? 'error'})`);
109
+ return null;
110
+ }
111
+ data = new Uint8Array(await res.arrayBuffer());
75
112
  }
76
- const type = res.headers.get('content-type') ?? '';
77
- const data = new Uint8Array(await res.arrayBuffer());
78
- const ext = type.includes('svg') || url.endsWith('.svg') ? 'svg' : type.includes('jpeg') || /\.jpe?g$/.test(url) ? 'jpg' : type.includes('webp') || url.endsWith('.webp') ? 'webp' : 'png';
113
+ else {
114
+ const path = /^file:/i.test(ref) ? fileURLToPath(ref) : resolve(dir, ref);
115
+ const read = await readFile(path).catch(() => null);
116
+ if (!read) {
117
+ problems.push(`${label} ${ref} isn't a file (paths are relative to mcplane.json)`);
118
+ return null;
119
+ }
120
+ data = new Uint8Array(read);
121
+ }
122
+ const info = imageInfo(data);
79
123
  if (data.length > 5 * 1024 * 1024)
80
124
  problems.push(`${label} is ${(data.length / 1048576).toFixed(1)} MiB; the limit is 5 MiB`);
81
- if (ext === 'png' && data[0] === 0x89) {
82
- const v = new DataView(data.buffer, data.byteOffset);
83
- const [w, h] = [v.getUint32(16), v.getUint32(20)];
84
- if (w !== h)
85
- problems.push(`${label} is ${w}×${h}; it must be square`);
86
- else if (w < 48 || w > 4096)
87
- problems.push(`${label} is ${w} px; it must be 48 to 4096 px`);
125
+ if (!info.format) {
126
+ problems.push(`${label} ${ref} isn't a PNG, JPEG, WebP or SVG image`);
127
+ return null;
88
128
  }
89
- return { data, ext };
129
+ problems.push(...iconProblems(info, label));
130
+ return { data, ext: EXTENSION[info.format] };
90
131
  }
91
132
  /**
92
133
  * ChatGPT: the plugin ZIP the portal takes since DevDay (27 Sep 2026). plugin.json in the
93
134
  * Agent Plugins format carries the listing, the 5+3 test cases, demo video, release notes
94
- * and translations; mcp.json points at the server; icons are bundled. Reviewer credentials,
95
- * hint justifications and the policy attestations stay in the dashboard.
135
+ * and translations; mcp.json points at the server; icons are bundled. Reviewer credentials
136
+ * and the policy attestations stay in the dashboard.
96
137
  */
97
- export async function chatgptPack(m, token) {
138
+ export async function chatgptPack(m, opts = {}) {
98
139
  m = forStore(m, 'chatgpt');
99
140
  const g = m.chatgpt ?? {};
100
- const tools = await listTools(m, token);
141
+ const dir = opts.dir ?? process.cwd();
142
+ const listed = await toolsFor(m, opts);
143
+ const tools = listed.tools ?? [];
101
144
  const problems = [];
102
145
  const todo = [];
146
+ if (!listed.tools)
147
+ todo.push(withoutTools(listed.why, 'tool hints and the tools your test cases name'));
103
148
  const need = (ok, msg) => {
104
149
  if (!ok)
105
150
  problems.push(msg);
@@ -139,15 +184,11 @@ export async function chatgptPack(m, token) {
139
184
  problems.push(`brandColor ${g.brandColor} needs 2:1 contrast against white`);
140
185
  if (g.brandColorDark && (!/^#[0-9a-fA-F]{6}$/.test(g.brandColorDark) || contrast(g.brandColorDark, '#212121') < 2))
141
186
  problems.push(`brandColorDark ${g.brandColorDark} needs 2:1 contrast against #212121`);
142
- need(g.demoVideo, 'chatgpt.demoVideo is missing: a reviewer-accessible video URL showing the main use cases (required for review)');
187
+ need(g.demoVideo, 'chatgpt.demoVideo is missing: a reviewer-accessible video URL showing the main use cases (required for review; "mcplane demo" records one)');
143
188
  need(g.releaseNotes, 'chatgpt.releaseNotes is missing (required for review)');
144
189
  const missingHints = tools.filter((t) => ['readOnlyHint', 'openWorldHint', 'destructiveHint'].some((h) => typeof t.annotations?.[h] !== 'boolean'));
145
190
  for (const t of missingHints)
146
191
  problems.push(`${t.name} doesn't set all three hints; ChatGPT treats that as a blocker`);
147
- for (const [name, j] of Object.entries(m.justifications ?? {}))
148
- for (const [k, v] of Object.entries(j))
149
- if (v && v.length > MAX_JUSTIFICATION)
150
- problems.push(`justifications.${name}.${k} is ${v.length} characters; ChatGPT cuts at ${MAX_JUSTIFICATION}`);
151
192
  const pos = m.tests?.positive ?? [];
152
193
  const neg = m.tests?.negative ?? [];
153
194
  const names = new Set(tools.map((t) => t.name));
@@ -155,35 +196,44 @@ export async function chatgptPack(m, token) {
155
196
  problems.push(`tests.positive has ${pos.length}; ChatGPT needs exactly 5`);
156
197
  if (neg.length !== 3)
157
198
  problems.push(`tests.negative has ${neg.length}; ChatGPT needs exactly 3`);
158
- for (const p of pos)
159
- for (const n of p.tools)
160
- if (!names.has(n))
161
- problems.push(`test "${p.scenario}" names a tool the server doesn't have: ${n}`);
199
+ if (listed.tools)
200
+ for (const p of pos)
201
+ for (const n of p.tools)
202
+ if (!names.has(n))
203
+ problems.push(`test "${p.scenario}" names a tool the server doesn't have: ${n}`);
162
204
  const covered = new Set(pos.flatMap((p) => p.tools));
163
205
  for (const t of tools)
164
206
  if (!covered.has(t.name))
165
207
  todo.push(`No positive test uses ${t.name}. Reviewers check every tool.`);
166
- // Icons, bundled into the package.
208
+ // Icons, bundled into the package, from URLs or local files.
167
209
  const assets = [];
168
- const icon = async (url, file, label) => {
169
- if (!url)
210
+ const icon = async (ref, file, label) => {
211
+ if (!ref)
170
212
  return undefined;
171
- const got = await fetchIcon(url, label, problems);
213
+ const got = await loadIcon(ref, label, dir, problems);
172
214
  if (!got)
173
215
  return undefined;
174
216
  const path = `assets/${file}.${got.ext}`;
175
217
  assets.push({ path, data: got.data });
176
218
  return `./${path}`;
177
219
  };
178
- need(m.icon, 'icon is missing: a direct URL to a square image, 48 to 4096 px');
179
- const logo = await icon(m.icon, 'logo', 'icon');
220
+ const logoRef = g.logo ?? m.icon;
221
+ need(logoRef, 'icon is missing: a square image, 48 to 4096 px (a URL, or a local file in chatgpt.logo)');
222
+ const logo = await icon(logoRef, 'logo', g.logo ? 'chatgpt.logo' : 'icon');
180
223
  const composerIcon = (await icon(g.composerIcon, 'composer-icon', 'chatgpt.composerIcon')) ?? logo;
181
224
  const logoDark = await icon(g.logoDark, 'logo-dark', 'chatgpt.logoDark');
225
+ // Agent Skills, under skills/<folder>/ as the Agent Plugins format discovers them.
226
+ const skills = await bundleSkills(g.skills, dir, m.name);
227
+ problems.push(...skills.problems);
228
+ todo.push(...skills.todo);
229
+ const description = m.oneLiner || m.subtitle || m.title;
230
+ if (description.length > DESCRIPTION_MAX)
231
+ problems.push(`plugin.json's description, taken from oneLiner, is ${description.length} characters; the limit is ${DESCRIPTION_MAX} (plugin_description_too_long). listing.chatgpt.oneLiner can shorten it for ChatGPT only`);
182
232
  const plugin = {
183
- $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
233
+ $schema: PLUGIN_SCHEMA,
184
234
  name: m.name,
185
235
  version: m.version ?? '1.0.0',
186
- description: m.oneLiner ?? m.subtitle ?? m.title,
236
+ description,
187
237
  author: { name: developerName ?? m.title, ...(m.author?.url ? { url: m.author.url } : {}) },
188
238
  ...(m.links?.website ? { homepage: m.links.website } : {}),
189
239
  ...(m.repository ? { repository: m.repository } : {}),
@@ -224,29 +274,23 @@ export async function chatgptPack(m, token) {
224
274
  },
225
275
  },
226
276
  };
227
- const mcp = { $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', mcpServers: { [m.name]: { type: 'streamable-http', url: m.server.url } } };
277
+ const mcp = { $schema: MCP_SCHEMA, mcpServers: { [m.name]: { type: 'streamable-http', url: m.server.url } } };
278
+ problems.push(...(await schemaProblems(plugin, mcp)));
228
279
  const pkg = zip([
229
280
  { path: 'plugin.json', data: JSON.stringify(plugin, null, 2) + '\n' },
230
281
  { path: 'mcp.json', data: JSON.stringify(mcp, null, 2) + '\n' },
231
282
  ...assets,
283
+ ...skills.files,
232
284
  ]);
285
+ if (pkg.length > 100 * 1024 * 1024)
286
+ problems.push(`the ZIP is ${(pkg.length / 1048576).toFixed(0)} MB; the limit is 100 MB`);
233
287
  const zipName = `${m.name}-${m.version ?? '1.0.0'}.zip`;
234
- // Justifications still go in the dashboard, one per hint, per tool.
235
- const justified = tools
236
- .map((t) => {
237
- const j = justify(t, m.title, m.justifications?.[t.name]);
238
- const a = t.annotations ?? {};
239
- return `### ${t.name}\n- readOnlyHint ${!!a.readOnlyHint}: ${j.read_only_justification}\n- openWorldHint ${!!a.openWorldHint}: ${j.open_world_justification}\n- destructiveHint ${!!a.destructiveHint}: ${j.destructive_justification}`;
240
- })
241
- .join('\n\n');
242
- const drafted = tools.filter((t) => !m.justifications?.[t.name]).map((t) => t.name);
243
- if (drafted.length)
244
- todo.push(`Read the drafted justifications for ${drafted.join(', ')} in chatgpt.md. Once they say exactly what each tool changes, save them under "justifications" in mcplane.json.`);
288
+ const notes = appealNotes(m, listed.tools);
245
289
  const steps = `# ChatGPT submission: ${m.title} ${m.version ?? ''}
246
290
 
247
291
  ## 1. Upload the package
248
292
  platform.openai.com/plugins → **Upload new or existing plugin** → choose your verified developer identity → upload \`${zipName}\`.
249
- It fills the listing, icons, test cases, demo video, release notes and translations. To change any of them later, edit mcplane.json, bump "version" and run \`mcplane pack chatgpt\` again.
293
+ It fills the listing, icons, test cases, demo video, release notes and translations${skills.names.length ? `, and adds ${skills.names.length === 1 ? 'the skill' : `${skills.names.length} skills:`} ${skills.names.join(', ')}` : ''}. To change any of them later, edit mcplane.json, bump "version" and run \`mcplane pack chatgpt\` again.${skills.names.length ? '\nSkills are scanned too, which can take up to 2 hours. If one is flagged, take it out of chatgpt.skills and upload again; skills can come back in a later version.' : ''}
250
294
 
251
295
  ## 2. Resolve findings
252
296
  Open **Metadata & Skills** and **MCPs**, wait for the checks, fix anything listed (Copy issues is handy), and upload again if the package changes.
@@ -254,16 +298,15 @@ Domain verification: serve the token it gives you as plain text at ${new URL(m.s
254
298
 
255
299
  ## 3. What only you can enter
256
300
  - **Review details:** reviewer credentials${m.server.auth === 'oauth' ? ` (${m.reviewerAccess ?? 'an email-and-password account with no 2FA, SMS or email confirmation, seeded for every test case'})` : ' (none needed: no sign-in)'}. Credentials never go in the ZIP.
257
- - **Hint justifications**, one per value on every tool (200 characters each, cut without warning):
258
-
259
- ${justified}
260
-
261
301
  - **Submit for review** and the policy attestations.
262
302
 
303
+ Hints need no justification: OpenAI's automated review checks readOnlyHint, destructiveHint and openWorldHint against what each tool does. If it flags one you believe is right, appeal with an explanation.${notes ? `\n\n## Appeal notes (optional)\nYour "justifications" in mcplane.json, for an appeal if review flags a hint:\n\n${notes}` : ''}
304
+
263
305
  ## Traps
264
306
  - One review can be active per plugin. To replace a package in review, cancel the review first.
265
307
  - "Submit for review" can show nothing for a while; confirm the status on the Plugins page.
266
308
  - Tool changes don't need a new package: OpenAI's scans pick them up. Listing changes do.
309
+ - The package takes icons up to 5 MiB, but developer mode's own icon upload (when you add the server by hand to record the demo) takes 10 KB at most.
267
310
 
268
311
  When it's in: \`mcplane submitted chatgpt --version ${m.version ?? '1.0.0'} --app-id <asdk_app_… from the URL>\`
269
312
  `;
@@ -283,10 +326,12 @@ function readWrite(tools) {
283
326
  return 'Read only';
284
327
  return writes.length === tools.length ? 'Write only (check: most servers also read)' : 'Read and write';
285
328
  }
286
- export async function claudePack(m, token) {
329
+ export async function claudePack(m, opts = {}) {
287
330
  m = forStore(m, 'claude-connectors');
288
- const tools = await listTools(m, token);
331
+ const listed = await toolsFor(m, opts);
332
+ const tools = listed.tools ?? [];
289
333
  const problems = [];
334
+ const todo = listed.tools ? [] : [withoutTools(listed.why, 'the tool list and the read/write answer')];
290
335
  if ((m.oneLiner ?? '').length > 200)
291
336
  problems.push(`oneLiner is ${m.oneLiner.length} characters; the limit is 200`);
292
337
  if ((m.description ?? '').length > 2000)
@@ -307,7 +352,7 @@ Submit at claude.ai/directory/manage/new (any paid Claude plan). Ten steps; the
307
352
  - Authentication: ${m.server.auth === 'oauth' ? 'OAuth 2.0 + Dynamic Client Registration (Anthropic says DCR works best)' : 'No authentication (authless)'}
308
353
 
309
354
  ## 2. Tools
310
- Every tool needs a title and explicit hints. Current tools: ${tools.map((t) => t.name).join(', ')}.
355
+ Every tool needs a title and explicit hints. Current tools: ${listed.tools ? tools.map((t) => t.name).join(', ') : 'unknown (pass --token or --tools)'}.
311
356
 
312
357
  ## 3. Listing
313
358
  - Name: ${m.title}
@@ -327,7 +372,7 @@ ${m.description ?? 'MISSING'}
327
372
  ${useCases || 'MISSING'}
328
373
 
329
374
  - Connection requirements: ${m.server.auth === 'oauth' ? 'An account on your service; say which plan.' : 'None. No account, sign-in or API key.'}
330
- - Read / write: ${readWrite(tools)}
375
+ - Read / write: ${listed.tools ? readWrite(tools) : 'unknown (pass --token or --tools)'}
331
376
 
332
377
  ## 6. Authentication
333
378
  Match step 1. Partial auth is available if only some tools need sign-in.
@@ -347,7 +392,252 @@ Seven statements you make yourself. One asks that tool descriptions contain no i
347
392
  "Passed review" is not the same as listed: check claude.ai/directory, and choose when to publish.
348
393
  When it's in: \`mcplane submitted claude-connectors\`
349
394
  `;
350
- return { files: [{ path: 'claude-connectors.md', content: md }], problems, todo: [] };
395
+ return { files: [{ path: 'claude-connectors.md', content: md }], problems, todo };
396
+ }
397
+ const CONNECT_SUBMIT = 'https://vercel.com/d?to=%2F%5Bteam%5D%2F~%2Fconnect%2Fsubmit-service&title=Submit+a+Service';
398
+ /**
399
+ * Vercel Connect: every value for Submit a Service, the OAuth method as Connect will discover it,
400
+ * and the token test each OAuth method needs before the form lets you submit.
401
+ * https://vercel.com/docs/connect/providers#submit-your-service-to-vercel-connect
402
+ */
403
+ export async function vercelConnectPack(m) {
404
+ m = forStore(m, 'vercel-connect');
405
+ const v = m.vercelConnect ?? {};
406
+ const oauth = m.server.auth === 'oauth';
407
+ const d = oauth ? await discover(m.server.url).catch(() => null) : null;
408
+ const md = metadata(d) ?? {};
409
+ // Required-tier failures block; so does having no connection method at all.
410
+ const problems = vercelChecks(m, d)
411
+ .filter((c) => c.level === 'fail' || (c.id === 'vercel.method' && c.level !== 'pass'))
412
+ .map((c) => `${c.title}: ${c.detail ?? ''}`.replace(/: $/, ''));
413
+ const description = m.description ?? m.oneLiner;
414
+ if (!description)
415
+ problems.push('description (or oneLiner) is missing; Service Information describes your service');
416
+ const todo = [];
417
+ if (m.icon && !/\.svg(\?|$)/i.test(m.icon))
418
+ todo.push(`Vercel takes an optional SVG icon and yours is ${m.icon.split('/').pop()}: export an SVG to upload.`);
419
+ const cc = clientCreation(md);
420
+ const auto = cc.dcr || cc.cimd;
421
+ const grants = Array.isArray(md.grant_types_supported) ? md.grant_types_supported : [];
422
+ const declared = Array.isArray(md.scopes_supported) ? md.scopes_supported : [];
423
+ const scopes = v.scopes ?? declared;
424
+ if (oauth && auto && v.scopes && v.scopes.join(' ') !== declared.join(' '))
425
+ todo.push('Your default scopes differ from scopes_supported. Vercel treats that as an OAuth override and tests it with Automatic Registration off, using a client ID from your service.');
426
+ const host = new URL(m.server.url).host;
427
+ const found = d ? [d.prm && `protected-resource metadata at ${d.prm.url}`, `issuer ${d.issuer}`, (d.oauth ?? d.oidc)?.url].filter(Boolean).join(' → ') : '';
428
+ const oauthMethod = oauth
429
+ ? `### OAuth
430
+ - Server URL: ${m.server.url} (discovery runs when you enter it)
431
+ - What Connect finds: ${found || 'nothing (see the problems mcplane printed)'}
432
+ - Endpoints: authorization ${md.authorization_endpoint ?? 'MISSING'}, token ${md.token_endpoint ?? 'MISSING'}${md.registration_endpoint ? `, registration ${md.registration_endpoint}` : ''}${md.revocation_endpoint ? `, revocation ${md.revocation_endpoint}` : ''}
433
+ - Automatic Registration: ${auto ? `on, through ${[cc.dcr && 'Dynamic Client Registration', cc.cimd && 'Client ID Metadata Documents'].filter(Boolean).join(' or ')}` : `not offered. Register an application with your service using the redirect URL ${CONNECT_CALLBACK}, then enter its client ID (and secret, if you issue one); the draft doesn't keep them`}
434
+ - Client authentication: ${cc.usable.join(', ') || cc.methods?.join(', ') || 'not declared'}
435
+ - Grants: ${grants.map((g) => (SUBJECTS[g] ? `${g} (${SUBJECTS[g]} tokens)` : g === 'refresh_token' ? 'refresh_token (renewal)' : g)).join(', ') || 'MISSING'}
436
+ - Default scopes: ${scopes.length ? scopes.join(' ') : 'none declared'}${v.scopes ? ' (from mcplane.json)' : ''}
437
+ - Redirect URL to allow: ${CONNECT_CALLBACK} (an exact match; no wildcard needed)
438
+ `
439
+ : '';
440
+ const keyMethod = v.apiKey
441
+ ? `### API key
442
+ - Where users create a key: ${v.apiKey.docs ?? 'MISSING: set vercelConnect.apiKey.docs'}
443
+ - Sent as: ${v.apiKey.header ?? 'Authorization: Bearer <key>'}
444
+ - No token test: Vercel validates the configuration only.
445
+ `
446
+ : '';
447
+ const sheet = `# Vercel Connect submission: ${m.title}
448
+
449
+ Submit a Service: ${CONNECT_SUBMIT}
450
+ (Vercel dashboard → Connect → Browse Connectors → Submit a Service. You need permission to create connectors in the team. The draft is kept in your browser, for your account and team only.)
451
+
452
+ ## 1. Service
453
+ - Service Name: ${m.title}
454
+ - Icon: optional, an SVG upload${m.icon ? ` (yours: ${m.icon})` : ''}
455
+ - Service Information:
456
+ - Description: ${description ?? 'MISSING'}
457
+ - Website: ${m.links?.website ?? 'MISSING'}
458
+ - Documentation: ${m.links?.docs ?? 'none'}
459
+ - Support: ${m.links?.support ?? 'none'}
460
+ - Privacy policy: ${m.links?.privacy ?? 'MISSING'}
461
+ - Terms: ${m.links?.terms ?? 'none'}
462
+ - Contact Email: yours. Vercel writes to it if it needs more details (not kept in mcplane.json)
463
+
464
+ Field names follow Vercel's docs; the form may word them differently.
465
+
466
+ ## 2. Targets (optional)
467
+ - MCP server: ${m.server.url}
468
+ - REST API: ${v.apiBase ?? 'none in mcplane.json (vercelConnect.apiBase adds one)'}
469
+
470
+ ## 3. Connection methods
471
+ ${[oauthMethod, keyMethod].filter(Boolean).join('\n') || 'MISSING: Vercel Connect needs OAuth or an API key. An authless server has nothing for it to connect.\n'}
472
+ ## 4. Test each OAuth method
473
+ ${oauth
474
+ ? `Optional, from a terminal, to see what Connect discovers before you open the form:
475
+
476
+ vercel connect create ${host}
477
+ vercel connect token <connector uid> # a user token; "vercel connect list" shows the uid
478
+
479
+ Then in the form, for each OAuth method:
480
+ 1. Preview & Test.
481
+ 2. Set Up Test Connector${auto ? ', with Automatic Registration on (the toggle only appears because your metadata advertises DCR or CIMD)' : ''}.
482
+ 3. Create Test Connector. It appears under Test Connectors, selected for submission.
483
+ 4. Test User Token${grants.includes('client_credentials') ? ' (Test App Token for client credentials)' : ''}, with the method's default scopes selected, and sign in as a user in the popup.
484
+ 5. Wait for "Token retrieved".
485
+ `
486
+ : 'API key methods need no test: Vercel validates the configuration when you submit.\n'}
487
+ ## 5. Submit
488
+ Review Submission, then Submit for Review. The page shows Service Submitted, a submission ID and each method's status (${[oauth && 'OAuth: Token Verified', v.apiKey && 'API key: Configuration Valid'].filter(Boolean).join('; ') || 'one per connection method'}). Vercel reviews the submission before publishing it to the directory. Keep the test connector and its token in your team until then: Vercel re-checks the test on its servers.
489
+
490
+ To change it later: Edit and Resubmit.
491
+
492
+ ## If the form refuses
493
+ | Message | What to do |
494
+ |---|---|
495
+ | Create a matching test connector and retrieve a token. | The selected connector has no token, or it tested an earlier configuration. Create a new test connector and test a token. |
496
+ | Test the default scopes configured for this connection method. | Test the token again with the default scopes selected. |
497
+ | Create a fresh connector with automatic registration, or disable automatic registration and test manual setup. | Create a new test connector with Automatic Registration on, or turn it off. |
498
+ | Obtain a new token for the current connector configuration and discovered metadata. | The connector or your discovery documents changed after the test. Create a new test connector and test a token. |
499
+ | These OAuth overrides require manual setup. Turn off Auto Registration to test this configuration. | The method customises scopes or other OAuth settings. Turn off Automatic Registration and enter a client ID from your service. |
500
+
501
+ ## Traps
502
+ - A token test only counts for the exact configuration it tested. Change a URL, an OAuth setting, a template field or Automatic Registration, or edit your discovery documents, and you need a new test connector and token.
503
+ - Return expires_in in token responses. Without it Connect guesses the lifetime and your API sees expired tokens.
504
+ - If you rotate refresh tokens, return the new one in every refresh response, or users re-authorise more often than they should.
505
+ - Return registration_access_token and registration_client_uri from registration (RFC 7592), so Connect can keep a DCR client in step when a user renames a connector or changes its icon.
506
+ - Reset Draft clears the browser draft but leaves test connectors in your team.
507
+
508
+ When it's in: \`mcplane submitted vercel-connect\`
509
+ `;
510
+ return { files: [{ path: 'vercel-connect.md', content: sheet }], problems, todo };
511
+ }
512
+ /** ARD: one manifest for /.well-known/ard.json that Lighthouse 13.5 also accepts at ai-catalog.json. */
513
+ export async function ardPack(m, opts = {}) {
514
+ const tools = (await toolsFor(m, opts)).tools ?? [];
515
+ const card = `${new URL(m.server.url).origin}/.well-known/mcp/server-card.json`;
516
+ const res = await fetch(card, { headers: { accept: 'application/json', 'user-agent': 'mcplane' }, signal: AbortSignal.timeout(10_000) }).catch(() => null);
517
+ const hasCard = !!res?.ok && /json/i.test(res.headers.get('content-type') ?? '') && !!(await res.json().catch(() => null));
518
+ const doc = ardManifest(m, { tools: tools.map((t) => t.name), serverCard: hasCard ? card : undefined });
519
+ const v = validateArd(doc);
520
+ const site = new URL(m.links?.website ?? m.server.url).origin;
521
+ const todo = v.warnings.map((w) => `${w} (2 to 5 representative queries make it findable by search; add prompts or tests to mcplane.json)`);
522
+ if (!hasCard)
523
+ todo.push(`No server card at ${card}, so the entry points at the MCP endpoint itself. MCP Server Cards (SEP-2127) are still a draft; run this again once you serve one.`);
524
+ if (!tools.length)
525
+ todo.push('capabilities came from your test cases: tools/list needs sign-in (pass --token, or --tools <file>).');
526
+ const md = `# ARD manifest: ${m.title}
527
+
528
+ Serve ard.json, as application/json, at:
529
+ - ${site}/.well-known/ard.json (ARD v0.91: every consumer must read this one)
530
+ - ${site}/.well-known/ai-catalog.json (the predecessor path, which Lighthouse 13.5 still audits), or point to ard.json from robots.txt:
531
+
532
+ Agentmap: ${site}/.well-known/ard.json
533
+
534
+ Lighthouse reads an Agentmap line before ai-catalog.json, so either works for it. In your pages, \`<link rel="ard" href="/.well-known/ard.json">\` is the v0.91 link.
535
+
536
+ ARD (Agentic Resource Discovery) is a proposal: v0.91, 26 August 2026, https://agenticresourcediscovery.org/spec/
537
+ The entry follows the v0.91 entry schema. specVersion "1.0" and host are there for Lighthouse's older ai-catalog schema; ARD ignores them.
538
+
539
+ Check what's live: \`mcplane preflight\` (the ard.* checks).
540
+ `;
541
+ return {
542
+ files: [
543
+ { path: 'ard/ard.json', content: JSON.stringify(doc, null, 2) + '\n' },
544
+ { path: 'ard/ard.md', content: md },
545
+ ],
546
+ problems: [...v.errors, ...v.lighthouse],
547
+ todo,
548
+ };
549
+ }
550
+ /** The plugin and MCP server names Claude Code builds tool names from: claudePlugin in mcplane.json, else the public plugin repo, else the manifest's name. */
551
+ async function evalPlugin(m) {
552
+ let plugin = m.claudePlugin?.name;
553
+ let server = m.claudePlugin?.server;
554
+ const gh = m.repository?.match(/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?$/);
555
+ if ((!plugin || !server) && gh) {
556
+ const raw = (p) => fetch(`https://raw.githubusercontent.com/${gh[1]}/${gh[2]}/HEAD/${p}`, { headers: { 'user-agent': 'mcplane' }, signal: AbortSignal.timeout(10_000) })
557
+ .then((r) => (r.ok ? r.json() : null))
558
+ .catch(() => null);
559
+ const [manifest, mcp] = await Promise.all([raw('.claude-plugin/plugin.json'), raw('.mcp.json')]);
560
+ const servers = { ...(manifest?.mcpServers && typeof manifest.mcpServers === 'object' ? manifest.mcpServers : {}), ...(mcp?.mcpServers ?? {}) };
561
+ const bare = (u) => (u ?? '').replace(/\/+$/, '').toLowerCase();
562
+ plugin ??= manifest?.name;
563
+ server ??= Object.keys(servers).find((k) => bare(servers[k]?.url) === bare(m.server.url)) ?? Object.keys(servers)[0];
564
+ }
565
+ return { plugin: plugin ?? m.name, server: server ?? m.name, guessed: !plugin || !server };
566
+ }
567
+ /**
568
+ * Claude Code plugin evals: mcplane.json's review tests as a "claude plugin eval" suite, with mocks
569
+ * from your live tools/list, to copy into the plugin repo.
570
+ * https://code.claude.com/docs/en/plugin-evals
571
+ */
572
+ export async function claudeEvalPack(m, opts = {}) {
573
+ const [p, tools] = await Promise.all([evalPlugin(m), toolsFor(m, opts).then((r) => r.tools ?? [])]);
574
+ const suite = evalSuite(m, p, tools);
575
+ const pos = m.tests?.positive?.length ?? 0;
576
+ const neg = m.tests?.negative?.length ?? 0;
577
+ const pattern = toolId(p, '*');
578
+ const todo = [];
579
+ if (p.guessed)
580
+ todo.push(`The plugin and server names are a guess ("${p.plugin}", "${p.server}"). If yours differ, set claudePlugin.name and claudePlugin.server in mcplane.json: tool graders match ${toolId(p, '<tool>')}.`);
581
+ if (!tools.length)
582
+ todo.push('tools/list needs sign-in, so the mocks have no real descriptions or schemas (pass --token, or --tools <file>).');
583
+ todo.push(`Copy .mcplane/packs/claude-eval/evals/ into the plugin repo${m.repository ? ` (${m.repository})` : ''}, then run "claude plugin eval . --runs 1 --ablation none" for a cheap first pass.`);
584
+ const readme = `# Plugin evals: ${m.title}
585
+
586
+ ${pos} positive and ${neg} negative cases from mcplane.json's review tests, for "claude plugin eval". They test the plugin "${p.plugin}", whose MCP server is "${p.server}", so its tools are ${pattern}.
587
+
588
+ ## Use it
589
+ 1. Copy evals/ into the plugin repo, next to .claude-plugin/. Add evals/results/ to .gitignore.
590
+ 2. A cheap first pass while you tune graders: \`claude plugin eval . --runs 1 --ablation none\`
591
+ 3. The full suite, three runs a case, against a no-plugin baseline: \`claude plugin eval .\`
592
+
593
+ Every run and every llm grader is a model call on your account.
594
+
595
+ ## What each case checks
596
+ - positive-*: an llm judge on the reply, against the expected result in your test. The calls-<tool> graders show whether the plugin's tools were called; they aren't scored, because they can't pass without the plugin and would inflate its delta.
597
+ - negative-*: none of the server's tools was called, scored with and without the plugin.
598
+
599
+ ## Mocks
600
+ mocks/${p.server}/ answers every tool with a small model (type: agent)${tools.length ? ', using the descriptions and schemas in _tools.json, saved from your live tools/list' : ''}. Each positive case has its own mock for the tools it names, so the data fits the request. Nothing reaches your server and nothing is written.
601
+
602
+ To use the real server instead: \`claude plugin eval . --mocks off --allow-tools "${pattern}"\`. Only do that when every tool is read-only: write tools would really write.
603
+
604
+ ## CI
605
+ \`\`\`yaml
606
+ # .github/workflows/plugin-eval.yml in the plugin repo
607
+ on: [pull_request]
608
+ jobs:
609
+ eval:
610
+ runs-on: ubuntu-latest
611
+ steps:
612
+ - uses: actions/checkout@v4
613
+ - run: npm install -g @anthropic-ai/claude-code
614
+ - run: claude plugin eval . --trust-plugin --json results.json --threshold 0.8 --model claude-sonnet-5 --judge-model claude-haiku-4-5 --no-publish --max-cost-usd 5
615
+ env:
616
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
617
+ \`\`\`
618
+
619
+ Exit code 1 means a case scored under the threshold or a case file didn't load; 2 means the cost ceiling stopped the run.
620
+
621
+ Regenerate after changing the tests in mcplane.json: \`mcplane pack claude-eval\`.
622
+ `;
623
+ return { files: [...suite.files.map((f) => ({ path: `claude-eval/${f.path}`, content: f.content })), { path: 'claude-eval/README.md', content: readme }], problems: suite.problems, todo };
624
+ }
625
+ /** Every pack by name: a store with a form, or one of the extras. */
626
+ export const PACKS = ['chatgpt', 'claude-connectors', 'vercel-connect', 'cursor', 'muse', 'directories', 'ard', 'claude-eval'];
627
+ export async function packFor(m, name, opts = {}) {
628
+ if (name === 'directories')
629
+ return directoriesPack(m);
630
+ if (name === 'ard')
631
+ return ardPack(m, opts);
632
+ if (name === 'claude-eval')
633
+ return claudeEvalPack(m, opts);
634
+ if (name === 'chatgpt')
635
+ return chatgptPack(m, opts);
636
+ if (name === 'claude-connectors')
637
+ return claudePack(m, opts);
638
+ if (name === 'vercel-connect')
639
+ return vercelConnectPack(m);
640
+ return simplePack(m, name);
351
641
  }
352
642
  export async function simplePack(m, store) {
353
643
  m = forStore(m, store);
@@ -362,7 +652,7 @@ export async function simplePack(m, store) {
362
652
  md = `# Muse connectors: ${m.title}\n\nSubmit at muse.ai/platform, choosing "Existing MCP".\n\n- MCP server URL: ${m.server.url}\n- Auth: ${m.server.auth === 'oauth' ? 'OAuth (PKCE)' : 'None'}\n- Name: ${m.title}\n- Description: ${m.oneLiner ?? m.description ?? 'MISSING'}\n- Privacy: ${m.links?.privacy ?? 'MISSING'}\n\nMeta onboards in waves and gives no status yet. When it's in: \`mcplane submitted muse\`.\n`;
363
653
  }
364
654
  else {
365
- throw new Error(`No pack for ${store} yet. Packs: chatgpt, claude-connectors, cursor, muse.`);
655
+ throw new Error(`No pack for ${store} yet. Packs: ${PACKS.join(', ')}.`);
366
656
  }
367
657
  return { files: [{ path: `${store}.md`, content: md }], problems, todo: [] };
368
658
  }
@@ -412,4 +702,4 @@ export async function writePack(pack, dir = process.cwd()) {
412
702
  }
413
703
  return written;
414
704
  }
415
- export const _test = { thirdPerson, justify, contrast, category };
705
+ export const _test = { appealNotes, contrast, category, toolsFor };