From ae039453ae52c214d5ce59380a22918e1ea92d89 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:29:18 -0600 Subject: [PATCH] fix(mcp): enforce roots for structured local status --- packages/gittensory-mcp/bin/gittensory-mcp.js | 3 +- test/unit/mcp-discovery.test.ts | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 0a72a236..b8fec10a 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -571,8 +571,9 @@ server.registerTool( }, async (input) => { let git = null; + const workspaceInput = await withClientWorkspaceRoots(input); try { - git = collectLocalBranchMetadata({ cwd: input.cwd ?? process.cwd(), baseRef: input.baseRef, repoFullName: input.repoFullName, login: "local" }); + git = collectLocalBranchMetadata({ cwd: workspaceInput.cwd, baseRef: input.baseRef, repoFullName: input.repoFullName, login: "local", workspaceRoots: workspaceInput.workspaceRoots }); } catch (error) { git = { error: error instanceof Error ? error.message : "local_status_failed" }; } diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts index bab8324c..9757759f 100644 --- a/test/unit/mcp-discovery.test.ts +++ b/test/unit/mcp-discovery.test.ts @@ -1,8 +1,11 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { mkdtempSync, rmSync } from "node:fs"; +import { ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); @@ -33,6 +36,48 @@ async function disconnect() { if (configDir) rmSync(configDir, { recursive: true, force: true }); } + +describe("MCP workspace root boundaries", () => { + it("applies client-advertised roots to structured local status cwd requests", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "gittensory-roots-")); + const advertisedWorkspace = join(tempRoot, "advertised-workspace"); + const privateRepo = join(tempRoot, "private-repo-outside-root"); + const localConfigDir = join(tempRoot, "config"); + mkdirSync(advertisedWorkspace, { recursive: true }); + mkdirSync(privateRepo, { recursive: true }); + mkdirSync(localConfigDir, { recursive: true }); + execFileSync("git", ["init"], { cwd: privateRepo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "security@example.com"], { cwd: privateRepo, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Security Test"], { cwd: privateRepo, stdio: "ignore" }); + + const rootedTransport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: localConfigDir, + GITTENSORY_API_TIMEOUT_MS: "1000", + }, + }); + const rootedClient = new Client({ name: "roots-boundary-test", version: "0.0.1" }, { capabilities: { roots: {} } }); + rootedClient.setRequestHandler(ListRootsRequestSchema, async () => ({ + roots: [{ uri: pathToFileURL(advertisedWorkspace).href, name: "advertised-workspace" }], + })); + + try { + await rootedClient.connect(rootedTransport); + const result = await rootedClient.callTool({ name: "gittensory_local_status_structured", arguments: { cwd: privateRepo } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + git: { error: "Selected workspace is outside the MCP roots exposed by the client." }, + }); + } finally { + await rootedClient.close().catch(() => undefined); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + describe("MCP resource discovery", () => { beforeEach(connect); afterEach(disconnect);