fix(mcp): validate aliases without schema combinators

This commit is contained in:
Eva 2026-07-16 09:29:47 +07:00
parent f12493a97c
commit eb684f51b7
4 changed files with 57 additions and 10 deletions

View file

@ -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 };
}

View file

@ -591,7 +591,6 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
},
},
required: ['direction'],
anyOf: [{ required: ['target'] }, { required: ['name'] }, { required: ['symbol'] }],
},
},
{

View file

@ -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

View file

@ -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)', () => {