Compare commits

...

10 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
86942b1e91 feat(gate): route prompt-selection through mcpd server Llm (#87)
Some checks failed
CI/CD / typecheck (push) Successful in 1m2s
CI/CD / lint (push) Successful in 2m7s
CI/CD / test (push) Successful in 1m18s
CI/CD / smoke (push) Failing after 1m51s
CI/CD / build (push) Successful in 4m8s
CI/CD / publish (push) Has been skipped
2026-07-24 11:52:03 +00:00
Michal
2729580974 feat(gate): route prompt-selection through mcpd's server Llm (credential tiering)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m3s
CI/CD / lint (pull_request) Successful in 2m6s
CI/CD / test (pull_request) Successful in 1m18s
CI/CD / build (pull_request) Successful in 2m10s
CI/CD / smoke (pull_request) Failing after 3m9s
CI/CD / publish (pull_request) Has been skipped
Cloud/server keys belong at the k8s/mcpd level; mcplocal handles only the user's
personal tokens. The gate's prompt-selection used mcplocal's LOCAL provider
registry (heavy=anthropic = the user's OAuth token, which is API-gated and 404s),
so selection always degraded.

Now LlmPromptSelector tries sources in priority order: (1) the project's server
Llm via mcpd's inference proxy (POST /api/v1/llms/:name/infer) — cloud/server
keys stay at k8s — then (2) the local personal-token provider as fallback. First
source that returns valid selection JSON wins. Extracted pickCompletionText
(content ?? reasoning_content) + extractSelection helpers.

Wiring: GatePluginConfig.llmProvider (from the project), threaded via
createDefaultPlugin; handleBeginSession builds serverInfer from ctx.postToMcpd.
Verified live: POST .../llms/vllm-current/infer returns valid selection JSON
(qwen3-thinking, 4000-token budget). + selector tests (server preferred, local
fallback on error, server-only). mcplocal suite green (755).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:43:48 +01:00
6 changed files with 378 additions and 64 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
}
// 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 — cleanup session // 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

@@ -1,12 +1,16 @@
/** /**
* LLM-based prompt selection for the gating flow. * LLM-based prompt selection for the gating flow.
* *
* Sends tags + prompt index to the heavy LLM, which returns * Sends tags + prompt index to a heavy LLM, which returns a ranked list of
* a ranked list of relevant prompt names. * relevant prompt names. Credential tiering (user rule): prefer mcpd's server
* `Llm` (cloud/server keys live at the k8s level) via the inference proxy, and
* fall back to the local (personal-token) provider registry only when the
* server path is unavailable. Cloud keys never live in mcplocal config.
*/ */
import type { ProviderRegistry } from '../providers/registry.js'; import type { ProviderRegistry } from '../providers/registry.js';
import type { SystemPromptFetcher } from '../proxymodel/types.js'; import type { SystemPromptFetcher } from '../proxymodel/types.js';
import type { ChatMessage, CompletionOptions } from '../providers/types.js';
export interface PromptIndexForLlm { export interface PromptIndexForLlm {
name: string; name: string;
@@ -20,6 +24,23 @@ export interface LlmSelectionResult {
reasoning: string; reasoning: string;
} }
/**
* Route a selection inference through mcpd's server `Llm` (typically a
* `POST /api/v1/llms/:name/infer`). Returns the completion text. Must throw on
* transport/HTTP failure so the selector can fall back to the local provider.
*/
export type ServerInfer = (
messages: ChatMessage[],
opts: { maxTokens: number; temperature: number; signal?: AbortSignal },
) => Promise<string>;
export interface SelectPromptsOptions {
getSystemPromptFn?: SystemPromptFetcher;
signal?: AbortSignal;
/** Preferred inference path: mcpd server Llm (cloud/server keys at k8s). */
serverInfer?: ServerInfer;
}
/** /**
* Token budget for the selection call. A reasoning model spends most of its * Token budget for the selection call. A reasoning model spends most of its
* budget inside reasoning_content before emitting the {selectedNames} JSON, so * budget inside reasoning_content before emitting the {selectedNames} JSON, so
@@ -31,18 +52,54 @@ const SELECT_MAX_TOKENS = (() => {
return Number.isFinite(v) && v > 0 ? v : 4000; return Number.isFinite(v) && v > 0 ? v : 4000;
})(); })();
/**
* Pull the assistant text from an OpenAI-shaped completion, falling back to
* reasoning_content — thinking models emit their answer there with content null.
*/
export function pickCompletionText(resp: unknown): string {
const m = (resp as {
choices?: Array<{
message?: {
content?: string | null;
reasoning_content?: string | null;
provider_specific_fields?: { reasoning_content?: string | null };
};
}>;
}).choices?.[0]?.message;
const primary = m?.content ?? '';
if (primary !== '') return primary;
return m?.reasoning_content ?? m?.provider_specific_fields?.reasoning_content ?? '';
}
/** Extract the `{ "selectedNames": [...], "reasoning": "..." }` object, or null. */
export function extractSelection(text: string): LlmSelectionResult | null {
const jsonMatch = text.match(/\{[\s\S]*"selectedNames"[\s\S]*\}/);
if (!jsonMatch) return null;
try {
const parsed = JSON.parse(jsonMatch[0]) as { selectedNames?: string[]; reasoning?: string };
return { selectedNames: parsed.selectedNames ?? [], reasoning: parsed.reasoning ?? '' };
} catch {
return null;
}
}
export class LlmPromptSelector { export class LlmPromptSelector {
constructor( constructor(
private readonly providerRegistry: ProviderRegistry, private readonly providerRegistry: ProviderRegistry | null,
) {} ) {}
async selectPrompts( async selectPrompts(
tags: string[], tags: string[],
promptIndex: PromptIndexForLlm[], promptIndex: PromptIndexForLlm[],
getSystemPromptFn?: SystemPromptFetcher, opts: SelectPromptsOptions = {},
signal?: AbortSignal,
): Promise<LlmSelectionResult> { ): Promise<LlmSelectionResult> {
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.`; const { getSystemPromptFn, signal, serverInfer } = opts;
// 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;
@@ -52,47 +109,64 @@ 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 provider = this.providerRegistry.getProvider('heavy'); const messages: ChatMessage[] = [
if (!provider) { { role: 'system', content: systemPrompt },
throw new Error('No heavy LLM provider available'); { role: 'user', content: userPrompt },
];
// Sources in priority order: mcpd server Llm first (cloud/server keys at
// k8s), then the local personal-token provider as fallback.
const sources: Array<{ label: string; run: () => Promise<string> }> = [];
if (serverInfer) {
sources.push({
label: 'mcpd server Llm',
run: () => serverInfer(messages, { maxTokens: SELECT_MAX_TOKENS, temperature: 0, ...(signal ? { signal } : {}) }),
});
}
if (this.providerRegistry) {
sources.push({
label: 'local provider',
run: async () => {
const provider = this.providerRegistry!.getProvider('heavy');
if (!provider) throw new Error('No heavy LLM provider available');
const completionOptions: CompletionOptions = {
messages,
temperature: 0,
maxTokens: SELECT_MAX_TOKENS,
...(signal ? { signal } : {}),
};
return (await provider.complete(completionOptions)).content;
},
});
}
if (sources.length === 0) {
throw new Error('No LLM provider available for prompt selection');
} }
// Deliberately NOT setting `model` here. Prompt-ranking only needs a let lastErr: Error | null = null;
// working LLM; forcing the project's vLLM model onto the (anthropic) heavy for (const src of sources) {
// provider made every selection fail silently. Use the provider's own model. let text: string;
const completionOptions: import('../providers/types.js').CompletionOptions = { try {
messages: [ text = await src.run();
{ role: 'system', content: systemPrompt }, } catch (err) {
{ role: 'user', content: userPrompt }, lastErr = err instanceof Error ? err : new Error(String(err));
], continue; // transport/HTTP failure → try the next source
temperature: 0,
maxTokens: SELECT_MAX_TOKENS,
...(signal ? { signal } : {}),
};
const result = await provider.complete(completionOptions);
const response = result.content;
// Parse JSON from response (may be wrapped in markdown code blocks)
const jsonMatch = response.match(/\{[\s\S]*"selectedNames"[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('LLM response did not contain valid selection JSON');
}
const parsed = JSON.parse(jsonMatch[0]) as { selectedNames?: string[]; reasoning?: string };
const selectedNames = parsed.selectedNames ?? [];
const reasoning = parsed.reasoning ?? '';
// Always include priority 10 prompts
for (const p of promptIndex) {
if (p.priority === 10 && !selectedNames.includes(p.name)) {
selectedNames.push(p.name);
} }
const sel = extractSelection(text);
if (!sel) {
lastErr = new Error(`${src.label}: LLM response did not contain valid selection JSON`);
continue;
}
// Always include priority 10 prompts.
for (const p of promptIndex) {
if (p.priority === 10 && !sel.selectedNames.includes(p.name)) {
sel.selectedNames.push(p.name);
}
}
return sel;
} }
throw lastErr ?? new Error('LLM prompt-selection failed');
return { selectedNames, reasoning };
} }
} }

View File

@@ -147,6 +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 a server Llm (mcpd inference proxy)
// so cloud/server keys stay at the k8s level; the local personal-token
// provider is the fallback. See credential-tiering. A dedicated fast
// (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

@@ -13,7 +13,7 @@ import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js';
import { SessionGate } from '../../gate/session-gate.js'; import { SessionGate } from '../../gate/session-gate.js';
import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription } from '../../gate/tag-matcher.js'; import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription } from '../../gate/tag-matcher.js';
import type { TagMatchResult } from '../../gate/tag-matcher.js'; import type { TagMatchResult } from '../../gate/tag-matcher.js';
import { LlmPromptSelector } from '../../gate/llm-selector.js'; import { LlmPromptSelector, pickCompletionText, type ServerInfer } from '../../gate/llm-selector.js';
import type { ProviderRegistry } from '../../providers/registry.js'; import type { ProviderRegistry } from '../../providers/registry.js';
import { withTimeout, TimeoutError } from '../../util/with-timeout.js'; import { withTimeout, TimeoutError } from '../../util/with-timeout.js';
@@ -21,11 +21,38 @@ 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;
modelOverride?: string; modelOverride?: string;
byteBudget?: number; byteBudget?: number;
/**
* Name of the project's server `Llm` (mcpd). When set (and not 'none'), gate
* prompt-selection routes through mcpd's inference proxy first — keeping
* cloud/server keys at the k8s level — and only falls back to the local
* personal-token provider registry. See credential-tiering principle.
*/
llmProvider?: string;
} }
const MAX_RESPONSE_CHARS = 24_000; const MAX_RESPONSE_CHARS = 24_000;
@@ -33,8 +60,12 @@ const MAX_RESPONSE_CHARS = 24_000;
export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugin { export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugin {
const isGated = config.gated !== false; const isGated = config.gated !== false;
const tagMatcher = new TagMatcher(config.byteBudget); const tagMatcher = new TagMatcher(config.byteBudget);
const llmSelector = config.providerRegistry // Prefer routing selection through mcpd's server Llm (cloud/server keys at
? new LlmPromptSelector(config.providerRegistry) // k8s); the local provider registry (personal tokens) is the fallback. A gate
// with a server Llm but no local providers still selects (server-only).
const serverLlm = config.llmProvider && config.llmProvider !== 'none' ? config.llmProvider : undefined;
const llmSelector = (config.providerRegistry || serverLlm)
? new LlmPromptSelector(config.providerRegistry ?? null)
: null; : null;
// Per-session state tracking (plugin-scoped, not global SessionGate) // Per-session state tracking (plugin-scoped, not global SessionGate)
@@ -49,7 +80,7 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
// Register begin_session virtual tool // Register begin_session virtual tool
ctx.registerTool(getBeginSessionTool(llmSelector), async (args, callCtx) => { ctx.registerTool(getBeginSessionTool(llmSelector), async (args, callCtx) => {
return handleBeginSession(args, callCtx, sessionGate, tagMatcher, llmSelector); return handleBeginSession(args, callCtx, sessionGate, tagMatcher, llmSelector, serverLlm);
}); });
// Register read_prompts virtual tool (available even when ungated) // Register read_prompts virtual tool (available even when ungated)
@@ -250,6 +281,7 @@ async function handleBeginSession(
sessionGate: SessionGate, sessionGate: SessionGate,
tagMatcher: TagMatcher, tagMatcher: TagMatcher,
llmSelector: LlmPromptSelector | null, llmSelector: LlmPromptSelector | null,
serverLlm?: string,
): Promise<unknown> { ): Promise<unknown> {
const rawTags = args['tags'] as string[] | undefined; const rawTags = args['tags'] as string[] | undefined;
const description = args['description'] as string | undefined; const description = args['description'] as string | undefined;
@@ -287,8 +319,26 @@ async function handleBeginSession(
chapters: p.chapters, chapters: p.chapters,
})); }));
const getSystemPromptFn = ctx.getSystemPrompt.bind(ctx); const getSystemPromptFn = ctx.getSystemPrompt.bind(ctx);
// Prefer mcpd's server Llm (keeps cloud/server keys at k8s); the selector
// falls back to the local personal-token provider on failure.
const serverInfer: ServerInfer | undefined = serverLlm
? async (messages, o) => {
const resp = await ctx.postToMcpd(`/api/v1/llms/${encodeURIComponent(serverLlm)}/infer`, {
messages,
temperature: o.temperature,
max_tokens: o.maxTokens,
stream: false,
...GATE_SELECT_EXTRA_BODY, // ask reasoning models for a fast, no-think answer
});
return pickCompletionText(resp);
}
: undefined;
const llmResult = await withTimeout( const llmResult = await withTimeout(
(signal) => llmSelector.selectPrompts(tags, llmIndex, getSystemPromptFn, signal), (signal) => llmSelector.selectPrompts(tags, llmIndex, {
getSystemPromptFn,
signal,
...(serverInfer ? { serverInfer } : {}),
}),
GATE_LLM_TIMEOUT_MS, GATE_LLM_TIMEOUT_MS,
'gate LLM prompt-selection', 'gate LLM prompt-selection',
); );

View File

@@ -109,12 +109,49 @@ describe('LlmPromptSelector', () => {
const selector = new LlmPromptSelector(registry); const selector = new LlmPromptSelector(registry);
const ac = new AbortController(); const ac = new AbortController();
await selector.selectPrompts(['test'], sampleIndex, undefined, ac.signal); await selector.selectPrompts(['test'], sampleIndex, { signal: ac.signal });
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions; const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.signal).toBe(ac.signal); expect(call.signal).toBe(ac.signal);
}); });
it('prefers the mcpd server Llm (serverInfer) over the local provider', async () => {
const provider = makeMockProvider('{ "selectedNames": ["mqtt-config"], "reasoning": "local" }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const serverInfer = vi.fn().mockResolvedValue('{ "selectedNames": ["zigbee-pairing"], "reasoning": "server" }');
const result = await selector.selectPrompts(['x'], sampleIndex, { serverInfer });
expect(serverInfer).toHaveBeenCalledOnce();
expect(provider.complete).not.toHaveBeenCalled(); // server succeeded → no local call
expect(result.selectedNames).toContain('zigbee-pairing');
expect(result.reasoning).toBe('server');
});
it('falls back to the local provider when serverInfer throws', async () => {
const provider = makeMockProvider('{ "selectedNames": ["mqtt-config"], "reasoning": "local" }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const serverInfer = vi.fn().mockRejectedValue(new Error('mcpd HTTP 503'));
const result = await selector.selectPrompts(['x'], sampleIndex, { serverInfer });
expect(serverInfer).toHaveBeenCalledOnce();
expect(provider.complete).toHaveBeenCalledOnce();
expect(result.selectedNames).toContain('mqtt-config');
expect(result.reasoning).toBe('local');
});
it('works server-only (null registry) when a serverInfer is supplied', async () => {
const selector = new LlmPromptSelector(null);
const serverInfer = vi.fn().mockResolvedValue('{ "selectedNames": ["mqtt-config"], "reasoning": "s" }');
const result = await selector.selectPrompts(['x'], sampleIndex, { serverInfer });
expect(result.selectedNames).toContain('mqtt-config');
});
it('throws when no heavy provider is available', async () => { it('throws when no heavy provider is available', async () => {
const registry = new ProviderRegistry(); // Empty registry const registry = new ProviderRegistry(); // Empty registry
const selector = new LlmPromptSelector(registry); const selector = new LlmPromptSelector(registry);