Skip to content

Commit 50544c5

Browse files
committed
fix(mcp-server): suppress dispatch-ready execution while gated (#1422)
When executionGate.gated=true, the parse_mode response now suppresses expensive specialist dispatch metadata to prevent clients from proceeding with costly execution during clarification: - dispatchReady is fully removed (no Task-tool-ready params leak) - executionPlan is fully removed (no execution plan metadata leaks) - parallelAgentsRecommendation.dispatch is downgraded to "deferred" - Specialist names and hints are preserved for transparency Added suppressDispatchWhileGated() pure function in execution-gate.ts with 14 new tests covering gated suppression, ungated pass-through, and edge cases.
1 parent 7006180 commit 50544c5

4 files changed

Lines changed: 357 additions & 8 deletions

File tree

apps/mcp-server/src/mcp/handlers/execution-gate.spec.ts

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect } from 'vitest';
2-
import { evaluateExecutionGate, type ExecutionGateInput } from './execution-gate';
2+
import {
3+
evaluateExecutionGate,
4+
suppressDispatchWhileGated,
5+
type ExecutionGateInput,
6+
type ExecutionGate,
7+
type GatedResponseFields,
8+
} from './execution-gate';
39
import type { ClarificationMetadata } from './clarification-gate';
410
import type { PlanningStageMetadata } from './planning-stage';
511

@@ -273,4 +279,242 @@ describe('execution-gate', () => {
273279
});
274280
});
275281
});
282+
283+
// ====================================================================
284+
// suppressDispatchWhileGated (#1422)
285+
// ====================================================================
286+
287+
describe('suppressDispatchWhileGated', () => {
288+
const sampleDispatchReady = {
289+
primaryAgent: {
290+
name: 'software-engineer',
291+
displayName: 'Software Engineer',
292+
description: 'Software Engineer - PLAN mode',
293+
dispatchParams: {
294+
subagent_type: 'general-purpose' as const,
295+
prompt: 'You are a software engineer...',
296+
description: 'Software Engineer - PLAN mode',
297+
},
298+
},
299+
parallelAgents: [
300+
{
301+
name: 'security-specialist',
302+
displayName: 'Security Specialist',
303+
description: 'Security specialist',
304+
dispatchParams: {
305+
subagent_type: 'general-purpose' as const,
306+
prompt: 'You are a security specialist...',
307+
description: 'Security specialist',
308+
run_in_background: true as const,
309+
},
310+
},
311+
{
312+
name: 'performance-specialist',
313+
displayName: 'Performance Specialist',
314+
description: 'Performance specialist',
315+
dispatchParams: {
316+
subagent_type: 'general-purpose' as const,
317+
prompt: 'You are a performance specialist...',
318+
description: 'Performance specialist',
319+
run_in_background: true as const,
320+
},
321+
},
322+
],
323+
};
324+
325+
const sampleParallelRecommendation = {
326+
specialists: ['security-specialist', 'performance-specialist'],
327+
hint: 'Dispatch specialists for review',
328+
dispatch: 'auto' as const,
329+
suggestedStack: 'review',
330+
stackBased: true,
331+
};
332+
333+
// ------------------------------------------------------------------
334+
// Gated: suppression
335+
// ------------------------------------------------------------------
336+
337+
describe('when gated=true', () => {
338+
const gatedGate: ExecutionGate = {
339+
gated: true,
340+
reason: 'Request is ambiguous',
341+
unblockCondition: 'Resolve clarification',
342+
deferredSpecialists: ['security-specialist', 'performance-specialist'],
343+
};
344+
345+
it('removes dispatchReady entirely', () => {
346+
const fields: GatedResponseFields = {
347+
dispatchReady: sampleDispatchReady,
348+
parallelAgentsRecommendation: sampleParallelRecommendation,
349+
};
350+
351+
const result = suppressDispatchWhileGated(gatedGate, fields);
352+
353+
expect(result.dispatchReady).toBeUndefined();
354+
});
355+
356+
it('downgrades parallelAgentsRecommendation.dispatch to "deferred"', () => {
357+
const fields: GatedResponseFields = {
358+
parallelAgentsRecommendation: { ...sampleParallelRecommendation, dispatch: 'auto' },
359+
};
360+
361+
const result = suppressDispatchWhileGated(gatedGate, fields);
362+
363+
expect(result.parallelAgentsRecommendation?.dispatch).toBe('deferred');
364+
});
365+
366+
it('preserves specialist names in parallelAgentsRecommendation for transparency', () => {
367+
const fields: GatedResponseFields = {
368+
parallelAgentsRecommendation: sampleParallelRecommendation,
369+
};
370+
371+
const result = suppressDispatchWhileGated(gatedGate, fields);
372+
373+
expect(result.parallelAgentsRecommendation?.specialists).toEqual([
374+
'security-specialist',
375+
'performance-specialist',
376+
]);
377+
});
378+
379+
it('preserves hint in parallelAgentsRecommendation', () => {
380+
const fields: GatedResponseFields = {
381+
parallelAgentsRecommendation: sampleParallelRecommendation,
382+
};
383+
384+
const result = suppressDispatchWhileGated(gatedGate, fields);
385+
386+
expect(result.parallelAgentsRecommendation?.hint).toBe(
387+
'Dispatch specialists for review',
388+
);
389+
});
390+
391+
it('removes executionPlan when gated', () => {
392+
const fields: GatedResponseFields = {
393+
dispatchReady: sampleDispatchReady,
394+
executionPlan: { strategy: 'subagent', layers: [] },
395+
};
396+
397+
const result = suppressDispatchWhileGated(gatedGate, fields);
398+
399+
expect(result.executionPlan).toBeUndefined();
400+
});
401+
402+
it('handles missing dispatchReady gracefully', () => {
403+
const fields: GatedResponseFields = {
404+
parallelAgentsRecommendation: sampleParallelRecommendation,
405+
};
406+
407+
const result = suppressDispatchWhileGated(gatedGate, fields);
408+
409+
expect(result.dispatchReady).toBeUndefined();
410+
expect(result.parallelAgentsRecommendation?.dispatch).toBe('deferred');
411+
});
412+
413+
it('handles missing parallelAgentsRecommendation gracefully', () => {
414+
const fields: GatedResponseFields = {
415+
dispatchReady: sampleDispatchReady,
416+
};
417+
418+
const result = suppressDispatchWhileGated(gatedGate, fields);
419+
420+
expect(result.dispatchReady).toBeUndefined();
421+
expect(result.parallelAgentsRecommendation).toBeUndefined();
422+
});
423+
424+
it('handles all fields missing gracefully', () => {
425+
const fields: GatedResponseFields = {};
426+
427+
const result = suppressDispatchWhileGated(gatedGate, fields);
428+
429+
expect(result.dispatchReady).toBeUndefined();
430+
expect(result.parallelAgentsRecommendation).toBeUndefined();
431+
expect(result.executionPlan).toBeUndefined();
432+
});
433+
434+
it('does not mutate the original fields object', () => {
435+
const fields: GatedResponseFields = {
436+
dispatchReady: sampleDispatchReady,
437+
parallelAgentsRecommendation: { ...sampleParallelRecommendation },
438+
};
439+
const originalDispatch = fields.parallelAgentsRecommendation?.dispatch;
440+
441+
suppressDispatchWhileGated(gatedGate, fields);
442+
443+
// Original should be untouched
444+
expect(fields.dispatchReady).toBe(sampleDispatchReady);
445+
expect(fields.parallelAgentsRecommendation?.dispatch).toBe(originalDispatch);
446+
});
447+
448+
it('downgrades "recommend" dispatch to "deferred"', () => {
449+
const fields: GatedResponseFields = {
450+
parallelAgentsRecommendation: { ...sampleParallelRecommendation, dispatch: 'recommend' },
451+
};
452+
453+
const result = suppressDispatchWhileGated(gatedGate, fields);
454+
455+
expect(result.parallelAgentsRecommendation?.dispatch).toBe('deferred');
456+
});
457+
});
458+
459+
// ------------------------------------------------------------------
460+
// Ungated: pass-through
461+
// ------------------------------------------------------------------
462+
463+
describe('when gated=false', () => {
464+
const ungatedGate: ExecutionGate = {
465+
gated: false,
466+
reason: 'Request is clear',
467+
};
468+
469+
it('preserves dispatchReady unchanged', () => {
470+
const fields: GatedResponseFields = {
471+
dispatchReady: sampleDispatchReady,
472+
parallelAgentsRecommendation: sampleParallelRecommendation,
473+
};
474+
475+
const result = suppressDispatchWhileGated(ungatedGate, fields);
476+
477+
expect(result.dispatchReady).toBe(sampleDispatchReady);
478+
});
479+
480+
it('preserves parallelAgentsRecommendation unchanged', () => {
481+
const fields: GatedResponseFields = {
482+
parallelAgentsRecommendation: sampleParallelRecommendation,
483+
};
484+
485+
const result = suppressDispatchWhileGated(ungatedGate, fields);
486+
487+
expect(result.parallelAgentsRecommendation).toBe(sampleParallelRecommendation);
488+
});
489+
490+
it('preserves executionPlan unchanged', () => {
491+
const executionPlan = { strategy: 'subagent', layers: [] };
492+
const fields: GatedResponseFields = {
493+
executionPlan,
494+
};
495+
496+
const result = suppressDispatchWhileGated(ungatedGate, fields);
497+
498+
expect(result.executionPlan).toBe(executionPlan);
499+
});
500+
});
501+
502+
// ------------------------------------------------------------------
503+
// Edge: undefined gate
504+
// ------------------------------------------------------------------
505+
506+
describe('when gate is undefined', () => {
507+
it('passes through all fields unchanged', () => {
508+
const fields: GatedResponseFields = {
509+
dispatchReady: sampleDispatchReady,
510+
parallelAgentsRecommendation: sampleParallelRecommendation,
511+
};
512+
513+
const result = suppressDispatchWhileGated(undefined, fields);
514+
515+
expect(result.dispatchReady).toBe(sampleDispatchReady);
516+
expect(result.parallelAgentsRecommendation).toBe(sampleParallelRecommendation);
517+
});
518+
});
519+
});
276520
});

