Compare commits

...

6 Commits

Author SHA1 Message Date
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
ae5a6203f8 Merge PR #108: fail the release when smoke tests fail
Some checks failed
CI/CD / lint (push) Successful in 1m12s
CI/CD / test (push) Successful in 1m26s
CI/CD / typecheck (push) Successful in 3m7s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 2m15s
CI/CD / publish (push) Has been skipped
2026-08-10 16:16:05 +00:00
Michal
822c1bb047 build: fail the release when smoke tests fail, and fix the SSE test that hung
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m6s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 4m40s
CI/CD / publish (pull_request) Has been skipped
release.sh printed `WARNING: Smoke tests failed!` and exited 0. That is how four
broken readiness probes shipped unnoticed on 2026-08-10 — the warning scrolled
past in the build log and the release reported success.

It now exits 1, with `MCPCTL_ALLOW_SMOKE_FAILURE=1` as the escape hatch. The
message is explicit that smoke runs LAST, against the installed binary: the
package is already published and installed, so the failure reports fleet
breakage rather than preventing a bad artifact.

Turning the gate on required fixing a latent hang first, or every release would
have blocked on it. `security.test.ts > /inspect SSE endpoint …` waited for a
response body that by design never ends, so it could only settle via the
socket's *inactivity* timeout — and /inspect relays every project's MCP traffic,
so during a full smoke run it is never idle. Run alone it passed and looked
flaky; run with the suite it failed every time.

httpRequest gains `headersOnly`, which resolves on the response headers and
hangs up. The assertion only ever needed the status line.

Verified: full smoke suite 158/158 (was 157/158 with this test timing out); the
gate block lifted verbatim from release.sh exits 1 with a stubbed failing smoke
run, and exits 0 reaching subsequent code under MCPCTL_ALLOW_SMOKE_FAILURE=1.

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

View File

@@ -908,6 +908,15 @@ MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another b
Offline it falls back to the last fetched `origin/main`, then to a local `main`,
and says which it used; outside a git checkout it skips entirely.
**A failing smoke run fails the release.** It used to print
`WARNING: Smoke tests failed!` and exit 0 — which is exactly how four broken
readiness probes shipped unnoticed (see `docs/reliability.md`): the warning
scrolled past and the release reported success. Note what the gate does and does
not do — smoke runs *last*, against the installed binary, so the package is
already published and installed by the time it fails. It reports the breakage
rather than preventing it, so investigate the fleet rather than assuming the
artifact is bad. Override with `MCPCTL_ALLOW_SMOKE_FAILURE=1`.
Installs via nfpm:
- `/usr/bin/mcpctl` — CLI binary (bun compiled)
- `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled)

View File

@@ -75,9 +75,28 @@ echo "==> Running smoke tests..."
export PATH="$HOME/.npm-global/bin:$PATH"
if pnpm test:smoke; then
echo "==> Smoke tests passed!"
elif [ "${MCPCTL_ALLOW_SMOKE_FAILURE:-}" = "1" ]; then
echo "==> WARNING: Smoke tests failed, continuing (MCPCTL_ALLOW_SMOKE_FAILURE=1)."
else
echo "==> WARNING: Smoke tests failed! Check mcplocal/mcpd are running."
echo " Continuing anyway — deployment is complete, but verify manually."
# This used to print a warning and exit 0. That is how four broken readiness
# probes shipped unnoticed on 2026-08-10: the warning scrolled past in the
# build log and the release reported success. A failing smoke run means
# something in the live fleet is genuinely broken — say so in the exit code.
#
# Note what this does and does not do: smoke runs LAST, against the installed
# binary, so the package is already published and installed by now. Failing
# here reports the breakage, it does not prevent it — investigate, do not
# assume the artifact is bad.
echo "" >&2
echo "ERROR: smoke tests failed — the release is published and installed, but" >&2
echo " something in the live fleet is broken. Investigate before relying" >&2
echo " on this build; do not just re-run." >&2
echo "" >&2
echo " Common causes: mcplocal/mcpd not running, a readiness probe pointing at" >&2
echo " a tool the upstream renamed, or an expired credential." >&2
echo " Override: MCPCTL_ALLOW_SMOKE_FAILURE=1 $0" >&2
echo "" >&2
exit 1
fi
echo ""

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);
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

@@ -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

@@ -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

@@ -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,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

@@ -32,6 +32,18 @@ function httpRequest(opts: {
headers?: Record<string, string>;
body?: string;
timeout?: number;
/**
* Resolve as soon as the response headers arrive, then hang up, instead of
* waiting for the body to end.
*
* Required for a streaming endpoint: SSE responses never end, so the normal
* path can only settle via the socket's *inactivity* timeout — which never
* fires while the stream is busy. `/inspect` relays every project's MCP
* traffic, so during a full smoke run it is never idle, and the request hung
* until vitest killed the test. Alone it looked flaky; under load it failed
* every time. Reading the status does not need the body anyway.
*/
headersOnly?: boolean;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(opts.url);
@@ -46,6 +58,12 @@ function httpRequest(opts: {
timeout: opts.timeout ?? 10_000,
},
(res) => {
if (opts.headersOnly === 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', () => {
@@ -93,17 +111,15 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => {
// /inspect streams ALL MCP traffic (tool calls, arguments, responses)
// for ALL projects to any unauthenticated local client
// headersOnly: the stream never ends, and waiting for it to go idle is what
// made this hang whenever other suites were generating traffic. The status
// line is all this assertion needs.
const res = await httpRequest({
url: `${MCPLOCAL_URL}/inspect`,
method: 'GET',
headers: { 'Accept': 'text/event-stream' },
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;
headersOnly: true,
});
// Should be accessible without auth (documenting the vulnerability)