Compare commits

..

8 Commits

Author SHA1 Message Date
Michal
e85250fedf fix(cli): stop the MCP stdio bridge serialising requests
Some checks failed
CI/CD / lint (pull_request) Successful in 1m7s
CI/CD / typecheck (pull_request) Successful in 2m11s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m23s
CI/CD / publish (pull_request) Has been skipped
The bridge's stdin loop awaited every request before reading the next line, so
it handled exactly one at a time. A single slow call therefore stalled every
later request — and because those requests were never even sent, nothing could
time them out. The client saw silence, not an error.

Observed 2026-08-05: two gitea calls through this bridge sat completely mute
until Claude Code aborted them at its own 1800s idle limit, reporting "sent no
response or progress". The upstream was healthy the whole time — a fresh
session answered the same tools in 0.3s, and the gitea MCP server's own log
showed the calls never reached it. They died queued in the bridge.

JSON-RPC ids exist precisely so responses may return out of order, so nothing
here needed a queue. Requests now dispatch concurrently and are tracked in a
set; stdin close awaits them before the session DELETE, otherwise a concurrent
call races the teardown and dies with a 404.

We still serialise until the session id exists: it arrives on the first
response, and firing later requests without it would open a second upstream
session. A client sends `initialize` first and waits for the reply anyway, so
this costs one round trip rather than throughput.

Also makes the per-request timeout configurable via MCPCTL_MCP_TIMEOUT_MS
(default unchanged at 30s) and names it in the timeout error, so a project with
genuinely long tool calls can raise it instead of hitting a hardcoded wall.

