Merge pull request #3054 from code-yeongyu/fix/p0-6-mcp-local-scope-subdirectory
Fix local MCP scope loading in subdirectories
This commit is contained in:
@@ -1,17 +1,6 @@
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { resolve } from "path"
|
||||
import { containsPath } from "../../shared/contains-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()
|
||||
@@ -24,5 +13,5 @@ export function shouldLoadMcpServer(
|
||||
return false
|
||||
}
|
||||
|
||||
return normalizePath(server.projectPath) === normalizePath(cwd)
|
||||
return containsPath(server.projectPath, cwd)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
import { shouldLoadMcpServer } from "./scope-filter"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `mcp-scope-filtering-test-${Date.now()}`)
|
||||
const TEST_HOME = join(TEST_DIR, "home")
|
||||
@@ -27,6 +28,56 @@ describe("loadMcpConfigs", () => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given local MCP scope checks", () => {
|
||||
it("#when cwd exactly matches project path #then the server is loaded", () => {
|
||||
const result = shouldLoadMcpServer(
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: "/tmp/repo",
|
||||
},
|
||||
"/tmp/repo"
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when cwd is a subdirectory of project path #then the server is loaded", () => {
|
||||
const result = shouldLoadMcpServer(
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: "/tmp/repo",
|
||||
},
|
||||
"/tmp/repo/packages/app"
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#when cwd does not overlap project path #then the server is not loaded", () => {
|
||||
const result = shouldLoadMcpServer(
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: "/tmp/repo",
|
||||
},
|
||||
"/tmp/other"
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("#when cwd is the parent of project path #then the server is not loaded", () => {
|
||||
const result = shouldLoadMcpServer(
|
||||
{
|
||||
scope: "local",
|
||||
projectPath: "/tmp/repo",
|
||||
},
|
||||
"/tmp"
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
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(
|
||||
|
||||
@@ -6,12 +6,14 @@ import type { LoadedPlugin } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `plugin-mcp-loader-test-${Date.now()}`)
|
||||
const PROJECT_DIR = join(TEST_DIR, "project")
|
||||
const PROJECT_SUBDIRECTORY = join(PROJECT_DIR, "packages", "app")
|
||||
const PLUGIN_DIR = join(TEST_DIR, "plugin")
|
||||
const MCP_CONFIG_PATH = join(PLUGIN_DIR, "mcp.json")
|
||||
|
||||
describe("loadPluginMcpServers", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(PROJECT_DIR, { recursive: true })
|
||||
mkdirSync(PROJECT_SUBDIRECTORY, { recursive: true })
|
||||
mkdirSync(PLUGIN_DIR, { recursive: true })
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
@@ -24,7 +26,7 @@ describe("loadPluginMcpServers", () => {
|
||||
})
|
||||
|
||||
describe("#given plugin MCP entries with local scope metadata", () => {
|
||||
it("#when loading plugin MCP servers #then only entries matching the current cwd are included", async () => {
|
||||
it("#when loading plugin MCP servers from a project subdirectory #then only entries within the same project are included", async () => {
|
||||
writeFileSync(
|
||||
MCP_CONFIG_PATH,
|
||||
JSON.stringify({
|
||||
@@ -45,6 +47,12 @@ describe("loadPluginMcpServers", () => {
|
||||
scope: "local",
|
||||
projectPath: join(PROJECT_DIR, "other-project"),
|
||||
},
|
||||
parentLocal: {
|
||||
command: "npx",
|
||||
args: ["parent-plugin-local"],
|
||||
scope: "local",
|
||||
projectPath: join(PROJECT_SUBDIRECTORY, "nested-project"),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -59,7 +67,7 @@ describe("loadPluginMcpServers", () => {
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(PROJECT_DIR)
|
||||
process.chdir(PROJECT_SUBDIRECTORY)
|
||||
|
||||
try {
|
||||
const { loadPluginMcpServers } = await import("./mcp-server-loader")
|
||||
@@ -68,6 +76,7 @@ describe("loadPluginMcpServers", () => {
|
||||
expect(servers).toHaveProperty("demo-plugin:globalServer")
|
||||
expect(servers).toHaveProperty("demo-plugin:matchingLocal")
|
||||
expect(servers).not.toHaveProperty("demo-plugin:nonMatchingLocal")
|
||||
expect(servers).not.toHaveProperty("demo-plugin:parentLocal")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { spawn } from "bun";
|
||||
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
|
||||
import { extractZip } from "./zip-extractor";
|
||||
|
||||
function isTarTraversalErrorOutput(output: string): boolean {
|
||||
return /path contains '\.\.'|member name contains '\.\.'|removing leading [`'\"]?\.\.\//i.test(output)
|
||||
}
|
||||
|
||||
export function getCachedBinaryPath(cacheDir: string, binaryName: string): string | null {
|
||||
const binaryPath = path.join(cacheDir, binaryName);
|
||||
return existsSync(binaryPath) ? binaryPath : null;
|
||||
@@ -43,6 +47,11 @@ export async function extractTarGz(
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (isTarTraversalErrorOutput(stderr)) {
|
||||
throw new Error(`Unsafe archive entry: path contains path traversal (${archivePath})`)
|
||||
}
|
||||
|
||||
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +111,10 @@ async function listTarEntries(archivePath: string, cwd?: string): Promise<Archiv
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
if (isTarTraversalErrorOutput(stderr)) {
|
||||
throw new Error(`Unsafe archive entry: path contains path traversal (${archivePath})`)
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`tar entry listing failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
function findNearestExistingAncestor(resolvedPath: string): string {
|
||||
let candidatePath = resolvedPath
|
||||
|
||||
while (!existsSync(candidatePath)) {
|
||||
const parentPath = dirname(candidatePath)
|
||||
|
||||
if (parentPath === candidatePath) {
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
candidatePath = parentPath
|
||||
}
|
||||
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
function toCanonicalPath(pathToNormalize: string): string {
|
||||
const resolvedPath = resolve(pathToNormalize)
|
||||
|
||||
@@ -12,12 +28,13 @@ function toCanonicalPath(pathToNormalize: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(resolvedPath)
|
||||
const canonicalParentDirectory = existsSync(parentDirectory)
|
||||
? realpathSync.native(parentDirectory)
|
||||
: parentDirectory
|
||||
const nearestExistingAncestor = findNearestExistingAncestor(resolvedPath)
|
||||
const canonicalAncestor = existsSync(nearestExistingAncestor)
|
||||
? realpathSync.native(nearestExistingAncestor)
|
||||
: nearestExistingAncestor
|
||||
const relativePathFromAncestor = relative(nearestExistingAncestor, resolvedPath)
|
||||
|
||||
return normalize(join(canonicalParentDirectory, basename(resolvedPath)))
|
||||
return normalize(join(canonicalAncestor, relativePathFromAncestor || basename(resolvedPath)))
|
||||
}
|
||||
|
||||
export function containsPath(rootPath: string, candidatePath: string): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "bun"
|
||||
import { spawn, spawnSync } from "bun"
|
||||
|
||||
import type { ArchiveEntry } from "./archive-entry-validator"
|
||||
|
||||
@@ -48,6 +48,56 @@ export async function listZipEntriesWithTar(archivePath: string): Promise<Archiv
|
||||
.filter((entry): entry is ArchiveEntry => entry !== null)
|
||||
}
|
||||
|
||||
export function isPythonZipListingAvailable(): boolean {
|
||||
const proc = spawnSync(["python3", "--version"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
return proc.exitCode === 0
|
||||
}
|
||||
|
||||
export async function listZipEntriesWithPython(archivePath: string): Promise<ArchiveEntry[]> {
|
||||
const script = [
|
||||
"import json, stat, sys, zipfile",
|
||||
"entries = []",
|
||||
"with zipfile.ZipFile(sys.argv[1], 'r') as archive:",
|
||||
" for info in archive.infolist():",
|
||||
" mode = (info.external_attr >> 16) & 0xFFFF",
|
||||
" if stat.S_ISLNK(mode):",
|
||||
" entry_type = 'symlink'",
|
||||
" link_path = archive.read(info).decode('utf-8', 'surrogateescape')",
|
||||
" elif info.filename.endswith('/'):",
|
||||
" entry_type = 'directory'",
|
||||
" link_path = None",
|
||||
" else:",
|
||||
" entry_type = 'file'",
|
||||
" link_path = None",
|
||||
" entry = {'path': info.filename, 'type': entry_type}",
|
||||
" if link_path is not None:",
|
||||
" entry['linkPath'] = link_path",
|
||||
" entries.append(entry)",
|
||||
"print(json.dumps(entries))",
|
||||
].join("\n")
|
||||
|
||||
const proc = spawn(["python3", "-c", script, archivePath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
|
||||
return JSON.parse(stdout) as ArchiveEntry[]
|
||||
}
|
||||
|
||||
export async function listZipEntriesWithPowerShell(
|
||||
archivePath: string,
|
||||
escapePowerShellPath: (path: string) => string,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { spawn, spawnSync } from "bun"
|
||||
import { release } from "os"
|
||||
|
||||
import { validateArchiveEntries } from "./archive-entry-validator"
|
||||
import { listZipEntriesWithPowerShell, listZipEntriesWithTar } from "./zip-entry-listing"
|
||||
import {
|
||||
isPythonZipListingAvailable,
|
||||
listZipEntriesWithPowerShell,
|
||||
listZipEntriesWithPython,
|
||||
listZipEntriesWithTar,
|
||||
} from "./zip-entry-listing"
|
||||
|
||||
const WINDOWS_BUILD_WITH_TAR = 17134
|
||||
|
||||
@@ -98,5 +103,9 @@ async function listZipEntries(archivePath: string) {
|
||||
return listZipEntriesWithPowerShell(archivePath, escapePowerShellPath, extractor)
|
||||
}
|
||||
|
||||
if (isPythonZipListingAvailable()) {
|
||||
return listZipEntriesWithPython(archivePath)
|
||||
}
|
||||
|
||||
return listZipEntriesWithTar(archivePath)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user