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.
@@ -0,0 +1,122 @@
1
+ const HINTS = ['readOnlyHint', 'destructiveHint', 'openWorldHint'];
2
+ /** Phrases that read as instructions to the model rather than a description of the tool. */
3
+ const INSTRUCTION = [
4
+ /\byou (must|should|always|never)\b/i,
5
+ /\balways (call|use|run)\b/i,
6
+ /\bnever (call|use|tell|mention)\b/i,
7
+ /\bignore (previous|prior|other|all)\b/i,
8
+ /\b(before|after) (calling|using) (this|any|another) tool\b/i,
9
+ /\buse this tool (whenever|for every|for all)\b/i,
10
+ /\bdo not (tell|inform|mention)\b/i,
11
+ /\bonly (call|use) (this|it|with|when)\b/i,
12
+ /\b(call|use) (this|it) only\b/i,
13
+ ];
14
+ /** Plan and pricing copy. OpenAI prohibits in-app upsells, "including freemium upsells". */
15
+ const UPSELL = /\b(upgrade|pro plan|premium|paid plan|pricing|subscribe|subscription|free trial|unlock)\b/i;
16
+ /** Inputs that suggest a write authorised only by a secret passed through the chat. */
17
+ const BEARER_INPUT = /^(token|edit_token|update_token|secret|access_key|api_key|password)$/i;
18
+ export function toolChecks(tools) {
19
+ if (!tools.length)
20
+ return [];
21
+ const checks = [];
22
+ const names = new Set(tools.map((t) => t.name));
23
+ const noTitle = tools.filter((t) => !t.title && !t.annotations?.title);
24
+ checks.push(noTitle.length
25
+ ? { id: 'tools.title', level: 'fail', title: 'Every tool has a human-readable title', detail: noTitle.map((t) => t.name).join(', '), fix: 'Add "title" (or annotations.title) to each tool. Anthropic rejects connectors without them.', stores: ['claude-connectors', 'claude-plugins'] }
26
+ : { id: 'tools.title', level: 'pass', title: 'Every tool has a human-readable title' });
27
+ const missing = tools.flatMap((t) => HINTS.filter((h) => typeof t.annotations?.[h] !== 'boolean').map((h) => `${t.name}.${h}`));
28
+ checks.push(missing.length
29
+ ? {
30
+ id: 'tools.hints',
31
+ level: 'fail',
32
+ title: 'Safety annotations are explicit on every tool',
33
+ detail: missing.join(', '),
34
+ fix: 'Set readOnlyHint, destructiveHint and openWorldHint to true or false on every tool. ChatGPT asks you to justify each explicit value and flags missing ones; Claude checks them against your read/write answer.',
35
+ stores: ['chatgpt', 'claude-connectors'],
36
+ }
37
+ : { id: 'tools.hints', level: 'pass', title: 'Safety annotations are explicit on every tool' });
38
+ const contradictory = tools.filter((t) => t.annotations?.readOnlyHint === true && t.annotations?.destructiveHint === true);
39
+ if (contradictory.length) {
40
+ checks.push({ id: 'tools.hints-consistent', level: 'fail', title: 'Annotations are consistent', detail: `read-only yet destructive: ${contradictory.map((t) => t.name).join(', ')}`, fix: 'A read-only tool cannot be destructive.' });
41
+ }
42
+ const noOutput = tools.filter((t) => !t.outputSchema);
43
+ checks.push(noOutput.length
44
+ ? { id: 'tools.output-schema', level: 'warn', title: 'Tools declare an outputSchema', detail: noOutput.map((t) => t.name).join(', '), fix: 'Declare outputSchema and return structuredContent that matches it. ChatGPT recommends it on every tool during review.', stores: ['chatgpt'] }
45
+ : { id: 'tools.output-schema', level: 'pass', title: 'Tools declare an outputSchema' });
46
+ const instructing = tools.flatMap((t) => {
47
+ const text = `${t.description ?? ''}`;
48
+ const hits = INSTRUCTION.filter((re) => re.test(text)).map((re) => text.match(re)[0]);
49
+ const others = [...names].filter((n) => n !== t.name && text.includes(n));
50
+ return hits.length || others.length ? [`${t.name}: ${[...hits.map((h) => `"${h}"`), ...others.map((n) => `mentions ${n}`)].join(', ')}`] : [];
51
+ });
52
+ checks.push(instructing.length
53
+ ? {
54
+ id: 'tools.no-instructions',
55
+ level: 'warn',
56
+ title: 'Descriptions describe the tool, not the model’s behaviour',
57
+ detail: instructing.join('; '),
58
+ fix: "Anthropic's submission asks you to confirm tool descriptions contain no instructions about model behaviour or other tools. Rephrase as facts about what the tool does, or explain the line in the form's Additional notes.",
59
+ stores: ['claude-connectors', 'claude-plugins'],
60
+ }
61
+ : { id: 'tools.no-instructions', level: 'pass', title: 'Descriptions describe the tool, not the model’s behaviour' });
62
+ const bearer = tools.filter((t) => t.annotations?.readOnlyHint !== true && Object.keys(t.inputSchema?.properties ?? {}).some((k) => BEARER_INPUT.test(k)));
63
+ checks.push(bearer.length
64
+ ? {
65
+ id: 'tools.bearer-writes',
66
+ level: 'fail',
67
+ title: 'Writes are bound to an owner, not a token in the chat',
68
+ detail: bearer.map((t) => t.name).join(', '),
69
+ fix: 'Anthropic rejected a write tool authorised only by a token passed through the conversation ("anyone who obtains or guesses a token can rewrite"). Put writes behind OAuth, or move them to your site and email the owner a private link.',
70
+ stores: ['claude-connectors'],
71
+ }
72
+ : { id: 'tools.bearer-writes', level: 'pass', title: 'Writes are bound to an owner, not a token in the chat' });
73
+ // OpenAI: openWorldHint is true for tools that reach the public internet or open-ended external entities,
74
+ // read-only ones like web search included, and for writes that post, send, publish, push or submit.
75
+ const openWorld = tools.filter((t) => t.annotations?.openWorldHint === false &&
76
+ /\b(send|sends|sent|e-?mail(s|ed)?|post|posts|publish|publishes|public|tweet|message|notify|notifies|web search|search the web|internet|any url|fetch(es)? (a |the |any )?url|scrape|scrapes|crawl|crawls)\b/i.test(`${t.description ?? ''} ${Object.keys(t.inputSchema?.properties ?? {}).join(' ')}`));
77
+ checks.push(openWorld.length
78
+ ? {
79
+ id: 'tools.open-world',
80
+ level: 'warn',
81
+ title: 'openWorldHint matches what the tool does',
82
+ detail: `${openWorld.map((t) => t.name).join(', ')}: openWorldHint is false, but the tool seems to send, post or publish`,
83
+ fix: 'OpenAI: set openWorldHint to true "if the tool accesses the public internet or open-ended external entities", including read-only tools such as web search and tools that post, send messages to external recipients, publish, push code or submit forms. False only when it is limited to a bounded private account or workspace.',
84
+ stores: ['chatgpt'],
85
+ }
86
+ : { id: 'tools.open-world', level: 'pass', title: 'openWorldHint matches what the tool does' });
87
+ // OpenAI: destructiveHint is true for irreversible outcomes, "sending messages or transactions you can't undo" included.
88
+ const sends = tools.filter((t) => t.annotations?.destructiveHint === false && t.annotations?.readOnlyHint !== true && /\b(sends?|sent|e-?mail(s|ed)?|sms|texts?|messag(es?|ed)|notif(y|ies|ied)|transfers?|pays?|payments?)\b/i.test(t.description ?? ''));
89
+ if (sends.length) {
90
+ checks.push({
91
+ id: 'tools.destructive-send',
92
+ level: 'warn',
93
+ title: 'destructiveHint covers messages that can’t be unsent',
94
+ detail: `${sends.map((t) => t.name).join(', ')}: destructiveHint is false, but the tool seems to send something`,
95
+ fix: 'OpenAI sets destructiveHint to true for tools that can cause irreversible outcomes, "sending messages or transactions you can\'t undo" included, even through default parameters. If a user can\'t take it back, set it to true and say in the justification what safeguards exist (confirmation, scoping).',
96
+ stores: ['chatgpt'],
97
+ });
98
+ }
99
+ const long = tools.filter((t) => t.name.length > 64);
100
+ if (long.length)
101
+ checks.push({ id: 'tools.name-length', level: 'fail', title: 'Tool names are 64 characters or fewer', detail: long.map((t) => t.name).join(', '), fix: 'Anthropic’s review criteria cap tool names at 64 characters.', stores: ['claude-connectors', 'claude-plugins'] });
102
+ // A tool that takes an HTTP method and a path is one tool doing both reads and writes.
103
+ const catchAll = tools.filter((t) => {
104
+ const keys = Object.keys(t.inputSchema?.properties ?? {}).map((k) => k.toLowerCase());
105
+ return keys.some((k) => k === 'method' || k === 'http_method' || k === 'verb') && keys.some((k) => /^(path|endpoint|url|route)$/.test(k));
106
+ });
107
+ if (catchAll.length) {
108
+ checks.push({ id: 'tools.catch-all', level: 'warn', title: 'No catch-all request tool', detail: catchAll.map((t) => t.name).join(', '), fix: 'Anthropic rejects a single tool that makes any API call (e.g. api_request with a method parameter). Split it into read tools and write tools with honest hints.', stores: ['claude-connectors'] });
109
+ }
110
+ // Inputs that ask for data OpenAI treats as sensitive.
111
+ const sensitive = tools.flatMap((t) => Object.keys(t.inputSchema?.properties ?? {})
112
+ .filter((k) => /(ssn|social_security|passport|national_id|tax_id|credit_card|card_number|cvv|iban|password|mfa|otp|2fa|diagnos|medical|health_record|biometric|fingerprint)/i.test(k))
113
+ .map((k) => `${t.name}.${k}`));
114
+ if (sensitive.length) {
115
+ checks.push({ id: 'tools.sensitive-inputs', level: 'fail', title: 'Tools don’t ask for sensitive data', detail: sensitive.join(', '), fix: 'OpenAI flags inputs that request PHI, card data, SSNs, credentials, MFA codes, government IDs or biometrics unless strictly necessary. Remove them or justify them in the submission.', stores: ['chatgpt'] });
116
+ }
117
+ const upsell = tools.filter((t) => UPSELL.test(`${t.title ?? ''} ${t.annotations?.title ?? ''} ${t.description ?? ''}`));
118
+ checks.push(upsell.length
119
+ ? { id: 'tools.no-upsell', level: 'fail', title: 'No upgrade or pricing copy in tools', detail: upsell.map((t) => t.name).join(', '), fix: 'OpenAI prohibits in-app commerce for digital subscriptions, including freemium upsells. Keep plans and pricing on your site; tool text and errors should not steer users to upgrade.', stores: ['chatgpt'] }
120
+ : { id: 'tools.no-upsell', level: 'pass', title: 'No upgrade or pricing copy in tools' });
121
+ return checks;
122
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile, access } from 'node:fs/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import { MANIFEST_FILE, draftManifest, loadManifest } from './manifest.js';
5
+ import { VERSION } from './version.js';
6
+ import { preflight } from './preflight.js';
7
+ import { printPreflight } from './report.js';
8
+ import { STORE_NAMES } from './types.js';
9
+ import { recordDecision, recordSubmitted, status } from './submissions.js';
10
+ import { chatgptPack, claudePack, directoriesPack, simplePack, writePack } from './packs.js';
11
+ import { drift, printDrift, takeSnapshot } from './drift.js';
12
+ import { publish } from './publish.js';
13
+ import { lanes, pull, runLane, tryLinks } from './extras.js';
14
+ import { fleet, fleetRun, printFleet } from './fleet.js';
15
+ const HELP = `mcplane: fastlane for MCP servers
16
+
17
+ Usage
18
+ mcplane init --url <mcp url> Create mcplane.json from your live server
19
+ mcplane preflight [--store <id>]... Check against every store's rejection causes
20
+ --url <mcp url> Check a server without a manifest
21
+ --json Machine-readable output (exit 1 on blockers)
22
+ --verbose List passing checks too
23
+ --register Also test OAuth client registration (writes test clients)
24
+ --token <token> Access token for servers behind sign-in (or MCPLANE_TOKEN)
25
+ mcplane submitted <store> Record a submission and log it to Review Times
26
+ --date YYYY-MM-DD --kind new|update|resubmission --version x.y.z
27
+ --app-id asdk_app_... ChatGPT: your app id, so its draft date is checked
28
+ --private Keep it local; don't log to Review Times
29
+ mcplane decided <store> approved|rejected|withdrawn [--date YYYY-MM-DD]
30
+ mcplane status Every submission, how long it's waited, and the store's typical wait
31
+ mcplane pack <store> Write a submission pack to .mcplane/packs (chatgpt, claude-connectors, cursor, muse, directories)
32
+ mcplane drift [--store <id>]... What changed since each store saw your server, and what each needs
33
+ --ci Exit 1 when a store needs a new version or an edit
34
+ mcplane baseline <store> [--version x.y.z] Record a listing that's already live as the drift baseline
35
+ mcplane publish <store> [--yes] Publish where the store allows it (mcp-registry, grok); dry run without --yes
36
+ mcplane try Install links for every client, for you and your testers
37
+ mcplane pull Compare your live public listings with mcplane.json
38
+ mcplane lanes List lanes; run one with "mcplane <lane>" (built in: check, release)
39
+ mcplane fleet [--root <dir>] Every project under a folder: blockers, store updates needed, reviews waiting
40
+ --quick Skip the live checks; just submissions
41
+ mcplane fleet run <command...> Run any mcplane command in every project (e.g. fleet run publish mcp-registry --yes)
42
+ mcplane mcp Run as an MCP server over stdio (claude mcp add mcplane -- npx -y mcplane mcp)
43
+
44
+ Stores: ${Object.keys(STORE_NAMES).join(', ')}
45
+ `;
46
+ async function main() {
47
+ const [cmd, ...rest] = process.argv.slice(2);
48
+ // Everything after "fleet run" belongs to the command being run, so it's passed through untouched.
49
+ if (cmd === 'fleet' && rest[0] === 'run') {
50
+ const i = rest.indexOf('--root');
51
+ const root = i > 0 ? rest[i + 1] : process.cwd();
52
+ const args = i > 0 ? [...rest.slice(1, i), ...rest.slice(i + 2)] : rest.slice(1);
53
+ if (!args.length)
54
+ throw new Error('Which command? e.g. mcplane fleet run preflight');
55
+ process.exitCode = await fleetRun(root, args);
56
+ return;
57
+ }
58
+ const { values, positionals } = parseArgs({
59
+ args: rest,
60
+ options: {
61
+ url: { type: 'string' },
62
+ store: { type: 'string', multiple: true },
63
+ json: { type: 'boolean' },
64
+ verbose: { type: 'boolean' },
65
+ register: { type: 'boolean' },
66
+ token: { type: 'string' },
67
+ date: { type: 'string' },
68
+ kind: { type: 'string' },
69
+ version: { type: 'string' },
70
+ 'app-id': { type: 'string' },
71
+ private: { type: 'boolean' },
72
+ ci: { type: 'boolean' },
73
+ root: { type: 'string' },
74
+ quick: { type: 'boolean' },
75
+ yes: { type: 'boolean' },
76
+ },
77
+ allowPositionals: true,
78
+ });
79
+ const asStore = (v) => {
80
+ if (!v || !(v in STORE_NAMES))
81
+ throw new Error(`Which store? One of: ${Object.keys(STORE_NAMES).join(', ')}`);
82
+ return v;
83
+ };
84
+ if (cmd === 'mcp') {
85
+ const { serve } = await import('./mcp-server.js');
86
+ await serve();
87
+ return;
88
+ }
89
+ if (cmd === '--version' || cmd === '-v') {
90
+ console.log(VERSION);
91
+ return;
92
+ }
93
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
94
+ console.log(HELP);
95
+ return;
96
+ }
97
+ if (cmd === 'init') {
98
+ if (!values.url)
99
+ throw new Error('mcplane init needs --url <your MCP server URL>');
100
+ const exists = await access(MANIFEST_FILE).then(() => true, () => false);
101
+ if (exists)
102
+ throw new Error(`${MANIFEST_FILE} already exists.`);
103
+ const m = await draftManifest(values.url);
104
+ await writeFile(MANIFEST_FILE, JSON.stringify(m, null, 2) + '\n');
105
+ console.log(`Wrote ${MANIFEST_FILE}. Fill in the blanks, then run "mcplane preflight".`);
106
+ return;
107
+ }
108
+ if (cmd === 'preflight') {
109
+ const m = values.url ? await draftManifest(values.url) : await loadManifest();
110
+ const stores = (values.store ?? []);
111
+ for (const s of stores)
112
+ if (!(s in STORE_NAMES))
113
+ throw new Error(`Unknown store "${s}". Stores: ${Object.keys(STORE_NAMES).join(', ')}`);
114
+ const r = await preflight(m, { stores, register: values.register, token: values.token ?? process.env.MCPLANE_TOKEN });
115
+ if (values.json)
116
+ console.log(JSON.stringify(r, null, 2));
117
+ else
118
+ printPreflight(r, { verbose: values.verbose });
119
+ process.exitCode = r.checks.some((c) => c.level === 'fail') ? 1 : 0;
120
+ return;
121
+ }
122
+ if (cmd === 'submitted') {
123
+ const m = await loadManifest();
124
+ const store = asStore(positionals[0]);
125
+ const kind = (values.kind ?? 'new');
126
+ const { submission, note } = await recordSubmitted(m, store, { date: values.date, kind, version: values.version, appId: values['app-id'], share: !values.private, token: values.token ?? process.env.MCPLANE_TOKEN });
127
+ console.log(`Recorded: ${STORE_NAMES[store]}, submitted ${submission.submittedOn}${submission.version ? ` (v${submission.version})` : ''}.`);
128
+ console.log(note);
129
+ return;
130
+ }
131
+ if (cmd === 'decided') {
132
+ const store = asStore(positionals[0]);
133
+ const outcome = positionals[1];
134
+ if (!['approved', 'rejected', 'withdrawn'].includes(outcome))
135
+ throw new Error('Say how it went: approved, rejected or withdrawn.');
136
+ const s = await recordDecision(store, outcome, values.date);
137
+ console.log(`${STORE_NAMES[store]}: ${outcome} on ${s.decidedOn}, ${Math.round((Date.parse(s.decidedOn) - Date.parse(s.submittedOn)) / 86_400_000)} days after submitting.`);
138
+ return;
139
+ }
140
+ if (cmd === 'pack') {
141
+ const m = await loadManifest();
142
+ const store = positionals[0] === 'directories' ? null : asStore(positionals[0]);
143
+ const token = values.token ?? process.env.MCPLANE_TOKEN;
144
+ const pack = !store ? directoriesPack(m) : store === 'chatgpt' ? await chatgptPack(m, token) : store === 'claude-connectors' ? await claudePack(m, token) : await simplePack(m, store);
145
+ const written = await writePack(pack);
146
+ console.log(`Wrote ${written.join(', ')}`);
147
+ if (pack.problems.length) {
148
+ console.log('\nFix before submitting:');
149
+ for (const p of pack.problems)
150
+ console.log(` ✗ ${p}`);
151
+ }
152
+ if (pack.todo.length) {
153
+ console.log('\nThen:');
154
+ for (const t of pack.todo)
155
+ console.log(` • ${t}`);
156
+ }
157
+ process.exitCode = pack.problems.length ? 1 : 0;
158
+ return;
159
+ }
160
+ if (cmd === 'baseline') {
161
+ const m = await loadManifest();
162
+ const store = asStore(positionals[0]);
163
+ const snap = await takeSnapshot(m, store, { version: values.version, state: 'live', token: values.token ?? process.env.MCPLANE_TOKEN });
164
+ console.log(`Baseline for ${STORE_NAMES[store]}: ${snap.tools.length} tools${snap.repoSha ? `, repo at ${snap.repoSha.slice(0, 7)}` : ''}. "mcplane drift" now compares against this.`);
165
+ return;
166
+ }
167
+ if (cmd === 'drift') {
168
+ const m = await loadManifest();
169
+ const stores = (values.store?.length ? values.store : m.stores ?? Object.keys(STORE_NAMES));
170
+ const r = await drift(m, stores, { token: values.token ?? process.env.MCPLANE_TOKEN });
171
+ if (values.json)
172
+ console.log(JSON.stringify(r, null, 2));
173
+ else
174
+ printDrift(r);
175
+ if (values.ci && r.items.some((i) => i.level === 'action'))
176
+ process.exitCode = 1;
177
+ return;
178
+ }
179
+ if (cmd === 'status') {
180
+ const rows = await status();
181
+ if (!rows.length) {
182
+ console.log('No submissions recorded yet. After you submit, run "mcplane submitted <store>".');
183
+ return;
184
+ }
185
+ if (values.json) {
186
+ console.log(JSON.stringify(rows, null, 2));
187
+ return;
188
+ }
189
+ for (const r of rows) {
190
+ const s = r.submission;
191
+ const state = s.status === 'waiting' ? `waiting ${r.days} day${r.days === 1 ? '' : 's'}` : `${s.status} after ${r.days} day${r.days === 1 ? '' : 's'}`;
192
+ console.log(`${STORE_NAMES[s.store].padEnd(22)} ${s.submittedOn} ${state}${s.version ? ` v${s.version}` : ''}`);
193
+ if (r.typical && s.status === 'waiting')
194
+ console.log(`${''.padEnd(22)} Store right now: ${r.typical}`);
195
+ }
196
+ return;
197
+ }
198
+ if (cmd === 'publish') {
199
+ const m = await loadManifest();
200
+ const store = asStore(positionals[0]);
201
+ const r = await publish(m, store, { yes: values.yes });
202
+ for (const l of r.lines)
203
+ if (l)
204
+ console.log(l);
205
+ if (r.done && values.yes && (store === 'mcp-registry' || store === 'grok')) {
206
+ const { note } = await recordSubmitted(m, store, { version: m.version, share: true }).catch(() => ({ note: '' }));
207
+ if (note)
208
+ console.log(note);
209
+ }
210
+ return;
211
+ }
212
+ if (cmd === 'fleet') {
213
+ const rows = await fleet(values.root ?? process.cwd(), { checks: !values.quick, token: values.token ?? process.env.MCPLANE_TOKEN });
214
+ if (values.json)
215
+ console.log(JSON.stringify(rows, null, 2));
216
+ else
217
+ printFleet(rows);
218
+ if (values.ci && rows.some((r) => r.error || r.blocking || r.driftActions?.length))
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+ if (cmd === 'try') {
223
+ const m = await loadManifest();
224
+ for (const t of tryLinks(m))
225
+ console.log(`${t.client.padEnd(22)} ${t.how}`);
226
+ return;
227
+ }
228
+ if (cmd === 'pull') {
229
+ const m = await loadManifest();
230
+ const diffs = await pull(m);
231
+ if (values.json) {
232
+ console.log(JSON.stringify(diffs, null, 2));
233
+ return;
234
+ }
235
+ for (const d of diffs) {
236
+ if (!d.found)
237
+ console.log(`${d.store}: not listed yet.`);
238
+ else if (!d.fields.length)
239
+ console.log(`${d.store}: matches mcplane.json.`);
240
+ else {
241
+ console.log(`${d.store}: live listing differs${d.url ? ` (${d.url})` : ''}`);
242
+ for (const f of d.fields)
243
+ console.log(` ${f.field}\n live: ${f.live.slice(0, 160)}\n yours: ${f.yours.slice(0, 160)}`);
244
+ }
245
+ }
246
+ return;
247
+ }
248
+ const m = await loadManifest().catch(() => null);
249
+ const all = m ? lanes(m) : lanes({ name: '', title: '', server: { url: '' } });
250
+ if (cmd === 'lanes') {
251
+ for (const [name, steps] of Object.entries(all))
252
+ console.log(`${name}: ${steps.map((s) => (s.startsWith('handoff:') ? '(you)' : s)).join(' → ')}`);
253
+ return;
254
+ }
255
+ if (all[cmd]) {
256
+ process.exitCode = runLane(cmd, all[cmd], values.token ? ['--token', values.token] : []);
257
+ return;
258
+ }
259
+ throw new Error(`Unknown command "${cmd}". Run "mcplane help".`);
260
+ }
261
+ main().catch((e) => {
262
+ console.error(`mcplane: ${e.message}`);
263
+ process.exitCode = 2;
264
+ });
package/dist/drift.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Drift: what changed since each store last saw your server, and what each
3
+ * store needs because of it. A snapshot of tools and listing text is saved
4
+ * whenever you record a submission (or `mcplane baseline`); drift compares
5
+ * the live server and mcplane.json against it.
6
+ *
7
+ * Every store calls your live server, so behaviour fixes reach users on their
8
+ * own. What doesn't: listing text, tool names in Claude's listing, hint
9
+ * justifications, registry versions and plugins pinned to a commit.
10
+ */
11
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
+ import { resolve } from 'node:path';
13
+ import { ensureLocalDir } from './manifest.js';
14
+ import { McpClient } from './mcp-client.js';
15
+ import { STORE_NAMES } from './types.js';
16
+ const DIR = '.mcplane/snapshots';
17
+ /** JSON with sorted keys, so reordering isn't a change. */
18
+ function canon(v) {
19
+ if (v === undefined || v === null)
20
+ return '';
21
+ const sort = (x) => (Array.isArray(x) ? x.map(sort) : x && typeof x === 'object' ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, sort(x[k])])) : x);
22
+ return JSON.stringify(sort(v));
23
+ }
24
+ function shape(t) {
25
+ return {
26
+ name: t.name,
27
+ title: t.title ?? t.annotations?.title,
28
+ description: t.description,
29
+ hints: { readOnly: t.annotations?.readOnlyHint, destructive: t.annotations?.destructiveHint, openWorld: t.annotations?.openWorldHint },
30
+ input: canon(t.inputSchema),
31
+ output: canon(t.outputSchema),
32
+ };
33
+ }
34
+ function listingOf(m) {
35
+ return {
36
+ title: m.title,
37
+ subtitle: m.subtitle,
38
+ oneLiner: m.oneLiner,
39
+ description: m.description,
40
+ icon: m.icon,
41
+ privacy: m.links?.privacy,
42
+ support: m.links?.support,
43
+ website: m.links?.website,
44
+ };
45
+ }
46
+ export async function liveTools(m, token) {
47
+ const c = new McpClient(m.server.url, token ? { authorization: `Bearer ${token}` } : {});
48
+ const init = await c.initialize();
49
+ if (init.status >= 400)
50
+ throw new Error(`Couldn't read tools from ${m.server.url} (HTTP ${init.status})${m.server.auth === 'oauth' ? '; pass --token' : ''}.`);
51
+ return ((await c.request('tools/list')).body?.result?.tools ?? []);
52
+ }
53
+ /** HEAD of the plugin repository on GitHub, when there is one. */
54
+ export async function repoHead(repository) {
55
+ const gh = repository?.match(/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?$/);
56
+ if (!gh)
57
+ return undefined;
58
+ const headers = { 'user-agent': 'mcplane', accept: 'application/vnd.github+json' };
59
+ if (process.env.GITHUB_TOKEN)
60
+ headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
61
+ const r = await fetch(`https://api.github.com/repos/${gh[1]}/${gh[2]}/commits/HEAD`, { headers }).catch(() => null);
62
+ return r?.ok ? (await r.json()).sha : undefined;
63
+ }
64
+ export async function takeSnapshot(m, store, opts, dir = process.cwd()) {
65
+ const [tools, repoSha] = await Promise.all([liveTools(m, opts.token), repoHead(m.repository)]);
66
+ const snap = {
67
+ store,
68
+ takenOn: new Date().toISOString().slice(0, 10),
69
+ version: opts.version,
70
+ state: opts.state,
71
+ tools: tools.map(shape),
72
+ listing: listingOf(m),
73
+ repoSha,
74
+ };
75
+ await ensureLocalDir(dir);
76
+ await mkdir(resolve(dir, DIR), { recursive: true });
77
+ await writeFile(resolve(dir, DIR, `${store}.json`), JSON.stringify(snap, null, 2) + '\n');
78
+ return snap;
79
+ }
80
+ export async function markLive(store, dir = process.cwd()) {
81
+ const snap = await readSnapshot(store, dir);
82
+ if (!snap)
83
+ return;
84
+ snap.state = 'live';
85
+ await writeFile(resolve(dir, DIR, `${store}.json`), JSON.stringify(snap, null, 2) + '\n');
86
+ }
87
+ async function readSnapshot(store, dir = process.cwd()) {
88
+ try {
89
+ return JSON.parse(await readFile(resolve(dir, DIR, `${store}.json`), 'utf8'));
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /** What changed between a snapshot and now, as plain descriptions. */
96
+ export function diffTools(before, now) {
97
+ const b = new Map(before.map((t) => [t.name, t]));
98
+ const n = new Map(now.map((t) => [t.name, t]));
99
+ const added = [...n.keys()].filter((k) => !b.has(k));
100
+ const removed = [...b.keys()].filter((k) => !n.has(k));
101
+ const changed = [];
102
+ for (const [name, t] of n) {
103
+ const o = b.get(name);
104
+ if (!o)
105
+ continue;
106
+ const fields = [];
107
+ for (const h of ['readOnly', 'destructive', 'openWorld'])
108
+ if (o.hints[h] !== t.hints[h])
109
+ fields.push(`${h}Hint ${o.hints[h]} → ${t.hints[h]}`);
110
+ if (o.input !== t.input)
111
+ fields.push('inputs');
112
+ if (o.output !== t.output)
113
+ fields.push('outputSchema');
114
+ if ((o.title ?? '') !== (t.title ?? ''))
115
+ fields.push('title');
116
+ if ((o.description ?? '') !== (t.description ?? ''))
117
+ fields.push('description');
118
+ if (fields.length)
119
+ changed.push({ tool: name, fields });
120
+ }
121
+ return { added, removed, changed };
122
+ }
123
+ /** Grok bumps pins daily; this reads the pin it currently serves. */
124
+ async function grokPin(name) {
125
+ const r = await fetch('https://raw.githubusercontent.com/xai-org/plugin-marketplace/main/.grok-plugin/marketplace.json', { headers: { 'user-agent': 'mcplane' } }).catch(() => null);
126
+ const data = r?.ok ? (await r.json().catch(() => null)) : null;
127
+ return data?.plugins?.find((p) => p.name === name)?.source?.sha;
128
+ }
129
+ export async function drift(m, stores, opts = {}, dir = process.cwd()) {
130
+ const items = [];
131
+ const missing = [];
132
+ const [live, head] = await Promise.all([liveTools(m, opts.token), repoHead(m.repository)]);
133
+ const now = live.map(shape);
134
+ const listing = listingOf(m);
135
+ for (const store of stores) {
136
+ const snap = await readSnapshot(store, dir);
137
+ if (!snap) {
138
+ missing.push(store);
139
+ continue;
140
+ }
141
+ const d = diffTools(snap.tools, now);
142
+ const toolChange = d.added.length + d.removed.length + d.changed.length > 0;
143
+ const listingChange = Object.keys(listing).filter((k) => (listing[k] ?? '') !== (snap.listing[k] ?? ''));
144
+ const summary = [
145
+ d.added.length && `added ${d.added.join(', ')}`,
146
+ d.removed.length && `removed ${d.removed.join(', ')}`,
147
+ ...d.changed.map((c) => `${c.tool}: ${c.fields.join(', ')}`),
148
+ ]
149
+ .filter(Boolean)
150
+ .join('; ');
151
+ const since = `since ${snap.state === 'live' ? 'the live version' : 'your submission'}${snap.version ? ` (v${snap.version})` : ''} on ${snap.takenOn}`;
152
+ if (store === 'chatgpt') {
153
+ // OpenAI rescans published servers: removals apply at once, new and changed tools after automated checks.
154
+ // Listing text is part of the version and needs a new one.
155
+ const hintChanges = d.changed.filter((c) => c.fields.some((f) => f.includes('Hint')));
156
+ if (toolChange)
157
+ items.push({
158
+ store,
159
+ level: 'info',
160
+ what: `Tools changed ${since}: ${summary}`,
161
+ todo: `No resubmission needed for tools: OpenAI rescans your server, drops removed tools at once and switches new or changed ones over after they pass automated checks (the old definition stays live until then).${d.added.length || d.removed.length ? ' Update the test cases and demo video in your next version.' : ''}`,
162
+ });
163
+ if (hintChanges.length)
164
+ items.push({ store, level: 'action', what: `Hints changed: ${hintChanges.map((c) => c.tool).join(', ')}`, todo: 'Your published version’s justifications explain the old values. Put new ones in the next version ("mcplane pack chatgpt").' });
165
+ if (listingChange.length)
166
+ items.push({ store, level: 'action', what: `Listing text changed (${listingChange.join(', ')})`, todo: 'Listing changes need a new version, review and publication: "mcplane pack chatgpt", import it into a new version, submit.' });
167
+ }
168
+ if (store === 'claude-connectors') {
169
+ if (d.added.length || d.removed.length)
170
+ items.push({ store, level: 'action', what: `Tool list changed ${since}: ${summary}`, todo: 'Edit the listing’s tool names in claude.ai/directory/manage (changes are reviewed before going live).' });
171
+ const hintChanges = d.changed.filter((c) => c.fields.some((f) => f.includes('Hint')));
172
+ if (hintChanges.length)
173
+ items.push({ store, level: 'action', what: `Safety hints changed: ${hintChanges.map((c) => `${c.tool} (${c.fields.filter((f) => f.includes('Hint')).join(', ')})`).join('; ')}`, todo: 'Reviewers check hints against your read/write answer. Update it if needed and add a line to the listing’s Additional notes.' });
174
+ const other = d.changed.filter((c) => !c.fields.every((f) => f.includes('Hint')));
175
+ if (other.length && !d.added.length && !d.removed.length)
176
+ items.push({ store, level: 'info', what: `Tool details changed: ${other.map((c) => c.tool).join(', ')}`, todo: 'Claude reads descriptions and schemas live; nothing to resubmit.' });
177
+ if (listingChange.length)
178
+ items.push({ store, level: 'action', what: `Listing text changed (${listingChange.join(', ')})`, todo: 'Edit the listing in claude.ai/directory/manage.' });
179
+ }
180
+ if (store === 'claude-plugins' || store === 'cursor') {
181
+ if (head && snap.repoSha && head !== snap.repoSha)
182
+ items.push({ store, level: store === 'cursor' ? 'action' : 'info', what: `Plugin repo moved on ${since} (${snap.repoSha.slice(0, 7)} → ${head.slice(0, 7)})`, todo: store === 'cursor' ? 'Cursor pins marketplace plugins to the commit that was current when they were added and doesn’t document updates. Resubmit at cursor.com/marketplace/publish, or ask Cursor to re-pin.' : 'The portal picks up commits on the branch it tracks (push webhook or scheduled check) and validates them; a reviewer publishes each version unless you have auto-publish. Check claude.ai/directory/manage.' });
183
+ }
184
+ if (store === 'grok' && head) {
185
+ const pinned = await grokPin(m.name);
186
+ if (pinned && pinned !== head)
187
+ items.push({ store, level: 'info', what: `Grok serves ${pinned.slice(0, 7)}; your repo is at ${head.slice(0, 7)}`, todo: 'xAI bumps pins daily. If it’s still behind tomorrow, "mcplane publish grok" opens a pin-bump pull request.' });
188
+ }
189
+ if (store === 'muse' && (toolChange || listingChange.length)) {
190
+ items.push({ store, level: 'info', what: `Changed ${since}: ${[summary, listingChange.length && `listing ${listingChange.join(', ')}`].filter(Boolean).join('; ')}`, todo: 'Meta hasn’t published an update process yet. Update the listing in muse.ai/platform if it shows old details.' });
191
+ }
192
+ if (store === 'mcp-registry' && (toolChange || listingChange.length)) {
193
+ items.push({ store, level: 'action', what: `Changed ${since}`, todo: 'Bump the version and run "mcplane publish mcp-registry".' });
194
+ }
195
+ }
196
+ return { items, missing };
197
+ }
198
+ export function printDrift(r) {
199
+ if (!r.items.length)
200
+ console.log('No drift: every store with a snapshot matches your live server and mcplane.json.');
201
+ for (const i of r.items) {
202
+ console.log(`${i.level === 'action' ? '✗' : '•'} ${STORE_NAMES[i.store]}: ${i.what}`);
203
+ console.log(` ${i.todo}`);
204
+ }
205
+ if (r.missing.length)
206
+ console.log(`\nNo snapshot yet for ${r.missing.map((s) => STORE_NAMES[s]).join(', ')}. Run "mcplane baseline <store>" for listings that are already live.`);
207
+ }