Tests pin both halves: a fast request must overtake a slow one (this fails on
the old serial code — verified by reverting), and a failed request must still
produce a JSON-RPC error carrying the original id rather than nothing.
2026-08-07 15:19:50 +01:00
cf85e36ede Merge pull request 'feat(gate): drop reasoning field from selection (~1.5s)' (#90) from feat/gate-drop-reasoning into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m5s
CI/CD / lint (push) Successful in 2m10s
CI/CD / test (push) Successful in 1m19s
CI/CD / smoke (push) Failing after 1m50s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-07-25 02:07:30 +00:00
Michal
34e00af731 feat(gate): drop the reasoning field from selection → ~1.5s (comfortable margin)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m12s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
Even "<=8 words" was ignored — the model wrote a full-sentence reasoning, pushing
begin_session to ~7s (thin under the 8s budget). The gate only needs the names, so
request just {"selectedNames":[...]}. Validated live vs vllm-current
(glm-4.6-reap-fast): 1.5s, valid JSON. (reasoning defaults to '' in extractSelection.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 03:07:28 +01:00
2f9e7be762 feat(gate): terse selection prompt (~2s) (#89)
Some checks failed
CI/CD / typecheck (push) Successful in 1m4s
CI/CD / lint (push) Successful in 2m8s
CI/CD / test (push) Successful in 1m20s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m7s
CI/CD / publish (push) Has been skipped
2026-07-24 20:38:32 +00:00
Michal
a432e9e19f feat(gate): terse selection prompt so a no-think model answers in ~2s
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m21s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 3m16s
CI/CD / publish (pull_request) Has been skipped
With the fast (no-think) route, gate-selection latency is now proportional to the
ANSWER length (a no-think model runs ~10-13 tok/s), not hidden thinking. A verbose
"reasoning" field pushed a selection to ~9s (over the 8s budget). Request compact
JSON with a <=8-word reasoning → ~2s (vs ~9s prose, or ~1.2s with no reasoning).
Validated live against vllm-fast (glm-4.6-reap-fast): 2.0s, valid selection JSON,
zero reasoning tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:38:08 +01:00
3b96b0399e feat(gate): fast no-think selection + dedicated selection Llm (#88)
Some checks failed
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m17s
CI/CD / smoke (push) Failing after 1m50s
CI/CD / build (push) Successful in 4m5s
CI/CD / publish (push) Has been skipped
2026-07-24 13:29:58 +00:00
Michal
5225b54901 feat(gate): MCPCTL_GATE_SELECTION_LLM — pin a dedicated fast selection Llm
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m7s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m11s
CI/CD / smoke (pull_request) Failing after 2m47s
CI/CD / publish (pull_request) Has been skipped
Lets ops route gate prompt-selection at a fast (no-think) server Llm independent
of the project's chat llmProvider — so selection stays ~1-2s while chat keeps its
thinking model. Overrides the project llmProvider for the gate's server-selection
path; unset → prior behavior (use the project's llmProvider). Pairs with the
litellm qwen3-fast no-think alias (kubernetes-deployment) + a `vllm-fast` mcpd Llm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 14:28:44 +01:00
Michal
27480181bf feat(gate): ask reasoning models for a fast, no-think selection response
Prompt selection is mechanical classification — it gains nothing from chain-of-
thought, and a reasoning model (qwen3-thinking) otherwise burns its whole budget
reasoning (~40s, 6k+ chars) and blows the gate's 8s timeout. The gate's server
selection request now includes thinking-suppression hints, forwarded verbatim by
mcpd's passthrough adapter to litellm/vLLM:
  chat_template_kwargs.enable_thinking=false  (Qwen3 hard-off)
  reasoning_effort=low                         (o-series / newer vLLM)
Harmless for models that ignore them; if a backend rejects them the selector
falls back to the local provider. Override via MCPCTL_GATE_SELECT_EXTRA_BODY
(JSON), or '' to disable.

NOT yet validated live — the qwen3-thinking vLLM is crashlooping again (0/1,
HTTP 500), and whether litellm forwards chat_template_kwargs to vLLM is unconfirmed
(the /no_think prompt directive was NOT honored). Validate when the backend
recovers. mcpld gate/selector tests green (75).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:07:27 +01:00
5 changed files with 195 additions and 21 deletions

View File

@@ -11,6 +11,14 @@ export interface McpBridgeOptions {
stderr: NodeJS.WritableStream; stderr: NodeJS.WritableStream;
} }
/**
* Per-request socket-inactivity timeout for the bridge. A stalled upstream must
* become a JSON-RPC error, not silence: the client cannot distinguish "still
* working" from "wedged", and its own idle limit may be half an hour away.
* Raise it for a project with genuinely long tool calls.
*/
export const BRIDGE_TIMEOUT_MS = Number(process.env['MCPCTL_MCP_TIMEOUT_MS']) || 30_000;
export function postJsonRpc( export function postJsonRpc(
url: string, url: string,
body: string, body: string,
@@ -37,7 +45,7 @@ export function postJsonRpc(
path: parsed.pathname, path: parsed.pathname,
method: 'POST', method: 'POST',
headers, headers,
timeout: 30_000, timeout: BRIDGE_TIMEOUT_MS,
}, },
(res) => { (res) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -54,7 +62,7 @@ export function postJsonRpc(
req.on('error', reject); req.on('error', reject);
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error('Request timed out')); reject(new Error(`Request timed out after ${BRIDGE_TIMEOUT_MS}ms (set MCPCTL_MCP_TIMEOUT_MS to change)`));
}); });
req.write(body); req.write(body);
req.end(); req.end();
@@ -128,19 +136,15 @@ export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
const rl = createInterface({ input: stdin, crlfDelay: Infinity }); const rl = createInterface({ input: stdin, crlfDelay: Infinity });
for await (const line of rl) { /**
const trimmed = line.trim(); * In-flight requests. Dispatch is CONCURRENT after the session is
if (!trimmed) continue; * established — see the head-of-line note below — so stdin can keep being
* read while a slow call is outstanding.
// Parse request ID for error responses */
let requestId: unknown = null; const inFlight = new Set<Promise<void>>();
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
requestId = parsed.id ?? null;
} catch {
// Non-JSON or notification — no id to respond to
}
/** POST one JSON-RPC message and write whatever comes back to stdout. */
const dispatch = async (trimmed: string, requestId: unknown): Promise<void> => {
try { try {
const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token); const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token);
@@ -178,9 +182,48 @@ export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
stdout.write(errorResponse + '\n'); stdout.write(errorResponse + '\n');
} }
} }
};
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
// Parse request ID for error responses
let requestId: unknown = null;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
requestId = parsed.id ?? null;
} catch {
// Non-JSON or notification — no id to respond to
} }
// stdin closed — cleanup session // HEAD-OF-LINE BLOCKING: this loop used to `await` every request, so the
// bridge handled exactly one at a time. A single slow call stalled EVERY
// later request — the client saw silence rather than an error, because the
// queued requests were never even sent, so nothing could time them out.
// Observed 2026-08-05: two gitea calls sat mute until the client aborted
// them at its own 1800s idle limit, while the upstream server was healthy
// and answering other sessions in milliseconds. JSON-RPC ids exist exactly
// so responses can come back out of order; nothing here needs a queue.
//
// We still serialise until the session id exists: it comes back on the
// first response, and firing later requests without it would open a second
// upstream session. In practice a client sends `initialize` first and waits
// for its reply anyway, so this costs one round trip, not throughput.
if (sessionId === undefined) {
await dispatch(trimmed, requestId);
continue;
}
const p = dispatch(trimmed, requestId).finally(() => inFlight.delete(p));
inFlight.add(p);
}
// stdin closed — let outstanding work finish before tearing the session down,
// otherwise a concurrent call races the DELETE and dies with a 404.
if (inFlight.size > 0) {
await Promise.allSettled([...inFlight]);
}
if (sessionId) { if (sessionId) {
await sendDelete(endpointUrl, sessionId, token); await sendDelete(endpointUrl, sessionId, token);
} }

