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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ?? {} });
|
||||
}
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user