mcplane 0.3.0 → 0.4.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/README.md CHANGED
@@ -72,7 +72,7 @@ npx mcplane fleet --root ~/Projects
72
72
  |---|---|---|
73
73
  | Official MCP Registry | **Publishes**: writes `server.json`, runs `mcp-publisher`, signs in with GitHub OIDC in Actions. Glama and PulseMCP import from it; GitHub's MCP gallery (VS Code) syncs it after a one-time manual onboarding | Versions can't change: bump, or a prerelease like `1.2.0-1` for listing-only edits. mcplane checks what's already published |
74
74
  | 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 |
75
- | 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 |
75
+ | ChatGPT | **Builds the plugin ZIP** the portal uploads since DevDay: `plugin.json` (Agent Plugins format) with listing, icons, 5+3 tests, demo video, release notes and translations, plus `mcp.json`. Checked against OpenAI's submission rules | Tool changes roll out after OpenAI's scans. Listing changes need a new ZIP and review, and drift says when |
76
76
  | Claude connectors | **Prepares** every field of the directory form | Tool changes are live on deploy; listing edits (tool names included) are reviewed |
77
77
  | Claude plugins | **Prepares** and checks the plugin repo | The portal tracks your branch; drift tells you a new version is waiting for review |
78
78
  | Cursor | **Prepares** and checks the plugin repo | Pinned to the commit first added; drift flags when you've moved on |
@@ -122,6 +122,21 @@ A form that returned 200 is a claim; the public record is the result. `listings`
122
122
 
123
123
  Coverage decays quietly, so run it on a schedule (`--ci` exits 1 on a mismatch). `fleet` runs it for every project.
124
124
 
125
+ ## ChatGPT's plugin ZIP
126
+
127
+ Since DevDay (September 2026) ChatGPT takes a plugin ZIP. `mcplane pack chatgpt` builds it from `mcplane.json`: `plugin.json` in the Agent Plugins format, with listing, icons, the 5 positive and 3 negative test cases, demo video, release notes and translations, plus `mcp.json`. It checks the package against OpenAI's submission rules first: name and description lengths, category, the four https URLs, at most 3 starter prompts, square icons of 48 to 4096 px, brand-colour contrast.
128
+
129
+ ```json
130
+ "chatgpt": {
131
+ "demoVideo": "https://example.com/demo.mp4",
132
+ "releaseNotes": "Adds hourly forecasts.",
133
+ "capabilities": ["Search forecasts", "Compare cities"],
134
+ "brandColor": "#2357C6"
135
+ }
136
+ ```
137
+
138
+ What stays with you: upload the ZIP, enter reviewer credentials and the per-tool hint justifications (drafted in `chatgpt.md`), and make the attestations. Bump `version` for every upload. Tool changes don't need a new ZIP; OpenAI's scans pick them up, and `drift` tracks extension metadata (sidebar, panels, file viewers) as part of each tool.
139
+
125
140
  ## Keeping listings current
126
141
 
127
142
  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.
@@ -237,6 +252,18 @@ It's what powers [reviewtimes.fyi/check](https://reviewtimes.fyi/check). The TLS
237
252
 
238
253
  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.
239
254
 
255
+
256
+ Each store can have its own listing text, the way fastlane keeps metadata per language. Anything you leave out falls back to the shared fields:
257
+
258
+ ```json
259
+ "description": "Shows how long AI app stores are taking to review submissions…",
260
+ "listing": {
261
+ "claude-connectors": { "description": "Ask Claude before you submit (\"How long is Claude plugin review taking?\")…" }
262
+ }
263
+ ```
264
+
265
+ Packs, checks, `drift` and `listings` all use the right text for each store.
266
+
240
267
  ## fastlane, mapped
241
268
 
242
269
  | fastlane | mcplane |
@@ -1,3 +1,4 @@
1
+ import { forStore } from '../draft.js';
1
2
  const get = (url) => fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(12_000), headers: { 'user-agent': 'mcplane' } }).catch(() => null);
2
3
  /** Width and height from a PNG's IHDR chunk. */
3
4
  function pngSize(buf) {
@@ -89,23 +90,32 @@ export async function listingChecks(m, tools, stores) {
89
90
  checks.push({ id, level: 'pass', title: `${label} fits (${max} characters)`, stores: s });
90
91
  };
91
92
  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 ?? [];
93
+ const g = forStore(m, 'chatgpt');
94
+ len(g.title, 30, 'listing.name', 'Display name', ['chatgpt']);
95
+ len(g.subtitle, 30, 'listing.subtitle', 'Subtitle', ['chatgpt']);
96
+ const prompts = g.prompts ?? [];
95
97
  const bad = prompts.filter((p) => p.length > 128 || /(^|\s)@\w/.test(p));
98
+ if (prompts.length > 3)
99
+ bad.push(`${prompts.length} prompts (at most 3)`);
96
100
  const dupes = prompts.filter((p, i) => prompts.findIndex((q) => q.trim().toLowerCase() === p.trim().toLowerCase()) !== i);
97
101
  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'] });
102
+ 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 takes at most 3 starter prompts, each one line of up to 128 characters, unique, with no @mentions.', stores: ['chatgpt'] });
99
103
  }
100
104
  else if (prompts.length) {
101
105
  checks.push({ id: 'listing.prompts', level: 'pass', title: 'Starter prompts are short, unique and free of @mentions', stores: ['chatgpt'] });
102
106
  }
107
+ len(g.description, 4000, 'listing.chatgpt-description', 'Long description for ChatGPT', ['chatgpt']);
108
+ // The plugin package requires all four listing URLs for MCP review.
109
+ const missing = ['website', 'support', 'privacy', 'terms'].filter((k) => !g.links?.[k]?.startsWith('https://'));
110
+ checks.push(missing.length
111
+ ? { id: 'listing.chatgpt-urls', level: 'fail', title: 'Website, support, privacy and terms URLs are set', detail: `missing or not https: ${missing.join(', ')}`, fix: 'ChatGPT’s plugin package requires all four as https URLs for MCP review.', stores: ['chatgpt'] }
112
+ : { id: 'listing.chatgpt-urls', level: 'pass', title: 'Website, support, privacy and terms URLs are set', stores: ['chatgpt'] });
103
113
  }
104
114
  if (has(stores, 'claude-connectors', 'claude-plugins'))
105
- len(m.title, 100, 'listing.claude-name', 'Name for Claude', ['claude-connectors', 'claude-plugins']);
115
+ len(forStore(m, 'claude-connectors').title, 100, 'listing.claude-name', 'Name for Claude', ['claude-connectors', 'claude-plugins']);
106
116
  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', []);
117
+ len(forStore(m, 'claude-connectors').oneLiner, 200, 'listing.one-liner', 'One-liner', ['claude-connectors', 'claude-plugins']);
118
+ len(forStore(m, 'claude-connectors').description, 2000, 'listing.description', 'Description', []);
109
119
  // Test cases must name tools the server actually has; descriptions drift away from live servers.