apps/mcp-server/src/mcp/handlers/execution-gate.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,82 @@ export function evaluateExecutionGate(input: ExecutionGateInput): ExecutionGate
9999
};
100100
}
101101

102+
// ---------------------------------------------------------------------------
103+
// Response suppression (#1422)
104+
// ---------------------------------------------------------------------------
105+
106+
/** Minimal parallel-agent recommendation shape for gating. */
107+
interface GatedParallelRecommendation {
108+
specialists: string[];
109+
hint: string;
110+
dispatch?: string;
111+
suggestedStack?: string;
112+
stackBased?: boolean;
113+
}
114+
115+
/**
116+
* Fields from the parse_mode response that may need suppression while gated.
117+
* Uses a generic for dispatchReady and executionPlan so the caller can pass
118+
* the concrete types from keyword.types without coupling this module to them.
119+
*/
120+
export interface GatedResponseFields<
121+
D = unknown,
122+
E = unknown,
123+
> {
124+
dispatchReady?: D;
125+
parallelAgentsRecommendation?: GatedParallelRecommendation;
126+
executionPlan?: E;
127+
}
128+
129+
/** Return type mirrors the input but with suppressed fields. */
130+
export type SuppressedResponseFields<
131+
D = unknown,
132+
E = unknown,
133+
> = GatedResponseFields<D, E>;
134+
135+
/**
136+
* Suppress or downgrade dispatch-ready metadata when the execution gate
137+
* is active (#1422).
138+
*
139+
* When `gate.gated === true`:
140+
* - `dispatchReady` is removed (no Task-tool-ready params leak).
141+
* - `executionPlan` is removed (no execution plan metadata leaks).
142+
* - `parallelAgentsRecommendation.dispatch` is downgraded to `"deferred"`.
143+
* - Specialist names and hint are preserved for transparency.
144+
*
145+
* When `gate` is undefined or ungated, all fields pass through unchanged.
146+
*
147+
* This function is pure and does NOT mutate its inputs.
148+
*/
149+
export function suppressDispatchWhileGated<D, E>(
150+
gate: ExecutionGate | undefined,
151+
fields: GatedResponseFields<D, E>,
152+
): SuppressedResponseFields<D, E> {
153+
// No gate or ungated → pass through
154+
if (!gate || !gate.gated) {
155+
return fields;
156+
}
157+
158+
// Gated → suppress expensive dispatch metadata
159+
const result: SuppressedResponseFields<D, E> = {};
160+
161+
// dispatchReady: fully removed (no dispatch params should leak)
162+
// result.dispatchReady stays undefined
163+
164+
// executionPlan: fully removed
165+
// result.executionPlan stays undefined
166+
167+
// parallelAgentsRecommendation: keep specialist names, downgrade dispatch
168+
if (fields.parallelAgentsRecommendation) {
169+
result.parallelAgentsRecommendation = {
170+
...fields.parallelAgentsRecommendation,
171+
dispatch: 'deferred',
172+
};
173+
}
174+
175+
return result;
176+
}
177+
102178
// ---------------------------------------------------------------------------
103179
// Helpers
104180
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)