diff --git a/CHANGELOG.md b/CHANGELOG.md index f58218b4c..37b2164c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- 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. (#2982) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) 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..f8e38aa06 --- /dev/null +++ b/__tests__/mcp-projectpath-file.test.ts @@ -0,0 +1,57 @@ +/** + * 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/); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 89fa53101..ab06e00e3 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; @@ -2941,7 +2941,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.