From e935e42292b4f38064bdd89f82672e8c549cb500 Mon Sep 17 00:00:00 2001 From: Yuki Fujisaki Date: Tue, 13 Oct 2026 17:12:55 +0900 Subject: [PATCH 1/2] fix: keep the bundle under 1 MiB and set directory listing links The Claude plugin directory reads files only up to 1 MiB as text, so the 1.2 MB bundle held every version for a reviewer as code it couldn't inspect. zod re-exports all of its error-message locales (about 280 KB), and the server only uses English, which zod configures itself. Bundle only the English locale, which brings bundle.js to about 917 KB, and fail the bundle build at 1 MiB so CI catches growth before a release. The directory reads listing links from plugin.json and, without them, guessed a support URL from the README that it then rejected. Set documentationUrl, supportUrl, privacyPolicyUrl, and termsOfServiceUrl, and point the README's support section at the same channels as SUPPORT.md. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + plugin/.claude-plugin/plugin.json | 4 ++++ plugin/README.md | 9 +++++--- scripts/build-bundle.mjs | 35 ++++++++++++++++++++++++++++--- src/__tests__/plugin.test.ts | 14 +++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f52d0c2..126cf97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Tests validate structural invariants like version consistency between `package.j - ESM throughout (`"type": "module"` in package.json, `.js` extensions in imports) - TypeScript strict mode, target ES2022, Node16 module resolution - The build produces both `dist/` (tsc output) and `plugin/scripts/bundle.js` (esbuild single-file bundle); the bundle is what end users run +- `scripts/build-bundle.mjs` fails when the bundle reaches 1 MiB, the size the Claude plugin directory reads as text; a larger bundle holds every version for a reviewer. Keep the bundle readable: trim dependencies instead of minifying. It already keeps only zod's English locale. - Authentication uses the device authorization code flow. `login_start` → user approves in browser → `login_wait` stores the token at `~/.config/deploygate/token` (0600). `logout` revokes server-side and deletes the file. - Skill `allowed-tools` must name each MCP tool with the plugin scope (`mcp__plugin_deploygate_deploygate__`), never a wildcard. Every entry must be on the `PRE_APPROVABLE_RULES` allowlist in `skills.test.ts`, which leaves out destructive or access-granting MCP tools, build tools (`./gradlew`, `xcodebuild`, `fastlane`), shell commands that write files or run arbitrary code, and unscoped `Write`/`Edit` (use `Edit()`, which also covers `Write`). The Claude plugin directory holds versions that pre-approve broad access, so extend the allowlist only deliberately. - The `members.ts` `add_member` tool orchestrates multiple API calls (workspace → project → team) in a single tool invocation, handling "already exists" gracefully diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4a032d1..de70af0 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -8,6 +8,10 @@ }, "homepage": "https://deploygate.com", "license": "MIT", + "documentationUrl": "https://github.com/DeployGate/deploygate-agent-plugin#readme", + "supportUrl": "https://github.com/DeployGate/deploygate-agent-plugin/blob/main/SUPPORT.md", + "privacyPolicyUrl": "https://deploygate.com/terms/privacy", + "termsOfServiceUrl": "https://deploygate.com/terms", "keywords": [ "deploygate", "mobile", diff --git a/plugin/README.md b/plugin/README.md index 1cc0a61..b2202bc 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -44,12 +44,15 @@ The plugin runs a local MCP server, so its tools work in Claude Code and Cowork ## About `scripts/bundle.js` -`scripts/bundle.js` is the MCP server and its npm dependencies, mainly `@modelcontextprotocol/sdk` and `zod`, bundled into one unminified file with esbuild. It is built from the TypeScript source in [`src/`](https://github.com/DeployGate/deploygate-agent-plugin/tree/main/src) by `npm ci && npm run bundle`, and CI checks that the committed bundle matches a fresh build of the source before each release. +`scripts/bundle.js` is the MCP server and its npm dependencies, mainly `@modelcontextprotocol/sdk` and `zod`, bundled into one unminified file with esbuild. It is built from the TypeScript source in [`src/`](https://github.com/DeployGate/deploygate-agent-plugin/tree/main/src) by `npm ci && npm run bundle`, and CI checks that the committed bundle matches a fresh build of the source before each release, and that it stays under 1 MiB so it can be read as text. ## Support -- Source and issues: https://github.com/DeployGate/deploygate-agent-plugin -- Help: help@deploygate.com +- Questions about the DeployGate service: [DeployGate Help Center](https://intercom.help/deploygate) +- Bugs and feature requests for this plugin: [GitHub Issues](https://github.com/DeployGate/deploygate-agent-plugin/issues) +- Security vulnerabilities: [help@deploygate.com](mailto:help@deploygate.com) (please don't open a public issue) + +See [SUPPORT.md](https://github.com/DeployGate/deploygate-agent-plugin/blob/main/SUPPORT.md) for details. ## License diff --git a/scripts/build-bundle.mjs b/scripts/build-bundle.mjs index 23495b6..f3b5378 100644 --- a/scripts/build-bundle.mjs +++ b/scripts/build-bundle.mjs @@ -1,19 +1,48 @@ import { build } from "esbuild"; -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, resolve, sep } from "node:path"; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, ".."); const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf-8")); +const outfile = resolve(root, "plugin/scripts/bundle.js"); + +// The Claude plugin directory reads files only up to 1 MiB as text; a larger +// bundle can't be inspected and holds every version for a reviewer. +const MAX_BUNDLE_BYTES = 1024 * 1024; + +// zod re-exports every error-message locale as `z.locales`, which adds about +// 280 KB that the server never uses: zod configures English itself, and +// nothing switches locales. Keep only English. +const zodEnglishLocaleOnly = { + name: "zod-english-locale-only", + setup(build) { + const zodLocalesDir = `${sep}node_modules${sep}zod${sep}v4${sep}locales${sep}`; + build.onLoad({ filter: /[\\/]zod[\\/]v4[\\/]locales[\\/]index\.js$/ }, (args) => + args.path.includes(zodLocalesDir) + ? { contents: 'export { default as en } from "./en.js";', loader: "js" } + : undefined, + ); + }, +}; await build({ entryPoints: [resolve(root, "dist/index.js")], bundle: true, platform: "node", format: "esm", - outfile: resolve(root, "plugin/scripts/bundle.js"), + outfile, + plugins: [zodEnglishLocaleOnly], define: { __PLUGIN_VERSION__: JSON.stringify(pkg.version), }, }); + +const { size } = await stat(outfile); +if (size >= MAX_BUNDLE_BYTES) { + throw new Error( + `plugin/scripts/bundle.js is ${size} bytes, at or over the ${MAX_BUNDLE_BYTES}-byte limit ` + + "the Claude plugin directory reads as text. Trim dependencies instead of minifying.", + ); +} diff --git a/src/__tests__/plugin.test.ts b/src/__tests__/plugin.test.ts index 8792c1a..89f2624 100644 --- a/src/__tests__/plugin.test.ts +++ b/src/__tests__/plugin.test.ts @@ -78,6 +78,20 @@ describe("plugin/.claude-plugin/plugin.json", () => { expect(plugin.skills).toBe("./skills/"); expect(plugin.mcpServers).toBe("./.mcp.json"); }); + + // Anthropic's directory reads its listing links from these fields, and + // drops any link that isn't https://. Without them it guesses from the + // README. + it("sets https:// directory listing links", () => { + for (const field of [ + "documentationUrl", + "supportUrl", + "privacyPolicyUrl", + "termsOfServiceUrl", + ]) { + expect(plugin[field]).toMatch(/^https:\/\/\S+$/); + } + }); }); describe(".claude-plugin/marketplace.json", () => { From 2d49de393e9f4f1c2de74bff316a22b6b1f55435 Mon Sep 17 00:00:00 2001 From: Yuki Fujisaki Date: Tue, 13 Oct 2026 17:18:56 +0900 Subject: [PATCH 2/2] fix: leave the MCP SDK's ajv validator out of the bundle With the locked dependencies (zod 4.6.5, MCP SDK 1.32.0), dropping zod's extra locales still left the bundle at 1,062,693 bytes, over the 1 MiB limit, so bundle-compat failed. The SDK's server uses ajv, about 270 KB with its dependencies, only to validate form elicitation responses, and this server never elicits. Replace the SDK's ajv provider with a validator that throws if it is ever used, which brings the bundle to 781,077 bytes. A test checks that the server source doesn't use elicitation. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- plugin/README.md | 2 +- scripts/build-bundle.mjs | 22 +++++++++++++++++++++- src/__tests__/plugin.test.ts | 19 ++++++++++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 126cf97..3781062 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Tests validate structural invariants like version consistency between `package.j - ESM throughout (`"type": "module"` in package.json, `.js` extensions in imports) - TypeScript strict mode, target ES2022, Node16 module resolution - The build produces both `dist/` (tsc output) and `plugin/scripts/bundle.js` (esbuild single-file bundle); the bundle is what end users run -- `scripts/build-bundle.mjs` fails when the bundle reaches 1 MiB, the size the Claude plugin directory reads as text; a larger bundle holds every version for a reviewer. Keep the bundle readable: trim dependencies instead of minifying. It already keeps only zod's English locale. +- `scripts/build-bundle.mjs` fails when the bundle reaches 1 MiB, the size the Claude plugin directory reads as text; a larger bundle holds every version for a reviewer. Keep the bundle readable: trim dependencies instead of minifying. It already keeps only zod's English locale and leaves out the MCP SDK's ajv validator, which the SDK's server uses only for form elicitation, so the server must not use elicitation (`plugin.test.ts` checks this). - Authentication uses the device authorization code flow. `login_start` → user approves in browser → `login_wait` stores the token at `~/.config/deploygate/token` (0600). `logout` revokes server-side and deletes the file. - Skill `allowed-tools` must name each MCP tool with the plugin scope (`mcp__plugin_deploygate_deploygate__`), never a wildcard. Every entry must be on the `PRE_APPROVABLE_RULES` allowlist in `skills.test.ts`, which leaves out destructive or access-granting MCP tools, build tools (`./gradlew`, `xcodebuild`, `fastlane`), shell commands that write files or run arbitrary code, and unscoped `Write`/`Edit` (use `Edit()`, which also covers `Write`). The Claude plugin directory holds versions that pre-approve broad access, so extend the allowlist only deliberately. - The `members.ts` `add_member` tool orchestrates multiple API calls (workspace → project → team) in a single tool invocation, handling "already exists" gracefully diff --git a/plugin/README.md b/plugin/README.md index b2202bc..6222d73 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -44,7 +44,7 @@ The plugin runs a local MCP server, so its tools work in Claude Code and Cowork ## About `scripts/bundle.js` -`scripts/bundle.js` is the MCP server and its npm dependencies, mainly `@modelcontextprotocol/sdk` and `zod`, bundled into one unminified file with esbuild. It is built from the TypeScript source in [`src/`](https://github.com/DeployGate/deploygate-agent-plugin/tree/main/src) by `npm ci && npm run bundle`, and CI checks that the committed bundle matches a fresh build of the source before each release, and that it stays under 1 MiB so it can be read as text. +`scripts/bundle.js` is the MCP server and its npm dependencies, mainly `@modelcontextprotocol/sdk` and `zod`, bundled into one unminified file with esbuild. The build leaves out code the server doesn't use: zod's error messages in languages other than English, and the SDK's JSON Schema validator for form elicitation. It is built from the TypeScript source in [`src/`](https://github.com/DeployGate/deploygate-agent-plugin/tree/main/src) by `npm ci && npm run bundle`, and CI checks that the committed bundle matches a fresh build of the source before each release, and that it stays under 1 MiB so it can be read as text. ## Support diff --git a/scripts/build-bundle.mjs b/scripts/build-bundle.mjs index f3b5378..77d60b6 100644 --- a/scripts/build-bundle.mjs +++ b/scripts/build-bundle.mjs @@ -27,13 +27,33 @@ const zodEnglishLocaleOnly = { }, }; +// On the server side, the MCP SDK uses ajv (about 270 KB with its +// dependencies) only to validate form elicitation responses, and this server +// never elicits. Swap in a validator that fails loudly if that ever changes. +const sdkWithoutAjv = { + name: "sdk-without-ajv", + setup(build) { + build.onLoad( + { filter: /[\\/]@modelcontextprotocol[\\/]sdk[\\/]dist[\\/]esm[\\/]validation[\\/]ajv-provider\.js$/ }, + () => ({ + contents: `export class AjvJsonSchemaValidator { + getValidator() { + throw new Error("JSON Schema validation (ajv) is left out of the DeployGate MCP bundle; see scripts/build-bundle.mjs"); + } +}`, + loader: "js", + }), + ); + }, +}; + await build({ entryPoints: [resolve(root, "dist/index.js")], bundle: true, platform: "node", format: "esm", outfile, - plugins: [zodEnglishLocaleOnly], + plugins: [zodEnglishLocaleOnly, sdkWithoutAjv], define: { __PLUGIN_VERSION__: JSON.stringify(pkg.version), }, diff --git a/src/__tests__/plugin.test.ts b/src/__tests__/plugin.test.ts index 89f2624..ade7ca8 100644 --- a/src/__tests__/plugin.test.ts +++ b/src/__tests__/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync, existsSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; const ROOT = resolve(import.meta.dirname, "../.."); @@ -317,3 +317,20 @@ describe("version sync across release manifests", () => { expect(manifest["."]).toBe(version); }); }); + +// scripts/build-bundle.mjs leaves the MCP SDK's ajv validator out of the +// bundle, because the SDK's server uses it only to validate form elicitation +// responses. Elicitation would fail at runtime in the bundle, so the server +// must not use it. +describe("bundle build assumptions", () => { + it("server source does not use elicitation", () => { + const dirs = ["src", "src/tools"]; + for (const dir of dirs) { + for (const file of readdirSync(resolve(ROOT, dir))) { + if (!file.endsWith(".ts")) continue; + const source = readFileSync(resolve(ROOT, dir, file), "utf-8"); + expect(source, `${dir}/${file}`).not.toMatch(/elicit/i); + } + } + }); +});