mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
fix(cli): add POST /api/mcp/restart and mcp enable/disable subcommands (#13012)
`bin/cli/commands/mcp.mjs::runMcpRestartCommand` POSTs to /api/mcp/restart, but that route never existed under src/app/api/mcp/ — every `omniroute mcp restart` call 404d. There was also no CLI/env path to flip the mcpEnabled setting on: it was dashboard-only (/dashboard/mcp -> PATCH /api/settings), stranding headless/portable installs (Windows portable Node, no browser session) with no way to reach the MCP server's 110 tools. Adds the missing route (auth-gated via requireManagementAuth, 409 when disabled, 501 for stdio since HTTP/SSE sessions are the only in-process handle shutdownMcpHttp() can tear down) plus `omniroute mcp enable [--transport stdio|sse|streamable-http]` / `mcp disable`, which PATCH /api/settings the same way the dashboard toggle does. `mcp status` now hints at `mcp enable` when it reports the server stopped because it is disabled. Regression tests: tests/unit/mcp-restart-route-13012.test.ts (route behavior across disabled/stdio/http-family states + LOCAL_ONLY classification) and tests/unit/cli-mcp-enable-disable-13012.test.ts (PATCH body shape).
This commit is contained in:
@@ -9,6 +9,8 @@ function truncate(v, len = 60) {
|
||||
return s.length > len ? s.slice(0, len - 1) + "…" : s;
|
||||
}
|
||||
|
||||
const VALID_MCP_TRANSPORTS = ["stdio", "sse", "streamable-http"];
|
||||
|
||||
const mcpToolSchema = [
|
||||
{ key: "name", header: "Tool", width: 36 },
|
||||
{
|
||||
@@ -43,6 +45,25 @@ export function registerMcp(program) {
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("enable")
|
||||
.description(t("mcp.enable.description"))
|
||||
.option("--transport <transport>", t("mcp.enable.transport"))
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runMcpEnableCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("disable")
|
||||
.description(t("mcp.disable.description"))
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runMcpDisableCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
// 5.1 — mcp call + mcp scopes
|
||||
mcp
|
||||
.command("call <tool> [argsJson]")
|
||||
@@ -61,10 +82,15 @@ export function registerMcp(program) {
|
||||
? JSON.parse(argsPositional)
|
||||
: {};
|
||||
|
||||
const exitCode = await runMcpCallCommand(tool, args, {
|
||||
...opts,
|
||||
stream: opts.stream,
|
||||
}, globalOpts);
|
||||
const exitCode = await runMcpCallCommand(
|
||||
tool,
|
||||
args,
|
||||
{
|
||||
...opts,
|
||||
stream: opts.stream,
|
||||
},
|
||||
globalOpts
|
||||
);
|
||||
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
@@ -127,7 +153,9 @@ async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } =
|
||||
|
||||
if (!initRes.ok) {
|
||||
const text = await initRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
process.stderr.write(
|
||||
`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -227,6 +255,7 @@ export async function runMcpStatusCommand(opts = {}) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.log(t("mcp.stopped"));
|
||||
console.log(t("mcp.stoppedHint"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -240,6 +269,9 @@ export async function runMcpStatusCommand(opts = {}) {
|
||||
const transport = status.transport || "stdio";
|
||||
const online = status.online ?? status.running;
|
||||
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
if (!online && status.enabled === false) {
|
||||
console.log(t("mcp.stoppedHint"));
|
||||
}
|
||||
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
|
||||
if (status.scopes?.length) {
|
||||
console.log(" Scopes:");
|
||||
@@ -270,10 +302,76 @@ export async function runMcpRestartCommand(opts = {}) {
|
||||
console.log(t("mcp.restarted"));
|
||||
return 0;
|
||||
}
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
const body = await res.json().catch(() => null);
|
||||
const message = body?.error || `HTTP ${res.status}`;
|
||||
console.error(t("common.error", { message }));
|
||||
return 1;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMcpEnableCommand(opts = {}) {
|
||||
const serverUp = await isServerUp();
|
||||
if (!serverUp) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (opts.transport && !VALID_MCP_TRANSPORTS.includes(opts.transport)) {
|
||||
console.error(
|
||||
t("common.error", {
|
||||
message: `Invalid transport '${opts.transport}'. Valid: ${VALID_MCP_TRANSPORTS.join(", ")}`,
|
||||
})
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { mcpEnabled: true };
|
||||
if (opts.transport) body.mcpTransport = opts.transport;
|
||||
|
||||
const res = await apiFetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
body,
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("mcp.enabled"));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMcpDisableCommand(opts = {}) {
|
||||
const serverUp = await isServerUp();
|
||||
if (!serverUp) {
|
||||
console.error(t("common.serverOffline"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
body: { mcpEnabled: false },
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
console.log(t("mcp.disabled"));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,16 @@
|
||||
"running": "MCP server running ({transport})",
|
||||
"stopped": "MCP server stopped.",
|
||||
"restarted": "MCP server restarted.",
|
||||
"stoppedHint": "Run `omniroute mcp enable` to turn it on.",
|
||||
"enabled": "MCP server enabled.",
|
||||
"disabled": "MCP server disabled.",
|
||||
"enable": {
|
||||
"description": "Enable the MCP server",
|
||||
"transport": "Transport to use: stdio|sse|streamable-http"
|
||||
},
|
||||
"disable": {
|
||||
"description": "Disable the MCP server"
|
||||
},
|
||||
"call": {
|
||||
"description": "Invoke an MCP tool directly",
|
||||
"args": "JSON arguments object (inline)",
|
||||
|
||||
@@ -345,6 +345,16 @@
|
||||
"running": "MCP 服务器正在运行({transport})",
|
||||
"stopped": "MCP 服务器已停止。",
|
||||
"restarted": "MCP 服务器已重启。",
|
||||
"stoppedHint": "运行 `omniroute mcp enable` 以启用它。",
|
||||
"enabled": "MCP 服务器已启用。",
|
||||
"disabled": "MCP 服务器已禁用。",
|
||||
"enable": {
|
||||
"description": "启用 MCP 服务器",
|
||||
"transport": "要使用的传输方式:stdio|sse|streamable-http"
|
||||
},
|
||||
"disable": {
|
||||
"description": "禁用 MCP 服务器"
|
||||
},
|
||||
"call": {
|
||||
"description": "直接调用 MCP 工具",
|
||||
"args": "JSON 参数对象(内联)",
|
||||
|
||||
@@ -345,6 +345,16 @@
|
||||
"running": "MCP 伺服器正在執行({transport})",
|
||||
"stopped": "MCP 伺服器已停止。",
|
||||
"restarted": "MCP 伺服器已重啟。",
|
||||
"stoppedHint": "執行 `omniroute mcp enable` 以啟用它。",
|
||||
"enabled": "MCP 伺服器已啟用。",
|
||||
"disabled": "MCP 伺服器已停用。",
|
||||
"enable": {
|
||||
"description": "啟用 MCP 伺服器",
|
||||
"transport": "要使用的傳輸方式:stdio|sse|streamable-http"
|
||||
},
|
||||
"disable": {
|
||||
"description": "停用 MCP 伺服器"
|
||||
},
|
||||
"call": {
|
||||
"description": "直接呼叫 MCP 工具",
|
||||
"args": "JSON 引數物件(內聯)",
|
||||
|
||||
1
changelog.d/fixes/13012-cli-mcp-restart-and-enable.md
Normal file
1
changelog.d/fixes/13012-cli-mcp-restart-and-enable.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** `omniroute mcp restart` no longer 404s — the missing `POST /api/mcp/restart` route now exists — and new `omniroute mcp enable`/`mcp disable [--transport]` subcommands give the CLI a way to turn the MCP server on without the dashboard ([#13012](https://github.com/diegosouzapw/OmniRoute/issues/13012)) — thanks @ricardusx
|
||||
@@ -25,6 +25,23 @@ Or via the open-sse transport:
|
||||
omniroute --dev # MCP auto-starts on /mcp endpoint
|
||||
```
|
||||
|
||||
The HTTP transports (`sse` / `streamable-http`, served in-process by the dashboard server) are
|
||||
off by default and were previously toggleable only from the `/dashboard/mcp` page. As of v3.8.51
|
||||
the CLI has parity:
|
||||
|
||||
```bash
|
||||
omniroute mcp status # enabled/online, transport, tool count
|
||||
omniroute mcp enable [--transport stdio|sse|streamable-http]
|
||||
omniroute mcp disable
|
||||
omniroute mcp restart # resets active sse/streamable-http sessions
|
||||
```
|
||||
|
||||
`mcp enable`/`mcp disable` PATCH the same `mcpEnabled` (and optionally `mcpTransport`) setting
|
||||
the dashboard toggles via `/api/settings`. `mcp restart` calls `POST /api/mcp/restart`: it tears
|
||||
down active `sse`/`streamable-http` sessions so the next request re-initializes cleanly, returns
|
||||
`409` if MCP is disabled, and `501` for the `stdio` transport (stdio clients own their own
|
||||
subprocess — there is no in-process handle to restart).
|
||||
|
||||
## Transports
|
||||
|
||||
The MCP server exposes three transports, all backed by the same `createMcpServer()` factory:
|
||||
|
||||
53
src/app/api/mcp/restart/route.ts
Normal file
53
src/app/api/mcp/restart/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMcpHttpStatus, shutdownMcpHttp } from "@omniroute/open-sse/mcp-server/httpTransport";
|
||||
import { getCachedSettings } from "@/lib/db/settings";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/mcp/restart — resets the in-process MCP HTTP/SSE transport so the
|
||||
* next MCP request re-initializes cleanly (mirrors the lazy-start design
|
||||
* documented at the top of open-sse/mcp-server/httpTransport.ts). There is no
|
||||
* external MCP process to restart the way `/api/restart` self-restarts the
|
||||
* whole server — this only tears down active SSE/Streamable HTTP sessions.
|
||||
*
|
||||
* Fixes #13012: the CLI's `omniroute mcp restart` POSTs here but the route
|
||||
* never existed, so every call 404d.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
|
||||
if (authError) return authError;
|
||||
|
||||
const settings = await getCachedSettings();
|
||||
const mcpEnabled = !!settings.mcpEnabled;
|
||||
const mcpTransport = (settings.mcpTransport as string) || "stdio";
|
||||
|
||||
if (!mcpEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "MCP is disabled; enable it first (`omniroute mcp enable`).",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (mcpTransport === "stdio") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"MCP restart is not supported for the stdio transport — stdio clients spawn their " +
|
||||
"own subprocess with no in-process handle to restart. Switch to sse/streamable-http " +
|
||||
"(`omniroute mcp enable --transport sse`) or restart the client instead.",
|
||||
},
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
|
||||
shutdownMcpHttp();
|
||||
|
||||
return NextResponse.json({
|
||||
status: "restarted",
|
||||
enabled: mcpEnabled,
|
||||
transport: mcpTransport,
|
||||
httpTransport: getMcpHttpStatus(),
|
||||
});
|
||||
}
|
||||
95
tests/unit/cli-mcp-enable-disable-13012.test.ts
Normal file
95
tests/unit/cli-mcp-enable-disable-13012.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Regression for GitHub issue #13012 (Bug 2): there was no CLI/env path to
|
||||
// flip the mcpEnabled setting on — it was dashboard-only. Pins the new
|
||||
// `omniroute mcp enable`/`mcp disable` PATCH /api/settings body shape.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
function makeResp(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(JSON.stringify(data)),
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
};
|
||||
}
|
||||
|
||||
type Call = { url: string; method: string; body: unknown };
|
||||
|
||||
function mockFetch(calls: Call[]) {
|
||||
globalThis.fetch = (async (url: string, opts: Record<string, unknown> = {}) => {
|
||||
const u = String(url);
|
||||
const method = String(opts.method || "GET").toUpperCase();
|
||||
const body = opts.body ? JSON.parse(String(opts.body)) : null;
|
||||
calls.push({ url: u, method, body });
|
||||
if (u.includes("/api/health")) return makeResp({ status: "ok" }) as unknown as Response;
|
||||
if (u.includes("/api/settings")) return makeResp({ ok: true }) as unknown as Response;
|
||||
return makeResp({ error: "unexpected call" }, 404) as unknown as Response;
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
async function withMockedFetch(fn: (calls: Call[]) => Promise<void>) {
|
||||
const calls: Call[] = [];
|
||||
mockFetch(calls);
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
console.log = () => {};
|
||||
console.error = () => {};
|
||||
try {
|
||||
await fn(calls);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
}
|
||||
}
|
||||
|
||||
test("mcp enable PATCHes /api/settings with mcpEnabled: true", async () => {
|
||||
await withMockedFetch(async (calls) => {
|
||||
const { runMcpEnableCommand } = await import("../../bin/cli/commands/mcp.mjs");
|
||||
const result = await runMcpEnableCommand({});
|
||||
assert.equal(result, 0);
|
||||
|
||||
const patchCall = calls.find((c) => c.url.includes("/api/settings") && c.method === "PATCH");
|
||||
assert.ok(patchCall, "expected a PATCH /api/settings call");
|
||||
assert.deepEqual(patchCall!.body, { mcpEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
test("mcp enable --transport sse also sets mcpTransport in the same PATCH", async () => {
|
||||
await withMockedFetch(async (calls) => {
|
||||
const { runMcpEnableCommand } = await import("../../bin/cli/commands/mcp.mjs");
|
||||
const result = await runMcpEnableCommand({ transport: "sse" });
|
||||
assert.equal(result, 0);
|
||||
|
||||
const patchCall = calls.find((c) => c.url.includes("/api/settings") && c.method === "PATCH");
|
||||
assert.ok(patchCall);
|
||||
assert.deepEqual(patchCall!.body, { mcpEnabled: true, mcpTransport: "sse" });
|
||||
});
|
||||
});
|
||||
|
||||
test("mcp enable rejects an invalid --transport value without calling the API", async () => {
|
||||
await withMockedFetch(async (calls) => {
|
||||
const { runMcpEnableCommand } = await import("../../bin/cli/commands/mcp.mjs");
|
||||
const result = await runMcpEnableCommand({ transport: "bogus" });
|
||||
assert.equal(result, 1);
|
||||
assert.ok(
|
||||
!calls.some((c) => c.url.includes("/api/settings")),
|
||||
"invalid transport must not reach the settings API"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("mcp disable PATCHes /api/settings with mcpEnabled: false", async () => {
|
||||
await withMockedFetch(async (calls) => {
|
||||
const { runMcpDisableCommand } = await import("../../bin/cli/commands/mcp.mjs");
|
||||
const result = await runMcpDisableCommand({});
|
||||
assert.equal(result, 0);
|
||||
|
||||
const patchCall = calls.find((c) => c.url.includes("/api/settings") && c.method === "PATCH");
|
||||
assert.ok(patchCall, "expected a PATCH /api/settings call");
|
||||
assert.deepEqual(patchCall!.body, { mcpEnabled: false });
|
||||
});
|
||||
});
|
||||
77
tests/unit/mcp-restart-route-13012.test.ts
Normal file
77
tests/unit/mcp-restart-route-13012.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Regression for GitHub issue #13012 (Bug 2): `omniroute mcp restart` POSTs to
|
||||
// /api/mcp/restart, but that route never existed — every call 404d. This test
|
||||
// boots the route handler directly and pins its behavior across the three
|
||||
// states the CLI can hit: disabled, enabled+stdio, enabled+http-family.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mcp-restart-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { POST } = await import("../../src/app/api/mcp/restart/route.ts");
|
||||
const { shutdownMcpHttp } = await import("../../open-sse/mcp-server/httpTransport.ts");
|
||||
const { isLocalOnlyPath } = await import("../../src/server/authz/routeGuard.ts");
|
||||
|
||||
// Hard Rule #15: /api/mcp/ must stay LOCAL_ONLY so loopback enforcement runs
|
||||
// unconditionally before any auth check — a leaked JWT over a tunnel must not
|
||||
// reach a route that can tear down/spin up MCP transport sessions.
|
||||
test("issue #13012: POST /api/mcp/restart is classified LOCAL_ONLY", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/mcp/restart"), true);
|
||||
});
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
reset();
|
||||
// No JWT_SECRET / requireLogin=false ⇒ auth-disabled deployment (pre-existing
|
||||
// open-door contract also exercised by tests/unit/mcp-route-scope-carveout.test.ts).
|
||||
await settingsDb.updateSettings({ requireLogin: false });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
function restartRequest(): Request {
|
||||
return new Request("http://localhost:20128/api/mcp/restart", { method: "POST" });
|
||||
}
|
||||
|
||||
test("issue #13012: POST /api/mcp/restart exists and returns 409 when MCP is disabled", async () => {
|
||||
await settingsDb.updateSettings({ mcpEnabled: false });
|
||||
const res = await POST(restartRequest());
|
||||
assert.equal(res.status, 409);
|
||||
const body = (await res.json()) as { error?: string };
|
||||
assert.match(body.error ?? "", /disabled/i);
|
||||
});
|
||||
|
||||
test("POST /api/mcp/restart returns 501 for the stdio transport (no in-process handle)", async () => {
|
||||
await settingsDb.updateSettings({ mcpEnabled: true, mcpTransport: "stdio" });
|
||||
const res = await POST(restartRequest());
|
||||
assert.equal(res.status, 501);
|
||||
const body = (await res.json()) as { error?: string };
|
||||
assert.match(body.error ?? "", /stdio/i);
|
||||
});
|
||||
|
||||
test("POST /api/mcp/restart tears down HTTP sessions and returns 200 for sse/streamable-http", async () => {
|
||||
await settingsDb.updateSettings({ mcpEnabled: true, mcpTransport: "sse" });
|
||||
const res = await POST(restartRequest());
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { status?: string; enabled?: boolean; transport?: string };
|
||||
assert.equal(body.status, "restarted");
|
||||
assert.equal(body.enabled, true);
|
||||
assert.equal(body.transport, "sse");
|
||||
});
|
||||
|
||||
test("shutdownMcpHttp is exported and callable (route depends on this contract)", () => {
|
||||
assert.equal(typeof shutdownMcpHttp, "function");
|
||||
});
|
||||
Reference in New Issue
Block a user