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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/checks/auth.js +72 -0
- package/dist/checks/listing.js +149 -0
- package/dist/checks/server.js +148 -0
- package/dist/checks/tools.js +122 -0
- package/dist/cli.js +264 -0
- package/dist/drift.js +207 -0
- package/dist/extras.js +109 -0
- package/dist/fleet.js +112 -0
- package/dist/manifest.js +97 -0
- package/dist/mcp-client.js +70 -0
- package/dist/mcp-server.js +172 -0
- package/dist/packs.js +278 -0
- package/dist/preflight.js +39 -0
- package/dist/publish.js +271 -0
- package/dist/report.js +31 -0
- package/dist/submissions.js +126 -0
- package/dist/types.js +15 -0
- package/dist/version.js +1 -0
- package/package.json +56 -0
- package/schema.json +283 -0
package/dist/extras.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fastlane ideas, for agent-era stores:
|
|
3
|
+
* lanes named workflows in mcplane.json ("mcplane release")
|
|
4
|
+
* try install links for every client, like pilot for TestFlight testers
|
|
5
|
+
* pull read your live public listings back and compare with mcplane.json,
|
|
6
|
+
* like deliver's download_metadata
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
/* ---------------- lanes ---------------- */
|
|
10
|
+
export const DEFAULT_LANES = {
|
|
11
|
+
// Fast checks for CI on every push or deploy.
|
|
12
|
+
check: ['preflight', 'drift --ci'],
|
|
13
|
+
// Everything for a release: checks, packs, the stores that can be automated, then your hand-offs.
|
|
14
|
+
release: [
|
|
15
|
+
'preflight',
|
|
16
|
+
'drift',
|
|
17
|
+
'pack chatgpt',
|
|
18
|
+
'pack claude-connectors',
|
|
19
|
+
'publish mcp-registry',
|
|
20
|
+
'publish grok',
|
|
21
|
+
'handoff: Upload .mcplane/packs/chatgpt-app-submission.json in the ChatGPT portal, tick the policy statements and submit. Then: mcplane submitted chatgpt --app-id <asdk_app_…>',
|
|
22
|
+
'handoff: Paste .mcplane/packs/claude-connectors.md into claude.ai/directory/manage/new, make the compliance statements yourself and submit. Then: mcplane submitted claude-connectors',
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
export function lanes(m) {
|
|
26
|
+
return { ...DEFAULT_LANES, ...(m.lanes ?? {}) };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Runs a lane step by step. A step that fails stops the lane unless it starts
|
|
30
|
+
* with "?" (optional). "handoff:" steps are what only a person can do: policy
|
|
31
|
+
* statements, the final submit. They're collected and shown at the end.
|
|
32
|
+
*/
|
|
33
|
+
export function runLane(name, steps, passthrough) {
|
|
34
|
+
const handoffs = [];
|
|
35
|
+
const me = process.argv[1];
|
|
36
|
+
for (const raw of steps) {
|
|
37
|
+
if (raw.startsWith('handoff:')) {
|
|
38
|
+
handoffs.push(raw.slice(8).trim());
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const optional = raw.startsWith('?');
|
|
42
|
+
const step = optional ? raw.slice(1).trim() : raw.trim();
|
|
43
|
+
console.log(`\n▸ mcplane ${step}`);
|
|
44
|
+
const r = spawnSync(process.execPath, [...process.execArgv, me, ...step.split(/\s+/), ...passthrough], { stdio: 'inherit' });
|
|
45
|
+
if (r.status !== 0 && !optional) {
|
|
46
|
+
console.log(`\nLane "${name}" stopped at "${step}". Fix that, then run "mcplane ${name}" again.`);
|
|
47
|
+
return r.status ?? 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (handoffs.length) {
|
|
51
|
+
console.log('\nYour turn (only you can do these):');
|
|
52
|
+
handoffs.forEach((h, i) => console.log(` ${i + 1}. ${h}`));
|
|
53
|
+
}
|
|
54
|
+
console.log(`\nLane "${name}" done.`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
/* ---------------- try ---------------- */
|
|
58
|
+
export function tryLinks(m) {
|
|
59
|
+
const name = m.name;
|
|
60
|
+
const url = m.server.url;
|
|
61
|
+
const cursorConfig = Buffer.from(JSON.stringify({ url })).toString('base64');
|
|
62
|
+
const vscode = encodeURIComponent(JSON.stringify({ name, type: 'http', url }));
|
|
63
|
+
return [
|
|
64
|
+
{ client: 'Claude Code', how: `claude mcp add --transport http ${name} ${url}` },
|
|
65
|
+
{ client: 'Cursor', how: `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(name)}&config=${cursorConfig}` },
|
|
66
|
+
{ client: 'VS Code', how: `vscode:mcp/install?${vscode}` },
|
|
67
|
+
{ client: 'Claude (web, desktop)', how: `Settings → Connectors → Add custom connector → ${url}` },
|
|
68
|
+
{ client: 'ChatGPT', how: `Turn on developer mode (Settings → Apps → Advanced), then Plugins → Add → Create MCP App → ${url}${m.server.auth === 'oauth' ? ' (OAuth)' : ' (No authentication)'}` },
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
const norm = (s) => (s ?? '').replace(/\s+/g, ' ').trim();
|
|
72
|
+
const sameUrl = (a, b) => !!a && !!b && a.replace(/\/+$/, '').replace(/\.git$/, '').toLowerCase() === b.replace(/\/+$/, '').replace(/\.git$/, '').toLowerCase();
|
|
73
|
+
/** Reads the public listings that have an open feed and compares them with mcplane.json. */
|
|
74
|
+
export async function pull(m) {
|
|
75
|
+
const out = [];
|
|
76
|
+
// Claude connectors: Anthropic's public directory feed.
|
|
77
|
+
const res = await fetch('https://api.anthropic.com/api/directory/servers?verified_tier=anthropic,partner,community&visibility=commercial&limit=5000', { headers: { 'user-agent': 'mcplane' } }).catch(() => null);
|
|
78
|
+
const data = res?.ok ? (await res.json().catch(() => null)) : null;
|
|
79
|
+
const hit = data?.servers?.find((s) => sameUrl(s.remote?.url, m.server.url));
|
|
80
|
+
out.push({
|
|
81
|
+
store: 'Claude connectors',
|
|
82
|
+
found: !!hit,
|
|
83
|
+
url: hit?.directory_url,
|
|
84
|
+
fields: hit
|
|
85
|
+
? [
|
|
86
|
+
['name', hit.display_name ?? hit.name, m.title],
|
|
87
|
+
['one-liner', hit.one_liner, m.oneLiner],
|
|
88
|
+
['description', hit.description, m.description],
|
|
89
|
+
]
|
|
90
|
+
.filter(([, live, yours]) => norm(live) && norm(yours) && norm(live) !== norm(yours))
|
|
91
|
+
.map(([field, live, yours]) => ({ field, live: norm(live), yours: norm(yours) }))
|
|
92
|
+
: [],
|
|
93
|
+
});
|
|
94
|
+
// Cursor: the marketplace page embeds every approved plugin.
|
|
95
|
+
if (m.repository) {
|
|
96
|
+
const page = await fetch('https://cursor.com/marketplace', { headers: { 'user-agent': 'mcplane' } }).then((r) => r.text()).catch(() => '');
|
|
97
|
+
const html = page.replace(/\\"/g, '"');
|
|
98
|
+
const repo = m.repository.replace(/\.git$/, '').replace(/\/+$/, '');
|
|
99
|
+
const i = html.toLowerCase().indexOf(`"repositoryurl":"${repo.toLowerCase()}`);
|
|
100
|
+
const around = i >= 0 ? html.slice(Math.max(0, i - 3000), i) : '';
|
|
101
|
+
const desc = around.match(/"description":"((?:[^"\\]|\\.)*)"[^{]*$/)?.[1];
|
|
102
|
+
out.push({
|
|
103
|
+
store: 'Cursor',
|
|
104
|
+
found: i >= 0,
|
|
105
|
+
fields: desc && norm(desc) !== norm(m.oneLiner ?? m.description) ? [{ field: 'description', live: norm(desc), yours: norm(m.oneLiner ?? m.description) }] : [],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
package/dist/fleet.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet: many MCP servers, one view. Finds every mcplane.json under a folder
|
|
3
|
+
* (your ~/Projects, a monorepo) and runs checks across all of them, so
|
|
4
|
+
* seventeen apps cost the same attention as one.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { readdir } from 'node:fs/promises';
|
|
8
|
+
import { join, relative, resolve } from 'node:path';
|
|
9
|
+
import { drift } from './drift.js';
|
|
10
|
+
import { MANIFEST_FILE, loadManifest, storesOf } from './manifest.js';
|
|
11
|
+
import { preflight } from './preflight.js';
|
|
12
|
+
import { load } from './submissions.js';
|
|
13
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.mcplane', '.next', '.wrangler', 'vendor', 'target']);
|
|
14
|
+
/** Folders under root that hold an mcplane.json, depth-first, a few levels down. */
|
|
15
|
+
export async function findProjects(root = process.cwd(), depth = 3) {
|
|
16
|
+
const found = [];
|
|
17
|
+
const walk = async (dir, level) => {
|
|
18
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
19
|
+
if (entries.some((e) => e.isFile() && e.name === MANIFEST_FILE)) {
|
|
20
|
+
const p = { dir, path: relative(root, dir) || '.' };
|
|
21
|
+
try {
|
|
22
|
+
p.manifest = await loadManifest(dir);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
p.error = e.message;
|
|
26
|
+
}
|
|
27
|
+
found.push(p);
|
|
28
|
+
}
|
|
29
|
+
if (level >= depth)
|
|
30
|
+
return;
|
|
31
|
+
for (const e of entries)
|
|
32
|
+
if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith('.'))
|
|
33
|
+
await walk(join(dir, e.name), level + 1);
|
|
34
|
+
};
|
|
35
|
+
await walk(resolve(root), 0);
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
/** Runs fn over items, a few at a time. */
|
|
39
|
+
async function pool(items, n, fn) {
|
|
40
|
+
const out = new Array(items.length);
|
|
41
|
+
let i = 0;
|
|
42
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
43
|
+
while (i < items.length) {
|
|
44
|
+
const k = i++;
|
|
45
|
+
out[k] = await fn(items[k]);
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** Preflight, drift and submissions for every project under root. Checks run four projects at a time. */
|
|
51
|
+
export async function fleet(root = process.cwd(), opts = {}) {
|
|
52
|
+
const projects = await findProjects(root);
|
|
53
|
+
return pool(projects, 4, async (p) => {
|
|
54
|
+
const subs = await load(p.dir);
|
|
55
|
+
const row = {
|
|
56
|
+
project: p.path,
|
|
57
|
+
name: p.manifest?.title,
|
|
58
|
+
url: p.manifest?.server.url,
|
|
59
|
+
waiting: subs.filter((s) => s.status === 'waiting').map((s) => ({ store: s.store, since: s.submittedOn })),
|
|
60
|
+
live: [...new Set(subs.filter((s) => s.status === 'approved').map((s) => s.store))],
|
|
61
|
+
error: p.error,
|
|
62
|
+
};
|
|
63
|
+
if (!p.manifest || opts.checks === false)
|
|
64
|
+
return row;
|
|
65
|
+
try {
|
|
66
|
+
const r = await preflight(p.manifest, { token: opts.token });
|
|
67
|
+
row.blocking = r.checks.filter((c) => c.level === 'fail').length;
|
|
68
|
+
row.toReview = r.checks.filter((c) => c.level === 'warn').length;
|
|
69
|
+
const d = await drift(p.manifest, storesOf(p.manifest), { token: opts.token }, p.dir);
|
|
70
|
+
row.driftActions = d.items.filter((i) => i.level === 'action').map((i) => ({ store: i.store, what: i.what }));
|
|
71
|
+
row.noSnapshot = d.missing;
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
row.error = e.message;
|
|
75
|
+
}
|
|
76
|
+
return row;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function printFleet(rows) {
|
|
80
|
+
if (!rows.length) {
|
|
81
|
+
console.log(`No ${MANIFEST_FILE} found here or a few folders down. Run "mcplane init --url <server>" in each project.`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
const bits = [
|
|
86
|
+
r.error ? `error: ${r.error}` : r.blocking === undefined ? '' : r.blocking ? `${r.blocking} blocking` : 'preflight clean',
|
|
87
|
+
r.toReview ? `${r.toReview} to review` : '',
|
|
88
|
+
r.driftActions?.length ? `${r.driftActions.length} store update${r.driftActions.length === 1 ? '' : 's'} needed` : '',
|
|
89
|
+
r.waiting.length ? `waiting on ${r.waiting.map((w) => w.store).join(', ')}` : '',
|
|
90
|
+
r.live.length ? `live on ${r.live.join(', ')}` : '',
|
|
91
|
+
].filter(Boolean);
|
|
92
|
+
const mark = r.error || r.blocking || r.driftActions?.length ? '✗' : '✓';
|
|
93
|
+
console.log(`${mark} ${(r.name ?? r.project).padEnd(24)} ${bits.join(' · ')}`);
|
|
94
|
+
for (const a of r.driftActions ?? [])
|
|
95
|
+
console.log(` ${a.store}: ${a.what}`);
|
|
96
|
+
}
|
|
97
|
+
const bad = rows.filter((r) => r.error || r.blocking || r.driftActions?.length).length;
|
|
98
|
+
console.log(`\n${rows.length} project${rows.length === 1 ? '' : 's'}, ${bad} need${bad === 1 ? 's' : ''} attention. Details: cd into one and run "mcplane preflight" or "mcplane drift".`);
|
|
99
|
+
}
|
|
100
|
+
/** Runs one mcplane command in every project, one after another. */
|
|
101
|
+
export async function fleetRun(root, args) {
|
|
102
|
+
const projects = (await findProjects(root)).filter((p) => p.manifest);
|
|
103
|
+
let failed = 0;
|
|
104
|
+
for (const p of projects) {
|
|
105
|
+
console.log(`\n━━ ${p.manifest.title} (${p.path})`);
|
|
106
|
+
const r = spawnSync(process.execPath, [...process.execArgv, process.argv[1], ...args], { cwd: p.dir, stdio: 'inherit' });
|
|
107
|
+
if (r.status !== 0)
|
|
108
|
+
failed++;
|
|
109
|
+
}
|
|
110
|
+
console.log(`\n${projects.length - failed} of ${projects.length} succeeded.`);
|
|
111
|
+
return failed ? 1 : 0;
|
|
112
|
+
}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { McpClient } from './mcp-client.js';
|
|
4
|
+
import { STORE_NAMES } from './types.js';
|
|
5
|
+
export const MANIFEST_FILE = 'mcplane.json';
|
|
6
|
+
export const SCHEMA_URL = 'https://unpkg.com/mcplane/schema.json';
|
|
7
|
+
export async function loadManifest(dir = process.cwd()) {
|
|
8
|
+
const path = resolve(dir, MANIFEST_FILE);
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await readFile(path, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`No ${MANIFEST_FILE} here. Run "mcplane init --url <your MCP server URL>" to create one.`);
|
|
15
|
+
}
|
|
16
|
+
let m;
|
|
17
|
+
try {
|
|
18
|
+
m = JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new Error(`${MANIFEST_FILE} isn't valid JSON: ${e.message}`);
|
|
22
|
+
}
|
|
23
|
+
const problems = [];
|
|
24
|
+
if (!m.name)
|
|
25
|
+
problems.push('"name" is required');
|
|
26
|
+
if (!m.title)
|
|
27
|
+
problems.push('"title" is required');
|
|
28
|
+
if (!m.server?.url)
|
|
29
|
+
problems.push('"server.url" is required');
|
|
30
|
+
for (const s of m.stores ?? [])
|
|
31
|
+
if (!(s in STORE_NAMES))
|
|
32
|
+
problems.push(`unknown store "${s}"`);
|
|
33
|
+
if (problems.length)
|
|
34
|
+
throw new Error(`${MANIFEST_FILE}: ${problems.join('; ')}`);
|
|
35
|
+
return m;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Creates .mcplane with its own .gitignore: snapshots are committed (CI reads
|
|
39
|
+
* them for drift), while submissions (private Review Times tokens) and packs stay local.
|
|
40
|
+
*/
|
|
41
|
+
export async function ensureLocalDir(dir = process.cwd()) {
|
|
42
|
+
const d = resolve(dir, '.mcplane');
|
|
43
|
+
await mkdir(d, { recursive: true });
|
|
44
|
+
await writeFile(resolve(d, '.gitignore'), 'submissions.json\npacks/\n', { flag: 'wx' }).catch(() => null);
|
|
45
|
+
return d;
|
|
46
|
+
}
|
|
47
|
+
export function storesOf(m) {
|
|
48
|
+
return m.stores?.length ? m.stores : Object.keys(STORE_NAMES);
|
|
49
|
+
}
|
|
50
|
+
/** A starting manifest from what the live server and its domain reveal. */
|
|
51
|
+
export async function draftManifest(url) {
|
|
52
|
+
const client = new McpClient(url);
|
|
53
|
+
const init = await client.initialize().catch(() => null);
|
|
54
|
+
const auth = init?.status === 401 ? 'oauth' : 'none';
|
|
55
|
+
const info = init?.body?.result?.serverInfo ?? {};
|
|
56
|
+
const host = new URL(url).hostname;
|
|
57
|
+
const labels = host.split('.');
|
|
58
|
+
// api.example.com and mcp.example.com usually keep their pages on example.com.
|
|
59
|
+
const origins = [`https://${host}`, ...(labels.length > 2 ? [`https://${labels.slice(1).join('.')}`] : [])];
|
|
60
|
+
const origin = origins[origins.length - 1];
|
|
61
|
+
const found = async (p) => {
|
|
62
|
+
for (const o of origins) {
|
|
63
|
+
const r = await fetch(o + p, { method: 'GET', redirect: 'follow' }).catch(() => null);
|
|
64
|
+
if (r?.ok && !(r.headers.get('content-type') ?? '').includes('json'))
|
|
65
|
+
return o + p;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
const first = async (...paths) => {
|
|
70
|
+
for (const p of paths) {
|
|
71
|
+
const hit = await found(p);
|
|
72
|
+
if (hit)
|
|
73
|
+
return hit;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
};
|
|
77
|
+
const [privacy, support, terms, docs, icon] = await Promise.all([
|
|
78
|
+
first('/privacy', '/privacy-policy', '/legal/privacy'),
|
|
79
|
+
first('/support', '/contact', '/help'),
|
|
80
|
+
first('/terms', '/terms-of-service', '/legal/terms'),
|
|
81
|
+
first('/llms.txt', '/docs'),
|
|
82
|
+
first('/icon-512.png', '/icon.png', '/apple-touch-icon.png'),
|
|
83
|
+
]);
|
|
84
|
+
return {
|
|
85
|
+
$schema: SCHEMA_URL,
|
|
86
|
+
name: String(info.name ?? new URL(url).hostname.split('.')[0]),
|
|
87
|
+
title: String(info.title ?? info.name ?? ''),
|
|
88
|
+
subtitle: '',
|
|
89
|
+
oneLiner: '',
|
|
90
|
+
description: '',
|
|
91
|
+
server: { url, auth },
|
|
92
|
+
author: { name: '', url: origin },
|
|
93
|
+
links: { website: origin, support, privacy, terms, docs },
|
|
94
|
+
icon,
|
|
95
|
+
stores: ['mcp-registry', 'chatgpt', 'claude-connectors', 'cursor', 'muse'],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const UA = 'mcplane (+https://github.com/stevysmith/mcplane)';
|
|
2
|
+
export class McpClient {
|
|
3
|
+
url;
|
|
4
|
+
extraHeaders;
|
|
5
|
+
timeoutMs;
|
|
6
|
+
session = null;
|
|
7
|
+
id = 0;
|
|
8
|
+
constructor(url, extraHeaders = {}, timeoutMs = 15_000) {
|
|
9
|
+
this.url = url;
|
|
10
|
+
this.extraHeaders = extraHeaders;
|
|
11
|
+
this.timeoutMs = timeoutMs;
|
|
12
|
+
}
|
|
13
|
+
async request(method, params, opts = {}) {
|
|
14
|
+
const body = opts.notification ? { jsonrpc: '2.0', method, params } : { jsonrpc: '2.0', id: ++this.id, method, params };
|
|
15
|
+
const headers = {
|
|
16
|
+
'content-type': 'application/json',
|
|
17
|
+
accept: 'application/json, text/event-stream',
|
|
18
|
+
'mcp-protocol-version': '2025-06-18',
|
|
19
|
+
'user-agent': UA,
|
|
20
|
+
...this.extraHeaders,
|
|
21
|
+
};
|
|
22
|
+
if (this.session)
|
|
23
|
+
headers['mcp-session-id'] = this.session;
|
|
24
|
+
const res = await fetch(opts.url ?? this.url, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers,
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
29
|
+
});
|
|
30
|
+
const sid = res.headers.get('mcp-session-id');
|
|
31
|
+
if (sid)
|
|
32
|
+
this.session = sid;
|
|
33
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
return { status: res.status, contentType, body: parseBody(text, contentType), headers: res.headers };
|
|
36
|
+
}
|
|
37
|
+
async initialize() {
|
|
38
|
+
const r = await this.request('initialize', {
|
|
39
|
+
protocolVersion: '2025-06-18',
|
|
40
|
+
capabilities: {},
|
|
41
|
+
clientInfo: { name: 'mcplane', version: '0.1.0' },
|
|
42
|
+
});
|
|
43
|
+
if (r.status < 300)
|
|
44
|
+
await this.request('notifications/initialized', undefined, { notification: true }).catch(() => null);
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** JSON, or the last JSON-RPC message in an SSE stream. */
|
|
49
|
+
function parseBody(text, contentType) {
|
|
50
|
+
if (contentType.includes('text/event-stream')) {
|
|
51
|
+
const datas = text
|
|
52
|
+
.split(/\r?\n/)
|
|
53
|
+
.filter((l) => l.startsWith('data:'))
|
|
54
|
+
.map((l) => l.slice(5).trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
for (let i = datas.length - 1; i >= 0; i--) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(datas[i]);
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return text ? JSON.parse(text) : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mcplane mcp`: the same commands as MCP tools over stdio, so Claude Code,
|
|
3
|
+
* Codex or Cursor can check, prepare and track submissions from inside the
|
|
4
|
+
* repo. Every tool takes an optional project folder (default: where the
|
|
5
|
+
* server was started), and fleet covers every project under a folder at once.
|
|
6
|
+
*/
|
|
7
|
+
import { access, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { drift } from './drift.js';
|
|
13
|
+
import { fleet } from './fleet.js';
|
|
14
|
+
import { pull, tryLinks } from './extras.js';
|
|
15
|
+
import { MANIFEST_FILE, draftManifest, loadManifest, storesOf } from './manifest.js';
|
|
16
|
+
import { chatgptPack, claudePack, directoriesPack, simplePack, writePack } from './packs.js';
|
|
17
|
+
import { preflight } from './preflight.js';
|
|
18
|
+
import { publish } from './publish.js';
|
|
19
|
+
import { recordDecision, recordSubmitted, status } from './submissions.js';
|
|
20
|
+
import { STORE_NAMES } from './types.js';
|
|
21
|
+
import { VERSION } from './version.js';
|
|
22
|
+
const STORE = z.enum(Object.keys(STORE_NAMES));
|
|
23
|
+
const reply = (data, summary) => ({
|
|
24
|
+
content: [{ type: 'text', text: summary ? `${summary}\n\n${JSON.stringify(data, null, 2)}` : JSON.stringify(data, null, 2) }],
|
|
25
|
+
structuredContent: data,
|
|
26
|
+
});
|
|
27
|
+
const token = () => process.env.MCPLANE_TOKEN;
|
|
28
|
+
const PROJECT = z.string().optional().describe('Folder holding mcplane.json. Defaults to the folder mcplane was started in.');
|
|
29
|
+
const dirOf = (p) => resolve(p ?? process.cwd());
|
|
30
|
+
export async function serve() {
|
|
31
|
+
const server = new McpServer({ name: 'mcplane', title: 'mcplane', version: VERSION });
|
|
32
|
+
server.registerTool('preflight', {
|
|
33
|
+
title: 'Check an MCP server against store review rules',
|
|
34
|
+
description: 'Checks a live MCP server, its listing links, icon and plugin repo against the known rejection causes of ChatGPT, Claude, Cursor, Grok, Muse and the MCP Registry. Returns each check with pass, warn, fail or skip, what was found and how to fix it, plus reminders that no script can verify. Uses mcplane.json, or a url for a server without one.',
|
|
35
|
+
inputSchema: { project: PROJECT, url: z.string().url().optional(), stores: z.array(STORE).optional() },
|
|
36
|
+
annotations: { title: 'Preflight', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
37
|
+
}, async ({ project, url, stores }) => {
|
|
38
|
+
const m = url ? await draftManifest(url) : await loadManifest(dirOf(project));
|
|
39
|
+
const r = await preflight(m, { stores, token: token() });
|
|
40
|
+
const fails = r.checks.filter((c) => c.level === 'fail').length;
|
|
41
|
+
return reply(r, `${fails} blocking, ${r.checks.filter((c) => c.level === 'warn').length} to review, ${r.checks.filter((c) => c.level === 'pass').length} passed.`);
|
|
42
|
+
});
|
|
43
|
+
server.registerTool('drift', {
|
|
44
|
+
title: 'What each store needs since your last submission',
|
|
45
|
+
description: 'Compares the live server and mcplane.json with the snapshot saved when each submission was recorded, and lists what each store needs: a new ChatGPT version, a Claude listing edit, a plugin pin bump. Stores without a snapshot are listed as missing.',
|
|
46
|
+
inputSchema: { project: PROJECT, stores: z.array(STORE).optional() },
|
|
47
|
+
annotations: { title: 'Drift', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
48
|
+
}, async ({ project, stores }) => {
|
|
49
|
+
const m = await loadManifest(dirOf(project));
|
|
50
|
+
const r = await drift(m, stores?.length ? stores : storesOf(m), { token: token() }, dirOf(project));
|
|
51
|
+
return reply(r, r.items.length ? `${r.items.filter((i) => i.level === 'action').length} store actions needed.` : 'No drift.');
|
|
52
|
+
});
|
|
53
|
+
server.registerTool('pack', {
|
|
54
|
+
title: 'Write a submission pack',
|
|
55
|
+
description: 'Writes everything a store’s submission form asks for into .mcplane/packs: chatgpt-app-submission.json for ChatGPT’s import, markdown packs for claude-connectors, cursor and muse, and one sheet for the form-only directories. Returns the files written and any problems to fix first.',
|
|
56
|
+
inputSchema: { project: PROJECT, store: z.enum(['chatgpt', 'claude-connectors', 'cursor', 'muse', 'directories']) },
|
|
57
|
+
annotations: { title: 'Pack', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
58
|
+
}, async ({ project, store }) => {
|
|
59
|
+
const m = await loadManifest(dirOf(project));
|
|
60
|
+
const pack = store === 'directories' ? directoriesPack(m) : store === 'chatgpt' ? await chatgptPack(m, token()) : store === 'claude-connectors' ? await claudePack(m, token()) : await simplePack(m, store);
|
|
61
|
+
const files = await writePack(pack, dirOf(project));
|
|
62
|
+
return reply({ files, problems: pack.problems, todo: pack.todo });
|
|
63
|
+
});
|
|
64
|
+
server.registerTool('publish', {
|
|
65
|
+
title: 'Publish to a store that allows automation',
|
|
66
|
+
description: 'mcp-registry: writes server.json and runs mcp-publisher (a published registry version can’t be changed afterwards). grok: opens a pull request to xai-org/plugin-marketplace (new listing or pin bump), validated with xAI’s scripts. Other stores return the prepared entry or command. With confirm false it returns what it would do and changes nothing.',
|
|
67
|
+
inputSchema: { project: PROJECT, store: STORE, confirm: z.boolean().default(false) },
|
|
68
|
+
annotations: { title: 'Publish', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
69
|
+
}, async ({ project, store, confirm }) => {
|
|
70
|
+
const m = await loadManifest(dirOf(project));
|
|
71
|
+
const r = await publish(m, store, { yes: confirm, dir: dirOf(project) });
|
|
72
|
+
if (r.done && confirm && (store === 'mcp-registry' || store === 'grok')) {
|
|
73
|
+
const rec = await recordSubmitted(m, store, { version: m.version, share: true }, dirOf(project)).catch(() => null);
|
|
74
|
+
return reply({ ...r, recorded: rec?.note ?? null });
|
|
75
|
+
}
|
|
76
|
+
return reply(r);
|
|
77
|
+
});
|
|
78
|
+
server.registerTool('record_submission', {
|
|
79
|
+
title: 'Record a store submission',
|
|
80
|
+
description: 'Records that the developer submitted to a store, saves a snapshot for drift, and logs an anonymous public report to Review Times (reviewtimes.fyi) so the wait counts toward the store’s public numbers. share false keeps it local.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
project: PROJECT,
|
|
83
|
+
store: STORE,
|
|
84
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
85
|
+
kind: z.enum(['new', 'update', 'resubmission']).optional(),
|
|
86
|
+
version: z.string().optional(),
|
|
87
|
+
app_id: z.string().optional(),
|
|
88
|
+
share: z.boolean().default(true),
|
|
89
|
+
},
|
|
90
|
+
annotations: { title: 'Record submission', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
91
|
+
}, async ({ project, store, date, kind, version, app_id, share }) => {
|
|
92
|
+
const m = await loadManifest(dirOf(project));
|
|
93
|
+
return reply(await recordSubmitted(m, store, { date, kind, version, appId: app_id, share, token: token() }, dirOf(project)));
|
|
94
|
+
});
|
|
95
|
+
server.registerTool('record_decision', {
|
|
96
|
+
title: 'Record a store’s decision',
|
|
97
|
+
description: 'Marks the latest waiting submission to a store as approved, rejected or withdrawn, and updates its Review Times report.',
|
|
98
|
+
inputSchema: { project: PROJECT, store: STORE, outcome: z.enum(['approved', 'rejected', 'withdrawn']), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() },
|
|
99
|
+
annotations: { title: 'Record decision', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
100
|
+
}, async ({ project, store, outcome, date }) => reply(await recordDecision(store, outcome, date, dirOf(project))));
|
|
101
|
+
server.registerTool('submission_status', {
|
|
102
|
+
title: 'Your submissions and how long they’ve waited',
|
|
103
|
+
description: 'Every recorded submission with days waited, refreshed from Review Times (which closes reports itself when a listing appears), and each store’s typical wait right now.',
|
|
104
|
+
inputSchema: { project: PROJECT },
|
|
105
|
+
annotations: { title: 'Status', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
106
|
+
}, async ({ project }) => reply({ submissions: await status(dirOf(project)) }));
|
|
107
|
+
server.registerTool('install_links', {
|
|
108
|
+
title: 'Install links for every client',
|
|
109
|
+
description: 'Install commands and links for Claude Code, Cursor, VS Code, Claude and ChatGPT developer mode, for testing before and after listing.',
|
|
110
|
+
inputSchema: { project: PROJECT },
|
|
111
|
+
annotations: { title: 'Try', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
112
|
+
}, async ({ project }) => reply({ links: tryLinks(await loadManifest(dirOf(project))) }));
|
|
113
|
+
server.registerTool('pull_listings', {
|
|
114
|
+
title: 'Compare live listings with mcplane.json',
|
|
115
|
+
description: 'Reads the store listings that have an open feed (Claude directory, Cursor marketplace) and returns fields that differ from mcplane.json.',
|
|
116
|
+
inputSchema: { project: PROJECT },
|
|
117
|
+
annotations: { title: 'Pull', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
118
|
+
}, async ({ project }) => reply({ listings: await pull(await loadManifest(dirOf(project))) }));
|
|
119
|
+
server.registerTool('init', {
|
|
120
|
+
title: 'Create mcplane.json',
|
|
121
|
+
description: 'Writes a starting mcplane.json from what the live server and its domain reveal (name, auth, privacy, support, terms and icon URLs). Refuses to overwrite an existing file.',
|
|
122
|
+
inputSchema: { project: PROJECT, url: z.string().url() },
|
|
123
|
+
annotations: { title: 'Init', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
124
|
+
}, async ({ project, url }) => {
|
|
125
|
+
const file = join(dirOf(project), MANIFEST_FILE);
|
|
126
|
+
if (await access(file).then(() => true, () => false))
|
|
127
|
+
return reply({ error: `${file} already exists` });
|
|
128
|
+
const m = await draftManifest(url);
|
|
129
|
+
await writeFile(file, JSON.stringify(m, null, 2) + '\n');
|
|
130
|
+
return reply({ written: file, manifest: m }, 'Fill in subtitle, oneLiner, description and tests, then run preflight.');
|
|
131
|
+
});
|
|
132
|
+
server.registerTool('fleet', {
|
|
133
|
+
title: 'Every project at a glance',
|
|
134
|
+
description: 'Finds every mcplane.json under a folder (three levels down) and returns, per project: blocking preflight checks, store updates needed since the last submission, reviews waiting and stores it is live on. quick true skips the live checks.',
|
|
135
|
+
inputSchema: { root: z.string().optional().describe('Folder to search. Defaults to where mcplane was started.'), quick: z.boolean().default(false) },
|
|
136
|
+
annotations: { title: 'Fleet', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
137
|
+
}, async ({ root, quick }) => {
|
|
138
|
+
const rows = await fleet(dirOf(root), { checks: !quick, token: token() });
|
|
139
|
+
const bad = rows.filter((r) => r.error || r.blocking || r.driftActions?.length).length;
|
|
140
|
+
return reply({ projects: rows }, `${rows.length} projects, ${bad} need attention.`);
|
|
141
|
+
});
|
|
142
|
+
// Prompts are the agent-era "fastlane init": the agent in your repo writes the listing, you approve it.
|
|
143
|
+
const say = (text) => ({ messages: [{ role: 'user', content: { type: 'text', text } }] });
|
|
144
|
+
server.registerPrompt('onboard', {
|
|
145
|
+
title: 'Get an MCP server ready for the stores',
|
|
146
|
+
description: 'Creates mcplane.json, writes the listing and review tests from the code, and runs preflight.',
|
|
147
|
+
argsSchema: { url: z.string().describe('The server’s public MCP URL'), project: z.string().optional().describe('Project folder, if not the current one') },
|
|
148
|
+
}, ({ url, project }) => say(`Get this MCP server ready for store submission with mcplane.${project ? ` The project is in ${project}; pass it as "project" to every mcplane tool.` : ''}
|
|
149
|
+
|
|
150
|
+
1. Call mcplane's init tool with url ${url}. If mcplane.json already exists, read it instead.
|
|
151
|
+
2. Read the server's code and README, then fill in mcplane.json:
|
|
152
|
+
- title; subtitle (30 characters max, no "AI" filler); oneLiner (one plain sentence: what it does); description (what it does, who it's for, what it can't do).
|
|
153
|
+
- category (a ChatGPT category such as DEVELOPER_TOOLS or PRODUCTIVITY), stores (where it should list), repository if there's a public plugin repo, version.
|
|
154
|
+
- tests: exactly 5 positive cases (scenario, a realistic user prompt, the tools it should call, the expected result) and 3 negative cases (prompts that should NOT trigger this app). Base them on real tool behaviour, not hopes.
|
|
155
|
+
- justifications, only where the drafts would be wrong: one factual sentence per hint, under 200 characters.
|
|
156
|
+
Write in plain language. No marketing words, no instructions to the model, no pricing or upgrade copy.
|
|
157
|
+
3. Call preflight. Fix what you can in the code (annotations, titles, output schemas, descriptions) and list what needs me (legal pages, icon, OAuth setup).
|
|
158
|
+
4. Show me the final listing text and the tests before anything is submitted.`));
|
|
159
|
+
server.registerPrompt('ship', {
|
|
160
|
+
title: 'Ship to the stores',
|
|
161
|
+
description: 'Checks, prepares every submission, publishes where the store allows it, and lists exactly what is left for you.',
|
|
162
|
+
argsSchema: { project: z.string().optional().describe('Project folder, if not the current one') },
|
|
163
|
+
}, ({ project }) => say(`Ship this MCP server to its stores with mcplane.${project ? ` Pass project "${project}" to every mcplane tool.` : ''}
|
|
164
|
+
|
|
165
|
+
1. preflight. If anything is blocking, stop and fix it (or tell me what needs me).
|
|
166
|
+
2. drift, to see what each store needs since the last submission.
|
|
167
|
+
3. pack for each form-based store in mcplane.json (chatgpt, claude-connectors, cursor, muse).
|
|
168
|
+
4. publish with confirm false for mcp-registry and grok, show me what it would do, and on my go-ahead publish with confirm true.
|
|
169
|
+
5. For the form-based stores, give me one short checklist: which file to upload or paste where, and the statements only I can make. If you have browser tools, offer to fill the form fields from the pack; I tick the policy statements and press submit myself.
|
|
170
|
+
6. After I say I've submitted, call record_submission for each store (ChatGPT: ask me for the asdk_app_ id).`));
|
|
171
|
+
await server.connect(new StdioServerTransport());
|
|
172
|
+
}
|