110
120
  const named = [...new Set((m.tests?.positive ?? []).flatMap((t) => t.tools))];
111
121
  if (tools.length && named.length) {
@@ -12,17 +12,17 @@ const INSTRUCTION = [
12
12
  /\b(call|use) (this|it) only\b/i,
13
13
  ];
14
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;
15
+ // Plan and tier language only: product prices ("live pricing", "premium cars") are not upsells.
16
+ const UPSELL = /\b(upgrade (to|your|now)|pro plan|premium (plan|tier|account|subscription|version)|paid (plan|tier|account)|free trial|subscribe (to|now)|unlock (more|premium|pro|unlimited|all))\b/i;
16
17
  /** Inputs that suggest a write authorised only by a secret passed through the chat. */
17
18
  const BEARER_INPUT = /^(token|edit_token|update_token|secret|access_key|api_key|password)$/i;
18
19
  export function toolChecks(tools) {
19
20
  if (!tools.length)
20
21
  return [];
21
22
  const checks = [];
22
- const names = new Set(tools.map((t) => t.name));
23
23
  const noTitle = tools.filter((t) => !t.title && !t.annotations?.title);
24
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'] }
25
+ ? { id: 'tools.title', level: 'warn', title: 'Every tool has a human-readable title', detail: noTitle.map((t) => t.name).join(', '), fix: 'Add "title" (or annotations.title) to each tool. Clients show the raw name without one, and Anthropic’s reviewers ask for them on new submissions.', stores: ['claude-connectors', 'claude-plugins'] }
26
26
  : { id: 'tools.title', level: 'pass', title: 'Every tool has a human-readable title' });
27
27
  const missing = tools.flatMap((t) => HINTS.filter((h) => typeof t.annotations?.[h] !== 'boolean').map((h) => `${t.name}.${h}`));
28
28
  checks.push(missing.length
@@ -46,8 +46,8 @@ export function toolChecks(tools) {
46
46
  const instructing = tools.flatMap((t) => {
47
47
  const text = `${t.description ?? ''}`;
48
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(', ')}`] : [];
49
+ // Naming a sibling tool ("call get_session first") is common in approved listings, so only explicit instructions count.
50
+ return hits.length ? [`${t.name}: ${hits.map((h) => `"${h}"`).join(', ')}`] : [];
51
51
  });
52
52
  checks.push(instructing.length
53
53
  ? {
@@ -118,5 +118,9 @@ export function toolChecks(tools) {
118
118
  checks.push(upsell.length
119
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
120
  : { id: 'tools.no-upsell', level: 'pass', title: 'No upgrade or pricing copy in tools' });
121
+ // ChatGPT extensions (DevDay 2026): entrypoints declared in _meta["openai/ui"]. Reported so they show up in reviews and drift.
122
+ const ext = tools.flatMap((t) => ((t._meta?.['openai/ui']?.entrypoints) ?? []).map((e) => `${t.name}: ${e.type}${e.extensions?.length ? ` (${e.extensions.join(', ')})` : ''}`));
123
+ if (ext.length)
124
+ checks.push({ id: 'tools.extensions', level: 'pass', title: 'ChatGPT extensions declared', detail: ext.join('; '), stores: ['chatgpt'] });
121
125
  return checks;
122
126
  }
package/dist/core.d.ts CHANGED
@@ -12,7 +12,7 @@ export declare function preflight(m: Manifest, opts?: {
12
12
  token?: string;
13
13
  extraChecks?: SocketCheck[];
14
14
  }): Promise<PreflightResult>;
15
- export { draftManifest, storesOf, SCHEMA_URL } from './draft.js';
15
+ export { draftManifest, forStore, storesOf, SCHEMA_URL } from './draft.js';
16
16
  export { toolChecks } from './checks/tools.js';
17
17
  export { STORE_NAMES } from './types.js';
18
18
  export type { Check, Level, Manifest, StoreId, Tool } from './types.js';
package/dist/core.js CHANGED
@@ -47,6 +47,6 @@ export async function preflight(m, opts = {}) {
47
47
  const checks = all.filter((c) => !c.stores?.length || c.stores.some((s) => stores.includes(s)));
48
48
  return { checks, reminders: reminders(m, stores), stores };
49
49
  }
50
- export { draftManifest, storesOf, SCHEMA_URL } from './draft.js';
50
+ export { draftManifest, forStore, storesOf, SCHEMA_URL } from './draft.js';
51
51
  export { toolChecks } from './checks/tools.js';
52
52
  export { STORE_NAMES } from './types.js';
package/dist/draft.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type Manifest, type StoreId } from './types.js';
2
2
  export declare const SCHEMA_URL = "https://unpkg.com/mcplane/schema.json";
3
+ /** The manifest as one store sees it: that store's listing text over the shared fields. */
4
+ export declare function forStore(m: Manifest, store: StoreId): Manifest;
3
5
  export declare function storesOf(m: Manifest): StoreId[];
4
6
  /** A starting manifest from what the live server and its domain reveal. */
5
7
  export declare function draftManifest(url: string): Promise<Manifest>;
package/dist/draft.js CHANGED
@@ -2,6 +2,14 @@
2
2
  import { McpClient } from './mcp-client.js';
3
3
  import { STORE_NAMES } from './types.js';
4
4
  export const SCHEMA_URL = 'https://unpkg.com/mcplane/schema.json';
5
+ /** The manifest as one store sees it: that store's listing text over the shared fields. */
6
+ export function forStore(m, store) {
7
+ const o = m.listing?.[store];
8
+ if (!o)
9
+ return m;
10
+ const set = Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== ''));
11
+ return { ...m, ...set };
12
+ }
5
13
  export function storesOf(m) {
6
14
  return m.stores?.length ? m.stores : Object.keys(STORE_NAMES);
7
15
  }
package/dist/drift.d.ts CHANGED
@@ -10,6 +10,8 @@ interface ToolShape {
10
10
  };
11
11
  input: string;
12
12
  output: string;
13
+ /** UI and extension metadata (entrypoints, resource URIs), when the tool declares any. */
14
+ ui?: string;
13
15
  }
14
16
  export interface Snapshot {
15
17
  store: StoreId;
package/dist/drift.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { resolve } from 'node:path';
13
13
  import { ensureLocalDir } from './manifest.js';
14
+ import { forStore } from './draft.js';
14
15
  import { McpClient } from './mcp-client.js';
15
16
  import { STORE_NAMES } from './types.js';
16
17
  const DIR = '.mcplane/snapshots';
@@ -21,6 +22,12 @@ function canon(v) {
21
22
  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
23
  return JSON.stringify(sort(v));
23
24
  }
25
+ /** The parts of _meta that put a tool on screen: MCP Apps UI, ChatGPT extension entrypoints, output templates. */
26
+ function uiOf(t) {
27
+ const m = t._meta ?? {};
28
+ const picked = Object.fromEntries(Object.entries({ ui: m.ui, openai: m['openai/ui'], template: m['openai/outputTemplate'] }).filter(([, v]) => v !== undefined));
29
+ return Object.keys(picked).length ? canon(picked) : '';
30
+ }
24
31
  function shape(t) {
25
32
  return {
26
33
  name: t.name,
@@ -29,6 +36,7 @@ function shape(t) {
29
36
  hints: { readOnly: t.annotations?.readOnlyHint, destructive: t.annotations?.destructiveHint, openWorld: t.annotations?.openWorldHint },
30
37
  input: canon(t.inputSchema),
31
38
  output: canon(t.outputSchema),
39
+ ui: uiOf(t),
32
40
  };
33
41
  }
34
42
  function listingOf(m) {
@@ -69,7 +77,7 @@ export async function takeSnapshot(m, store, opts, dir = process.cwd()) {
69
77
  version: opts.version,
70
78
  state: opts.state,
71
79
  tools: tools.map(shape),
72
- listing: listingOf(m),
80
+ listing: listingOf(forStore(m, store)),
73
81
  repoSha,
74
82
  };
75
83
  await ensureLocalDir(dir);
@@ -111,6 +119,9 @@ export function diffTools(before, now) {
111
119
  fields.push('inputs');
112
120
  if (o.output !== t.output)
113
121
  fields.push('outputSchema');
122
+ // Snapshots from before 0.4 have no ui field; don't report that as a change.
123
+ if (o.ui !== undefined && o.ui !== t.ui)
124
+ fields.push('UI/extensions');
114
125
  if ((o.title ?? '') !== (t.title ?? ''))
115
126
  fields.push('title');
116
127
  if ((o.description ?? '') !== (t.description ?? ''))
@@ -131,8 +142,8 @@ export async function drift(m, stores, opts = {}, dir = process.cwd()) {
131
142
  const missing = [];
132
143
  const [live, head] = await Promise.all([liveTools(m, opts.token), repoHead(m.repository)]);
133
144
  const now = live.map(shape);
134
- const listing = listingOf(m);
135
145
  for (const store of stores) {
146
+ const listing = listingOf(forStore(m, store));
136
147
  const snap = await readSnapshot(store, dir);
137
148
  if (!snap) {
138
149
  missing.push(store);
package/dist/extras.js CHANGED
@@ -18,7 +18,7 @@ export const DEFAULT_LANES = {
18
18
  'pack claude-connectors',
19
19
  'publish mcp-registry',
20
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_…>',
21
+ 'handoff: Upload the ZIP in .mcplane/packs/chatgpt/ at platform.openai.com/plugins, add reviewer credentials and hint justifications (see chatgpt.md), make the attestations and submit. Then: mcplane submitted chatgpt --app-id <asdk_app_…>',
22
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
23
  ],
24
24
  };
package/dist/listings.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * public record is the result. Coverage decays quietly, so this is meant to
5
5
  * run on a schedule.
6
6
  */
7
+ import { forStore } from './draft.js';
7
8
  import { liveTools, repoHead } from './drift.js';
8
9
  import { registryName } from './publish.js';
9
10
  import { STORE_NAMES } from './types.js';
@@ -44,6 +45,7 @@ async function registry(m) {
44
45
  return { store: 'mcp-registry', state: 'listed', url: `https://registry.modelcontextprotocol.io/v0/servers?search=${encodeURIComponent(name)}`, issues };
45
46
  }
46
47
  async function claudeConnectors(m, token) {
48
+ m = forStore(m, 'claude-connectors');
47
49
  const data = await getJson('https://api.anthropic.com/api/directory/servers?verified_tier=anthropic,partner,community&visibility=commercial&limit=5000');
48
50
  if (!data)
49
51
  return { store: 'claude-connectors', state: 'unknown', issues: [], note: 'Anthropic’s directory feed did not answer.' };
@@ -52,9 +54,9 @@ async function claudeConnectors(m, token) {
52
54
  return { store: 'claude-connectors', state: 'missing', issues: [] };
53
55
  const issues = [];
54
56
  if (m.oneLiner && norm(hit.one_liner) !== norm(m.oneLiner))
55
- issues.push('one-liner differs from mcplane.json');
57
+ issues.push('one-liner differs from mcplane.json (edit the listing, or set listing["claude-connectors"] if the difference is intended)');
56
58
  if (m.description && norm(hit.description) !== norm(m.description))
57
- issues.push('description differs from mcplane.json');
59
+ issues.push('description differs from mcplane.json (edit the listing, or set listing["claude-connectors"] if the difference is intended)');
58
60
  // The tool list is synced from your server at submission and isn't an editable field: compare it with what you serve now.
59
61
  const published = hit.tool_names ?? [];
60
62
  const live = (await liveTools(m, token).catch(() => [])).map((t) => t.name);
@@ -1,5 +1,5 @@
1
1
  import { type Manifest } from './types.js';
2
- export { draftManifest, storesOf } from './draft.js';
2
+ export { draftManifest, forStore, storesOf } from './draft.js';
3
3
  export declare const MANIFEST_FILE = "mcplane.json";
4
4
  export { SCHEMA_URL } from './draft.js';
5
5
  export declare function loadManifest(dir?: string): Promise<Manifest>;
package/dist/manifest.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { STORE_NAMES } from './types.js';
4
- export { draftManifest, storesOf } from './draft.js';
4
+ export { draftManifest, forStore, storesOf } from './draft.js';
5
5
  export const MANIFEST_FILE = 'mcplane.json';
6
6
  export { SCHEMA_URL } from './draft.js';
7
7
  export async function loadManifest(dir = process.cwd()) {
@@ -53,7 +53,7 @@ export async function serve() {
53
53
  });
54
54
  server.registerTool('pack', {
55
55
  title: 'Write a submission pack',
56
- 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
+ description: 'Writes everything a store’s submission form asks for into .mcplane/packs: the plugin ZIP ChatGPT’s portal uploads (listing, icons, 5+3 tests, release notes) with a checklist for the dashboard-only steps, 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.',
57
57
  inputSchema: { project: PROJECT, store: z.enum(['chatgpt', 'claude-connectors', 'cursor', 'muse', 'directories']) },
58
58
  annotations: { title: 'Pack', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
59
59
  }, async ({ project, store }) => {
package/dist/packs.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Manifest, StoreId, Tool } from './types.js';
2
2
  export interface Pack {
3
3
  files: {
4
4
  path: string;
5
- content: string;
5
+ content: string | Uint8Array;
6
6
  }[];
7
7
  problems: string[];
8
8
  todo: string[];
@@ -19,6 +19,15 @@ declare function justify(t: Tool, product: string, own?: {
19
19
  open_world_justification: string;
20
20
  destructive_justification: string;
21
21
  };
22
+ declare function category(raw?: string): string | null;
23
+ /** WCAG contrast ratio between two #RRGGBB colours. */
24
+ declare function contrast(a: string, b: string): number;
25
+ /**
26
+ * ChatGPT: the plugin ZIP the portal takes since DevDay (27 Sep 2026). plugin.json in the
27
+ * Agent Plugins format carries the listing, the 5+3 test cases, demo video, release notes
28
+ * and translations; mcp.json points at the server; icons are bundled. Reviewer credentials,
29
+ * hint justifications and the policy attestations stay in the dashboard.
30
+ */
22
31
  export declare function chatgptPack(m: Manifest, token?: string): Promise<Pack>;
23
32
  export declare function claudePack(m: Manifest, token?: string): Promise<Pack>;
24
33
  export declare function simplePack(m: Manifest, store: StoreId): Promise<Pack>;
@@ -28,5 +37,7 @@ export declare function writePack(pack: Pack, dir?: string): Promise<string[]>;
28
37
  export declare const _test: {
29
38
  thirdPerson: typeof thirdPerson;
30
39
  justify: typeof justify;
40
+ contrast: typeof contrast;
41
+ category: typeof category;
31
42
  };
32
43
  export {};
package/dist/packs.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Submission packs: everything a store's form asks for, in its order, checked
3
- * against its limits. ChatGPT gets the chatgpt-app-submission.json its portal
4
- * imports (the format OpenAI's own chatgpt-app-submission skill writes); the
5
- * rest get a markdown pack to paste from.
2
+ * Submission packs: everything a store asks for, checked against its limits.
3
+ * ChatGPT gets the plugin ZIP its portal uploads; the rest get a markdown pack
4
+ * to paste from.
6
5
  */
7
6
  import { mkdir, writeFile } from 'node:fs/promises';
8
- import { resolve } from 'node:path';
7
+ import { dirname, resolve } from 'node:path';
9
8
  import { ensureLocalDir } from './manifest.js';
9
+ import { forStore } from './draft.js';
10
10
  import { McpClient } from './mcp-client.js';
11
- const CATEGORIES = ['BUSINESS', 'COLLABORATION', 'DESIGN', 'DEVELOPER_TOOLS', 'EDUCATION', 'ENTERTAINMENT', 'FINANCE', 'FOOD', 'LIFESTYLE', 'NEWS', 'PRODUCTIVITY', 'SHOPPING', 'TRAVEL'];
11
+ import { zip } from './zip.js';
12
12
  const MAX_JUSTIFICATION = 200; // the ChatGPT form cuts longer ones without warning
13
13
  async function listTools(m, token) {
14
14
  const c = new McpClient(m.server.url, token ? { authorization: `Bearer ${token}` } : {});
@@ -45,34 +45,105 @@ function justify(t, product, own) {
45
45
  (a.destructiveHint ? `Can delete or overwrite data: it ${what}.` : a.readOnlyHint ? 'Read-only, so it cannot delete or overwrite anything.' : 'Only adds or updates the user’s own data; it never deletes or overwrites existing records.'), MAX_JUSTIFICATION),
46
46
  };
47
47
  }
48
+ /** The categories ChatGPT's package validator accepts, and the old form's names mapped onto them. */
49
+ const CATEGORIES = ['Productivity', 'Creativity', 'Developer Tools', 'Business & Operations', 'Data & Analytics', 'Communication', 'Education & Research', 'Security', 'Finance', 'Healthcare', 'Travel', 'Entertainment', 'Other'];
50
+ const OLD_CATEGORY = {
51
+ BUSINESS: 'Business & Operations', COLLABORATION: 'Communication', DESIGN: 'Creativity', DEVELOPER_TOOLS: 'Developer Tools', EDUCATION: 'Education & Research',
52
+ ENTERTAINMENT: 'Entertainment', FINANCE: 'Finance', PRODUCTIVITY: 'Productivity', TRAVEL: 'Travel', FOOD: 'Other', LIFESTYLE: 'Other', NEWS: 'Other', SHOPPING: 'Other',
53
+ };
54
+ function category(raw) {
55
+ if (!raw)
56
+ return null;
57
+ const hit = CATEGORIES.find((c) => c.toLowerCase() === raw.toLowerCase());
58
+ return hit ?? OLD_CATEGORY[raw.toUpperCase().replace(/[^A-Z]+/g, '_')] ?? null;
59
+ }
60
+ /** WCAG contrast ratio between two #RRGGBB colours. */
61
+ function contrast(a, b) {
62
+ const lum = (hex) => {
63
+ const [r, g, bl] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
64
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
65
+ };
66
+ const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
67
+ return (x + 0.05) / (y + 0.05);
68
+ }
69
+ /** Downloads an icon and checks it against the package rules: square, 48 to 4096 px, 5 MiB, PNG/JPEG/WebP/SVG. */
70
+ async function fetchIcon(url, label, problems) {
71
+ const res = await fetch(url, { headers: { 'user-agent': 'mcplane' } }).catch(() => null);
72
+ if (!res?.ok) {
73
+ problems.push(`${label} ${url} didn't load (HTTP ${res?.status ?? 'error'})`);
74
+ return null;
75
+ }
76
+ const type = res.headers.get('content-type') ?? '';
77
+ const data = new Uint8Array(await res.arrayBuffer());
78
+ const ext = type.includes('svg') || url.endsWith('.svg') ? 'svg' : type.includes('jpeg') || /\.jpe?g$/.test(url) ? 'jpg' : type.includes('webp') || url.endsWith('.webp') ? 'webp' : 'png';
79
+ if (data.length > 5 * 1024 * 1024)
80
+ problems.push(`${label} is ${(data.length / 1048576).toFixed(1)} MiB; the limit is 5 MiB`);
81
+ if (ext === 'png' && data[0] === 0x89) {
82
+ const v = new DataView(data.buffer, data.byteOffset);
83
+ const [w, h] = [v.getUint32(16), v.getUint32(20)];
84
+ if (w !== h)
85
+ problems.push(`${label} is ${w}×${h}; it must be square`);
86
+ else if (w < 48 || w > 4096)
87
+ problems.push(`${label} is ${w} px; it must be 48 to 4096 px`);
88
+ }
89
+ return { data, ext };
90
+ }
91
+ /**
92
+ * ChatGPT: the plugin ZIP the portal takes since DevDay (27 Sep 2026). plugin.json in the
93
+ * Agent Plugins format carries the listing, the 5+3 test cases, demo video, release notes
94
+ * and translations; mcp.json points at the server; icons are bundled. Reviewer credentials,
95
+ * hint justifications and the policy attestations stay in the dashboard.
96
+ */
48
97
  export async function chatgptPack(m, token) {
98
+ m = forStore(m, 'chatgpt');
99
+ const g = m.chatgpt ?? {};
49
100
  const tools = await listTools(m, token);
50
101
  const problems = [];
51
102
  const todo = [];
52
- const category = (m.category ?? '').toUpperCase().replace(/[^A-Z]+/g, '_');
53
- if (!CATEGORIES.includes(category))
54
- problems.push(`category "${m.category ?? ''}" isn't one of ChatGPT's: ${CATEGORIES.join(', ')}`);
55
- if ((m.subtitle ?? '').length > 30)
56
- problems.push(`subtitle is ${m.subtitle.length} characters; the limit is 30`);
57
- if (!m.subtitle)
58
- problems.push('subtitle is missing (30 characters, a plain functional phrase)');
103
+ const need = (ok, msg) => {
104
+ if (!ok)
105
+ problems.push(msg);
106
+ };
107
+ const oneLine = (v, max, label) => {
108
+ need(v, `${label} is missing`);
109
+ if (v && v.length > max)
110
+ problems.push(`${label} is ${v.length} characters; the limit is ${max}`);
111
+ if (v && /\n/.test(v))
112
+ problems.push(`${label} must be one line`);
113
+ };
114
+ need(/^[a-z0-9]+(-[a-z0-9]+)*$/.test(m.name) && m.name.length <= 64, `name "${m.name}" must be lowercase letters, digits and single hyphens, at most 64 characters`);
115
+ need(m.version && /^\d+\.\d+\.\d+/.test(m.version), 'version must be a semantic version (e.g. 1.0.0); every upload needs a new one');
116
+ oneLine(m.title, 30, 'Display name (title)');
117
+ oneLine(m.subtitle, 30, 'Short description (subtitle)');
118
+ need(m.description, 'Long description is missing');
119
+ if ((m.description ?? '').length > 4000)
120
+ problems.push(`Long description is ${m.description.length} characters; the limit is 4000`);
121
+ const developerName = g.developerName ?? m.author?.name;
122
+ oneLine(developerName, 80, 'Developer name (chatgpt.developerName or author.name)');
123
+ const cat = category(m.category);
124
+ need(cat, `category "${m.category ?? ''}" isn't one of: ${CATEGORIES.join(', ')}`);
125
+ for (const [k, v] of Object.entries({ website: m.links?.website, support: m.links?.support, privacy: m.links?.privacy, terms: m.links?.terms }))
126
+ need(v && v.startsWith('https://'), `links.${k} must be an https URL (all four are required for review)`);
127
+ const prompts = m.prompts ?? [];
128
+ if (prompts.length > 3)
129
+ problems.push(`${prompts.length} starter prompts; the limit is 3`);
130
+ for (const p of prompts)
131
+ if (p.length > 128 || /\n/.test(p) || /(^|\s)@\w/.test(p))
132
+ problems.push(`starter prompt "${p.slice(0, 40)}…" must be one line, at most 128 characters, with no @mention`);
133
+ for (const c of g.capabilities ?? [])
134
+ if (c.length > 120)
135
+ problems.push(`capability "${c.slice(0, 40)}…" is over 120 characters`);
136
+ if ((g.capabilities ?? []).length > 20)
137
+ problems.push('at most 20 capabilities');
138
+ if (g.brandColor && (!/^#[0-9a-fA-F]{6}$/.test(g.brandColor) || contrast(g.brandColor, '#FFFFFF') < 2))
139
+ problems.push(`brandColor ${g.brandColor} needs 2:1 contrast against white`);
140
+ if (g.brandColorDark && (!/^#[0-9a-fA-F]{6}$/.test(g.brandColorDark) || contrast(g.brandColorDark, '#212121') < 2))
141
+ problems.push(`brandColorDark ${g.brandColorDark} needs 2:1 contrast against #212121`);
142
+ need(g.demoVideo, 'chatgpt.demoVideo is missing: a reviewer-accessible video URL showing the main use cases (required for review)');
143
+ need(g.releaseNotes, 'chatgpt.releaseNotes is missing (required for review)');
59
144
  const missingHints = tools.filter((t) => ['readOnlyHint', 'openWorldHint', 'destructiveHint'].some((h) => typeof t.annotations?.[h] !== 'boolean'));
60
145
  for (const t of missingHints)
61
146
  problems.push(`${t.name} doesn't set all three hints; ChatGPT treats that as a blocker`);
62
- const toolsOut = Object.fromEntries(tools.map((t) => [
63
- t.name,
64
- {
65
- annotations: {
66
- readOnlyHint: !!t.annotations?.readOnlyHint,
67
- openWorldHint: !!t.annotations?.openWorldHint,
68
- destructiveHint: !!t.annotations?.destructiveHint,
69
- },
70
- justifications: justify(t, m.title, m.justifications?.[t.name]),
71
- },
72
- ]));
73
- const drafted = tools.filter((t) => !m.justifications?.[t.name]).map((t) => t.name);
74
- if (drafted.length)
75
- todo.push(`Read the drafted justifications for ${drafted.join(', ')}: they come from your hints and tool descriptions. Once they say exactly what each tool changes, save them under "justifications" in mcplane.json so every version reuses them.`);
76
147
  for (const [name, j] of Object.entries(m.justifications ?? {}))
77
148
  for (const [k, v] of Object.entries(j))
78
149
  if (v && v.length > MAX_JUSTIFICATION)
@@ -92,54 +163,115 @@ export async function chatgptPack(m, token) {
92
163
  for (const t of tools)
93
164
  if (!covered.has(t.name))
94
165
  todo.push(`No positive test uses ${t.name}. Reviewers check every tool.`);
95
- const json = {
96
- $schema: 'https://developers.openai.com/apps-sdk/schemas/chatgpt-app-submission.v1.json',
97
- schema_version: 1,
98
- app_info: { display_name: m.title, subtitle: m.subtitle ?? '', description: m.description ?? '', category: CATEGORIES.includes(category) ? category : 'PRODUCTIVITY' },
99
- tools: toolsOut,
100
- ...(pos.length
101
- ? {
102
- test_cases: pos.map((p) => ({ description: p.scenario, user_prompt: p.prompt, file_attachment_urls: null, tools_triggered: p.tools.join(', '), expected_output: p.expected, expected_output_url: null })),
103
- }
104
- : {}),
105
- ...(neg.length
106
- ? {
107
- negative_test_cases: neg.map((n) => ({ description: n.scenario, user_prompt: n.prompt, file_attachment_urls: null, tools_triggered: null, expected_output: `${m.title} should not be invoked.`, expected_output_url: null })),
108
- }
109
- : {}),
166
+ // Icons, bundled into the package.
167
+ const assets = [];
168
+ const icon = async (url, file, label) => {
169
+ if (!url)
170
+ return undefined;
171
+ const got = await fetchIcon(url, label, problems);
172
+ if (!got)
173
+ return undefined;
174
+ const path = `assets/${file}.${got.ext}`;
175
+ assets.push({ path, data: got.data });
176
+ return `./${path}`;
177
+ };
178
+ need(m.icon, 'icon is missing: a direct URL to a square image, 48 to 4096 px');
179
+ const logo = await icon(m.icon, 'logo', 'icon');
180
+ const composerIcon = (await icon(g.composerIcon, 'composer-icon', 'chatgpt.composerIcon')) ?? logo;
181
+ const logoDark = await icon(g.logoDark, 'logo-dark', 'chatgpt.logoDark');
182
+ const plugin = {
183
+ $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
184
+ name: m.name,
185
+ version: m.version ?? '1.0.0',
186
+ description: m.oneLiner ?? m.subtitle ?? m.title,
187
+ author: { name: developerName ?? m.title, ...(m.author?.url ? { url: m.author.url } : {}) },
188
+ ...(m.links?.website ? { homepage: m.links.website } : {}),
189
+ ...(m.repository ? { repository: m.repository } : {}),
190
+ extensions: {
191
+ 'com.openai': {
192
+ interface: {
193
+ displayName: m.title,
194
+ shortDescription: m.subtitle ?? '',
195
+ longDescription: m.description ?? '',
196
+ developerName: developerName ?? '',
197
+ category: cat ?? 'Other',
198
+ capabilities: g.capabilities ?? [],
199
+ websiteURL: m.links?.website,
200
+ supportURL: m.links?.support,
201
+ privacyPolicyURL: m.links?.privacy,
202
+ termsOfServiceURL: m.links?.terms,
203
+ ...(prompts.length ? { defaultPrompt: prompts.slice(0, 3) } : {}),
204
+ ...(g.brandColor ? { brandColor: g.brandColor } : {}),
205
+ ...(g.brandColorDark ? { brandColorDark: g.brandColorDark } : {}),
206
+ ...(logo ? { logo } : {}),
207
+ ...(composerIcon ? { composerIcon } : {}),
208
+ ...(logoDark ? { logoDark } : {}),
209
+ },
210
+ review: {
211
+ test_cases: {
212
+ positive: pos.map((p) => ({ description: p.scenario, prompt: p.prompt, tools_triggered: p.tools.join(', '), expected_behavior: p.expected })),
213
+ negative: neg.map((n) => ({ description: n.scenario, prompt: n.prompt })),
214
+ },
215
+ ...(g.demoVideo ? { demo_recording_url: g.demoVideo } : {}),
216
+ ...(g.commerce !== undefined ? { commerce: g.commerce } : {}),
217
+ ...(g.commerceDescription ? { commerce_description: g.commerceDescription } : {}),
218
+ },
219
+ publication: {
220
+ ...(g.countries ? { countries: g.countries } : {}),
221
+ ...(g.releaseNotes ? { release_notes: g.releaseNotes } : {}),
222
+ ...(g.translations ? { translations: g.translations } : {}),
223
+ },
224
+ },
225
+ },
110
226
  };
111
- const steps = `# ChatGPT submission: ${m.title}
227
+ const mcp = { $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', mcpServers: { [m.name]: { type: 'streamable-http', url: m.server.url } } };
228
+ const pkg = zip([
229
+ { path: 'plugin.json', data: JSON.stringify(plugin, null, 2) + '\n' },
230
+ { path: 'mcp.json', data: JSON.stringify(mcp, null, 2) + '\n' },
231
+ ...assets,
232
+ ]);
233
+ const zipName = `${m.name}-${m.version ?? '1.0.0'}.zip`;
234
+ // Justifications still go in the dashboard, one per hint, per tool.
235
+ const justified = tools
236
+ .map((t) => {
237
+ const j = justify(t, m.title, m.justifications?.[t.name]);
238
+ const a = t.annotations ?? {};
239
+ return `### ${t.name}\n- readOnlyHint ${!!a.readOnlyHint}: ${j.read_only_justification}\n- openWorldHint ${!!a.openWorldHint}: ${j.open_world_justification}\n- destructiveHint ${!!a.destructiveHint}: ${j.destructive_justification}`;
240
+ })
241
+ .join('\n\n');
242
+ const drafted = tools.filter((t) => !m.justifications?.[t.name]).map((t) => t.name);
243
+ if (drafted.length)
244
+ todo.push(`Read the drafted justifications for ${drafted.join(', ')} in chatgpt.md. Once they say exactly what each tool changes, save them under "justifications" in mcplane.json.`);
245
+ const steps = `# ChatGPT submission: ${m.title} ${m.version ?? ''}
112
246
 
113
- Upload \`chatgpt-app-submission.json\` on the Info step ("Use Codex to fill this form"): it fills app info, tool justifications and tests.
247
+ ## 1. Upload the package
248
+ platform.openai.com/plugins → **Upload new or existing plugin** → choose your verified developer identity → upload \`${zipName}\`.
249
+ It fills the listing, icons, test cases, demo video, release notes and translations. To change any of them later, edit mcplane.json, bump "version" and run \`mcplane pack chatgpt\` again.
114
250
 
115
- ## Before you start
116
- - A verified organisation on platform.openai.com (Settings, Organization). Start early; it can lag.
117
- - Domain challenge: the MCP step gives you a token to serve as plain text at ${new URL(m.server.url).origin}/.well-known/openai-apps-challenge.
118
- - A demo video recorded in ChatGPT developer mode showing the tools your tests use, hosted as an .mp4 URL.
119
- - Icons: a directory icon (square PNG, 256 px or more) and a composer icon (48 px or more; the developer-mode dialog caps uploads at 10 KB).
251
+ ## 2. Resolve findings
252
+ Open **Metadata & Skills** and **MCPs**, wait for the checks, fix anything listed (Copy issues is handy), and upload again if the package changes.
253
+ Domain verification: serve the token it gives you as plain text at ${new URL(m.server.url).origin}/.well-known/openai-apps-challenge, and keep it there.
120
254
 
121
- ## Fields the JSON doesn't cover
122
- - Developer identity and Plugin Author: must match your verified legal or business name.
123
- - Website: ${m.links?.website ?? 'MISSING'}
124
- - Customer support URL (a URL, not an email): ${m.links?.support ?? 'MISSING'}
125
- - Privacy policy: ${m.links?.privacy ?? 'MISSING'}
126
- - Terms: ${m.links?.terms ?? 'MISSING'}
127
- - MCP server URL: ${m.server.url} (${m.server.auth === 'oauth' ? 'OAuth' : 'No Auth'})${m.server.auth === 'oauth' ? `\n- Test credentials: ${m.reviewerAccess ?? 'MISSING: an email-and-password demo account with no 2FA, seeded for every test case'}` : ''}
128
- - Prompts (up to 3): ${(m.prompts ?? []).slice(0, 3).map((p) => `"${p}"`).join(', ') || 'none set'}
255
+ ## 3. What only you can enter
256
+ - **Review details:** reviewer credentials${m.server.auth === 'oauth' ? ` (${m.reviewerAccess ?? 'an email-and-password account with no 2FA, SMS or email confirmation, seeded for every test case'})` : ' (none needed: no sign-in)'}. Credentials never go in the ZIP.
257
+ - **Hint justifications**, one per value on every tool (200 characters each, cut without warning):
258
+
259
+ ${justified}
260
+
261
+ - **Submit for review** and the policy attestations.
129
262
 
130
263
  ## Traps
131
- - Run "Scan Tools" twice if the first click shows nothing.
132
- - Justifications over 200 characters are cut without warning.
133
- - Typing into fields while the draft autosaves can drop characters; re-read before submitting.
134
- - "Submit for Review" shows nothing for about 20 seconds. Confirm on the plugins list that the version reads "Review".
135
- - One version can be in review at a time. Changing tools on a published plugin needs a new version.
264
+ - One review can be active per plugin. To replace a package in review, cancel the review first.
265
+ - "Submit for review" can show nothing for a while; confirm the status on the Plugins page.
266
+ - Tool changes don't need a new package: OpenAI's scans pick them up. Listing changes do.
136
267
 
137
- When it's in: \`mcplane submitted chatgpt --app-id <asdk_app_… from the URL>\`
268
+ When it's in: \`mcplane submitted chatgpt --version ${m.version ?? '1.0.0'} --app-id <asdk_app_… from the URL>\`
138
269
  `;
139
270
  return {
140
271
  files: [
141
- { path: 'chatgpt-app-submission.json', content: JSON.stringify(json, null, 2) + '\n' },
142
- { path: 'chatgpt.md', content: steps },
272
+ { path: `chatgpt/${zipName}`, content: pkg },
273
+ { path: 'chatgpt/plugin.json', content: JSON.stringify(plugin, null, 2) + '\n' },
274
+ { path: 'chatgpt/chatgpt.md', content: steps },
143
275
  ],
144
276
  problems,
145
277
  todo,
@@ -152,6 +284,7 @@ function readWrite(tools) {
152
284
  return writes.length === tools.length ? 'Write only (check: most servers also read)' : 'Read and write';
153
285
  }
154
286
  export async function claudePack(m, token) {
287
+ m = forStore(m, 'claude-connectors');
155
288
  const tools = await listTools(m, token);
156
289
  const problems = [];
157
290
  if ((m.oneLiner ?? '').length > 200)
@@ -217,6 +350,7 @@ When it's in: \`mcplane submitted claude-connectors\`
217
350
  return { files: [{ path: 'claude-connectors.md', content: md }], problems, todo: [] };
218
351
  }
219
352
  export async function simplePack(m, store) {
353
+ m = forStore(m, store);
220
354
  const problems = [];
221
355
  let md = '';
222
356
  if (store === 'cursor') {
@@ -272,9 +406,10 @@ export async function writePack(pack, dir = process.cwd()) {
272
406
  await mkdir(out, { recursive: true });
273
407
  const written = [];
274
408
  for (const f of pack.files) {
409
+ await mkdir(dirname(resolve(out, f.path)), { recursive: true });
275
410
  await writeFile(resolve(out, f.path), f.content);
276
411
  written.push(`.mcplane/packs/${f.path}`);
277
412
  }
278
413
  return written;
279
414
  }
280
- export const _test = { thirdPerson, justify };
415
+ export const _test = { thirdPerson, justify, contrast, category };
package/dist/types.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface Manifest {
39
39
  icon?: string;
40
40
  prompts?: string[];
41
41
  stores?: StoreId[];
42
+ /** Per-store listing text, like fastlane's per-language metadata. Each store falls back to the top-level fields. */
43
+ listing?: Partial<Record<StoreId, ListingText>>;
42
44
  /** Review test cases: ChatGPT wants exactly 5 positive and 3 negative. */
43
45
  tests?: {
44
46
  positive?: {
@@ -66,6 +68,31 @@ export interface Manifest {
66
68
  description?: string;
67
69
  file?: string;
68
70
  };
71
+ /** ChatGPT plugin package fields (plugin.json) that have no shared equivalent. */
72
+ chatgpt?: {
73
+ /** Publisher name shown in the listing (80 max). Defaults to author.name. */
74
+ developerName?: string;
75
+ /** Capability labels (20 max, 120 characters each). */
76
+ capabilities?: string[];
77
+ /** #RRGGBB with 2:1 contrast against white; the dark one against #212121. */
78
+ brandColor?: string;
79
+ brandColorDark?: string;
80
+ /** Direct image URLs; default to "icon". */
81
+ composerIcon?: string;
82
+ logoDark?: string;
83
+ /** Reviewer-accessible video URL showing the main use cases (required for review). */
84
+ demoVideo?: string;
85
+ commerce?: boolean;
86
+ commerceDescription?: string;
87
+ /** Uppercase country codes; leave out to keep current targeting. */
88
+ countries?: string[];
89
+ /** What changed in this version (required for review). */
90
+ releaseNotes?: string;
91
+ translations?: Record<string, {
92
+ subtitle?: string;
93
+ description?: string;
94
+ }>;
95
+ };
69
96
  /** Grok marketplace entry overrides. */
70
97
  grok?: {
71
98
  description?: string;
@@ -78,6 +105,13 @@ export interface Manifest {
78
105
  /** How reviewers get in, for servers behind sign-in. Never put the password here; say where it lives. */
79
106
  reviewerAccess?: string;
80
107
  }
108
+ export interface ListingText {
109
+ title?: string;
110
+ subtitle?: string;
111
+ oneLiner?: string;
112
+ description?: string;
113
+ prompts?: string[];
114
+ }
81
115
  export interface Tool {
82
116
  name: string;
83
117
  title?: string;
@@ -87,6 +121,7 @@ export interface Tool {
87
121
  required?: string[];
88
122
  };
89
123
  outputSchema?: unknown;
124
+ _meta?: Record<string, unknown>;
90
125
  annotations?: {
91
126
  title?: string;
92
127
  readOnlyHint?: boolean;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.3.0";
1
+ export declare const VERSION = "0.4.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.3.0';
1
+ export const VERSION = '0.4.0';
package/dist/zip.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function zip(files: {
2
+ path: string;
3
+ data: Uint8Array | string;
4
+ }[]): Uint8Array;
package/dist/zip.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A minimal ZIP writer (deflate, no dependencies): enough to build the plugin
3
+ * package ChatGPT's submission portal takes.
4
+ */
5
+ import { deflateRawSync } from 'node:zlib';
6
+ const CRC = (() => {
7
+ const t = new Uint32Array(256);
8
+ for (let n = 0; n < 256; n++) {
9
+ let c = n;
10
+ for (let k = 0; k < 8; k++)
11
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
12
+ t[n] = c >>> 0;
13
+ }
14
+ return t;
15
+ })();
16
+ function crc32(buf) {
17
+ let c = 0xffffffff;
18
+ for (const b of buf)
19
+ c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
20
+ return (c ^ 0xffffffff) >>> 0;
21
+ }
22
+ export function zip(files) {
23
+ const enc = new TextEncoder();
24
+ const locals = [];
25
+ const central = [];
26
+ let offset = 0;
27
+ for (const f of files) {
28
+ const name = enc.encode(f.path);
29
+ const raw = typeof f.data === 'string' ? enc.encode(f.data) : f.data;
30
+ const body = deflateRawSync(raw);
31
+ const crc = crc32(raw);
32
+ const local = new Uint8Array(30 + name.length);
33
+ const lv = new DataView(local.buffer);
34
+ lv.setUint32(0, 0x04034b50, true);
35
+ lv.setUint16(4, 20, true);
36
+ lv.setUint16(6, 0x0800, true); // UTF-8 names
37
+ lv.setUint16(8, 8, true); // deflate
38
+ lv.setUint32(14, crc, true);
39
+ lv.setUint32(18, body.length, true);
40
+ lv.setUint32(22, raw.length, true);
41
+ lv.setUint16(26, name.length, true);
42
+ local.set(name, 30);
43
+ const cen = new Uint8Array(46 + name.length);
44
+ const cv = new DataView(cen.buffer);
45
+ cv.setUint32(0, 0x02014b50, true);
46
+ cv.setUint16(4, 20, true);
47
+ cv.setUint16(6, 20, true);
48
+ cv.setUint16(8, 0x0800, true);
49
+ cv.setUint16(10, 8, true);
50
+ cv.setUint32(16, crc, true);
51
+ cv.setUint32(20, body.length, true);
52
+ cv.setUint32(24, raw.length, true);
53
+ cv.setUint16(28, name.length, true);
54
+ cv.setUint32(42, offset, true);
55
+ cen.set(name, 46);
56
+ locals.push(local, body);
57
+ central.push(cen);
58
+ offset += local.length + body.length;
59
+ }
60
+ const cenSize = central.reduce((n, c) => n + c.length, 0);
61
+ const end = new Uint8Array(22);
62
+ const ev = new DataView(end.buffer);
63
+ ev.setUint32(0, 0x06054b50, true);
64
+ ev.setUint16(8, files.length, true);
65
+ ev.setUint16(10, files.length, true);
66
+ ev.setUint32(12, cenSize, true);
67
+ ev.setUint32(16, offset, true);
68
+ const out = new Uint8Array(offset + cenSize + 22);
69
+ let p = 0;
70
+ for (const part of [...locals, ...central, end]) {
71
+ out.set(part, p);
72
+ p += part.length;
73
+ }
74
+ return out;
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcplane",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "fastlane for MCP servers: preflight against every store's rejection causes, publish where you can, prepare the rest, track every review.",
5
5
  "type": "module",
6
6
  "bin": {
package/schema.json CHANGED
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "category": {
64
64
  "type": "string",
65
- "description": "ChatGPT category, e.g. DEVELOPER_TOOLS, PRODUCTIVITY, BUSINESS."
65
+ "description": "ChatGPT category: Productivity, Creativity, Developer Tools, Business & Operations, Data & Analytics, Communication, Education & Research, Security, Finance, Healthcare, Travel, Entertainment or Other (the old form's names like DEVELOPER_TOOLS are mapped)."
66
66
  },
67
67
  "server": {
68
68
  "type": "object",
@@ -279,6 +279,111 @@
279
279
  "reviewerAccess": {
280
280
  "type": "string",
281
281
  "description": "How reviewers get in, for servers behind sign-in. Say where credentials live; never put a password here."
282
+ },
283
+ "listing": {
284
+ "type": "object",
285
+ "description": "Per-store listing text, like fastlane's per-language metadata. Each store falls back to the top-level title, subtitle, oneLiner, description and prompts.",
286
+ "propertyNames": {
287
+ "$ref": "#/$defs/store"
288
+ },
289
+ "additionalProperties": {
290
+ "type": "object",
291
+ "additionalProperties": false,
292
+ "properties": {
293
+ "title": {
294
+ "type": "string"
295
+ },
296
+ "subtitle": {
297
+ "type": "string"
298
+ },
299
+ "oneLiner": {
300
+ "type": "string"
301
+ },
302
+ "description": {
303
+ "type": "string"
304
+ },
305
+ "prompts": {
306
+ "type": "array",
307
+ "items": {
308
+ "type": "string"
309
+ }
310
+ }
311
+ }
312
+ }
313
+ },
314
+ "chatgpt": {
315
+ "type": "object",
316
+ "additionalProperties": false,
317
+ "description": "ChatGPT plugin package fields (plugin.json) with no shared equivalent.",
318
+ "properties": {
319
+ "developerName": {
320
+ "type": "string",
321
+ "description": "Publisher name (80 max). Defaults to author.name.",
322
+ "maxLength": 80
323
+ },
324
+ "capabilities": {
325
+ "type": "array",
326
+ "maxItems": 20,
327
+ "items": {
328
+ "type": "string",
329
+ "maxLength": 120
330
+ }
331
+ },
332
+ "brandColor": {
333
+ "type": "string",
334
+ "description": "#RRGGBB with 2:1 contrast against white.",
335
+ "pattern": "^#[0-9a-fA-F]{6}$"
336
+ },
337
+ "brandColorDark": {
338
+ "type": "string",
339
+ "description": "#RRGGBB with 2:1 contrast against #212121.",
340
+ "pattern": "^#[0-9a-fA-F]{6}$"
341
+ },
342
+ "composerIcon": {
343
+ "$ref": "#/$defs/url"
344
+ },
345
+ "logoDark": {
346
+ "$ref": "#/$defs/url"
347
+ },
348
+ "demoVideo": {
349
+ "$ref": "#/$defs/url",
350
+ "description": "Reviewer-accessible video URL showing the main use cases. Required for review."
351
+ },
352
+ "commerce": {
353
+ "type": "boolean"
354
+ },
355
+ "commerceDescription": {
356
+ "type": "string"
357
+ },
358
+ "countries": {
359
+ "type": "array",
360
+ "items": {
361
+ "type": "string",
362
+ "pattern": "^[A-Z]{2}$"
363
+ }
364
+ },
365
+ "releaseNotes": {
366
+ "type": "string",
367
+ "description": "What changed in this version. Required for review."
368
+ },
369
+ "translations": {
370
+ "type": "object",
371
+ "additionalProperties": {
372
+ "type": "object",
373
+ "additionalProperties": false,
374
+ "properties": {
375
+ "subtitle": {
376
+ "type": "string",
377
+ "maxLength": 30
378
+ },
379
+ "description": {
380
+ "type": "string",
381
+ "maxLength": 4000
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }
282
387
  }
283
388
  }
284
389
  }