diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2924c7628..7b7a6199e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,12 @@ on: push: tags: - "v*.*.*" + workflow_dispatch: + inputs: + release_tag: + description: "Existing v GitHub Release to backfill with an MCPB asset" + required: true + type: string permissions: contents: write @@ -11,6 +17,7 @@ permissions: jobs: release: + if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -64,3 +71,68 @@ jobs: else gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes fi + + mcpb: + needs: release + if: >- + always() && + (github.event_name == 'workflow_dispatch' || needs.release.result == 'success') + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + steps: + - name: Check out packaging tooling + uses: actions/checkout@v4 + with: + path: .packaging-tools + + - name: Verify release tag + env: + GH_TOKEN: ${{ github.token }} + run: | + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Release tag must match v..: $RELEASE_TAG" + exit 1 + } + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + + - name: Check out release source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + path: release-source + + - uses: pnpm/action-setup@v4 + with: + version: 10.18.2 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: release-source/pnpm-lock.yaml + + - name: Verify package version + run: | + TAG_VERSION="${RELEASE_TAG#v}" + PACKAGE_VERSION=$(node -p "require('./release-source/package.json').version") + test "$PACKAGE_VERSION" = "$TAG_VERSION" || { + echo "package.json version $PACKAGE_VERSION does not match tag $RELEASE_TAG" + exit 1 + } + + - name: Install release dependencies + working-directory: release-source + run: pnpm install --frozen-lockfile + + - name: Build and validate MCPB + run: | + node .packaging-tools/scripts/prepare-mcpb.js \ + --source-dir release-source \ + --output-dir artifacts + echo "MCPB_ASSET=artifacts/harness-mcp-server-${RELEASE_TAG#v}.mcpb" >> "$GITHUB_ENV" + + - name: Upload MCPB to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "$RELEASE_TAG" "$MCPB_ASSET" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/README.md b/README.md index 628fe08ad..b8d0f55b1 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ pnpm inspect # Test with MCP Inspector ### Anthropic MCP Directory bundle -The MCPB bundle manifest lives in `[mcp-directory/](mcp-directory/)`, and the bundle icon is tracked at `[icon.png](icon.png)` in the repository root. Copy `mcp-directory/manifest.json` to the bundle root after `pnpm build` so the generated archive contains root-level `manifest.json`, `icon.png`, `build/`, `package.json`, and production `node_modules/`. +The MCPB bundle manifest lives in `[mcp-directory/](mcp-directory/)`, and the 512×512 bundle icon is tracked at `[icon.png](icon.png)` in the repository root. The packaged archive contains root-level `manifest.json`, `icon.png`, `server/`, `package.json`, `npm-shrinkwrap.json`, and production `node_modules/`. To keep the archive small, build MCPB packages from a staging directory: @@ -94,7 +94,9 @@ To keep the archive small, build MCPB packages from a staging directory: pnpm prepare:mcpb ``` -The staged package is written to `dist/mcpb/` with production dependencies installed using npm's flat layout. +The staging directory is written to `dist/mcpb/` with production dependencies installed from `npm-shrinkwrap.json` using npm's flat layout. The pinned official MCPB CLI validates it and creates `dist/harness-mcp-server-.mcpb`. + +Version tags matching `v*.*.*` publish that bundle to the corresponding GitHub Release automatically. To backfill an existing release without republishing npm, run the `Release` workflow manually with its `release_tag` input (for example, `v3.2.20`). The workflow checks out and builds that exact tag before replacing only its versioned MCPB asset. ### CLI Usage diff --git a/icon.png b/icon.png index b7f3878aa..c834322f3 100644 Binary files a/icon.png and b/icon.png differ diff --git a/manifest.json b/manifest.json index 529170096..1a568fd41 100644 --- a/manifest.json +++ b/manifest.json @@ -33,11 +33,11 @@ "prompts_generated": true, "server": { "type": "node", - "entry_point": "build/index.js", + "entry_point": "server/index.js", "mcp_config": { "command": "node", "args": [ - "${__dirname}/build/index.js", + "${__dirname}/server/index.js", "stdio" ], "env": { diff --git a/mcp-directory/README.md b/mcp-directory/README.md index ea44e6f46..e41bdfd30 100644 --- a/mcp-directory/README.md +++ b/mcp-directory/README.md @@ -1,16 +1,18 @@ # MCPB directory bundle files This directory contains the tracked metadata copied to the root of the Anthropic MCP Directory bundle. -The bundle root also includes `icon.png`, which is the same Harness logo tracked at the repository root. +The bundle root also includes `icon.png`, which is the same 512×512 Harness logo tracked at the repository root. - `manifest.json` follows MCPB manifest spec `0.3`. - `icon.png` is the bundle icon referenced by the manifest. -Build the staging directory before packing: +Build, validate, and pack the bundle: ```bash pnpm install --frozen-lockfile pnpm prepare:mcpb ``` -Pack `dist/mcpb/`, not the repository root. The staging directory contains only the files needed at runtime: `manifest.json`, `icon.png`, `build/`, `package.json`, `LICENSE`, and production `node_modules/` installed with npm's flat layout. \ No newline at end of file +The staging directory at `dist/mcpb/` contains only the files needed at runtime: `manifest.json`, `icon.png`, `server/`, `package.json`, `npm-shrinkwrap.json`, `LICENSE`, and production `node_modules/`. The versioned output is `dist/harness-mcp-server-.mcpb`. + +The `Release` GitHub Actions workflow attaches this cross-platform bundle to every `v*.*.*` GitHub Release. Its manual `release_tag` input safely backfills an existing release from that exact tag. diff --git a/mcp-directory/icon.png b/mcp-directory/icon.png index b7f3878aa..c834322f3 100644 Binary files a/mcp-directory/icon.png and b/mcp-directory/icon.png differ diff --git a/mcp-directory/manifest.json b/mcp-directory/manifest.json index 651b8c07a..f0d8cce4a 100644 --- a/mcp-directory/manifest.json +++ b/mcp-directory/manifest.json @@ -33,11 +33,11 @@ "prompts_generated": true, "server": { "type": "node", - "entry_point": "build/index.js", + "entry_point": "server/index.js", "mcp_config": { "command": "node", "args": [ - "${__dirname}/build/index.js", + "${__dirname}/server/index.js", "stdio" ], "env": { diff --git a/scripts/prepare-mcpb.js b/scripts/prepare-mcpb.js index db74b8400..c970caef6 100644 --- a/scripts/prepare-mcpb.js +++ b/scripts/prepare-mcpb.js @@ -1,42 +1,150 @@ #!/usr/bin/env node -import { copyFileSync, cpSync, existsSync, rmSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; import { spawnSync } from "node:child_process"; -const root = process.cwd(); -const outDir = join(root, "dist", "mcpb"); +export const MCPB_CLI_PACKAGE = "@anthropic-ai/mcpb@2.1.2"; -function run(command, args, options = {}) { +export function assetNameForVersion(version) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid MCPB version: ${version}`); + } + return `harness-mcp-server-${version}.mcpb`; +} + +export function normalizeBundleManifest(manifest, version) { + if (manifest.version !== version) { + throw new Error( + `Bundle manifest version ${manifest.version} does not match package version ${version}`, + ); + } + + return { + ...manifest, + server: { + ...manifest.server, + entry_point: "server/index.js", + mcp_config: { + ...manifest.server.mcp_config, + args: manifest.server.mcp_config.args.map((arg) => + arg === "${__dirname}/build/index.js" ? "${__dirname}/server/index.js" : arg, + ), + }, + }, + }; +} + +export function bundlePackageJson(packageJson) { + const directDependencies = new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.optionalDependencies ?? {}), + ]); + const overrides = Object.fromEntries( + Object.entries(packageJson.pnpm?.overrides ?? {}).filter( + ([name]) => !directDependencies.has(name), + ), + ); + + return { + name: packageJson.name, + version: packageJson.version, + description: packageJson.description, + private: true, + type: packageJson.type, + license: packageJson.license, + engines: packageJson.engines, + dependencies: packageJson.dependencies, + optionalDependencies: packageJson.optionalDependencies, + overrides, + }; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function run(command, args, cwd) { const result = spawnSync(command, args, { - cwd: options.cwd ?? root, + cwd, stdio: "inherit", shell: process.platform === "win32", - env: { ...process.env, ...options.env }, }); if (result.status !== 0) { - process.exit(result.status ?? 1); + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`); } } -rmSync(outDir, { recursive: true, force: true }); -mkdirSync(outDir, { recursive: true }); +export function prepareMcpb({ sourceDir = process.cwd(), outputDir } = {}) { + const sourceRoot = resolve(sourceDir); + const toolingRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const outputRoot = resolve(outputDir ?? join(sourceRoot, "dist")); + const stageDir = join(outputRoot, "mcpb"); + const packageJson = readJson(join(sourceRoot, "package.json")); + const manifest = normalizeBundleManifest( + readJson(join(sourceRoot, "mcp-directory", "manifest.json")), + packageJson.version, + ); + const bundlePath = join(outputRoot, assetNameForVersion(packageJson.version)); + + rmSync(stageDir, { recursive: true, force: true }); + rmSync(bundlePath, { force: true }); + mkdirSync(stageDir, { recursive: true }); -run("pnpm", ["build"]); + run("pnpm", ["build"], sourceRoot); -for (const path of ["build", "package.json", "LICENSE", "NOTICE", "icon.png"]) { - const from = join(root, path); - if (existsSync(from)) { - cpSync(from, join(outDir, path), { recursive: true }); + cpSync(join(sourceRoot, "build"), join(stageDir, "server"), { recursive: true }); + for (const path of ["npm-shrinkwrap.json", "LICENSE", "NOTICE"]) { + const from = join(sourceRoot, path); + if (existsSync(from)) { + cpSync(from, join(stageDir, path), { recursive: true }); + } } -} + cpSync(join(toolingRoot, "icon.png"), join(stageDir, "icon.png")); + writeFileSync( + join(stageDir, "package.json"), + `${JSON.stringify(bundlePackageJson(packageJson), null, 2)}\n`, + ); + writeFileSync(join(stageDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); -copyFileSync(join(root, "mcp-directory", "manifest.json"), join(outDir, "manifest.json")); + run("npm", ["ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], stageDir); + run("npx", ["--yes", MCPB_CLI_PACKAGE, "validate", join(stageDir, "manifest.json")], sourceRoot); + run("npx", ["--yes", MCPB_CLI_PACKAGE, "pack", stageDir, bundlePath], sourceRoot); + run("npx", ["--yes", MCPB_CLI_PACKAGE, "info", bundlePath], sourceRoot); + + console.error(`[mcpb] Bundle created: ${bundlePath}`); + return bundlePath; +} -run("npm", ["install", "--omit=dev", "--ignore-scripts", "--package-lock=false"], { cwd: outDir }); +function main() { + const { values } = parseArgs({ + options: { + "source-dir": { type: "string" }, + "output-dir": { type: "string" }, + }, + }); -const mcpbPath = join(root, "mcp-server.mcpb"); -rmSync(mcpbPath, { force: true }); -run("zip", ["-r", mcpbPath, "."], { cwd: outDir }); + prepareMcpb({ + sourceDir: values["source-dir"], + outputDir: values["output-dir"], + }); +} -console.error(`[mcpb] Bundle created: ${mcpbPath}`); +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (invokedPath && fileURLToPath(import.meta.url) === invokedPath) { + try { + main(); + } catch (error) { + console.error(`[mcpb] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/tasks/lessons.md b/tasks/lessons.md index 43d53d01b..97a55e59b 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,5 +1,10 @@ # Lessons Learned +## Production Shrinkwraps Need a Production Staging Manifest +- **Issue**: `npm-shrinkwrap.json` intentionally captures the production dependency tree and npm-native mirrors of the repository's pnpm security overrides. Running `npm ci --omit=dev` against that shrinkwrap and the full development `package.json` still makes npm validate missing dev dependencies and ignore `pnpm.overrides`, so MCPB staging fails even though the release shrinkwrap check is healthy. +- **Fix**: Generate a minimal staging `package.json` with runtime dependencies, optional dependencies, and transitive pnpm overrides mirrored into npm's `overrides`, then run `npm ci` against the checked-in shrinkwrap. +- **Rule**: When consuming a production-only shrinkwrap outside npm publish, pair it with the same production manifest shape used to generate it; `--omit=dev` changes installation, not lockfile validation. + ## List-Filter Enums Must Be Canonicalized at Dispatch - **Issue**: `listFilterFields.enum` is only visible via `harness_describe`. The global `harness_list` schema cannot encode per-resource enums, so agents often send lowercase (`pending`) while APIs require PascalCase/UPPERCASE. Those 400s count as `tool_error` and can page on-call. - **Fix**: `canonicalizeListFilterEnums` in `Registry.dispatch` rewrites case-insensitive matches to declared enum values (including comma-separated tokens). Also clarify that some resources have a lower `size` max than the global 1–100 tool schema. diff --git a/tests/release-metadata.test.ts b/tests/release-metadata.test.ts index 827787313..aebd38b1f 100644 --- a/tests/release-metadata.test.ts +++ b/tests/release-metadata.test.ts @@ -1,13 +1,21 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { + MCPB_CLI_PACKAGE, + assetNameForVersion, + bundlePackageJson, + normalizeBundleManifest, +} from "../scripts/prepare-mcpb.js"; const root = process.cwd(); interface BundleManifest { version: string; server: { + entry_point: string; mcp_config: { + args: string[]; env: Record; }; }; @@ -29,6 +37,52 @@ describe("release metadata", () => { expect(directoryManifest.version).toBe(packageJson.version); }); + it("uses a relocatable server entry point in packaged manifests", () => { + for (const manifest of [readJson("manifest.json"), readJson("mcp-directory/manifest.json")]) { + expect(manifest.server.entry_point).toBe("server/index.js"); + expect(manifest.server.mcp_config.args).toEqual([ + "${__dirname}/server/index.js", + "stdio", + ]); + } + }); + + it("keeps bundle naming stable and can normalize legacy release manifests", () => { + const legacyManifest = readJson("mcp-directory/manifest.json"); + legacyManifest.server.entry_point = "build/index.js"; + legacyManifest.server.mcp_config.args[0] = "${__dirname}/build/index.js"; + + expect(assetNameForVersion("3.2.20")).toBe("harness-mcp-server-3.2.20.mcpb"); + expect(MCPB_CLI_PACKAGE).toBe("@anthropic-ai/mcpb@2.1.2"); + expect(normalizeBundleManifest(legacyManifest, "3.2.20").server).toMatchObject({ + entry_point: "server/index.js", + mcp_config: { args: ["${__dirname}/server/index.js", "stdio"] }, + }); + }); + + it("creates an npm-ci-compatible production package manifest", () => { + const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const bundled = bundlePackageJson(packageJson); + + expect(bundled).not.toHaveProperty("devDependencies"); + expect(bundled).not.toHaveProperty("scripts"); + expect(bundled.dependencies).toEqual(packageJson.dependencies); + expect(bundled.optionalDependencies).toEqual(packageJson.optionalDependencies); + expect(bundled.overrides.sharp).toBe(packageJson.pnpm.overrides.sharp); + expect(bundled.overrides).not.toHaveProperty("hono"); + }); + + it("ships matching 512×512 directory icons", () => { + const icons = [readFileSync(join(root, "icon.png")), readFileSync(join(root, "mcp-directory/icon.png"))]; + + for (const icon of icons) { + expect(icon.subarray(1, 4).toString("ascii")).toBe("PNG"); + expect(icon.readUInt32BE(16)).toBe(512); + expect(icon.readUInt32BE(20)).toBe(512); + } + expect(icons[0].equals(icons[1])).toBe(true); + }); + it("exposes FME config in packaged manifests", () => { for (const manifest of [readJson("manifest.json"), readJson("mcp-directory/manifest.json")]) { expect(manifest.server.mcp_config.env.HARNESS_FME_API_KEY).toBe("${user_config.HARNESS_FME_API_KEY}"); diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 7d35a43bf..74def5764 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -6,11 +6,25 @@ import { describe, expect, it } from "vitest"; type WorkflowStep = { name?: string; run?: string; + uses?: string; + with?: Record; }; type ReleaseWorkflow = { + on: { + push: { tags: string[] }; + workflow_dispatch: { + inputs: { release_tag: { required: boolean; type: string } }; + }; + }; jobs: { release: { + if: string; + steps: WorkflowStep[]; + }; + mcpb: { + if: string; + needs: string; steps: WorkflowStep[]; }; }; @@ -22,8 +36,12 @@ function readReleaseWorkflow(): ReleaseWorkflow { return parse(readFileSync(join(root, ".github/workflows/release.yml"), "utf8")) as ReleaseWorkflow; } -function runScriptForStep(workflow: ReleaseWorkflow, stepName: string): string { - const step = workflow.jobs.release.steps.find((candidate) => candidate.name === stepName); +function runScriptForStep( + workflow: ReleaseWorkflow, + jobName: keyof ReleaseWorkflow["jobs"], + stepName: string, +): string { + const step = workflow.jobs[jobName].steps.find((candidate) => candidate.name === stepName); expect(step, `Expected release workflow to include step ${stepName}`).toBeDefined(); expect(step?.run, `Expected release workflow step ${stepName} to run shell commands`).toBeDefined(); return step?.run ?? ""; @@ -32,7 +50,7 @@ function runScriptForStep(workflow: ReleaseWorkflow, stepName: string): string { describe("release workflow", () => { it("does not fail when the package version is already published to npm", () => { const workflow = readReleaseWorkflow(); - const script = runScriptForStep(workflow, "Publish to npm"); + const script = runScriptForStep(workflow, "release", "Publish to npm"); expect(script).toContain("npm view \"$PKG_NAME@$PKG_VERSION\" version"); expect(script).toContain("npm publish"); @@ -41,10 +59,38 @@ describe("release workflow", () => { it("does not fail when the GitHub Release already exists", () => { const workflow = readReleaseWorkflow(); - const script = runScriptForStep(workflow, "Create GitHub Release"); + const script = runScriptForStep(workflow, "release", "Create GitHub Release"); expect(script).toContain("gh release view \"$GITHUB_REF_NAME\""); expect(script).toContain("gh release create \"$GITHUB_REF_NAME\""); expect(script).toContain("already exists"); }); + + it("publishes versioned MCPB assets for tags and exact-tag backfills", () => { + const workflow = readReleaseWorkflow(); + const verifyTag = runScriptForStep(workflow, "mcpb", "Verify release tag"); + const verifyVersion = runScriptForStep(workflow, "mcpb", "Verify package version"); + const build = runScriptForStep(workflow, "mcpb", "Build and validate MCPB"); + const upload = runScriptForStep(workflow, "mcpb", "Upload MCPB to GitHub Release"); + const sourceCheckout = workflow.jobs.mcpb.steps.find( + (step) => step.name === "Check out release source", + ); + + expect(workflow.on.push.tags).toEqual(["v*.*.*"]); + expect(workflow.on.workflow_dispatch.inputs.release_tag).toMatchObject({ + required: true, + type: "string", + }); + expect(workflow.jobs.release.if).toBe("github.event_name == 'push'"); + expect(workflow.jobs.mcpb.needs).toBe("release"); + expect(workflow.jobs.mcpb.if).toContain("github.event_name == 'workflow_dispatch'"); + expect(verifyTag).toContain("^v[0-9]+"); + expect(verifyTag).toContain('gh release view "$RELEASE_TAG"'); + expect(sourceCheckout?.with?.ref).toBe("${{ env.RELEASE_TAG }}"); + expect(verifyVersion).toContain('PACKAGE_VERSION=$(node -p'); + expect(build).toContain("--source-dir release-source"); + expect(build).toContain("harness-mcp-server-${RELEASE_TAG#v}.mcpb"); + expect(upload).toContain('gh release upload "$RELEASE_TAG" "$MCPB_ASSET"'); + expect(upload).toContain("--clobber"); + }); });