View File

@@ -483,3 +483,105 @@ describe('createMcpCommand', () => {
expect(parsed.opts().project).toBe('my-project'); expect(parsed.opts().project).toBe('my-project');
}); });
}); });
// ---------------------------------------------------------------------------
// Regression: head-of-line blocking (2026-08-05)
//
// The bridge used to `await` every request inside its stdin loop, so it handled
// exactly one at a time. A single slow call stalled every later request, and
// because those requests were never even sent, nothing could time them out —
// the client just saw silence until its own idle limit fired (30 min, in the
// incident that prompted this). These pin both halves of the fix: later
// requests must not queue behind a slow one, and a stalled request must produce
// a JSON-RPC error rather than nothing.
// ---------------------------------------------------------------------------
describe('MCP bridge concurrency', () => {
let srv: http.Server;
let port: number;
function sse(id: number | string, result: unknown) {
return `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id, result })}\n\n`;
}
beforeAll(async () => {
srv = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const msg = JSON.parse(body || '{}') as { id?: number | string; method?: string; params?: any };
const headers: Record<string, string> = { 'Content-Type': 'text/event-stream' };
if (msg.method === 'initialize') headers['mcp-session-id'] = 'sess-1';
// `slow` blocks far longer than `fast`, so a serial bridge would force
// fast's response to arrive second.
const delay = msg.params?.name === 'slow' ? 400 : 0;
setTimeout(() => {
res.writeHead(200, headers);
res.end(sse(msg.id ?? 0, { ok: msg.params?.name ?? msg.method }));
}, delay);
});
});
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
port = (srv.address() as any).port;
});
afterAll(async () => {
await new Promise<void>((r) => srv.close(() => r()));
});
function bridge(lines: string[], out: string[]) {
const stdin = Readable.from(lines.map((l) => l + '\n'));
const stdout = new Writable({
write(chunk, _enc, cb) {
out.push(chunk.toString().trim());
cb();
},
});
const stderr = new Writable({ write(_c, _e, cb) { cb(); } });
return runMcpBridge({
projectName: 'p',
mcplocalUrl: `http://127.0.0.1:${port}`,
stdin,
stdout,
stderr,
});
}
it('does not let a slow request block a later fast one', async () => {
const out: string[] = [];
await bridge(
[
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'slow' } }),
JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'fast' } }),
],
out,
);
const ids = out.map((l) => (JSON.parse(l) as { id: number }).id);
expect(ids).toContain(2);
expect(ids).toContain(3);
// The whole point: fast (id 3) overtakes slow (id 2). Serially it could not.
expect(ids.indexOf(3)).toBeLessThan(ids.indexOf(2));
});
it('answers with a JSON-RPC error instead of silence when a request fails', async () => {
const out: string[] = [];
const stdin = Readable.from([
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n',
]);
const stdout = new Writable({
write(chunk, _enc, cb) { out.push(chunk.toString().trim()); cb(); },
});
const stderr = new Writable({ write(_c, _e, cb) { cb(); } });
// Port with nothing on it: the POST fails fast, and the bridge must still
// emit a response carrying the original id.
await runMcpBridge({
projectName: 'p',
mcplocalUrl: 'http://127.0.0.1:1',
stdin, stdout, stderr,
});
expect(out.length).toBeGreaterThan(0);
const msg = JSON.parse(out[0]!) as { id: number; error?: { code: number } };
expect(msg.id).toBe(1);
expect(msg.error?.code).toBe(-32603);
});
});

View File

