mcplane 0.4.0 → 0.5.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -18
  3. package/dist/agent-plugins.d.ts +6 -0
  4. package/dist/agent-plugins.js +76 -0
  5. package/dist/cdp.d.ts +49 -0
  6. package/dist/cdp.js +253 -0
  7. package/dist/checks/ard.d.ts +67 -0
  8. package/dist/checks/ard.js +215 -0
  9. package/dist/checks/auth.js +2 -1
  10. package/dist/checks/chain.d.ts +45 -0
  11. package/dist/checks/chain.js +171 -0
  12. package/dist/checks/listing.js +23 -14
  13. package/dist/checks/oauth.d.ts +45 -0
  14. package/dist/checks/oauth.js +64 -0
  15. package/dist/checks/readiness.d.ts +58 -0
  16. package/dist/checks/readiness.js +230 -0
  17. package/dist/checks/tools.js +38 -7
  18. package/dist/checks/vercel.d.ts +27 -0
  19. package/dist/checks/vercel.js +153 -0
  20. package/dist/cli.js +54 -6
  21. package/dist/core.d.ts +1 -0
  22. package/dist/core.js +20 -4
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +10 -3
  26. package/dist/evals.d.ts +34 -0
  27. package/dist/evals.js +75 -0
  28. package/dist/extras.js +1 -1
  29. package/dist/images.d.ts +21 -0
  30. package/dist/images.js +99 -0
  31. package/dist/listings.d.ts +14 -0
  32. package/dist/listings.js +40 -1
  33. package/dist/mcp-client.d.ts +1 -5
  34. package/dist/mcp-client.js +8 -1
  35. package/dist/mcp-server.js +21 -11
  36. package/dist/packs.d.ts +48 -18
  37. package/dist/packs.js +380 -90
  38. package/dist/publish.js +13 -4
  39. package/dist/report.js +30 -13
  40. package/dist/skills.d.ts +21 -0
  41. package/dist/skills.js +180 -0
  42. package/dist/submissions.js +2 -1
  43. package/dist/types.d.ts +37 -4
  44. package/dist/types.js +1 -0
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/dist/video.d.ts +58 -0
  48. package/dist/video.js +190 -0
  49. package/dist/zip.d.ts +16 -2
  50. package/dist/zip.js +65 -5
  51. package/package.json +4 -1
  52. package/schema.json +86 -12
  53. package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
  54. package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
  55. package/schemas/agent-plugins-1.0.0/plugin.schema.json +65 -0
