Skip to content

claude_code roles load the operator's user- and project-scope MCP servers; --strict-mcp-config is only a comment #74

Description

@maaz-codes

Summary

src/lh_harness/adapters/claude_code.py:65 states that role isolation keeps unrelated MCP servers
out of the agents:

# MCP support remains opt-in. --strict-mcp-config keeps unrelated
# user/project MCP servers out of every role.

The flag is never added to command_parts. It occurs exactly once in the repository — in that
comment:

$ grep -rn 'strict-mcp-config\|strict_mcp' . --include='*.py' --include='*.md' --include='*.toml'
src/lh_harness/adapters/claude_code.py:65:        # MCP support remains opt-in. --strict-mcp-config keeps unrelated

Without it the claude CLI loads its normal MCP configuration sources, so every MCP server the
operator has configured at user scope, plus any .mcp.json sitting in the workspace, is loaded into
the manager, executor, auditor and final-response episodes.

What happens

Measured from the system/init event in each role's captured claude_stream.jsonl of a real run
(lh-harness run --agent claude_code --max-rounds 1 --no-dashboard), on a workspace containing a
.mcp.json and on an account with one unrelated user-scope server:

=== manager_episodes ===
  mcp_servers = [{'name': 'leak-probe', 'status': 'pending'},
                 {'name': '<unrelated user-scope server>', 'status': 'needs-auth'}]
=== cli_executor_episodes ===
  mcp_servers = [{'name': 'leak-probe', 'status': 'pending'},
                 {'name': '<unrelated user-scope server>', 'status': 'needs-auth'}]
=== cli_auditor_episodes ===
  mcp_servers = [{'name': 'leak-probe', 'status': 'pending'},
                 {'name': '<unrelated user-scope server>', 'status': 'needs-auth'}]
=== final_response_episodes ===
  mcp_servers = [{'name': 'leak-probe', 'status': 'pending'},
                 {'name': '<unrelated user-scope server>', 'status': 'needs-auth'}]

leak-probe is a placeholder server declared only in the workspace .mcp.json; it was never passed
through --claude-mcp-config or LH_HARNESS_CLAUDECODE_MCP_CONFIG. Note that the workspace
.mcp.json is loaded without any approval prompt, because the adapter also passes
--dangerously-skip-permissions.

Two consequences, of different weight:

  1. Executor and auditor roles can call these servers' tools. policy_for_role denies mcp__*
    for manager and final_response, but the executor deny-list is ("Agent",) and the auditor
    deny-list is the write tools plus "Agent" (src/lh_harness/adapters/claude_permissions.py:33-72).
    So for the roles that actually act, an arbitrary unrelated MCP server is reachable — including
    inside the auditor, whose value depends on it being a constrained observer.
  2. Every role starts these servers and pays for their tool definitions in each episode's context,
    for servers the run never asked for.

The read-only guard does not cover this: snapshot_workspace compares the workspace, and an MCP
server's side effects are typically not in the workspace at all.

Reproduction

No personal configuration needed:

mkdir /tmp/mcp-repro && cd /tmp/mcp-repro
printf 'alpha,3\nbeta,7\ngamma,5\n' > input.txt
cat > .mcp.json <<'JSON'
{"mcpServers": {"leak-probe": {"command": "/bin/sh", "args": ["-c", "sleep 3600"]}}}
JSON

lh-harness run --agent claude_code --model claude-sonnet-5 --max-rounds 1 --no-dashboard \
  --task 'Read input.txt. Write total.txt containing only the sum of the numbers.'

python3 - <<'PY'
import json, pathlib
for p in sorted(pathlib.Path(".lh-harness").rglob("claude_stream.jsonl")):
    for line in p.open():
        d = json.loads(line)
        if d.get("type") == "system" and d.get("subtype") == "init":
            print(p.parent.parent.name, "->", d.get("mcp_servers"))
            break
PY

Every role prints leak-probe. The same happens for user-scope servers, with no .mcp.json
present at all.

The CLI behaviour on its own, for isolation:

$ cd /tmp/mcp-repro
$ claude --print --output-format stream-json --verbose --dangerously-skip-permissions \
    --model claude-sonnet-5 <<< "Reply with OK."      # init event:
mcp_servers = [{'name': 'leak-probe', 'status': 'pending'}, ...]

$ claude ... --strict-mcp-config <<< "Reply with OK."  # init event:
mcp_servers = []

Expected behaviour

What the comment says: a role sees only the servers in an explicit --mcp-config, and no servers at
all when none is configured. Adding --strict-mcp-config to command_parts produces exactly that;
with the flag, all four roles report mcp_servers = [] on the same reproduction, and the run
reaches the same outcome (total.txt written, same status and abort_reason).

Environment

  • lh-harness 0.1.7, commit a1dd930 (current main)
  • claude 2.1.251
  • macOS 26.3 arm64, Python 3.14.6
  • --agent claude_code, --no-dashboard, no --claude-mcp-config and no
    LH_HARNESS_CLAUDECODE_MCP_CONFIG set

Related

Distinct from #61, which opts Claude Code agents out of the operator's plugins and skills via
--setting-sources project. That is a larger, opt-in feature and does not add
--strict-mcp-config; MCP servers are a separate configuration source. In the run above, with the
MCP fix applied, the plugin/skill surface is unchanged (63 slash commands, 5 sub-agents still
visible), so the two changes are complementary rather than overlapping.

I have a one-line fix plus a regression test and can open a PR.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions