fix: resolve 3 community-reported bugs (#2915, #2917, #2918)

- background_output: snapshot read cursor before consuming, restore on
  /undo message removal so re-reads return data (fixes #2915)
- MCP loader: preserve oauth field in transformMcpServer, add scope/
  projectPath filtering so local-scoped MCPs only load in matching
  directories (fixes #2917)
- runtime-fallback: add 'reached your usage limit' to retryable error
  patterns so quota exhaustion triggers model fallback (fixes #2918)

Verified: bun test (4606 pass / 0 fail), tsc --noEmit clean
This commit is contained in:
YeonGyu-Kim
2026-03-29 04:53:36 +09:00
parent 9fc56ab544
commit b2497f1327
16 changed files with 575 additions and 6 deletions
@@ -10,6 +10,7 @@ import type {
} from "./types"
import { transformMcpServer } from "./transformer"
import { log } from "../../shared/logger"
import { shouldLoadMcpServer } from "./scope-filter"
interface McpConfigPath {
path: string
@@ -75,6 +76,7 @@ export async function loadMcpConfigs(
const loadedServers: LoadedMcpServer[] = []
const paths = getMcpConfigPaths()
const disabledSet = new Set(disabledMcps)
const cwd = process.cwd()
for (const { path, scope } of paths) {
const config = await loadMcpConfigFile(path)
@@ -86,6 +88,15 @@ export async function loadMcpConfigs(
continue
}
if (!shouldLoadMcpServer(serverConfig, cwd)) {
log(`Skipping MCP server "${name}" because local scope does not match cwd`, {
path,
projectPath: serverConfig.projectPath,
cwd,
})
continue
}
if (serverConfig.disabled) {
log(`Disabling MCP server "${name}"`, { path })
delete servers[name]
@@ -0,0 +1,28 @@
import { existsSync, realpathSync } from "fs"
import { resolve } from "path"
import type { ClaudeCodeMcpServer } from "./types"
function normalizePath(path: string): string {
const resolvedPath = resolve(path)
if (!existsSync(resolvedPath)) {
return resolvedPath
}
return realpathSync(resolvedPath)
}
export function shouldLoadMcpServer(
server: Pick<ClaudeCodeMcpServer, "scope" | "projectPath">,
cwd = process.cwd()
): boolean {
if (server.scope !== "local") {
return true
}
if (!server.projectPath) {
return false
}
return normalizePath(server.projectPath) === normalizePath(cwd)
}
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
const TEST_DIR = join(tmpdir(), `mcp-scope-filtering-test-${Date.now()}`)
const TEST_HOME = join(TEST_DIR, "home")
describe("loadMcpConfigs", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEST_HOME, { recursive: true })
mock.module("os", () => ({
homedir: () => TEST_HOME,
tmpdir,
}))
mock.module("../../shared", () => ({
getClaudeConfigDir: () => join(TEST_HOME, ".claude"),
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
})
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("#given user-scoped MCP entries with local scope metadata", () => {
it("#when loading configs #then only servers matching the current project path are loaded", async () => {
writeFileSync(
join(TEST_HOME, ".claude.json"),
JSON.stringify({
mcpServers: {
globalServer: {
command: "npx",
args: ["global-server"],
},
matchingLocal: {
command: "npx",
args: ["matching-local"],
scope: "local",
projectPath: TEST_DIR,
},
nonMatchingLocal: {
command: "npx",
args: ["non-matching-local"],
scope: "local",
projectPath: join(TEST_DIR, "other-project"),
},
missingProjectPath: {
command: "npx",
args: ["missing-project-path"],
scope: "local",
},
},
})
)
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const { loadMcpConfigs } = await import("./loader")
const result = await loadMcpConfigs()
expect(result.servers).toHaveProperty("globalServer")
expect(result.servers).toHaveProperty("matchingLocal")
expect(result.servers).not.toHaveProperty("nonMatchingLocal")
expect(result.servers).not.toHaveProperty("missingProjectPath")
expect(result.loadedServers.map((server) => server.name)).toEqual([
"globalServer",
"matchingLocal",
])
} finally {
process.chdir(originalCwd)
}
})
})
})
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test"
import { transformMcpServer } from "./transformer"
describe("transformMcpServer", () => {
describe("#given a remote MCP server with oauth config", () => {
it("#when transforming the server #then preserves oauth on the remote config", () => {
const transformed = transformMcpServer("remote-oauth", {
type: "http",
url: "https://mcp.example.com",
headers: { Authorization: "Bearer test" },
oauth: {
clientId: "client-id",
scopes: ["read", "write"],
},
})
expect(transformed).toEqual({
type: "remote",
url: "https://mcp.example.com",
headers: { Authorization: "Bearer test" },
oauth: {
clientId: "client-id",
scopes: ["read", "write"],
},
enabled: true,
})
})
})
})
@@ -30,6 +30,10 @@ export function transformMcpServer(
config.headers = expanded.headers
}
if (expanded.oauth && Object.keys(expanded.oauth).length > 0) {
config.oauth = expanded.oauth
}
return config
}
+9 -4
View File
@@ -1,5 +1,10 @@
export type McpScope = "user" | "project" | "local"
export interface McpOAuthConfig {
clientId?: string
scopes?: string[]
}
export interface ClaudeCodeMcpServer {
type?: "http" | "sse" | "stdio"
url?: string
@@ -7,10 +12,9 @@ export interface ClaudeCodeMcpServer {
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
oauth?: {
clientId?: string
scopes?: string[]
}
oauth?: McpOAuthConfig
scope?: McpScope
projectPath?: string
disabled?: boolean
}
@@ -29,6 +33,7 @@ export interface McpRemoteConfig {
type: "remote"
url: string
headers?: Record<string, string>
oauth?: McpOAuthConfig
enabled?: boolean
}