package/dist/video.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Recorded scenes to one mp4 with ffmpeg: a title card, then each scene at real
3
+ * speed with its caption burned in. A DevTools screencast only sends a frame when
4
+ * something on screen changes, so each frame is held until the next one arrives
5
+ * (ffmpeg's concat demuxer with a duration per frame).
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import { writeFile } from 'node:fs/promises';
10
+ import { delimiter, join, resolve } from 'node:path';
11
+ const FPS = 30;
12
+ const DARK = '0x16181d';
13
+ const TITLE_SECONDS = 3;
14
+ const FONTS = ['/System/Library/Fonts/SFNS.ttf', '/System/Library/Fonts/Helvetica.ttc', '/Library/Fonts/Arial Unicode.ttf'];
15
+ /** ffmpeg's concat-demuxer list: each frame held until the next one, the last until `end`. */
16
+ export function concatList(frames, end) {
17
+ if (!frames.length)
18
+ throw new Error('No frames were recorded for this scene.');
19
+ const sorted = [...frames].sort((a, b) => a.ts - b.ts);
20
+ const entry = (f) => `file '${f.file.replace(/'/g, "'\\''")}'`;
21
+ const lines = [];
22
+ sorted.forEach((f, i) => {
23
+ const until = i + 1 < sorted.length ? sorted[i + 1].ts : Math.max(end, f.ts + 1 / FPS);
24
+ lines.push(entry(f), `duration ${Math.max(0.001, until - f.ts).toFixed(3)}`);
25
+ });
26
+ // The demuxer ignores the last entry's duration, so the last frame is listed once more.
27
+ lines.push(entry(sorted[sorted.length - 1]));
28
+ return `${lines.join('\n')}\n`;
29
+ }
30
+ /** How long a scene runs once assembled. */
31
+ export function sceneSeconds(s) {
32
+ if (!s.frames.length)
33
+ return 0;
34
+ return Math.max(s.end, Math.max(...s.frames.map((f) => f.ts))) - Math.min(...s.frames.map((f) => f.ts));
35
+ }
36
+ /**
37
+ * Escapes a value for a filter option inside an ffmpeg filtergraph. Two levels:
38
+ * the option value (\ ' :) and then the filtergraph itself (\ ' [ ] , ;).
39
+ */
40
+ export function ffEscape(value) {
41
+ const option = value.replace(/[\\':]/g, (c) => `\\${c}`);
42
+ return option.replace(/[\\'[\],;]/g, (c) => `\\${c}`);
43
+ }
44
+ /**
45
+ * A caption as at most `maxLines` lines of about `width` characters. Control
46
+ * characters become spaces (drawtext would draw them as boxes); anything that
47
+ * doesn't fit ends in an ellipsis.
48
+ */
49
+ export function wrapCaption(text, width = 64, maxLines = 2) {
50
+ const words = text.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().split(/\s+/).filter(Boolean);
51
+ const lines = [];
52
+ for (const w of words) {
53
+ const last = lines[lines.length - 1];
54
+ if (last !== undefined && `${last} ${w}`.length <= width)
55
+ lines[lines.length - 1] = `${last} ${w}`;
56
+ else
57
+ lines.push(w);
58
+ }
59
+ if (lines.length <= maxLines)
60
+ return lines;
61
+ const kept = lines.slice(0, maxLines);
62
+ let tail = kept[maxLines - 1];
63
+ while (tail.length > width - 1 && tail.includes(' '))
64
+ tail = tail.slice(0, tail.lastIndexOf(' '));
65
+ kept[maxLines - 1] = `${tail.replace(/[\s,;:.]+$/, '')}…`;
66
+ return kept;
67
+ }
68
+ /** Width and height from a JPEG's start-of-frame marker. */
69
+ export function jpegSize(b) {
70
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
71
+ return null;
72
+ let i = 2;
73
+ while (i + 8 < b.length) {
74
+ if (b[i] !== 0xff) {
75
+ i++;
76
+ continue;
77
+ }
78
+ const marker = b[i + 1];
79
+ if (marker === 0xff) {
80
+ i++;
81
+ continue;
82
+ }
83
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
84
+ i += 2;
85
+ continue;
86
+ }
87
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
88
+ return { height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] };
89
+ }
90
+ i += 2 + ((b[i + 2] << 8) | b[i + 3]);
91
+ }
92
+ return null;
93
+ }
94
+ /** Even dimensions (H.264 needs them) within the maximum, keeping the aspect ratio. */
95
+ export function outputSize(width, height, maxWidth = 1920, maxHeight = 1200) {
96
+ const s = Math.min(1, maxWidth / width, maxHeight / height);
97
+ const even = (n) => Math.max(2, Math.floor((n * s) / 2) * 2);
98
+ return { width: even(width), height: even(height) };
99
+ }
100
+ export function findFfmpeg(path = process.env.PATH ?? '') {
101
+ for (const dir of [...path.split(delimiter), '/opt/homebrew/bin', '/usr/local/bin']) {
102
+ if (dir && existsSync(join(dir, 'ffmpeg')))
103
+ return join(dir, 'ffmpeg');
104
+ }
105
+ return null;
106
+ }
107
+ /** A drawtext filter reading its text from a file, so no caption needs filtergraph escaping. */
108
+ function drawtext(o) {
109
+ return [`drawtext=fontfile=${ffEscape(o.font)}`, `textfile=${ffEscape(o.textfile)}`, 'expansion=none', `fontsize=${o.size}`, `fontcolor=${o.color}`, 'x=(w-tw)/2', `y=${o.y}`].join(':');
110
+ }
111
+ /** Text colours that read on a background, given as 0xRRGGBB: light on dark, dark on light. */
112
+ export function inkFor(bg) {
113
+ const n = parseInt(bg.replace(/^(0x|#)/, ''), 16);
114
+ const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
115
+ const light = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
116
+ return light ? { text: '0x1d1d1f', muted: '0x5f6673' } : { text: 'white', muted: '0x9aa3b2' };
117
+ }
118
+ /** The app's background colour, from a frame's bottom-left corner, so the borders and caption band blend in. */
119
+ function sampleBackground(ffmpeg, file, cwd) {
120
+ return new Promise((done) => {
121
+ const p = spawn(ffmpeg, ['-v', 'error', '-i', file, '-vf', 'format=rgb24,crop=1:1:4:ih-5', '-frames:v', '1', '-f', 'rawvideo', 'pipe:1'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
122
+ const chunks = [];
123
+ p.stdout.on('data', (d) => chunks.push(d));
124
+ p.on('error', () => done(null));
125
+ p.on('close', (code) => {
126
+ const px = Buffer.concat(chunks);
127
+ done(code === 0 && px.length >= 3 ? `0x${[px[0], px[1], px[2]].map((v) => v.toString(16).padStart(2, '0')).join('')}` : null);
128
+ });
129
+ });
130
+ }
131
+ function run(cmd, args, cwd) {
132
+ return new Promise((done, fail) => {
133
+ const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
134
+ let err = '';
135
+ p.stderr.on('data', (d) => (err += d));
136
+ p.on('error', fail);
137
+ p.on('close', (code) => (code === 0 ? done() : fail(new Error(`ffmpeg failed: ${err.trim().split('\n').slice(-3).join(' ')}`))));
138
+ });
139
+ }
140
+ /**
141
+ * Builds the video in `work` (where the frames are) and writes it to `out`.
142
+ * Every part is encoded the same way so they join without re-encoding.
143
+ */
144
+ export async function buildVideo(o) {
145
+ const { width: W, height: H } = o.size;
146
+ const k = H / 1200;
147
+ const font = FONTS.find((f) => existsSync(f));
148
+ if (!font)
149
+ throw new Error(`No font for captions; looked for ${FONTS.join(', ')}`);
150
+ const enc = ['-an', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', String(FPS), '-video_track_timescale', '15360'];
151
+ const parts = [];
152
+ const first = o.scenes.find((s) => s.frames.length)?.frames[0];
153
+ const bg = (first && (await sampleBackground(o.ffmpeg, first.file, o.work))) ?? DARK;
154
+ const ink = inkFor(bg);
155
+ // Title card: the name large, one line of what it does underneath.
156
+ await writeFile(join(o.work, 'title.txt'), o.title);
157
+ const sub = o.subtitle ? wrapCaption(o.subtitle, 70) : [];
158
+ for (const [i, line] of sub.entries())
159
+ await writeFile(join(o.work, `title-${i}.txt`), line);
160
+ const titleSize = Math.round(76 * k);
161
+ const subSize = Math.round(38 * k);
162
+ const titleY = sub.length ? `(h/2)-th-${Math.round(24 * k)}` : '(h-th)/2';
163
+ const titleFilters = [
164
+ drawtext({ textfile: 'title.txt', font, size: titleSize, color: ink.text, y: titleY }),
165
+ ...sub.map((_, i) => drawtext({ textfile: `title-${i}.txt`, font, size: subSize, color: ink.muted, y: `(h/2)+${Math.round((24 + i * 54) * k)}` })),
166
+ 'format=yuv420p',
167
+ ];
168
+ await run(o.ffmpeg, ['-y', '-v', 'error', '-f', 'lavfi', '-i', `color=c=${bg}:s=${W}x${H}:r=${FPS}:d=${TITLE_SECONDS}`, '-vf', titleFilters.join(','), ...enc, 'part-title.mp4'], o.work);
169
+ parts.push('part-title.mp4');
170
+ // Scenes: the window at real speed above a band that holds the caption, so it never covers the app.
171
+ const band = Math.round((136 * k) / 2) * 2;
172
+ const capSize = Math.round(38 * k);
173
+ const lineH = Math.round(50 * k);
174
+ for (const [i, s] of o.scenes.entries()) {
175
+ await writeFile(join(o.work, `scene-${i}.txt`), concatList(s.frames, s.end));
176
+ const lines = wrapCaption(s.caption, 80);
177
+ const filters = [`scale=${W}:${H - band}:force_original_aspect_ratio=decrease:flags=lanczos`, `pad=${W}:${H}:(ow-iw)/2:0:color=${bg}`, `fps=${FPS}`];
178
+ const top = H - band + Math.round((band - lines.length * lineH) / 2);
179
+ for (const [j, line] of lines.entries()) {
180
+ await writeFile(join(o.work, `caption-${i}-${j}.txt`), line);
181
+ filters.push(drawtext({ textfile: `caption-${i}-${j}.txt`, font, size: capSize, color: ink.text, y: `${top + j * lineH}+(${lineH}-th)/2` }));
182
+ }
183
+ filters.push('format=yuv420p');
184
+ await run(o.ffmpeg, ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', `scene-${i}.txt`, '-vf', filters.join(','), ...enc, `part-${i}.mp4`], o.work);
185
+ parts.push(`part-${i}.mp4`);
186
+ }
187
+ await writeFile(join(o.work, 'parts.txt'), parts.map((p) => `file '${p}'`).join('\n') + '\n');
188
+ await run(o.ffmpeg, ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', 'parts.txt', '-c', 'copy', '-movflags', '+faststart', resolve(o.out)], o.work);
189
+ return TITLE_SECONDS + o.scenes.reduce((n, s) => n + sceneSeconds(s), 0);
190
+ }
package/dist/zip.d.ts CHANGED
@@ -1,4 +1,18 @@
1
- export declare function zip(files: {
1
+ /** 1980-01-01 00:00 in DOS format: (year - 1980) << 9 | month << 5 | day. */
2
+ export declare const DOS_DATE: number;
3
+ export interface ZipEntry {
2
4
  path: string;
3
5
  data: Uint8Array | string;
4
- }[]): Uint8Array;
6
+ /** Unix permission bits; 0o644 unless given. Use 0o755 for scripts. */
7
+ mode?: number;
8
+ }
9
+ export declare function zip(files: ZipEntry[]): Uint8Array;
10
+ export interface UnzippedEntry {
11
+ path: string;
12
+ data: Uint8Array;
13
+ /** Unix permission bits, when the archive was made on Unix. */
14
+ mode: number | null;
15
+ dosDate: number;
16
+ }
17
+ /** Reads every entry from the central directory, inflating and checking each CRC. */
18
+ export declare function unzip(buf: Uint8Array): UnzippedEntry[];
package/dist/zip.js CHANGED
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * A minimal ZIP writer (deflate, no dependencies): enough to build the plugin
3
- * package ChatGPT's submission portal takes.
3
+ * package ChatGPT's submission portal takes, and a reader to check it.
4
+ *
5
+ * Every entry is dated 1980-01-01 00:00, the first valid DOS date (an all-zero
6
+ * date has month 0, which some readers reject), so the same files always give
7
+ * the same bytes. Entries carry Unix modes, so a script marked executable stays
8
+ * executable when the package is unpacked.
4
9
  */
5
- import { deflateRawSync } from 'node:zlib';
10
+ import { deflateRawSync, inflateRawSync } from 'node:zlib';
6
11
  const CRC = (() => {
7
12
  const t = new Uint32Array(256);
8
13
  for (let n = 0; n < 256; n++) {
@@ -19,6 +24,13 @@ function crc32(buf) {
19
24
  c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
20
25
  return (c ^ 0xffffffff) >>> 0;
21
26
  }
27
+ /** 1980-01-01 00:00 in DOS format: (year - 1980) << 9 | month << 5 | day. */
28
+ export const DOS_DATE = (0 << 9) | (1 << 5) | 1;
29
+ const DOS_TIME = 0;
30
+ const UTF8 = 0x0800;
31
+ /** "Version made by": Unix (3), ZIP 2.0, so readers apply the modes in the external attributes. */
32
+ const MADE_BY_UNIX = (3 << 8) | 20;
33
+ const REGULAR_FILE = 0o100000;
22
34
  export function zip(files) {
23
35
  const enc = new TextEncoder();
24
36
  const locals = [];
@@ -33,8 +45,10 @@ export function zip(files) {
33
45
  const lv = new DataView(local.buffer);
34
46
  lv.setUint32(0, 0x04034b50, true);
35
47
  lv.setUint16(4, 20, true);
36
- lv.setUint16(6, 0x0800, true); // UTF-8 names
48
+ lv.setUint16(6, UTF8, true);
37
49
  lv.setUint16(8, 8, true); // deflate
50
+ lv.setUint16(10, DOS_TIME, true);
51
+ lv.setUint16(12, DOS_DATE, true);
38
52
  lv.setUint32(14, crc, true);
39
53
  lv.setUint32(18, body.length, true);
40
54
  lv.setUint32(22, raw.length, true);
@@ -43,14 +57,17 @@ export function zip(files) {
43
57
  const cen = new Uint8Array(46 + name.length);
44
58
  const cv = new DataView(cen.buffer);
45
59
  cv.setUint32(0, 0x02014b50, true);
46
- cv.setUint16(4, 20, true);
60
+ cv.setUint16(4, MADE_BY_UNIX, true);
47
61
  cv.setUint16(6, 20, true);
48
- cv.setUint16(8, 0x0800, true);
62
+ cv.setUint16(8, UTF8, true);
49
63
  cv.setUint16(10, 8, true);
64
+ cv.setUint16(12, DOS_TIME, true);
65
+ cv.setUint16(14, DOS_DATE, true);
50
66
  cv.setUint32(16, crc, true);
51
67
  cv.setUint32(20, body.length, true);
52
68
  cv.setUint32(24, raw.length, true);
53
69
  cv.setUint16(28, name.length, true);
70
+ cv.setUint32(38, ((REGULAR_FILE | ((f.mode ?? 0o644) & 0o777)) << 16) >>> 0, true);
54
71
  cv.setUint32(42, offset, true);
55
72
  cen.set(name, 46);
56
73
  locals.push(local, body);
@@ -73,3 +90,46 @@ export function zip(files) {
73
90
  }
74
91
  return out;
75
92
  }
93
+ /** Reads every entry from the central directory, inflating and checking each CRC. */
94
+ export function unzip(buf) {
95
+ const v = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
96
+ let eocd = -1;
97
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65_557); i--) {
98
+ if (v.getUint32(i, true) === 0x06054b50) {
99
+ eocd = i;
100
+ break;
101
+ }
102
+ }
103
+ if (eocd < 0)
104
+ throw new Error('not a ZIP: no end of central directory');
105
+ const count = v.getUint16(eocd + 10, true);
106
+ let p = v.getUint32(eocd + 16, true);
107
+ const dec = new TextDecoder();
108
+ const out = [];
109
+ for (let i = 0; i < count; i++) {
110
+ if (v.getUint32(p, true) !== 0x02014b50)
111
+ throw new Error(`bad central directory entry ${i}`);
112
+ const madeBy = v.getUint16(p + 4, true);
113
+ const method = v.getUint16(p + 10, true);
114
+ const dosDate = v.getUint16(p + 14, true);
115
+ const crc = v.getUint32(p + 16, true);
116
+ const csize = v.getUint32(p + 20, true);
117
+ const usize = v.getUint32(p + 24, true);
118
+ const nlen = v.getUint16(p + 28, true);
119
+ const xlen = v.getUint16(p + 30, true);
120
+ const clen = v.getUint16(p + 32, true);
121
+ const attrs = v.getUint32(p + 38, true);
122
+ const at = v.getUint32(p + 42, true);
123
+ const path = dec.decode(buf.subarray(p + 46, p + 46 + nlen));
124
+ const start = at + 30 + v.getUint16(at + 26, true) + v.getUint16(at + 28, true);
125
+ const body = buf.subarray(start, start + csize);
126
+ const data = method === 8 ? new Uint8Array(inflateRawSync(body)) : method === 0 ? body.slice() : null;
127
+ if (!data)
128
+ throw new Error(`${path}: unsupported compression method ${method}`);
129
+ if (data.length !== usize || crc32(data) !== crc)
130
+ throw new Error(`${path}: CRC or size mismatch`);
131
+ out.push({ path, data, mode: madeBy >> 8 === 3 ? (attrs >>> 16) & 0o777 : null, dosDate });
132
+ p += 46 + nlen + xlen + clen;
133
+ }
134
+ return out;
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcplane",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {
@@ -8,8 +8,10 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "schemas",
11
12
  "schema.json",
12
13
  "README.md",
14
+ "CHANGELOG.md",
13
15
  "LICENSE"
14
16
  ],
15
17
  "engines": {
@@ -41,6 +43,7 @@
41
43
  },
42
44
  "dependencies": {
43
45
  "@modelcontextprotocol/sdk": "^1.31.0",
46
+ "ajv": "^8.17.1",
44
47
  "zod": "^4.6.5"
45
48
  },
46
49
  "repository": {
package/schema.json CHANGED
@@ -26,7 +26,8 @@
26
26
  "cline",
27
27
  "lobehub",
28
28
  "awesome-mcp-servers",
29
- "awesome-remote-mcp-servers"
29
+ "awesome-remote-mcp-servers",
30
+ "vercel-connect"
30
31
  ]
31
32
  },
32
33
  "url": {
@@ -80,6 +81,13 @@
80
81
  "none",
81
82
  "oauth"
82
83
  ]
84
+ },
85
+ "signIn": {
86
+ "type": "array",
87
+ "items": {
88
+ "$ref": "#/$defs/url"
89
+ },
90
+ "description": "Sign-in pages and identity-provider URLs your consent screen sends people to. Preflight GETs them, with the authorization endpoint, to find bot challenges and 403s reviewers would hit."
83
91
  }
84
92
  }
85
93
  },
@@ -124,8 +132,8 @@
124
132
  }
125
133
  },
126
134
  "icon": {
127
- "$ref": "#/$defs/url",
128
- "description": "A direct link to a square PNG, 512px or larger. Not a page, not a favicon."
135
+ "type": "string",
136
+ "description": "A direct link to a square PNG, 512px or larger. Not a page, not a favicon. The ChatGPT package also takes a local path, relative to mcplane.json; other stores need the link."
129
137
  },
130
138
  "prompts": {
131
139
  "type": "array",
@@ -200,22 +208,20 @@
200
208
  },
201
209
  "justifications": {
202
210
  "type": "object",
203
- "description": "Your own ChatGPT hint justifications per tool name. They replace mcplane's drafts. 200 characters each.",
211
+ "deprecated": true,
212
+ "description": "Deprecated: OpenAI no longer asks for hint justifications. Still accepted; the ChatGPT pack lists them as optional notes for an appeal if review flags a hint.",
204
213
  "additionalProperties": {
205
214
  "type": "object",
206
215
  "additionalProperties": false,
207
216
  "properties": {
208
217
  "readOnly": {
209
- "type": "string",
210
- "maxLength": 200
218
+ "type": "string"
211
219
  },
212
220
  "openWorld": {
213
- "type": "string",
214
- "maxLength": 200
221
+ "type": "string"
215
222
  },
216
223
  "destructive": {
217
- "type": "string",
218
- "maxLength": 200
224
+ "type": "string"
219
225
  }
220
226
  }
221
227
  }
@@ -266,6 +272,54 @@
266
272
  }
267
273
  }
268
274
  },
275
+ "vercelConnect": {
276
+ "type": "object",
277
+ "additionalProperties": false,
278
+ "description": "Vercel Connect's Submit a Service fields with no shared equivalent.",
279
+ "properties": {
280
+ "apiBase": {
281
+ "$ref": "#/$defs/url",
282
+ "description": "Base URL of a REST API your users also call, listed as a second target."
283
+ },
284
+ "scopes": {
285
+ "type": "array",
286
+ "items": {
287
+ "type": "string"
288
+ },
289
+ "description": "Default scopes for the OAuth method. Defaults to the discovery document's scopes_supported."
290
+ },
291
+ "apiKey": {
292
+ "type": "object",
293
+ "additionalProperties": false,
294
+ "description": "Offer an API key connection method.",
295
+ "properties": {
296
+ "docs": {
297
+ "$ref": "#/$defs/url",
298
+ "description": "Where users create a key."
299
+ },
300
+ "header": {
301
+ "type": "string",
302
+ "description": "How the key is sent, e.g. \"x-api-key: <key>\". Defaults to Authorization: Bearer <key>."
303
+ }
304
+ }
305
+ }
306
+ }
307
+ },
308
+ "claudePlugin": {
309
+ "type": "object",
310
+ "additionalProperties": false,
311
+ "description": "The Claude Code plugin that ships this server, for \"mcplane pack claude-eval\". Read from the plugin repo when left out.",
312
+ "properties": {
313
+ "name": {
314
+ "type": "string",
315
+ "description": "The plugin's name in its plugin.json."
316
+ },
317
+ "server": {
318
+ "type": "string",
319
+ "description": "The server's key in the plugin's .mcp.json (or plugin.json mcpServers)."
320
+ }
321
+ }
322
+ },
269
323
  "lanes": {
270
324
  "type": "object",
271
325
  "description": "Named workflows run with \"mcplane <lane>\". Each step is a command without \"mcplane\"; prefix \"?\" for optional, \"handoff:\" for a step only a person can do.",
@@ -339,11 +393,31 @@
339
393
  "description": "#RRGGBB with 2:1 contrast against #212121.",
340
394
  "pattern": "^#[0-9a-fA-F]{6}$"
341
395
  },
396
+ "logo": {
397
+ "type": "string",
398
+ "description": "The package's logo: an image URL or a local path relative to mcplane.json. Defaults to icon."
399
+ },
342
400
  "composerIcon": {
343
- "$ref": "#/$defs/url"
401
+ "type": "string",
402
+ "description": "An image URL or a local path. Defaults to the logo."
344
403
  },
345
404
  "logoDark": {
346
- "$ref": "#/$defs/url"
405
+ "type": "string",
406
+ "description": "An image URL or a local path, for dark mode."
407
+ },
408
+ "skills": {
409
+ "description": "Agent Skills to bundle under skills/ in the package: a folder whose subfolders each hold a SKILL.md, or a list of skill folders. Relative to mcplane.json.",
410
+ "oneOf": [
411
+ {
412
+ "type": "string"
413
+ },
414
+ {
415
+ "type": "array",
416
+ "items": {
417
+ "type": "string"
418
+ }
419
+ }
420
+ ]
347
421
  },
348
422
  "demoVideo": {
349
423
  "$ref": "#/$defs/url",