diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index c945130355..2c8b6a41e6 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -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(); diff --git a/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx index 0016b00508..5dc2b54403 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx @@ -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
{t("loading")}
; @@ -363,14 +381,8 @@ export default function McpDashboardPage() {
- - + +
@@ -408,13 +420,12 @@ export default function McpDashboardPage() {

{t("runtimeDetails")}

- {t("transport")}:{" "} - {status?.heartbeat?.transport || "—"} + {t("transport")}: {runtimeTransport}

{t("scopesEnforced")}:{" "} - {status?.heartbeat?.scopesEnforced ? t("yes") : t("no")} + {(status?.scopesEnforced ?? status?.heartbeat?.scopesEnforced) ? t("yes") : t("no")}

diff --git a/src/app/api/mcp/status/route.ts b/src/app/api/mcp/status/route.ts index f87d887799..419424e456 100644 --- a/src/app/api/mcp/status/route.ts +++ b/src/app/api/mcp/status/route.ts @@ -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 ? { diff --git a/tests/unit/mcp-session-sweep.test.ts b/tests/unit/mcp-session-sweep.test.ts index 33e16fe609..c4928f6fba 100644 --- a/tests/unit/mcp-session-sweep.test.ts +++ b/tests/unit/mcp-session-sweep.test.ts @@ -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)"),