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
+41 -1
View File
@@ -655,6 +655,36 @@ export const tools: ToolDefinition[] = [
},
];
/**
* Return `defs` with `projectPath` marked `required` in each tool's inputSchema.
*
* Used for the NO-DEFAULT-PROJECT tool surface (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 call MUST carry an explicit `projectPath`, so the schema should say so.
* A `required` field is a HIGH-salience channel (MCP clients surface and often
* validate it), unlike the instructions text the reporter found too weak to stop
* the agent omitting the param. When a default project IS open, callers leave
* projectPath optional and never call this.
*
* Pure: clones each tool's schema rather than mutating the shared module-level
* `tools` array (reused by every session and the static surface). A tool that
* doesn't expose projectPath, or already requires it, is returned untouched;
* explore's `['query']` becomes `['query', 'projectPath']`, and a tool with no
* `required` list (status/files) gains `['projectPath']`.
*/
function withRequiredProjectPath(defs: ToolDefinition[]): ToolDefinition[] {
return defs.map((tool) => {
if (!tool.inputSchema.properties.projectPath) return tool;
const required = tool.inputSchema.required ?? [];
if (required.includes('projectPath')) return tool;
return {
...tool,
inputSchema: { ...tool.inputSchema, required: [...required, 'projectPath'] },
};
});
}
/**
* Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
* proxy answers `tools/list` with before any project is open. Mirrors
@@ -835,7 +865,17 @@ export class ToolHandler {
let visible = allow
? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
: tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
if (!this.cg) return visible;
// No default project loaded → no-root-index case (#993): a gateway server
// started outside any repo, or a monorepo root whose indexes live in
// sub-projects. With nothing to fall back to, EVERY call needs an explicit
// projectPath, so mark it required in the schema — a high-salience nudge the
// agent acts on, where SERVER_INSTRUCTIONS_NO_ROOT_INDEX's prose alone
// wasn't enough (the reporter had to add an AGENTS.md note). `this.cg` is
// settled by `retryInitIfNeeded()` before `handleToolsList` calls us, so a
// null here means "genuinely no default", not a startup race. When a default
// IS open we leave projectPath optional (below): a bare call falls back to
// it, exactly as in the common single-project launch.
if (!this.cg) return withRequiredProjectPath(visible);
try {
const stats = this.cg.getStats();