Compare commits

...

7 Commits

Author SHA1 Message Date
Michal
eb0e97e76e test(mcplocal): end-to-end wire-name coverage on the project endpoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / test (pull_request) Successful in 3m42s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
Drives /projects/:name/mcp over the real Streamable HTTP transport with a
fake websearch upstream: tools/list must serve `websearch_fetch_content`
(every name matching the OpenAI function-name charset), calling that wire
name must reach the upstream as bare `fetch_content`, and a legacy client
echoing the canonical `websearch/fetch_content` must still route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:23:09 +01:00
Michal
065ce02a60 fix(mcplocal): serve OpenAI-safe tool names on the wire
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m40s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / smoke (pull_request) Failing after 2m3s
CI/CD / build (pull_request) Successful in 4m58s
CI/CD / publish (pull_request) Has been skipped
The proxy namespaces tools as `server/tool` (and favourite-index presents
`favourite/<tool>` / `all/<server>/<tool>`). A `/` is not a valid character
in an OpenAI-style function name, so hosts that forward MCP tool names
verbatim as LLM function names depend on the model faithfully echoing an
illegal name. LibreChat did exactly that: deepseek-v4-flash intermittently
dropped the `websearch/` prefix, LibreChat's registry lookup failed, and it
reported "This tool's MCP server is temporarily unavailable" while nothing
was down — the calls never reached mcplocal at all (confirmed against the
AuditEvent table, 2026-08-25). Claude Code and the pi extension only dodge
this because they sanitize names client-side.

Fix at the HTTP boundary only: a WireNameCodec rewrites tools/list responses
to wire-safe names (`/` -> `_`, exact-match reverse map, deterministic
suffix on collision) and maps tools/call names back before routing. Wired
into both /mcp and /projects/:name/mcp. Everything inside the proxy —
routing maps, plugins, favourites config, audit events — keeps canonical
names, and unknown inbound names (legacy clients echoing slash names,
virtual tools) pass through unchanged, so existing clients keep working.

Codecs are keyed per project and outlive the router cache TTL so a client
can call a tool it listed minutes earlier; after a restart the client's
initialize-time tools/list repopulates the map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:17:33 +01:00
db38de7e09 Merge pull request 'fix(cli): don't brick the chat REPL when the first turn fails upstream' (#110) from fix/chat-repl-thread-brick into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m19s
CI/CD / lint (push) Successful in 2m46s
CI/CD / test (push) Successful in 1m26s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m36s
CI/CD / publish (push) Has been skipped
2026-08-10 21:55:10 +00:00
Michal
ac5dee906e fix(cli): don't brick the chat REPL when the first turn fails upstream
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m20s
CI/CD / lint (pull_request) Successful in 2m48s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / build (pull_request) Successful in 2m40s
CI/CD / smoke (pull_request) Failing after 3m46s
CI/CD / publish (pull_request) Has been skipped
Observed live: turn 1 died with an anthropic 429 before the stream's `final`
frame, streamOnce resolved '' as the thread id, the REPL stored it, and every
later message sent `threadId: ""` — rejected by mcpd's z.string().min(1) with
HTTP 400. Permanently stuck: no turn could succeed again, so no `final` frame
could ever repair the id.

Two independent layers:
- streamOnce now resolves `string | undefined` — undefined when no `final`
  frame arrived — and the REPL keeps its previous thread state on undefined
  instead of overwriting it. One-shot mode skips the `(thread: ...)` footer
  when there is none to report.
- chatBody refuses to serialize an empty threadId at all, so even a leaked ''
  can never reach the wire.

Regression cover in chat-thread-brick.test.ts (7 tests), including the full
REPL chain: failed turn 1 → turn 2 body carries no threadId key. The
assertions are the direct inverse of the old behavior, so they fail pre-fix
by construction.

