Procházet zdrojové kódy

feat(mcp): unindexed sessions go quiet — empty tools/list + inactive instructions, no-error policy (#769) (#817)

An MCP session in a workspace with no .codegraph/ previously got the full
"lean on codegraph for everything" playbook plus all 8 tools, then every
call returned isError — and one or two early errors teach an agent to
abandon codegraph for the whole session (maintainer-observed). Now the
initialize response picks an instructions variant by index state (cheap
sync walk-up, #172 respond-fast contract holds) and tools/list serves an
EMPTY list when unindexed: absence is the one signal an agent can't
misread. Indexing is deliberately the user's call — the inactive note
tells the agent not to run init itself.

No-error policy in the tool handler: expected/recoverable conditions
(NotIndexedError — cross-project query to an unindexed path, default-
project detection miss) return SUCCESS-shaped guidance instead of
isError; security refusals (PathRefusalError) stay hard errors without
retry encouragement; genuine internal failures keep isError but add a
retry-once note so a transient blip doesn't convert to permanent
abandonment. Principle recorded in CLAUDE.md.

Also: codegraph_search kind:"type" (advertised by its own schema enum)
silently matched nothing — now maps to type_alias; codegraph_explore's
query param no longer tells agents to run codegraph_search first
(contradicted explore's call-FIRST design); server-instructions
§Limitations rewords the unindexed case to stay-out-for-the-session.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Colby Mchenry před 1 týdnem
rodič
revize
f9fcc2cd6a
6 změnil soubory, kde provedl 312 přidání a 12 odebrání
  1. 2 0
      CHANGELOG.md
  2. 1 0
      CLAUDE.md
  3. 212 0
      __tests__/mcp-unindexed.test.ts
  4. 22 1
      src/mcp/server-instructions.ts
  5. 23 3
      src/mcp/session.ts
  6. 52 8
      src/mcp/tools.ts

+ 2 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769)
 - **Astro projects are now indexed.** `.astro` files previously weren't parsed at all — on a typical Astro site that left most of the codebase invisible to search, impact, and `codegraph_explore`. CodeGraph now extracts the TypeScript frontmatter (functions, imports, `getStaticPaths`, …) and client-side `<script>` blocks, captures function calls and `<Component>` usages in template markup so cross-component dependencies trace end-to-end, resolves the `Astro` global and `astro:*` module imports as framework-provided, and maps `src/pages/` file-based routing to route nodes (`.astro` pages and `.ts` endpoints, including `[param]` and `[...rest]` dynamic segments, with underscore-prefixed files correctly excluded). Validated on two real-world Astro sites with 93% measured cross-file coverage and every page mapping to its route. Thanks @xingwangzhe. (#768) (Astro)
 - Same-named symbols across a monorepo's apps are no longer conflated. In a NestJS-style workspace with one `UserService` per app, `codegraph_callers`, `codegraph_callees`, and `codegraph_impact` now report **one section per distinct definition** — each app's callers and blast radius under its own file-labeled heading — instead of a single merged list, and accept a `file` argument to focus exactly the definition you mean (like `codegraph_node` already did). Impact in particular no longer overstates a change's blast radius by merging unrelated same-named classes. Thanks @Igorgro. (#764)
 - Fixed a related source of cross-package wrong edges: PascalCase **type references from plain `.ts` files were being resolved as React components**, which could link a file's own type alias to an arbitrary same-named class in another package (on one large monorepo this produced over a thousand wrong cross-package reference edges; 96% are now gone, and the remainder are genuine shared-model imports). Component resolution now applies only to references from JSX-capable files and never guesses between multiple candidates without a positional signal. The **Svelte and Vue component resolvers had the same arbitrary-pick flaw** (Vue resolved the first same-named `.vue` file found anywhere in the tree) and now follow the same rule: same-directory first, otherwise only an unambiguous name resolves. Re-index a project to benefit. (#764) (TypeScript, React, Svelte, Vue)
@@ -39,6 +40,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- The `codegraph_search` tool's `kind: "type"` filter — a value its own schema advertises — silently matched nothing; it now correctly finds type aliases. The `codegraph_explore` tool's parameter guidance also no longer suggests running `codegraph_search` first, which contradicted explore's call-it-first design and cost agents an extra round-trip.
 - Symbols defined in Svelte and Vue `<script>` blocks were reported one line below where they actually are — a function on line 3 was reported at line 4 — which offset every script-block symbol's location in search, `codegraph_node`, and explore output. Line numbers now match the file exactly. Re-index a project to benefit. (Svelte, Vue)
 - Doc comments are now captured for exported, `const`-assigned, and decorated declarations, and the documentation a symbol carries is now clean across every supported language. Previously a comment above `export class X`, `export const fn = () => …`, a plain `const fn = () => …`, or a decorated Python `def`/`class` (`@app.route(...)`, `@dataclass`) was dropped entirely — only comments directly above a plain declaration were kept. CodeGraph now finds the comment through the `export` / `const` / decorator wrapper. Comment-marker cleanup was also rounded out for every language CodeGraph supports: Rust/Swift/Kotlin doc lines (`///`, `//!`), Python/Ruby/shell `#`, Lua/Luau (`--` and `--[[ ]]`), and Pascal (`{ }` and `(* *)`) no longer leave stray markers in the stored text — validated end-to-end across all 19 code languages plus Svelte/Vue `<script>` blocks. (#780). Thanks @caleb-kaiser.
 - Go method calls made through a chained factory function now resolve to the correct type. A call like `New().Method()` used to drop the receiver, so the chained method attached to a same-named method on an unrelated type — or didn't resolve. CodeGraph now captures Go return types (a pointer `*Foo` resolves to `Foo`, and a multi-return `(*Foo, error)` to its first result), infers the chained receiver's type from what the factory function returns, and resolves the method on it — including methods promoted from an embedded struct — creating the edge only when the type or an embedded type genuinely has the method. Existing Go indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Go)

+ 1 - 0
CLAUDE.md

@@ -104,6 +104,7 @@ CodeGraph's only channels to influence the agent are low-salience: the MCP `init
 What works is meeting the agent where it already is:
 - **explore-flow** — `codegraph_explore` is the PRIMARY tool the agent reliably calls; its query is a precise bag of symbol names (incl. qualified `Class.method`) spanning the flow the agent is after; explore finds the call path _among those named symbols_ (riding synthesized edges) and leads its output with it. (`buildFlowFromNamedSymbols`: segment/co-naming disambiguation; ≤1 unnamed bridge so it never wanders a god-function's fan-out. Overload-aware: a PascalCase type token in the query biases an overloaded name to that type's own def — `DataRequest task` → DataRequest's `task`, not the abstract base; named-symbol files sort first.)
 - **Sufficiency** — make the tool's output complete enough that the agent stops. `codegraph_node` returns the full body + the caller/callee trail, and for an AMBIGUOUS name returns **every overload's body in one call** (so the agent never Reads a file to find the right overload — validated on Alamofire/gin). This is the after-explore depth tool (labeled SECONDARY).
+- **Errors teach abandonment** — one or two `isError: true` responses early in a session and the agent stops calling codegraph entirely (maintainer-observed, repeatedly). `isError` is reserved for genuine "stop trying" cases: security refusals (`PathRefusalError`) and real malfunctions (which carry a retry-once note). Every expected/recoverable condition — project not indexed, symbol not found, file not in the index — returns a **SUCCESS-shaped response carrying the guidance** (`NotIndexedError` → `textResult`, see `ToolHandler.execute`'s catch). The same principle session-wide: an **unindexed workspace serves an empty `tools/list` + a 2-line "inactive" instructions variant** instead of 8 tools that all fail — absence is the one signal an agent can't misread, and indexing is deliberately the user's call, never the agent's.
 
 What fails is the inverse — folding a precise answer into a **fuzzy-input** tool: the now-removed `codegraph_context` took a description, not symbols, so it couldn't disambiguate a flow's endpoints and surfaced the _wrong feature_ (which is why it was cut). Precise output needs precise input — explore takes a symbol bag for exactly this reason. (`codegraph_trace` was likewise removed: explore-flow does its job and the agent under-picked it.)
 

+ 212 - 0
__tests__/mcp-unindexed.test.ts

@@ -0,0 +1,212 @@
+/**
+ * Unindexed-workspace session policy tests.
+ *
+ * An MCP session attached to a workspace with no .codegraph/ must go quiet
+ * rather than fail loudly: `initialize` returns the short "inactive"
+ * instructions variant (not the full playbook), `tools/list` returns an
+ * EMPTY list, and a tool call that still arrives (cross-project
+ * `projectPath`, or a host that skips tools/list) answers with a
+ * SUCCESS-shaped guidance message — never `isError: true`. One or two early
+ * isError responses teach an agent to abandon codegraph for the whole
+ * session; that observed failure mode is what this suite guards.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { ToolHandler } from '../src/mcp/tools';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
+  return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
+    cwd,
+    stdio: ['pipe', 'pipe', 'pipe'],
+    // Direct (in-process) mode — the unindexed path never has a daemon
+    // anyway (the daemon socket lives in .codegraph/), and this keeps the
+    // suite from leaking a detached daemon in the indexed test.
+    env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' },
+  }) as ChildProcessWithoutNullStreams;
+}
+
+/** Send a JSON-RPC request and resolve with the response matching its id. */
+function request(
+  child: ChildProcessWithoutNullStreams,
+  msg: { id: number; method: string; params?: unknown },
+  timeoutMs = 15000
+): Promise<Record<string, unknown>> {
+  return new Promise((resolve, reject) => {
+    let buf = '';
+    const timer = setTimeout(() => {
+      child.stdout.off('data', onData);
+      reject(new Error(`timeout waiting for response id=${msg.id}`));
+    }, timeoutMs);
+    const onData = (chunk: Buffer) => {
+      buf += chunk.toString();
+      let idx: number;
+      while ((idx = buf.indexOf('\n')) !== -1) {
+        const line = buf.slice(0, idx).trim();
+        buf = buf.slice(idx + 1);
+        if (!line) continue;
+        try {
+          const parsed = JSON.parse(line) as Record<string, unknown>;
+          if (parsed.id === msg.id) {
+            clearTimeout(timer);
+            child.stdout.off('data', onData);
+            resolve(parsed);
+            return;
+          }
+        } catch {
+          // non-JSON noise on stdout — ignore
+        }
+      }
+    };
+    child.stdout.on('data', onData);
+    child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
+  });
+}
+
+function initializeParams(projectPath: string) {
+  return {
+    protocolVersion: '2025-11-25',
+    capabilities: {},
+    clientInfo: { name: 'test', version: '0.0.0' },
+    rootUri: `file://${projectPath}`,
+  };
+}
+
+describe('Unindexed-workspace session policy', () => {
+  let tempDir: string;
+  let child: ChildProcessWithoutNullStreams | null = null;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-'));
+  });
+
+  afterEach(() => {
+    if (child) {
+      child.kill('SIGKILL');
+      child = null;
+    }
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('initialize returns the short "inactive" instructions, not the playbook', async () => {
+    fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n');
+    child = spawnServer(tempDir);
+
+    const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
+    const instructions = (res.result as { instructions: string }).instructions;
+
+    expect(instructions).toMatch(/inactive/i);
+    expect(instructions).toMatch(/codegraph init/);
+    // The full playbook must NOT be sent into a session where every call fails
+    expect(instructions).not.toMatch(/Tool selection by intent/);
+    expect(instructions).not.toMatch(/codegraph_explore/);
+  });
+
+  it('tools/list returns an EMPTY list when the workspace has no index', async () => {
+    child = spawnServer(tempDir);
+    await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
+
+    const res = await request(child, { id: 1, method: 'tools/list' });
+    expect((res.result as { tools: unknown[] }).tools).toEqual([]);
+  });
+
+  it('an INDEXED workspace still gets the full playbook and all tools', async () => {
+    fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n');
+    const cg = await CodeGraph.init(tempDir, { index: true });
+    cg.close();
+
+    child = spawnServer(tempDir);
+    const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
+    const instructions = (init.result as { instructions: string }).instructions;
+    expect(instructions).toMatch(/Tool selection by intent/);
+    expect(instructions).not.toMatch(/inactive/i);
+
+    const list = await request(child, { id: 1, method: 'tools/list' });
+    const tools = (list.result as { tools: Array<{ name: string }> }).tools;
+    // A 1-file project triggers the pre-existing tiny-repo tool gating (a
+    // reduced core set) — the contract under test is "indexed → tools are
+    // PRESENT", in contrast to the unindexed empty list above.
+    expect(tools.length).toBeGreaterThanOrEqual(3);
+    expect(tools.map((t) => t.name)).toContain('codegraph_explore');
+  });
+});
+
+describe('No-error policy on expected conditions', () => {
+  let tempDir: string;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => {
+    const res = await new ToolHandler(null).execute('codegraph_search', {
+      query: 'anything',
+      projectPath: tempDir,
+    });
+
+    expect(res.isError).toBeUndefined();
+    expect(res.content[0]!.text).toMatch(/isn't indexed/);
+    expect(res.content[0]!.text).toMatch(/codegraph init/);
+    expect(res.content[0]!.text).toMatch(/built-in tools/);
+  });
+
+  it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => {
+    const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' });
+
+    expect(res.isError).toBeUndefined();
+    expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/);
+    expect(res.content[0]!.text).toMatch(/projectPath/);
+  });
+
+  it.runIf(process.platform !== 'win32')(
+    'sensitive-path refusal stays a hard error (no retry encouragement)',
+    async () => {
+      const res = await new ToolHandler(null).execute('codegraph_search', {
+        query: 'anything',
+        projectPath: '/etc',
+      });
+
+      expect(res.isError).toBe(true);
+      expect(res.content[0]!.text).not.toMatch(/retry the call once/);
+    }
+  );
+});
+
+describe('search kind filter', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(async () => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-'));
+    fs.writeFileSync(
+      path.join(tempDir, 'types.ts'),
+      'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n'
+    );
+    cg = await CodeGraph.init(tempDir, { index: true });
+  });
+
+  afterEach(() => {
+    cg.close();
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it("kind: 'type' (the advertised enum value) finds type aliases", async () => {
+    const res = await new ToolHandler(cg).execute('codegraph_search', {
+      query: 'PaymentMethod',
+      kind: 'type',
+    });
+
+    expect(res.isError).toBeUndefined();
+    expect(res.content[0]!.text).toMatch(/PaymentMethod/);
+    expect(res.content[0]!.text).not.toMatch(/No results found/);
+  });
+});

+ 22 - 1
src/mcp/server-instructions.ts

@@ -71,8 +71,29 @@ typically one to a few calls; a grep/read exploration is dozens.
 
 ## Limitations
 
-- If a tool reports the project isn't initialized, \`.codegraph/\` doesn't exist yet — offer to run \`codegraph init -i\` to build the index.
+- If a tool reports a project isn't indexed (no \`.codegraph/\`), stop calling codegraph tools for that project for the rest of the session and use your built-in tools there instead. Indexing is the user's decision — mention they can run \`codegraph init\` if it comes up, but don't run it yourself.
 - Index lags file writes by ~1 second.
 - Cross-file resolution is best-effort name matching; ambiguous calls may return multiple candidates.
 - No live correctness validation — that's still the TypeScript compiler / test suite / linter's job. Codegraph supplements those with structural context they don't have.
 `;
+
+/**
+ * Instructions variant sent when the workspace has NO codegraph index.
+ *
+ * Sending the full playbook ("lean on codegraph for everything") into a
+ * session where every call would fail wastes the agent's calls and — worse —
+ * the failures teach it codegraph is broken. The unindexed variant is a
+ * short, unambiguous "inactive this session" note; `tools/list` is gated to
+ * empty in the same state, so the agent has nothing to mis-call. Indexing is
+ * deliberately left to the user: the agent is told NOT to run init itself.
+ */
+export const SERVER_INSTRUCTIONS_UNINDEXED = `# Codegraph — inactive (workspace not indexed)
+
+This workspace has no codegraph index (no \`.codegraph/\` directory), so no
+codegraph tools are available this session. Work with your built-in tools as
+usual.
+
+Indexing is the user's decision — do not run it yourself. If the user asks
+about codegraph, they can enable it by running \`codegraph init\` in the
+project root and starting a new session.
+`;

+ 23 - 3
src/mcp/session.ts

@@ -16,8 +16,9 @@ import * as path from 'path';
 import { JsonRpcRequest, JsonRpcNotification, JsonRpcTransport, ErrorCodes } from './transport';
 import { MCPEngine } from './engine';
 import { tools } from './tools';
-import { SERVER_INSTRUCTIONS } from './server-instructions';
+import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_UNINDEXED } from './server-instructions';
 import { CodeGraphPackageVersion } from './version';
+import { findNearestCodeGraphRoot } from '../directory';
 
 /**
  * MCP Server Info — kept on the session because some clients log it. The
@@ -178,12 +179,24 @@ export class MCPSession {
       explicitPath = this.explicitProjectPath;
     }
 
+    // Pick the instructions variant by the workspace's index state — a cheap
+    // synchronous walk-up (existsSync loop only, no DB open, so the #172
+    // respond-fast contract holds). An unindexed workspace gets the short
+    // "inactive this session" note instead of the full playbook: the playbook
+    // tells the agent to lean on tools that would all fail, and early failures
+    // teach the agent to abandon codegraph entirely. `tools/list` is gated the
+    // same way (empty list when unindexed). When no explicit path is known yet
+    // (roots/list dance pending), cwd is the best predictor of where the
+    // default project will resolve — and on a mismatch the worst case is the
+    // optimistic full playbook backstopped by the empty tool list.
+    const indexed = findNearestCodeGraphRoot(explicitPath ?? process.cwd()) !== null;
+
     // Respond to the handshake BEFORE doing any heavy init — see issue #172.
     this.transport.sendResult(request.id, {
       protocolVersion: PROTOCOL_VERSION,
       capabilities: { tools: {} },
       serverInfo: SERVER_INFO,
-      instructions: SERVER_INSTRUCTIONS,
+      instructions: indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_UNINDEXED,
     });
 
     if (explicitPath) {
@@ -196,8 +209,15 @@ export class MCPSession {
 
   private async handleToolsList(request: JsonRpcRequest): Promise<void> {
     await this.retryInitIfNeeded();
+    // An unindexed workspace serves an EMPTY tool list: absence is the one
+    // signal an agent can't misread. Listing 8 tools that all fail wastes the
+    // agent's calls and teaches it codegraph is broken (observed: one or two
+    // early isError responses and the agent stops calling codegraph for the
+    // whole session). A `codegraph init` run after the server started is
+    // picked up on the next tools/list — retryInitIfNeeded re-walks — though
+    // most hosts only request the list once per connection.
     this.transport.sendResult(request.id, {
-      tools: this.engine.getToolHandler().getTools(),
+      tools: this.engine.hasDefaultCodeGraph() ? this.engine.getToolHandler().getTools() : [],
     });
   }
 

+ 52 - 8
src/mcp/tools.ts

@@ -28,6 +28,25 @@ import {
 } from 'fs';
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
 import { isGeneratedFile } from '../extraction/generated-detection';
+
+/**
+ * An expected, recoverable "codegraph can't serve this" condition — most
+ * importantly a project with no index. The dispatch catch converts these to
+ * SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
+ * early in a session teaches the agent the toolset is broken and it stops
+ * calling codegraph entirely (observed repeatedly), which is exactly wrong
+ * for conditions the agent can simply work around (use built-in tools for
+ * that codebase / pass projectPath). isError is reserved for "stop trying"
+ * cases: security refusals ({@link PathRefusalError}) and genuine
+ * malfunctions.
+ */
+export class NotIndexedError extends Error {}
+
+/**
+ * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
+ * retry guidance — abandoning this path is the desired agent reaction.
+ */
+export class PathRefusalError extends Error {}
 import { resolve as resolvePath } from 'path';
 
 /** Maximum output length to prevent context bloat (characters) */
@@ -522,7 +541,7 @@ export const tools: ToolDefinition[] = [
       properties: {
         query: {
           type: 'string',
-          description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.',
+          description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too — no prior codegraph_search needed.',
         },
         maxFiles: {
           type: 'number',
@@ -752,14 +771,16 @@ export class ToolHandler {
     if (!projectPath) {
       if (!this.cg) {
         const searched = this.defaultProjectHint ?? process.cwd();
-        throw new Error(
+        throw new NotIndexedError(
           'No CodeGraph project is loaded for this session.\n' +
           `Searched for a .codegraph/ directory starting from: ${searched}\n` +
-          'The index is likely fine — this is a working-directory detection issue: ' +
+          'If this project IS indexed, this is a working-directory detection issue: ' +
           "the MCP client launched the server outside your project and didn't report the " +
           'workspace root. Fix it either way:\n' +
           '  • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
-          '  • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
+          '  • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
+          'If the project simply has no index, continue with your built-in tools (Read/Grep/Glob) ' +
+          "and don't call codegraph again this session — the user can run 'codegraph init' to enable it."
         );
       }
       return this.cg;
@@ -778,7 +799,7 @@ export class ToolHandler {
     if (existsSync(projectPath)) {
       const pathError = validateProjectPath(projectPath);
       if (pathError) {
-        throw new Error(pathError);
+        throw new PathRefusalError(pathError);
       }
     }
 
@@ -786,7 +807,12 @@ export class ToolHandler {
     const resolvedRoot = findNearestCodeGraphRoot(projectPath);
 
     if (!resolvedRoot) {
-      throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
+      throw new NotIndexedError(
+        `The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
+        'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
+        "for that codebase instead, and don't call codegraph for it again this session. " +
+        "Indexing is the user's decision — they can run 'codegraph init' in that project to enable it."
+      );
     }
 
     // If the path resolves to the default project, reuse the already-open
@@ -1069,7 +1095,21 @@ export class ToolHandler {
       const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
       return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
     } catch (err) {
-      return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
+      // Expected condition, not a malfunction: answer as a SUCCESS so the
+      // agent keeps trusting the toolset for projects that ARE indexed.
+      // (An isError here teaches session-long abandonment — see NotIndexedError.)
+      if (err instanceof NotIndexedError) {
+        return this.textResult(err.message);
+      }
+      // Security refusal: a clean error, no retry encouragement.
+      if (err instanceof PathRefusalError) {
+        return this.errorResult(err.message);
+      }
+      return this.errorResult(
+        `Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
+        'This is an internal codegraph error — retry the call once; if it persists, ' +
+        'continue without codegraph for this task.'
+      );
     }
   }
 
@@ -1081,7 +1121,11 @@ export class ToolHandler {
     if (typeof query !== 'string') return query;
 
     const cg = this.getCodeGraph(args.projectPath as string | undefined);
-    const kind = args.kind as string | undefined;
+    const rawKind = args.kind as string | undefined;
+    // The schema enum says 'type' (what agents naturally reach for); the
+    // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
+    // matched nothing — a filter value we advertise must work.
+    const kind = rawKind === 'type' ? 'type_alias' : rawKind;
     const rawLimit = Number(args.limit) || 10;
     const limit = clamp(rawLimit, 1, 100);