From e906917545c0c5600fde47980746fc940fc34eb6 Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:02:47 -0700 Subject: [PATCH] fix: use bare ${CLAUDE_PLUGIN_ROOT} so the server launches from the plugin dir This is the actual cause of the -32000 that has blocked the plugin since #2. Both earlier diagnoses were wrong. The MCP server log tells it plainly. Under the original `command: "uv"` config: Server stderr: error: Failed to spawn: `harbor-mcp` Caused by: No such file or directory (os error 2) and after #3 switched to the launcher script: Server stderr: bash: ./scripts/start-server.sh: No such file or directory Connection failed after 10ms: MCP error -32000: Connection closed Both are the same bug. `${CLAUDE_PLUGIN_ROOT:-.}` expanded to `.`, never to the plugin directory, so the server was launched against whatever project the user was in. The `:-default` form is handled by environment-variable expansion, which does not know CLAUDE_PLUGIN_ROOT, so the default always won. Only the bare form is substituted by the plugin loader. What the earlier diagnoses got wrong: - Not a uv PATH problem. "Failed to spawn: harbor-mcp" is uv's own error, so uv was found and running; it had simply resolved the wrong project. - Not a cold-start timeout. The log shows a 30000ms timeout and failures at 10-40ms, and the venv is present. Adds tests/unit/test_plugin_config.py, which fails if a :-default reappears on CLAUDE_PLUGIN_ROOT or if the referenced launcher is missing or non-executable. Verified it catches the real bug: reintroducing `:-.` fails two of its tests. This class of breakage is invisible to every other test, since it only surfaces at MCP connect time as an opaque -32000 in ~/Library/Caches/claude-cli-nodejs//mcp-logs-plugin-harbor-mcp-harbor-hub/. Bumps to 0.3.0 so installed copies actually receive it. Local development now runs ./scripts/start-server.sh directly, since .mcp.json is plugin-only; the README says so and explains why a :-default must not be added back. --- harbor-mcp/.claude-plugin/plugin.json | 10 +++- harbor-mcp/.mcp.json | 2 +- harbor-mcp/README.md | 10 +++- harbor-mcp/tests/unit/test_plugin_config.py | 55 +++++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 harbor-mcp/tests/unit/test_plugin_config.py diff --git a/harbor-mcp/.claude-plugin/plugin.json b/harbor-mcp/.claude-plugin/plugin.json index f947c17..3da456f 100644 --- a/harbor-mcp/.claude-plugin/plugin.json +++ b/harbor-mcp/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harbor-mcp", - "version": "0.2.0", + "version": "0.3.0", "description": "MCP server for the Harbor hub: inspect evaluation jobs, trials, and uploads; publish tasks and datasets when writes are enabled.", "author": { "name": "Walker Hughes" @@ -8,5 +8,11 @@ "license": "MIT", "homepage": "https://github.com/walkerhughes/mcps/tree/main/harbor-mcp", "repository": "https://github.com/walkerhughes/mcps", - "keywords": ["harbor", "evals", "mcp", "evaluation", "llm"] + "keywords": [ + "harbor", + "evals", + "mcp", + "evaluation", + "llm" + ] } diff --git a/harbor-mcp/.mcp.json b/harbor-mcp/.mcp.json index 4decf75..b74a3af 100644 --- a/harbor-mcp/.mcp.json +++ b/harbor-mcp/.mcp.json @@ -3,7 +3,7 @@ "harbor-hub": { "type": "stdio", "command": "bash", - "args": ["${CLAUDE_PLUGIN_ROOT:-.}/scripts/start-server.sh"] + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/start-server.sh"] } } } diff --git a/harbor-mcp/README.md b/harbor-mcp/README.md index 2437f94..a8246db 100644 --- a/harbor-mcp/README.md +++ b/harbor-mcp/README.md @@ -44,9 +44,15 @@ You do not need a global harbor install: harbor ships inside the plugin's enviro uv sync ``` -The same [`.mcp.json`](.mcp.json) serves both cases: it runs [`scripts/start-server.sh`](scripts/start-server.sh) at `${CLAUDE_PLUGIN_ROOT:-.}`, which resolves to the installed plugin directory when loaded as a plugin and to this directory when Claude Code is started from here. +Run the server directly: -That wrapper exists for a reason worth knowing: naming `uv` directly as the command assumes it is on whatever PATH the MCP client spawns with, and it often is not. A Homebrew `uv` lives in `/opt/homebrew/bin`, which is missing from the minimal PATH some launch contexts provide, and the server then fails with an opaque JSON-RPC `-32000` and no explanation. The wrapper searches PATH plus the common install locations, resolves the plugin root from its own location, and prints an actionable message to stderr if `uv` genuinely is not installed. +```bash +./scripts/start-server.sh +``` + +[`.mcp.json`](.mcp.json) is the **plugin** config and only works when loaded as a plugin, because `${CLAUDE_PLUGIN_ROOT}` is substituted by the plugin loader. Do not add a `:-default` to it to make local discovery work, which is what broke it for three releases: the `:-default` form is handled by environment-variable expansion, which does not know `CLAUDE_PLUGIN_ROOT`, so the default silently won and the server was launched against whatever project the user happened to be in. It failed as an opaque JSON-RPC `-32000`, visible only in `~/Library/Caches/claude-cli-nodejs//mcp-logs-plugin-harbor-mcp-harbor-hub/`. Two tests in [tests/unit/test_plugin_config.py](tests/unit/test_plugin_config.py) now guard this. + +[`scripts/start-server.sh`](scripts/start-server.sh) resolves the plugin root from its own location, finds `uv` on PATH or in the common install directories, drops an inherited `VIRTUAL_ENV`, and writes diagnostics to stderr so stdout stays a clean JSON-RPC channel. ## Tools diff --git a/harbor-mcp/tests/unit/test_plugin_config.py b/harbor-mcp/tests/unit/test_plugin_config.py new file mode 100644 index 0000000..eae1739 --- /dev/null +++ b/harbor-mcp/tests/unit/test_plugin_config.py @@ -0,0 +1,55 @@ +"""Guards on the shipped plugin config. + +These exist because a bad .mcp.json fails at MCP connect time with an opaque +-32000 that no other test can see, and the first three attempts at this file all +shipped broken. +""" + +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +MCP_CONFIG = REPO / ".mcp.json" +PLUGIN_MANIFEST = REPO / ".claude-plugin" / "plugin.json" + + +def server_config() -> dict: + return json.loads(MCP_CONFIG.read_text())["mcpServers"]["harbor-hub"] + + +def test_plugin_root_has_no_default_fallback(): + """`${CLAUDE_PLUGIN_ROOT:-.}` silently expands to `.`, not the plugin root. + + The `:-default` form is handled by env-var expansion, which does not know + CLAUDE_PLUGIN_ROOT, so the default always won and the server was launched + against the user's own project directory. Only the bare form is substituted + by the plugin loader. + """ + for arg in server_config()["args"]: + assert ":-" not in arg, ( + f"{arg!r} uses a :-default; CLAUDE_PLUGIN_ROOT must be bare or it " + "expands to the default and the server launches from the wrong " + "directory" + ) + + +def test_launcher_path_exists(): + """The referenced script must actually ship, or bash exits before the server runs.""" + args = server_config()["args"] + assert len(args) == 1, f"expected a single script argument, got {args}" + relative = args[0].replace("${CLAUDE_PLUGIN_ROOT}/", "") + script = REPO / relative + assert script.is_file(), f"{script} is referenced by .mcp.json but does not exist" + assert script.stat().st_mode & 0o111, f"{script} is not executable" + + +def test_manifest_version_matches_nothing_stale(): + """Version is the plugin cache key, so it must be a real semver triple. + + A malformed version would land users in an unexpected cache directory. + """ + version = json.loads(PLUGIN_MANIFEST.read_text())["version"] + parts = version.split(".") + assert len(parts) == 3 and all(p.isdigit() for p in parts), ( + f"version {version!r} must be MAJOR.MINOR.PATCH" + )