Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
release_tag:
description: "Existing v<semver> GitHub Release to backfill with an MCPB asset"
required: true
type: string

permissions:
contents: write
id-token: write

jobs:
release:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -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<major>.<minor>.<patch>: $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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,17 @@ 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:

```bash
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-<version>.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

Expand Down
Binary file modified icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 5 additions & 3 deletions mcp-directory/README.md
Original file line number Diff line number Diff line change
@@ -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.
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-<version>.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.
Binary file modified mcp-directory/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions mcp-directory/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
152 changes: 130 additions & 22 deletions scripts/prepare-mcpb.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions tasks/lessons.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading