Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions __tests__/mcp-nested-repo-projectpath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions __tests__/mcp-projectpath-file.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
13 changes: 11 additions & 2 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down