CLI suite 726 passed, lint clean, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP
2026-08-10 22:54:53 +01:00
d4c33baf03 Merge pull request 'fix(mcplocal): stream chat SSE through the proxy instead of buffering it' (#109) from fix/chat-sse-streaming into main
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Successful in 1m27s
CI/CD / typecheck (push) Successful in 3m4s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 2m21s
CI/CD / publish (push) Has been skipped
2026-08-10 21:28:00 +00:00
Michal
bbd2195c64 test(mcplocal): prove chat SSE streams live through the proxy, not buffered
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / lint (pull_request) Successful in 2m55s
CI/CD / smoke (pull_request) Failing after 1m59s
CI/CD / build (pull_request) Successful in 4m33s
CI/CD / publish (pull_request) Has been skipped
`mcpctl chat reviewer` showed nothing until the turn finished, then dumped
the whole answer at once. mcpd streams token deltas and the CLI renders
them incrementally — the sole buffering point was mcplocal's catch-all
/api/v1/* proxy reading the whole SSE body via res.text() before replying.
The previous commit (cherry-picked from feat/agentic-teams) pipes the body
through instead; this one adds the cover that was missing:

- proxy-long-running.test.ts: a progressive-delivery test in which the
  stand-in mcpd withholds its final frame until the client has observed the
  first delta through the proxy. A buffering proxy cannot satisfy that
  ordering — verified: the test fails in 3s (no hang) against a res.text()
  proxy and passes against the piped one. The existing SSE test used
  inject(), which collects the whole body and so passes either way.

- agent-chat.smoke.test.ts: a live smoke that posts through mcplocal on
  localhost:3200 (the path `mcpctl chat` actually takes — every other chat
  smoke uses --direct and bypasses the proxy entirely) and asserts delta
  frames arrive spread across the generation window, not in one burst at
  stream end. Uses its own agent: the shared smoke agent pins replies to a
  single token, too short to tell live streaming from a buffer dump.

Also settles the strict-boolean-expressions lint on the auth-header check
the streaming split touched.

Local: workspace 2546 passed, proxy suite 10/10, smoke file loads + self-skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP
2026-08-10 22:27:21 +01:00
Michal
5a8185d7c9 fix(mcplocal): stop the 30s proxy timeout killing agent turns
`mcpctl chat <agent>` failed with

    HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"}

while mcpd was answering /healthz in 32ms. The message was wrong in a way that
cost real debugging time: mcplocal was reaching mcpd fine and giving up after
30s. journalctl shows the signature plainly — statusCode 503 with
responseTime 30003.87 on POST /api/v1/agents/reviewer/chat.

This blocks the agentic-teams epic outright. An agent turn is a multi-turn
tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path
is not a safety net, it is a guaranteed failure for every non-trivial turn.

Three defects, all in the same path:

1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is
   right for CRUD and wrong for chat. Chat, project chat, llm infer and
   inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override
   with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI
   already allowed. mcplocal in the middle was the binding constraint.

2. Timeouts were reported as connection failures. Split UpstreamTimeoutError
   out of ConnectionError and map it to 504 with an accurate message that says
   the daemon IS reachable. ConnectionError still means unreachable and still
   returns 503. Verified nothing else branches on ConnectionError.

3. SSE was buffered. `forward()` reads the whole body through res.text(), so
   even turns that finished in time arrived as one blob and the CLI's live
   token output never appeared. Streaming routes now use forwardStream() and
   pipe the body straight through, preserving content-type and
   x-accel-buffering (dropping the latter lets intermediaries re-buffer and
   reintroduces the stall).

Also closes the escape that produced the sibling `500 code:23` failure: the body
read in forward() was outside the try, so when mcpd had already written SSE
headers the raw DOMException reached Fastify unhandled.

Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat
that outlives the CRUD budget" and "streams SSE through instead of buffering" —
were confirmed to FAIL against the pre-fix behaviour and pass after. Three
existing mcpd-client tests asserted the old taxonomy and were updated to assert
the new one deliberately.

Local: build clean, workspace 2375 passed, lint unchanged at 869.

NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package
rebuild + `systemctl --user restart mcplocal` to take effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
2026-08-10 22:19:56 +01:00
15 changed files with 1279 additions and 40 deletions

View File

@@ -70,7 +70,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
}
/** What the chat is bound to: a named Agent or a Project. */
interface ChatSubject {
export interface ChatSubject {
kind: 'agent' | 'project';
name: string;
/** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */
@@ -97,14 +97,17 @@ function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject
* `personality` overlay (the project schema rejects unknown fields) and adds
* `allowSecrets` when requested.
*/
function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
export function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
const o: Record<string, unknown> = { ...overrides };
if (subject.kind === 'project') {
delete o.personality;
if (subject.allowSecrets) o.allowSecrets = true;
}
const body: Record<string, unknown> = { message, ...o };
if (threadId !== undefined) body.threadId = threadId;
// Guard the empty string, not just undefined: a turn that dies before its
// `final` frame yields no thread id, and sending `threadId: ""` trips mcpd's
// min(1) validation — bricking every later message in the REPL with a 400.
if (threadId !== undefined && threadId !== '') body.threadId = threadId;
if (stream === true) body.stream = true;
return body;
}
@@ -205,7 +208,11 @@ async function runOneShot(
const bar = installStatusBar();
try {
const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar);
process.stderr.write(`\n(thread: ${finalThread})\n`);
if (finalThread !== undefined) {
process.stderr.write(`\n(thread: ${finalThread})\n`);
} else {
process.stderr.write('\n');
}
} finally {
bar?.teardown();
}
@@ -262,7 +269,9 @@ async function runRepl(
const answered = formatAnswered(res.llm, res.model, res.failedOver);
if (answered !== '') process.stderr.write(`${styleStats(`(${answered})`)}\n`);
} else {
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar);
// A failed turn resolves undefined — keep the previous thread (or
// none) instead of overwriting it, so the next message still works.
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar) ?? threadId;
process.stdout.write('\n');
}
} catch (err) {
@@ -502,15 +511,21 @@ async function chatRequestNonStream(
});
}
/** Stream a single chat call. Returns the resolved threadId. */
async function streamOnce(
/**
* Stream a single chat call. Returns the resolved threadId, or undefined when
* the turn never produced a `final` frame (upstream error, early disconnect).
* Returning undefined — instead of the old '' — lets callers keep their
* previous thread state rather than poisoning the next request with an empty
* id that mcpd's validation rejects.
*/
export async function streamOnce(
deps: ChatCommandDeps,
subject: ChatSubject,
message: string,
threadId: string | undefined,
overrides: Overrides,
bar: StatusBar | null = null,
): Promise<string> {
): Promise<string | undefined> {
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true));
@@ -531,7 +546,7 @@ async function streamOnce(
}
}
return new Promise<string>((resolve, reject) => {
return new Promise<string | undefined>((resolve, reject) => {
const driver = url.protocol === 'https:' ? https : http;
const req = driver.request({
hostname: url.hostname,
@@ -552,7 +567,7 @@ async function streamOnce(
return;
}
let buf = '';
let resolvedThread = threadId ?? '';
let resolvedThread: string | undefined = threadId;
let answered = '';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {

View File

@@ -0,0 +1,116 @@
/**
* Regression: a failed first turn must not brick the REPL.
*
* Observed live: the first message died upstream (anthropic 429) before the
* stream's `final` frame, streamOnce resolved '' as the thread id, the REPL
* stored it, and every later message sent `threadId: ""` — which mcpd's
* `z.string().min(1)` rejects with HTTP 400. The session was permanently
* stuck: no turn could succeed again, so no `final` frame could ever repair
* the thread id.
*
* The fix has two independent layers, pinned separately below:
* 1. streamOnce resolves `undefined` (not '') when no `final` frame arrived,
* and the REPL keeps its previous thread state on undefined;
* 2. chatBody never serializes an empty threadId, even if one leaks in.
*/
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { describe, it, expect, afterEach } from 'vitest';
import { chatBody, streamOnce } from '../../src/commands/chat.js';
import type { ChatCommandDeps, ChatSubject } from '../../src/commands/chat.js';
import type { ApiClient } from '../../src/api-client.js';
const subject: ChatSubject = {
kind: 'agent',
name: 'reviewer',
path: 'agents/reviewer',
allowSecrets: false,
};
// streamOnce only touches baseUrl + token; the ApiClient is for the
// non-streaming path and never dereferenced here.
function depsFor(baseUrl: string): ChatCommandDeps {
return { client: null as unknown as ApiClient, baseUrl, log: () => {} };
}
let server: http.Server | null = null;
afterEach(async () => {
if (server !== null) {
await new Promise<void>((r) => server!.close(() => r()));
server = null;
}
});
/** Serve one SSE response body for any POST, return the base URL. */
async function serveSse(frames: string[]): Promise<string> {
server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
for (const f of frames) res.write(`data: ${f}\n\n`);
res.end();
});
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${String(port)}`;
}
describe('chatBody — threadId serialization', () => {
it('omits threadId when undefined', () => {
expect(chatBody(subject, 'hi', undefined, {})).not.toHaveProperty('threadId');
});
it('omits threadId when empty — the exact payload that 400s against mcpd', () => {
expect(chatBody(subject, 'hi', '', {})).not.toHaveProperty('threadId');
});
it('includes a real threadId', () => {
expect(chatBody(subject, 'hi', 'cthread123', {})).toHaveProperty('threadId', 'cthread123');
});
});
describe('streamOnce — thread id after a failed turn', () => {
it('resolves undefined when the stream errors before any final frame', async () => {
const base = await serveSse([
'{"type":"error","message":"anthropic stream: HTTP 429"}',
'[DONE]',
]);
const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {});
expect(resolved).toBeUndefined();
});
it('keeps the caller-supplied thread when the turn fails mid-conversation', async () => {
const base = await serveSse([
'{"type":"error","message":"upstream died"}',
'[DONE]',
]);
const resolved = await streamOnce(depsFor(base), subject, 'hi', 'cexisting1', {});
expect(resolved).toBe('cexisting1');
});
it('resolves the threadId announced by the final frame', async () => {
const base = await serveSse([
'{"type":"text","delta":"pong"}',
'{"type":"final","threadId":"cfresh42"}',
'[DONE]',
]);
const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {});
expect(resolved).toBe('cfresh42');
});
it('REPL chain: failed turn 1 leaves turn 2 sendable (the brick)', async () => {
const base = await serveSse([
'{"type":"error","message":"anthropic stream: HTTP 429"}',
'[DONE]',
]);
// Mirrors runRepl's assignment: threadId = streamOnce(...) ?? threadId
let threadId: string | undefined = undefined;
threadId = (await streamOnce(depsFor(base), subject, 'hi', threadId, {})) ?? threadId;
// Turn 2's body must be valid for mcpd: no threadId key at all.
const body = chatBody(subject, 'hi again', threadId, {}, true);
expect(body).not.toHaveProperty('threadId');
expect(body).toHaveProperty('message', 'hi again');
});
});

View File

@@ -2,7 +2,7 @@ export { createHttpServer } from './server.js';
export type { HttpServerDeps } from './server.js';
export { loadHttpConfig } from './config.js';
export type { HttpConfig } from './config.js';
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
export { McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError } from './mcpd-client.js';
export { registerProxyRoutes } from './routes/proxy.js';
export { registerMcpEndpoint } from './mcp-endpoint.js';
export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js';

View File

@@ -13,6 +13,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { McpRouter } from '../router.js';
import type { JsonRpcRequest } from '../types.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
interface SessionEntry {
transport: StreamableHTTPServerTransport;
@@ -20,6 +21,9 @@ interface SessionEntry {
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
const sessions = new Map<string, SessionEntry>();
// One codec for the shared router: serve OpenAI-safe tool names on the wire,
// map tools/call names back to the internal `server/tool` form.
const wireCodec = new WireNameCodec();
// POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
app.post('/mcp', async (request, reply) => {
@@ -52,7 +56,11 @@ export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): vo
transport.onmessage = async (message: JSONRPCMessage) => {
// The transport sends us JSON-RPC messages; route them through McpRouter
if ('method' in message && 'id' in message) {
const response = await router.route(message as unknown as JsonRpcRequest);
const response = await routeWithWireNames(
wireCodec,
(req) => router.route(req),
message as unknown as JsonRpcRequest,
);
await transport.send(response as unknown as JSONRPCMessage);
}
// Notifications (no id) are ignored — router doesn't handle inbound notifications

View File

@@ -20,9 +20,41 @@ export class ConnectionError extends Error {
}
}
/**
* Thrown when mcpd was reachable but did not finish in time.
*
* Deliberately NOT a ConnectionError. Folding timeouts into "cannot connect"
* is what made this class of failure so expensive to diagnose: mcpd answered
* /healthz in 32ms while the proxy insisted the daemon was down. A timeout and
* an unreachable daemon need different messages and different status codes.
*/
export class UpstreamTimeoutError extends Error {
constructor(readonly url: string, readonly timeoutMs: number) {
super(`mcpd did not respond within ${String(timeoutMs)}ms: ${url}`);
this.name = 'UpstreamTimeoutError';
}
}
/** True when `err` is an AbortSignal.timeout() firing. */
function isTimeout(err: unknown): boolean {
return err instanceof DOMException && err.name === 'TimeoutError';
}
/** Default timeout for mcpd requests (ms). Prevents indefinite hangs on slow upstream tool calls. */
export const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Budget for routes that are *expected* to run long: agent/project chat and
* raw inference. An agent turn is a multi-turn tool-use loop and legitimately
* runs for minutes, so the 30s default is not a safety net there — it is a
* guaranteed failure. Matches `STREAM_TIMEOUT_MS` in the CLI's chat command
* (src/cli/src/commands/chat.ts), which already allowed 10 minutes; mcplocal
* sitting in the middle with 30s was the binding constraint.
*
* Override with `MCPLOCAL_LONG_TIMEOUT_MS`.
*/
export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT_MS']) || 600_000;
/**
* Discovery-class operations (tools/list, resources/list, prompts/list) should not share
* the full tool-call timeout budget — a single dead upstream would stall session init for
@@ -121,9 +153,7 @@ export class McpdClient {
try {
res = await fetch(url, init);
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new ConnectionError(this.baseUrl, new Error(`Request timed out after ${this.timeoutMs}ms`));
}
if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs);
throw new ConnectionError(this.baseUrl, err);
}
@@ -131,7 +161,18 @@ export class McpdClient {
throw new AuthenticationError();
}
const text = await res.text();
// The body read MUST be inside a try. mcpd writes SSE headers immediately
// on chat routes, so fetch() resolves long before the turn finishes and the
// abort lands here instead — previously escaping as a raw DOMException and
// surfacing to the user as an opaque `500 code:23`.
let text: string;
try {
text = await res.text();
} catch (err: unknown) {
if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs);
throw new ConnectionError(this.baseUrl, err);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
@@ -142,6 +183,51 @@ export class McpdClient {
return { status: res.status, body: parsed };
}
/**
* Forward a request and hand back the raw Response, body unread.
*
* `forward()` buffers through `res.text()`, which is fine for CRUD but
* defeats streaming entirely: an SSE chat arrives at the client as one blob
* after the turn ends, so the token-by-token output the CLI draws never
* appears. Streaming routes use this instead and pipe the body straight
* through.
*/
async forwardStream(
method: string,
path: string,
query: string,
body: unknown | undefined,
authOverride?: string,
): Promise<Response> {
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
const headers: Record<string, string> = {
...this.extraHeaders,
'Authorization': `Bearer ${authOverride ?? this.token}`,
// Accept both: mcpd picks SSE or JSON based on the request's `stream` flag.
'Accept': 'text/event-stream, application/json',
};
const init: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(this.timeoutMs),
};
if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
try {
const res = await fetch(url, init);
if (res.status === 401) throw new AuthenticationError();
return res;
} catch (err: unknown) {
if (err instanceof AuthenticationError) throw err;
if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs);
throw new ConnectionError(this.baseUrl, err);
}
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const result = await this.forward(method, path, '', body);

View File

@@ -27,6 +27,7 @@ import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-inde
import { composePlugins } from '../proxymodel/plugins/compose.js';
import type { ProxyModelPlugin } from '../proxymodel/plugin.js';
import { AuditCollector } from '../audit/collector.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
interface ProjectCacheEntry {
router: McpRouter;
@@ -45,6 +46,10 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
let resolvedUserName: string | null | undefined; // undefined = not yet resolved
const projectCache = new Map<string, ProjectCacheEntry>();
const sessions = new Map<string, SessionEntry>();
// Wire-name codecs are keyed per project and OUTLIVE the router cache TTL:
// a client may call a tool it listed minutes ago through a refreshed router,
// and the mapping must still resolve.
const wireCodecs = new Map<string, WireNameCodec>();
/** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */
async function ensureUserName(): Promise<string | null> {
@@ -331,7 +336,18 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
});
const ctx = transport.sessionId ? { sessionId: transport.sessionId, correlationId } : { correlationId };
const response = await router.route(message as unknown as JsonRpcRequest, ctx);
// Wire-name translation happens HERE, at the client boundary, so the
// router, plugins and audit all keep canonical `server/tool` names.
let codec = wireCodecs.get(projectName);
if (!codec) {
codec = new WireNameCodec();
wireCodecs.set(projectName, codec);
}
const response = await routeWithWireNames(
codec,
(req) => router.route(req, ctx),
message as unknown as JsonRpcRequest,
);
// Forward queued notifications BEFORE the response — the response send
// closes the POST SSE stream, so notifications must go first.

View File

@@ -1,10 +1,62 @@
/**
* Catch-all proxy route that forwards /api/v1/* requests to mcpd.
*/
import type { FastifyInstance } from 'fastify';
import { AuthenticationError, ConnectionError } from '../mcpd-client.js';
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { AuthenticationError, ConnectionError, UpstreamTimeoutError, LONG_RUNNING_TIMEOUT_MS } from '../mcpd-client.js';
import type { McpdClient } from '../mcpd-client.js';
/**
* Routes that are expected to run long and/or stream.
*
* An agent turn is a multi-turn tool-use loop — minutes, not seconds — so the
* 30s default budget guarantees failure rather than guarding against it. These
* also stream SSE, which must be piped rather than buffered or the client sees
* one blob at the end instead of live output.
*/
const LONG_RUNNING = [
/^\/api\/v1\/agents\/[^/]+\/chat\b/,
/^\/api\/v1\/projects\/[^/]+\/chat\b/,
/^\/api\/v1\/llms\/[^/]+\/infer\b/,
/^\/api\/v1\/inference-tasks\/[^/]+\/stream\b/,
];
function isLongRunning(path: string): boolean {
return LONG_RUNNING.some((re) => re.test(path));
}
/** Headers worth preserving from mcpd; everything else is re-derived by Fastify. */
const PASSTHROUGH_HEADERS = ['content-type', 'cache-control', 'x-accel-buffering'];
function sendUpstreamError(reply: FastifyReply, err: unknown): FastifyReply | undefined {
if (err instanceof AuthenticationError) {
return reply.code(401).send({
error: 'unauthorized',
message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.',
});
}
if (err instanceof UpstreamTimeoutError) {
// 504, not 503 — mcpd was reachable, it just did not finish. Reporting this
// as "cannot reach mcpd" sent a previous debugging session chasing a
// network fault while /healthz answered in 32ms.
return reply.code(504).send({
error: 'upstream_timeout',
message:
`mcpd did not respond within ${String(err.timeoutMs)}ms. The daemon is reachable — the ` +
'request itself ran long. Raise MCPLOCAL_LONG_TIMEOUT_MS if this is a legitimately slow turn.',
});
}
if (err instanceof ConnectionError) {
return reply.code(503).send({
error: 'service_unavailable',
message: 'Cannot reach mcpd daemon. Is it running?',
});
}
return undefined;
}
export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): void {
app.all('/api/v1/*', async (request, reply) => {
const path = (request.url.split('?')[0]) ?? '/';
@@ -19,25 +71,78 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v
// Forward the user's auth token to mcpd so RBAC applies per-user.
// If no user token is present, mcpd will use its auth hook to reject.
const authHeader = request.headers['authorization'] as string | undefined;
const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
const userToken = authHeader !== undefined && authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: undefined;
if (isLongRunning(path)) {
return proxyStreaming(reply, client, request.method, path, querystring, body, userToken);
}
try {
const result = await client.forward(request.method, path, querystring, body, userToken);
return reply.code(result.status).send(result.body);
} catch (err: unknown) {
if (err instanceof AuthenticationError) {
return reply.code(401).send({
error: 'unauthorized',
message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.',
});
}
if (err instanceof ConnectionError) {
return reply.code(503).send({
error: 'service_unavailable',
message: 'Cannot reach mcpd daemon. Is it running?',
});
}
const handled = sendUpstreamError(reply, err);
if (handled) return handled;
throw err;
}
});
}
/**
* Pipe a long-running response straight through, headers and all.
*
* Hijacks the reply so Fastify does not try to serialize a stream, then copies
* mcpd's status and content-type before piping. `x-accel-buffering` matters:
* mcpd sets it to `no` so intermediaries don't buffer SSE, and dropping it here
* would reintroduce the exact stall we are fixing.
*/
async function proxyStreaming(
reply: FastifyReply,
client: McpdClient,
method: string,
path: string,
querystring: string,
body: unknown,
userToken: string | undefined,
): Promise<void> {
const longClient = client.withTimeout(LONG_RUNNING_TIMEOUT_MS);
let res: Response;
try {
res = await longClient.forwardStream(method, path, querystring, body, userToken);
} catch (err: unknown) {
const handled = sendUpstreamError(reply, err);
if (handled) return;
throw err;
}
const headers: Record<string, string> = {};
for (const name of PASSTHROUGH_HEADERS) {
const value = res.headers.get(name);
if (value !== null) headers[name] = value;
}
reply.hijack();
reply.raw.writeHead(res.status, headers);
if (res.body === null) {
reply.raw.end();
return;
}
try {
// Node's Readable.fromWeb bridges the fetch ReadableStream onto the socket.
await new Promise<void>((resolve, reject) => {
const upstream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
upstream.on('error', reject);
reply.raw.on('close', () => { upstream.destroy(); resolve(); });
upstream.pipe(reply.raw).on('finish', resolve).on('error', reject);
});
} catch {
// Headers are already on the wire, so there is no status left to change.
// Close the socket; the client surfaces the truncated stream.
if (!reply.raw.writableEnded) reply.raw.end();
}
}

View File

@@ -11,7 +11,7 @@ export type { MainResult } from './main.js';
export { ProviderRegistry } from './providers/index.js';
export type { LlmProvider, CompletionOptions, CompletionResult, ChatMessage } from './providers/index.js';
export { OpenAiProvider, AnthropicProvider, OllamaProvider, GeminiCliProvider, DeepSeekProvider } from './providers/index.js';
export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, registerProxyRoutes } from './http/index.js';
export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError, registerProxyRoutes } from './http/index.js';
export type { HttpConfig, HttpServerDeps } from './http/index.js';
export type {
JsonRpcRequest,

View File

@@ -0,0 +1,126 @@
/**
* Wire-safe tool-name codec for client-facing MCP endpoints.
*
* Internally the proxy namespaces tools as `server/tool`, and presentation
* plugins add `favourite/<tool>`, `all/<server>/<tool>` and
* `agent-<name>/chat`. A `/` is not a valid character in OpenAI-style
* function names (`^[a-zA-Z0-9_.-]{1,64}$`), so hosts that forward MCP tool
* names verbatim as LLM function names (LibreChat) depend on the model
* faithfully echoing an illegal name. deepseek-v4-flash intermittently drops
* the `server/` prefix; the host's registry lookup then fails and it reports
* the tool's "MCP server is temporarily unavailable" while nothing is down
* (the librechat fetch_content incident, 2026-08-25). Claude Code and the pi
* extension dodge this only because they sanitize names client-side.
*
* The codec translates ONLY at the HTTP boundary:
* - tools/list responses are rewritten to wire-safe names (`/` → `_`),
* - tools/call requests are mapped back to the presented (internal) name
* via an exact-match reverse map.
*
* Everything inside the proxy — routing maps, plugins, favourites config,
* audit events — keeps the canonical names. Inbound names with no map entry
* (legacy clients echoing slash names, virtual tools called before any
* tools/list) pass through unchanged, so existing clients keep working.
*
* The maps live per project router (not per session) so a client that
* reconnects mid-conversation still resolves names listed on its previous
* session, as long as the process is alive. After a restart the first
* tools/list (which every MCP client performs on initialize) repopulates them.
*/
import type { JsonRpcRequest, JsonRpcResponse } from '../types.js';
/** Replace every character that is invalid in an OpenAI-style function name. */
export function sanitizeWireName(name: string): string {
return name.replace(/[^A-Za-z0-9_.-]/g, '_');
}
export class WireNameCodec {
/** wire name → presented (internal) name */
private toPresented = new Map<string, string>();
/** presented (internal) name → wire name */
private toWire = new Map<string, string>();
/**
* Wire name for a presented tool name. Stable for the codec's lifetime.
*
* Collisions (two presented names sanitizing to the same string, or a
* sanitized name shadowing a tool that already uses that exact name) get a
* numeric suffix — first registration wins the plain name. This keeps the
* reverse map unambiguous; order within one tools/list pass is stable, so
* suffixes are deterministic in practice.
*/
encodeName(presented: string): string {
const existing = this.toWire.get(presented);
if (existing !== undefined) return existing;
const base = sanitizeWireName(presented);
let wire = base;
for (let i = 2; this.toPresented.has(wire) && this.toPresented.get(wire) !== presented; i++) {
wire = `${base}_${String(i)}`;
}
if (wire !== base) {
console.warn(`[wire-names] collision: '${presented}' presented as '${wire}' (base '${base}' taken by '${this.toPresented.get(base) ?? '?'}')`);
}
this.toWire.set(presented, wire);
this.toPresented.set(wire, presented);
return wire;
}
/** The presented name a wire name maps to, or the input unchanged if unknown. */
decodeName(wire: string): string {
return this.toPresented.get(wire) ?? wire;
}
/**
* Rewrite a tools/list response's tool names to wire-safe names,
* registering each mapping. Non-list responses and errors pass through.
*/
encodeToolsList(response: JsonRpcResponse): JsonRpcResponse {
if (response.error !== undefined) return response;
if (response.result === null || typeof response.result !== 'object') return response;
const result = response.result as Record<string, unknown>;
const tools: unknown = result['tools'];
if (!Array.isArray(tools)) return response;
let changed = false;
const encoded = (tools as unknown[]).map((tool) => {
if (tool === null || typeof tool !== 'object' || typeof (tool as { name?: unknown }).name !== 'string') return tool;
const presented = (tool as { name: string }).name;
const wire = this.encodeName(presented);
if (wire === presented) return tool;
changed = true;
return { ...(tool as Record<string, unknown>), name: wire };
});
if (!changed) return response;
return { ...response, result: { ...result, tools: encoded } };
}
/**
* Map a tools/call request's wire name back to the presented name.
* Requests for unknown names (or without a name) pass through unchanged.
*/
decodeToolCall(request: JsonRpcRequest): JsonRpcRequest {
const params = request.params;
const name = params?.['name'];
if (typeof name !== 'string') return request;
const presented = this.toPresented.get(name);
if (presented === undefined || presented === name) return request;
return { ...request, params: { ...params, name: presented } };
}
}
/**
* Route one client request through `route` with wire-name translation:
* decode the tool name on the way in (tools/call), encode tool names on the
* way out (tools/list). Every other method is untouched.
*/
export async function routeWithWireNames(
codec: WireNameCodec,
route: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
request: JsonRpcRequest,
): Promise<JsonRpcResponse> {
const inbound = request.method === 'tools/call' ? codec.decodeToolCall(request) : request;
const response = await route(inbound);
return request.method === 'tools/list' ? codec.encodeToolsList(response) : response;
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerMcpEndpoint } from '../src/http/mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* End-to-end over the Streamable HTTP transport: the /mcp endpoint must serve
* OpenAI-safe tool names on tools/list and map them back to the router's
* canonical `server/tool` names on tools/call (the librechat fetch_content
* incident, 2026-08-25).
*/
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
describe('registerMcpEndpoint wire names', () => {
it('lists wire-safe names and routes calls back to canonical names', async () => {
const routed: JsonRpcRequest[] = [];
const fakeRouter = {
route: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
routed.push(req);
switch (req.method) {
case 'initialize':
return {
jsonrpc: '2.0',
id: req.id,
result: {
protocolVersion: '2024-11-05',
serverInfo: { name: 'test', version: '0' },
capabilities: { tools: {} },
},
};
case 'tools/list':
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'websearch/fetch_content', inputSchema: { type: 'object' } }] },
};
case 'tools/call':
return {
jsonrpc: '2.0',
id: req.id,
result: { content: [{ type: 'text', text: 'ok' }] },
};
default:
return { jsonrpc: '2.0', id: req.id, result: {} };
}
}),
} as unknown as McpRouter;
const app = Fastify();
registerMcpEndpoint(app, fakeRouter);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content']);
const call = await app.inject({
method: 'POST',
url: '/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callResponse = parseSse(call.body);
expect(callResponse.error).toBeUndefined();
const routedCall = routed.find((r) => r.method === 'tools/call');
expect(routedCall?.params?.['name']).toBe('websearch/fetch_content');
} finally {
await app.close();
}
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterAll, afterEach } from 'vitest';
import http from 'node:http';
import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js';
import { McpdClient, ConnectionError, UpstreamTimeoutError } from '../src/http/mcpd-client.js';
/**
* Create a local HTTP server for testing McpdClient behavior.
@@ -85,7 +85,7 @@ describe('McpdClient', () => {
// ── Timeout behavior ──
it('times out on slow responses and throws ConnectionError', async () => {
it('times out on slow responses and throws UpstreamTimeoutError', async () => {
const { server, url } = await createTestServer((_req, _res) => {
// Never respond — simulates a hanging upstream tool call
});
@@ -96,7 +96,7 @@ describe('McpdClient', () => {
const start = Date.now();
await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow(
/timed out/,
/did not respond within/,
);
const elapsed = Date.now() - start;
@@ -105,7 +105,7 @@ describe('McpdClient', () => {
expect(elapsed).toBeLessThan(3000);
});
it('timeout error is a ConnectionError with descriptive message', async () => {
it('timeout is NOT a ConnectionError — a slow daemon is not an absent one', async () => {
const { server, url } = await createTestServer((_req, _res) => {
// Never respond
});
@@ -117,8 +117,12 @@ describe('McpdClient', () => {
await client.get('/test');
expect.unreachable('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(ConnectionError);
expect((err as Error).message).toContain('Request timed out after 200ms');
// Reporting a timeout as "cannot connect" is what sent a previous
// debugging session chasing a network fault that did not exist.
expect(err).toBeInstanceOf(UpstreamTimeoutError);
expect(err).not.toBeInstanceOf(ConnectionError);
expect((err as UpstreamTimeoutError).timeoutMs).toBe(200);
expect((err as Error).message).toContain('did not respond within 200ms');
}
});
@@ -146,7 +150,7 @@ describe('McpdClient', () => {
const derived = client.withHeaders({ 'X-Custom': 'val' });
const start = Date.now();
await expect(derived.get('/test')).rejects.toThrow(/timed out/);
await expect(derived.get('/test')).rejects.toThrow(/did not respond within/);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(2000);
});

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* End-to-end wire-name test on the endpoint the librechat incident actually
* hit: /projects/:name/mcp. A fake `websearch` upstream serves a tool named
* `fetch_content`; the client must see `websearch_fetch_content` in
* tools/list, and calling that wire name must reach the upstream as plain
* `fetch_content` (router canonical `websearch/fetch_content`, prefix
* stripped on dispatch).
*/
const upstreamRequests: JsonRpcRequest[] = [];
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async (router: McpRouter) => {
router.addUpstream({
name: 'websearch',
send: async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
upstreamRequests.push(req);
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'fetch_content', description: 'fetch a page', inputSchema: { type: 'object' } }] },
};
}
if (req.method === 'tools/call') {
return {
jsonrpc: '2.0',
id: req.id,
result: { content: [{ type: 'text', text: 'page body' }] },
};
}
return { jsonrpc: '2.0', id: req.id, result: {} };
},
close: async () => {},
isAlive: () => true,
});
return ['websearch'];
}),
// gated: false → no gate plugin, the full catalog is served at initialize
// (the chat-web configuration).
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
}));
vi.mock('../src/http/config.js', async () => {
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
});
function mockMcpdClient() {
const client: Record<string, unknown> = {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
withToken: vi.fn(),
withTimeout: vi.fn(),
};
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withToken as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withTimeout as ReturnType<typeof vi.fn>).mockReturnValue(client);
return client;
}
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
describe('registerProjectMcpEndpoint wire names', () => {
it('serves wire-safe names on the project endpoint and dispatches calls upstream', async () => {
upstreamRequests.length = 0;
const app = Fastify();
registerProjectMcpEndpoint(app, mockMcpdClient() as never);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
const names = tools.map((t) => t.name);
expect(names).toContain('websearch_fetch_content');
// Every served name must be a valid OpenAI-style function name — the
// invariant the librechat incident violated.
for (const name of names) {
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
}
const call = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callResponse = parseSse(call.body);
expect(callResponse.error).toBeUndefined();
expect((callResponse.result as { content: Array<{ text: string }> }).content[0]?.text).toBe('page body');
// The upstream saw the bare tool name — namespace stripped, not mangled.
const upstreamCall = upstreamRequests.find((r) => r.method === 'tools/call');
expect(upstreamCall?.params?.['name']).toBe('fetch_content');
// Legacy clients echoing the canonical slash name keep working.
const legacy = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'websearch/fetch_content', arguments: { url: 'https://x' } } },
});
expect(parseSse(legacy.body).error).toBeUndefined();
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,255 @@
import http from 'node:http';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, afterEach } from 'vitest';
import {
McpdClient,
UpstreamTimeoutError,
ConnectionError,
LONG_RUNNING_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
} from '../src/http/mcpd-client.js';
import { registerProxyRoutes } from '../src/http/routes/proxy.js';
/**
* Regression cover for the 30s proxy timeout that made `mcpctl chat` fail with
* a misleading "Cannot reach mcpd daemon" 503 while mcpd was answering
* /healthz in 32ms.
*
* Three separate defects are pinned here:
* 1. chat routes inherited the 30s CRUD budget, so any turn longer than 30s
* failed — and an agent turn is a tool-use loop that routinely exceeds it;
* 2. a timeout was reported as a connection failure, sending diagnosis after
* a network fault that did not exist;
* 3. SSE was buffered through res.text(), so streaming never reached the
* client even when the turn finished in time.
*/
let app: FastifyInstance | null = null;
let upstream: FastifyInstance | null = null;
afterEach(async () => {
if (app) { await app.close(); app = null; }
if (upstream) { await upstream.close(); upstream = null; }
});
/** A stand-in mcpd. Returns its base URL. */
async function startUpstream(register: (a: FastifyInstance) => void): Promise<string> {
upstream = Fastify();
register(upstream);
await upstream.listen({ port: 0, host: '127.0.0.1' });
const addr = upstream.server.address();
if (addr === null || typeof addr === 'string') throw new Error('no address');
return `http://127.0.0.1:${String(addr.port)}`;
}
async function startProxy(baseUrl: string, timeoutMs?: number): Promise<FastifyInstance> {
app = Fastify();
registerProxyRoutes(app, new McpdClient(baseUrl, 'test-token', {}, timeoutMs));
await app.ready();
return app;
}
describe('proxy — long-running route budget', () => {
it('gives chat routes the long budget, not the 30s CRUD default', () => {
// The constants themselves are the contract: a 30s cap on an agent turn is
// a guaranteed failure, not a safety net.
expect(DEFAULT_TIMEOUT_MS).toBe(30_000);
expect(LONG_RUNNING_TIMEOUT_MS).toBeGreaterThanOrEqual(600_000);
});
it('does not abort an agent chat that outlives the CRUD budget', async () => {
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async () => {
// Longer than the (deliberately tiny) CRUD budget below. Before the
// fix this inherited that budget and 503'd.
await new Promise((r) => setTimeout(r, 250));
return { answer: 'pong' };
});
});
// CRUD budget of 50ms — a chat route must NOT inherit it.
const proxy = await startProxy(base, 50);
const res = await proxy.inject({
method: 'POST',
url: '/api/v1/agents/reviewer/chat',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ answer: 'pong' });
});
it('still applies the short budget to ordinary CRUD routes', async () => {
const base = await startUpstream((a) => {
a.get('/api/v1/servers', async () => {
await new Promise((r) => setTimeout(r, 300));
return [];
});
});
const proxy = await startProxy(base, 50);
const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' });
// Times out — and is now reported honestly as a timeout, not a connection fault.
expect(res.statusCode).toBe(504);
expect(res.json().error).toBe('upstream_timeout');
});
it('reports a timeout as 504, never as "cannot reach mcpd"', async () => {
const base = await startUpstream((a) => {
a.get('/api/v1/servers', async () => {
await new Promise((r) => setTimeout(r, 300));
return [];
});
});
const proxy = await startProxy(base, 50);
const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' });
const body = res.json();
expect(body.message).toMatch(/did not respond within/);
expect(body.message).not.toMatch(/Cannot reach mcpd/);
expect(body.message).toMatch(/reachable/);
});
it('streams SSE through instead of buffering it', async () => {
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
});
reply.raw.write('data: {"type":"text","delta":"po"}\n\n');
reply.raw.write('data: {"type":"text","delta":"ng"}\n\n');
reply.raw.write('data: [DONE]\n\n');
reply.raw.end();
return reply;
});
});
const proxy = await startProxy(base, 50);
const res = await proxy.inject({
method: 'POST',
url: '/api/v1/agents/reviewer/chat',
payload: { message: 'hi', stream: true },
});
expect(res.statusCode).toBe(200);
// Content-type must survive — a client that gets application/json will not
// parse the event stream.
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
// x-accel-buffering=no must survive too, or intermediaries re-buffer the
// stream and reintroduce the stall.
expect(res.headers['x-accel-buffering']).toBe('no');
expect(res.body).toContain('"delta":"po"');
expect(res.body).toContain('"delta":"ng"');
expect(res.body).toContain('[DONE]');
});
it('delivers each SSE frame while the upstream is still generating', async () => {
// The buffering regression is invisible to the pass-through test above:
// `inject()` collects the whole body, so a proxy that buffers via
// res.text() still passes it. This test proves *progressive* delivery by
// making the upstream withhold its final frame until the client has
// observed the first one. A buffering proxy can never satisfy that
// ordering — the 3s guard resolves the gate so the run fails cleanly
// instead of deadlocking.
let openGate: (seen: boolean) => void = () => {};
const clientSawFirstFrame = new Promise<boolean>((r) => { openGate = r; });
const guard = setTimeout(() => openGate(false), 3_000);
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' });
reply.raw.write('data: {"type":"text","delta":"live"}\n\n');
await clientSawFirstFrame;
reply.raw.write('data: {"type":"final"}\n\n');
reply.raw.write('data: [DONE]\n\n');
reply.raw.end();
return reply;
});
});
const proxy = await startProxy(base, 50);
await proxy.listen({ port: 0, host: '127.0.0.1' });
const addr = proxy.server.address();
if (addr === null || typeof addr === 'string') throw new Error('no address');
const body = await new Promise<string>((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port: addr.port,
path: '/api/v1/agents/reviewer/chat',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, (res) => {
let acc = '';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
acc += chunk;
if (acc.includes('"delta":"live"')) openGate(true);
});
res.on('end', () => resolve(acc));
res.on('error', reject);
});
req.on('error', reject);
req.end(JSON.stringify({ message: 'hi', stream: true }));
});
clearTimeout(guard);
// The ordering proof: the first frame reached the client while the
// upstream was still holding the stream open.
await expect(clientSawFirstFrame).resolves.toBe(true);
expect(body).toContain('"type":"final"');
expect(body).toContain('[DONE]');
});
it('relays a non-200 status from a streaming route', async () => {
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {
return reply.code(404).send({ error: 'Agent not found' });
});
});
const proxy = await startProxy(base, 50);
const res = await proxy.inject({
method: 'POST',
url: '/api/v1/agents/ghost/chat',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(404);
expect(res.body).toContain('Agent not found');
});
it('still reports a genuinely unreachable daemon as 503', async () => {
// Port 1 is reserved and refuses instantly.
const proxy = await startProxy('http://127.0.0.1:1', 500);
const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' });
expect(res.statusCode).toBe(503);
expect(res.json().error).toBe('service_unavailable');
});
it('propagates 401 from a streaming route so login guidance still fires', async () => {
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async (_req, reply) => reply.code(401).send({}));
});
const proxy = await startProxy(base, 50);
const res = await proxy.inject({
method: 'POST',
url: '/api/v1/agents/reviewer/chat',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(401);
expect(res.json().message).toMatch(/mcpctl login/);
});
});
describe('error taxonomy', () => {
it('keeps timeout and unreachable as distinct types', () => {
const timeout = new UpstreamTimeoutError('http://mcpd', 30_000);
expect(timeout).not.toBeInstanceOf(ConnectionError);
expect(timeout.timeoutMs).toBe(30_000);
expect(timeout.message).toMatch(/did not respond within 30000ms/);
});
});

