feat(claude): register the MCP server in user scope by default

`config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in
every checkout you opened — and in a repo that commits `.mcp.json` (this one
does) it dirtied the working tree. Every other integration is already global:
pi, prime-agent and opencode each have one active project, wired once.

Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in
every directory and window. That is now the default. `--scope project`, or an
explicit `-o/--output`, keeps the old per-directory file for a repo that wants
its own pinned project. `--inspect` stays project-scope — it is a debugging
server you turn on for one checkout.

Details worth knowing:

  - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is
    set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside
    it. Verified against a live Claude Code run with an isolated config dir.
  - `.claude.json` also holds onboarding state, caches and a per-project map
    that Claude Code rewrites while running, so this merges into the document
    and writes through a temp file + rename.
  - User scope writes no `.mcpctl-project` marker: it scopes nothing to a
    directory, and a marker beside `.claude.json` would sit in $HOME and scope
    every repo under it.
  - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that
    pinned itself wins), then user scope, then the marker.

Scope selection reads Commander's option source rather than process.argv —
argv is the test runner's command line when the command is driven in-process,
which the suite caught immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
This commit is contained in:
Michal
2026-08-09 19:59:48 +01:00
parent 834aa704ed
commit b7c0de2bf0
8 changed files with 299 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
@@ -43,8 +43,12 @@ import {
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
mergeUserScopeServer,
userScopeProject,
activeProjectIn,
claudeJsonPath,
type McpJson,
type ClaudeJson,
} from '../config/claude-mcp.js';
import {
opencodeConfigDir,
@@ -74,6 +78,23 @@ const OPENCODE_TOKEN_PREFIX = 'opencode';
* the caller starts fresh — the same behaviour as before, kept because a
* half-written file must not stop you re-provisioning.
*/
function readJsonFile<T>(path: string): T | null {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as T;
} catch {
return null;
}
}
/** Write JSON through a temp file + rename, so a crash cannot truncate it. */
function writeJsonAtomicSync(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp.${String(process.pid)}`;
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
renameSync(tmp, path);
}
function readMcpJson(path: string): McpJson | null {
if (!existsSync(path)) return null;
try {
@@ -279,7 +300,8 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.command(name)
.description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook')
.option('-p, --project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--scope <scope>', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user')
.option('-o, --output <path>', 'Project-scope output file path (implies --scope project)', '.mcp.json')
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
.option('--stdout', 'Print to stdout instead of writing a file')
.option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)')
@@ -287,7 +309,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.option('--skip-ui', 'Do not install the status line or the /mcpctl slash command')
.option('--claude-dir <path>', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)')
.option('--dry-run', 'Print what would change without writing or syncing')
.action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => {
.action(async (opts: { project?: string; scope: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }, command: Command) => {
// Resolve Claude's config dir once: an explicit --claude-dir wins, then
// $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather
// than letting each helper default) is what keeps the test suite off the
@@ -301,12 +323,41 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
return;
}
const outputPath = resolve(opts.output);
const existing = readMcpJson(outputPath);
const { config: finalConfig, retired } = mergeMcpctlServers(existing, {
...(opts.project !== undefined ? { project: opts.project } : {}),
...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}),
});
// An explicit --output only makes sense for the per-directory file, so
// it selects project scope on its own — no need to pass both.
// Commander's source tracking, not process.argv: the latter is the test
// runner's command line when the command is driven in-process.
const explicitOutput = command.getOptionValueSource('output') === 'cli';
const scope = explicitOutput ? 'project' : opts.scope;
if (scope !== 'user' && scope !== 'project') {
log(`Error: unknown --scope '${scope}' (expected 'user' or 'project')`);
process.exitCode = 1;
return;
}
const userScope = scope === 'user';
const outputPath = userScope ? claudeJsonPath() : resolve(opts.output);
const existing = userScope
? (readJsonFile<ClaudeJson>(outputPath) ?? {})
: readMcpJson(outputPath);
// `--inspect` is a project-scope idea (a debugging server you turn on
// for one checkout), so it stays on .mcp.json even in user scope.
let finalConfig: McpJson | ClaudeJson;
let retired: string[];
if (userScope) {
if (opts.project === undefined || opts.project === '') {
log('Error: --project is required for user scope (--scope project for an --inspect-only .mcp.json)');
process.exitCode = 1;
return;
}
({ config: finalConfig, retired } = mergeUserScopeServer(existing as ClaudeJson, opts.project));
} else {
({ config: finalConfig, retired } = mergeMcpctlServers(existing as McpJson, {
...(opts.project !== undefined ? { project: opts.project } : {}),
...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}),
}));
}
if (opts.stdout === true) {
log(JSON.stringify(finalConfig, null, 2));
@@ -316,10 +367,13 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (opts.dryRun === true) {
log(JSON.stringify({
claude: {
scope,
output: outputPath,
previousProject: activeProjectIn(existing),
previousProject: userScope
? userScopeProject(existing as ClaudeJson)
: activeProjectIn(existing as McpJson),
server: MCPCTL_SERVER_NAME,
entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '<unchanged>',
entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '<unchanged>',
retiredLegacyEntries: retired,
marker: opts.skipMarker === true || opts.project === undefined
? '<skipped>'
@@ -333,9 +387,14 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
return;
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
log(`Wrote ${outputPath} (${serverCount} server(s))`);
// Atomic: `.claude.json` also holds Claude Code's onboarding state and
// per-project map, and Claude Code rewrites it while running — a
// truncated write there costs far more than a stale MCP entry.
writeJsonAtomicSync(outputPath, finalConfig);
const serverCount = Object.keys(finalConfig.mcpServers ?? {}).length;
log(userScope
? `Registered '${MCPCTL_SERVER_NAME}' for every directory in ${outputPath}`
: `Wrote ${outputPath} (${String(serverCount)} server(s))`);
if (retired.length > 0) {
// Before the constant name, every project you configured stayed
// mounted alongside the new one.
@@ -344,12 +403,20 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (opts.project !== undefined) {
log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`);
}
if (userScope) {
// The whole point of user scope: you do this once, not per checkout.
log('This applies in every directory — no need to re-run it per repo.');
}
// PR-5: write project marker, run initial skills sync, install
// SessionStart hook. Skipped when --inspect-only or --skip-skills.
if (opts.project && !opts.skipSkills) {
const projectDir = dirname(outputPath);
if (opts.skipMarker === true) {
if (userScope) {
// User scope deliberately scopes nothing to a directory; writing a
// marker into $HOME would silently scope every repo under it.
log('Skipped .mcpctl-project marker (user scope is not directory-specific)');
} else if (opts.skipMarker === true) {
log('Skipped .mcpctl-project marker (--skip-marker)');
} else {
try {

View File

@@ -2,7 +2,7 @@ import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { activeProjectIn, type McpJson } from '../config/claude-mcp.js';
import { activeProjectIn, claudeJsonPath, userScopeProject, type McpJson, type ClaudeJson } from '../config/claude-mcp.js';
import { findProjectMarker } from '../utils/project-marker.js';
/**
@@ -53,6 +53,15 @@ export function resolveDirectory(input: StatusLineInput, fallback: string): stri
return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback;
}
/** The project Claude Code's user-scope config mounts, or null. */
export function projectFromUserScope(path: string): string | null {
try {
return userScopeProject(JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson);
} catch {
return null;
}
}
/** The project `.mcp.json` in `dir` mounts, or null. */
export function projectFromMcpJson(dir: string): string | null {
try {
@@ -87,7 +96,9 @@ export function createStatuslineCommand(deps?: Partial<StatuslineDeps>): Command
const input = opts.directory !== undefined ? {} : await readStdinJson();
const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd());
let project = projectFromMcpJson(dir);
// Directory-scoped wiring wins: a repo with its own .mcp.json entry has
// deliberately pinned itself, and that beats the global default.
let project = projectFromMcpJson(dir) ?? projectFromUserScope(claudeJsonPath());
if (project === null) {
// Not wired here (or wired above this directory) — the marker is the
// other thing `config claude` writes, and skills sync already trusts it.

View File

@@ -1,3 +1,6 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* `.mcp.json` shaping for `mcpctl config claude`.
*
@@ -19,7 +22,24 @@
* `isLegacyMcpctlEntry` for what counts as ours.
*/
/** The one MCP server name mcpctl owns in `.mcp.json`. */
/**
* WHERE THE ENTRY LIVES
*
* Claude Code has two MCP scopes:
* - **project** — `./.mcp.json`, which applies only in that directory (and is
* usually committed, so writing to it dirties the repo);
* - **user** — `mcpServers` in `.claude.json`, which applies in every
* directory and every window.
*
* mcpctl defaults to **user** scope, because one active project everywhere is
* how the pi, prime-agent and opencode integrations already behave — and
* because per-directory wiring means re-running `config claude` in every
* checkout you open. `--scope project` (or an explicit `--output`) keeps the
* old per-directory file for a repo that genuinely wants its own pinned
* project.
*/
/** The one MCP server name mcpctl owns. */
export const MCPCTL_SERVER_NAME = 'mcpctl';
/** Name of the optional traffic-inspection server (`--inspect`). */
@@ -131,3 +151,57 @@ export function mergeMcpctlServers(
delete rest.mcpServers;
return { config: { ...rest, mcpServers: servers }, retired };
}
/**
* Path of Claude Code's user-scope config.
*
* NOTE the asymmetry: with `CLAUDE_CONFIG_DIR` set the file is
* `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json`
* — *beside* `~/.claude/`, not inside it. Verified against a live Claude Code
* run with an isolated config dir.
*/
export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: string): string {
const override = env['CLAUDE_CONFIG_DIR'];
const home = homeDir ?? homedir();
return override !== undefined && override !== ''
? join(override, '.claude.json')
: join(home, '.claude.json');
}
/** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */
export interface ClaudeJson {
mcpServers?: Record<string, McpServerEntry>;
[key: string]: unknown;
}
/**
* Set the user-scope entry, returning the new document and any legacy
* project-named entries retired from it.
*
* `.claude.json` also holds onboarding state, caches and a per-project map that
* Claude Code rewrites constantly — so this merges into the document it was
* given and never reconstructs it.
*/
export function mergeUserScopeServer(
existing: ClaudeJson | null | undefined,
project: string,
): { config: ClaudeJson; retired: string[] } {
const doc: ClaudeJson = { ...(existing ?? {}) };
const servers: Record<string, McpServerEntry> = { ...(doc.mcpServers ?? {}) };
const retired: string[] = [];
for (const name of Object.keys(servers)) {
if (isLegacyMcpctlEntry(name, servers[name])) {
delete servers[name];
retired.push(name);
}
}
servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(project);
doc.mcpServers = servers;
return { config: doc, retired };
}
/** The project the user-scope entry mounts, or null. */
export function userScopeProject(doc: ClaudeJson | null | undefined): string | null {
return activeProjectIn({ mcpServers: doc?.mcpServers ?? {} });
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
@@ -278,3 +278,85 @@ describe('config impersonate', () => {
expect(output.join('\n')).toContain('No impersonation session to quit');
});
});
describe('config claude — user scope', () => {
let output: string[];
let tmpDir: string;
let claudeDir: string;
let prior: string | undefined;
const log = (...args: string[]): void => { output.push(args.join(' ')); };
const claudeJson = (): string => join(claudeDir, '.claude.json');
beforeEach(() => {
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-user-'));
claudeDir = join(tmpDir, 'claude-home');
mkdirSync(claudeDir, { recursive: true });
prior = process.env['CLAUDE_CONFIG_DIR'];
process.env['CLAUDE_CONFIG_DIR'] = claudeDir;
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
if (prior === undefined) delete process.env['CLAUDE_CONFIG_DIR'];
else process.env['CLAUDE_CONFIG_DIR'] = prior;
});
const cmd = () => createConfigCommand({ configDeps: {}, log });
it('registers in .claude.json by default, not a per-directory .mcp.json', async () => {
// The whole point: wire it once, not in every checkout you open.
await cmd().parseAsync(['claude', '--project', 'homeautomation', '--skip-skills', '--skip-ui'], { from: 'user' });
const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8'));
expect(parsed.mcpServers.mcpctl).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] });
expect(output.join('\n')).toContain('every directory');
});
it('preserves everything else in .claude.json', async () => {
// That file also holds onboarding state, caches and the per-project map.
writeFileSync(claudeJson(), JSON.stringify({
numStartups: 42,
mcpServers: { 'taskmaster-ai': { type: 'stdio', command: 'task-master-ai' } },
projects: { '/some/repo': { allowedTools: [] } },
}));
await cmd().parseAsync(['claude', '--project', 'p', '--skip-skills', '--skip-ui'], { from: 'user' });
const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8'));
expect(parsed.numStartups).toBe(42);
expect(parsed.projects).toEqual({ '/some/repo': { allowedTools: [] } });
expect(parsed.mcpServers['taskmaster-ai']).toBeDefined();
expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'p']);
});
it('switching re-points the one entry', async () => {
await cmd().parseAsync(['claude', '--project', 'a', '--skip-skills', '--skip-ui'], { from: 'user' });
await cmd().parseAsync(['claude', '--project', 'b', '--skip-skills', '--skip-ui'], { from: 'user' });
const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8'));
expect(Object.keys(parsed.mcpServers)).toEqual(['mcpctl']);
expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'b']);
});
it('writes no .mcpctl-project marker — user scope is not directory-specific', async () => {
// A marker beside .claude.json would sit in $HOME and scope every repo under it.
const cwd = process.cwd();
process.chdir(tmpDir);
try {
await cmd().parseAsync(['claude', '--project', 'p', '--skip-ui'], { from: 'user' });
expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false);
expect(output.join('\n')).toContain('not directory-specific');
} finally { process.chdir(cwd); }
});
it('an explicit --output still means the per-directory file', async () => {
const outPath = join(tmpDir, '.mcp.json');
await cmd().parseAsync(['claude', '--project', 'p', '-o', outPath, '--skip-skills', '--skip-ui'], { from: 'user' });
expect(existsSync(outPath)).toBe(true);
expect(existsSync(claudeJson())).toBe(false);
});
it('rejects an unknown scope instead of silently picking one', async () => {
const prevExit = process.exitCode;
await cmd().parseAsync(['claude', '--project', 'p', '--scope', 'global', '--skip-skills'], { from: 'user' });
expect(process.exitCode).toBe(1);
process.exitCode = prevExit;
expect(output.join('\n')).toContain("unknown --scope 'global'");
});
});