feat(mcp): require projectPath when the MCP server has no default project (#993) (#1007)

When the server runs with no default project to fall back to — a gateway
server started outside any repo, or a monorepo root whose .codegraph/
indexes live only in sub-projects — every tool call must carry an explicit
projectPath. Previously projectPath was always optional, so an agent talking
to such a server would omit it, get success-shaped "pass projectPath"
guidance, and not reliably retry; the user had to nudge it by hand.

getTools() now marks projectPath required in the exposed tool schemas on the
no-default-project branch (a high-salience channel clients surface/validate,
unlike the instructions prose the reporter found too weak). When a default
project is open, projectPath stays optional and a bare call falls back to it.

The fix lives at the MCP schema layer, not the Claude-only front-load hook:
the hook is local-filesystem-based and never runs for the reporter (they're
on AGENTS.md / Codex-opencode). The proxy/getStaticTools path is untouched —
index.ts forces direct mode whenever resolveDaemonRoot is null, so the
no-default case never reaches the proxy.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-26 19:31:12 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b45f309a1b
commit d3179f5004
3 changed files with 146 additions and 1 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* No-default-project → projectPath is `required` in the tool schema (issue #993).
*
* When the MCP server has no default project to fall back to — a gateway server
* started outside any repo, or a monorepo root whose `.codegraph/` indexes live
* only in sub-projects — every tool call MUST carry an explicit `projectPath`.
* `ToolHandler.getTools()` reflects that by marking `projectPath` required in the
* exposed schemas, a high-salience nudge that gets the agent to pass it on the
* first call instead of omitting it (the reported behavior). When a default
* project IS open, projectPath stays optional: a bare call falls back to it.
*
* The change is schema-only — the runtime stays exactly as before: a missing
* projectPath with no default still returns SUCCESS-shaped guidance (never
* `isError`), and a missing projectPath WITH a default still falls back to it.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ToolHandler, tools } from '../src/mcp/tools';
import { CodeGraph } from '../src';
const ENV = 'CODEGRAPH_MCP_TOOLS';
const exploreOf = (defs: { name: string; inputSchema: { required?: string[] } }[]) =>
defs.find((t) => t.name === 'codegraph_explore')!;
describe('No-default-project requires projectPath in the schema (#993)', () => {
const originalAllowlist = process.env[ENV];
afterEach(() => {
if (originalAllowlist === undefined) delete process.env[ENV];
else process.env[ENV] = originalAllowlist;
});
it('marks projectPath required on codegraph_explore when no default project is loaded', () => {
const explore = exploreOf(new ToolHandler(null).getTools());
expect(explore.inputSchema.required).toContain('projectPath');
// The tool's own required arg is preserved, not replaced.
expect(explore.inputSchema.required).toContain('query');
});
it('requires projectPath on EVERY exposed tool, incl. ones with no prior required list', () => {
// status has no `required` array of its own → it should gain ['projectPath'].
process.env[ENV] = 'explore,node,status';
const got = new ToolHandler(null).getTools();
expect(got.map((t) => t.name).sort()).toEqual([
'codegraph_explore',
'codegraph_node',
'codegraph_status',
]);
for (const t of got) {
expect(t.inputSchema.required ?? []).toContain('projectPath');
}
});
it('does NOT mutate the shared module-level tools array (purity)', () => {
// Marking required must clone — otherwise a no-default session would corrupt
// the schema every later default-project session reuses.
new ToolHandler(null).getTools();
expect(exploreOf(tools).inputSchema.required).toEqual(['query']);
});
it('a missing projectPath with no default is still SUCCESS-shaped guidance, not isError', async () => {
// Schema-only change: the runtime backstop is unchanged. A client that
// ignores `required` still gets the nudge, never a session-souring isError.
const res = await new ToolHandler(null).execute('codegraph_explore', { query: 'anything' });
expect(res.isError).toBeUndefined();
expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/);
expect(res.content[0]!.text).toMatch(/projectPath/);
});
});
describe('A default project keeps projectPath OPTIONAL (#993)', () => {
let tempDir: string;
let cg: CodeGraph;
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-reqpath-'));
fs.writeFileSync(
path.join(tempDir, 'pay.ts'),
'export function processPayment(amount: number): boolean { return amount > 0; }\n'
);
cg = await CodeGraph.init(tempDir, { index: true });
});
afterEach(() => {
cg.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('leaves projectPath optional when a default project is loaded', () => {
const explore = exploreOf(new ToolHandler(cg).getTools());
expect(explore.inputSchema.required).toEqual(['query']);
expect(explore.inputSchema.required).not.toContain('projectPath');
});
it('a bare call (no projectPath) still falls back to the default project', async () => {
const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'processPayment' });
expect(res.isError).toBeUndefined();
// Resolved against the default project — not the no-default guidance.
expect(res.content[0]!.text).not.toMatch(/No CodeGraph project is loaded/);
expect(res.content[0]!.text).toMatch(/processPayment/);
});
});