View File

@@ -18,8 +18,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { spawnSync, execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const MCPLOCAL_URL = process.env.MCPLOCAL_URL ?? 'http://localhost:3200';
const LLM_URL = process.env.MCPCTL_SMOKE_LLM_URL;
const LLM_MODEL = process.env.MCPCTL_SMOKE_LLM_MODEL ?? 'qwen3-thinking';
const LLM_KEY = process.env.MCPCTL_SMOKE_LLM_KEY;
@@ -27,6 +31,10 @@ const SUFFIX = Date.now().toString(36);
const SECRET_NAME = `smoke-chat-sec-${SUFFIX}`;
const LLM_NAME = `smoke-chat-llm-${SUFFIX}`;
const AGENT_NAME = `smoke-chat-agent-${SUFFIX}`;
// Dedicated agent for the streaming-timing test: the shared agent's system
// prompt pins the reply to a single token, which is too short to distinguish
// live streaming from an end-of-turn buffer dump.
const STREAM_AGENT_NAME = `smoke-stream-agent-${SUFFIX}`;
interface CliResult { code: number; stdout: string; stderr: string }
@@ -99,6 +107,7 @@ describe('agent chat smoke (live LLM)', () => {
afterAll(() => {
if (!liveLlmConfigured || !mcpdUp) return;
run(`delete agent ${AGENT_NAME}`);
run(`delete agent ${STREAM_AGENT_NAME}`);
run(`delete llm ${LLM_NAME}`);
run(`delete secret ${SECRET_NAME}`);
});
@@ -139,6 +148,92 @@ describe('agent chat smoke (live LLM)', () => {
expect(result.stderr).toMatch(/thread:\s+c[a-z0-9]+/);
});
it('streams progressively THROUGH mcplocal — frames arrive during generation, not in one burst', async () => {
if (!liveLlmConfigured || !mcpdUp) return;
// The regression this pins: mcplocal's /api/v1/* proxy buffered SSE via
// res.text(), so the CLI showed nothing until the turn finished and then
// dumped the whole answer at once. The --direct tests above bypass
// mcplocal entirely and cannot catch that. This one posts to the local
// proxy (the path `mcpctl chat` actually takes) and asserts frames are
// spread across the generation window: with buffering, everything lands
// within a few ms of stream end.
if (!(await healthz(MCPLOCAL_URL))) {
// eslint-disable-next-line no-console
console.warn(`\n ○ mcplocal streaming smoke: skipped — ${MCPLOCAL_URL}/healthz unreachable.\n`);
return;
}
let token = '';
try {
const credsPath = join(homedir(), '.mcpctl', 'credentials');
if (existsSync(credsPath)) {
const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) as { token?: string };
if (creds.token !== undefined) token = creds.token;
}
} catch { /* unauthenticated — the request will 401 and fail loudly */ }
run(`delete agent ${STREAM_AGENT_NAME}`);
const agent = run([
`create agent ${STREAM_AGENT_NAME}`,
`--llm ${LLM_NAME}`,
`--description "mcplocal streaming smoke"`,
`--system-prompt "You are a smoke test. Follow the user's instructions exactly."`,
'--default-temperature 0',
'--default-max-tokens 512',
].join(' '));
expect(agent.code, agent.stderr).toBe(0);
const url = new URL(`${MCPLOCAL_URL.replace(/\/$/, '')}/api/v1/agents/${STREAM_AGENT_NAME}/chat`);
const deltaTimes: number[] = [];
let endTime = 0;
let status = 0;
let raw = '';
await new Promise<void>((resolve, reject) => {
const req = http.request({
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'POST',
timeout: 120_000,
headers: {
'Content-Type': 'application/json',
...(token !== '' ? { Authorization: `Bearer ${token}` } : {}),
},
}, (res) => {
status = res.statusCode ?? 0;
res.setEncoding('utf-8');
let buf = '';
res.on('data', (chunk: string) => {
raw += chunk;
buf += chunk;
let nl: number;
while ((nl = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, nl);
buf = buf.slice(nl + 2);
if (/"type":"(text|thinking)"/.test(frame)) deltaTimes.push(Date.now());
}
});
res.on('end', () => { endTime = Date.now(); resolve(); });
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('stream timed out')); });
req.end(JSON.stringify({
message: 'Count from 1 to 40, one number per line. No other text.',
stream: true,
max_tokens: 400,
}));
});
expect(status, raw.slice(0, 500)).toBe(200);
expect(deltaTimes.length).toBeGreaterThanOrEqual(2);
// The buffering signature: every frame lands in the same final burst as
// stream end. Live streaming puts the first delta well before the end —
// a 40-line generation spans seconds; 300ms is a conservative floor.
const firstDelta = deltaTimes[0]!;
expect(endTime - firstDelta).toBeGreaterThanOrEqual(300);
}, 150_000);
it('streaming `mcpctl chat` emits text deltas', () => {
if (!liveLlmConfigured || !mcpdUp) return;
// Default mode is streaming. Pipe stdout/stderr separately.

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from 'vitest';
import { sanitizeWireName, WireNameCodec, routeWithWireNames } from '../src/util/wire-names.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
describe('sanitizeWireName', () => {
it('replaces slashes with underscores', () => {
expect(sanitizeWireName('websearch/fetch_content')).toBe('websearch_fetch_content');
expect(sanitizeWireName('all/websearch/fetch_content')).toBe('all_websearch_fetch_content');
});
it('keeps names that are already OpenAI-safe', () => {
expect(sanitizeWireName('begin_session')).toBe('begin_session');
expect(sanitizeWireName('my-grafana.tool')).toBe('my-grafana.tool');
});
it('replaces every character outside [A-Za-z0-9_.-]', () => {
expect(sanitizeWireName('a b:c/d')).toBe('a_b_c_d');
});
});
describe('WireNameCodec', () => {
it('round-trips a namespaced tool name', () => {
const codec = new WireNameCodec();
const wire = codec.encodeName('websearch/fetch_content');
expect(wire).toBe('websearch_fetch_content');
expect(codec.decodeName(wire)).toBe('websearch/fetch_content');
});
it('is stable across repeated encodes', () => {
const codec = new WireNameCodec();
expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read');
expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read');
});
it('passes unknown inbound names through unchanged', () => {
const codec = new WireNameCodec();
// Legacy client echoing the slash form, or a virtual tool never listed.
expect(codec.decodeName('websearch/fetch_content')).toBe('websearch/fetch_content');
expect(codec.decodeName('begin_session')).toBe('begin_session');
});
it('suffixes on collision, first registration wins the plain name', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const codec = new WireNameCodec();
expect(codec.encodeName('foo_bar/baz')).toBe('foo_bar_baz');
expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2');
// Both decode back to their own presented names.
expect(codec.decodeName('foo_bar_baz')).toBe('foo_bar/baz');
expect(codec.decodeName('foo_bar_baz_2')).toBe('foo/bar_baz');
// And stay stable.
expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2');
expect(warn).toHaveBeenCalledOnce();
} finally {
warn.mockRestore();
}
});
it('encodes tools/list responses and leaves other fields intact', () => {
const codec = new WireNameCodec();
const response: JsonRpcResponse = {
jsonrpc: '2.0',
id: 1,
result: {
tools: [
{ name: 'websearch/fetch_content', description: 'fetch', inputSchema: { type: 'object' } },
{ name: 'begin_session', description: 'gate' },
],
},
};
const encoded = codec.encodeToolsList(response);
const tools = (encoded.result as { tools: Array<{ name: string; description?: string }> }).tools;
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content', 'begin_session']);
expect(tools[0]?.description).toBe('fetch');
// Original response object is not mutated.
const originalTools = (response.result as { tools: Array<{ name: string }> }).tools;
expect(originalTools[0]?.name).toBe('websearch/fetch_content');
});
it('returns error and non-list responses unchanged', () => {
const codec = new WireNameCodec();
const err: JsonRpcResponse = { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'boom' } };
expect(codec.encodeToolsList(err)).toBe(err);
const other: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { content: [] } };
expect(codec.encodeToolsList(other)).toBe(other);
});
it('decodes tools/call requests for known wire names only', () => {
const codec = new WireNameCodec();
codec.encodeName('websearch/fetch_content');
const known: JsonRpcRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } },
};
const decoded = codec.decodeToolCall(known);
expect(decoded.params?.['name']).toBe('websearch/fetch_content');
expect(decoded.params?.['arguments']).toEqual({ url: 'https://x' });
// Original request object is not mutated.
expect(known.params?.['name']).toBe('websearch_fetch_content');
const unknown: JsonRpcRequest = {
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'not_listed', arguments: {} },
};
expect(codec.decodeToolCall(unknown)).toBe(unknown);
});
});
describe('routeWithWireNames', () => {
const listResponse: JsonRpcResponse = {
jsonrpc: '2.0',
id: 1,
result: { tools: [{ name: 'websearch/fetch_content' }, { name: 'searxng/web_url_read' }] },
};
it('serves wire-safe names on tools/list and maps tools/call back', async () => {
const codec = new WireNameCodec();
const seen: JsonRpcRequest[] = [];
const route = async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
seen.push(req);
if (req.method === 'tools/list') return listResponse;
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'ok' }] } };
};
const listed = await routeWithWireNames(codec, route, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
const names = (listed.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toEqual(['websearch_fetch_content', 'searxng_web_url_read']);
// The exact scenario from the librechat incident: the model echoes the
// wire name; the router must receive the canonical name.
await routeWithWireNames(codec, route, {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } },
});
expect(seen[1]?.params?.['name']).toBe('websearch/fetch_content');
});
it('keeps legacy slash-name calls working', async () => {
const codec = new WireNameCodec();
const seen: JsonRpcRequest[] = [];
const route = async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
seen.push(req);
return { jsonrpc: '2.0', id: req.id, result: {} };
};
await routeWithWireNames(codec, route, {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'websearch/fetch_content', arguments: {} },
});
expect(seen[0]?.params?.['name']).toBe('websearch/fetch_content');
});
it('does not touch other methods', async () => {
const codec = new WireNameCodec();
const init: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} };
const response: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05' } };
const out = await routeWithWireNames(codec, async () => response, init);
expect(out).toBe(response);
});
});