Skip to content
Draft
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,10 @@ These apply to **every** repo you open with that agent, not just this one. The p
| `trail_repo_map` | nothing | A first look at an unfamiliar repo: directory clusters, hubs, hotspots. |
| `trail_check_freshness` | nothing | Whether the local graph has drifted from the code. |

Every tool accepts an optional `root` to query another checkout, for example `trail_find_all` with `{"pattern":"readList", "root":"/tmp/review"}`. The path may name the checkout or one of its subdirectories; relative paths resolve from the server's root. Explicit roots must share the server checkout's Git common directory, so linked worktrees are accepted while independent clones, submodules and unrelated repositories are refused. Omitting `root` keeps the server's existing root selection, including workspace queries.

Each checkout uses its own graph and in-process cache. A fresh worktree can copy its main checkout's graph and refresh it before answering, just like `trail mcp /tmp/review`. A server started with `--dir` keeps its original source root and graph directory, and refuses to switch to another checkout. Tool results end with the resolved root and its current full `HEAD` commit (`unavailable` outside Git or before the first commit). HEAD identifies the checkout; queries still include uncommitted source edits, and refresh warnings still apply. A rejected root returns a soft tool error before graph access.

The same server still answers the old `graft_*` tool names. Register it by hand if your agent needs it explicit:

```json
Expand Down
3 changes: 3 additions & 0 deletions src/ingest/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Filesystem walking used by `init`/`check` to enumerate a repo's source files.
*/
import { spawnSync } from "node:child_process";
import { gitEnvForDirectory } from "../util/git-env.js";
import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs";
import { isAbsolute, join, relative, resolve, sep } from "node:path";

Expand Down Expand Up @@ -191,6 +192,7 @@ function gitVisibleFiles(
["ls-files", "-t", "--stage", "--cached", "--others", "--exclude-standard", "-z", "--"],
{
cwd: root,
env: gitEnvForDirectory(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 64 * 1024 * 1024,
Expand Down Expand Up @@ -295,6 +297,7 @@ function gitVisibleFilesShallow(root: string, includes?: ReadonlySet<string>): s
["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--"],
{
cwd: root,
env: gitEnvForDirectory(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 64 * 1024 * 1024,
Expand Down
63 changes: 63 additions & 0 deletions src/mcp/root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/** Per-call checkout selection for a server shared by agents in linked worktrees. */
import { execFileSync } from 'node:child_process';
import { realpathSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import { gitEnvForDirectory } from '../util/git-env.js';
import { tag } from '../brand.js';

/** Git must inspect the requested directory, even when the host inherited a
* repository override (for example from a Git hook). */
function git(root: string, ...args: string[]): string {
return execFileSync('git', ['-C', root, 'rev-parse', ...args], {
env: gitEnvForDirectory(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 2000, maxBuffer: 64 * 1024,
}).trim();
}

function checkout(root: string): { root: string; common: string } {
const top = realpathSync(git(root, '--show-toplevel'));
const common = realpathSync(git(root, '--path-format=absolute', '--git-common-dir'));
return { root: top, common };
}

/** Missing `root` preserves the server's existing selection, including workspace
* and non-Git roots. Explicit paths choose a checkout, never an ancestor graph.
* Repository identity is the on-disk common Git directory: equal remote URLs
* do not grant access to an independent clone or submodule. */
export function resolveToolRoot(serverRoot: string, requested: unknown, dirOverride?: string): string {
if (requested === undefined) return serverRoot;
if (typeof requested !== 'string' || !requested.trim()) {
throw new Error('root must be a non-empty checkout path');
}
const path = resolve(serverRoot, requested);
let target: ReturnType<typeof checkout>;
let server: ReturnType<typeof checkout>;
try {
if (!statSync(path).isDirectory()) throw new Error('not a directory');
target = checkout(path);
server = checkout(serverRoot);
} catch {
throw new Error(`cannot select root ${path} — root and server must be existing Git checkouts of the same repository`);
}
if (target.common !== server.common) {
throw new Error(`refusing root ${target.root} — it is not a checkout of the server's Git repository (${server.root})`);
}
// A --dir graph is pinned to one source tree. Sharing it between worktrees
// would overwrite the parent's graph during the ordinary refresh step.
if (dirOverride !== undefined) {
if (target.root !== server.root) {
throw new Error('cannot select another root while --dir pins the graph directory — start a server without --dir to query another worktree');
}
// Preserve the source scope too: an explicit server root may be a repo
// subdirectory with a custom graph built only from that subdirectory.
return serverRoot;
}
return target.root;
}

