Skip to content

claude adapter drops cwd for source-built stdio servers: no working-directory anchor on bare installs (portable and codex both provide one) #47

Description

@ScriptedAlchemy

Package: agent-bundle claude adapter, at main 4e546516. Surfaced during final hardening of the movie-library port (its commit af0ab2a), where a copied claude artifact wrote persistent state into whatever directory the host happened to launch from.

What differs across the three adapters

Normalization gives every source-built (entry:) stdio server a plugin-root working directory in the canonical model — packages/agent-bundle/src/config/normalize.ts emits command: 'node', args: ['mcp/<name>.mjs', …], cwd: pathTokens.pluginRoot. The three adapters then diverge on what reaches the emitted config:

  • portable (src/adapters/portable.ts L159, L178): expands the token and emits cwd: "${PLUGIN_ROOT}". Anchor present.
  • codex (src/adapters/codex.ts L218–L226, L256): plugin-root cwd becomes cwd: "./", and plugin-root-prefixed command/args/env values are rewritten ./-relative against it — the anchor is load-bearing there (values embedding the plugin-root token without it are hard diagnostics). Anchor present.
  • claude (src/adapters/claude.ts L153–L161): absolutizes args[0] to ${CLAUDE_PLUGIN_ROOT}/mcp/…, then drops cwd for any server with source set:
if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) {
  args[0] = `${hookContract.commandRoot}/${args[0]}`;
}
// …
...(server.cwd === undefined || server.source !== undefined ? {} : { cwd: expandClaudeToken(server.cwd) }),

So the bundled script itself resolves (absolute arg), but the spawned process's working directory is whatever the Claude host launched from — the plugin-root anchor that both sibling targets provide is simply absent. (Command-based servers with an explicit cwd do get the field emitted, so the drop is specific to source-built entries.)

Consumer consequence

Any plugin whose runtime resolves state or assets relative to its install root gets no anchor on a bare claude install. In the movie-library port, persistent stores (per-tracker policy ledger, Trakt device-flow auth) fragmented into <launch-cwd>/.runtime per launch directory — live credentials effectively lost between sessions. The port had to fix it consumer-side twice over (commit af0ab2a):

// agent-bundle.config.ts — every server ships an env anchor, expanded per target
const withStateRootEnv = Object.fromEntries(
  Object.entries(base.mcp!.servers).map(([serverName, server]) => [
    serverName,
    { ...server, env: { MOVIE_LIBRARY_ROOT: pathTokens.pluginRoot } },
  ]),
);

The claude adapter runs env values through its token expander, so this lands as env: { MOVIE_LIBRARY_ROOT: "${CLAUDE_PLUGIN_ROOT}" } and works. As a second layer, the state-root resolver detects the artifact layout (bundle at <install>/mcp/ beside the generated manifest ⇒ root is <install>) so even an env-stripping host cannot land state in a random cwd.

What the Claude config format actually supports

Checked, and it cuts both ways:

  • The cwd field is documented in Claude's plugins reference for stdio servers, and this repo's own emitted-config schema (src/adapters/schemas/claude/mcp.schema.json) accepts it — the adapter already emits it for command-based servers.
  • But Claude Code currently ignores the field at runtime — silently, on all platforms: anthropics/claude-code#17565, #42883, #54786. The adapter's args[0] absolutization looks like a deliberate hedge against exactly that; it covers the script path but not runtime state. (It also means the cwd the adapter emits for command-based servers is a no-op upstream today.)

Suggested fix directions

  1. Adapter-provided env anchor (works today). For source-built stdio servers, have every adapter emit a well-known env var — e.g. AGENT_BUNDLE_PLUGIN_ROOT — set to its native plugin-root representation (${PLUGIN_ROOT} / ${CLAUDE_PLUGIN_ROOT} / the codex ./-relative form), and document it as the sanctioned install-root anchor for runtime code. This is exactly the consumer workaround, hoisted into the framework so every plugin doesn't rediscover it after a state-fragmentation incident.
  2. Also emit cwd: "${CLAUDE_PLUGIN_ROOT}" for source-built servers. Documented format, schema-valid, harmless, and it becomes the real fix the day Claude Code honors the field — but per the linked upstream bugs it cannot be the only fix today.
  3. Documentation-only. Bless the per-server env: { X: pathTokens.pluginRoot } pattern in the adapter docs as the claude-target equivalent of portable/codex cwd anchoring. Weakest: the failure mode it papers over is silent state fragmentation, discovered only after data lands in the wrong place.

Related: #42, #43, #44, #45 — surfaced by the same consumer port (movie-library); this one came out of its final hardening pass rather than the initial scaffold.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions