mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
Reference in New Issue
Block a user