fix(mcp): resolve streamable http transport readiness offline status (#3037)

Integrated into release/v3.8.8. Fixes MCP streamable-HTTP transport readiness reporting when offline + session sweep. Thanks @Chewji9875!
This commit is contained in:
Chewji
2026-06-01 19:59:23 +07:00
committed by GitHub
parent e11366b647
commit 6b7ae9ad2e
4 changed files with 75 additions and 25 deletions

View File

@@ -34,7 +34,9 @@ const _mcpSessionSweep = setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of _streamableSessions) {
if (now - session.lastActivityAt > MCP_SESSION_IDLE_MS) {
try { closeStreamableSession(sessionId); } catch {}
try {
closeStreamableSession(sessionId);
} catch {}
}
}
}, 60_000);
@@ -256,6 +258,13 @@ export function getMcpHttpStatus(): {
};
}
export function isMcpHttpTransportReady(
enabled: boolean,
transport: string | null | undefined
): boolean {
return enabled && (transport === "sse" || transport === "streamable-http");
}
export function shutdownMcpHttp(): void {
closeSseTransport();
closeAllStreamableSessions();

View File

@@ -4,9 +4,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
type McpTransport = "stdio" | "sse" | "streamable-http";
type McpStatusResponse = {
status: "online" | "offline";
online: boolean;
enabled: boolean;
transport: McpTransport;
scopesEnforced?: boolean;
heartbeatPath: string;
heartbeat: {
pid: number;
@@ -21,6 +26,12 @@ type McpStatusResponse = {
heartbeatAgeMs: number | null;
uptimeMs: number | null;
} | null;
httpTransport: {
online: boolean;
transport: "sse" | "streamable-http" | null;
startedAt: number | null;
uptime: string | null;
};
activity: {
totalCalls24h: number;
successRate: number;
@@ -353,6 +364,13 @@ export default function McpDashboardPage() {
const totalPages = Math.max(1, Math.ceil((auditData.total || 0) / AUDIT_PAGE_SIZE));
const currentPage = Math.floor((auditData.offset || 0) / AUDIT_PAGE_SIZE) + 1;
const topTools = status?.activity?.topTools || [];
const runtimeTransport = status?.transport || status?.heartbeat?.transport || "—";
const runtimeUptime =
status?.transport === "stdio"
? formatDuration(status?.heartbeat?.uptimeMs ?? null)
: status?.httpTransport?.uptime || "—";
const heartbeatLabel =
status?.transport === "stdio" ? formatDuration(status?.heartbeat?.heartbeatAgeMs ?? null) : "—";
if (loading) {
return <div className="text-sm text-text-muted">{t("loading")}</div>;
@@ -363,14 +381,8 @@ export default function McpDashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard label={t("processStatus")} value={status?.online ? t("online") : t("offline")} />
<StatCard label={t("pid")} value={status?.heartbeat?.pid ?? "—"} />
<StatCard
label={t("sessionUptime")}
value={formatDuration(status?.heartbeat?.uptimeMs ?? null)}
/>
<StatCard
label={t("lastHeartbeat")}
value={formatDuration(status?.heartbeat?.heartbeatAgeMs ?? null)}
/>
<StatCard label={t("sessionUptime")} value={runtimeUptime} />
<StatCard label={t("lastHeartbeat")} value={heartbeatLabel} />
</div>
<Card className="p-5">
@@ -408,13 +420,12 @@ export default function McpDashboardPage() {
<h3 className="text-sm font-semibold mb-2">{t("runtimeDetails")}</h3>
<div className="text-sm space-y-1">
<p>
{t("transport")}:{" "}
<span className="font-mono">{status?.heartbeat?.transport || "—"}</span>
{t("transport")}: <span className="font-mono">{runtimeTransport}</span>
</p>
<p>
{t("scopesEnforced")}:{" "}
<span className="font-semibold">
{status?.heartbeat?.scopesEnforced ? t("yes") : t("no")}
{(status?.scopesEnforced ?? status?.heartbeat?.scopesEnforced) ? t("yes") : t("no")}
</span>
</p>
<p>

View File

@@ -6,7 +6,10 @@ import {
readMcpHeartbeat,
resolveMcpHeartbeatPath,
} from "@omniroute/open-sse/mcp-server/runtimeHeartbeat";
import { getMcpHttpStatus } from "../../../../../open-sse/mcp-server/httpTransport";
import {
getMcpHttpStatus,
isMcpHttpTransportReady,
} from "../../../../../open-sse/mcp-server/httpTransport";
import { getSettings } from "@/lib/db/settings";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@@ -24,12 +27,18 @@ export async function GET(request: Request) {
const mcpEnabled = !!settings.mcpEnabled;
const mcpTransport = (settings.mcpTransport as string) || "stdio";
// Check HTTP transport (SSE / Streamable HTTP) if active
// Check HTTP transport active-session state separately from endpoint readiness.
const httpStatus = getMcpHttpStatus();
// stdio uses heartbeat file; HTTP transports use in-process state
// stdio uses an external process heartbeat. HTTP transports are in-process and lazy-start
// on first request, so an enabled HTTP endpoint is online even before any session exists.
const stdioOnline = isMcpHeartbeatOnline(heartbeat, { requireLivePid: true });
const online = mcpTransport === "stdio" ? stdioOnline : httpStatus.online;
const online =
mcpTransport === "stdio"
? mcpEnabled && stdioOnline
: isMcpHttpTransportReady(mcpEnabled, mcpTransport);
const scopesEnforced = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true";
const lastCall = lastCallPage.entries[0] || null;
const now = Date.now();
@@ -49,6 +58,7 @@ export async function GET(request: Request) {
online,
enabled: mcpEnabled,
transport: mcpTransport,
scopesEnforced,
heartbeatPath: resolveMcpHeartbeatPath(),
heartbeat: heartbeat
? {

View File

@@ -28,6 +28,10 @@ test("module exports shutdownMcpHttp", () => {
assert.equal(typeof mod.shutdownMcpHttp, "function");
});
test("module exports isMcpHttpTransportReady", () => {
assert.equal(typeof mod.isMcpHttpTransportReady, "function");
});
test("module exports isMcpHttpActive", () => {
assert.equal(typeof mod.isMcpHttpActive, "function");
});
@@ -37,22 +41,24 @@ test("module exports isMcpHttpActive", () => {
test("StreamableSession type includes lastActivityAt field", () => {
const typeBlock = src.match(/type StreamableSession\s*=\s*\{([^}]+)\}/);
assert.ok(typeBlock, "StreamableSession type definition must exist");
assert.ok(typeBlock[1].includes("lastActivityAt"), "StreamableSession must have lastActivityAt field");
assert.ok(
typeBlock[1].includes("lastActivityAt"),
"StreamableSession must have lastActivityAt field"
);
});
// ── Source-level invariant: sweep uses lastActivityAt not startedAt ───────────
test("sweep interval compares against lastActivityAt, not startedAt", () => {
const sweepBlock = src.match(/_mcpSessionSweep\s*=\s*setInterval\(\(\)\s*=>\s*\{([\s\S]*?)\},\s*60_000\)/);
const sweepBlock = src.match(
/_mcpSessionSweep\s*=\s*setInterval\(\(\)\s*=>\s*\{([\s\S]*?)\},\s*60_000\)/
);
assert.ok(sweepBlock, "sweep interval block must exist");
assert.ok(
sweepBlock[1].includes("session.lastActivityAt"),
"sweep must check session.lastActivityAt"
);
assert.ok(
!sweepBlock[1].includes("session.startedAt"),
"sweep must NOT check session.startedAt"
);
assert.ok(!sweepBlock[1].includes("session.startedAt"), "sweep must NOT check session.startedAt");
});
// ── Source-level invariant: MCP_SESSION_IDLE_MS constant ─────────────────────
@@ -75,7 +81,9 @@ test("createStreamableSession initializes lastActivityAt to Date.now()", () => {
// ── Source-level invariant: handleStreamableRequest updates lastActivityAt ────
test("handleStreamableRequest updates lastActivityAt on every request", () => {
const fnBlock = src.match(/async function handleStreamableRequest[\s\S]*?(?=\n(?:async )?function |\nexport )/);
const fnBlock = src.match(
/async function handleStreamableRequest[\s\S]*?(?=\n(?:async )?function |\nexport )/
);
assert.ok(fnBlock, "handleStreamableRequest function must exist");
assert.ok(
fnBlock[0].includes("session.lastActivityAt = Date.now()"),
@@ -85,7 +93,7 @@ test("handleStreamableRequest updates lastActivityAt on every request", () => {
// ── Behavioral: getMcpHttpStatus returns expected shape when idle ─────────────
test("getMcpHttpStatus returns correct shape with no active sessions", () => {
test("getMcpHttpStatus returns active-session state with no active sessions", () => {
mod.shutdownMcpHttp();
const status = mod.getMcpHttpStatus();
assert.equal(typeof status.online, "boolean");
@@ -95,6 +103,16 @@ test("getMcpHttpStatus returns correct shape with no active sessions", () => {
assert.equal(status.uptime, null);
});
test("isMcpHttpTransportReady treats enabled lazy HTTP transports as ready", () => {
mod.shutdownMcpHttp();
const status = mod.getMcpHttpStatus();
assert.equal(status.online, false);
assert.equal(mod.isMcpHttpTransportReady(true, "streamable-http"), true);
assert.equal(mod.isMcpHttpTransportReady(true, "sse"), true);
assert.equal(mod.isMcpHttpTransportReady(true, "stdio"), false);
assert.equal(mod.isMcpHttpTransportReady(false, "streamable-http"), false);
});
// ── Behavioral: isMcpHttpActive is false after shutdown ──────────────────────
test("isMcpHttpActive returns false when no transports are active", () => {
@@ -132,7 +150,9 @@ test("sweep timer is unref'd to avoid preventing process exit", () => {
// ── Source-level invariant: sweep calls closeStreamableSession for idle ───────
test("sweep closes idle sessions via closeStreamableSession", () => {
const sweepBlock = src.match(/_mcpSessionSweep\s*=\s*setInterval\(\(\)\s*=>\s*\{([\s\S]*?)\},\s*60_000\)/);
const sweepBlock = src.match(
/_mcpSessionSweep\s*=\s*setInterval\(\(\)\s*=>\s*\{([\s\S]*?)\},\s*60_000\)/
);
assert.ok(sweepBlock, "sweep block must exist");
assert.ok(
sweepBlock[1].includes("closeStreamableSession(sessionId)"),