- 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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user