Compare commits

..

1 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
10 changed files with 180 additions and 546 deletions

View File

@@ -33,7 +33,7 @@ Key routing rules:
- `secret` / `secretbackend` — credentials
- `template` — reusable server blueprint
- `project` — workspace grouping servers, prompts, agents
- `llm` — server-managed LLM provider (api key + endpoint). Never hardcode a served-model id: rows are named for the role they fill (`vllm-current`/fast, `vllm-think`/heavy), Pulumi repoints their `model` on a swap, and consumers resolve by `tier`. See `docs/llm-tiers.md`.
- `llm` — server-managed LLM provider (api key + endpoint)
- `agent` — LLM persona pinned to one Llm; project attach surfaces project Prompts as system context, project MCP servers as tools, and exposes the agent itself as an MCP virtual server (`agent-<name>/chat`). See `docs/agents.md`, `docs/chat.md`.
- `prompt` / `promptrequest` — curated content / legacy pending proposal (use `proposal` for new work).
- `skill` — Claude Code skill bundle (SKILL.md + files + typed metadata). Materialised onto disk by `mcpctl skills sync`. See `docs/skills.md`.

View File

@@ -516,19 +516,17 @@ description "I review security design — ask me after each major change."
That's how agents consult each other.
```bash
# 1) point at an LLM. Name the ROW for the role it fills, not for the model —
# the served model changes, the row should not. See docs/llm-tiers.md.
# 1) point at an LLM. For your in-cluster qwen3-thinking via LiteLLM:
mcpctl create secret litellm-key --data API_KEY=sk-…
mcpctl create llm vllm-current \
mcpctl create llm qwen3-thinking \
--type openai \
--model deepseek-v4-flash \
--tier fast \
--model qwen3-thinking \
--url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \
--api-key-ref litellm-key/API_KEY
# 2) create an agent, pinned to that Llm and attached to a project
mcpctl create agent reviewer \
--llm vllm-current \
--llm qwen3-thinking \
--project mcpctl-dev \
--description "I review security design — ask me after each major change." \
--system-prompt-file ./prompts/reviewer.md \
@@ -588,8 +586,7 @@ systemctl --user restart mcplocal
mcpctl get llm
# NAME KIND STATUS TYPE MODEL TIER ID
# vllm-current public active openai deepseek-v4-flash fast ...
# vllm-think public active openai deepseek-v4-think heavy ...
# qwen3-thinking public active openai qwen3-thinking fast ...
# vllm-local virtual active openai Qwen/Qwen2.5-7B-Instruct-AWQ fast ...
mcpctl chat-llm vllm-local

View File

@@ -1,117 +0,0 @@
# LLM tiers — tracking the served model without hardcoding it
The homelab's served model changes. `glm-4.6-reap` became `deepseek-v4-flash`;
something else will replace it. Every place mcpctl writes a model id down is a
place that silently rots when that happens — a row left requesting a suspended
model gets `HTTP 400 model not found` on the next call, and the failure surfaces
somewhere unhelpful (a gate that quietly stops ranking prompts, an agent that
500s).
mcpctl's answer is that **it never names a model, and prefers not to name a
row**. Consumers ask for a *role*; the registry says who currently fills it.
## The three layers
```
LiteLLM (llm.ad.itaz.eu) deepseek-v4-flash, -fast, -low, -think, -max
▲ served-model ids — change on every model swap
│ Pulumi owns this mapping
mcpd Llm rows vllm-current (tier: fast)
▲ vllm-think (tier: heavy)
│ stable identities; `model` follows the deployment
mcpctl consumers "give me the fast one"
no model id, no row name
```
**Layer 1 → 2 is Pulumi's job.** `deployments/mcpctl/llm-target.ts` in the
`kubernetes-deployment` repo declares both rows via the `@mcpctl/pulumi`
provider and feeds them the active served-model name. Swap the model, run a
targeted `pulumi up`, and the rows follow. See
[pulumi-provider-llm-autopoint](../src/pulumi/README.md).
**Layer 2 → 3 is `src/mcplocal/src/server-llm.ts`.** `tier` is already a
first-class field on the `Llm` resource and is set by the same Pulumi resource
that sets `model`, so the two cannot drift.
## Resolution rules
`resolveServerLlmByTier(client, tier)` returns the row filling a tier, or
`null`. A row is eligible when:
- its `tier` matches exactly;
- its `status` is `active` — selecting a row with no live backend just moves
the failure to the first inference call;
- its `kind` is **not** `virtual`. Virtual rows are backed by some user's
`mcplocal` over the SSE control channel, i.e. by a machine and personal
credentials nobody chose deliberately. An explicit pin may still name one;
automatic resolution must not route a project's traffic through a laptop.
When a tier holds several rows — `heavy` holds both `vllm-think` and the cloud
`anthropic-fallback` — an ordered **well-known name** list breaks the tie:
| tier | preference order |
|------|------------------|
| `fast` | `vllm-fast`, `vllm-current` |
| `heavy` | `vllm-think`, `vllm-thinking` |
Names that match nothing are inert. That is deliberate: the list carries both
the current names and the symmetric names the rows might be renamed to, so a
rename stays a Pulumi-only change and neither deploy order breaks the other.
Rows absent from the list are still eligible — they just sort last, then by
name, so the choice is stable across calls regardless of mcpd's list order.
Override the order without a release:
```bash
MCPCTL_LLM_PREFER_FAST=spare-row,vllm-current
MCPCTL_LLM_PREFER_HEAVY=vllm-think
```
Resolution never throws. mcpd being unreachable is a normal degraded state for
`mcplocal`, and every caller has a fallback path; returning `null` keeps that
fallback intact instead of failing session setup.
## Who uses it
**Gate prompt-selection** (`project-mcp-endpoint.ts`). Order:
1. `MCPCTL_GATE_SELECTION_LLM` — a global pin, so selection can sit on a fast
no-think Llm while chat keeps a thinking model;
2. the project's own `llmProvider` (`none` disables);
3. whichever row currently fills the `fast` tier.
Step 3 is what makes an unpinned project work. Without it, a project with no
`llmProvider` fell through to the local personal-token provider — and on a
machine whose personal key is stale, that means every gated session silently
ran on priority-ordered prompts instead of LLM ranking:
```
[gate] LLM prompt-selection failed: Anthropic HTTP 404: model: claude-opus-4-20250514
— falling back to priority-ordered prompts
```
The gate prints a `⚠ Smart prompt-selection unavailable` banner in the
`begin_session` response whenever it degrades. Absence of that banner on a
project with no pin is what the smoke test asserts.
Explicit pins still win everywhere — this only changes what happens when
nothing is pinned.
## Why not rename `vllm-current` to `vllm-fast`?
`name` is immutable in mcpd (agents and projects reference it), so a rename is
a data migration plus a Pulumi resource replace, not an edit. Once resolution
is tier-based nothing in mcpctl reads the names, so the migration buys nothing.
`vllm-fast` would also be actively confusing: the fast row points at
`deepseek-v4-flash` (the base route, which carries no forced `extra_body`),
while LiteLLM separately serves a `deepseek-v4-fast` route that pins
`thinking: false`. Two different things, one name apart.
## Testing
- `src/mcplocal/tests/server-llm.test.ts` — selection rules, tiebreak, env
override, degraded paths.
- `src/mcplocal/tests/smoke/llm-tier.smoke.test.ts` — against the live stack:
both tiers resolve, the resolved fast row answers real inference (the drift
check — a row pointing at a suspended model fails here), and an unpinned
gated project gets LLM-ranked selection.

View File

@@ -11,6 +11,14 @@ export interface McpBridgeOptions {
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(
url: string,
body: string,
@@ -37,7 +45,7 @@ export function postJsonRpc(
path: parsed.pathname,
method: 'POST',
headers,
timeout: 30_000,
timeout: BRIDGE_TIMEOUT_MS,
},
(res) => {
const chunks: Buffer[] = [];
@@ -54,7 +62,7 @@ export function postJsonRpc(
req.on('error', reject);
req.on('timeout', () => {
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.end();
@@ -128,19 +136,15 @@ export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
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
}
/**
* In-flight requests. Dispatch is CONCURRENT after the session is
* established — see the head-of-line note below — so stdin can keep being
* read while a slow call is outstanding.
*/
const inFlight = new Set<Promise<void>>();
/** POST one JSON-RPC message and write whatever comes back to stdout. */
const dispatch = async (trimmed: string, requestId: unknown): Promise<void> => {
try {
const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token);
@@ -178,9 +182,48 @@ export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
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) {
await sendDelete(endpointUrl, sessionId, token);
}

View File

@@ -483,3 +483,105 @@ describe('createMcpCommand', () => {
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

@@ -14,7 +14,6 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { McpRouter } from '../router.js';
import { ResponsePaginator } from '../llm/pagination.js';
import { refreshProjectUpstreams, fetchProjectLlmConfig } from '../discovery.js';
import { resolveServerLlmByTier } from '../server-llm.js';
import { loadProjectLlmOverride } from './config.js';
import type { McpdClient } from './mcpd-client.js';
import type { ProviderRegistry } from '../providers/registry.js';
@@ -150,25 +149,12 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
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. Resolution order:
// 1. MCPCTL_GATE_SELECTION_LLM — a global pin, so selection can stay on a
// fast (no-think) Llm while chat keeps its thinking model;
// 2. the project's own llmProvider ('none' disables, handled downstream);
// 3. whichever row currently fills the 'fast' tier (see server-llm.ts).
//
// (3) is what keeps this working across a model swap: Pulumi repoints the
// row's `model` and mcpctl names neither the model nor the row. Without it
// an unpinned project silently fell through to the local heavy provider —
// which on a machine with no valid personal key means every gated session
// has been running on priority-ordered prompts, not LLM selection.
const pinnedSelectionLlm = process.env['MCPCTL_GATE_SELECTION_LLM'] ?? '';
let gateSelectionLlm = pinnedSelectionLlm !== '' ? pinnedSelectionLlm : mcpdConfig.llmProvider;
if ((gateSelectionLlm === undefined || gateSelectionLlm === '') && !llmDisabled) {
gateSelectionLlm = (await resolveServerLlmByTier(requestClient, 'fast')) ?? undefined;
}
if (gateSelectionLlm !== undefined && gateSelectionLlm !== '') {
pluginConfig.llmProvider = gateSelectionLlm;
}
// 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);
// Optional favourite-index presentation: curated favourite/<tool> + full
// all/<server>/<tool> + a "prefer favourite/" instruction. Composed AFTER

View File

@@ -1,112 +0,0 @@
/**
* Tier-based resolution of mcpd server `Llm` rows.
*
* The homelab's served model changes (glm-4.6-reap → deepseek-v4-flash → …).
* Pulumi owns that swap: `deployments/mcpctl/llm-target.ts` in the
* kubernetes-deployment repo repoints long-lived mcpd `Llm` rows at whatever
* LiteLLM currently serves. mcpctl must therefore never name a *model* — and
* ideally not name a *row* either, or every swap risks stranding a pin the way
* `vllm-think` was stranded on a suspended `glm-4.6-reap`.
*
* So consumers ask for a ROLE (a tier) and get back whichever row currently
* fills it. `tier` is already a first-class field on the Llm resource and is
* set by the same Pulumi resource that sets `model`, so the two cannot drift.
*
* Well-known names are only a TIEBREAK, used when a tier has several rows
* (today `heavy` holds both `vllm-think` and the cloud `anthropic-fallback`).
* They are an ordered list rather than a single constant so that renaming a row
* on the Pulumi side stays a Pulumi-only change: list both the old and the new
* name and neither deploy order breaks the other.
*/
import type { McpdClient } from './http/mcpd-client.js';
export type LlmTier = 'fast' | 'heavy';
/**
* Preferred row names per tier, most-preferred first. A name that matches
* nothing is inert, which is what makes this safe to pre-seed with names that
* do not exist yet (`vllm-fast`, `vllm-thinking` are the symmetric names the
* rows may be renamed to; `vllm-current`/`vllm-think` are what Pulumi owns
* today). Rows absent from this list are still eligible — they just sort last.
*/
export const WELL_KNOWN_LLM_NAMES: Record<LlmTier, readonly string[]> = {
fast: ['vllm-fast', 'vllm-current'],
heavy: ['vllm-think', 'vllm-thinking'],
};
/** Env override for the tiebreak order, e.g. `MCPCTL_LLM_PREFER_FAST=a,b`. */
const PREFER_ENV: Record<LlmTier, string> = {
fast: 'MCPCTL_LLM_PREFER_FAST',
heavy: 'MCPCTL_LLM_PREFER_HEAVY',
};
/** Subset of mcpd's LlmView that tier resolution actually reads. */
export interface LlmSummary {
name: string;
tier?: string;
kind?: string;
status?: string;
}
/** Read the preference order for a tier: env override, else the built-in list. */
export function preferredNamesForTier(tier: LlmTier): readonly string[] {
const raw = process.env[PREFER_ENV[tier]];
if (raw === undefined || raw.trim() === '') return WELL_KNOWN_LLM_NAMES[tier];
return raw.split(',').map((s) => s.trim()).filter((s) => s !== '');
}
/**
* Pick the row that fills `tier`, or null if none does.
*
* Eligibility:
* - `tier` matches exactly;
* - `status` is 'active' — an inactive row has no live backend, and silently
* selecting one just moves the failure to the first inference call;
* - `kind` is not 'virtual'. Virtual rows are backed by some user's mcplocal
* over the SSE control channel, i.e. by a machine and personal credentials
* we did not choose. An explicit pin may still name one; automatic
* resolution must not route a project's traffic through someone's laptop.
*
* Ordering: position in `preferred` (unlisted rows sort last), then name, so
* the choice is stable across calls and independent of mcpd's list order.
*/
export function pickLlmForTier(
llms: readonly LlmSummary[],
tier: LlmTier,
preferred: readonly string[] = preferredNamesForTier(tier),
): string | null {
const eligible = llms.filter(
(l) => l.tier === tier && (l.status ?? 'active') === 'active' && l.kind !== 'virtual',
);
if (eligible.length === 0) return null;
const rank = (name: string): number => {
const i = preferred.indexOf(name);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
eligible.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
return eligible[0]?.name ?? null;
}
/**
* Resolve the name of the mcpd `Llm` currently filling `tier`, or null.
*
* Never throws: mcpd being unreachable is a normal degraded state for
* mcplocal, and every caller has a fallback path (the gate falls back to the
* local personal-token provider). Returning null keeps that fallback intact
* instead of failing the whole session setup.
*/
export async function resolveServerLlmByTier(
mcpdClient: McpdClient,
tier: LlmTier,
preferred?: readonly string[],
): Promise<string | null> {
let llms: LlmSummary[];
try {
llms = await mcpdClient.get<LlmSummary[]>('/api/v1/llms');
} catch {
return null;
}
if (!Array.isArray(llms)) return null;
return pickLlmForTier(llms, tier, preferred ?? preferredNamesForTier(tier));
}

View File

@@ -1,122 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
pickLlmForTier,
preferredNamesForTier,
resolveServerLlmByTier,
WELL_KNOWN_LLM_NAMES,
type LlmSummary,
} from '../src/server-llm.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
/** Minimal McpdClient stand-in — tier resolution only ever calls `get`. */
function stubClient(impl: () => Promise<unknown>): McpdClient {
return { get: impl } as unknown as McpdClient;
}
const row = (over: Partial<LlmSummary> & { name: string }): LlmSummary => ({
tier: 'fast',
kind: 'public',
status: 'active',
...over,
});
/** The live shape as of the DeepSeek-V4-Flash-0731 swap. */
const LIVE: LlmSummary[] = [
row({ name: 'anthropic-fallback', tier: 'heavy' }),
row({ name: 'vllm-current', tier: 'fast' }),
row({ name: 'vllm-think', tier: 'heavy' }),
];
afterEach(() => {
delete process.env['MCPCTL_LLM_PREFER_FAST'];
delete process.env['MCPCTL_LLM_PREFER_HEAVY'];
});
describe('pickLlmForTier', () => {
it('picks the only row filling a tier', () => {
expect(pickLlmForTier(LIVE, 'fast')).toBe('vllm-current');
});
it('breaks a multi-row tier on the well-known order, not list order', () => {
// 'heavy' holds both the homelab row and the cloud last-resort. The cloud
// row sorts first alphabetically, so this fails without the tiebreak.
expect(pickLlmForTier(LIVE, 'heavy')).toBe('vllm-think');
});
it('prefers a renamed row over the current one when both exist', () => {
// The deploy window during a Pulumi rename: both names are present.
const during = [...LIVE, row({ name: 'vllm-fast', tier: 'fast' })];
expect(pickLlmForTier(during, 'fast')).toBe('vllm-fast');
});
it('still resolves a tier whose rows are all unknown names', () => {
const renamed = [row({ name: 'homelab-quick', tier: 'fast' })];
expect(pickLlmForTier(renamed, 'fast')).toBe('homelab-quick');
});
it('sorts unknown names deterministically', () => {
const many = [row({ name: 'zeta' }), row({ name: 'alpha' }), row({ name: 'mid' })];
expect(pickLlmForTier(many, 'fast')).toBe('alpha');
expect(pickLlmForTier([...many].reverse(), 'fast')).toBe('alpha');
});
it('skips inactive rows — a dead backend just defers the failure', () => {
const rows = [row({ name: 'vllm-current', status: 'inactive' }), row({ name: 'spare' })];
expect(pickLlmForTier(rows, 'fast')).toBe('spare');
});
it('never auto-selects a virtual row', () => {
// Virtual rows are backed by some user's mcplocal over SSE. An explicit
// pin may name one; automatic resolution must not.
const rows = [row({ name: 'vllm-current', kind: 'virtual' })];
expect(pickLlmForTier(rows, 'fast')).toBeNull();
});
it('treats a missing status as active (older rows)', () => {
expect(pickLlmForTier([{ name: 'legacy', tier: 'fast' }], 'fast')).toBe('legacy');
});
it('returns null when the tier is empty', () => {
expect(pickLlmForTier([row({ name: 'x', tier: 'heavy' })], 'fast')).toBeNull();
expect(pickLlmForTier([], 'fast')).toBeNull();
});
});
describe('preferredNamesForTier', () => {
it('defaults to the well-known list', () => {
expect(preferredNamesForTier('fast')).toEqual(WELL_KNOWN_LLM_NAMES.fast);
expect(preferredNamesForTier('heavy')).toEqual(WELL_KNOWN_LLM_NAMES.heavy);
});
it('honours the env override so a repoint needs no release', () => {
process.env['MCPCTL_LLM_PREFER_FAST'] = ' spare , vllm-current ';
expect(preferredNamesForTier('fast')).toEqual(['spare', 'vllm-current']);
expect(pickLlmForTier([...LIVE, row({ name: 'spare' })], 'fast')).toBe('spare');
});
it('ignores a blank override', () => {
process.env['MCPCTL_LLM_PREFER_FAST'] = ' ';
expect(preferredNamesForTier('fast')).toEqual(WELL_KNOWN_LLM_NAMES.fast);
});
});
describe('resolveServerLlmByTier', () => {
it('resolves against mcpd', async () => {
const client = stubClient(async () => LIVE);
await expect(resolveServerLlmByTier(client, 'fast')).resolves.toBe('vllm-current');
await expect(resolveServerLlmByTier(client, 'heavy')).resolves.toBe('vllm-think');
});
it('returns null when mcpd is unreachable instead of throwing', async () => {
// Callers have a fallback path; failing session setup would be worse.
const client = stubClient(async () => {
throw new Error('Cannot connect to mcpd');
});
await expect(resolveServerLlmByTier(client, 'fast')).resolves.toBeNull();
});
it('returns null on an unexpected payload shape', async () => {
const client = stubClient(async () => ({ error: 'forbidden' }));
await expect(resolveServerLlmByTier(client, 'fast')).resolves.toBeNull();
});
});

View File

@@ -1,130 +0,0 @@
/**
* Smoke test: tier-based server-Llm resolution against the live stack.
*
* mcpctl must keep working across a homelab model swap without an mcpctl edit.
* Pulumi (kubernetes-deployment `deployments/mcpctl/llm-target.ts`) repoints
* long-lived mcpd `Llm` rows at whatever LiteLLM currently serves; mcpctl names
* neither the model nor the row, and resolves by tier instead.
*
* The three things that have to hold for that to be true:
* 1. some active, non-virtual row fills the `fast` tier, and it resolves;
* 2. that row's `model` is a route LiteLLM actually serves right now — this
* is the assertion that catches drift (a suspended model leaves the row
* requesting an id the gateway answers with HTTP 400);
* 3. a gated project with NO llmProvider pin still gets LLM-ranked prompt
* selection, rather than silently degrading to priority order.
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SmokeMcpSession, isMcplocalRunning, loadMcpdAuth, mcpctl } from './mcp-client.js';
import { pickLlmForTier, type LlmSummary } from '../../src/server-llm.js';
const PROJECT_NAME = 'smoke-llm-tier';
/** Call mcpd directly with the CLI's own credentials. */
async function mcpd<T>(path: string, body?: unknown): Promise<T> {
const { token, url } = loadMcpdAuth();
const res = await fetch(`${url.replace(/\/$/, '')}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(60_000),
});
const text = await res.text();
if (!res.ok) throw new Error(`mcpd ${path}${String(res.status)}: ${text.slice(0, 300)}`);
return JSON.parse(text) as T;
}
describe('Smoke: tier-based server Llm resolution', () => {
let ready = false;
let llms: LlmSummary[] = [];
beforeAll(async () => {
console.log('\n ━━━ Smoke Test: llm tier resolution ━━━');
if (!(await isMcplocalRunning())) {
console.log(' ✗ mcplocal not running — skipping\n');
return;
}
try {
llms = await mcpd<LlmSummary[]>('/api/v1/llms');
} catch (err) {
console.log(` ✗ cannot list Llms: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// A gated project with no --llm pin: exactly the case that used to fall
// through to the local personal-token provider.
try {
await mcpctl(`create project ${PROJECT_NAME} --force --server docmost`);
ready = true;
} catch (err) {
console.log(` ⚠ project setup error: ${err instanceof Error ? err.message : String(err)}`);
}
}, 90_000);
afterAll(async () => {
try { await mcpctl(`delete project ${PROJECT_NAME}`); } catch { /* best effort cleanup */ }
console.log('\n ━━━ llm tier smoke complete ━━━\n');
});
it('resolves an active non-virtual row for the fast tier', () => {
if (llms.length === 0) return;
const picked = pickLlmForTier(llms, 'fast');
expect(picked, 'no Llm fills the fast tier — Pulumi mcpctl-reasoning-llm may not have run').not.toBeNull();
const row = llms.find((l) => l.name === picked);
expect(row?.status ?? 'active').toBe('active');
expect(row?.kind).not.toBe('virtual');
console.log(` ✓ fast tier → ${String(picked)}`);
});
it('resolves an active non-virtual row for the heavy tier', () => {
if (llms.length === 0) return;
const picked = pickLlmForTier(llms, 'heavy');
expect(picked, 'no Llm fills the heavy tier').not.toBeNull();
console.log(` ✓ heavy tier → ${String(picked)}`);
});
it('the fast-tier row points at a model the gateway actually serves', async () => {
if (llms.length === 0) return;
const picked = pickLlmForTier(llms, 'fast');
if (picked === null) return; // already failed above
// Drift check: a row left pointing at a suspended model answers HTTP 400
// ("model X not found"), which `mcpd()` surfaces as a thrown error.
const resp = await mcpd<{ choices?: Array<{ message?: { content?: string | null } }> }>(
`/api/v1/llms/${encodeURIComponent(picked)}/infer`,
{ messages: [{ role: 'user', content: 'Reply with the single word: ok' }], max_tokens: 16, stream: false },
);
expect(resp.choices?.length ?? 0).toBeGreaterThan(0);
console.log(`${picked} answered live inference`);
}, 120_000);
it('an unpinned gated project gets LLM-ranked selection, not the degraded fallback', async () => {
if (!ready) return;
const session = new SmokeMcpSession(PROJECT_NAME);
try {
await session.initialize();
const result = await session.callTool(
'begin_session',
{ description: 'reviewing documentation pages', tags: ['docs', 'wiki', 'search'] },
120_000,
);
const text = result.content.map((c) => c.text ?? '').join('\n');
// The gate prints this banner whenever it falls back to priority order.
// Its absence is the whole point of tier resolution: no pin anywhere, and
// selection still ran through a server Llm.
expect(text, 'gate degraded — tier resolution did not reach a server Llm').not.toContain(
'Smart prompt-selection unavailable',
);
console.log(' ✓ begin_session used LLM-ranked selection with no llmProvider pin');
} finally {
await session.close();
}
}, 180_000);
});

View File

@@ -32,16 +32,6 @@ function httpRequest(opts: {
headers?: Record<string, string>;
body?: string;
timeout?: number;
/**
* Resolve as soon as response headers arrive, without waiting for the body
* to end. Required for endpoints that never end: an SSE stream's socket only
* goes idle when nothing else is talking to mcplocal, so waiting for the
* inactivity timeout made this depend on whether other smoke files happened
* to be generating traffic concurrently. Resolving on headers is also a
* STRONGER assertion — the caller sees the real status instead of inferring
* "reachable" from a timeout, which would pass just as happily on a slow 401.
*/
resolveOnHeaders?: boolean;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(opts.url);
@@ -56,12 +46,6 @@ function httpRequest(opts: {
timeout: opts.timeout ?? 10_000,
},
(res) => {
if (opts.resolveOnHeaders === true) {
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: '' });
res.destroy();
req.destroy();
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
@@ -108,19 +92,22 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => {
if (!available) return;
// /inspect streams ALL MCP traffic (tool calls, arguments, responses)
// for ALL projects to any unauthenticated local client. The stream never
// ends, so take the status off the response headers and hang up.
// for ALL projects to any unauthenticated local client
const res = await httpRequest({
url: `${MCPLOCAL_URL}/inspect`,
method: 'GET',
headers: { 'Accept': 'text/event-stream' },
timeout: 5_000,
resolveOnHeaders: true,
timeout: 3_000,
}).catch((err) => {
// Timeout is expected (SSE keeps connection open) — still means endpoint is accessible
if ((err as Error).message.includes('timed out')) {
return { status: 200, headers: {} as http.IncomingHttpHeaders, body: '' };
}
throw err;
});
// Should be accessible without auth (documenting the vulnerability)
expect(res.status).toBeLessThan(400);
expect(res.headers['content-type']).toContain('text/event-stream');
console.log(` ⚠ /inspect accessible without auth (status ${res.status})`);
}, 10_000);