/** Read each time: a long-lived server must notice checkout/commit changes.
* HEAD identifies the checkout, not a claim that uncommitted files are absent. */
export function rootNote(root: string): string {
let head = 'unavailable';
try { head = git(root, '--verify', 'HEAD'); } catch { /* non-Git/unborn checkout */ }
return `${tag()} root: ${resolve(root)} · HEAD: ${head}`;
}
26 changes: 22 additions & 4 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from '../graph/workspace.js';
import type { NodeV1 } from '../graph/types.js';
import { canonicalToolName } from './tool-names.js';
import { resolveToolRoot, rootNote } from './root.js';
import { cmd } from '../brand.js';

export interface ToolDef {
Expand All @@ -34,6 +35,11 @@ export interface ToolDef {
inputSchema: object;
}

const ROOT_ARGUMENT = {
type: 'string',
description: 'checkout path in the server’s Git repository (absolute or relative to the server root); defaults to the server root; cannot switch checkout with --dir',
};

const noGraph = (): string => `no graph found — run \`${cmd('graft build')}\` first`;

function unknownSymbolText(query: string): string {
Expand All @@ -48,6 +54,7 @@ export const TOOLS: ToolDef[] = [
inputSchema: {
type: 'object',
properties: {
root: ROOT_ARGUMENT,
query: { type: 'string', description: 'what you want to understand, in plain words' },
limit: { type: 'number', description: 'max results (default 5)' },
full: {
Expand All @@ -69,6 +76,7 @@ export const TOOLS: ToolDef[] = [
inputSchema: {
type: 'object',
properties: {
root: ROOT_ARGUMENT,
file: { type: 'string', description: 'repo-relative path (or unique basename) of the file' },
},
required: ['file'],
Expand All @@ -77,7 +85,7 @@ export const TOOLS: ToolDef[] = [
{
name: 'graft_check_freshness',
description: 'Report whether the committed graph is in sync with the code (drift check).',
inputSchema: { type: 'object', properties: {} },
inputSchema: { type: 'object', properties: { root: ROOT_ARGUMENT } },
},
{
name: 'graft_trace_calls',
Expand All @@ -86,6 +94,7 @@ export const TOOLS: ToolDef[] = [
inputSchema: {
type: 'object',
properties: {
root: ROOT_ARGUMENT,
symbol: { type: 'string', description: 'bare name, qualified (Class.method), or package-qualified (pkg.Fn); a file path also works' },
direction: {
type: 'string',
Expand All @@ -105,6 +114,7 @@ export const TOOLS: ToolDef[] = [
inputSchema: {
type: 'object',
properties: {
root: ROOT_ARGUMENT,
pattern: { type: 'string', description: 'regex pattern (or literal string with fixed: true)' },
in: { type: 'string', description: 'narrow to files at or under this repo-relative path prefix, e.g. server/src' },
ignore_case: { type: 'boolean', description: 'case-insensitive match' },
Expand All @@ -120,6 +130,7 @@ export const TOOLS: ToolDef[] = [
inputSchema: {
type: 'object',
properties: {
root: ROOT_ARGUMENT,
max_dirs: { type: 'number', description: 'max directory entries shown, rest counted into dropped (default 16)' },
},
},
Expand Down Expand Up @@ -220,27 +231,34 @@ export async function callTool(
args: Record<string, unknown>,
dirOverride?: string,
): Promise<{ text: string; isError: boolean }> {
let answeredRoot: string | undefined;
try {
root = resolveToolRoot(root, args.root, dirOverride);
answeredRoot = root;
const name = canonicalToolName(requestedName);
const ws = readWorkspace(root, dirOverride);
// Freshness first: an answer that cites file:line has to be about the code as
// it is right now, including edits nobody has committed (or even saved through
// this agent). ~3ms when nothing moved; a structural, $0 rebuild when it did.
// Same reason as the CLI's `noteQuery`: price this session's tokens once,
// here, so the formatters downstream can put a dollar figure in the nudge.
setInputRate(sessionInputRate(root));
let note: string | null = null;
if (!NO_REFRESH_TOOLS.has(name)) {
const r = ws
? await ensureFreshChildren(root, ws.children, { contextDir: dirOverride })
: await ensureFreshGraph(root, { contextDir: dirOverride });
note = refreshNote(r);
}
// Pricing is process-global. Set it after the refresh await, directly
// before synchronous query formatting, so concurrent roots do not cross-talk.
setInputRate(sessionInputRate(root));
const fed = ws ? await callWorkspaceTool(root, dirOverride, name, args) : null;
const res = fed ?? (await callSingleTool(root, name, args, dirOverride));
return note ? { ...res, text: `${note}\n${res.text}` } : res;
const text = note ? `${note}\n${res.text}` : res.text;
return { ...res, text: `${text}\n${rootNote(root)}` };
} catch (err) {
return { text: err instanceof Error ? err.message : String(err), isError: true };
const text = err instanceof Error ? err.message : String(err);
return { text: answeredRoot ? `${text}\n${rootNote(answeredRoot)}` : text, isError: true };
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/util/git-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Git probes with an explicit cwd must inspect that directory, even when an
* agent host inherited repository overrides (for example from a Git hook). */
export function gitEnvForDirectory(): NodeJS.ProcessEnv {
const env = { ...process.env };
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']) {
delete env[key];
}
return env;
}
43 changes: 42 additions & 1 deletion test/mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
Expand Down Expand Up @@ -153,3 +153,44 @@ test('unknown method returns -32601', async () => {
const rs = await rpc([{ jsonrpc: '2.0', id: 9, method: 'resources/list' }], dir, 1);
assert.equal(rs[0].error.code, -32601);
});


test('one MCP server answers concurrent calls from distinct same-repository checkouts', async (t) => {
const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'graft-mcpsrv-roots-')));
t.after(() => rmSync(base, { recursive: true, force: true }));
const main = join(base, 'main'); mkdirSync(main);
const git = (...args: string[]): string => execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], {
cwd: main, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
GIT_AUTHOR_NAME: 'graft test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'graft test', GIT_COMMITTER_EMAIL: 'test@example.invalid' },
}).trim();
git('init', '-b', 'main');
writeFileSync(join(main, '.gitignore'), 'graft/\n');
writeFileSync(join(main, 'x.ts'), "export function version() { return 'older-source'; }\n");
git('add', '-A'); git('commit', '-m', 'older');
const olderHead = git('rev-parse', 'HEAD');
const review = join(base, 'review'); git('worktree', 'add', '--detach', review, olderHead);
writeFileSync(join(main, 'x.ts'), "export function version() { return 'current-source'; }\n");
git('add', 'x.ts'); git('commit', '-m', 'current');
const mainHead = git('rev-parse', 'HEAD');
await buildGraph(main);
const rs = await rpc([
{ jsonrpc: '2.0', id: 1, method: 'tools/list' },
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'graft_find_all', arguments: { pattern: 'source' } } },
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'graft_find_all', arguments: { pattern: 'source', root: review } } },
], main, 3);
assert.equal(rs.length, 3);
for (const tool of rs.find((r) => r.id === 1).result.tools) assert.equal(tool.inputSchema.properties.root.type, 'string');
for (const [id, marker, absent, root, head] of [
[2, 'current-source', 'older-source', main, mainHead],
[3, 'older-source', 'current-source', review, olderHead],
] as const) {
const answer = rs.find((r) => r.id === id).result;
assert.equal(answer.isError, false);
const text = answer.content[0].text;
assert.ok(text.includes(marker), text); assert.ok(!text.includes(absent), text);
const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1);
assert.equal(footer, `[graft] root: ${realpathSync.native(root)} · HEAD: ${head}`);
}
});
Loading
Loading