From 2566bf6efeedf65bfca730c132f293edc4e5b270 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 22:42:47 +0700 Subject: [PATCH 1/5] fix(mcp): advertise provider-compatible tool schemas --- gitnexus/src/mcp/local/local-backend.ts | 32 +++++++++++++++++-- gitnexus/src/mcp/tools.ts | 13 ++------ .../integration/mcp/server-startup.test.ts | 7 +++- gitnexus/test/unit/calltool-dispatch.test.ts | 17 ++++++++++ gitnexus/test/unit/mcp-http-transport.test.ts | 28 ++++++++++++++++ gitnexus/test/unit/mcp-read-only.test.ts | 3 ++ .../test/unit/mcp-repository-policy.test.ts | 5 +++ gitnexus/test/unit/server.test.ts | 3 ++ gitnexus/test/unit/tools.test.ts | 26 ++++++++------- 9 files changed, 108 insertions(+), 26 deletions(-) diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 6fd1ab45d0..29a339ee5b 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -154,16 +154,30 @@ const TOOL_STRING_ALIASES: Readonly> = { + impact: { + keys: ['target'], + error: 'impact requires at least one of target, name, or symbol.', + }, + api_impact: { + keys: ['route', 'file'], + error: 'Either "route" or "file" is required for api_impact.', + }, +}; + function normalizeToolParams( method: string, params: unknown, ): { params: Record } | { error: string } { const input = params && typeof params === 'object' ? (params as Record) : {}; const definitions = TOOL_STRING_ALIASES[method]; - if (!definitions) return { params: input }; - const normalized = { ...input }; - for (const { canonical, aliases } of definitions) { + for (const { canonical, aliases } of definitions ?? []) { const keys = [canonical, ...aliases]; const supplied = keys.flatMap((key) => { const value = input[key]; @@ -181,6 +195,18 @@ function normalizeToolParams( for (const alias of aliases) delete normalized[alias]; if (supplied.length > 0) normalized[canonical] = supplied[0].value; } + + const requiredGroup = TOOL_REQUIRED_STRING_GROUPS[method]; + if ( + requiredGroup && + !requiredGroup.keys.some((key) => { + const value = normalized[key]; + return typeof value === 'string' && value.trim().length > 0; + }) + ) { + return { error: requiredGroup.error }; + } + return { params: normalized }; } diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index f5e24025ed..378452c106 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -28,12 +28,6 @@ export interface ToolDefinition { } >; required: string[]; - /** - * JSON-Schema `anyOf` for cross-property constraints `required` cannot express - * — e.g. "at least one of route/file". Forwarded verbatim to clients by the - * server's ListTools handler, so MCP clients see the constraint. - */ - anyOf?: Array<{ required: string[] }>; }; } @@ -444,6 +438,7 @@ Each edit is tagged with confidence: name: 'impact', description: `Analyze the blast radius of changing a code symbol. Returns affected symbols grouped by depth, plus risk assessment, affected execution flows, and affected modules. +Requires at least one of "target", "name", or "symbol"; aliases are normalized and conflicting values are rejected before repository resolution. MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/EXTENDS/IMPLEMENTS) — inter-procedural, the established comparator/default behavior. "pdg" requires an index built with \`gitnexus analyze --pdg\` and returns one unified PDG-facing result: statement-level control/data dependence from the persisted PDG plus inter-procedural symbol reach. The explicit interprocedural surface is interproceduralByDepth/pdgInterprocedural; byDepth remains the compatibility symbol bucket. pdg remains incompatible with crossDepth and @group targets; relationTypes/minConfidence filter the inter-symbol reach. @@ -615,7 +610,6 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep }, }, required: ['direction'], - anyOf: [{ required: ['target'] }, { required: ['name'] }, { required: ['symbol'] }], }, }, { @@ -784,7 +778,7 @@ Returns routes that have both detected response keys AND consumers. Shows top-le name: 'api_impact', description: `Pre-change impact report for an API route handler. -WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend on, what response fields they access, what middleware protects the route, and what execution flows it triggers. Requires at least "route" or "file" parameter. +WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend on, what response fields they access, what middleware protects the route, and what execution flows it triggers. Requires at least "route" or "file" parameter; the requirement is enforced before repository resolution. Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate. @@ -803,9 +797,6 @@ Response shape is keyed on how many routes match, not on the data: exactly one m repo: { type: 'string', description: 'Repository name or path.' }, }, required: [], - // Exactly one lookup key is needed, but either works (route wins if both - // are passed) — so the structural constraint is "at least one of route/file". - anyOf: [{ required: ['route'] }, { required: ['file'] }], }, }, { diff --git a/gitnexus/test/integration/mcp/server-startup.test.ts b/gitnexus/test/integration/mcp/server-startup.test.ts index bc3e214c6c..73bf75932d 100644 --- a/gitnexus/test/integration/mcp/server-startup.test.ts +++ b/gitnexus/test/integration/mcp/server-startup.test.ts @@ -234,7 +234,7 @@ describe('MCP server end-to-end startup', () => { const toolsResponse = (await server.nextMessage()) as { jsonrpc: string; id: number; - result?: { tools: Array<{ name: string }> }; + result?: { tools: Array<{ name: string; inputSchema: Record }> }; }; expect(toolsResponse.id).toBe(2); @@ -252,6 +252,11 @@ describe('MCP server end-to-end startup', () => { for (const t of expectedTools) { expect(toolNames).toContain(t); } + for (const tool of toolsResponse.result!.tools) { + expect(tool.inputSchema).not.toHaveProperty('anyOf'); + expect(tool.inputSchema).not.toHaveProperty('oneOf'); + expect(tool.inputSchema).not.toHaveProperty('allOf'); + } // tools/call list_repos — proves the paginated { repositories, pagination } // shape survives the real request → backend.callTool → JSON.stringify → diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index bdedf8011f..78b10b86c9 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -379,6 +379,23 @@ describe('LocalBackend.callTool', () => { expect(impactSpy.mock.calls[0][1]).toMatchObject({ target: 'validate' }); }); + it.each([ + ['impact', { direction: 'upstream' }, /target.*name.*symbol/i], + ['impact', { target: ' ', direction: 'upstream' }, /target.*name.*symbol/i], + ['api_impact', {}, /route.*file/i], + ['api_impact', { route: ' ', file: '' }, /route.*file/i], + ])( + 'rejects missing %s lookup keys before repository resolution', + async (method, params, error) => { + const resolveSpy = vi.spyOn(backend, 'resolveRepo'); + + const result = await backend.callTool(method, params); + + expect(result.error).toMatch(error); + expect(resolveSpy).not.toHaveBeenCalled(); + }, + ); + it.each([ ['impact', { target: 'validate', name: 'login', direction: 'upstream' }], ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], diff --git a/gitnexus/test/unit/mcp-http-transport.test.ts b/gitnexus/test/unit/mcp-http-transport.test.ts index 105caff2ac..91d52a10f9 100644 --- a/gitnexus/test/unit/mcp-http-transport.test.ts +++ b/gitnexus/test/unit/mcp-http-transport.test.ts @@ -19,6 +19,8 @@ import type { AddressInfo } from 'net'; import { describe, it, expect, vi, afterEach } from 'vitest'; import express from 'express'; import type { Request, Response, NextFunction } from 'express'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { createAuthMiddleware, createStreamableHttpHandler, @@ -269,6 +271,32 @@ describe('startMcpHttpServer', () => { expect(JSON.parse(body)).toEqual({ status: 'ok' }); }); + it('advertises provider-compatible schemas over Streamable HTTP', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + const app = express(); + app.use(express.json()); + app.all('/mcp', (req, res) => void handler(req, res)); + const { port, close } = await listen(app); + const client = new Client({ name: 'http-schema-test', version: '0.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); + + try { + await client.connect(transport); + const response = await client.listTools(); + expect(response.tools.length).toBeGreaterThan(0); + for (const tool of response.tools) { + expect(tool.inputSchema).not.toHaveProperty('anyOf'); + expect(tool.inputSchema).not.toHaveProperty('oneOf'); + expect(tool.inputSchema).not.toHaveProperty('allOf'); + } + } finally { + await client.close(); + await close(); + await cleanup(); + } + }); + it('POST /mcp without auth token returns 401 when --auth-token is configured', async () => { const { port, server, cleanup } = await startOnFreePort('supersecret'); servers.push({ server, cleanup }); diff --git a/gitnexus/test/unit/mcp-read-only.test.ts b/gitnexus/test/unit/mcp-read-only.test.ts index 6e247646fe..2885ac4885 100644 --- a/gitnexus/test/unit/mcp-read-only.test.ts +++ b/gitnexus/test/unit/mcp-read-only.test.ts @@ -71,6 +71,9 @@ describe('MCP read-only mode', () => { expect(response.tools.map((tool) => tool.name).sort()).toEqual(READ_ONLY_TOOLS); for (const tool of response.tools) { expect(tool.description).not.toMatch(/GROUP MODE|CROSS-REPO|@/); + expect(tool.inputSchema).not.toHaveProperty('anyOf'); + expect(tool.inputSchema).not.toHaveProperty('oneOf'); + expect(tool.inputSchema).not.toHaveProperty('allOf'); const properties = tool.inputSchema.properties as Record< string, { description?: string } | undefined diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts index 6decc77d77..ab226e5457 100644 --- a/gitnexus/test/unit/mcp-repository-policy.test.ts +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -276,6 +276,11 @@ describe('MCP repository policy', () => { await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); const tools = await client.listTools(); + for (const tool of tools.tools) { + expect(tool.inputSchema).not.toHaveProperty('anyOf'); + expect(tool.inputSchema).not.toHaveProperty('oneOf'); + expect(tool.inputSchema).not.toHaveProperty('allOf'); + } expect(tools.tools.map((tool) => tool.name)).not.toContain('group_list'); expect(tools.tools.map((tool) => tool.name)).not.toContain('group_sync'); for (const tool of tools.tools) { diff --git a/gitnexus/test/unit/server.test.ts b/gitnexus/test/unit/server.test.ts index 84850ffa41..c59fa6f178 100644 --- a/gitnexus/test/unit/server.test.ts +++ b/gitnexus/test/unit/server.test.ts @@ -98,6 +98,9 @@ describe('createMCPServer', () => { for (const tool of response.tools) { const definition = GITNEXUS_TOOLS.find((t) => t.name === tool.name)!; expect(tool.annotations).toEqual(definition.annotations); + expect(tool.inputSchema).not.toHaveProperty('anyOf'); + expect(tool.inputSchema).not.toHaveProperty('oneOf'); + expect(tool.inputSchema).not.toHaveProperty('allOf'); } } finally { await client.close(); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 29f6eb5736..bf948437f3 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -59,6 +59,14 @@ describe('GITNEXUS_TOOLS', () => { } }); + it('advertises provider-compatible root schemas without combinators', () => { + for (const tool of GITNEXUS_TOOLS) { + expect(tool.inputSchema).not.toHaveProperty('anyOf'); + expect(tool.inputSchema).not.toHaveProperty('oneOf'); + expect(tool.inputSchema).not.toHaveProperty('allOf'); + } + }); + it('each tool exposes all MCP safety annotations', () => { for (const tool of GITNEXUS_TOOLS) { expect(typeof tool.annotations.readOnlyHint).toBe('boolean'); @@ -144,14 +152,12 @@ describe('GITNEXUS_TOOLS', () => { expect(contextTool.inputSchema.properties.file).toMatchObject({ type: 'string' }); }); - it('api_impact tool expresses the route-or-file requirement via anyOf (#2308)', () => { + it('api_impact describes its runtime-enforced route-or-file requirement', () => { const apiImpactTool = GITNEXUS_TOOLS.find((t) => t.name === 'api_impact')!; - expect(apiImpactTool.inputSchema.anyOf).toEqual([ - { required: ['route'] }, - { required: ['file'] }, - ]); - // route/file stay optional in `required` (anyOf carries the cross-field rule) expect(apiImpactTool.inputSchema.required).toEqual([]); + expect(apiImpactTool.inputSchema.properties).toHaveProperty('route'); + expect(apiImpactTool.inputSchema.properties).toHaveProperty('file'); + expect(apiImpactTool.description).toContain('Requires at least "route" or "file" parameter'); }); it('impact tool requires direction and accepts target, name, or symbol', () => { @@ -160,11 +166,9 @@ describe('GITNEXUS_TOOLS', () => { expect(impactTool.inputSchema.required).not.toContain('target'); expect(impactTool.inputSchema.properties.name).toMatchObject({ type: 'string' }); expect(impactTool.inputSchema.properties.symbol).toMatchObject({ type: 'string' }); - expect(impactTool.inputSchema.anyOf).toEqual([ - { required: ['target'] }, - { required: ['name'] }, - { required: ['symbol'] }, - ]); + expect(impactTool.description).toContain( + 'Requires at least one of "target", "name", or "symbol"', + ); }); it('impact tool advertises the PDG-only `line` statement anchor (integer, min 0, not required)', () => { From f5b349e6174540c53d43ac5bba4073d2c8abe14a Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 23:01:00 +0700 Subject: [PATCH 2/5] fix(mcp): preserve impact UID selection --- gitnexus/src/mcp/local/local-backend.ts | 4 ++-- gitnexus/src/mcp/tools.ts | 2 +- gitnexus/test/unit/calltool-dispatch.test.ts | 16 ++++++++++++++++ gitnexus/test/unit/tools.test.ts | 1 + 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 29a339ee5b..3322a3038a 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -161,8 +161,8 @@ interface RequiredStringGroup { const TOOL_REQUIRED_STRING_GROUPS: Readonly> = { impact: { - keys: ['target'], - error: 'impact requires at least one of target, name, or symbol.', + keys: ['target', 'target_uid'], + error: 'impact requires target, name, symbol, or target_uid.', }, api_impact: { keys: ['route', 'file'], diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 378452c106..0fcb9c7cde 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -438,7 +438,7 @@ Each edit is tagged with confidence: name: 'impact', description: `Analyze the blast radius of changing a code symbol. Returns affected symbols grouped by depth, plus risk assessment, affected execution flows, and affected modules. -Requires at least one of "target", "name", or "symbol"; aliases are normalized and conflicting values are rejected before repository resolution. +Requires at least one of "target", "name", or "symbol", unless "target_uid" selects the symbol directly; aliases are normalized and conflicting values are rejected before repository resolution. MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/EXTENDS/IMPLEMENTS) — inter-procedural, the established comparator/default behavior. "pdg" requires an index built with \`gitnexus analyze --pdg\` and returns one unified PDG-facing result: statement-level control/data dependence from the persisted PDG plus inter-procedural symbol reach. The explicit interprocedural surface is interproceduralByDepth/pdgInterprocedural; byDepth remains the compatibility symbol bucket. pdg remains incompatible with crossDepth and @group targets; relationTypes/minConfidence filter the inter-symbol reach. diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 78b10b86c9..00abbbecd7 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -396,6 +396,22 @@ describe('LocalBackend.callTool', () => { }, ); + it('preserves impact target_uid-only selection before local dispatch', async () => { + const impactSpy = vi + .spyOn(backend as any, 'impact') + .mockResolvedValue({ status: 'uid-selected' }); + + const result = await backend.callTool('impact', { + target_uid: 'Function:src/auth.ts:validate', + direction: 'upstream', + }); + + expect(result).toEqual({ status: 'uid-selected' }); + expect(impactSpy.mock.calls[0][1]).toMatchObject({ + target_uid: 'Function:src/auth.ts:validate', + }); + }); + it.each([ ['impact', { target: 'validate', name: 'login', direction: 'upstream' }], ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index bf948437f3..daf5a8ed4c 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -169,6 +169,7 @@ describe('GITNEXUS_TOOLS', () => { expect(impactTool.description).toContain( 'Requires at least one of "target", "name", or "symbol"', ); + expect(impactTool.description).toContain('unless "target_uid" selects the symbol directly'); }); it('impact tool advertises the PDG-only `line` statement anchor (integer, min 0, not required)', () => { From a6cfad62a74380c9a1e2dd26709d0d8beae24781 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 23:03:08 +0700 Subject: [PATCH 3/5] test(mcp): guarantee HTTP teardown --- gitnexus/test/unit/mcp-http-transport.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/gitnexus/test/unit/mcp-http-transport.test.ts b/gitnexus/test/unit/mcp-http-transport.test.ts index 91d52a10f9..9c72eab55d 100644 --- a/gitnexus/test/unit/mcp-http-transport.test.ts +++ b/gitnexus/test/unit/mcp-http-transport.test.ts @@ -291,9 +291,15 @@ describe('startMcpHttpServer', () => { expect(tool.inputSchema).not.toHaveProperty('allOf'); } } finally { - await client.close(); - await close(); - await cleanup(); + try { + await client.close(); + } finally { + try { + await close(); + } finally { + await cleanup(); + } + } } }); From 85d6cab8b819aa9aa5b8da12547f8bc3851e10a0 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 23:15:29 +0700 Subject: [PATCH 4/5] fix(mcp): discard blank lookup keys before dispatch --- gitnexus/src/mcp/local/local-backend.ts | 9 ++++++++- gitnexus/test/unit/calltool-dispatch.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 3322a3038a..064839cb84 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -197,11 +197,18 @@ function normalizeToolParams( } const requiredGroup = TOOL_REQUIRED_STRING_GROUPS[method]; + for (const key of requiredGroup?.keys ?? []) { + const value = normalized[key]; + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) normalized[key] = trimmed; + else delete normalized[key]; + } if ( requiredGroup && !requiredGroup.keys.some((key) => { const value = normalized[key]; - return typeof value === 'string' && value.trim().length > 0; + return typeof value === 'string' && value.length > 0; }) ) { return { error: requiredGroup.error }; diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 00abbbecd7..1db098ace7 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -412,6 +412,20 @@ describe('LocalBackend.callTool', () => { }); }); + it('removes blank api_impact lookup keys before dispatching a valid fallback', async () => { + const apiImpactSpy = vi + .spyOn(backend as any, 'apiImpact') + .mockResolvedValue({ status: 'file-selected' }); + + const result = await backend.callTool('api_impact', { + route: ' ', + file: ' src/routes.ts ', + }); + + expect(result).toEqual({ status: 'file-selected' }); + expect(apiImpactSpy.mock.calls[0][1]).toEqual({ file: 'src/routes.ts' }); + }); + it.each([ ['impact', { target: 'validate', name: 'login', direction: 'upstream' }], ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], From 3d0daebd9738a7f53b71dae3a458b72550e9f766 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 23:26:05 +0700 Subject: [PATCH 5/5] fix(mcp): preserve UID impact through group routes --- gitnexus/src/core/group/cross-impact.ts | 9 +++++++-- gitnexus/src/core/group/service.ts | 3 ++- gitnexus/src/mcp/local/local-backend.ts | 4 +++- gitnexus/test/unit/calltool-dispatch.test.ts | 17 +++++++++++++++++ gitnexus/test/unit/group/cross-impact.test.ts | 15 +++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index 70c0af5062..dc47bb4960 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -125,6 +125,7 @@ export function validateGroupImpactParams(params: Record): name: string; repoPath: string; target: string; + targetUid?: string; direction: 'upstream' | 'downstream'; maxDepth: number; crossDepth: number; @@ -140,10 +141,11 @@ export function validateGroupImpactParams(params: Record): const name = String(params.name ?? '').trim(); const repoPath = String(params.repo ?? '').trim(); const target = String(params.target ?? '').trim(); + const targetUid = String(params.target_uid ?? '').trim(); if (!name) return { ok: false, error: 'name is required' }; if (!repoPath) return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' }; - if (!target) return { ok: false, error: 'target is required' }; + if (!target && !targetUid) return { ok: false, error: 'target or target_uid is required' }; if ( params.service !== undefined && params.service !== null && @@ -190,6 +192,7 @@ export function validateGroupImpactParams(params: Record): name, repoPath, target, + targetUid: targetUid || undefined, direction, maxDepth, crossDepth, @@ -446,6 +449,7 @@ export async function runGroupImpact( name, repoPath, target, + targetUid, direction, maxDepth, crossDepth: _crossDepth, @@ -472,7 +476,8 @@ export async function runGroupImpact( if ('error' in resolved) return { error: resolved.error }; const impactParams: Parameters[1] = { - target, + target: target || undefined, + target_uid: targetUid, direction, maxDepth, relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined, diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index c18587ce2f..fd9c36b8d1 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -38,7 +38,8 @@ export interface GroupToolPort { impact( repo: GroupRepoHandle, params: { - target: string; + target?: string; + target_uid?: string; direction: 'upstream' | 'downstream'; maxDepth?: number; relationTypes?: string[]; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 064839cb84..ae9c52e916 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -784,7 +784,8 @@ export class LocalBackend { if (!this.groupToolSvc) { const port: GroupToolPort = { resolveRepo: (p) => this.resolveRepo(p), - impact: (r, p) => this.impact(r as RepoHandle, p), + impact: (r, p) => + this.impact(r as RepoHandle, { ...p, target: p.target ?? '' } as ImpactParams), query: (r, p) => this.query(r as RepoHandle, p), impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o), context: (r, p) => this.context(r as RepoHandle, p), @@ -6546,6 +6547,7 @@ export class LocalBackend { target: params.target, direction: params.direction, }; + if (params.target_uid !== undefined) impactArgs.target_uid = params.target_uid; if (params.maxDepth !== undefined) impactArgs.maxDepth = params.maxDepth; if (params.crossDepth !== undefined) impactArgs.crossDepth = params.crossDepth; if (params.relationTypes !== undefined) impactArgs.relationTypes = params.relationTypes; diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 1db098ace7..6b3c05c7fe 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -454,6 +454,23 @@ describe('LocalBackend.callTool', () => { expect(groupImpactSpy.mock.calls[0][0]).toMatchObject({ target: 'validate' }); }); + it('preserves impact target_uid-only selection before @group forwarding', async () => { + resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' }); + const groupImpactSpy = vi + .spyOn(backend.getGroupService(), 'groupImpact') + .mockResolvedValue({ status: 'uid-selected' } as any); + + await backend.callTool('impact', { + target_uid: 'Function:src/auth.ts:validate', + direction: 'upstream', + repo: '@grp', + }); + + expect(groupImpactSpy.mock.calls[0][0]).toMatchObject({ + target_uid: 'Function:src/auth.ts:validate', + }); + }); + it('dispatches query tool', async () => { (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('query', { query: 'auth' }); diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts index e68ab62094..79c1330117 100644 --- a/gitnexus/test/unit/group/cross-impact.test.ts +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -63,6 +63,21 @@ describe('cross-impact', () => { if (!r.ok) expect(r.error).toContain('direction'); }); + it('accepts target_uid as the group impact selector', () => { + const result = validateGroupImpactParams({ + name: 'suite', + repo: 'app/backend', + target_uid: 'Function:src/auth.ts:validate', + direction: 'upstream', + }); + + expect(result).toMatchObject({ + ok: true, + target: '', + targetUid: 'Function:src/auth.ts:validate', + }); + }); + it('test_validateGroupImpactParams_clamps_crossDepth_and_warns', () => { const r = validateGroupImpactParams({ name: 'g',