@@ -95,7 +95,11 @@ export class LlmPromptSelector {
): Promise<LlmSelectionResult> { ): Promise<LlmSelectionResult> {
const { getSystemPromptFn, signal, serverInfer } = opts; const { getSystemPromptFn, signal, serverInfer } = opts;
const DEFAULT_SYSTEM_PROMPT = `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`; // Keep the answer as SHORT as possible: latency is proportional to output
// length (a no-think model runs ~10-18 tok/s). Even an "<=8 words" reasoning
// got ignored (models write a full sentence → ~7s). Drop the reasoning field
// entirely — the gate only needs the names → just {selectedNames} → ~1-2s.
const DEFAULT_SYSTEM_PROMPT = `You are a context selection assistant. Given a developer's task keywords and available project prompts, select the relevant ones. Return ONLY this JSON and nothing else: {"selectedNames":[...]}. No reasoning, no prose, no explanation. Priority 10 prompts must always be included.`;
const systemPrompt = getSystemPromptFn const systemPrompt = getSystemPromptFn
? await getSystemPromptFn('llm-gate-context-selector', DEFAULT_SYSTEM_PROMPT) ? await getSystemPromptFn('llm-gate-context-selector', DEFAULT_SYSTEM_PROMPT)
: DEFAULT_SYSTEM_PROMPT; : DEFAULT_SYSTEM_PROMPT;
@@ -105,7 +109,7 @@ export class LlmPromptSelector {
Available prompts: Available prompts:
${promptIndex.map((p) => `- ${p.name} (priority: ${p.priority}): ${p.summary ?? 'No summary'}${p.chapters?.length ? `\n Chapters: ${p.chapters.join(', ')}` : ''}`).join('\n')} ${promptIndex.map((p) => `- ${p.name} (priority: ${p.priority}): ${p.summary ?? 'No summary'}${p.chapters?.length ? `\n Chapters: ${p.chapters.join(', ')}` : ''}`).join('\n')}
Select the relevant prompts. Return JSON: { "selectedNames": [...], "reasoning": "..." }`; Select the relevant prompts. Return ONLY: {"selectedNames":[...]}`;
const messages: ChatMessage[] = [ const messages: ChatMessage[] = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },

View File

@@ -147,10 +147,14 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
providerRegistry: effectiveRegistry, providerRegistry: effectiveRegistry,
}; };
if (resolvedModel) pluginConfig.modelOverride = resolvedModel; if (resolvedModel) pluginConfig.modelOverride = resolvedModel;
// Route gate prompt-selection through this project's server Llm (mcpd // Route gate prompt-selection through a server Llm (mcpd inference proxy)
// inference proxy) so cloud/server keys stay at the k8s level; the local // so cloud/server keys stay at the k8s level; the local personal-token
// personal-token provider is the fallback. See credential-tiering. // provider is the fallback. See credential-tiering. A dedicated fast
if (mcpdConfig.llmProvider) pluginConfig.llmProvider = mcpdConfig.llmProvider; // (no-think) selection Llm can be pinned globally via
// MCPCTL_GATE_SELECTION_LLM — it overrides the project's chat llmProvider so
// selection stays fast while chat keeps its (thinking) model.
const gateSelectionLlm = process.env['MCPCTL_GATE_SELECTION_LLM'] || mcpdConfig.llmProvider;
if (gateSelectionLlm) pluginConfig.llmProvider = gateSelectionLlm;
const basePlugin = createDefaultPlugin(pluginConfig); const basePlugin = createDefaultPlugin(pluginConfig);
// Optional favourite-index presentation: curated favourite/<tool> + full // Optional favourite-index presentation: curated favourite/<tool> + full
// all/<server>/<tool> + a "prefer favourite/" instruction. Composed AFTER // all/<server>/<tool> + a "prefer favourite/" instruction. Composed AFTER

View File

@@ -21,6 +21,26 @@ import { withTimeout, TimeoutError } from '../../util/with-timeout.js';
* begin_session — on timeout we fall back to deterministic tag matching. */ * begin_session — on timeout we fall back to deterministic tag matching. */
const GATE_LLM_TIMEOUT_MS = Number(process.env['MCPCTL_GATE_LLM_TIMEOUT_MS'] ?? '8000'); const GATE_LLM_TIMEOUT_MS = Number(process.env['MCPCTL_GATE_LLM_TIMEOUT_MS'] ?? '8000');
/**
* Extra request fields for the gate's server-Llm selection call. Prompt
* selection is mechanical classification that gains nothing from chain-of-
* thought, so we ask reasoning models to skip thinking (they otherwise burn the
* whole budget reasoning and blow the gate timeout). Sent verbatim by mcpd's
* passthrough adapter → litellm/vLLM. Harmless for models that ignore them, and
* if a backend rejects them the selector falls back to the local provider.
* - `chat_template_kwargs.enable_thinking:false` — Qwen3 hard-off.
* - `reasoning_effort:'low'` — OpenAI o-series / newer vLLM.
* Override with a JSON object in MCPCTL_GATE_SELECT_EXTRA_BODY, or '' to disable.
*/
const GATE_SELECT_EXTRA_BODY: Record<string, unknown> = (() => {
const raw = process.env['MCPCTL_GATE_SELECT_EXTRA_BODY'];
if (raw === '') return {};
if (raw !== undefined) {
try { return JSON.parse(raw) as Record<string, unknown>; } catch { /* use default */ }
}
return { chat_template_kwargs: { enable_thinking: false }, reasoning_effort: 'low' };
})();
export interface GatePluginConfig { export interface GatePluginConfig {
gated?: boolean; gated?: boolean;
providerRegistry?: ProviderRegistry | null; providerRegistry?: ProviderRegistry | null;
@@ -308,6 +328,7 @@ async function handleBeginSession(
temperature: o.temperature, temperature: o.temperature,
max_tokens: o.maxTokens, max_tokens: o.maxTokens,
stream: false, stream: false,
...GATE_SELECT_EXTRA_BODY, // ask reasoning models for a fast, no-think answer
}); });
return pickCompletionText(resp); return pickCompletionText(resp);
} }