diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 2cf60e64b..72741bd2d 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -163,10 +163,15 @@ function normalizeToolParams( const normalized = { ...input }; for (const { canonical, aliases } of definitions) { const keys = [canonical, ...aliases]; - const supplied = keys.flatMap((key) => { + const supplied: Array<{ key: string; value: string }> = []; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(input, key)) continue; const value = input[key]; - return typeof value === 'string' && value.trim() ? [{ key, value: value.trim() }] : []; - }); + if (typeof value !== 'string' || !value.trim()) { + return { error: `MCP parameter ${method}.${key} must be a non-empty string.` }; + } + supplied.push({ key, value: value.trim() }); + } const distinctValues = new Set(supplied.map(({ value }) => value)); if (distinctValues.size > 1) { return { @@ -179,6 +184,14 @@ function normalizeToolParams( for (const alias of aliases) delete normalized[alias]; if (supplied.length > 0) normalized[canonical] = supplied[0].value; } + + if ( + method === 'impact' && + typeof normalized.target !== 'string' && + (typeof normalized.target_uid !== 'string' || !normalized.target_uid.trim()) + ) { + return { error: 'MCP impact requires target, name, symbol, or target_uid.' }; + } return { params: normalized }; } diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 57f71f675..792f62b3b 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -591,7 +591,6 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep }, }, required: ['direction'], - anyOf: [{ required: ['target'] }, { required: ['name'] }, { required: ['symbol'] }], }, }, { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index cbd2f3cef..7439f0239 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -391,6 +391,43 @@ describe('LocalBackend.callTool', () => { expect(resolveSpy).not.toHaveBeenCalled(); }); + it.each([ + ['impact', { target: '', direction: 'upstream' }], + ['impact', { name: 42, direction: 'upstream' }], + ['context', { name: 'validate', file: ' ' }], + ])('rejects invalid %s aliases before repository resolution', async (method, params) => { + const resolveSpy = vi.spyOn(backend, 'resolveRepo'); + + const result = await backend.callTool(method, params); + + expect(result.error).toMatch(/non-empty string/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it('rejects a missing impact target before repository resolution', async () => { + const resolveSpy = vi.spyOn(backend, 'resolveRepo'); + + const result = await backend.callTool('impact', { direction: 'upstream' }); + + expect(result.error).toMatch(/requires target, name, symbol, or target_uid/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it('preserves target_uid-only impact dispatch', async () => { + const impactSpy = vi + .spyOn(backend as any, 'impact') + .mockResolvedValue({ status: 'normalized' }); + + await backend.callTool('impact', { + target_uid: 'Function:src/auth.ts:validate', + direction: 'upstream', + }); + + expect(impactSpy.mock.calls[0][1]).toMatchObject({ + target_uid: 'Function:src/auth.ts:validate', + }); + }); + it('normalizes impact aliases before @group forwarding', async () => { resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' }); const groupImpactSpy = vi diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 4757fd9f7..82cd718fc 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -144,17 +144,15 @@ describe('GITNEXUS_TOOLS', () => { expect(apiImpactTool.inputSchema.required).toEqual([]); }); - it('impact tool requires direction and accepts target, name, or symbol', () => { + it('impact tool requires direction and advertises target, name, or symbol without combinators', () => { const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!; expect(impactTool.inputSchema.required).toContain('direction'); 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.inputSchema).not.toHaveProperty('anyOf'); + expect(impactTool.inputSchema).not.toHaveProperty('oneOf'); + expect(impactTool.inputSchema).not.toHaveProperty('allOf'); }); it('impact tool advertises the PDG-only `line` statement anchor (integer, min 0, not required)', () => {