mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
# 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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user