# v0.4.30 (2026-05-11)

## Features
- MCP stdio→SSE bridge: expose local stdio MCP plugins over SSE (api/mcp/[plugin]/sse, /message)
- Dynamic Linux cert resolution + NSS DB injection (Debian/Arch/Fedora/openSUSE, Chrome/Chromium/Firefox incl. snap) (#1010)
- Cowork tool: expanded settings UI & API
- GitBook docs (DocsContent, DocsLayout)
## Fixes
- OAuth callback postMessage scoped to expected origins (CWE-1385) (#998)
- Re-enable TLS verification on DNS-bypass fetch (CWE-295) (#998)
- Normalize `developer` role → `system` for OpenAI-format providers (Deepseek, Groq, …) (#1011, closes #773)
- Respect `PORT` env in internal model-test fetch (#1014)
- Dropdown text readability in dark theme on usage page (#997)
## Improvements
- Refactor Claude CLI spoof headers into shared constant
- Tool deduper utility in open-sse handlers
This commit is contained in:
decolua
2026-05-12 09:19:50 +07:00
parent 76f3d4b74e
commit 8f4d29caa4
23 changed files with 1198 additions and 155 deletions

View File

@@ -5,7 +5,11 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
import crypto from "crypto";
import { DEFAULT_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { DATA_DIR } from "@/lib/dataDir";
const APP_PORT = UPDATER_CONFIG.appPort;
const PROVIDER = "gateway";
@@ -89,21 +93,87 @@ const get1pRoot = () => {
return path.join(os.homedir(), ".config", "Claude");
};
const bootstrapDeploymentMode = async () => {
const cfgPath = path.join(get1pRoot(), "claude_desktop_config.json");
let cfg = {};
try {
cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8"));
} catch (error) {
if (error.code !== "ENOENT") throw error;
const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json");
const read1pConfig = async () => {
try { return JSON.parse(await fs.readFile(get1pConfigPath(), "utf-8")) || {}; }
catch (error) {
if (error.code === "ENOENT") return {};
throw error;
}
};
const write1pConfig = async (cfg) => {
await fs.mkdir(get1pRoot(), { recursive: true });
await fs.writeFile(get1pConfigPath(), JSON.stringify(cfg, null, 2));
};
const bootstrapDeploymentMode = async () => {
const cfg = await read1pConfig();
if (cfg.deploymentMode === "3p") return false;
cfg.deploymentMode = "3p";
await fs.mkdir(get1pRoot(), { recursive: true });
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
await write1pConfig(cfg);
return true;
};
// Remove any legacy stdio entries previously written into 1p claude_desktop_config.json.
const cleanup1pLegacy = async () => {
const cfg = await read1pConfig();
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") return;
const managedNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name));
for (const k of Object.keys(cfg.mcpServers)) {
if (managedNames.has(k)) delete cfg.mcpServers[k];
}
if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
await write1pConfig(cfg);
};
// Build SSE bridge entries pointing at this app's inline /api/mcp/{name} endpoint.
const buildLocalBridgeEntries = (localPluginNames) => {
const names = Array.isArray(localPluginNames) ? localPluginNames : [];
const out = [];
for (const n of names) {
const def = LOCAL_STDIO_PLUGINS.find((p) => p.name === n);
if (!def) continue;
const entry = {
name: def.name,
url: `http://localhost:${APP_PORT}/api/mcp/${def.name}/sse`,
transport: "sse",
};
if (Array.isArray(def.toolNames) && def.toolNames.length > 0) {
const prefix = `${def.name}-`;
const policy = {};
for (const t of def.toolNames) {
policy[t] = "allow";
policy[`${prefix}${t}`] = "allow";
}
entry.toolPolicy = policy;
}
out.push(entry);
}
return out;
};
// Build entries for user-defined custom MCP plugins (URL or stdio command).
const buildCustomEntries = (customPlugins) => {
if (!Array.isArray(customPlugins)) return [];
const out = [];
for (const p of customPlugins) {
if (!p?.name) continue;
if (p.url) {
out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true });
} else if (p.command) {
out.push({
name: p.name,
url: `http://localhost:${APP_PORT}/api/mcp/${encodeURIComponent(p.name)}/sse`,
transport: "sse",
custom: true,
});
}
}
return out;
};
const checkInstalled = async () => {
for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) {
try { await fs.access(dir); return true; } catch { /* try next */ }
@@ -171,6 +241,17 @@ export async function GET() {
const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
// Active local plugins = managedMcp entries whose URL points at our inline bridge.
const stdioNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name));
const activeLocalNames = managedMcp
.filter((m) => stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))
.map((m) => m.name);
// Custom plugins = bridge entries not in preset LOCAL_STDIO_PLUGINS (custom:true or unknown name).
const activeCustomPlugins = managedMcp
.filter((m) => m.custom || (!stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/")))
.map((m) => ({ name: m.name, url: m.url, transport: m.transport, custom: true }));
return NextResponse.json({
installed: true,
config,
@@ -181,7 +262,7 @@ export async function GET() {
baseUrl,
models,
provider: config?.inferenceProvider || null,
plugins: managedMcp.map((m) => {
plugins: managedMcp.filter((m) => !m.custom && !(stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))).map((m) => {
// Strip "{name}-" prefix and dedupe so re-applies don't multiply entries.
const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : [];
const prefix = `${m.name}-`;
@@ -196,8 +277,11 @@ export async function GET() {
const toolNames = def && Array.isArray(def.toolNames) ? def.toolNames : Array.from(bare);
return { name: m.name, url: m.url, transport: m.transport, oauth: !!m.oauth, toolNames };
}),
localPlugins: activeLocalNames,
customPlugins: activeCustomPlugins,
},
defaultPlugins: DEFAULT_PLUGINS,
localStdioPlugins: LOCAL_STDIO_PLUGINS,
});
} catch (error) {
console.log("Error reading cowork settings:", error);
@@ -207,7 +291,7 @@ export async function GET() {
export async function POST(request) {
try {
const { baseUrl, apiKey, models, plugins } = await request.json();
const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
@@ -217,9 +301,26 @@ export async function POST(request) {
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
}
// Plugins: array of {name, url, transport?, oauth?}. Default to DEFAULT_PLUGINS if absent.
const pluginsArray = Array.isArray(plugins) && plugins.length > 0 ? plugins : DEFAULT_PLUGINS;
const managedMcpServers = buildManagedMcpServers(pluginsArray);
// Respect empty array (user toggled all off); fallback to defaults only when undefined.
const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS;
const localPluginNames = Array.isArray(localPlugins) ? localPlugins : [];
const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];
// Register custom stdio plugins into bridge + persist for restart survival.
if (customPluginsArray.length > 0) {
const { registerCustomPlugin } = require("@/lib/mcp/stdioSseBridge");
const stdioCustoms = customPluginsArray.filter((p) => p.command).map((p) => ({ name: p.name, command: p.command, args: p.args || [] }));
for (const p of stdioCustoms) registerCustomPlugin(p);
try {
const dir = path.join(DATA_DIR, "mcp");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "customPlugins.json"), JSON.stringify(stdioCustoms, null, 2));
} catch { /* ignore */ }
}
const bridgeEntries = buildLocalBridgeEntries(localPluginNames);
const customEntries = buildCustomEntries(customPluginsArray);
const managedMcpServers = [...buildManagedMcpServers(pluginsArray), ...bridgeEntries, ...customEntries];
const bootstrapped = await bootstrapDeploymentMode();
const meta = await ensureMeta();
@@ -239,6 +340,10 @@ export async function POST(request) {
let skipResult = null;
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
// Best-effort cleanup of legacy 1p mcpServers entries written by earlier versions.
let localMcpResult = { applied: localPluginNames, via: "3p-sse-bridge" };
try { await cleanup1pLegacy(); } catch { /* ignore */ }
return NextResponse.json({
success: true,
bootstrapped,
@@ -247,6 +352,7 @@ export async function POST(request) {
: "Cowork settings applied. Quit & reopen Claude Desktop.",
configPath,
skipApprovals: skipResult,
localMcp: localMcpResult,
});
} catch (error) {
console.log("Error applying cowork settings:", error);
@@ -264,6 +370,7 @@ export async function DELETE() {
try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); }
catch (error) { if (error.code !== "ENOENT") throw error; }
try { await writeSkipApprovals([]); } catch { /* ignore */ }
try { await cleanup1pLegacy(); } catch { /* ignore */ }
return NextResponse.json({ success: true, message: "Cowork config reset" });
} catch (error) {
console.log("Error resetting cowork settings:", error);

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { sendToChild, findPlugin } from "@/lib/mcp/stdioSseBridge";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request, { params }) {
const { plugin } = await params;
if (!findPlugin(plugin)) {
return NextResponse.json({ error: `Unknown plugin: ${plugin}` }, { status: 404 });
}
try {
const body = await request.json();
sendToChild(plugin, body);
return new Response(null, { status: 202 });
} catch (e) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,35 @@
import { registerSession, unregisterSession, findPlugin } from "@/lib/mcp/stdioSseBridge";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request, { params }) {
const { plugin } = await params;
if (!findPlugin(plugin)) {
return new Response(`Unknown plugin: ${plugin}`, { status: 404 });
}
const encoder = new TextEncoder();
let sid;
const stream = new ReadableStream({
start(controller) {
const send = (chunk) => controller.enqueue(encoder.encode(chunk));
sid = registerSession(plugin, send);
// MCP SSE handshake: tell client where to POST messages.
send(`event: endpoint\ndata: /api/mcp/${plugin}/message?sessionId=${sid}\n\n`);
},
cancel() {
if (sid) unregisterSession(plugin, sid);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@@ -286,48 +286,34 @@ export async function POST(request) {
case "minimax":
case "minimax-cn":
case "alicode-intl":
case "alicode": {
const claudeBaseUrls = {
glm: "https://api.z.ai/api/anthropic/v1/messages",
"glm-cn": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
kimi: "https://api.kimi.com/coding/v1/messages",
minimax: "https://api.minimax.io/anthropic/v1/messages",
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
alicode: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
"alicode-intl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
};
case "alicode":
case "agentrouter": {
// Use baseUrl from PROVIDERS (DRY); separate openai-format vs claude-format flow
const cfg = PROVIDERS[provider];
const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl";
// glm-cn, alicode and alicode-intl use OpenAI format
if (provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl") {
if (isOpenAiFormat) {
const testModel = getDefaultModel(provider);
const glmCnRes = await fetch(claudeBaseUrls[provider], {
const res = await fetch(cfg.baseUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: testModel,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
headers: { "Authorization": `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
isValid = glmCnRes.status !== 401 && glmCnRes.status !== 403;
isValid = res.status !== 401 && res.status !== 403;
} else {
const claudeRes = await fetch(claudeBaseUrls[provider], {
const testModel = getDefaultModel(provider) || "claude-sonnet-4-20250514";
const res = await fetch(cfg.baseUrl, {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
...(cfg.headers || {}),
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
body: JSON.stringify({ model: testModel, max_tokens: 1, messages: [{ role: "user", content: "test" }] }),
});
isValid = claudeRes.status !== 401;
// 400 = model resolution error but auth passed (e.g. agentrouter "no available channel")
isValid = res.status !== 401 && res.status !== 403;
}
break;
}
@@ -588,8 +574,43 @@ export async function POST(request) {
break;
}
default:
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
default: {
// Generic probe for OpenAI-compatible providers (config-driven from PROVIDERS)
const cfg = PROVIDERS[provider];
if (!cfg || cfg.format !== "openai" || !cfg.baseUrl) {
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
}
if (cfg.noAuth) {
isValid = true;
break;
}
// Build auth headers based on cfg.authHeader (default: bearer)
const headers = { "Content-Type": "application/json", ...(cfg.headers || {}) };
if (cfg.authHeader === "x-api-key") headers["X-API-Key"] = apiKey;
else headers["Authorization"] = `Bearer ${apiKey}`;
// Try /models first (fast GET), fallback to chat probe on ambiguous response
const modelsUrl = cfg.baseUrl.replace(/\/chat\/completions$/, "/models").replace(/\/chatbot$/, "/models");
let probeOk = null;
try {
const probeRes = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
if (probeRes.status === 401 || probeRes.status === 403) probeOk = false;
else if (probeRes.ok) probeOk = true;
} catch { /* fallback to chat */ }
if (probeOk !== null) {
isValid = probeOk;
break;
}
// Fallback: minimal chat probe
const defaultModel = getDefaultModel(provider) || "test";
const chatRes = await fetch(cfg.baseUrl, {
method: "POST",
headers,
body: JSON.stringify({ model: defaultModel, messages: [{ role: "user", content: "ping" }], max_tokens: 1 }),
signal: AbortSignal.timeout(10000),
});
isValid = chatRes.status !== 401 && chatRes.status !== 403;
break;
}
}
} catch (err) {
error = err.message;