mcplane 0.1.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/dist/packs.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Submission packs: everything a store's form asks for, in its order, checked
3
+ * against its limits. ChatGPT gets the chatgpt-app-submission.json its portal
4
+ * imports (the format OpenAI's own chatgpt-app-submission skill writes); the
5
+ * rest get a markdown pack to paste from.
6
+ */
7
+ import { mkdir, writeFile } from 'node:fs/promises';
8
+ import { resolve } from 'node:path';
9
+ import { ensureLocalDir } from './manifest.js';
10
+ import { McpClient } from './mcp-client.js';
11
+ const CATEGORIES = ['BUSINESS', 'COLLABORATION', 'DESIGN', 'DEVELOPER_TOOLS', 'EDUCATION', 'ENTERTAINMENT', 'FINANCE', 'FOOD', 'LIFESTYLE', 'NEWS', 'PRODUCTIVITY', 'SHOPPING', 'TRAVEL'];
12
+ const MAX_JUSTIFICATION = 200; // the ChatGPT form cuts longer ones without warning
13
+ async function listTools(m, token) {
14
+ const c = new McpClient(m.server.url, token ? { authorization: `Bearer ${token}` } : {});
15
+ const init = await c.initialize();
16
+ if (init.status >= 400)
17
+ throw new Error(`Couldn't read tools from ${m.server.url} (HTTP ${init.status})${m.server.auth === 'oauth' ? '; pass --token' : ''}.`);
18
+ const r = await c.request('tools/list');
19
+ return (r.body?.result?.tools ?? []);
20
+ }
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);
31
+ }
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
+ };
47
+ }
48
+ export async function chatgptPack(m, token) {
49
+ const tools = await listTools(m, token);
50
+ const problems = [];
51
+ const todo = [];
52
+ const category = (m.category ?? '').toUpperCase().replace(/[^A-Z]+/g, '_');
53
+ if (!CATEGORIES.includes(category))
54
+ problems.push(`category "${m.category ?? ''}" isn't one of ChatGPT's: ${CATEGORIES.join(', ')}`);
55
+ if ((m.subtitle ?? '').length > 30)
56
+ problems.push(`subtitle is ${m.subtitle.length} characters; the limit is 30`);
57
+ if (!m.subtitle)
58
+ problems.push('subtitle is missing (30 characters, a plain functional phrase)');
59
+ const missingHints = tools.filter((t) => ['readOnlyHint', 'openWorldHint', 'destructiveHint'].some((h) => typeof t.annotations?.[h] !== 'boolean'));
60
+ for (const t of missingHints)
61
+ problems.push(`${t.name} doesn't set all three hints; ChatGPT treats that as a blocker`);
62
+ const toolsOut = Object.fromEntries(tools.map((t) => [
63
+ t.name,
64
+ {
65
+ annotations: {
66
+ readOnlyHint: !!t.annotations?.readOnlyHint,
67
+ openWorldHint: !!t.annotations?.openWorldHint,
68
+ destructiveHint: !!t.annotations?.destructiveHint,
69
+ },
70
+ justifications: justify(t, m.title, m.justifications?.[t.name]),
71
+ },
72
+ ]));
73
+ const drafted = tools.filter((t) => !m.justifications?.[t.name]).map((t) => t.name);
74
+ if (drafted.length)
75
+ todo.push(`Read the drafted justifications for ${drafted.join(', ')}: they come from your hints and tool descriptions. Once they say exactly what each tool changes, save them under "justifications" in mcplane.json so every version reuses them.`);
76
+ for (const [name, j] of Object.entries(m.justifications ?? {}))
77
+ for (const [k, v] of Object.entries(j))
78
+ if (v && v.length > MAX_JUSTIFICATION)
79
+ problems.push(`justifications.${name}.${k} is ${v.length} characters; ChatGPT cuts at ${MAX_JUSTIFICATION}`);
80
+ const pos = m.tests?.positive ?? [];
81
+ const neg = m.tests?.negative ?? [];
82
+ const names = new Set(tools.map((t) => t.name));
83
+ if (pos.length !== 5)
84
+ problems.push(`tests.positive has ${pos.length}; ChatGPT needs exactly 5`);
85
+ if (neg.length !== 3)
86
+ problems.push(`tests.negative has ${neg.length}; ChatGPT needs exactly 3`);
87
+ for (const p of pos)
88
+ for (const n of p.tools)
89
+ if (!names.has(n))
90
+ problems.push(`test "${p.scenario}" names a tool the server doesn't have: ${n}`);
91
+ const covered = new Set(pos.flatMap((p) => p.tools));
92
+ for (const t of tools)
93
+ if (!covered.has(t.name))
94
+ todo.push(`No positive test uses ${t.name}. Reviewers check every tool.`);
95
+ const json = {
96
+ $schema: 'https://developers.openai.com/apps-sdk/schemas/chatgpt-app-submission.v1.json',
97
+ schema_version: 1,
98
+ app_info: { display_name: m.title, subtitle: m.subtitle ?? '', description: m.description ?? '', category: CATEGORIES.includes(category) ? category : 'PRODUCTIVITY' },
99
+ tools: toolsOut,
100
+ ...(pos.length
101
+ ? {
102
+ test_cases: pos.map((p) => ({ description: p.scenario, user_prompt: p.prompt, file_attachment_urls: null, tools_triggered: p.tools.join(', '), expected_output: p.expected, expected_output_url: null })),
103
+ }
104
+ : {}),
105
+ ...(neg.length
106
+ ? {
107
+ negative_test_cases: neg.map((n) => ({ description: n.scenario, user_prompt: n.prompt, file_attachment_urls: null, tools_triggered: null, expected_output: `${m.title} should not be invoked.`, expected_output_url: null })),
108
+ }
109
+ : {}),
110
+ };
111
+ const steps = `# ChatGPT submission: ${m.title}
112
+
113
+ Upload \`chatgpt-app-submission.json\` on the Info step ("Use Codex to fill this form"): it fills app info, tool justifications and tests.
114
+
115
+ ## Before you start
116
+ - A verified organisation on platform.openai.com (Settings, Organization). Start early; it can lag.
117
+ - Domain challenge: the MCP step gives you a token to serve as plain text at ${new URL(m.server.url).origin}/.well-known/openai-apps-challenge.
118
+ - A demo video recorded in ChatGPT developer mode showing the tools your tests use, hosted as an .mp4 URL.
119
+ - Icons: a directory icon (square PNG, 256 px or more) and a composer icon (48 px or more; the developer-mode dialog caps uploads at 10 KB).
120
+
121
+ ## Fields the JSON doesn't cover
122
+ - Developer identity and Plugin Author: must match your verified legal or business name.
123
+ - Website: ${m.links?.website ?? 'MISSING'}
124
+ - Customer support URL (a URL, not an email): ${m.links?.support ?? 'MISSING'}
125
+ - Privacy policy: ${m.links?.privacy ?? 'MISSING'}
126
+ - Terms: ${m.links?.terms ?? 'MISSING'}
127
+ - MCP server URL: ${m.server.url} (${m.server.auth === 'oauth' ? 'OAuth' : 'No Auth'})${m.server.auth === 'oauth' ? `\n- Test credentials: ${m.reviewerAccess ?? 'MISSING: an email-and-password demo account with no 2FA, seeded for every test case'}` : ''}
128
+ - Prompts (up to 3): ${(m.prompts ?? []).slice(0, 3).map((p) => `"${p}"`).join(', ') || 'none set'}
129
+
130
+ ## Traps
131
+ - Run "Scan Tools" twice if the first click shows nothing.
132
+ - Justifications over 200 characters are cut without warning.
133
+ - Typing into fields while the draft autosaves can drop characters; re-read before submitting.
134
+ - "Submit for Review" shows nothing for about 20 seconds. Confirm on the plugins list that the version reads "Review".
135
+ - One version can be in review at a time. Changing tools on a published plugin needs a new version.
136
+
137
+ When it's in: \`mcplane submitted chatgpt --app-id <asdk_app_… from the URL>\`
138
+ `;
139
+ return {
140
+ files: [
141
+ { path: 'chatgpt-app-submission.json', content: JSON.stringify(json, null, 2) + '\n' },
142
+ { path: 'chatgpt.md', content: steps },
143
+ ],
144
+ problems,
145
+ todo,
146
+ };
147
+ }
148
+ function readWrite(tools) {
149
+ const writes = tools.filter((t) => t.annotations?.readOnlyHint === false);
150
+ if (!writes.length)
151
+ return 'Read only';
152
+ return writes.length === tools.length ? 'Write only (check: most servers also read)' : 'Read and write';
153
+ }
154
+ export async function claudePack(m, token) {
155
+ const tools = await listTools(m, token);
156
+ const problems = [];
157
+ if ((m.oneLiner ?? '').length > 200)
158
+ problems.push(`oneLiner is ${m.oneLiner.length} characters; the limit is 200`);
159
+ if ((m.description ?? '').length > 2000)
160
+ problems.push(`description is ${m.description.length} characters; the limit is 2000`);
161
+ if (!m.icon)
162
+ problems.push('icon is missing; Claude asks for a direct .png/.svg URL when your favicon fails');
163
+ const pos = m.tests?.positive ?? [];
164
+ const useCases = (pos.length ? pos.slice(0, 4).map((p) => `${p.scenario}: "${p.prompt}"`) : (m.prompts ?? []).map((p) => `"${p}"`)).join('\n');
165
+ const tests = pos.length
166
+ ? pos.map((p, i) => `${i + 1}. "${p.prompt}" (${p.tools.join(', ')}): ${p.expected}`).join('\n')
167
+ : 'MISSING: add tests.positive to mcplane.json';
168
+ const md = `# Claude directory submission: ${m.title}
169
+
170
+ Submit at claude.ai/directory/manage/new (any paid Claude plan). Ten steps; the draft autosaves.
171
+
172
+ ## 1. Connection
173
+ - Server URL: ${m.server.url}
174
+ - Authentication: ${m.server.auth === 'oauth' ? 'OAuth 2.0 + Dynamic Client Registration (Anthropic says DCR works best)' : 'No authentication (authless)'}
175
+
176
+ ## 2. Tools
177
+ Every tool needs a title and explicit hints. Current tools: ${tools.map((t) => t.name).join(', ')}.
178
+
179
+ ## 3. Listing
180
+ - Name: ${m.title}
181
+ - Slug: ${m.name} (permanent after submission)
182
+ - One-liner (${(m.oneLiner ?? '').length}/200): ${m.oneLiner ?? 'MISSING'}
183
+ - Description (${(m.description ?? '').length}/2000):
184
+
185
+ ${m.description ?? 'MISSING'}
186
+
187
+ - Author: ${m.author?.name ?? 'MISSING'}, ${m.author?.url ?? ''}
188
+ - Icon: add a custom icon URL: ${m.icon ?? 'MISSING'} (the favicon fallback often fails)
189
+ - Documentation: ${m.links?.docs ?? 'MISSING'}
190
+ - Support: ${m.links?.support ?? 'MISSING'}
191
+ - Privacy policy: ${m.links?.privacy ?? 'MISSING'}
192
+
193
+ ## 4. Use cases (at least three, each with an example prompt)
194
+ ${useCases || 'MISSING'}
195
+
196
+ - Connection requirements: ${m.server.auth === 'oauth' ? 'An account on your service; say which plan.' : 'None. No account, sign-in or API key.'}
197
+ - Read / write: ${readWrite(tools)}
198
+
199
+ ## 6. Authentication
200
+ Match step 1. Partial auth is available if only some tools need sign-in.
201
+
202
+ ## 7. Data handling
203
+ Answer API ownership, health data and sponsored content truthfully. Sponsored or promoted content is prohibited.
204
+
205
+ ## 8. Test & launch
206
+ ${m.server.auth === 'oauth' ? `Reviewer access: ${m.reviewerAccess ?? 'MISSING: a demo account with every step, link and credential needed'}\n` : 'No account or credentials needed.\n'}${tests}
207
+
208
+ Tick "self-tested" only after running every tool.
209
+
210
+ ## 9. Compliance
211
+ Seven statements you make yourself. One asks that tool descriptions contain no instructions about model behaviour: run \`mcplane preflight\` and explain any flagged line in Additional notes.
212
+
213
+ ## After
214
+ "Passed review" is not the same as listed: check claude.ai/directory, and choose when to publish.
215
+ When it's in: \`mcplane submitted claude-connectors\`
216
+ `;
217
+ return { files: [{ path: 'claude-connectors.md', content: md }], problems, todo: [] };
218
+ }
219
+ export async function simplePack(m, store) {
220
+ const problems = [];
221
+ let md = '';
222
+ if (store === 'cursor') {
223
+ if (!m.repository)
224
+ problems.push('repository is missing; Cursor reads plugins from a public GitHub repo');
225
+ md = `# Cursor Marketplace: ${m.title}\n\nSubmit at cursor.com/marketplace/publish.\n\n- Repository: ${m.repository ?? 'MISSING'} (public, with a Cursor plugin manifest)\n- Name: ${m.title}\n- Description: ${m.oneLiner ?? m.description ?? 'MISSING'}\n- Website: ${m.links?.website ?? ''}\n\nEvery plugin is reviewed by hand. When it's in: \`mcplane submitted cursor\`. Review Times closes the report itself when your plugin appears in the marketplace.\n`;
226
+ }
227
+ else if (store === 'muse') {
228
+ 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`;
229
+ }
230
+ else {
231
+ throw new Error(`No pack for ${store} yet. Packs: chatgpt, claude-connectors, cursor, muse.`);
232
+ }
233
+ return { files: [{ path: `${store}.md`, content: md }], problems, todo: [] };
234
+ }
235
+ /** The long tail: directories that only take a web form. One sheet with every value to paste. */
236
+ export function directoriesPack(m) {
237
+ const repo = m.repository?.replace(/\.git$/, '');
238
+ const desc = m.oneLiner ?? m.description ?? m.title;
239
+ const rows = [
240
+ ['MCP.Directory', 'https://mcp.directory/submit', 'Reads the official registry; claim the listing there, or submit a GitHub URL (reviewed within a day).'],
241
+ ['mcp.so', 'https://mcp.so/submit', 'Repo URL and name. Paid option skips review. No update mechanism: edit by hand.'],
242
+ ['MCP Market', 'https://mcpmarket.com/submit', 'GitHub URL, email, type. The free queue runs to weeks; paid lists within a day.'],
243
+ ['mcpservers.org', 'https://mcpservers.org/submit', 'Has a field for your Official MCP Registry name. Free takes about two weeks.'],
244
+ ['cursor.directory', 'https://cursor.directory', 'Submit from its MCP section.'],
245
+ ];
246
+ const md = `# Directory forms: ${m.title}
247
+
248
+ Publish to the official MCP Registry first ("mcplane publish mcp-registry"): GitHub's registry, VS Code, PulseMCP and MCP.Directory read from it, so most of these become optional.
249
+
250
+ Values to paste:
251
+ - Name: ${m.title}
252
+ - Description: ${desc}
253
+ - Server URL: ${m.server.url}
254
+ - Repository: ${repo ?? 'none'}
255
+ - Website: ${m.links?.website ?? ''}
256
+ - Icon: ${m.icon ?? ''}
257
+ - Registry name: ${m.registry?.name ?? '(see "mcplane publish mcp-registry")'}
258
+
259
+ | Directory | Where | Notes |
260
+ |---|---|---|
261
+ ${rows.map((r) => `| ${r[0]} | ${r[1]} | ${r[2]} |`).join('\n')}
262
+
263
+ Directories send little traffic on their own; the registry, the big stores and your own docs matter more. Do these once, and don't pay to skip a queue unless a launch date depends on it.
264
+ `;
265
+ return { files: [{ path: 'directories.md', content: md }], problems: [], todo: [] };
266
+ }
267
+ export async function writePack(pack, dir = process.cwd()) {
268
+ await ensureLocalDir(dir);
269
+ const out = resolve(dir, '.mcplane/packs');
270
+ await mkdir(out, { recursive: true });
271
+ const written = [];
272
+ for (const f of pack.files) {
273
+ await writeFile(resolve(out, f.path), f.content);
274
+ written.push(`.mcplane/packs/${f.path}`);
275
+ }
276
+ return written;
277
+ }
278
+ export const _test = { thirdPerson, justify };
@@ -0,0 +1,39 @@
1
+ import { authChecks } from './checks/auth.js';
2
+ import { listingChecks, repoChecks } from './checks/listing.js';
3
+ import { serverChecks } from './checks/server.js';
4
+ import { toolChecks } from './checks/tools.js';
5
+ import { storesOf } from './manifest.js';
6
+ /** Things no script can see, which have still cost real submissions. */
7
+ function reminders(m, stores) {
8
+ const r = [];
9
+ if (m.server.auth === 'oauth') {
10
+ r.push('Give reviewers a demo account: email and password, no 2FA, SMS or email confirmation, seeded with data that matches every test case. Re-check the seed right before submitting.');
11
+ r.push('Sign in through your OAuth flow once as a brand-new user. A blank post-sign-in redirect stranded a ChatGPT reviewer between registration and consent.');
12
+ }
13
+ if (stores.includes('chatgpt')) {
14
+ r.push('ChatGPT needs a demo video recorded in developer mode that shows the tools your test cases use. Re-record it whenever tools change.');
15
+ r.push('After "Submit for Review" the ChatGPT portal shows nothing for about 20 seconds. Confirm the status on the plugins list, not the button.');
16
+ r.push('Tool-annotation justifications are cut at 200 characters without warning.');
17
+ r.push('ChatGPT allows one version in review at a time. To change a submission, Cancel Review and resubmit the same draft.');
18
+ }
19
+ if (stores.includes('claude-connectors') || stores.includes('claude-plugins')) {
20
+ if (stores.includes('claude-plugins'))
21
+ r.push('Run "claude plugin validate --strict" in the plugin repo; the portal validates every commit on the branch it tracks.');
22
+ r.push('Claude: "Passed review" is not the same as listed. Check claude.ai/directory for your listing after approval; with the new portal you choose when to publish.');
23
+ }
24
+ return r;
25
+ }
26
+ export async function preflight(m, opts = {}) {
27
+ const stores = opts.stores?.length ? opts.stores : storesOf(m);
28
+ const auth = m.server.auth ?? 'none';
29
+ const server = await serverChecks(m.server.url, auth, opts.token);
30
+ const [authC, listing, repo] = await Promise.all([
31
+ auth === 'oauth' ? authChecks(m.server.url, { register: opts.register }) : Promise.resolve([]),
32
+ listingChecks(m, server.tools, stores),
33
+ repoChecks(m, stores),
34
+ ]);
35
+ const all = [...server.checks, ...authC, ...toolChecks(server.tools), ...listing, ...repo];
36
+ // Keep checks that apply to every store or to one we're submitting to.
37
+ const checks = all.filter((c) => !c.stores?.length || c.stores.some((s) => stores.includes(s)));
38
+ return { checks, reminders: reminders(m, stores), stores };
39
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Publishing where a store allows it. Dry run by default: it shows exactly
3
+ * what it will do, and only acts with --yes.
4
+ *
5
+ * mcp-registry writes server.json and runs mcp-publisher
6
+ * grok a complete pull request to xai-org/plugin-marketplace:
7
+ * entry pinned to your repo's HEAD, index regenerated and
8
+ * validated with xAI's own scripts (new listing or pin bump)
9
+ * the rest the exact entry or command, ready to use
10
+ */
11
+ import { execFileSync } from 'node:child_process';
12
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, resolve } from 'node:path';
15
+ import { repoHead } from './drift.js';
16
+ const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
17
+ const has = (cmd) => {
18
+ try {
19
+ run('which', [cmd]);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ };
26
+ function ghRepo(m) {
27
+ const g = m.repository?.match(/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?$/);
28
+ return g ? { owner: g[1], repo: g[2] } : null;
29
+ }
30
+ /* ---------------- MCP Registry ---------------- */
31
+ /** Shortens at a word boundary, so a limit never cuts a word in half. */
32
+ const fit = (s, n) => (s.length <= n ? s : s.slice(0, n + 1).replace(/[\s,;:]+\S*$/, '').replace(/[\s,;:]+$/, ''));
33
+ export function registryName(m) {
34
+ if (m.registry?.name)
35
+ return m.registry.name;
36
+ const gh = ghRepo(m);
37
+ if (gh)
38
+ return `io.github.${gh.owner}/${m.name}`;
39
+ const host = new URL(m.links?.website ?? m.server.url).hostname.replace(/^www\./, '');
40
+ return `${host.split('.').reverse().join('.')}/${m.name}`;
41
+ }
42
+ export function serverJson(m) {
43
+ return {
44
+ $schema: 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json',
45
+ name: registryName(m),
46
+ title: m.title,
47
+ description: fit(m.registry?.description ?? m.oneLiner ?? m.subtitle ?? m.title, 100),
48
+ ...(m.links?.website ? { websiteUrl: m.links.website } : {}),
49
+ ...(m.repository ? { repository: { url: m.repository.replace(/\.git$/, ''), source: 'github' } } : {}),
50
+ version: m.version ?? '1.0.0',
51
+ remotes: [{ type: 'streamable-http', url: m.server.url }],
52
+ };
53
+ }
54
+ /** Versions of this server already in the registry. Published versions can't change. */
55
+ async function registryVersions(name) {
56
+ const r = await fetch(`https://registry.modelcontextprotocol.io/v0/servers?search=${encodeURIComponent(name)}`, { headers: { 'user-agent': 'mcplane' } }).catch(() => null);
57
+ const data = r?.ok ? (await r.json().catch(() => null)) : null;
58
+ return (data?.servers ?? []).filter((x) => x.server.name === name).map((x) => x.server.version);
59
+ }
60
+ /** 1.2.0 → 1.2.0-1, 1.2.0-1 → 1.2.0-2: the registry's way to change metadata without a new release. */
61
+ const nextPrerelease = (v) => (/-(\d+)$/.test(v) ? v.replace(/-(\d+)$/, (_, n) => `-${Number(n) + 1}`) : `${v}-1`);
62
+ const REGISTRY_READERS = 'GitHub’s MCP Registry (and VS Code), PulseMCP and MCP.Directory read from the official registry, so they pick this up on their own.';
63
+ async function publishRegistry(m, yes, dir) {
64
+ const json = serverJson(m);
65
+ const file = m.registry?.file ?? 'server.json';
66
+ const lines = [];
67
+ const existing = await registryVersions(json.name);
68
+ if (existing.includes(json.version)) {
69
+ return {
70
+ store: 'mcp-registry',
71
+ done: false,
72
+ lines: [`${json.name} v${json.version} is already in the registry, and published versions can't change. Bump "version" in mcplane.json, or for a listing-only change use a prerelease such as ${nextPrerelease(json.version)}.`],
73
+ };
74
+ }
75
+ const long = (m.registry?.description ?? m.oneLiner ?? '').length > 100;
76
+ if (long)
77
+ lines.push('Description trimmed to 100 characters, the registry’s limit. Set registry.description in mcplane.json to choose the words.');
78
+ const ns = json.name.split('/')[0];
79
+ const auth = ns.startsWith('io.github.') ? `mcp-publisher login github` : `mcp-publisher login dns --domain ${ns.split('.').reverse().join('.')} --private-key <key> (after adding the TXT record it prints)`;
80
+ if (!yes) {
81
+ return { store: 'mcp-registry', done: false, lines: [...lines, `Would write ${file}:`, JSON.stringify(json, null, 2), '', 'Then: mcp-publisher validate && mcp-publisher publish', `Log in first if needed: ${auth}`, existing.length ? `Already published: ${existing.join(', ')}.` : 'First version of this name.', REGISTRY_READERS, 'Run again with --yes to do it.'] };
82
+ }
83
+ writeFileSync(resolve(dir, file), JSON.stringify(json, null, 2) + '\n');
84
+ if (!has('mcp-publisher'))
85
+ return { store: 'mcp-registry', done: false, lines: [...lines, `Wrote ${file}. Install mcp-publisher (brew install mcp-publisher), then: ${auth} && mcp-publisher publish`] };
86
+ // In GitHub Actions, sign in without secrets (io.github.* names need "permissions: id-token: write"); DNS names use MCP_PRIVATE_KEY.
87
+ if (process.env.GITHUB_ACTIONS === 'true') {
88
+ try {
89
+ if (ns.startsWith('io.github.') && process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
90
+ run('mcp-publisher', ['login', 'github-oidc'], dir);
91
+ else if (!ns.startsWith('io.github.') && process.env.MCP_PRIVATE_KEY)
92
+ run('mcp-publisher', ['login', 'dns', '--domain', ns.split('.').reverse().join('.'), '--private-key', process.env.MCP_PRIVATE_KEY], dir);
93
+ }
94
+ catch (e) {
95
+ return { store: 'mcp-registry', done: false, lines: [...lines, 'Signing in to the registry from CI failed:', String(e.stderr ?? e.message).trim()] };
96
+ }
97
+ }
98
+ try {
99
+ run('mcp-publisher', ['validate', file], dir);
100
+ const out = run('mcp-publisher', ['publish', file], dir);
101
+ return { store: 'mcp-registry', done: true, lines: [...lines, `Wrote ${file} and published ${json.name} v${json.version}.`, out, REGISTRY_READERS], url: `https://registry.modelcontextprotocol.io/v0/servers?search=${encodeURIComponent(json.name)}` };
102
+ }
103
+ catch (e) {
104
+ const err = String(e.stderr ?? e.message);
105
+ return { store: 'mcp-registry', done: false, lines: [...lines, `Wrote ${file}, but publishing failed:`, err.trim(), /auth|login|token|401|403/i.test(err) ? `Log in: ${auth}` : ''] };
106
+ }
107
+ }
108
+ /* ---------------- Grok (xai-org/plugin-marketplace) ---------------- */
109
+ const GROK = 'xai-org/plugin-marketplace';
110
+ function grokEntry(m, sha) {
111
+ const gh = ghRepo(m);
112
+ const host = new URL(m.links?.website ?? m.server.url).hostname.replace(/^www\./, '');
113
+ return {
114
+ name: m.name,
115
+ description: m.grok?.description ?? m.oneLiner ?? m.description ?? m.title,
116
+ category: m.grok?.category ?? 'development',
117
+ source: { source: 'url', url: `https://github.com/${gh.owner}/${gh.repo}.git`, sha },
118
+ ...(m.links?.website ? { homepage: m.links.website } : {}),
119
+ keywords: m.grok?.keywords ?? [m.name, m.title.toLowerCase()],
120
+ domains: m.grok?.domains ?? [host],
121
+ };
122
+ }
123
+ /** Adds or re-pins our entry without reformatting the rest of the file. */
124
+ function editMarketplace(text, entry) {
125
+ const data = JSON.parse(text);
126
+ const existing = data.plugins.find((p) => p.name === entry.name);
127
+ if (existing) {
128
+ const old = existing.source?.sha;
129
+ if (!old || old === entry.source.sha)
130
+ return { text, mode: 'same' };
131
+ return { text: text.replace(old, entry.source.sha), mode: 'bump' };
132
+ }
133
+ const block = JSON.stringify(entry, null, 2)
134
+ .split('\n')
135
+ .map((l) => ` ${l}`)
136
+ .join('\n');
137
+ const end = text.lastIndexOf('}\n ]');
138
+ if (end < 0)
139
+ throw new Error('Couldn’t find the end of the plugins list in marketplace.json.');
140
+ return { text: `${text.slice(0, end + 1)},\n${block}${text.slice(end + 1)}`, mode: 'add' };
141
+ }
142
+ async function publishGrok(m, yes) {
143
+ const gh = ghRepo(m);
144
+ if (!gh)
145
+ return { store: 'grok', done: false, lines: ['Grok plugins come from a public GitHub repository. Set "repository" in mcplane.json.'] };
146
+ const sha = await repoHead(m.repository);
147
+ if (!sha)
148
+ return { store: 'grok', done: false, lines: [`Couldn't read the latest commit of ${m.repository}. Is it public?`] };
149
+ const entry = grokEntry(m, sha);
150
+ if (!yes) {
151
+ return {
152
+ store: 'grok',
153
+ done: false,
154
+ lines: [
155
+ `Would open a pull request to ${GROK} with this entry (pinned to ${sha.slice(0, 7)}), or bump the pin if ${m.name} is already listed:`,
156
+ JSON.stringify(entry, null, 2),
157
+ '',
158
+ 'Steps: fork, branch, edit .grok-plugin/marketplace.json, run scripts/generate-plugin-index.py and scripts/validate-catalog.py (xAI’s CI checks), push, open the PR.',
159
+ 'Needs: gh (logged in), git, python3. Run again with --yes to do it.',
160
+ ],
161
+ };
162
+ }
163
+ for (const tool of ['gh', 'git', 'python3'])
164
+ if (!has(tool))
165
+ return { store: 'grok', done: false, lines: [`${tool} is required for this. Install it and try again.`] };
166
+ const me = run('gh', ['api', 'user', '--jq', '.login']);
167
+ try {
168
+ run('gh', ['repo', 'fork', GROK, '--clone=false']);
169
+ }
170
+ catch {
171
+ // Already forked.
172
+ }
173
+ const dir = mkdtempSync(join(tmpdir(), 'mcplane-grok-'));
174
+ run('git', ['clone', '--depth', '50', `https://github.com/${GROK}.git`, dir]);
175
+ const branch = `mcplane/${m.name}-${sha.slice(0, 7)}`;
176
+ run('git', ['checkout', '-b', branch], dir);
177
+ const file = join(dir, '.grok-plugin/marketplace.json');
178
+ const edit = editMarketplace(readFileSync(file, 'utf8'), entry);
179
+ if (edit.mode === 'same')
180
+ return { store: 'grok', done: true, lines: [`${m.name} is already listed and pinned to ${sha.slice(0, 7)}. Nothing to do.`] };
181
+ writeFileSync(file, edit.text);
182
+ run('python3', ['scripts/generate-plugin-index.py'], dir);
183
+ run('python3', ['scripts/validate-catalog.py'], dir);
184
+ run('python3', ['scripts/generate-plugin-index.py', '--check'], dir);
185
+ const title = edit.mode === 'add' ? `Add ${m.name} plugin` : `Bump ${m.name} to ${sha.slice(0, 7)}`;
186
+ run('git', ['add', '.grok-plugin'], dir);
187
+ run('git', ['-c', 'user.name=mcplane', '-c', `user.email=${me}@users.noreply.github.com`, 'commit', '-m', title], dir);
188
+ run('git', ['remote', 'add', 'fork', `https://github.com/${me}/plugin-marketplace.git`], dir);
189
+ run('git', ['push', 'fork', branch], dir);
190
+ const body = [
191
+ edit.mode === 'add' ? `Adds **${m.title}**: ${entry.description}` : `Re-pins **${m.name}** to ${sha}.`,
192
+ '',
193
+ `- Source: https://github.com/${gh.owner}/${gh.repo} at \`${sha}\``,
194
+ ...(m.links?.website ? [`- Homepage: ${m.links.website}`] : []),
195
+ '- `validate-catalog.py` and `generate-plugin-index.py --check` pass locally.',
196
+ ].join('\n');
197
+ const url = run('gh', ['pr', 'create', '--repo', GROK, '--head', `${me}:${branch}`, '--title', title, '--body', body]);
198
+ return { store: 'grok', done: true, lines: [`Opened ${url}`], url };
199
+ }
200
+ /* ---------------- Prepared, not automated ---------------- */
201
+ function prepared(m, store) {
202
+ const gh = ghRepo(m);
203
+ const desc = m.oneLiner ?? m.subtitle ?? m.title;
204
+ if (store === 'awesome-mcp-servers') {
205
+ const link = gh ? `[${gh.owner}/${gh.repo}](https://github.com/${gh.owner}/${gh.repo})` : `[${m.title}](${m.links?.website ?? m.server.url})`;
206
+ return {
207
+ store,
208
+ done: false,
209
+ lines: [
210
+ 'Add this line to the right category in punkpeye/awesome-mcp-servers README.md (☁️ marks a hosted server), then open a pull request:',
211
+ `- ${link} ☁️ - ${desc}`,
212
+ 'Keep categories alphabetical, follow their legend and include your Glama score badge ("mcplane publish glama"); PRs without it are the ones that wait.',
213
+ ],
214
+ };
215
+ }
216
+ if (store === 'smithery') {
217
+ return { store, done: false, lines: [`smithery mcp publish ${m.server.url} -n @<your-org>/${m.name}`, 'Or at https://smithery.ai/new. If Smithery’s scan can’t get past your sign-in, serve /.well-known/mcp/server-card.json describing your tools.'] };
218
+ }
219
+ if (store === 'glama') {
220
+ const owner = gh?.owner ?? '<your-github-user>';
221
+ return {
222
+ store,
223
+ done: false,
224
+ lines: [
225
+ `Add ${gh ? `https://github.com/${gh.owner}/${gh.repo}` : 'your GitHub repo'} at https://glama.ai/mcp/servers ("Add MCP Server"). Glama runs its own checks (license, security, health) and scores the server.`,
226
+ 'To claim the listing, commit glama.json at the repo root:',
227
+ JSON.stringify({ $schema: 'https://glama.ai/mcp/schemas/server.json', maintainers: [owner] }, null, 2),
228
+ 'awesome-mcp-servers asks for the Glama score badge on each entry, so do this first.',
229
+ ],
230
+ };
231
+ }
232
+ if (store === 'cline') {
233
+ if (!gh)
234
+ return { store, done: false, lines: ['Cline’s marketplace lists GitHub repositories. Set "repository" in mcplane.json.'] };
235
+ const q = new URLSearchParams({ template: 'mcp-server-submission.yml', title: `Add ${m.title}`, 'repo-url': `https://github.com/${gh.owner}/${gh.repo}`, 'additional-info': `${desc}\n\nRemote server: ${m.server.url}` });
236
+ return {
237
+ store,
238
+ done: false,
239
+ lines: [
240
+ `Open the prefilled issue: https://github.com/cline/mcp-marketplace/issues/new?${q}`,
241
+ 'Attach a 400×400 PNG logo, and tick the testing boxes only after Cline has set the server up from your README (or llms-install.md) alone. Review takes a couple of days.',
242
+ ],
243
+ };
244
+ }
245
+ if (store === 'lobehub') {
246
+ return {
247
+ store,
248
+ done: false,
249
+ lines: [
250
+ `npx @lobehub/market-cli plugin publish ${gh ? `https://github.com/${gh.owner}/${gh.repo}` : '<your GitHub repo>'}`,
251
+ 'It signs in through your browser, so it can’t run in CI. Re-run "plugin update" after changes; a new version number creates a new release.',
252
+ ],
253
+ };
254
+ }
255
+ if (store === 'docker') {
256
+ return {
257
+ store,
258
+ done: false,
259
+ lines: ['In a fork of docker/mcp-registry run "task remote-wizard" (remote servers) or "task wizard" (images) and open the pull request it prepares. Entries pin a commit, so each update is a new PR. Live within 24 hours of approval.'],
260
+ };
261
+ }
262
+ return { store, done: false, lines: [`${store} is submitted through a form. Run "mcplane pack ${store}" for everything it asks.`] };
263
+ }
264
+ export async function publish(m, store, opts = {}) {
265
+ if (store === 'mcp-registry')
266
+ return publishRegistry(m, !!opts.yes, opts.dir ?? process.cwd());
267
+ if (store === 'grok')
268
+ return publishGrok(m, !!opts.yes);
269
+ return prepared(m, store);
270
+ }
271
+ export const _test = { editMarketplace, grokEntry, nextPrerelease };
package/dist/report.js ADDED
@@ -0,0 +1,31 @@
1
+ import { STORE_NAMES } from './types.js';
2
+ const tty = process.stdout.isTTY;
3
+ const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
4
+ const MARK = { pass: c('32', '✓'), warn: c('33', '!'), fail: c('31', '✗'), skip: c('90', '-') };
5
+ export function printPreflight(r, opts = {}) {
6
+ const fails = r.checks.filter((x) => x.level === 'fail');
7
+ const warns = r.checks.filter((x) => x.level === 'warn');
8
+ console.log(c('1', `Preflight for ${r.stores.map((s) => STORE_NAMES[s]).join(', ')}`));
9
+ console.log('');
10
+ for (const x of r.checks) {
11
+ if (x.level === 'pass' && !opts.verbose)
12
+ continue;
13
+ const where = x.stores?.length ? c('90', ` [${x.stores.map((s) => STORE_NAMES[s]).join(', ')}]`) : '';
14
+ console.log(`${MARK[x.level]} ${x.title}${where}`);
15
+ if (x.detail)
16
+ console.log(c('90', ` ${x.detail}`));
17
+ if (x.fix && x.level !== 'pass')
18
+ console.log(` ${x.fix}`);
19
+ }
20
+ const passed = r.checks.length - fails.length - warns.length;
21
+ if (!opts.verbose && passed)
22
+ console.log(c('32', `✓ ${passed} checks passed`) + c('90', ' (--verbose to list them)'));
23
+ if (r.reminders.length) {
24
+ console.log('');
25
+ console.log(c('1', 'Check these yourself'));
26
+ for (const t of r.reminders)
27
+ console.log(` • ${t}`);
28
+ }
29
+ console.log('');
30
+ console.log(fails.length ? c('31', `${fails.length} blocking, ${warns.length} to review.`) : warns.length ? c('33', `No blockers, ${warns.length} to review.`) : c('32', 'Ready to submit.'));
31
+ }