Переглянути джерело

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>
Colby Mchenry 13 годин тому
батько
коміт
d3179f5004
3 змінених файлів з 146 додано та 1 видалено
  1. 1 0
      CHANGELOG.md
  2. 104 0
      __tests__/mcp-require-project-path.test.ts
  3. 41 1
      src/mcp/tools.ts

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - CodeGraph now follows C/C++ commands that are dispatched through macro-built function-pointer tables, so the handler functions they reach are no longer dead-ends in the graph. Many C projects register a handler into a struct's function-pointer field through a macro and a generated table — redis is the classic case: every command (`getCommand`, `decrbyCommand`, …) is wired into the command struct's `proc` field by a `MAKE_CMD(…)` table that lives in a generated, `#include`-d file, then invoked as `c->cmd->proc(c)`. CodeGraph now reads those macro-built tables — including ones whose struct type is itself a macro alias, whose table sits in an `#include`-d file that is never indexed on its own, or that are wrapped in conditional compilation (`#ifdef`) and defined inline with the struct. It recognizes function-pointer fields declared through a function typedef, and follows the receiver — a chained access (`c->cmd->proc`) or an array subscript through a file-scope table (`(cmdnames[i].cmd_func)(…)`) — across field types. It also follows dispatch through a bare array of function pointers with no struct wrapper at all — the opcode/handler-table pattern common in interpreters and emulators, where a table like `opcodes[op](…)` invokes one of many registered handler functions by index — linking the dispatcher to every handler in the array. The upshot: asking for the callers or blast radius of a command handler now finds the dispatcher that reaches it. For redis, `call` shows up as a caller of every command; for SQLite, the builtin SQL functions registered through `FUNCTION(...)` link to where they're invoked; for Vim, every `:ex` and normal-mode command links from the dispatcher. (#991, extending #932)
 - CodeGraph no longer times out when many agents query it at once. The shared background server that serves all your editor and agent sessions used to run every query on a single thread, so a burst of concurrent requests — for example a swarm of subagents exploring a large monorepo together — queued up behind one another and, while the heavy ones ran, froze the connection so finished answers couldn't even be sent back until the whole batch drained. Past a handful of simultaneous callers that routinely surfaced as MCP request timeouts. The shared server now answers queries across a pool of worker threads, so concurrent requests run in parallel and the connection stays responsive the whole time; when it's genuinely saturated a call returns a brief "busy, retry shortly" note (not an error) instead of hanging past your client's timeout. The pool sizes itself to your machine — roughly one worker per core, leaving one for coordination — and a single editor session is unaffected (no pool, no overhead). Set `CODEGRAPH_QUERY_POOL_SIZE` to choose a specific number of workers, or `0` to revert to single-threaded in-process queries.
+- When CodeGraph's MCP server runs with no default project of its own — started outside any repository (for example behind an MCP gateway), or at a monorepo root whose indexes live in sub-projects — it now marks `projectPath` as a required argument on every tool call. Before, `projectPath` was always optional, so an agent talking to such a server would often omit it, get back guidance to pass it, and not reliably retry — you had to nudge it by hand every time. Now the requirement is part of the tool definition the agent sees, so it supplies the path to the project it's working on the first time. When the server does have a default project — the normal case, launched inside your repo — `projectPath` stays optional and a call without it falls back to that project exactly as before. Thanks @wauxhall for the report. (#993)
 
 ### Fixes
 

+ 104 - 0
__tests__/mcp-require-project-path.test.ts

@@ -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/);
+  });
+});

+ 41 - 1
src/mcp/tools.ts

@@ -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();