# 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

@ -1,3 +1,22 @@
# 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
# v0.4.29 (2026-05-10)
## Features

View file

@ -4,8 +4,8 @@ import { MarkdownRenderer } from "@/utils/markdown";
export default function DocsContent({ content }) {
return (
<main className="flex-1 overflow-y-auto">
<article className="max-w-4xl mx-auto px-6 py-8">
<main className="flex-1 min-w-0 overflow-x-hidden overflow-y-auto">
<article className="max-w-4xl mx-auto px-4 sm:px-6 py-8">
<MarkdownRenderer content={content} />
</article>
</main>

View file

@ -10,14 +10,14 @@ export default function DocsLayout({ children, headings = [], lang = DEFAULT_LAN
<div className="min-h-screen flex flex-col bg-[#FCFBF9]">
<DocsHeader lang={lang} />
<div className="flex-1 flex">
{/* Desktop sidebar */}
<div className="hidden lg:block">
<DocsSidebar lang={lang} />
</div>
<div className="flex-1 flex">
<div className="flex-1 flex min-w-0">
{children}
<DocsToc headings={headings} lang={lang} />
<div className="hidden lg:block">
<DocsToc headings={headings} lang={lang} />
</div>
</div>
</div>
</div>

View file

@ -1,7 +1,8 @@
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { dirname, resolve } from "node:path";
const projectRoot = dirname(fileURLToPath(import.meta.url));
const monorepoRoot = resolve(projectRoot, "..");
/** @type {import('next').NextConfig} */
const nextConfig = {
@ -10,9 +11,9 @@ const nextConfig = {
turbopack: {
root: projectRoot
},
outputFileTracingRoot: projectRoot,
outputFileTracingRoot: monorepoRoot,
outputFileTracingExcludes: {
"*": ["./gitbook/**/*"]
"*": ["./app/gitbook/**/*", "./gitbook/**/*"]
},
images: {
unoptimized: true

View file

@ -601,6 +601,152 @@ export const PROVIDER_MODELS = {
{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", type: "stt", params: ["language"] },
{ id: "openai/whisper-small", name: "Whisper Small (HF)", type: "stt", params: ["language"] },
],
// === Free-tier providers (synced from OmniRoute) ===
agentrouter: [
{ id: "claude-opus-4-6", name: "Claude 4.6 Opus" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
],
aimlapi: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash" },
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", name: "Llama 3.1 70B" },
],
novita: [
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek/deepseek-v3", name: "DeepSeek V3" },
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "qwen/qwen-2.5-72b-instruct", name: "Qwen 2.5 72B" },
],
modal: [
{ id: "auto", name: "Auto (User-hosted)" },
],
reka: [
{ id: "reka-flash-3", name: "Reka Flash 3" },
{ id: "reka-edge-2603", name: "Reka Edge 2603" },
],
nlpcloud: [
{ id: "chatdolphin", name: "ChatDolphin" },
{ id: "dolphin", name: "Dolphin" },
{ id: "finetuned-llama-3-70b", name: "Llama 3 70B (Finetuned)" },
],
bazaarlink: [
{ id: "auto:free", name: "Auto Free (Zero Cost)" },
{ id: "auto", name: "Auto (Best Model)" },
],
completions: [
{ id: "claude-opus-4", name: "Claude Opus 4" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
],
enally: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
],
freetheai: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
{ id: "deepseek-chat", name: "DeepSeek Chat" },
],
llm7: [
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
],
lepton: [
{ id: "llama3-1-405b", name: "Llama 3.1 405B" },
{ id: "llama3-1-70b", name: "Llama 3.1 70B" },
{ id: "llama3-1-8b", name: "Llama 3.1 8B" },
{ id: "mixtral-8x7b", name: "Mixtral 8x7B" },
],
kluster: [
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" },
{ id: "meta-llama/Llama-4-Scout-17B-16E-Instruct", name: "Llama 4 Scout" },
{ id: "Qwen/Qwen3-235B-A22B-Instruct", name: "Qwen3 235B" },
],
ai21: [
{ id: "jamba-large", name: "Jamba 1.5 Large" },
{ id: "jamba-mini", name: "Jamba 1.5 Mini" },
],
"inference-net": [
{ id: "meta-llama/llama-3.3-70b-instruct/fp-16", name: "Llama 3.3 70B" },
{ id: "deepseek/deepseek-v3-0324", name: "DeepSeek V3" },
{ id: "mistralai/mistral-nemo-12b-instruct/fp-16", name: "Mistral Nemo 12B" },
],
predibase: [
{ id: "llama-3-2-3b-instruct", name: "Llama 3.2 3B" },
{ id: "llama-3-1-8b-instruct", name: "Llama 3.1 8B" },
{ id: "qwen2-5-7b-instruct", name: "Qwen 2.5 7B" },
],
bytez: [
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" },
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
],
morph: [
{ id: "morph-v3-large", name: "Morph V3 Large" },
{ id: "morph-v3-fast", name: "Morph V3 Fast" },
],
longcat: [
{ id: "LongCat-Flash-Chat", name: "LongCat Flash Chat" },
{ id: "LongCat-Flash-Thinking", name: "LongCat Flash Thinking" },
{ id: "LongCat-Flash-Lite", name: "LongCat Flash Lite" },
],
puter: [
{ id: "gpt-5", name: "GPT-5" },
{ id: "claude-opus-4", name: "Claude Opus 4" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
{ id: "grok-4", name: "Grok 4" },
{ id: "deepseek-chat", name: "DeepSeek V3" },
],
uncloseai: [
{ id: "auto", name: "Auto (Free)" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
],
scaleway: [
{ id: "qwen3-235b-a22b-instruct-2507", name: "Qwen3 235B" },
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "mistral-small-3.1-24b-instruct-2503", name: "Mistral Small 3.1" },
],
deepinfra: [
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" },
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
],
sambanova: [
{ id: "Meta-Llama-3.1-405B-Instruct", name: "Llama 3.1 405B" },
{ id: "Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
{ id: "Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B" },
],
nscale: [
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
],
baseten: [
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
],
publicai: [
{ id: "auto", name: "Auto (Community)" },
],
"nous-research": [
{ id: "Hermes-4-405B", name: "Hermes 4 405B" },
{ id: "Hermes-4-70B", name: "Hermes 4 70B" },
],
glhf: [
{ id: "hf:meta-llama/Meta-Llama-3.1-405B-Instruct", name: "Llama 3.1 405B" },
{ id: "hf:meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
{ id: "hf:Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
],
deepgram: [
{ id: "nova-3", name: "Nova 3", type: "stt", params: ["language"] },
{ id: "nova-2", name: "Nova 2", type: "stt", params: ["language"] },

View file

@ -26,6 +26,24 @@ const CLAUDE_API_HEADERS = {
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
};
// Full Claude CLI fingerprint — required by providers that gate on client identity (e.g. agentrouter)
const CLAUDE_CLI_SPOOF_HEADERS = {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Runtime-Version": "v24.14.0",
"X-Stainless-Package-Version": "0.80.0",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": mapStainlessArch(),
"X-Stainless-Os": mapStainlessOs(),
"X-Stainless-Timeout": "600"
};
// Shared baseUrls
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
@ -33,22 +51,7 @@ export const PROVIDERS = {
claude: {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Runtime-Version": "v24.14.0",
"X-Stainless-Package-Version": "0.80.0",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": mapStainlessArch(),
"X-Stainless-Os": mapStainlessOs(),
"X-Stainless-Timeout": "600"
},
headers: { ...CLAUDE_CLI_SPOOF_HEADERS },
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
tokenUrl: "https://api.anthropic.com/v1/oauth/token"
},
@ -384,6 +387,39 @@ export const PROVIDERS = {
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
format: "openai"
},
// === Free-tier providers (synced from OmniRoute) ===
// Claude-format with Claude CLI header spoofing (auth: x-api-key)
agentrouter: { baseUrl: "https://agentrouter.org/v1/messages", format: "claude", headers: { ...CLAUDE_CLI_SPOOF_HEADERS } },
// OpenAI-compatible (auth: bearer)
aimlapi: { baseUrl: "https://api.aimlapi.com/v1/chat/completions", format: "openai" },
novita: { baseUrl: "https://api.novita.ai/v3/openai/chat/completions", format: "openai" },
modal: { baseUrl: "https://api.modal.com/v1/chat/completions", format: "openai" },
reka: { baseUrl: "https://api.reka.ai/v1/chat/completions", format: "openai" },
nlpcloud: { baseUrl: "https://api.nlpcloud.io/v1/gpu/chatbot", format: "openai" },
bazaarlink: { baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", format: "openai" },
completions: { baseUrl: "https://completions.me/api/v1/chat/completions", format: "openai" },
// enally uses X-API-Key header (not bearer); handled in validate route
enally: { baseUrl: "https://ai.enally.in/v1/chat/completions", format: "openai", authHeader: "x-api-key" },
freetheai: { baseUrl: "https://api.freetheai.xyz/v1/chat/completions", format: "openai" },
llm7: { baseUrl: "https://api.llm7.io/v1/chat/completions", format: "openai" },
lepton: { baseUrl: "https://api.lepton.ai/api/v1/chat/completions", format: "openai" },
kluster: { baseUrl: "https://api.kluster.ai/v1/chat/completions", format: "openai" },
ai21: { baseUrl: "https://api.ai21.com/studio/v1/chat/completions", format: "openai" },
"inference-net": { baseUrl: "https://api.inference.net/v1/chat/completions", format: "openai" },
predibase: { baseUrl: "https://serving.app.predibase.com/v1/chat/completions", format: "openai" },
bytez: { baseUrl: "https://api.bytez.com/models/v2", format: "openai" },
morph: { baseUrl: "https://api.morphllm.com/v1/chat/completions", format: "openai" },
longcat: { baseUrl: "https://api.longcat.chat/openai/v1/chat/completions", format: "openai" },
puter: { baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions", format: "openai" },
uncloseai: { baseUrl: "https://hermes.ai.unturf.com/v1/chat/completions", format: "openai", noAuth: true },
scaleway: { baseUrl: "https://api.scaleway.ai/v1/chat/completions", format: "openai" },
deepinfra: { baseUrl: "https://api.deepinfra.com/v1/openai/chat/completions", format: "openai" },
sambanova: { baseUrl: "https://api.sambanova.ai/v1/chat/completions", format: "openai" },
nscale: { baseUrl: "https://inference.api.nscale.com/v1/chat/completions", format: "openai" },
baseten: { baseUrl: "https://inference.baseten.co/v1/chat/completions", format: "openai" },
publicai: { baseUrl: "https://api.publicai.co/v1/chat/completions", format: "openai" },
"nous-research": { baseUrl: "https://inference-api.nousresearch.com/v1/chat/completions", format: "openai" },
glhf: { baseUrl: "https://glhf.chat/api/openai/v1/chat/completions", format: "openai" },
};
export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";

View file

@ -96,11 +96,9 @@ export class DefaultExecutor extends BaseExecutor {
case "kimi":
case "minimax":
case "minimax-cn":
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
break;
case "kimi-coding":
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
Object.assign(headers, buildKimiHeaders());
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
if (this.provider === "kimi-coding") Object.assign(headers, buildKimiHeaders());
break;
default:
if (this.provider?.startsWith?.("anthropic-compatible-")) {
@ -124,6 +122,10 @@ export class DefaultExecutor extends BaseExecutor {
}
} else if (this.provider === "cline") {
Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken));
} else if (this.config?.format === "claude") {
// Generic claude-format provider (e.g. agentrouter): x-api-key + anthropic-version
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
} else {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
}

View file

@ -16,6 +16,7 @@ import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
@ -94,6 +95,15 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
translatedBody.model = model;
}
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
if (clientTool === "claude" && Array.isArray(translatedBody.tools)) {
const { tools: deduped, stripped } = dedupeTools(translatedBody.tools);
if (stripped.length > 0) {
translatedBody.tools = deduped;
log?.debug?.("TOOLDEDUP", `stripped ${stripped.length}: ${stripped.slice(0, 3).join(", ")}${stripped.length > 3 ? "..." : ""}`);
}
}
// Token savers: applied at the final body just before dispatch
// Covers both passthrough (source shape) and translated (target shape) flows
const finalFormat = passthrough ? sourceFormat : targetFormat;

View file

@ -86,6 +86,50 @@ const ALIAS_TO_PROVIDER_ID = {
// TTS
polly: "aws-polly",
"aws-polly": "aws-polly",
// Free-tier providers (synced from OmniRoute)
agentrouter: "agentrouter",
aimlapi: "aimlapi",
aiml: "aimlapi",
novita: "novita",
modal: "modal",
mdl: "modal",
reka: "reka",
nlpcloud: "nlpcloud",
nlpc: "nlpcloud",
bazaarlink: "bazaarlink",
bzl: "bazaarlink",
completions: "completions",
cpl: "completions",
enally: "enally",
enly: "enally",
freetheai: "freetheai",
fta: "freetheai",
llm7: "llm7",
lepton: "lepton",
kluster: "kluster",
ai21: "ai21",
"inference-net": "inference-net",
inet: "inference-net",
predibase: "predibase",
bytez: "bytez",
morph: "morph",
longcat: "longcat",
lc: "longcat",
puter: "puter",
pu: "puter",
uncloseai: "uncloseai",
unc: "uncloseai",
scaleway: "scaleway",
scw: "scaleway",
deepinfra: "deepinfra",
sambanova: "sambanova",
samba: "sambanova",
nscale: "nscale",
baseten: "baseten",
publicai: "publicai",
"nous-research": "nous-research",
nous: "nous-research",
glhf: "glhf",
};
/**

View file

@ -0,0 +1,49 @@
/**
* Strip built-in/duplicate tools when equivalent MCP tools are present.
* Goal: reduce tool definitions token bloat for Claude clients.
*/
const DEDUP_RULES = [
{
// Exa MCP present → drop built-in web tools (Exa is preferred).
triggers: ["mcp__exa__web_search_exa", "mcp__exa__web_fetch_exa"],
strip: ["WebSearch", "WebFetch", "mcp__workspace__web_fetch"],
},
{
// Tavily MCP present → drop built-in web tools.
triggers: ["mcp__tavily__tavily_search", "mcp__tavily__tavily_extract"],
strip: ["WebSearch", "WebFetch", "mcp__workspace__web_fetch"],
},
{
// Browser MCP present → drop Cowork's duplicate Claude_in_Chrome connector.
triggers: [/^mcp__browsermcp__/],
strip: [/^mcp__Claude_in_Chrome__/],
},
];
function getToolName(t) {
return t?.name || t?.function?.name || "";
}
function matches(name, pattern) {
if (typeof pattern === "string") return name === pattern;
return pattern instanceof RegExp ? pattern.test(name) : false;
}
function dedupeTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) return { tools, stripped: [] };
const names = tools.map(getToolName);
const toStrip = new Set();
for (const rule of DEDUP_RULES) {
const hasTrigger = names.some((n) => rule.triggers.some((p) => matches(n, p)));
if (!hasTrigger) continue;
for (const n of names) {
if (rule.strip.some((p) => matches(n, p))) toStrip.add(n);
}
}
if (toStrip.size === 0) return { tools, stripped: [] };
const out = tools.filter((t) => !toStrip.has(getToolName(t)));
return { tools: out, stripped: Array.from(toStrip) };
}
export { dedupeTools };

View file

@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.4.29",
"version": "0.4.30",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View file

@ -41,8 +41,12 @@ export default function CoworkToolCard({
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [plugins, setPlugins] = useState([]);
const [localPlugins, setLocalPlugins] = useState([]);
const [customPlugins, setCustomPlugins] = useState([]);
const [comboModalOpen, setComboModalOpen] = useState(false);
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
const [addMcpOpen, setAddMcpOpen] = useState(false);
const [addMcpForm, setAddMcpForm] = useState({ type: "url", name: "", url: "", command: "", args: "" });
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
@ -71,6 +75,12 @@ export default function CoworkToolCard({
} else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) {
setPlugins(status.defaultPlugins);
}
if (Array.isArray(status?.cowork?.localPlugins)) {
setLocalPlugins(status.cowork.localPlugins);
}
if (Array.isArray(status?.cowork?.customPlugins) && status.cowork.customPlugins.length > 0) {
setCustomPlugins(status.cowork.customPlugins);
}
}, [status]);
const checkStatus = async () => {
@ -120,6 +130,8 @@ export default function CoworkToolCard({
apiKey: keyToUse,
models: selectedModels,
plugins,
localPlugins,
customPlugins,
}),
});
const data = await res.json();
@ -168,6 +180,8 @@ export default function CoworkToolCard({
setMessage({ type: "success", text: "Settings reset successfully" });
setSelectedModels([]);
setPlugins(status?.defaultPlugins || []);
setLocalPlugins([]);
setCustomPlugins([]);
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset" });
@ -288,11 +302,11 @@ export default function CoworkToolCard({
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<div className="flex-1 flex items-center gap-2">
<div className="flex-1 flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
@ -306,37 +320,145 @@ export default function CoworkToolCard({
))
)}
</div>
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`self-start px-2 py-1 rounded border text-xs transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Add Combo (claude-)</button>
<button onClick={() => setComboModalOpen(true)} disabled={!hasActiveProviders} className={`shrink-0 px-2 py-1.5 rounded border text-xs whitespace-nowrap transition-colors ${hasActiveProviders ? "bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>+ Combo</button>
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Plugins</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{plugins.filter((p) => p.name !== "exa").length === 0 ? (
<span className="text-xs text-text-muted">No plugins</span>
) : (
plugins.filter((p) => p.name !== "exa").map((p) => (
<span key={p.name} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
{p.title || p.name}
{p.oauth && <span className="text-[8px] text-amber-600">OAuth</span>}
<button onClick={() => removePlugin(p.name)} className="ml-0.5 hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-2">MCP</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-2">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1">
{/* Preset plugins */}
{plugins.filter((p) => p.name !== "exa").map((p) => (
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.title || p.name}</span>
{p.oauth && <span className="text-[8px] text-amber-600 shrink-0">OAuth</span>}
<div className="flex-1 flex flex-wrap gap-1 overflow-hidden" style={{ maxHeight: "1.5rem" }}>
{Array.isArray(p.toolNames) && p.toolNames.slice(0, 6).map((t) => (
<span key={t} className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">{t}</span>
))}
{Array.isArray(p.toolNames) && p.toolNames.length > 6 && (
<span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted whitespace-nowrap">+{p.toolNames.length - 6}</span>
)}
</div>
<button onClick={() => removePlugin(p.name)} className="shrink-0 hover:text-red-500 ml-auto">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
{/* Custom plugins */}
{customPlugins.map((p) => (
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url || p.command}</span>
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
))}
{plugins.filter((p) => p.name !== "exa").length === 0 && customPlugins.length === 0 && (
<div className="px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-muted">No MCPs added</div>
)}
{/* Actions row */}
<div className="flex items-center gap-2 mt-0.5">
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
+ Browse
</button>
<button onClick={() => { setAddMcpForm({ type: "url", name: "", url: "", command: "", args: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
+ Custom
</button>
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs </a>
</div>
<button onClick={() => setMarketplaceOpen(true)} className="self-start px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer">
+ Browse MCP marketplace
</button>
<p className="text-[10px] text-text-muted leading-snug">
💡 Exa is auto-installed. Prefer <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">web_search_exa</code> for web search and <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">web_fetch_exa</code> for reading pages.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Tools</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-1.5">
{(() => {
const exaEnabled = plugins.some((p) => p.name === "exa");
const exaDef = (status?.defaultPlugins || []).find((d) => d.name === "exa");
return (
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
<input
type="checkbox"
checked={exaEnabled}
onChange={(e) => {
if (e.target.checked && exaDef) setPlugins([...plugins.filter((p) => p.name !== "exa"), exaDef]);
else setPlugins(plugins.filter((p) => p.name !== "exa"));
}}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Web Search & Fetch (Exa)</div>
<p className="text-[10px] text-text-muted leading-snug">Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.</p>
</div>
</label>
);
})()}
{(() => {
const browserDef = (status?.localStdioPlugins || []).find((p) => p.name === "browsermcp");
if (!browserDef) return null;
const browserEnabled = localPlugins.includes("browsermcp");
return (
<label className="flex items-start gap-2 cursor-pointer px-2 py-1.5 bg-surface rounded border border-border">
<input
type="checkbox"
checked={browserEnabled}
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, "browsermcp"] : localPlugins.filter((n) => n !== "browsermcp"))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">Browser Control (Browser MCP)</div>
<p className="text-[10px] text-text-muted leading-snug">
Controls your running Chrome. Auto-strips Cowork&apos;s built-in browser tools.{" "}
<a href={browserDef.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">Install Chrome extension</a>
</p>
</div>
</label>
);
})()}
</div>
</div>
{Array.isArray(status?.localStdioPlugins) && status.localStdioPlugins.filter((p) => p.name !== "browsermcp").length > 0 && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Local Plugins</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-col gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
{status.localStdioPlugins.filter((p) => p.name !== "browsermcp").map((p) => {
const enabled = localPlugins.includes(p.name);
return (
<label key={p.name} className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setLocalPlugins(e.target.checked ? [...localPlugins, p.name] : localPlugins.filter((n) => n !== p.name))}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium">{p.title}</span>
<span className="text-[8px] text-amber-600">stdio</span>
</div>
<p className="text-[10px] text-text-muted leading-snug">{p.description}</p>
{p.extensionUrl && (
<a href={p.extensionUrl} target="_blank" rel="noopener noreferrer" className="text-[10px] text-primary underline">Install Chrome extension</a>
)}
</div>
</label>
);
})}
</div>
<p className="text-[10px] text-text-muted leading-snug">
Local plugins run as subprocess via <code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">npx</code>. Requires Node.js installed.
</p>
</div>
</div>
)}
</div>
{message && (
@ -385,6 +507,99 @@ export default function CoworkToolCard({
onAdd={addPlugin}
addedNames={plugins.map((p) => p.name)}
/>
{/* Add Custom MCP modal */}
{addMcpOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setAddMcpOpen(false)}>
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Add Custom MCP</h3>
<button onClick={() => setAddMcpOpen(false)} className="text-text-muted hover:text-text-main">
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => setAddMcpForm((f) => ({ ...f, type: "url" }))}
className={`flex-1 py-1.5 rounded border text-xs font-medium transition-colors ${addMcpForm.type === "url" ? "bg-primary/10 border-primary/40 text-primary" : "border-border text-text-muted hover:border-primary/40"}`}
>URL (SSE)</button>
<button
onClick={() => setAddMcpForm((f) => ({ ...f, type: "cmd" }))}
className={`flex-1 py-1.5 rounded border text-xs font-medium transition-colors ${addMcpForm.type === "cmd" ? "bg-primary/10 border-primary/40 text-primary" : "border-border text-text-muted hover:border-primary/40"}`}
>Command (stdio)</button>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">Name</label>
<input
type="text"
placeholder="my-mcp"
value={addMcpForm.name}
onChange={(e) => setAddMcpForm((f) => ({ ...f, name: e.target.value.replace(/\s+/g, "-").toLowerCase() }))}
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
/>
</div>
{addMcpForm.type === "url" ? (
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
<input
type="text"
placeholder="https://your-mcp-server.com/sse"
value={addMcpForm.url}
onChange={(e) => setAddMcpForm((f) => ({ ...f, url: e.target.value }))}
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
/>
</div>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">Command</label>
<input
type="text"
placeholder="npx"
value={addMcpForm.command}
onChange={(e) => setAddMcpForm((f) => ({ ...f, command: e.target.value }))}
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-text-muted font-medium">Args <span className="font-normal">(comma-separated)</span></label>
<input
type="text"
placeholder="-y, @some/mcp-package"
value={addMcpForm.args}
onChange={(e) => setAddMcpForm((f) => ({ ...f, args: e.target.value }))}
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
/>
</div>
</>
)}
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setAddMcpOpen(false)} className="px-3 py-1.5 rounded border border-border text-xs text-text-muted hover:bg-surface cursor-pointer">Cancel</button>
<button
onClick={() => {
const name = addMcpForm.name.trim();
if (!name) return;
if (addMcpForm.type === "url") {
if (!addMcpForm.url.trim()) return;
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
} else {
if (!addMcpForm.command.trim()) return;
const args = addMcpForm.args.split(",").map((a) => a.trim()).filter(Boolean);
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, command: addMcpForm.command.trim(), args, custom: true }]);
}
setAddMcpOpen(false);
}}
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
>Add</button>
</div>
</div>
</div>
)}
</Card>
);
}

View file

@ -120,12 +120,25 @@ export default function ProvidersPage() {
!searchQuery.trim() ||
name.toLowerCase().includes(searchQuery.trim().toLowerCase());
const sortByConnections = (entries, authType) =>
[...entries].sort(
(a, b) =>
getProviderStats(b[0], authType).total -
getProviderStats(a[0], authType).total,
);
const sortByPriority = (entries, authType) =>
[...entries].sort(([ka, a], [kb, b]) => {
const sa = getProviderStats(ka, authType);
const sb = getProviderStats(kb, authType);
const ca = sa.connected > 0 ? 1 : 0;
const cb = sb.connected > 0 ? 1 : 0;
if (ca !== cb) return cb - ca;
return (a.name || "").localeCompare(b.name || "");
});
const sortItemsByPriority = (items, authType) =>
[...items].sort((a, b) => {
const sa = getProviderStats(a.id, authType);
const sb = getProviderStats(b.id, authType);
const ca = sa.connected > 0 ? 1 : 0;
const cb = sb.connected > 0 ? 1 : 0;
if (ca !== cb) return cb - ca;
return (a.name || "").localeCompare(b.name || "");
});
useEffect(() => {
const fetchData = async () => {
@ -239,37 +252,48 @@ export default function ProvidersPage() {
}
};
const compatibleProviders = providerNodes
.filter((node) => node.type === "openai-compatible")
.map((node) => ({
id: node.id,
name: node.name || "OpenAI Compatible",
color: "#10A37F",
textIcon: "OC",
apiType: node.apiType,
}))
.filter((p) => matchSearch(p.name));
const compatibleProviders = sortItemsByPriority(
providerNodes
.filter((node) => node.type === "openai-compatible")
.map((node) => ({
id: node.id,
name: node.name || "OpenAI Compatible",
color: "#10A37F",
textIcon: "OC",
apiType: node.apiType,
}))
.filter((p) => matchSearch(p.name)),
"apikey",
);
const anthropicCompatibleProviders = providerNodes
.filter((node) => node.type === "anthropic-compatible")
.map((node) => ({
id: node.id,
name: node.name || "Anthropic Compatible",
color: "#D97757",
textIcon: "AC",
}))
.filter((p) => matchSearch(p.name));
const anthropicCompatibleProviders = sortItemsByPriority(
providerNodes
.filter((node) => node.type === "anthropic-compatible")
.map((node) => ({
id: node.id,
name: node.name || "Anthropic Compatible",
color: "#D97757",
textIcon: "AC",
}))
.filter((p) => matchSearch(p.name)),
"apikey",
);
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) =>
matchSearch(info.name),
const oauthEntries = sortByPriority(
Object.entries(OAUTH_PROVIDERS).filter(([, info]) => matchSearch(info.name)),
"oauth",
);
const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) =>
matchSearch(info.name),
const freeEntries = sortByPriority(
Object.entries(FREE_PROVIDERS).filter(([, info]) => matchSearch(info.name)),
"oauth",
);
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
([, info]) => matchSearch(info.name),
const freeTierEntries = sortByPriority(
Object.entries(FREE_TIER_PROVIDERS).filter(([, info]) =>
matchSearch(info.name),
),
"apikey",
);
const apikeyEntries = sortByConnections(
const apikeyEntries = sortByPriority(
Object.entries(APIKEY_PROVIDERS).filter(
([, info]) =>
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),

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;

View file

@ -0,0 +1,190 @@
// Inline stdio<->SSE bridge for MCP. Spawns one child per plugin on demand,
// broadcasts JSON-RPC frames over SSE, accepts client messages via HTTP POST.
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { LOCAL_STDIO_PLUGINS } = require("@/shared/constants/coworkPlugins");
const { DATA_DIR } = require("@/lib/dataDir");
const CUSTOM_FILE = path.join(DATA_DIR, "mcp", "customPlugins.json");
const G_KEY = "__9routerMcpBridges";
const MAX_TEXT_CHARS = 50000;
const COLLAPSE_THRESHOLD = 30;
const COLLAPSE_KEEP_HEAD = 10;
const COLLAPSE_KEEP_TAIL = 5;
// Drop noise nodes, collapse repeated siblings, hard-truncate. Preserve [ref=eXX].
function smartFilterText(text) {
if (typeof text !== "string" || text.length < 2000) return text;
let out = text;
out = out.replace(/^\s*-\s*generic:?\s*$/gm, "");
out = out.replace(/^\s*-\s*text:\s*""\s*$/gm, "");
out = collapseRepeated(out);
if (out.length > MAX_TEXT_CHARS) {
const head = out.slice(0, MAX_TEXT_CHARS - 300);
out = `${head}\n\n... [truncated ${text.length - head.length} chars by 9router bridge. Page is large; ask user to scroll/navigate to a specific section, or click an element with the refs shown above]`;
}
return out;
}
// Group consecutive lines sharing the same leading indent + role prefix; collapse if >= COLLAPSE_THRESHOLD.
function collapseRepeated(text) {
const lines = text.split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const m = line.match(/^(\s*)-\s*([a-zA-Z]+)\b/);
if (!m) { out.push(line); i++; continue; }
const indent = m[1];
const role = m[2];
let j = i;
while (j < lines.length) {
const ln = lines[j];
const mm = ln.match(/^(\s*)-\s*([a-zA-Z]+)\b/);
if (mm && mm[1] === indent && mm[2] === role) { j++; continue; }
if (ln.startsWith(`${indent} `) || ln.startsWith(`${indent}\t`)) { j++; continue; }
break;
}
const groupLen = j - i;
if (groupLen >= COLLAPSE_THRESHOLD) {
const headEnd = findNthSiblingEnd(lines, i, indent, role, COLLAPSE_KEEP_HEAD);
const tailStart = findLastNSiblingStart(lines, j, indent, role, COLLAPSE_KEEP_TAIL);
for (let k = i; k < headEnd; k++) out.push(lines[k]);
out.push(`${indent}... [${groupLen - COLLAPSE_KEEP_HEAD - COLLAPSE_KEEP_TAIL} similar "${role}" items omitted by 9router bridge]`);
for (let k = tailStart; k < j; k++) out.push(lines[k]);
} else {
for (let k = i; k < j; k++) out.push(lines[k]);
}
i = j;
}
return out.join("\n");
}
function findNthSiblingEnd(lines, start, indent, role, n) {
let count = 0;
for (let k = start; k < lines.length; k++) {
const mm = lines[k].match(/^(\s*)-\s*([a-zA-Z]+)\b/);
if (mm && mm[1] === indent && mm[2] === role) {
count++;
if (count > n) return k;
}
}
return lines.length;
}
function findLastNSiblingStart(lines, end, indent, role, n) {
const positions = [];
for (let k = 0; k < end; k++) {
const mm = lines[k].match(/^(\s*)-\s*([a-zA-Z]+)\b/);
if (mm && mm[1] === indent && mm[2] === role) positions.push(k);
}
return positions.length > n ? positions[positions.length - n] : end;
}
// Apply filter to JSON-RPC tool/result content text blocks only.
function filterFrame(line) {
try {
const msg = JSON.parse(line);
const content = msg?.result?.content;
if (!Array.isArray(content)) return line;
let mutated = false;
for (const item of content) {
if (item?.type === "text" && typeof item.text === "string") {
const filtered = smartFilterText(item.text);
if (filtered !== item.text) { item.text = filtered; mutated = true; }
}
}
return mutated ? JSON.stringify(msg) : line;
} catch { return line; }
}
const getStore = () => {
if (!globalThis[G_KEY]) globalThis[G_KEY] = new Map();
return globalThis[G_KEY];
};
const getCustomStore = () => {
if (!globalThis.__9routerCustomPlugins) globalThis.__9routerCustomPlugins = new Map();
return globalThis.__9routerCustomPlugins;
};
function registerCustomPlugin(def) {
getCustomStore().set(def.name, def);
}
function findPlugin(name) {
const fromMem = getCustomStore().get(name) || LOCAL_STDIO_PLUGINS.find((p) => p.name === name);
if (fromMem) return fromMem;
// Lazy-load custom plugins from disk (survives app restart).
try {
const list = JSON.parse(fs.readFileSync(CUSTOM_FILE, "utf-8"));
const def = Array.isArray(list) ? list.find((p) => p.name === name && p.command) : null;
if (def) { getCustomStore().set(def.name, def); return def; }
} catch { /* file missing or invalid */ }
return null;
}
function getOrSpawn(name) {
const store = getStore();
let entry = store.get(name);
if (entry?.proc && !entry.proc.killed && entry.proc.exitCode === null) return entry;
const plugin = findPlugin(name);
if (!plugin) throw new Error(`Unknown local plugin: ${name}`);
const proc = spawn(plugin.command, plugin.args, { stdio: ["pipe", "pipe", "pipe"], env: process.env });
entry = { proc, sessions: new Map(), buffer: "" };
store.set(name, entry);
// Parse newline-delimited JSON-RPC from child stdout, broadcast to all sessions.
proc.stdout.on("data", (chunk) => {
entry.buffer += chunk.toString("utf8");
let idx;
while ((idx = entry.buffer.indexOf("\n")) >= 0) {
const raw = entry.buffer.slice(0, idx).trim();
entry.buffer = entry.buffer.slice(idx + 1);
if (!raw) continue;
const line = filterFrame(raw);
for (const send of entry.sessions.values()) {
try { send(`event: message\ndata: ${line}\n\n`); } catch { /* ignore broken pipe */ }
}
}
});
proc.stderr.on("data", (d) => console.log(`[mcp:${name}]`, d.toString().trim()));
proc.on("exit", (code) => {
console.log(`[mcp:${name}] exited`, code);
store.delete(name);
});
return entry;
}
function registerSession(name, sendFn) {
const entry = getOrSpawn(name);
const sid = crypto.randomUUID();
entry.sessions.set(sid, sendFn);
return sid;
}
function unregisterSession(name, sid) {
const entry = getStore().get(name);
if (!entry) return;
entry.sessions.delete(sid);
}
function sendToChild(name, jsonRpc) {
const entry = getStore().get(name);
if (!entry?.proc?.stdin?.writable) throw new Error(`Bridge not running: ${name}`);
entry.proc.stdin.write(`${JSON.stringify(jsonRpc)}\n`);
}
function isRunning(name) {
const entry = getStore().get(name);
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
}
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, registerCustomPlugin };

View file

@ -12,7 +12,7 @@ export const INTERNET_CHECK = {
timeoutMs: 3000,
};
export const RESTART_COOLDOWN_MS = 60000;
export const RESTART_COOLDOWN_MS = 180000;
export const NETWORK_SETTLE_MS = 2500;
export const WATCHDOG_INTERVAL_MS = 60000;
export const NETWORK_CHECK_INTERVAL_MS = 5000;

View file

@ -211,6 +211,86 @@ export const CLI_TOOLS = {
"model": "{{model}}",
"provider": "openai",
"apiKey": "{{apiKey}}"
}`,
},
},
amp: {
id: "amp",
name: "Amp CLI",
icon: "terminal",
color: "#F97316",
description: "Sourcegraph Amp coding assistant CLI",
docsUrl: "/docs?section=cli-tools&tool=amp",
configType: "guide",
defaultCommand: "amp",
modelAliases: ["g25p", "g25f", "cs45", "g54"],
notes: [
{ type: "info", text: "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates." },
{ type: "warning", text: "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929." },
],
guideSteps: [
{ step: 1, title: "Install Amp", desc: "Install the Amp CLI using the package manager supported by your environment." },
{ step: 2, title: "API Key", type: "apiKeySelector" },
{ step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
{ step: 4, title: "Select Model", type: "modelSelector" },
{ step: 5, title: "Add Shorthands", desc: "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config." },
],
codeBlock: {
language: "bash",
code: `export OPENAI_API_KEY="{{apiKey}}"
export OPENAI_BASE_URL="{{baseUrl}}"
amp --model "{{model}}"
# Example shorthand aliases you can map locally:
# g25p -> gemini/gemini-2.5-pro
# cs45 -> cc/claude-sonnet-4-5-20250929`,
},
},
qwen: {
id: "qwen",
name: "Qwen Code",
icon: "psychology",
color: "#10B981",
description: "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router",
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
configType: "guide",
defaultCommand: "qwen",
notes: [
{ type: "info", text: "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint." },
{ type: "info", text: "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more." },
{ type: "warning", text: "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json" },
{ type: "error", text: "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead." },
],
modelAliases: ["coder-model", "qwen3-coder-plus", "qwen3-coder-flash", "vision-model", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gemini-3-flash", "gemini-3.1-pro-high"],
defaultModels: [
{ id: "coder-model", name: "Coder Model (Qwen 3.6 Plus)", alias: "coder-model", envKey: "OPENAI_MODEL", defaultValue: "coder-model", isTopLevel: true },
{ id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", alias: "qwen3-coder-plus", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-plus" },
{ id: "qwen3-coder-flash", name: "Qwen 3 Coder Flash", alias: "qwen3-coder-flash", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-flash" },
{ id: "vision-model", name: "Vision Model (Multimodal)", alias: "vision-model", envKey: "OPENAI_MODEL", defaultValue: "vision-model" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6", envKey: "OPENAI_MODEL", defaultValue: "claude-sonnet-4-6" },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking", envKey: "OPENAI_MODEL", defaultValue: "claude-opus-4-6-thinking" },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high", envKey: "OPENAI_MODEL", defaultValue: "gemini-3.1-pro-high" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash", envKey: "OPENAI_MODEL", defaultValue: "gemini-3-flash" },
],
guideSteps: [
{ step: 1, title: "Install Qwen Code", desc: "npm install -g @qwen-code/qwen-code" },
{ step: 2, title: "API Key", type: "apiKeySelector" },
{ step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
{ step: 4, title: "Select Model", type: "modelSelector" },
{ step: 5, title: "Save Config", desc: "Copy the JSON below to your ~/.qwen/settings.json file." },
],
codeBlock: {
language: "json",
code: `{
"security": {
"auth": {
"selectedType": "openai",
"apiKey": "{{apiKey}}",
"baseUrl": "{{baseUrl}}"
}
},
"model": {
"name": "{{model}}"
}
}`,
},
},

View file

@ -1,5 +1,4 @@
// Default plugins auto-installed for Claude Cowork (3p mode).
// Exa works without auth; Tavily uses OAuth (DCR auto-flow).
// Default remote plugins for Claude Cowork (3p managedMcpServers, HTTPS only).
const DEFAULT_PLUGINS = [
{
name: "exa",
@ -21,20 +20,24 @@ const DEFAULT_PLUGINS = [
},
];
// Build managedMcpServers entries from plugin objects.
// Schema: [{name, url, transport, oauth?, toolPolicy?}]
// toolPolicy maps each tool to "allow" so Claude doesn't prompt.
// Plugin name that's force-installed regardless of user selection.
const ALWAYS_ON = "exa";
// Local stdio plugins bridged via inline SSE endpoint on the app's port.
const LOCAL_STDIO_PLUGINS = [
{
name: "browsermcp",
title: "Browser MCP",
description: "Control your running Chrome (requires Chrome extension)",
extensionUrl: "https://chromewebstore.google.com/detail/browser-mcp-automate-your/bjfgambnhccakkhmkepdoekmckoijdlc",
command: "npx",
args: ["-y", "@browsermcp/mcp@latest"],
toolNames: ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_screenshot", "browser_get_console_logs", "browser_wait", "browser_press_key", "browser_go_back", "browser_go_forward"],
},
];
function buildManagedMcpServers(plugins) {
const list = Array.isArray(plugins) ? plugins : [];
// Force Exa always-on at the front; drop any duplicate from user list.
const exaDefault = DEFAULT_PLUGINS.find((p) => p.name === ALWAYS_ON);
const merged = exaDefault ? [exaDefault, ...list.filter((p) => p?.name !== ALWAYS_ON)] : list;
const out = [];
const seen = new Set();
for (const p of merged) {
for (const p of list) {
if (!p?.name || !p?.url || seen.has(p.name)) continue;
seen.add(p.name);
const entry = {
@ -66,4 +69,4 @@ function buildManagedMcpServers(plugins) {
return out;
}
module.exports = { DEFAULT_PLUGINS, buildManagedMcpServers, ALWAYS_ON };
module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers };

View file

@ -99,6 +99,37 @@ export const APIKEY_PROVIDERS = {
huggingface: { id: "huggingface", alias: "hf", name: "HuggingFace", icon: "face", color: "#FFD21E", textIcon: "HF", website: "https://huggingface.co", notice: { apiKeyUrl: "https://huggingface.co/settings/tokens" }, serviceKinds: ["image", "imageToText", "tts", "stt"], hiddenKinds: ["tts"], ttsConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-tts", models: [{ id: "facebook/mms-tts-eng", name: "MMS TTS English" }, { id: "microsoft/speecht5_tts", name: "SpeechT5 TTS" }] }, sttConfig: { baseUrl: "https://api-inference.huggingface.co/models", authType: "apikey", authHeader: "bearer", format: "huggingface-asr", models: [{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)" }, { id: "openai/whisper-small", name: "Whisper Small (HF)" }] } },
blackbox: { id: "blackbox", alias: "bb", name: "Blackbox AI", icon: "smart_toy", color: "#5B5FEF", textIcon: "BB", website: "https://blackbox.ai", notice: { apiKeyUrl: "https://www.blackbox.ai/api-management" }, serviceKinds: ["llm"] },
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai", notice: { apiKeyUrl: "https://chutes.ai/app/api" } },
// === Free-tier LLM providers (synced from OmniRoute) — DISABLED in UI ===
// Uncomment to re-enable. Backend config (PROVIDERS, PROVIDER_MODELS, ALIAS_TO_PROVIDER_ID) remains active.
// agentrouter: { id: "agentrouter", alias: "agentrouter", name: "AgentRouter", icon: "router", color: "#10B981", textIcon: "AR", website: "https://agentrouter.org", notice: { text: "$200 free credits on signup - multi-model routing gateway.", apiKeyUrl: "https://agentrouter.org/register" }, passthroughModels: true, serviceKinds: ["llm"] },
// aimlapi: { id: "aimlapi", alias: "aiml", name: "AI/ML API", icon: "hub", color: "#6366F1", textIcon: "AI", website: "https://aimlapi.com", notice: { text: "$0.025/day free — 200+ models (GPT-4o, Claude, Gemini, Llama) via single endpoint.", apiKeyUrl: "https://aimlapi.com/app/keys" }, passthroughModels: true, serviceKinds: ["llm", "image"] },
// novita: { id: "novita", alias: "novita", name: "Novita AI", icon: "auto_awesome", color: "#FF4081", textIcon: "NV", website: "https://novita.ai", notice: { text: "$0.50 trial credits on signup (valid ~1 year).", apiKeyUrl: "https://novita.ai/settings/key-management" }, passthroughModels: true, serviceKinds: ["llm", "image"] },
// modal: { id: "modal", alias: "mdl", name: "Modal", icon: "cloud_queue", color: "#7C3AED", textIcon: "MDL", website: "https://modal.com", notice: { text: "$30/month free credits for new accounts. Self-hosted OpenAI-compatible apps on /v1.", apiKeyUrl: "https://modal.com/settings/tokens" }, passthroughModels: true, serviceKinds: ["llm"], hasProviderSpecificData: true },
// reka: { id: "reka", alias: "reka", name: "Reka", icon: "auto_awesome", color: "#111827", textIcon: "RK", website: "https://docs.reka.ai", notice: { text: "$10/month recurring free API credits.", apiKeyUrl: "https://platform.reka.ai/apikeys" }, serviceKinds: ["llm"] },
// nlpcloud: { id: "nlpcloud", alias: "nlpc", name: "NLP Cloud", icon: "psychology", color: "#2196F3", textIcon: "NLPC", website: "https://docs.nlpcloud.com", notice: { text: "Trial credits for new accounts.", apiKeyUrl: "https://nlpcloud.com/home/token" }, serviceKinds: ["llm"] },
// bazaarlink: { id: "bazaarlink", alias: "bzl", name: "BazaarLink", icon: "storefront", color: "#6366F1", textIcon: "BZ", website: "https://bazaarlink.ai", notice: { text: "Use model 'auto:free' for zero-cost inference. OpenAI-compatible.", apiKeyUrl: "https://bazaarlink.ai" }, serviceKinds: ["llm"] },
// completions: { id: "completions", alias: "cpl", name: "Completions.me", icon: "bolt", color: "#F59E0B", textIcon: "CP", website: "https://completions.me", notice: { text: "Free unlimited access to Claude, GPT, Gemini.", apiKeyUrl: "https://completions.me" }, serviceKinds: ["llm"] },
// enally: { id: "enally", alias: "enly", name: "Enally AI", icon: "school", color: "#8B5CF6", textIcon: "EN", website: "https://ai.enally.in", notice: { text: "Free for students and developers — OTP verification.", apiKeyUrl: "https://ai.enally.in/api" }, serviceKinds: ["llm"] },
// freetheai: { id: "freetheai", alias: "fta", name: "FreeTheAi", icon: "lock_open", color: "#10B981", textIcon: "FT", website: "https://freetheai.xyz", notice: { text: "Community-run free tier — 16,000+ models, OpenAI-compatible.", apiKeyUrl: "https://freetheai.xyz" }, serviceKinds: ["llm"] },
// llm7: { id: "llm7", alias: "llm7", name: "LLM7.io", icon: "hub", color: "#6366F1", textIcon: "LM", website: "https://llm7.io", notice: { text: "Works without API key (use 'unused'). 2 req/s, 100 req/hr free.", apiKeyUrl: "https://token.llm7.io" }, serviceKinds: ["llm"] },
// lepton: { id: "lepton", alias: "lepton", name: "Lepton AI", icon: "bolt", color: "#10B981", textIcon: "LP", website: "https://lepton.ai", notice: { apiKeyUrl: "https://dashboard.lepton.ai/credentials" }, serviceKinds: ["llm"] },
// kluster: { id: "kluster", alias: "kluster", name: "Kluster AI", icon: "hub", color: "#8B5CF6", textIcon: "KL", website: "https://kluster.ai", notice: { text: "$5 free credits on signup — DeepSeek R1, Llama 4, Qwen3 235B.", apiKeyUrl: "https://kluster.ai/dashboard/api-keys" }, serviceKinds: ["llm"] },
// ai21: { id: "ai21", alias: "ai21", name: "AI21 Labs", icon: "psychology_alt", color: "#0284C7", textIcon: "AI21", website: "https://www.ai21.com", notice: { text: "$10 trial credits on signup (valid 3 months).", apiKeyUrl: "https://studio.ai21.com/account/api-key" }, serviceKinds: ["llm"] },
// "inference-net": { id: "inference-net", alias: "inet", name: "Inference.net", icon: "dns", color: "#2563EB", textIcon: "IN", website: "https://inference.net", notice: { text: "$25 free credits on signup.", apiKeyUrl: "https://inference.net/dashboard/api-keys" }, serviceKinds: ["llm"] },
// predibase: { id: "predibase", alias: "predibase", name: "Predibase", icon: "deployed_code_history", color: "#0F766E", textIcon: "PB", website: "https://predibase.com", notice: { text: "$25 free trial credits (30-day validity).", apiKeyUrl: "https://app.predibase.com/settings" }, serviceKinds: ["llm"] },
// bytez: { id: "bytez", alias: "bytez", name: "Bytez", icon: "api", color: "#6366F1", textIcon: "BZ", website: "https://bytez.com", notice: { text: "$1 free credits, refreshes every 4 weeks.", apiKeyUrl: "https://bytez.com/dashboard/api" }, serviceKinds: ["llm"] },
// morph: { id: "morph", alias: "morph", name: "Morph", icon: "auto_fix_high", color: "#2563EB", textIcon: "MP", website: "https://morphllm.com", notice: { text: "Free tier: 250K credits/month.", apiKeyUrl: "https://morphllm.com/dashboard/api-keys" }, serviceKinds: ["llm"] },
// longcat: { id: "longcat", alias: "lc", name: "LongCat AI", icon: "auto_awesome", color: "#FF6B9D", textIcon: "LC", website: "https://longcat.chat/platform/docs", notice: { text: "50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — free in public beta.", apiKeyUrl: "https://longcat.chat/platform/api_keys" }, serviceKinds: ["llm"] },
// puter: { id: "puter", alias: "pu", name: "Puter AI", icon: "cloud_circle", color: "#6366F1", textIcon: "PU", website: "https://puter.com", notice: { text: "500+ models (GPT-5, Claude Opus 4, Gemini 3 Pro, Grok 4, DeepSeek V3).", apiKeyUrl: "https://puter.com/dashboard" }, passthroughModels: true, serviceKinds: ["llm"] },
// uncloseai: { id: "uncloseai", alias: "unc", name: "UncloseAI", icon: "auto_awesome", color: "#8B5CF6", textIcon: "UN", website: "https://uncloseai.com", notice: { text: "Free forever — no signup, no credit card. OpenAI-compatible." }, passthroughModels: true, noAuth: true, serviceKinds: ["llm"] },
// scaleway: { id: "scaleway", alias: "scw", name: "Scaleway AI", icon: "cloud", color: "#4F0599", textIcon: "SCW", website: "https://www.scaleway.com/en/ai/generative-apis", notice: { text: "1M free tokens — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B.", apiKeyUrl: "https://console.scaleway.com/iam/api-keys" }, serviceKinds: ["llm"] },
// deepinfra: { id: "deepinfra", alias: "deepinfra", name: "DeepInfra", icon: "hub", color: "#2563EB", textIcon: "DI", website: "https://deepinfra.com", notice: { text: "Free signup credits for API testing.", apiKeyUrl: "https://deepinfra.com/dash/api_keys" }, serviceKinds: ["llm"] },
// sambanova: { id: "sambanova", alias: "samba", name: "SambaNova", icon: "memory", color: "#DC2626", textIcon: "SN", website: "https://sambanova.ai", notice: { text: "$5 free credits on signup (30-day validity).", apiKeyUrl: "https://cloud.sambanova.ai/apis" }, serviceKinds: ["llm"] },
// nscale: { id: "nscale", alias: "nscale", name: "nScale", icon: "token", color: "#0891B2", textIcon: "NS", website: "https://nscale.com", notice: { text: "$5 free credits on signup.", apiKeyUrl: "https://console.nscale.com/api-keys" }, serviceKinds: ["llm"] },
// baseten: { id: "baseten", alias: "baseten", name: "Baseten", icon: "deployed_code", color: "#111827", textIcon: "BT", website: "https://baseten.co", notice: { text: "$30 free trial credits for GPU inference.", apiKeyUrl: "https://app.baseten.co/settings/api_keys" }, serviceKinds: ["llm"] },
// publicai: { id: "publicai", alias: "publicai", name: "PublicAI", icon: "public", color: "#059669", textIcon: "PA", website: "https://publicai.co", notice: { text: "Free community inference tier.", apiKeyUrl: "https://publicai.co" }, serviceKinds: ["llm"] },
// "nous-research": { id: "nous-research", alias: "nous", name: "Nous Research", icon: "hub", color: "#2563EB", textIcon: "NO", website: "https://portal.nousresearch.com", notice: { text: "Free tier: 50 RPM, 500K TPM — no credit card.", apiKeyUrl: "https://portal.nousresearch.com" }, serviceKinds: ["llm"] },
// glhf: { id: "glhf", alias: "glhf", name: "GLHF Chat", icon: "hub", color: "#10B981", textIcon: "GH", website: "https://glhf.chat", notice: { text: "Free tier for open-source model inference.", apiKeyUrl: "https://glhf.chat/users/settings/api" }, passthroughModels: true, serviceKinds: ["llm"] },
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models", notice: { apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", notice: { apiKeyUrl: "https://app.tavily.com/home" }, serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.tavily.com/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.tavily.com/extract", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, formats: ["markdown", "text"], maxCharacters: 100000, timeoutMs: 15000 } },

View file

@ -41,6 +41,7 @@ const g = global.__appSingleton ??= {
networkMonitorInterval: null,
lastNetworkFingerprint: null,
lastWatchdogTick: Date.now(),
lastOnline: null,
mitmStartInProgress: false,
tunnelAutoResumed: false,
tailscaleAutoResumed: false,
@ -209,6 +210,7 @@ function startNetworkMonitor() {
g.lastNetworkFingerprint = getNetworkFingerprint();
g.lastWatchdogTick = Date.now();
g.lastOnline = null;
g.networkMonitorInterval = setInterval(async () => {
try {
@ -218,15 +220,24 @@ function startNetworkMonitor() {
const currentFingerprint = getNetworkFingerprint();
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 6;
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
if (!networkChanged && !wasSleep) return;
// Real reachability check (TCP 1.1.1.1:443) — not just interface presence
const online = await checkInternet();
const wasOffline = g.lastOnline === false;
g.lastOnline = online;
if (!online) return; // no internet → idle, don't restart
const onlineEdge = wasOffline; // offline → online transition
if (!networkChanged && !wasSleep && !onlineEdge) return;
// Wait for DHCP/DNS to settle before probing
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
const reason = wasSleep && networkChanged ? "sleep+netchange"
const reason = onlineEdge ? "online"
: wasSleep && networkChanged ? "sleep+netchange"
: wasSleep ? "sleep" : "netchange";
safeRestartTunnel(reason).catch(() => {});
safeRestartTailscale(reason).catch(() => {});