From c9c1a1fd1fc194ea87b5c381004a1ba1f460b208 Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Fri, 30 Oct 2026 05:32:14 -0500 Subject: [PATCH] fix(mcp): a projectPath naming a file resolves through its directory An agent that passed the file it was looking at as `projectPath` got `isError: true` ("Path is not a directory"), while a path that doesn't exist at all walked up to the project's `.codegraph/` fine. An error teaches an agent to stop calling codegraph, and this mistake is recoverable. `execute()` now takes an existing file to stand for its directory, right after the length check, and rewrites `args.projectPath` there, so the project gate, the worker dispatch, the worktree notice and getCodeGraph all see that directory. A path that can't be stat'd walks up as before. Path safety: the directory goes through the same checks as any projectPath, the sensitive-folder refusal on the path itself and on the root its up-walk lands on, so a file reaches exactly what its directory reaches and never more. `/etc/hosts` is refused as `/etc` is, a file in `~/.ssh` as `~/.ssh` is, and `win.ini` in the Windows folder as that folder is. A symlink inside a project that points at a file in a sensitive folder is answered from the link's own folder, as a symlinked folder already is; nothing in the sensitive folder is opened. Validation on main de0a4eb3 (Windows 11): - Red/green: the PR's 2 tests (a file in an indexed project, and a file in a linked worktree, which keeps its worktree notice) and 1 of ours fail with main's tools.ts and pass with the fix. Ours asks for a file and for its directory in 4 cases, an indexed project, `~/.ssh`, a folder in no project, and the Windows folder's `win.ini`, and requires the same answer for both, refusals included. - A real `serve --mcp` session over stdio with a private HOME: on main, codegraph_search and codegraph_status for `/src/a.ts` both returned isError "Path is not a directory"; with the fix both answer from the project, and `win.ini` is refused as a sensitive system directory. Original PR #2996 by @maxmilian. Fixes #2982 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/mcp-nested-repo-projectpath.test.ts | 13 +++ __tests__/mcp-projectpath-file.test.ts | 94 +++++++++++++++++++ src/mcp/tools.ts | 13 ++- 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 __tests__/mcp-projectpath-file.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06cc4570c..7117d9da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A long-running session, like the background server behind your agent, now notices a framework that arrives or goes while it runs, such as Express in a new `package.json`, or one app replaced by another with as many files. Files it indexes from then on are read with the frameworks the project has now; before, they were read with the ones it had when the session started, so a new Express app's routes were missing until a full `codegraph index`. Files indexed before the change are not read again, so `codegraph status` now says which frameworks changed since the last full index and recommends running one. Thanks @nk33-dev for the report and the first fix. (#2516, #2519) - On Windows, deleting the folder a link in your project points to, like a junction to a library kept outside the project, no longer makes CodeGraph's server use a full CPU core until it is restarted, or stop picking up your edits. The linked files now drop out of the index while the rest of the project keeps updating, and if the project folder itself disappears while watched, live updates turn off with a message that says so. Thanks @L0garithmic. - A project a session queries through `projectPath` is now let go once it has gone its own 10 minutes without a query. Before, when its index was still being brought up to date as the session went on to query another project, it was held, locked, until that other project's 10 minutes were up, so its own session and `codegraph index` could wait nearly twice as long to take over. Thanks @bompus. (#2087) +- An MCP tool call whose `projectPath` names a file inside a project, such as the file an agent is looking at, now answers from that project. Before, it failed with "Path is not a directory", even though a path that doesn't exist at all worked. Sensitive folders such as `/etc` are still refused when the path names a file inside them. Thanks @inth3shadows for the report and @maxmilian for the fix. (#2982) - An index left incomplete by an indexing run that was stopped partway, by a crash, an out-of-memory kill or the server's own hang check, no longer gets the background server killed with "Main thread unresponsive" every time it starts: on a large project, restoring the database indexes the run had set aside took longer than the hang check allows, so each restart died at the same point and the index was never repaired. They are now restored in the background, and the project is served once they are back. Thanks @cbeaulieu-gt for the report and the first fix. (#1887) - A new agent session no longer waits up to about 12 minutes for its first answer when the background server is stuck: a server whose process still holds its connection open but never responds made the session wait out a long series of retries. The session now gives up within seconds and answers on its own, and switches back to the background server once that has been restarted. - An MCP session whose agent stops reading its answers, while keeping the session open, now shuts down the normal way the next time it answers: it stops its work, frees the project for the next session and exits cleanly, and other sessions sharing the background server carry on. Before, that answer crashed it with "Uncaught exception: Error: EPIPE". Thanks @iiijiashu for the report and the first fix. (#2991) diff --git a/__tests__/mcp-nested-repo-projectpath.test.ts b/__tests__/mcp-nested-repo-projectpath.test.ts index aecc93df6..953c6f41f 100644 --- a/__tests__/mcp-nested-repo-projectpath.test.ts +++ b/__tests__/mcp-nested-repo-projectpath.test.ts @@ -155,6 +155,19 @@ describe('projectPath inside a nested git repository the ancestor index excludes expect(text(res)).toContain('parent-only.ts'); }); + it('gives the same #155 notice when projectPath names a file in that worktree (#2982)', async () => { + const wt = path.join(parent, 'wt'); + git(parent, 'worktree', 'add', '-q', '-b', 'feature', wt); + handler = new ToolHandler(cg); + const res = await handler.execute('codegraph_search', { + query: 'parentOnly', + projectPath: path.join(wt, 'src', 'sub.ts'), + }); + expect(res.isError).toBeUndefined(); + expect(text(res)).toContain('different git worktree'); + expect(text(res)).toContain('parent-only.ts'); + }); + it.runIf(process.platform === 'win32')('serves that linked worktree spelled with a lowercase drive letter too (#2517)', async () => { // VS Code and Cursor pass `d:\…` while git answers `D:\…`: one directory, so // the worktree is still the borrowed-index case, never a repository of its own. diff --git a/__tests__/mcp-projectpath-file.test.ts b/__tests__/mcp-projectpath-file.test.ts new file mode 100644 index 000000000..63b9b4ab7 --- /dev/null +++ b/__tests__/mcp-projectpath-file.test.ts @@ -0,0 +1,94 @@ +/** + * A `projectPath` that names a file inside an indexed project (#2982). + * + * Passing the file it is looking at is an easy agent mistake. It used to be + * refused with `isError: true` ("Path is not a directory") while a path that + * doesn't exist at all walked up to the project's `.codegraph/` fine. A file + * now resolves through its directory like any other path inside the project, + * and the sensitive-folder refusal still applies to a file inside one. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools'; + +describe('projectPath naming a file (#2982)', { timeout: 30_000 }, () => { + let project: string; + let handler: ToolHandler | undefined; + + beforeAll(async () => { + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2982-'))); + fs.mkdirSync(path.join(project, 'src')); + fs.writeFileSync(path.join(project, 'src', 'a.ts'), 'export function fileTarget() { return 1; }\n'); + const cg = await CodeGraph.init(project, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + cg.close(); + __setLoadCodeGraphForTests(CodeGraph); + }); + + afterAll(async () => { + __setLoadCodeGraphForTests(null); + if (handler) await handler.closeAll(); + fs.rmSync(project, { recursive: true, force: true }); + }); + + it('resolves a file inside the project to that project', async () => { + handler = new ToolHandler(null); + const res = await handler.execute('codegraph_search', { + query: 'fileTarget', + projectPath: path.join(project, 'src', 'a.ts'), + }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/fileTarget/); + }); + + it.runIf(process.platform !== 'win32')('still refuses a file inside a sensitive folder', async () => { + const res = await new ToolHandler(null).execute('codegraph_search', { + query: 'anything', + projectPath: '/etc/hosts', + }); + + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toMatch(/sensitive/); + }); + + // A file never reaches more than the folder holding it: whatever the folder's + // answer is (the project's results, a sensitive-folder refusal, the + // not-indexed guidance), the file gets that same answer. + it('answers a file exactly as it answers the folder holding it', async () => { + const ask = async (projectPath: string) => { + const h = new ToolHandler(null); + try { + const res = await h.execute('codegraph_search', { query: 'fileTarget', projectPath }); + return { isError: res.isError, text: res.content[0]!.text }; + } finally { + await h.closeAll(); + } + }; + const ssh = path.join(os.homedir(), '.ssh'); + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2982-outside-'))); + try { + fs.mkdirSync(ssh, { recursive: true }); + fs.writeFileSync(path.join(ssh, 'known_hosts'), 'example.com ssh-ed25519 AAAA\n'); + fs.writeFileSync(path.join(outside, 'notes.txt'), 'hi\n'); + const files = [ + path.join(project, 'src', 'a.ts'), + path.join(ssh, 'known_hosts'), + path.join(outside, 'notes.txt'), + ...(process.platform === 'win32' && fs.existsSync('C:\\Windows\\win.ini') ? ['C:\\Windows\\win.ini'] : []), + ]; + for (const file of files) { + expect({ file, ...(await ask(file)) }).toEqual({ file, ...(await ask(path.dirname(file))) }); + } + const refused = await ask(path.join(ssh, 'known_hosts')); + expect(refused.isError).toBe(true); + expect(refused.text).toMatch(/sensitive/); + } finally { + fs.rmSync(ssh, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index e92b09f54..10f965056 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -142,7 +142,7 @@ function wslSharedIndexGuidance(err: WslSharedIndexError): string { export { PathRefusalError } from '../errors'; import { PathRefusalError } from '../errors'; import { indexedHashInput } from '../file-limits'; -import { resolve as resolvePath, relative as relativePath } from 'path'; +import { resolve as resolvePath, relative as relativePath, dirname } from 'path'; /** Maximum output length to prevent context bloat (characters) */ const MAX_OUTPUT_LENGTH = 15000; @@ -3026,7 +3026,16 @@ export class ToolHandler { // serve it. Resolved on the main thread so the watcher lives here even // when dispatch is off-loaded to a worker. if (typeof pathCheck === 'string') { - await this.awaitProjectGate(pathCheck); + // A file inside a project (an agent passing the file it is looking at) + // stands for its directory, like any other path inside it (#2982). + // Rewritten here so the gate, the worker, the worktree notice and + // getCodeGraph all see the same directory. + let projectPath = pathCheck; + try { + if (statSync(pathCheck).isFile()) projectPath = dirname(pathCheck); + } catch { /* a missing path walks up to its .codegraph/ as before */ } + if (projectPath !== pathCheck) args = { ...args, projectPath }; + await this.awaitProjectGate(projectPath); } // The `path` and `pattern` properties used by codegraph_files are // also path-shaped — apply the same cap.