mcplane 0.4.0 → 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.
- package/CHANGELOG.md +27 -0
- package/README.md +108 -18
- package/dist/agent-plugins.d.ts +6 -0
- package/dist/agent-plugins.js +76 -0
- package/dist/cdp.d.ts +49 -0
- package/dist/cdp.js +253 -0
- package/dist/checks/ard.d.ts +67 -0
- package/dist/checks/ard.js +215 -0
- package/dist/checks/auth.js +2 -1
- package/dist/checks/chain.d.ts +45 -0
- package/dist/checks/chain.js +171 -0
- package/dist/checks/listing.js +23 -14
- package/dist/checks/oauth.d.ts +45 -0
- package/dist/checks/oauth.js +64 -0
- package/dist/checks/readiness.d.ts +58 -0
- package/dist/checks/readiness.js +230 -0
- package/dist/checks/tools.js +38 -7
- package/dist/checks/vercel.d.ts +27 -0
- package/dist/checks/vercel.js +153 -0
- package/dist/cli.js +54 -6
- package/dist/core.d.ts +1 -0
- package/dist/core.js +20 -4
- package/dist/demo.d.ts +60 -0
- package/dist/demo.js +573 -0
- package/dist/drift.js +10 -3
- package/dist/evals.d.ts +34 -0
- package/dist/evals.js +75 -0
- package/dist/extras.js +1 -1
- package/dist/images.d.ts +21 -0
- package/dist/images.js +99 -0
- package/dist/listings.d.ts +14 -0
- package/dist/listings.js +40 -1
- package/dist/mcp-client.d.ts +1 -5
- package/dist/mcp-client.js +8 -1
- package/dist/mcp-server.js +21 -11
- package/dist/packs.d.ts +48 -18
- package/dist/packs.js +380 -90
- package/dist/publish.js +13 -4
- package/dist/report.js +30 -13
- package/dist/skills.d.ts +21 -0
- package/dist/skills.js +180 -0
- package/dist/submissions.js +2 -1
- package/dist/types.d.ts +37 -4
- package/dist/types.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/video.d.ts +58 -0
- package/dist/video.js +190 -0
- package/dist/zip.d.ts +16 -2
- package/dist/zip.js +65 -5
- package/package.json +4 -1
- package/schema.json +86 -12
- package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
- package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
- 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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
138
|
+
export async function chatgptPack(m, opts = {}) {
|
|
98
139
|
m = forStore(m, 'chatgpt');
|
|
99
140
|
const g = m.chatgpt ?? {};
|
|
100
|
-
const
|
|
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
|
-
|
|
159
|
-
for (const
|
|
160
|
-
|
|
161
|
-
|
|
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 (
|
|
169
|
-
if (!
|
|
210
|
+
const icon = async (ref, file, label) => {
|
|
211
|
+
if (!ref)
|
|
170
212
|
return undefined;
|
|
171
|
-
const got = await
|
|
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
|
-
|
|
179
|
-
|
|
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:
|
|
233
|
+
$schema: PLUGIN_SCHEMA,
|
|
184
234
|
name: m.name,
|
|
185
235
|
version: m.version ?? '1.0.0',
|
|
186
|
-
description
|
|
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:
|
|
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
|
-
|
|
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,
|
|
329
|
+
export async function claudePack(m, opts = {}) {
|
|
287
330
|
m = forStore(m, 'claude-connectors');
|
|
288
|
-
const
|
|
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:
|
|
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 = {
|
|
705
|
+
export const _test = { appealNotes, contrast, category, toolsFor };
|