Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/standalone-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Ordinary top-level standalone sessions (`gjc`, `gjc --tmux`, print/text/json mod
| `.gjc/mcp.json`, `.gjc/.mcp.json` | project | Native GJC config; written by `gjc mcp add --project`. |
| `~/.gjc/agent/mcp.json`, `~/.gjc/agent/.mcp.json` | user | Native GJC config; written by `gjc mcp add`. |

User scope is the agent directory, not a fixed home path: an agent-directory profile (`GJC_CODING_AGENT_DIR`, an SDK session's `agentDir`) moves discovery, `gjc mcp add`, and the `disabledServers` denylist together, so a profile always autoloads its own registrations and never the default profile's.

Precedence per server name is deterministic: the native project scope wins over the native user scope on a name collision. Plugin-bundle MCP servers (from installed GJC plugins) override conventional servers with the same name; they are a validated, always-on product surface.

Claude Code and Codex MCP files (project `.claude/mcp.json` / `.claude/.mcp.json`, `.codex/config.toml` `[mcp_servers.*]`, and their user-global counterparts) are **import sources, not runtime authorities**: sessions never load them at startup. A bounded compatibility layer normalizes them into the same internal MCP contract, and an explicit import transaction writes the normalized definitions into the chosen `.gjc` scope (the `/extensions` import surface). `~/.claude`, `~/.codex`, and other foreign user-home configs are never read.
Expand All @@ -20,7 +22,7 @@ Claude Code and Codex MCP files (project `.claude/mcp.json` / `.claude/.mcp.json
A server is loaded at startup when all of the following hold:

- the server is not marked `enabled: false`;
- the server name is not in the `disabledServers` list of either native config scope (`~/.gjc/agent/mcp.json` or `./.gjc/mcp.json`);
- the server name is not in the `disabledServers` list of either native config scope (`<agent dir>/mcp.json` or `./.gjc/mcp.json`);
- the server is not marked `autoload: false` (autoload defaults to true; `autoload: false` keeps a server configured for on-demand `/mcp` connection);
- project-scope servers load by default; setting `mcp.enableProjectConfig` explicitly to `false` in settings disables every project-scope source for that environment.

Expand Down Expand Up @@ -60,7 +62,7 @@ There is no MCP config reload while the session runs except `/mcp reload` in ses

## Boundary

Standalone GJC does not inherit user-home MCP configurations from Claude Code, Codex, OpenCode, or other tools (`~/.claude`, `~/.codex`, and similar user-global configs are never read). MCP servers often carry credentials, filesystem reach, browser state, approval semantics, and lifecycle that belong to the configuring host. Claude/Codex MCP files are normalized only through the bounded compatibility layer on explicit import, and the only MCP config read from the user's home directory at session startup is GJC's own `~/.gjc/agent/mcp.json`.
Standalone GJC does not inherit user-home MCP configurations from Claude Code, Codex, OpenCode, or other tools (`~/.claude`, `~/.codex`, and similar user-global configs are never read). MCP servers often carry credentials, filesystem reach, browser state, approval semantics, and lifecycle that belong to the configuring host. Claude/Codex MCP files are normalized only through the bounded compatibility layer on explicit import, and the only MCP config read from the user's home directory at session startup is GJC's own `~/.gjc/agent/mcp.json` (or the active agent directory when a profile overrides it).

`--mode rpc`, `--mode rpc-ui`, `--mode bridge`, and `gjc sdk serve` have been removed. Do not use the former RPC host-tool protocol to connect an MCP server; use Coordinator MCP, the [SDK session CLI](./sdk-session-cli.md), or a managed adapter for supported external control.

Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## [Unreleased]
- Conventional MCP autoload now reads the user scope from the agent directory instead of a home-relative `<home>/.gjc/agent` path (#4767). Every writer and denylist reader already resolved user scope through `getMCPConfigPath("user")` (the agent directory), while native discovery derived it from the load context's home, so the two disagreed the moment an agent-directory profile was in play: `GJC_CODING_AGENT_DIR=<profile> gjc mcp add <name>` wrote `<profile>/mcp.json` and reported the server as loaded by ordinary sessions at startup, but startup read `~/.gjc/agent/mcp.json` — the profile's own registrations never loaded and the default profile's servers loaded into the profile instead. `loadAllMCPConfigs` accepts an `agentDir`, sessions created with their own `agentDir` pass it, and the `disabledServers` denylist follows the same scope, so discovery, `gjc mcp add`, the `/mcp` wizard, and `gjc customize doctor` all name one file. This also restores isolation for the MCP autoload suites, which established their temp user scope with `setAgentDir()`: after the trusted-home provenance rework their `os.homedir()` mock no longer reached discovery, so on a developer machine the red-team suite read the real `~/.gjc/agent/mcp.json` and in CI it found nothing.
- Esc/Ctrl+C now recover a WSL/basic-terminal session whose busy indicator outlived its turn (#4741). Both the global interrupt/clear listener and the editor escape handler treated any mounted working loader as unconditionally cancellable work: after a completed turn left the loader mounted, every press ran a no-op abort, consumed the key, and reset the escape gestures, so the composer stayed `working [esc]`/`Fetching … [esc]` and Esc, Ctrl+C, and Ctrl+X never did anything (Ctrl+Z suspend/resume was the only escape). The loading branch now stops the stale indicator via the shared activity-indicator stop and lets the key fall through to idle semantics when there is no pending submission (including a started one still inside prompt preflight, exposed through the new `hasPendingSubmission()` context query), no queued steering/follow-up/compaction messages, and the session is neither streaming nor compacting; active work (streaming, queued messages, pending optimistic or started-preflight submission) still aborts exactly as before. Recovery gates on drainable queues only: the new `AgentSession.drainableQueuedMessageCount` counts exactly the steering and follow-up entries that `clearQueue()`/`popLastQueuedMessage()`/`getQueuedMessageEntries()` return, whereas the aggregate `queuedMessageCount` also counts hidden next-turn context that no key press can drain and that deliberately survives turn completion (a `todo_write` failure reminder queued with `deliverAs: "nextTurn"` and no `triggerTurn`) — gating on the aggregate left a permanently nonzero count that reproduced the same lockout while idle. Hidden next-turn ordering and delivery are unchanged; recovery neither clears nor delivers those entries. The same correction applies to the two adjacent gates whose handlers are also visible-queue-only: `app.message.sendNow` no longer advertises itself when only hidden context is queued (its only outcome was "No visible queued message to send"), and an empty submit while streaming no longer aborts the live turn to flush a queue that holds nothing drainable.
- Bare-default Codex and Anthropic provider-overload retries now honor the configured retry ceiling instead of entering the unbounded transient path, and every replay still requires a clean retry scope after extension lifecycle handlers participate.
- `gjc accounts` command errors no longer escape as uncaught exceptions in text mode. `accounts pin` resolves its target through `resolveOAuthPinTarget`, which throws a typed `OAuthCredentialSelectorError` for user-correctable selector problems (API-key rows, active overrides, disabled or missing accounts, ambiguity), and `runAccountsCommand` rendered `AccountsCommandError` only in `--json` mode — text mode rethrew everything, so even the command's own "Provider … is not configured; no pin was written" surfaced as a stack trace plus a `gjc-crash.log` entry. Selector failures now map to `AccountsCommandError` with the message preserved (so `--json` reports `accounts-error` instead of `internal-error`), and text mode prints one clean stderr line with exit code 1. The framework's `CliParseError` handling and the JSON machine contract (exactly one document, never stacks or secrets) are unchanged.
Expand Down
5 changes: 3 additions & 2 deletions packages/coding-agent/src/capability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* - Loading items for a capability across all providers
*/
import * as path from "node:path";
import { getProjectDir, getTrustedHomeDir, logger } from "@gajae-code/utils";
import { getAgentDir, getProjectDir, getTrustedHomeDir, logger } from "@gajae-code/utils";

import type { Settings } from "../config/settings";
import { clearCache as clearFsCache, findRepoRoot, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
Expand Down Expand Up @@ -237,8 +237,9 @@ export async function loadCapability<T>(capabilityId: string, options: LoadOptio

const cwd = options.cwd ?? getProjectDir();
const home = getTrustedHomeDir();
const userAgentDir = options.agentDir ? path.resolve(options.agentDir) : getAgentDir();
const repoRoot = await findRepoRoot(cwd);
const ctx: LoadContext = { cwd, home, repoRoot };
const ctx: LoadContext = { cwd, home, userAgentDir, repoRoot };
const providers = filterProviders(capability, options);

return await loadImpl(capability, providers, ctx, options);
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/capability/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ export interface LoadContext {
cwd: string;
/** User home directory */
home: string;
/**
* GJC's user-scope config directory: the resolved agent directory
* (`getAgentDir()`), which `--agent-dir`, `GJC_CODING_AGENT_DIR` and
* `setAgentDir()` redirect away from `<home>/.gjc/agent`. `loadCapability`
* always sets it; ad-hoc contexts built for path scanning may omit it, and
* consumers then fall back to the same process-wide `getAgentDir()`.
*
* A native surface whose write path targets the agent directory resolves its
* user scope from here, or discovery reads a different file than the one the
* writer produced: `gjc mcp add` (user scope) writes `getMCPConfigPath("user")`
* under this directory. Surfaces whose writers are home-relative (skills) keep
* resolving from `home`.
*/
userAgentDir?: string;
/** Git repository root (directory containing .git), or null if not in a repo */
repoRoot: string | null;
}
Expand Down Expand Up @@ -67,6 +81,12 @@ export interface LoadOptions {
excludeProviders?: string[];
/** Custom cwd. Default: getProjectDir() */
cwd?: string;
/**
* Agent directory backing `LoadContext.userAgentDir`. Default: getAgentDir().
* Set it when loading for a session whose agent directory differs from the
* process-wide one (`createAgentSession({ agentDir })`).
*/
agentDir?: string;
/** Include items even if they fail validation. Default: false */
includeInvalid?: boolean;
/** Include items disabled via settings. Default: false */
Expand Down
23 changes: 18 additions & 5 deletions packages/coding-agent/src/discovery/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Primary provider for GJC native configs. Supports all capabilities.
*/
import * as path from "node:path";
import { logger, parseFrontmatter, tryParseJson } from "@gajae-code/utils";
import { getAgentDir, logger, parseFrontmatter, tryParseJson } from "@gajae-code/utils";
import { YAML } from "bun";
import { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
Expand Down Expand Up @@ -45,6 +45,20 @@ function getUserAgentDirs(): string[] {
return [PATHS.userAgent];
}

/**
* GJC's user-scope config directory.
*
* Home-relative `<home>/.gjc/agent` is only its default location: an agent
* directory profile (`--agent-dir`, `GJC_CODING_AGENT_DIR`, `setAgentDir()`)
* moves the whole user scope, and the writers (`getMCPConfigPath("user")` and
* everything `gjc mcp add` reaches) already follow it. Resolving from the home
* default instead would hide a profile's own registrations and load the default
* profile's servers into it.
*/
function resolveUserAgentDir(ctx: LoadContext): string {
return ctx.userAgentDir ?? getAgentDir();
}

function getProjectConfigDirs(): string[] {
return [PATHS.projectDir];
}
Expand Down Expand Up @@ -226,15 +240,14 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
return result;
};

const userAgentDir = resolveUserAgentDir(ctx);
const paths = [
...getProjectConfigDirs().flatMap(projectConfigDir => [
{ path: path.join(ctx.cwd, projectConfigDir, "mcp.json"), level: "project" as const },
{ path: path.join(ctx.cwd, projectConfigDir, ".mcp.json"), level: "project" as const },
]),
...getUserAgentDirs().flatMap(userAgentDir => [
{ path: path.join(ctx.home, userAgentDir, "mcp.json"), level: "user" as const },
{ path: path.join(ctx.home, userAgentDir, ".mcp.json"), level: "user" as const },
]),
{ path: path.join(userAgentDir, "mcp.json"), level: "user" as const },
{ path: path.join(userAgentDir, ".mcp.json"), level: "user" as const },
];

const contents = await Promise.allSettled(
Expand Down
9 changes: 8 additions & 1 deletion packages/coding-agent/src/runtime-mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface LoadMCPConfigsOptions {
nativeOnly?: boolean;
/** Load only this explicit MCP config file. */
configPath?: string;
/**
* Agent directory that holds the user scope (`<agentDir>/mcp.json`).
* Default: `getAgentDir()`. Sessions created with their own `agentDir` pass it
* so discovery and `gjc mcp add --scope user` read and write the same file.
*/
agentDir?: string;
}

/** Result of loading MCP configs */
Expand Down Expand Up @@ -140,6 +146,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
// implicit runtime authorities.
const result = await loadCapability<MCPServer>(mcpCapability.id, {
cwd,
agentDir: options?.agentDir,
providers: options?.nativeOnly === true ? ["native"] : undefined,
});
// Filter out project-level configs if disabled
Expand All @@ -150,7 +157,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
// must not abort discovery of valid servers in the other scope (the
// capability loader itself is already per-file tolerant).
const [userDisabled, projectDisabled] = await Promise.all([
readDisabledServers(getMCPConfigPath("user", cwd)).catch(() => []),
readDisabledServers(getMCPConfigPath("user", cwd, options?.agentDir)).catch(() => []),
readDisabledServers(getMCPConfigPath("project", cwd)).catch(() => []),
]);
disabledServers = new Set([...userDisabled, ...projectDisabled]);
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/src/sdk/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2314,6 +2314,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (options.enableMcpAutoload !== false) {
try {
const loaded = await loadAllMCPConfigs(cwd, {
// User scope is this session's agent directory, the same file
// `gjc mcp add` (user scope) writes; an SDK embedder that runs on
// its own agent directory autoloads its own registrations.
agentDir,
// Project-scope native config loads by default; only an
// explicitly configured `mcp.enableProjectConfig: false`
// disables it (the legacy schema default stays false for
Expand Down
9 changes: 6 additions & 3 deletions packages/coding-agent/test/mcp-autoload-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ describe("conventional MCP autoload in standalone sessions", () => {
beforeEach(async () => {
MCPManager.resetForTests();
projectDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-mcp-autoload-project-"));
agentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-mcp-autoload-agent-"));
tempHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-mcp-autoload-home-"));
// The MCP user scope is the agent directory, so `setAgentDir` is what keeps
// this test off the developer's real ~/.gjc MCP configuration.
agentDir = path.join(tempHome, ".gjc", "agent");
await fs.promises.mkdir(agentDir, { recursive: true });
setAgentDir(agentDir);
// Point the capability loader at a temp home so the test never reads or
// writes the developer's real ~/.gjc MCP configuration.
// Home-relative surfaces (skills and other convention scans) resolve from
// the mocked home.
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
authStorage = await AuthStorage.create(":memory:");
modelRegistry = new ModelRegistry(authStorage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { setAgentDir } from "@gajae-code/utils";
import { getMCPConfigPath, setAgentDir } from "@gajae-code/utils";
import type { MCPServer } from "../../src/capability/mcp";
import { normalizeClaudeMcpJson, normalizeCodexMcpToml, validateMCPCompatServer } from "../../src/discovery/mcp-compat";
import { loadAllMCPConfigs } from "../../src/runtime-mcp/config";
Expand Down Expand Up @@ -114,6 +114,60 @@ describe("conventional MCP precedence", () => {
});
});

describe("native user scope resolution", () => {
// Regression: discovery used to derive the user scope from `<home>/.gjc/agent`
// while every writer (`gjc mcp add` user scope, the `/mcp` wizard, the
// disabledServers denylist) writes `getMCPConfigPath("user")` under the agent
// directory. Under an agent-directory profile the two disagreed: the profile's
// own registrations never loaded and the default profile's servers loaded in
// their place (#4767).
it("reads the same user file `gjc mcp add --scope user` writes when the agent directory is a profile", async () => {
const profileAgentDir = path.join(tempHome, "profile-a");
await fs.mkdir(profileAgentDir, { recursive: true });
setAgentDir(profileAgentDir);

const userConfigPath = getMCPConfigPath("user", projectDir);
expect(userConfigPath).toBe(path.join(profileAgentDir, "mcp.json"));
await fs.writeFile(
userConfigPath,
JSON.stringify({ mcpServers: { profileSrv: { type: "stdio", command: "profile-bin" } } }),
);

const loaded = await loadAllMCPConfigs(projectDir, { filterExa: false, nativeOnly: true, autoloadOnly: true });
expect(Object.keys(loaded.configs)).toEqual(["profileSrv"]);
expect(loaded.sources.profileSrv.level).toBe("user");
expect(loaded.sources.profileSrv.path).toBe(userConfigPath);
});

it("honors an explicit agentDir over the process-wide one for both servers and the denylist", async () => {
const sessionAgentDir = path.join(tempHome, "profile-session");
await fs.mkdir(sessionAgentDir, { recursive: true });
await fs.writeFile(
path.join(sessionAgentDir, "mcp.json"),
JSON.stringify({
mcpServers: { sessionSrv: { type: "stdio", command: "session-bin" } },
disabledServers: ["deniedProject"],
}),
);
// The process-wide scope holds a different server that must not leak in.
await writeUserNativeConfig({ mcpServers: { globalSrv: { type: "stdio", command: "global-bin" } } });
await writeProjectConfig(".gjc/mcp.json", {
mcpServers: { deniedProject: { type: "stdio", command: "denied-bin" } },
});

const loaded = await loadAllMCPConfigs(projectDir, {
filterExa: false,
nativeOnly: true,
autoloadOnly: true,
agentDir: sessionAgentDir,
});
// `globalSrv` (process-wide scope) stays out, and `deniedProject` is dropped
// by the denylist in the session scope's own config file.
expect(Object.keys(loaded.configs)).toEqual(["sessionSrv"]);
expect(loaded.sources.sessionSrv.path).toBe(path.join(sessionAgentDir, "mcp.json"));
});
});

describe("conventional MCP filtering", () => {
it("honors enabled:false and merged user+project disabledServers lists", async () => {
await writeProjectConfig(".gjc/mcp.json", {
Expand Down
Loading
Loading