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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # mcplane
2
+
3
+ **fastlane for MCP servers.** mcplane checks your server against the reasons each store rejects submissions. It publishes wherever a store allows automation and prepares everything the form-based stores ask for. It keeps every listing in step with your live server. Run it as a CLI, in CI, or as an MCP server driven by your coding agent.
4
+
5
+ ```sh
6
+ npx mcplane init --url https://mcp.example.com/mcp
7
+ npx mcplane preflight
8
+ ```
9
+
10
+ ```
11
+ ✗ DNS does not advertise Encrypted ClientHello [ChatGPT]
12
+ example.com has ech= in its HTTPS record
13
+ Turn ECH off for this domain. TLS-inspecting review proxies reset ECH handshakes: three ChatGPT
14
+ rejections traced back to this, with nothing in the server's logs.
15
+ ✗ Every tool has a human-readable title [Claude connectors, Claude plugins]
16
+ search_docs, fetch_page
17
+ Add "title" (or annotations.title) to each tool. Anthropic rejects connectors without them.
18
+ ! A small icon is available for ChatGPT's dialogs [ChatGPT]
19
+ 28 KB
20
+ ChatGPT's icon upload in developer mode caps at 10 KB. Export an 8-bit PNG.
21
+ ✓ 15 checks passed (--verbose to list them)
22
+
23
+ 2 blocking, 1 to review.
24
+ ```
25
+
26
+ ## Why
27
+
28
+ An MCP server now has a dozen places to be listed: ChatGPT, Claude, Cursor, Grok, Muse, the official MCP Registry, Docker, Smithery and the directories that read from them. Each has its own form, its own rules and its own idea of what an update is. Most rejections are for the same few mechanical things, and each one costs you days in the queue. With one app that's annoying. With seventeen you stop shipping.
29
+
30
+ iOS had the same problem in 2014. fastlane fixed it by putting your metadata in the repo, checking it before Apple could reject it, and turning every release into one command. mcplane does that for agent-era stores.
31
+
32
+ An agent with a browser could fill in a form. It can't tell you that your DNS will make ChatGPT's reviewer time out, that a hint needs a justification you haven't written, or which of your seventeen listings went stale on Tuesday. That's the part mcplane does.
33
+
34
+ ## Use it from your agent
35
+
36
+ mcplane is itself an MCP server, so the agent in your repo can do the work:
37
+
38
+ ```sh
39
+ claude mcp add mcplane -- npx -y mcplane mcp # Claude Code
40
+ codex mcp add mcplane -- npx -y mcplane mcp # Codex
41
+ ```
42
+
43
+ Cursor, VS Code and others: add a stdio server with command `npx` and args `["-y", "mcplane", "mcp"]`.
44
+
45
+ Then run the **onboard** prompt (`/mcp__mcplane__onboard` in Claude Code). The agent creates `mcplane.json` and reads your code. It writes the listing copy and the review test cases (ChatGPT wants exactly 5 positive and 3 negative), runs preflight and fixes what it can. The **ship** prompt takes it from there: check, pack, publish, and a short list of what's left for you.
46
+
47
+ Tools: `preflight`, `drift`, `pack`, `publish`, `record_submission`, `record_decision`, `submission_status`, `install_links`, `pull_listings`, `init`, `fleet`. Every tool takes an optional `project` folder, so one mcplane serves all your apps. Annotations are honest: `publish` and `record_*` are marked open-world, and `publish` does nothing without `confirm: true`.
48
+
49
+ ## Many apps
50
+
51
+ ```sh
52
+ npx mcplane fleet --root ~/Projects
53
+ ```
54
+
55
+ ```
56
+ ✓ Review Times preflight clean · live on chatgpt
57
+ ✗ Invoice Bot 2 blocking · waiting on claude-connectors
58
+ ✗ Weather Pro 1 store update needed
59
+ chatgpt: Tools changed since the live version (v1.2.0) on 2026-09-02: added forecast_hourly
60
+
61
+ 3 projects, 2 need attention.
62
+ ```
63
+
64
+ `mcplane fleet run <command>` runs any command in every project, e.g. `mcplane fleet run publish mcp-registry --yes`.
65
+
66
+ ## What it does per store
67
+
68
+ | Store | How mcplane gets you there | Keeping it current |
69
+ |---|---|---|
70
+ | Official MCP Registry | **Publishes**: writes `server.json`, runs `mcp-publisher`, signs in with GitHub OIDC in Actions. GitHub's MCP Registry (VS Code), PulseMCP and MCP.Directory read from it | Versions can't change: bump, or a prerelease like `1.2.0-1` for listing-only edits. mcplane checks what's already published |
71
+ | Grok plugins | **Publishes**: opens the pull request to `xai-org/plugin-marketplace`, validated with xAI's own scripts | Opens a pin-bump PR when xAI's daily bump lags |
72
+ | ChatGPT | **Prepares** `chatgpt-app-submission.json` for the portal's import: listing, tools, hint justifications, 5+3 tests | Tool changes roll out after OpenAI's automated checks. Listing text and hint justifications need a new version, and drift says when |
73
+ | Claude connectors | **Prepares** every field of the directory form | Tool changes are live on deploy; listing edits (tool names included) are reviewed |
74
+ | Claude plugins | **Prepares** and checks the plugin repo | The portal tracks your branch; drift tells you a new version is waiting for review |
75
+ | Cursor | **Prepares** and checks the plugin repo | Pinned to the commit first added; drift flags when you've moved on |
76
+ | Muse | **Prepares** the form | Flags changes |
77
+ | Cline, Glama, LobeHub, Smithery, Docker, awesome-mcp-servers | The prefilled issue, `glama.json`, CLI command or entry | |
78
+ | mcp.so, MCP Market, mcpservers.org, cursor.directory | `mcplane pack directories`: one sheet with every value to paste | |
79
+
80
+ **What stays with you, and why.** The ChatGPT and Claude portals have no submission API. Neither do most directories. They ask you to make statements about policy, data use and testing that only you can make. mcplane gets you to the final click with every field filled and every known rejection checked. It never signs in for you, never ticks attestations and never gets around a bot check. If your agent has browser tools, the **ship** prompt offers to fill the form from the pack while you make the statements and press submit.
81
+
82
+ ## Preflight
83
+
84
+ Checks come from real rejections, most of them ours:
85
+
86
+ - **Server**: `initialize`, `tools/list` and `ping` work. Unknown methods return `-32601` (OpenAI's tool scan probes `server/discover` and gives up on a crash). The URL works with a trailing slash. CORS preflight answers. Automated clients aren't blocked by a bot filter. A TLS 1.2 client connects. DNS doesn't advertise Encrypted ClientHello, which review proxies reset without a trace in your logs.
87
+ - **OAuth**: unauthenticated calls get `401` with `resource_metadata`. Protected-resource and authorization-server metadata resolve, and PKCE S256 is supported. With `--register`, Dynamic Client Registration is tested, including the `cursor://` and other native redirect schemes that locked out every desktop client for us.
88
+ - **Tools**: every tool has a title and explicit `readOnlyHint`, `destructiveHint` and `openWorldHint`, and the hints are consistent. Tools declare `outputSchema`. Descriptions don't instruct the model. Writes aren't authorised by a token passed through the chat (Anthropic rejected exactly that). Hints follow OpenAI's current definitions: `openWorldHint` for anything that reaches the public internet (read-only web search included), `destructiveHint` for messages that can't be unsent. No catch-all request tools, no tool names over 64 characters, no sensitive inputs, no upgrade or pricing copy.
89
+ - **Listing**: the icon is a square PNG of at least 512px behind a direct link, and `/favicon.ico` resolves. The privacy policy covers collection, retention and user controls. Support is a web page. Name, subtitle, one-liner, description and starter prompts fit each store's limits. The ChatGPT domain challenge is served.
90
+ - **Plugin repo**: public, on GitHub, with `.claude-plugin/plugin.json`, and no root `SKILL.md` shadowing the plugin's skills. Grok plugins come from an organisation.
91
+
92
+ `--json` for machines; exit code 1 when something blocks.
93
+
94
+ ## Keeping listings current
95
+
96
+ Every store calls your live server, so fixes reach users on their own. What doesn't: listing text, tool names in Claude's listing, ChatGPT's hint justifications, registry versions and plugins pinned to a commit. Each store has different rules for these. When you record a submission, mcplane snapshots what the store is reviewing. `mcplane drift` compares your live server and `mcplane.json` against that snapshot and tells you what each store needs.
97
+
98
+ ```sh
99
+ mcplane submitted chatgpt --version 1.2.0 --app-id asdk_app_…
100
+ mcplane drift # later, after you change things
101
+ mcplane baseline claude-connectors # for listings that were live before you used mcplane
102
+ ```
103
+
104
+ Snapshots live in `.mcplane/snapshots` and are meant to be committed, so CI can catch drift. Submissions and packs stay local (mcplane writes the `.gitignore`).
105
+
106
+ ## Lanes
107
+
108
+ Like fastlane's lanes: named workflows in `mcplane.json`.
109
+
110
+ ```json
111
+ "lanes": {
112
+ "check": ["preflight", "drift --ci"],
113
+ "release": ["preflight", "drift", "pack chatgpt", "pack claude-connectors", "publish mcp-registry --yes", "?publish grok --yes",
114
+ "handoff: Upload the ChatGPT JSON and submit, then: mcplane submitted chatgpt"]
115
+ }
116
+ ```
117
+
118
+ `mcplane release` runs the steps in order and stops at the first failure. A `?` step may fail without stopping the lane. `handoff:` steps are listed at the end as your to-do. `check` and `release` are built in.
119
+
120
+ ## CI
121
+
122
+ ```yaml
123
+ # .github/workflows/mcplane.yml
124
+ on: [push]
125
+ jobs:
126
+ stores:
127
+ runs-on: ubuntu-latest
128
+ steps:
129
+ - uses: actions/checkout@v4
130
+ - uses: stevysmith/mcplane@v0
131
+ with:
132
+ command: check # preflight + drift --ci
133
+ ```
134
+
135
+ Publishing to the MCP Registry from CI needs no secrets for `io.github.*` names:
136
+
137
+ ```yaml
138
+ release:
139
+ runs-on: ubuntu-latest
140
+ permissions: { id-token: write, contents: read }
141
+ steps:
142
+ - uses: actions/checkout@v4
143
+ - run: |
144
+ curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
145
+ sudo mv mcp-publisher /usr/local/bin/
146
+ - uses: stevysmith/mcplane@v0
147
+ with:
148
+ command: publish mcp-registry --yes
149
+ ```
150
+
151
+ For a reverse-DNS name, set an `MCP_PRIVATE_KEY` secret and mcplane signs in with `login dns`.
152
+
153
+ ## Review Times
154
+
155
+ `mcplane submitted` logs an anonymous report to [Review Times](https://reviewtimes.fyi), the public tracker of how long each store takes to review. The report holds the store, dates, the kind of submission and the public listing reference that lets it close itself when your listing appears. No code, names or email. `mcplane status` shows how long each of yours has waited next to each store's typical wait. `--private` (or `share: false`) keeps it local.
156
+
157
+ ## Commands
158
+
159
+ ```
160
+ init --url <mcp url> Create mcplane.json from your live server
161
+ preflight [--store <id>] Check against every store's rejection causes (--url, --json, --verbose, --register, --token)
162
+ pack <store> Write a submission pack to .mcplane/packs (or "pack directories")
163
+ publish <store> [--yes] Publish where the store allows it; dry run without --yes
164
+ submitted <store> Record a submission (--date, --kind, --version, --app-id, --private)
165
+ decided <store> <outcome> approved | rejected | withdrawn
166
+ status Your submissions, days waited, each store's typical wait
167
+ drift [--ci] What each store needs since it last saw your server
168
+ baseline <store> Mark an existing live listing as the drift baseline
169
+ pull Compare live public listings with mcplane.json
170
+ try Install links for every client, for you and your testers
171
+ fleet [--root <dir>] Every project at a glance (--quick, --json, --ci)
172
+ fleet run <command...> Run a command in every project
173
+ lanes / <lane> List lanes / run one
174
+ mcp Run as an MCP server over stdio
175
+ ```
176
+
177
+ Servers behind sign-in: pass `--token` or set `MCPLANE_TOKEN` so tool checks can run.
178
+
179
+ ## mcplane.json
180
+
181
+ One file describes your server and its listings. [`schema.json`](schema.json) gives editors completion and validation; `mcplane init` adds the `$schema` line. [`examples/mcplane.json`](examples/mcplane.json) is a complete one, from [Review Times](https://reviewtimes.fyi)' own ChatGPT and Claude submissions.
182
+
183
+ ## fastlane, mapped
184
+
185
+ | fastlane | mcplane |
186
+ |---|---|
187
+ | `precheck` | `preflight` |
188
+ | `deliver` + metadata in the repo | `mcplane.json`, `pack`, `publish` |
189
+ | `download_metadata` | `pull` |
190
+ | `pilot` (TestFlight testers) | `try`: install links for every client |
191
+ | Fastfile lanes | `lanes` |
192
+ | `fastlane init` | `init`, and the **onboard** prompt that writes the listing for you |
193
+ | CI-first | `--ci` exit codes, JSON output, GitHub Action |
194
+ | New: agent-driven | the whole thing is an MCP server |
195
+ | New: drift | knows what each store needs when your server changes |
196
+
197
+ ## Contributing
198
+
199
+ Stores and checks are small, separate modules. To add a store: add its id to `src/types.ts`, its rules to `src/drift.ts`, a pack or publisher in `src/packs.ts` or `src/publish.ts`, and a row above. To add a check: a function in `src/checks/`, with the rejection it prevents in the `fix` text. The best contributions are rejections you've had. Open an issue with what the reviewer said.
200
+
201
+ MIT licensed.
@@ -0,0 +1,72 @@
1
+ const REDIRECTS = {
2
+ chatgpt: ['https://chatgpt.com/connector_platform_oauth_redirect', 'https://platform.openai.com/apps-manage/oauth'],
3
+ claude: ['https://claude.ai/api/mcp/auth_callback'],
4
+ native: ['cursor://anysphere.cursor-mcp/oauth/callback', 'grok://oauth/callback'],
5
+ };
6
+ const json = async (url) => {
7
+ const res = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'mcplane' }, signal: AbortSignal.timeout(10_000) }).catch(() => null);
8
+ return { res, body: res?.ok ? (await res.json().catch(() => null)) : null };
9
+ };
10
+ export async function authChecks(url, opts = {}) {
11
+ const checks = [];
12
+ const u = new URL(url);
13
+ // An unauthenticated call must say how to sign in.
14
+ const bare = await fetch(url, {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'user-agent': 'mcplane' },
17
+ body: '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcplane","version":"0.1.0"}}}',
18
+ }).catch(() => null);
19
+ const www = bare?.headers.get('www-authenticate') ?? '';
20
+ if (bare?.status === 401 && /resource_metadata=/i.test(www)) {
21
+ checks.push({ id: 'auth.401', level: 'pass', title: 'Unauthenticated calls get 401 with resource_metadata' });
22
+ }
23
+ else {
24
+ checks.push({
25
+ id: 'auth.401',
26
+ level: 'fail',
27
+ title: 'Unauthenticated calls get 401 with resource_metadata',
28
+ detail: `HTTP ${bare?.status ?? 'error'}${www ? `, WWW-Authenticate: ${www.slice(0, 120)}` : ', no WWW-Authenticate'}`,
29
+ fix: 'Answer unauthenticated MCP requests with 401 and WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource". Never 403: to a reviewer a 403 is "unable to connect".',
30
+ });
31
+ }
32
+ // Protected-resource metadata, on the MCP host itself.
33
+ const prm = (await json(`${u.origin}/.well-known/oauth-protected-resource${u.pathname === '/' ? '' : u.pathname}`)).body ?? (await json(`${u.origin}/.well-known/oauth-protected-resource`)).body;
34
+ const asUrl = prm?.authorization_servers?.[0];
35
+ checks.push(asUrl
36
+ ? { id: 'auth.prm', level: 'pass', title: 'Protected-resource metadata resolves on the MCP host', detail: `authorization server ${asUrl}` }
37
+ : { id: 'auth.prm', level: 'fail', title: 'Protected-resource metadata resolves on the MCP host', fix: `Serve ${u.origin}/.well-known/oauth-protected-resource with "resource" and "authorization_servers". Clients resolve it against the MCP URL's own host, not your apex domain.` });
38
+ if (!asUrl)
39
+ return checks;
40
+ const asOrigin = new URL(asUrl).origin;
41
+ const asm = (await json(`${asOrigin}/.well-known/oauth-authorization-server`)).body ?? (await json(`${asOrigin}/.well-known/openid-configuration`)).body;
42
+ if (!asm) {
43
+ checks.push({ id: 'auth.as-metadata', level: 'fail', title: 'Authorization-server metadata resolves', fix: `Serve ${asOrigin}/.well-known/oauth-authorization-server.` });
44
+ return checks;
45
+ }
46
+ checks.push({ id: 'auth.as-metadata', level: 'pass', title: 'Authorization-server metadata resolves' });
47
+ checks.push((asm.code_challenge_methods_supported ?? []).includes('S256')
48
+ ? { id: 'auth.pkce', level: 'pass', title: 'PKCE (S256) is supported' }
49
+ : { id: 'auth.pkce', level: 'fail', title: 'PKCE (S256) is supported', fix: 'Advertise code_challenge_methods_supported: ["S256"].' });
50
+ checks.push(asm.registration_endpoint
51
+ ? { id: 'auth.dcr', level: 'pass', title: 'Dynamic Client Registration is offered' }
52
+ : { id: 'auth.dcr', level: 'warn', title: 'Dynamic Client Registration is offered', fix: 'Offer a registration_endpoint (RFC 7591). Anthropic says DCR works best with its directory, and ChatGPT registers itself this way.', stores: ['claude-connectors', 'chatgpt'] });
53
+ if (opts.register && asm.registration_endpoint) {
54
+ for (const [who, uris] of Object.entries(REDIRECTS)) {
55
+ const r = await fetch(asm.registration_endpoint, {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json' },
58
+ body: JSON.stringify({ client_name: `mcplane-preflight-${who}`, redirect_uris: uris, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
59
+ }).catch(() => null);
60
+ checks.push(r && (r.status === 201 || r.status === 200)
61
+ ? { id: `auth.dcr-${who}`, level: 'pass', title: `Registration accepts ${who} redirect URIs` }
62
+ : {
63
+ id: `auth.dcr-${who}`,
64
+ level: 'fail',
65
+ title: `Registration accepts ${who} redirect URIs`,
66
+ detail: `HTTP ${r?.status ?? 'error'}: ${uris.join(', ')}`,
67
+ fix: who === 'native' ? 'Accept private-use URI schemes (RFC 8252). Rejecting them locks out every native client: Cursor, Grok, desktop apps.' : 'Allow these redirect URIs in client registration.',
68
+ });
69
+ }
70
+ }
71
+ return checks;
72
+ }
@@ -0,0 +1,149 @@
1
+ const get = (url) => fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(12_000), headers: { 'user-agent': 'mcplane' } }).catch(() => null);
2
+ /** Width and height from a PNG's IHDR chunk. */
3
+ function pngSize(buf) {
4
+ if (buf.length < 24 || buf[0] !== 0x89 || buf[1] !== 0x50)
5
+ return null;
6
+ const v = new DataView(buf.buffer, buf.byteOffset);
7
+ return { w: v.getUint32(16), h: v.getUint32(20) };
8
+ }
9
+ const has = (stores, ...s) => s.some((x) => stores.includes(x));
10
+ export async function listingChecks(m, tools, stores) {
11
+ const checks = [];
12
+ const origin = new URL(m.server.url).origin;
13
+ // Icon
14
+ if (!m.icon) {
15
+ checks.push({ id: 'listing.icon', level: 'warn', title: 'An icon URL is set', fix: 'Set "icon" to a direct PNG URL, at least 512×512. ChatGPT needs one uploaded; Claude asks for a direct .png/.svg URL when the favicon fails.' });
16
+ }
17
+ else {
18
+ const res = await get(m.icon);
19
+ const type = res?.headers.get('content-type') ?? '';
20
+ const buf = res?.ok ? new Uint8Array(await res.arrayBuffer()) : null;
21
+ const size = buf ? pngSize(buf) : null;
22
+ if (!res?.ok) {
23
+ checks.push({ id: 'listing.icon', level: 'fail', title: 'The icon URL loads', detail: `HTTP ${res?.status ?? 'error'}`, fix: 'Point "icon" at a URL that returns the image directly.' });
24
+ }
25
+ else if (!/\.(png|svg|jpe?g|gif|ico|webp)(\?|$)/i.test(m.icon)) {
26
+ checks.push({ id: 'listing.icon', level: 'warn', title: 'The icon URL ends in an image extension', detail: m.icon, fix: 'Anthropic asks for an icon URL ending in .png, .svg, .jpg, .gif, .ico or .webp.', stores: ['claude-connectors'] });
27
+ }
28
+ else if (type.includes('png') && size && (size.w < 512 || size.w !== size.h)) {
29
+ checks.push({ id: 'listing.icon', level: 'warn', title: 'The icon is square and at least 512 px', detail: `${size.w}×${size.h}`, fix: 'Use a square PNG of 512×512 or more; ChatGPT’s directory icon must be at least 256, and a 512 master covers every store.' });
30
+ }
31
+ else {
32
+ checks.push({ id: 'listing.icon', level: 'pass', title: 'The icon loads', detail: `${type}${size ? `, ${size.w}×${size.h}` : ''}, ${buf ? Math.round(buf.length / 1024) : '?'} KB` });
33
+ if (buf && buf.length > 10 * 1024 && has(stores, 'chatgpt')) {
34
+ checks.push({ id: 'listing.icon-small', level: 'warn', title: 'A small icon is available for ChatGPT’s dialogs', detail: `${Math.round(buf.length / 1024)} KB`, fix: 'ChatGPT’s icon upload in developer mode caps at 10 KB. Export an 8-bit PNG (pngquant or `magick in.png PNG8:out.png`).', stores: ['chatgpt'] });
35
+ }
36
+ }
37
+ }
38
+ // Favicon, the fallback Claude's directory reads.
39
+ const fav = await get(`${origin}/favicon.ico`);
40
+ checks.push(fav?.ok
41
+ ? { id: 'listing.favicon', level: 'pass', title: '/favicon.ico resolves on the server’s domain' }
42
+ : { id: 'listing.favicon', level: 'warn', title: '/favicon.ico resolves on the server’s domain', detail: `HTTP ${fav?.status ?? 'error'}`, fix: `Serve ${origin}/favicon.ico. Anthropic's reviewers reported "the fallback favicon is not resolving" when this 404'd.`, stores: ['claude-connectors'] });
43
+ // Privacy policy
44
+ const privacyUrl = m.links?.privacy;
45
+ if (!privacyUrl) {
46
+ checks.push({ id: 'listing.privacy', level: 'fail', title: 'A privacy policy is linked', fix: 'Every store requires one. Add links.privacy.' });
47
+ }
48
+ else {
49
+ const res = await get(privacyUrl);
50
+ const text = res?.ok ? (await res.text()).replace(/<[^>]+>/g, ' ').toLowerCase() : '';
51
+ if (!res?.ok) {
52
+ checks.push({ id: 'listing.privacy', level: 'fail', title: 'The privacy policy loads', detail: `HTTP ${res?.status ?? 'error'}` });
53
+ }
54
+ else {
55
+ const missing = [
56
+ !/(retain|retention|how long|keep (it|them|your)|deleted? after)/.test(text) && 'how long data is kept',
57
+ !/(delete|remove|unsubscribe|opt out|your (rights|choices))/.test(text) && 'what users can do about their data',
58
+ !/(collect|store|we keep|information we)/.test(text) && 'what is collected',
59
+ ].filter(Boolean);
60
+ checks.push(missing.length
61
+ ? { id: 'listing.privacy', level: 'warn', title: 'The privacy policy covers what reviewers look for', detail: `doesn’t seem to cover: ${missing.join(', ')}`, fix: 'OpenAI requires the policy to describe the data collected, its use, sharing, retention timelines and user controls.', stores: ['chatgpt'] }
62
+ : { id: 'listing.privacy', level: 'pass', title: 'The privacy policy covers collection, retention and user controls' });
63
+ // Tools that take free-form content need that disclosed.
64
+ const freeForm = tools.filter((t) => Object.keys(t.inputSchema?.properties ?? {}).some((k) => /^(html|content|body|document|message|text|file|markdown)$/i.test(k)));
65
+ if (freeForm.length && !/(content|document|html|message|file)s? (you|that you|we receive|submitted|uploaded|published)/.test(text)) {
66
+ checks.push({
67
+ id: 'listing.privacy-content',
68
+ level: 'warn',
69
+ title: 'Free-form content is covered by the privacy policy',
70
+ detail: `${freeForm.map((t) => t.name).join(', ')} accept free-form content`,
71
+ fix: 'Say what happens to content users send through these tools: what is stored, for how long, and any automatic screening for sensitive data. A "sensitive data" rejection on ChatGPT was answered by exactly this section.',
72
+ stores: ['chatgpt'],
73
+ });
74
+ }
75
+ }
76
+ }
77
+ // Support must be a URL for ChatGPT.
78
+ const support = m.links?.support;
79
+ if (has(stores, 'chatgpt')) {
80
+ checks.push(support && /^https?:\/\//.test(support)
81
+ ? { id: 'listing.support', level: 'pass', title: 'Support is a web page', stores: ['chatgpt'] }
82
+ : { id: 'listing.support', level: 'fail', title: 'Support is a web page', detail: support ?? 'missing', fix: 'ChatGPT’s form wants a customer support URL, not an email address. A /support page with your email on it is enough.', stores: ['chatgpt'] });
83
+ }
84
+ // Lengths the forms enforce (some silently).
85
+ const len = (v, max, id, label, s) => {
86
+ if (v && v.length > max)
87
+ checks.push({ id, level: 'fail', title: `${label} fits (${max} characters)`, detail: `${v.length} characters`, stores: s });
88
+ else if (v)
89
+ checks.push({ id, level: 'pass', title: `${label} fits (${max} characters)`, stores: s });
90
+ };
91
+ if (has(stores, 'chatgpt')) {
92
+ len(m.title, 30, 'listing.name', 'Display name', ['chatgpt']);
93
+ len(m.subtitle, 30, 'listing.subtitle', 'Subtitle', ['chatgpt']);
94
+ const prompts = m.prompts ?? [];
95
+ const bad = prompts.filter((p) => p.length > 128 || /(^|\s)@\w/.test(p));
96
+ const dupes = prompts.filter((p, i) => prompts.findIndex((q) => q.trim().toLowerCase() === p.trim().toLowerCase()) !== i);
97
+ if (bad.length || dupes.length) {
98
+ checks.push({ id: 'listing.prompts', level: 'fail', title: 'Starter prompts are short, unique and free of @mentions', detail: [...bad.map((p) => `"${p.slice(0, 40)}…"`), ...dupes.map((p) => `duplicate: "${p.slice(0, 40)}"`)].join('; '), fix: 'ChatGPT rejects starter prompts over 128 characters, with @mentions, or repeated.', stores: ['chatgpt'] });
99
+ }
100
+ else if (prompts.length) {
101
+ checks.push({ id: 'listing.prompts', level: 'pass', title: 'Starter prompts are short, unique and free of @mentions', stores: ['chatgpt'] });
102
+ }
103
+ }
104
+ if (has(stores, 'claude-connectors', 'claude-plugins'))
105
+ len(m.title, 100, 'listing.claude-name', 'Name for Claude', ['claude-connectors', 'claude-plugins']);
106
+ if (has(stores, 'claude-connectors', 'claude-plugins'))
107
+ len(m.oneLiner, 200, 'listing.one-liner', 'One-liner', ['claude-connectors', 'claude-plugins']);
108
+ len(m.description, 2000, 'listing.description', 'Description', []);
109
+ // ChatGPT domain verification
110
+ if (has(stores, 'chatgpt')) {
111
+ const ch = await get(`${origin}/.well-known/openai-apps-challenge`);
112
+ const body = ch?.ok ? (await ch.text()).trim() : '';
113
+ checks.push(body && body.length < 200 && !/<html/i.test(body)
114
+ ? { id: 'listing.openai-challenge', level: 'pass', title: 'ChatGPT domain challenge is served', stores: ['chatgpt'] }
115
+ : { id: 'listing.openai-challenge', level: 'warn', title: 'ChatGPT domain challenge is served', detail: ch?.ok ? 'answers, but with a web page rather than a plain-text token' : `HTTP ${ch?.status ?? 'error'}`, fix: `The ChatGPT form gives you a token to serve as plain text at ${origin}/.well-known/openai-apps-challenge. Add it before the MCP step, or domain verification blocks submission.`, stores: ['chatgpt'] });
116
+ }
117
+ return checks;
118
+ }
119
+ /** Rules for plugin repositories (Claude plugins, Cursor, Grok). */
120
+ export async function repoChecks(m, stores) {
121
+ if (!m.repository || !has(stores, 'claude-plugins', 'cursor', 'grok'))
122
+ return [];
123
+ const gh = m.repository.match(/github\.com\/([^/]+)\/([^/#?]+)/);
124
+ if (!gh)
125
+ return [{ id: 'repo.github', level: 'warn', title: 'The plugin repository is on GitHub', detail: m.repository, fix: 'Claude, Cursor and Grok all read plugins from a public GitHub repository.' }];
126
+ const [, owner, repo] = gh;
127
+ const api = (p) => fetch(`https://api.github.com/repos/${owner}/${repo.replace(/\.git$/, '')}${p}`, { headers: { 'user-agent': 'mcplane', accept: 'application/vnd.github+json' } }).catch(() => null);
128
+ const checks = [];
129
+ const meta = await api('');
130
+ if (!meta?.ok) {
131
+ return [{ id: 'repo.public', level: 'fail', title: 'The plugin repository is public', detail: `HTTP ${meta?.status ?? 'error'}`, fix: 'Stores pin a commit of a public repo. A private monorepo can’t host the plugin: move it to its own public repository.' }];
132
+ }
133
+ const info = (await meta.json());
134
+ checks.push({ id: 'repo.public', level: 'pass', title: 'The plugin repository is public' });
135
+ const exists = async (p) => (await api(`/contents/${p}`))?.ok ?? false;
136
+ if (has(stores, 'claude-plugins')) {
137
+ const [manifest, rootSkill, skillsDir] = await Promise.all([exists('.claude-plugin/plugin.json'), exists('SKILL.md'), exists('skills')]);
138
+ checks.push(manifest
139
+ ? { id: 'repo.claude-manifest', level: 'pass', title: '.claude-plugin/plugin.json exists', stores: ['claude-plugins'] }
140
+ : { id: 'repo.claude-manifest', level: 'fail', title: '.claude-plugin/plugin.json exists', fix: 'Add a plugin manifest and run `claude plugin validate`.', stores: ['claude-plugins'] });
141
+ if (rootSkill && skillsDir) {
142
+ checks.push({ id: 'repo.root-skill', level: 'warn', title: 'No root SKILL.md shadowing the plugin’s skills', fix: 'A SKILL.md at the repo root makes it load as a single-skill plugin and hides everything in skills/. Move it under skills/.', stores: ['claude-plugins'] });
143
+ }
144
+ }
145
+ if (has(stores, 'grok') && info.owner?.type === 'User') {
146
+ checks.push({ id: 'repo.grok-org', level: 'warn', title: 'Grok plugin comes from an organisation', detail: `${owner} is a personal account`, fix: 'xAI’s guide says plugins sourced from personal accounts "will be questioned". Transfer the repo to an organisation named after the product.', stores: ['grok'] });
147
+ }
148
+ return checks;
149
+ }
@@ -0,0 +1,148 @@
1
+ import { connect } from 'node:tls';
2
+ import { McpClient } from '../mcp-client.js';
3
+ const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36';
4
+ /** Connects the way a store's scanner does and returns the tool list for the other checks. */
5
+ export async function serverChecks(url, auth, token) {
6
+ const checks = [];
7
+ const bearer = token ? { authorization: `Bearer ${token}` } : {};
8
+ const client = new McpClient(url, bearer);
9
+ let tools = [];
10
+ // initialize
11
+ let init;
12
+ try {
13
+ init = await client.initialize();
14
+ }
15
+ catch (e) {
16
+ checks.push({ id: 'server.reachable', level: 'fail', title: 'Server answers', detail: String(e.message), fix: `Check ${url} is live and reachable from the public internet.` });
17
+ return { checks, tools };
18
+ }
19
+ if (auth === 'oauth' && init.status === 401) {
20
+ checks.push({ id: 'server.reachable', level: 'pass', title: 'Server answers (401 until signed in, as expected)' });
21
+ if (token)
22
+ checks.push({ id: 'server.token', level: 'fail', title: 'The --token signs in', detail: 'still 401', fix: 'Pass a valid access token for your own account.' });
23
+ else
24
+ checks.push({ id: 'server.tools-skipped', level: 'skip', title: 'Tool checks skipped: the server needs sign-in', fix: 'Run again with --token <access token> from your own account to check tool titles, annotations and descriptions.' });
25
+ }
26
+ else if (init.status >= 400 || init.body?.error) {
27
+ checks.push({
28
+ id: 'server.initialize',
29
+ level: 'fail',
30
+ title: 'initialize succeeds',
31
+ detail: `HTTP ${init.status}${init.body?.error ? `, ${JSON.stringify(init.body.error).slice(0, 160)}` : ''}`,
32
+ fix: auth === 'none' ? 'An authless server must answer initialize without credentials. If it needs sign-in, set "auth": "oauth" in mcplane.json.' : undefined,
33
+ });
34
+ return { checks, tools };
35
+ }
36
+ else {
37
+ const info = init.body?.result?.serverInfo;
38
+ checks.push({ id: 'server.initialize', level: 'pass', title: 'initialize succeeds', detail: info ? `${info.name ?? ''} ${info.version ?? ''}`.trim() : undefined });
39
+ }
40
+ if (auth === 'none' || (token && init.status < 300)) {
41
+ const list = await client.request('tools/list').catch(() => null);
42
+ tools = list?.body?.result?.tools ?? [];
43
+ checks.push(tools.length
44
+ ? { id: 'server.tools', level: 'pass', title: 'tools/list returns tools', detail: tools.map((t) => t.name).join(', ') }
45
+ : { id: 'server.tools', level: 'fail', title: 'tools/list returns tools', detail: `HTTP ${list?.status ?? 'error'}`, fix: 'Stores scan tools/list; with no tools there is nothing to list.' });
46
+ const ping = await client.request('ping').catch(() => null);
47
+ checks.push({
48
+ id: 'server.ping',
49
+ level: ping && ping.status < 300 && !ping.body?.error ? 'pass' : 'warn',
50
+ title: 'ping answers',
51
+ fix: 'Answer "ping" with an empty result; some clients use it for health checks.',
52
+ });
53
+ // OpenAI's scanner probes optional methods and treats a 500 / -32603 as a crash.
54
+ const probe = await client.request('server/discover').catch(() => null);
55
+ const code = probe?.body?.error?.code;
56
+ // Any other JSON-RPC error at HTTP 200 is survivable; a 5xx, -32603 or no JSON-RPC answer is not.
57
+ const survivable = probe?.status === 200 && typeof code === 'number' && code !== -32603;
58
+ checks.push(probe && probe.status === 200 && code === -32601
59
+ ? { id: 'server.unknown-method', level: 'pass', title: 'Unknown methods return -32601 (method not found)' }
60
+ : {
61
+ id: 'server.unknown-method',
62
+ level: survivable ? 'warn' : 'fail',
63
+ title: 'Unknown methods return -32601 (method not found)',
64
+ detail: `server/discover got HTTP ${probe?.status ?? 'error'}${code !== undefined ? `, code ${code}` : ''}`,
65
+ fix: "Return JSON-RPC error -32601 at HTTP 200 for methods you don't implement. OpenAI's tool scan probes server/discover and aborts on a 500 or -32603.",
66
+ stores: ['chatgpt'],
67
+ });
68
+ const note = await client.request('notifications/initialized', undefined, { notification: true }).catch(() => null);
69
+ checks.push({
70
+ id: 'server.notifications',
71
+ level: note && (note.status === 202 || note.status === 200 || note.status === 204) ? 'pass' : 'warn',
72
+ title: 'Notifications are accepted',
73
+ detail: note ? `HTTP ${note.status}` : 'no answer',
74
+ fix: 'Reply 202 Accepted to JSON-RPC notifications (messages with no id).',
75
+ });
76
+ const slash = await new McpClient(url.replace(/\/?$/, '/'), bearer).request('tools/list').catch(() => null);
77
+ checks.push({
78
+ id: 'server.trailing-slash',
79
+ level: slash && slash.status < 400 ? 'pass' : 'warn',
80
+ title: 'The URL with a trailing slash works too',
81
+ detail: slash ? `HTTP ${slash.status}` : 'no answer',
82
+ fix: 'Accept /mcp/ as well as /mcp. Reviewers paste URLs by hand, and a 404 reads as "unable to connect".',
83
+ });
84
+ }
85
+ // CORS preflight, as a browser-based connector would send it.
86
+ const pre = await fetch(url, {
87
+ method: 'OPTIONS',
88
+ headers: { origin: 'https://chatgpt.com', 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type, mcp-protocol-version, authorization' },
89
+ signal: AbortSignal.timeout(10_000),
90
+ }).catch(() => null);
91
+ const allow = pre?.headers.get('access-control-allow-origin');
92
+ checks.push({
93
+ id: 'server.cors',
94
+ level: pre && pre.status < 300 && allow ? 'pass' : 'warn',
95
+ title: 'CORS preflight answers',
96
+ detail: pre ? `HTTP ${pre.status}, allow-origin ${allow ?? 'missing'}` : 'no answer',
97
+ fix: 'Answer OPTIONS on the MCP endpoint with 204 and Access-Control-Allow-Origin/-Headers (content-type, mcp-protocol-version, mcp-session-id, authorization).',
98
+ });
99
+ // Filtering by user agent: a scanner and a browser should get the same status.
100
+ const asBrowser = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': BROWSER_UA }, body: '{"jsonrpc":"2.0","id":1,"method":"ping"}' }).catch(() => null);
101
+ const asBot = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'python-requests/2.32' }, body: '{"jsonrpc":"2.0","id":1,"method":"ping"}' }).catch(() => null);
102
+ if (asBrowser && asBot) {
103
+ const blocked = asBot.status === 403 && asBrowser.status !== 403;
104
+ checks.push({
105
+ id: 'server.bot-filter',
106
+ level: blocked ? 'fail' : asBot.status === 403 ? 'warn' : 'pass',
107
+ title: 'Automated clients are not blocked',
108
+ detail: `browser UA ${asBrowser.status}, script UA ${asBot.status}`,
109
+ fix: 'A firewall or bot rule is returning 403 to non-browser clients. Store scanners and reviewers are automated; allow them on the MCP path.',
110
+ });
111
+ }
112
+ checks.push(await tls12Check(url));
113
+ checks.push(await echCheck(url));
114
+ return { checks, tools };
115
+ }
116
+ /** Some review networks only speak TLS 1.2. */
117
+ function tls12Check(url) {
118
+ const host = new URL(url).hostname;
119
+ return new Promise((resolve) => {
120
+ const sock = connect({ host, port: 443, servername: host, maxVersion: 'TLSv1.2', timeout: 8000 }, () => {
121
+ sock.end();
122
+ resolve({ id: 'server.tls12', level: 'pass', title: 'A TLS 1.2 client can connect' });
123
+ });
124
+ const bad = (why) => resolve({ id: 'server.tls12', level: 'warn', title: 'A TLS 1.2 client can connect', detail: why, fix: 'Allow TLS 1.2; some corporate and review proxies cannot do TLS 1.3.' });
125
+ sock.on('error', (e) => bad(e.message));
126
+ sock.on('timeout', () => {
127
+ sock.destroy();
128
+ bad('timed out');
129
+ });
130
+ });
131
+ }
132
+ /** Encrypted ClientHello in DNS makes TLS-inspecting proxies reset the connection. */
133
+ async function echCheck(url) {
134
+ const host = new URL(url).hostname;
135
+ const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(host)}&type=HTTPS`, { headers: { accept: 'application/dns-json' } }).catch(() => null);
136
+ const data = (await res?.json().catch(() => null));
137
+ const ech = (data?.Answer ?? []).some((a) => /\bech=/.test(a.data));
138
+ return ech
139
+ ? {
140
+ id: 'server.ech',
141
+ level: 'fail',
142
+ title: 'DNS does not advertise Encrypted ClientHello',
143
+ detail: `${host} has ech= in its HTTPS record`,
144
+ fix: "Turn ECH off for this domain (Cloudflare: PATCH zones/<id>/settings/ech {\"value\":\"off\"}; it's API-only on the free plan). TLS-inspecting review proxies reset ECH handshakes: three ChatGPT rejections traced back to this, with nothing in the server's logs.",
145
+ stores: ['chatgpt'],
146
+ }
147
+ : { id: 'server.ech', level: 'pass', title: 'DNS does not advertise Encrypted ClientHello' };
148
+ }