fix(mcp): rename query/cypher params so Claude Code can call them (#2186)

* fix(mcp): advertise search_query/statement params for query/cypher tools (#2175)

Claude Code drops a tool-call argument named exactly 'query', making the
query and cypher tools unusable from it. Rename the advertised required
parameters to search_query and statement so the client transmits them.
Handler-side backward-compat for the legacy 'query' key follows in the
next commit.

* fix(mcp): accept search_query/statement with legacy query fallback (#2175)

Resolve the new advertised param names in the backend while still accepting
the legacy 'query' key, so curl/HTTP, other MCP clients, the CLI, the group
path, and the internal executeCypher() all keep working. Alias is normalized
once at the callTool chokepoint (covers group-forward + search alias); query()
and cypher() dual-read defensively. New name wins when both are supplied.
Updates the required-error message and adds dual-accept unit + integration
coverage.

* fix(cli): pass canonical search_query/statement params to query/cypher tools (#2175)

Stop the CLI from depending on the deprecated 'query' alias. No user-facing
change — the positional args are unchanged and the backend accepts both keys.

* fix(mcp): generators advertise search_query in query() examples (#2175)

Update the three doc/example generators (ai-context AGENTS/CLAUDE block,
skill-gen community skills, resources repo hint) so future analyze runs emit
query({search_query: ...}) — the param name Claude Code actually transmits.
Tests assert the new form is present and the legacy query({query: form is
absent (the #2059 generator-test pattern).

* docs(mcp): advertise search_query/statement in skill & guidance examples (#2175)

Sync the committed agent-facing docs to the renamed params so a Claude Code
agent following them emits the transmittable key: AGENTS.md/CLAUDE.md gitnexus
block, the canonical gitnexus/skills/* source and its installed/plugin/cursor
mirrors, and the README examples. Scoped rewrite of the two call prefixes only
(query({query: -> search_query, cypher({query: -> statement).

* style(mcp): prettier line-wrap for #2175 alias-resolution edits

* fix(review): uniform search_query precedence + cypher empty guard (#2175)

Code-review findings (correctness/adversarial/api-contract/maintainability
consensus):
- Group-mode query inverted the 'new name wins' rule: the callTool chokepoint
  backfilled params.query only when empty and the @group-forward read
  params.query directly, so a both-keys (or whitespace-legacy) group call let
  the legacy value win — unlike the local path. Replace the hidden param
  mutation with a self-contained 'search_query ?? query' resolve at the
  group-forward; precedence is now uniformly new-wins at every consumer site.
- cypher() now returns the same friendly required-param error as query() when
  neither statement nor query is supplied, instead of a raw DB prepare error.
- Document the legacy alias as permanent (third-party clients may send query=).
Adds group-forward alias tests (both-keys + legacy-only), empty/whitespace
search_query, the search-alias path, and the cypher empty-statement guard.

* fix(review): non-string alias safety + drop stale chokepoint comment (#2175)

Tri-review findings (correctness/adversarial/security + maintainability):
- Non-string statement/search_query/query (the MCP envelope is not
  schema-validated) hit .trim() and threw TypeError to the server boundary
  instead of a friendly required-param error. Introduce resolveAliasString()
  (new name wins; non-string -> undefined) used by query(), cypher(), and the
  group-forward, so all three return the structured error. Empirically verified
  (123 ?? '' -> 123, (123).trim() throws) — this overrides a critic refutation
  that mis-read ?? as a string coercion.
- Remove the stale query() comment claiming alias resolution happens at a
  callTool chokepoint; that mutation was removed earlier in this PR — each site
  resolves the alias itself.
- Document GroupToolPort.query's intentionally-narrower required type vs the
  wider LocalBackend impl.
Adds non-string and empty-new-key precedence tests.

* fix(mcp): alias falls back to legacy value when new key is blank (#2175)

PR #2186 review finding: resolveAliasString used `canonical ?? legacy`
(nullish), so an explicitly empty/whitespace new-name value (e.g.
{search_query:'', query:'real'}) won and was rejected — discarding a valid
legacy value, contradicting the 'new name wins when both supplied' intent.
Resolve to the first NON-BLANK string instead (new preferred when it carries
a real value, else legacy). Covers query(), cypher(), and the group-forward
(all route through the helper); non-string still resolves to a friendly error.
Flips the presence-based test and adds whitespace/cypher/group fallback cases.

* fix(mcp): drop legacy "query" mention from query/cypher schema descriptions (#2175)

PR #2186 review finding: the search_query/statement inputSchema descriptions
named the legacy "query" key — the exact arg Claude Code drops — and
description text is read by an LLM choosing arguments, weakly nudging it to
send "query". Trim the descriptions to their clean form and move the
legacy-alias note to a code comment next to the schema (preserved for
maintainers / non-CC clients). properties/required unchanged (no `query`).
This commit is contained in:
Gergő Magyar 2026-06-13 10:24:16 +01:00 • committed by GitHub
parent 148d56ec6a
commit aa5e436d91
29 changed files with 351 additions and 64 deletions

View file

@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -51,7 +51,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -75,7 +75,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -18,7 +18,7 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -50,7 +50,7 @@ description: "Use when the user asks how code works, wants to understand archite
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -68,7 +68,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -17,7 +17,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -83,7 +83,7 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do

View file

@ -65,7 +65,7 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do

View file

@ -360,7 +360,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| `group_query` | Search execution flows across all repos in a group | — |
| `group_status` | Check staleness of repos in a group | — |
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({search_query: "auth", repo: "my-app"})`.
**Resources** for instant context:
@ -738,7 +738,7 @@ gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # e
### Process-Grouped Search
```
query({query: "authentication middleware"})
query({search_query: "authentication middleware"})
processes:
- summary: "LoginFlow"

View file

@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -51,7 +51,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -75,7 +75,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -18,7 +18,7 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -50,7 +50,7 @@ description: "Use when the user asks how code works, wants to understand archite
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -68,7 +68,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -17,7 +17,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -15,10 +15,10 @@ description: Trace bugs through call chains using knowledge graph
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -49,7 +49,7 @@ description: Trace bugs through call chains using knowledge graph
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -71,7 +71,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -17,7 +17,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -48,7 +48,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -65,7 +65,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -16,7 +16,7 @@ description: Plan safe refactors using blast radius and dependency mapping
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -134,7 +134,7 @@ Your AI agent gets these tools automatically:
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
| `cypher` | Raw Cypher graph queries | Optional |
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({query: "auth", repo: "my-app"})`.
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`.
## MCP Resources

View file

@ -16,10 +16,10 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
1. query({search_query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
4. cypher({statement: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
@ -51,7 +51,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
**query** — find code related to error:
```
query({query: "payment validation error"})
query({search_query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
@ -75,7 +75,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
1. query({search_query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError

View file

@ -18,7 +18,7 @@ description: "Use when the user asks how code works, wants to understand archite
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
@ -50,7 +50,7 @@ description: "Use when the user asks how code works, wants to understand archite
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
query({search_query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
@ -68,7 +68,7 @@ context({name: "validateUser"})
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
2. query({search_query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})

View file

@ -17,7 +17,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
2. query({search_query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

View file

@ -177,7 +177,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run \`detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When exploring unfamiliar code, use \`query({search_query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`context({name: "symbolName"})\`.
- For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).

View file

@ -651,7 +651,7 @@ const renderSkillMarkdown = (
lines.push('');
lines.push(`1. \`context({name: "${firstEntry}"})\` \u2014 see callers and callees`);
lines.push(
`2. \`query({query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`,
`2. \`query({search_query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`,
);
lines.push('3. Read key files listed above for implementation details');
lines.push(

View file

@ -76,7 +76,8 @@ export async function queryCommand(
const backend = await getBackend();
const result = await backend.callTool('query', {
query: queryText,
// #2175: canonical param is search_query; the backend still accepts legacy "query".
search_query: queryText,
task_context: options?.context,
goal: options?.goal,
limit: options?.limit ? parseInt(options.limit) : undefined,
@ -204,7 +205,8 @@ export async function cypherCommand(
const backend = await getBackend();
const result = await backend.callTool('cypher', {
query,
// #2175: canonical param is statement; the backend still accepts legacy "query".
statement: query,
repo: options?.repo,
branch: options?.branch,
});

View file

@ -49,6 +49,10 @@ export interface GroupToolPort {
query(
repo: GroupRepoHandle,
params: {
// GroupService always supplies `query` as a string (it resolves the #2175
// search_query alias before calling the port), so the port contract keeps it
// required here even though the LocalBackend implementation accepts the wider
// `{ query?, search_query? }` shape for the direct MCP callTool path.
query: string;
task_context?: string;
goal?: string;

View file

@ -77,6 +77,23 @@ function looksLikeFilePath(target: string): boolean {
const lower = target.toLowerCase();
return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
/**
* Resolve a string tool param from its canonical name or legacy alias (#2175).
* Returns the first NON-BLANK string of [canonical, legacy] — the canonical (new)
* name is preferred when it carries a real value, otherwise the legacy value is used.
* A blank/whitespace new value therefore does NOT clobber a valid legacy value (e.g. a
* gradually-migrating client that always emits the new key, blank when unset). A
* non-string value (the MCP envelope is not schema-validated, so clients can send any
* JSON type) and an all-blank input resolve to `undefined`, so the caller returns a
* friendly required-param error instead of throwing `TypeError` on `.trim()`.
*/
function resolveAliasString(canonical: unknown, legacy: unknown): string | undefined {
for (const value of [canonical, legacy]) {
if (typeof value === 'string' && value.trim()) return value;
}
return undefined;
}
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -1242,6 +1259,16 @@ export class LocalBackend {
}
const p = params && typeof params === 'object' ? (params as Record<string, unknown>) : {};
// #2175: Claude Code drops a tool-call argument named exactly "query", so the
// query/cypher tools advertise "search_query"/"statement" while still accepting the
// legacy "query" key for backward compat. The alias is resolved with `?? ` (new name
// wins) at every consumer site rather than by mutating params here, so precedence is
// uniform and there is no hidden mutation: query()/cypher() read it directly, the
// legacy "search" alias routes through query(), and the cross-repo group-forward
// resolves it self-contained in callToolAtGroupRepo. This is permanent compatibility
// — third-party MCP clients may legitimately send "query", so the alias is not slated
// for removal even if Claude Code's argument handling later changes.
if (
(method === 'impact' || method === 'query' || method === 'context') &&
typeof p.repo === 'string' &&
@ -1345,7 +1372,8 @@ export class LocalBackend {
private async query(
repo: RepoHandle,
params: {
query: string;
query?: string;
search_query?: string;
task_context?: string;
goal?: string;
limit?: number;
@ -1353,8 +1381,12 @@ export class LocalBackend {
include_content?: boolean;
},
): Promise<any> {
if (!params.query?.trim()) {
return { error: 'query parameter is required and cannot be empty.' };
// #2175: each consumer resolves the search_query/query alias itself (there is no
// chokepoint mutation in callTool). This also serves the GroupService port, which
// reaches query() carrying only the legacy `query` key.
const rawQuery = resolveAliasString(params.search_query, params.query);
if (!rawQuery?.trim()) {
return { error: 'search_query (or legacy query) parameter is required and cannot be empty.' };
}
await this.ensureInitialized(repo);
@ -1362,7 +1394,7 @@ export class LocalBackend {
const processLimit = params.limit || 5;
const maxSymbolsPerProcess = params.max_symbols || 10;
const includeContent = params.include_content ?? false;
const searchQuery = params.query.trim();
const searchQuery = rawQuery.trim();
// Per-phase timing instrumentation (#553). Records wall time for each
// observable sub-step of the search pipeline so production latency can
@ -1932,7 +1964,9 @@ export class LocalBackend {
private async cypher(
repo: RepoHandle,
request: { query: string; params?: Record<string, unknown> },
// #2175: "statement" is the advertised param; "query" is the legacy alias,
// still accepted (and the field the internal executeCypher() passes). New wins.
request: { query?: string; statement?: string; params?: Record<string, unknown> },
): Promise<any> {
await this.ensureInitialized(repo);
@ -1945,8 +1979,15 @@ export class LocalBackend {
};
}
const cypherText = resolveAliasString(request.statement, request.query) ?? '';
if (!cypherText.trim()) {
// Mirror query()'s friendly required-param error instead of letting an empty
// string fall through to a raw LadybugDB prepare error (#2175 review).
return { error: 'statement (or legacy query) parameter is required and cannot be empty.' };
}
try {
const result = await executeParameterized(repo.lbugPath, request.query, request.params ?? {});
const result = await executeParameterized(repo.lbugPath, cypherText, request.params ?? {});
return result;
} catch (err: any) {
const msg = err.message || 'Query failed';
@ -4820,7 +4861,10 @@ export class LocalBackend {
if (method === 'query') {
const queryArgs: Record<string, unknown> = {
name: groupName,
query: params.query,
// #2175: resolve the search_query alias here (new name wins, same rule as the
// local query() handler) so the group path is self-contained and does not depend
// on params being normalized upstream. groupQuery() reads `query`.
query: resolveAliasString(params.search_query, params.query),
};
if (typeof params.task_context === 'string') queryArgs.task_context = params.task_context;
if (typeof params.goal === 'string') queryArgs.goal = params.goal;

View file

@ -292,7 +292,7 @@ async function getReposResource(backend: LocalBackend): Promise<string> {
if (repos.length > 1) {
lines.push('');
lines.push('# Multiple repos indexed. Use repo parameter in tool calls:');
lines.push(`# query({query: "auth", repo: "${repos[0].name}"})`);
lines.push(`# query({search_query: "auth", repo: "${repos[0].name}"})`);
}
return lines.join('\n');

View file

@ -130,7 +130,14 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Natural language or keyword search query' },
// #2175: the legacy `query` key is still accepted by the handler
// (resolveAliasString in local-backend.ts), but is deliberately NOT named in the
// advertised property or its description — surfacing "query" in the schema an LLM
// reads would nudge it to send `query`, the exact argument Claude Code drops.
search_query: {
type: 'string',
description: 'Natural language or keyword search query.',
},
task_context: {
type: 'string',
description: 'What you are working on (e.g., "adding OAuth support"). Helps ranking.',
@ -171,7 +178,7 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
'Optional monorepo service root (relative path, "/" separators). In group mode (@repo), prefix-matches symbol file paths; ignored for a normal repo name. Empty string is rejected server-side.',
},
},
required: ['query'],
required: ['search_query'],
},
},
{
@ -224,7 +231,14 @@ TIPS:
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Cypher query to execute' },
// #2175: the legacy `query` key is still accepted by the handler
// (resolveAliasString in local-backend.ts), but is deliberately NOT named in the
// advertised property or its description — surfacing "query" in the schema an LLM
// reads would nudge it to send `query`, the exact argument Claude Code drops.
statement: {
type: 'string',
description: 'Cypher statement to execute.',
},
params: {
type: 'object',
description:
@ -235,7 +249,7 @@ TIPS:
description: 'Repository name or path. Omit if only one repo is indexed.',
},
},
required: ['query'],
required: ['statement'],
},
},
{

View file

@ -119,6 +119,26 @@ withTestLbugDB(
expect(result).not.toHaveProperty('partial');
});
// #2175: end-to-end proof that the renamed parameters work against a real
// index (Claude Code drops a tool arg named exactly "query").
it('query tool returns results via the new search_query param (#2175)', async () => {
const result = await backend.callTool('query', { search_query: 'login' });
expect(result).not.toHaveProperty('error');
expect(result).toHaveProperty('processes');
expect(result.processes.map((p: any) => p.id)).toContain('proc:login-flow');
expect(result.process_symbols.map((s: any) => s.id)).toContain('func:login');
});
it('cypher tool executes via the new statement param (#2175)', async () => {
const result = await backend.callTool('cypher', {
statement: 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name',
});
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBeGreaterThanOrEqual(3);
expect(result.markdown).toContain('login');
});
// PR #222 port: the query tool batches per-symbol process/cohesion/content
// lookups (N+1 → 2-3 `WHERE n.id IN $nodeIds` queries). These assertions
// guard the batch-adaptation hazards that a naive cherry-pick would break:

View file

@ -920,7 +920,10 @@ Indexed as **placeholder** (1 symbols, 1 relationships, 1 execution flows). Cust
expect(content).not.toMatch(/gitnexus_(impact|query|context|detect_changes|rename|cypher)/);
expect(content).toContain('impact({target: "symbolName", direction: "upstream"})');
expect(content).toContain('detect_changes()');
expect(content).toContain('query({query: "concept"})');
// #2175: the generated guidance must advertise the renamed param, never the
// legacy "query" key (Claude Code drops a tool arg named exactly "query").
expect(content).toContain('query({search_query: "concept"})');
expect(content).not.toContain('query({query:');
expect(content).toContain('context({name: "symbolName"})');
});

View file

@ -85,6 +85,13 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
// #2175: lets the @group-forward path be exercised without real group.yaml infra.
// No existing test in this file uses an @repo, so this mock is inert for them.
const { resolveAtMemberMock } = vi.hoisted(() => ({ resolveAtMemberMock: vi.fn() }));
vi.mock('../../src/core/group/resolve-at-member.js', () => ({
resolveAtGroupMemberRepoPath: resolveAtMemberMock,
}));
import {
LocalBackend,
REPO_ID_HASH_LENGTH,
@ -433,12 +440,14 @@ describe('LocalBackend.callTool', () => {
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result.error).toContain('query parameter is required');
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('query tool returns error for whitespace-only query', async () => {
const result = await backend.callTool('query', { query: ' ' });
expect(result.error).toContain('query parameter is required');
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('dispatches cypher tool and blocks write queries', async () => {
@ -459,6 +468,186 @@ describe('LocalBackend.callTool', () => {
expect(result.row_count).toBe(1);
});
// ── #2175: backward-compatible parameter-alias dispatch ──────────────────
// Claude Code drops a tool-call argument named exactly "query", so the query
// and cypher tools advertise search_query / statement. The handlers must accept
// the new names AND keep accepting the legacy "query" key (verified by the
// existing tests above, which still pass { query: ... }).
it('query tool accepts the new search_query parameter (#2175)', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { search_query: 'auth' });
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
expect(result).not.toHaveProperty('error');
});
it('query tool prefers search_query over the legacy query when both are given (#2175)', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { search_query: 'newName', query: 'oldName' });
// bm25Search passes the resolved search text as arg 0 to searchFTSFromLbug.
const lastTerm = String(vi.mocked(searchFTSFromLbug).mock.calls.at(-1)?.[0] ?? '');
expect(lastTerm).toBe('newName');
});
it('query tool returns error when neither search_query nor query is provided (#2175)', async () => {
const result = await backend.callTool('query', {});
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('cypher tool accepts the new statement parameter (#2175)', async () => {
(executeParameterized as any).mockResolvedValue([{ name: 'test', filePath: 'src/test.ts' }]);
const result = await backend.callTool('cypher', {
statement: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5',
});
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBe(1);
});
it('cypher tool prefers statement over the legacy query when both are given (#2175)', async () => {
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('cypher', {
statement: 'MATCH (a) RETURN a',
query: 'MATCH (b) RETURN b',
});
const passedCypher = (executeParameterized as any).mock.calls.at(-1)[1] as string;
expect(passedCypher).toBe('MATCH (a) RETURN a');
});
it('executeCypher (internal API) still works via the legacy query field (#2175)', async () => {
(executeParameterized as any).mockResolvedValue([{ name: 'x' }]);
const result = await backend.executeCypher('test-project', 'MATCH (n) RETURN n LIMIT 1');
expect(result).not.toHaveProperty('error');
const passedCypher = (executeParameterized as any).mock.calls.at(-1)[1] as string;
expect(passedCypher).toBe('MATCH (n) RETURN n LIMIT 1');
});
it('query tool returns error for empty search_query (new key) (#2175)', async () => {
const result = await backend.callTool('query', { search_query: '' });
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('query tool returns error for whitespace-only search_query (new key) (#2175)', async () => {
const result = await backend.callTool('query', { search_query: ' ' });
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('search legacy alias accepts the new search_query parameter (#2175)', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('search', { search_query: 'auth' });
expect(result).toHaveProperty('processes');
expect(result).not.toHaveProperty('error');
});
it('cypher tool returns a friendly required error when neither statement nor query is given (#2175)', async () => {
const result = await backend.callTool('cypher', {});
expect(result.error).toContain('statement');
expect(result.error).toContain('parameter is required');
});
// #2175 review: the @group-forward path reads `query` from the forwarded args, so it
// must resolve the search_query alias itself (new name wins, mirroring query()).
it('group-mode query forwards the resolved search_query alias (#2175)', async () => {
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
const groupQuerySpy = vi
.spyOn(backend.getGroupService(), 'groupQuery')
.mockResolvedValue({ ok: true } as any);
await backend.callTool('query', {
search_query: 'alias-wins',
query: 'legacy-loses',
repo: '@grp',
});
expect(groupQuerySpy).toHaveBeenCalledTimes(1);
expect((groupQuerySpy.mock.calls[0][0] as any).query).toBe('alias-wins');
groupQuerySpy.mockRestore();
});
it('group-mode query still forwards a legacy-only query (#2175)', async () => {
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
const groupQuerySpy = vi
.spyOn(backend.getGroupService(), 'groupQuery')
.mockResolvedValue({ ok: true } as any);
await backend.callTool('query', { query: 'legacy', repo: '@grp' });
expect((groupQuerySpy.mock.calls[0][0] as any).query).toBe('legacy');
groupQuerySpy.mockRestore();
});
// #2175 review: the MCP envelope is not schema-validated, so a client can send a
// non-string value for a string param. Resolve it to a friendly required-param error
// rather than throwing TypeError on `.trim()` (query() and cypher() both).
it('query tool returns a friendly error (no throw) for a non-string search_query (#2175)', async () => {
const result = await backend.callTool('query', { search_query: 123 as any });
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('cypher tool returns a friendly error (no throw) for a non-string statement (#2175)', async () => {
const result = await backend.callTool('cypher', { statement: 123 as any });
expect(result.error).toContain('statement');
expect(result.error).toContain('parameter is required');
});
// #2175 review (PR #2186): resolution prefers the first NON-BLANK string, so a blank
// new-name value falls back to a valid legacy value instead of clobbering it.
it('query tool: a blank new search_query falls back to a valid legacy query (#2175)', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { search_query: '', query: 'real' });
expect(result).not.toHaveProperty('error');
expect(result).toHaveProperty('processes');
const lastTerm = String(vi.mocked(searchFTSFromLbug).mock.calls.at(-1)?.[0] ?? '');
expect(lastTerm).toBe('real');
});
it('query tool: a whitespace-only new search_query falls back to a valid legacy query (#2175)', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { search_query: ' ', query: 'real' });
expect(result).not.toHaveProperty('error');
const lastTerm = String(vi.mocked(searchFTSFromLbug).mock.calls.at(-1)?.[0] ?? '');
expect(lastTerm).toBe('real');
});
it('query tool: both keys blank still returns the required error (#2175)', async () => {
const result = await backend.callTool('query', { search_query: '', query: ' ' });
expect(result.error).toContain('search_query');
expect(result.error).toContain('parameter is required');
});
it('cypher tool: a blank statement falls back to a valid legacy query (#2175)', async () => {
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('cypher', { statement: '', query: 'MATCH (n) RETURN n LIMIT 1' });
const passedCypher = (executeParameterized as any).mock.calls.at(-1)[1] as string;
expect(passedCypher).toBe('MATCH (n) RETURN n LIMIT 1');
});
it('group-mode query: a blank new search_query falls back to the legacy query (#2175)', async () => {
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
const groupQuerySpy = vi
.spyOn(backend.getGroupService(), 'groupQuery')
.mockResolvedValue({ ok: true } as any);
await backend.callTool('query', { search_query: '', query: 'real', repo: '@grp' });
expect((groupQuerySpy.mock.calls[0][0] as any).query).toBe('real');
groupQuerySpy.mockRestore();
});
it('dispatches context tool', async () => {
(executeParameterized as any).mockResolvedValue([
{

View file

@ -388,7 +388,9 @@ describe('readResource', () => {
expect(result).toContain('repo parameter');
// The example must use a registered tool name, not the unregistered
// `gitnexus_search` / `gitnexus_*` prefix (#2059).
expect(result).toContain('query({query: "auth"');
// #2175: advertise the renamed param, not the legacy "query" key.
expect(result).toContain('query({search_query: "auth"');
expect(result).not.toContain('query({query:');
expect(result).not.toMatch(/gitnexus_/);
});
});

View file

@ -650,7 +650,9 @@ describe('generateSkillFiles — file output', () => {
);
expect(content).not.toMatch(/gitnexus_(context|query|impact|detect_changes|rename|cypher)/);
expect(content).toContain('context({name:');
expect(content).toContain('query({query:');
// #2175: advertise the renamed param, not the legacy "query" key.
expect(content).toContain('query({search_query:');
expect(content).not.toContain('query({query:');
});
/**

View file

@ -99,16 +99,23 @@ describe('GITNEXUS_TOOLS', () => {
}
});
it('query tool requires "query" parameter', () => {
it('query tool requires "search_query" parameter (renamed from "query" for #2175)', () => {
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
expect(queryTool.inputSchema.required).toContain('query');
expect(queryTool.inputSchema.properties.query).toBeDefined();
expect(queryTool.inputSchema.properties.query.type).toBe('string');
expect(queryTool.inputSchema.required).toContain('search_query');
// The legacy "query" key must NOT be advertised — Claude Code drops it (#2175).
expect(queryTool.inputSchema.required).not.toContain('query');
expect(queryTool.inputSchema.properties.query).toBeUndefined();
expect(queryTool.inputSchema.properties.search_query).toBeDefined();
expect(queryTool.inputSchema.properties.search_query.type).toBe('string');
});
it('cypher tool requires "query" parameter', () => {
it('cypher tool requires "statement" parameter (renamed from "query" for #2175)', () => {
const cypherTool = GITNEXUS_TOOLS.find((t) => t.name === 'cypher')!;
expect(cypherTool.inputSchema.required).toContain('query');
expect(cypherTool.inputSchema.required).toContain('statement');
expect(cypherTool.inputSchema.required).not.toContain('query');
expect(cypherTool.inputSchema.properties.query).toBeUndefined();
expect(cypherTool.inputSchema.properties.statement).toBeDefined();
expect(cypherTool.inputSchema.properties.statement.type).toBe('string');
expect(cypherTool.inputSchema.properties.params).toBeDefined();
expect(cypherTool.inputSchema.properties.params.type).toBe('object');
expect(cypherTool.inputSchema.properties.params.description).toContain('prepared statement');