feat: Added McpMarketplaceModal to the CoworkToolCard for improved plugin management.

This commit is contained in:
decolua 2026-05-09 09:53:06 +07:00
parent c734913b3f
commit 145f588cc0
8 changed files with 550 additions and 1210 deletions

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ManualConfigModal, ComboFormModal } from "@/shared/components";
import { Card, Button, ManualConfigModal, ComboFormModal, McpMarketplaceModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
@ -39,9 +39,9 @@ export default function CoworkToolCard({
const [selectedModels, setSelectedModels] = useState([]);
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [selectedPlugins, setSelectedPlugins] = useState([]);
const [pluginsExpanded, setPluginsExpanded] = useState(false);
const [plugins, setPlugins] = useState([]);
const [comboModalOpen, setComboModalOpen] = useState(false);
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
@ -64,8 +64,11 @@ export default function CoworkToolCard({
if (status?.cowork?.baseUrl && !customBaseUrl) {
setCustomBaseUrl(stripV1(status.cowork.baseUrl));
}
if (Array.isArray(status?.cowork?.selectedPlugins)) {
setSelectedPlugins(status.cowork.selectedPlugins);
// Initialize plugins: from current config, fallback to defaultPlugins
if (Array.isArray(status?.cowork?.plugins) && status.cowork.plugins.length > 0) {
setPlugins(status.cowork.plugins);
} else if (plugins.length === 0 && Array.isArray(status?.defaultPlugins)) {
setPlugins(status.defaultPlugins);
}
}, [status]);
@ -116,7 +119,7 @@ export default function CoworkToolCard({
baseUrl: effectiveUrl,
apiKey: keyToUse,
models: selectedModels,
plugins: selectedPlugins,
plugins,
}),
});
const data = await res.json();
@ -145,7 +148,6 @@ export default function CoworkToolCard({
setMessage({ type: "error", text: err.error || "Failed to create combo" });
return;
}
// Add combo name into selected models for Cowork
if (!selectedModels.includes(name)) {
setSelectedModels([...selectedModels, name]);
}
@ -165,6 +167,7 @@ export default function CoworkToolCard({
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully" });
setSelectedModels([]);
setPlugins(status?.defaultPlugins || []);
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset" });
@ -176,6 +179,15 @@ export default function CoworkToolCard({
}
};
const addPlugin = (p) => {
if (plugins.some((x) => x.name === p.name)) return;
setPlugins([...plugins, p]);
};
const removePlugin = (name) => {
setPlugins(plugins.filter((p) => p.name !== name));
};
const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
@ -307,51 +319,33 @@ export default function CoworkToolCard({
</div>
</div>
{false && (<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">Connectors</span>
<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 items-center justify-between">
<span className="text-xs text-text-muted">{selectedPlugins.length} of {(status?.availablePlugins || []).length} selected</span>
<button onClick={() => setPluginsExpanded(!pluginsExpanded)} className="text-xs text-primary hover:underline">
{pluginsExpanded ? "Hide" : "Show"} all
</button>
</div>
{pluginsExpanded && (
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto px-2 py-2 bg-surface rounded border border-border">
{(status?.availablePlugins || []).map((p) => {
const checked = selectedPlugins.includes(p.name);
return (
<label key={p.name} className="flex items-start gap-2 text-xs cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 px-1 py-0.5 rounded">
<input
type="checkbox"
checked={checked}
onChange={() => setSelectedPlugins((prev) => checked ? prev.filter((n) => n !== p.name) : [...prev, p.name])}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{p.name}</div>
{p.description && <div className="text-text-muted text-[10px] truncate">{p.description}</div>}
</div>
</label>
);
})}
</div>
)}
{!pluginsExpanded && selectedPlugins.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 py-1.5 bg-surface rounded border border-border">
{selectedPlugins.map((name) => (
<span key={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">
{name}
<button onClick={() => setSelectedPlugins((prev) => prev.filter((x) => x !== name))} className="ml-0.5 hover:text-red-500">
<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>
))}
</div>
)}
))
)}
</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>
</div>
{message && (
@ -372,7 +366,6 @@ export default function CoworkToolCard({
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button>
</div>
</>
)}
</div>
@ -394,6 +387,13 @@ export default function CoworkToolCard({
forcePrefix="claude-"
title="Create Cowork Combo"
/>
<McpMarketplaceModal
isOpen={marketplaceOpen}
onClose={() => setMarketplaceOpen(false)}
onAdd={addPlugin}
addedNames={plugins.map((p) => p.name)}
/>
</Card>
);
}

View file

@ -4,78 +4,58 @@ import { NextResponse } from "next/server";
const REGISTRY_URL = "https://api.anthropic.com/mcp-registry/v0/servers";
const VISIBILITY = "commercial,gsuite,gsuite-google";
const PLUGINS_REPO = "anthropics/knowledge-work-plugins";
const GH_API = "https://api.github.com";
const GH_RAW = "https://raw.githubusercontent.com";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
const CACHE_TTL_MS = 60 * 60 * 1000;
const G_KEY = "__9routerCoworkMcpRegistryCache";
function gcache() {
if (!globalThis[G_KEY]) globalThis[G_KEY] = { ts: 0, data: null };
return globalThis[G_KEY];
}
// Fetch full registry across pagination
async function fetchRegistry() {
// Filter out claude.ai-mediated servers (broken in 3p) and tenant-required entries.
function isDirectConnect(url) {
if (!url || typeof url !== "string") return false;
if (/^https?:\/\/[^/]*\bmcp\.claude\.com\b/i.test(url)) return false;
if (/^https?:\/\/api\.anthropic\.com\/mcp\b/i.test(url)) return false;
if (/[<{]/.test(url)) return false;
return /^https:\/\//i.test(url);
}
async function fetchAll() {
const out = [];
let cursor = "";
for (let i = 0; i < 20; i++) {
const url = `${REGISTRY_URL}?limit=500&visibility=${VISIBILITY}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const r = await fetch(url, { headers: { "accept": "application/json" } });
const r = await fetch(url, { headers: { accept: "application/json" } });
if (!r.ok) break;
const j = await r.json();
for (const item of j.servers || []) {
const s = item.server || {};
const meta = item._meta?.["com.anthropic.api/mcp-registry"] || {};
const remote = (s.remotes || [])[0];
if (!remote?.url) continue;
const transport = remote.type === "streamable-http" ? "http" : (remote.type === "sse" ? "sse" : "http");
if (!remote?.url || !isDirectConnect(remote.url)) continue;
if (meta.requiredFields?.length) continue;
const transport = remote.type === "sse" ? "sse" : "http";
const toolNames = Array.isArray(meta.toolNames) ? meta.toolNames : [];
out.push({
source: "registry",
name: s.name,
title: s.title || s.name,
description: s.description || "",
slug: meta.slug || s.name,
title: s.title || meta.displayName || s.name,
description: s.description || meta.oneLiner || "",
url: remote.url,
transport,
oauth: !meta.isAuthless,
toolNames,
toolCount: toolNames.length,
iconUrl: meta.iconUrl || null,
});
}
cursor = j.metadata?.nextCursor;
if (!cursor) break;
}
return out;
}
// Fetch plugins from anthropics/knowledge-work-plugins. Each plugin folder contains
// .claude-plugin/plugin.json with mcp_servers map.
async function fetchPlugins() {
const r = await fetch(`${GH_API}/repos/${PLUGINS_REPO}/contents/`, { headers: { "accept": "application/vnd.github.v3+json" } });
if (!r.ok) return [];
const items = await r.json();
const dirs = items.filter((i) => i.type === "dir" && !i.name.startsWith(".") && i.name !== "partner-built");
const out = [];
await Promise.all(dirs.map(async (d) => {
try {
const url = `${GH_RAW}/${PLUGINS_REPO}/main/${d.name}/.claude-plugin/plugin.json`;
const pr = await fetch(url);
if (!pr.ok) return;
const pj = await pr.json();
const servers = pj.mcp_servers || pj.mcpServers || {};
for (const [key, srv] of Object.entries(servers)) {
if (!srv?.url || typeof srv.url !== "string") continue;
if (!/^https?:\/\//i.test(srv.url)) continue;
const transport = /\/sse(\b|\/)/i.test(srv.url) ? "sse" : (srv.type === "sse" ? "sse" : "http");
out.push({
source: "plugins",
plugin: d.name,
name: `${d.name}-${key}`,
title: pj.name || d.name,
description: pj.description || "",
url: srv.url,
transport,
});
}
} catch { /* skip */ }
}));
return out;
// Dedupe by url
const seen = new Set();
return out.filter((s) => (seen.has(s.url) ? false : (seen.add(s.url), true)));
}
export async function GET(request) {
@ -86,19 +66,12 @@ export async function GET(request) {
return NextResponse.json({ cached: true, ...cache.data });
}
try {
const [registry, plugins] = await Promise.all([fetchRegistry(), fetchPlugins()]);
// Deduplicate by url
const seen = new Set();
const merged = [...registry, ...plugins].filter((s) => {
if (seen.has(s.url)) return false;
seen.add(s.url);
return true;
});
const data = { servers: merged, counts: { registry: registry.length, plugins: plugins.length, total: merged.length } };
const servers = await fetchAll();
const data = { servers, total: servers.length };
cache.ts = Date.now();
cache.data = data;
return NextResponse.json({ cached: false, ...data });
} catch (e) {
return NextResponse.json({ error: e.message, servers: [], counts: { total: 0 } }, { status: 500 });
return NextResponse.json({ error: e.message, servers: [], total: 0 }, { status: 500 });
}
}

View file

@ -0,0 +1,95 @@
"use server";
import { NextResponse } from "next/server";
const TIMEOUT_MS = 8000;
// Probe MCP server: initialize + tools/list. No auth header — works for authless servers.
// OAuth servers return 401, signal client to skip tool listing.
async function probeMcp(url) {
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-06-18",
};
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
// Step 1: initialize
const initRes = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
jsonrpc: "2.0", id: 1, method: "initialize",
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "9router", version: "1" } },
}),
signal: ac.signal,
});
if (initRes.status === 401 || initRes.status === 403) {
return { requiresAuth: true, tools: [] };
}
if (!initRes.ok) {
return { error: `init ${initRes.status}`, tools: [] };
}
const sessionId = initRes.headers.get("mcp-session-id") || "";
await initRes.text().catch(() => {});
const listHeaders = { ...headers };
if (sessionId) listHeaders["mcp-session-id"] = sessionId;
// Step 2: notifications/initialized (required by spec before tools/list)
await fetch(url, {
method: "POST",
headers: listHeaders,
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
signal: ac.signal,
}).catch(() => {});
// Step 3: tools/list
const listRes = await fetch(url, {
method: "POST",
headers: listHeaders,
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
signal: ac.signal,
});
if (listRes.status === 401 || listRes.status === 403) {
return { requiresAuth: true, tools: [] };
}
const ct = listRes.headers.get("content-type") || "";
let parsed;
if (ct.includes("text/event-stream")) {
// Parse SSE: each "data: {...}" line is a JSON-RPC message
const text = await listRes.text();
const dataLines = text.split("\n").filter((l) => l.startsWith("data:"));
for (const line of dataLines) {
try {
const obj = JSON.parse(line.replace(/^data:\s*/, ""));
if (obj?.id === 2 && obj.result) { parsed = obj; break; }
} catch { /* skip */ }
}
} else {
parsed = await listRes.json().catch(() => null);
}
const tools = parsed?.result?.tools || [];
return {
tools: tools.map((t) => ({ name: t.name, description: t.description || "" })),
};
} catch (e) {
return { error: e.name === "AbortError" ? "timeout" : e.message, tools: [] };
} finally {
clearTimeout(timer);
}
}
export async function POST(request) {
try {
const { url } = await request.json();
if (!url || typeof url !== "string") {
return NextResponse.json({ error: "url required" }, { status: 400 });
}
const result = await probeMcp(url);
return NextResponse.json(result);
} catch (e) {
return NextResponse.json({ error: e.message, tools: [] }, { status: 500 });
}
}

View file

@ -5,116 +5,27 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
import crypto from "crypto";
import { COWORK_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
import { DEFAULT_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
const PROVIDER = "gateway";
// Plugin folder mount location.
// Claude Cowork 3p actually launches with --user-data-dir=Claude-3p, so plugins
// must live there (not the system /Library path which requires admin & isn't read in 3p).
const getOrgPluginsCandidates = () => {
if (os.platform() === "darwin") {
const home = os.homedir();
return [
path.join(home, "Library", "Application Support", "Claude-3p", "org-plugins"),
path.join(home, "Library", "Application Support", "Claude", "org-plugins"),
"/Library/Application Support/Claude/org-plugins",
];
}
if (os.platform() === "win32") {
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const programData = process.env.ProgramData || "C:\\ProgramData";
return [
path.join(localApp, "Claude-3p", "org-plugins"),
path.join(localApp, "Claude", "org-plugins"),
path.join(programData, "Claude", "org-plugins"),
];
}
return [path.join(os.homedir(), ".config", "Claude-3p", "org-plugins"), "/etc/Claude/org-plugins"];
// Hardcoded relax-security profile applied on every Apply.
const SECURITY_RELAX = {
coworkEgressAllowedHosts: ["*"],
disabledBuiltinTools: [],
isLocalDevMcpEnabled: true,
isDesktopExtensionEnabled: true,
isDesktopExtensionDirectoryEnabled: true,
isDesktopExtensionSignatureRequired: false,
isClaudeCodeForDesktopEnabled: true,
disableEssentialTelemetry: true,
disableNonessentialTelemetry: true,
disableNonessentialServices: true,
};
// Pick first writable candidate for org-plugins
async function pickPluginsRoot() {
for (const dir of getOrgPluginsCandidates()) {
try {
await fs.mkdir(dir, { recursive: true });
// Probe write
const probe = path.join(dir, ".__9router_probe");
await fs.writeFile(probe, "ok");
await fs.unlink(probe);
return dir;
} catch { /* try next */ }
}
return null;
}
// Tools auto-allow per server via toolPolicy["*"] = "allow" semantics.
// 3p schema requires explicit tool names; we mark "*" via operonSkipMcpApprovals instead.
// Create plugin folder mount: org-plugins/<name>/claude-plugin/{plugin.json, version.json, .mcp.json}
async function writeOrgPluginsFolder(selectedPluginNames) {
const root = await pickPluginsRoot();
if (!root) return { error: "no_writable_plugins_dir", written: [] };
const set = new Set(selectedPluginNames || []);
const selectedPlugins = COWORK_PLUGINS.filter((p) => set.has(p.name));
// Remove previously-managed plugin subfolders (best-effort)
for (const p of COWORK_PLUGINS) {
try { await fs.rm(path.join(root, p.name), { recursive: true, force: true }); } catch { /* ignore */ }
}
const written = [];
for (const p of selectedPlugins) {
const pluginRoot = path.join(root, p.name);
const metaDir = path.join(pluginRoot, ".claude-plugin");
try {
await fs.mkdir(metaDir, { recursive: true });
const manifest = { name: p.name, version: "1.0.0", description: p.description || p.name, author: { name: "9router" } };
await fs.writeFile(path.join(metaDir, "plugin.json"), JSON.stringify(manifest, null, 2));
// .mcp.json at plugin root, schema: {mcpServers: {name: {type, url, oauth?}}}
const mcpServers = {};
for (const s of p.servers) {
const key = p.servers.length === 1 ? p.name : `${p.name}-${s.key}`;
mcpServers[key] = {
type: /\/sse(\b|\/)/i.test(s.url) ? "sse" : "http",
url: s.url,
};
}
await fs.writeFile(path.join(pluginRoot, ".mcp.json"), JSON.stringify({ mcpServers }, null, 2));
written.push(p.name);
} catch (e) {
return { error: e.code || e.message, written, root };
}
}
return { written, root };
}
// Set operonSkipMcpApprovals[serverName]=true in Claude-3p/config.json so user
// is not prompted for every tool call. Mirrors mcpToolAccessProvider.setSkipApprovals.
async function writeSkipApprovals(managedServers) {
const cfgPath = path.join(getWriteRoot(), "config.json");
let cfg = {};
try {
cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {};
} catch (e) {
if (e.code !== "ENOENT") return { error: e.code };
}
// Reset previous managed entries (those we own == COWORK_PLUGINS server names)
const ownedNames = new Set();
for (const p of COWORK_PLUGINS) {
for (const s of p.servers) {
ownedNames.add(p.servers.length === 1 ? p.name : `${p.name}-${s.key}`);
}
}
const skip = (cfg.operonSkipMcpApprovals && typeof cfg.operonSkipMcpApprovals === "object") ? cfg.operonSkipMcpApprovals : {};
for (const k of Object.keys(skip)) {
if (ownedNames.has(k)) delete skip[k];
}
for (const srv of managedServers) {
if (srv?.name) skip[srv.name] = true;
}
cfg.operonSkipMcpApprovals = skip;
await fs.mkdir(getWriteRoot(), { recursive: true });
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
return { written: Object.keys(skip).length };
}
// Candidate user-data roots — Cowork can run from either Claude-3p (3p mode) or Claude (1p mode w/ cowork features)
const getCandidateRoots = () => {
if (os.platform() === "darwin") {
const base = path.join(os.homedir(), "Library", "Application Support");
@ -136,7 +47,6 @@ const getCandidateRoots = () => {
];
};
// Claude.app/exe install paths — fallback detect when no user-data folder yet
const getAppInstallPaths = () => {
if (os.platform() === "darwin") {
return ["/Applications/Claude.app", path.join(os.homedir(), "Applications", "Claude.app")];
@ -153,7 +63,6 @@ const getAppInstallPaths = () => {
return [];
};
// For READ: prefer existing configLibrary (any root). For WRITE: always Claude-3p (first candidate).
const resolveAppRootForRead = async () => {
const candidates = getCandidateRoots();
for (const dir of candidates) {
@ -165,70 +74,55 @@ const resolveAppRootForRead = async () => {
return candidates[0];
};
const getWriteRoot = () => getCandidateRoots()[0]; // always Claude-3p
const getWriteRoot = () => getCandidateRoots()[0];
const getConfigDir = async () => path.join(await resolveAppRootForRead(), "configLibrary");
const getWriteConfigDir = () => path.join(getWriteRoot(), "configLibrary");
const getMetaPath = async () => path.join(await getConfigDir(), "_meta.json");
const getWriteMetaPath = () => path.join(getWriteConfigDir(), "_meta.json");
// Locate Claude (1p) folder for claude_desktop_config.json bootstrap
const get1pRoot = () => {
if (os.platform() === "darwin") {
return path.join(os.homedir(), "Library", "Application Support", "Claude");
}
if (os.platform() === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Claude");
if (os.platform() === "win32") {
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
return path.join(roaming, "Claude"); // 1p uses roaming on Win
return path.join(roaming, "Claude");
}
return path.join(os.homedir(), ".config", "Claude");
};
// Set deploymentMode="3p" in Claude/claude_desktop_config.json (preserve existing keys)
const bootstrapDeploymentMode = async () => {
const cfgPath = path.join(get1pRoot(), "claude_desktop_config.json");
let cfg = {};
try {
const content = await fs.readFile(cfgPath, "utf-8");
cfg = JSON.parse(content);
cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8"));
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
if (cfg.deploymentMode === "3p") return false; // no change
if (cfg.deploymentMode === "3p") return false;
cfg.deploymentMode = "3p";
await fs.mkdir(get1pRoot(), { recursive: true });
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
return true;
};
// Cowork is available if either (a) any user-data root exists or (b) Claude app is installed
const checkInstalled = async () => {
for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) {
try {
await fs.access(dir);
return true;
} catch { /* try next */ }
try { await fs.access(dir); return true; } catch { /* try next */ }
}
return false;
};
const readJson = async (filePath) => {
try {
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content);
} catch (error) {
try { return JSON.parse(await fs.readFile(filePath, "utf-8")); }
catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
};
// Ensure meta exists in Claude-3p/configLibrary (write target). If meta already exists in Claude/ (1p), copy appliedId.
const ensureMeta = async () => {
const writeMetaPath = getWriteMetaPath();
let meta = await readJson(writeMetaPath);
if (!meta || !meta.appliedId) {
// Try to inherit from any existing root
const existingRead = await readJson(await getMetaPath());
if (existingRead?.appliedId) {
meta = existingRead;
@ -242,17 +136,28 @@ const ensureMeta = async () => {
return meta;
};
// Auto-skip approvals for every managed server (no per-tool prompts).
async function writeSkipApprovals(managedServers) {
const cfgPath = path.join(getWriteRoot(), "config.json");
let cfg = {};
try { cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {}; }
catch (e) { if (e.code !== "ENOENT") return { error: e.code }; }
const skip = {};
for (const srv of managedServers) {
if (srv?.name) skip[srv.name] = true;
}
cfg.operonSkipMcpApprovals = skip;
await fs.mkdir(getWriteRoot(), { recursive: true });
await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2));
return { written: Object.keys(skip).length };
}
export async function GET() {
try {
const installed = await checkInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "Claude Desktop (Cowork mode) not detected",
});
return NextResponse.json({ installed: false, config: null, message: "Claude Desktop (Cowork mode) not detected" });
}
const meta = await readJson(await getMetaPath());
const appliedId = meta?.appliedId || null;
const configDir = await getConfigDir();
@ -263,13 +168,7 @@ export async function GET() {
const models = Array.isArray(config?.inferenceModels)
? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean)
: [];
// managedMcpServers stored as native array in configLibrary <uuid>.json
const managedMcpArr = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
const selectedPlugins = COWORK_PLUGINS
.filter((p) => p.servers.some((s) => managedMcpArr.some((v) => v?.url === s.url)))
.map((p) => p.name);
const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : [];
const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl);
return NextResponse.json({
@ -282,9 +181,23 @@ export async function GET() {
baseUrl,
models,
provider: config?.inferenceProvider || null,
selectedPlugins,
plugins: managedMcp.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}-`;
const bare = new Set();
for (const k of keys) {
let t = k;
while (t.startsWith(prefix)) t = t.slice(prefix.length);
bare.add(t);
}
// If plugin matches a default, prefer default toolNames (curated/correct).
const def = DEFAULT_PLUGINS.find((d) => d.name === m.name);
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 };
}),
},
availablePlugins: COWORK_PLUGINS.map((p) => ({ name: p.name, description: p.description })),
defaultPlugins: DEFAULT_PLUGINS,
});
} catch (error) {
console.log("Error reading cowork settings:", error);
@ -299,13 +212,13 @@ export async function POST(request) {
if (!baseUrl || !apiKey) {
return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 });
}
const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : [];
if (modelsArray.length === 0) {
return NextResponse.json({ error: "At least one model is required" }, { status: 400 });
}
const pluginsArray = Array.isArray(plugins) ? plugins.filter((p) => typeof p === "string") : [];
// 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);
const bootstrapped = await bootstrapDeploymentMode();
@ -313,22 +226,16 @@ export async function POST(request) {
const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`);
const newConfig = {
...SECURITY_RELAX,
inferenceProvider: PROVIDER,
inferenceGatewayBaseUrl: baseUrl,
inferenceGatewayApiKey: apiKey,
inferenceModels: modelsArray.map((name) => ({ name })),
isLocalDevMcpEnabled: true,
isDesktopExtensionEnabled: true,
};
if (managedMcpServers.length > 0) {
newConfig.managedMcpServers = managedMcpServers;
}
if (managedMcpServers.length > 0) newConfig.managedMcpServers = managedMcpServers;
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
// Plugin folder mount (best-effort, doesn't fail the request)
const pluginsResult = await writeOrgPluginsFolder(pluginsArray);
// Auto-skip approvals for managed servers
let skipResult = null;
try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; }
@ -339,7 +246,6 @@ export async function POST(request) {
? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop."
: "Cowork settings applied. Quit & reopen Claude Desktop.",
configPath,
plugins: pluginsResult,
skipApprovals: skipResult,
});
} catch (error) {
@ -355,12 +261,8 @@ export async function DELETE() {
return NextResponse.json({ success: true, message: "No active config to reset" });
}
const configPath = path.join(await getConfigDir(), `${meta.appliedId}.json`);
try {
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
await writeOrgPluginsFolder([]);
try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); }
catch (error) { if (error.code !== "ENOENT") throw error; }
try { await writeSkipApprovals([]); } catch { /* ignore */ }
return NextResponse.json({ success: true, message: "Cowork config reset" });
} catch (error) {

View file

@ -0,0 +1,255 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Modal from "./Modal";
const REGISTRY_ENDPOINT = "/api/cli-tools/cowork-mcp-registry";
const TOOLS_ENDPOINT = "/api/cli-tools/cowork-mcp-tools";
export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames = [] }) {
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const [error, setError] = useState(null);
const [expandedUrl, setExpandedUrl] = useState(null);
const [toolsCache, setToolsCache] = useState({});
const [toolsLoading, setToolsLoading] = useState({});
const [toolSelection, setToolSelection] = useState({});
useEffect(() => {
if (!isOpen) return;
if (servers.length > 0) return;
setLoading(true);
fetch(REGISTRY_ENDPOINT)
.then((r) => r.json())
.then((d) => {
if (d.error) setError(d.error);
else setServers(d.servers || []);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [isOpen]);
const addedSet = useMemo(() => new Set(addedNames), [addedNames]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return servers.filter((s) => {
if (filter === "authless" && s.oauth) return false;
if (filter === "oauth" && !s.oauth) return false;
if (!q) return true;
return (
(s.title || "").toLowerCase().includes(q) ||
(s.description || "").toLowerCase().includes(q) ||
(s.name || "").toLowerCase().includes(q)
);
});
}, [servers, search, filter]);
const fetchTools = async (server) => {
if (toolsCache[server.url]) return;
setToolsLoading((p) => ({ ...p, [server.url]: true }));
try {
const r = await fetch(TOOLS_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: server.url }),
});
const d = await r.json();
const tools = d.tools || [];
const fallback = Array.isArray(server.toolNames) ? server.toolNames : [];
const toolNames = tools.length > 0 ? tools.map((t) => t.name) : fallback;
setToolsCache((p) => ({ ...p, [server.url]: { tools, requiresAuth: !!d.requiresAuth, error: d.error } }));
// Default: all checked
setToolSelection((p) => ({ ...p, [server.url]: Object.fromEntries(toolNames.map((t) => [t, true])) }));
} catch (e) {
setToolsCache((p) => ({ ...p, [server.url]: { tools: [], error: e.message } }));
} finally {
setToolsLoading((p) => ({ ...p, [server.url]: false }));
}
};
const expandServer = (server) => {
if (expandedUrl === server.url) {
setExpandedUrl(null);
return;
}
setExpandedUrl(server.url);
fetchTools(server);
};
const toggleTool = (url, tool) => {
setToolSelection((prev) => ({ ...prev, [url]: { ...prev[url], [tool]: !prev[url]?.[tool] } }));
};
const setAllTools = (url, value) => {
const sel = toolSelection[url] || {};
setToolSelection((prev) => ({ ...prev, [url]: Object.fromEntries(Object.keys(sel).map((t) => [t, value])) }));
};
const confirmAdd = (server) => {
const sel = toolSelection[server.url] || {};
const enabled = Object.keys(sel).filter((t) => sel[t]);
onAdd?.({
name: server.slug || server.name,
title: server.title,
description: server.description,
url: server.url,
transport: server.transport,
oauth: server.oauth,
toolNames: enabled,
});
setExpandedUrl(null);
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Browse MCP Marketplace" size="lg">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or description..."
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="all">All</option>
<option value="authless">Authless</option>
<option value="oauth">OAuth</option>
</select>
</div>
{error && (
<div className="px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600">{error}</div>
)}
{loading && (
<div className="flex items-center gap-2 text-text-muted text-xs py-4 justify-center">
<span className="material-symbols-outlined animate-spin text-[18px]">progress_activity</span>
<span>Loading registry...</span>
</div>
)}
{!loading && (
<div className="flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
{filtered.length === 0 && (
<div className="text-center text-xs text-text-muted py-6">No servers match filter</div>
)}
{filtered.map((s) => {
const added = addedSet.has(s.slug || s.name);
const expanded = expandedUrl === s.url;
const cache = toolsCache[s.url];
const isLoadingTools = toolsLoading[s.url];
const sel = toolSelection[s.url] || {};
const toolKeys = Object.keys(sel);
const selectedCount = Object.values(sel).filter(Boolean).length;
return (
<div key={s.url} className="rounded border border-transparent hover:border-border">
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
{s.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} />
) : (
<div className="size-7 rounded bg-surface shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-xs">{s.title}</span>
{s.oauth ? (
<span className="px-1 py-0.5 text-[9px] rounded bg-amber-500/10 text-amber-600">OAuth</span>
) : (
<span className="px-1 py-0.5 text-[9px] rounded bg-green-500/10 text-green-600">Authless</span>
)}
{s.toolCount > 0 && (
<span className="text-[10px] text-text-muted">{s.toolCount} tools</span>
)}
</div>
{s.description && (
<p className="text-[10px] text-text-muted line-clamp-2 mt-0.5">{s.description}</p>
)}
</div>
<button
onClick={() => added ? null : expandServer(s)}
disabled={added}
className={`shrink-0 px-2 py-1 rounded text-[10px] font-medium transition-colors ${
added
? "bg-green-500/10 text-green-600 cursor-default"
: expanded
? "bg-surface border border-border text-text-muted hover:bg-black/5"
: "bg-primary/10 border border-primary/40 text-primary hover:bg-primary/20"
}`}
>
{added ? "Added" : expanded ? "Cancel" : "+ Add"}
</button>
</div>
{expanded && (
<div className="px-3 py-2 bg-surface/40 border-t border-border flex flex-col gap-2">
{isLoadingTools && (
<div className="flex items-center gap-2 text-text-muted text-[10px] py-1">
<span className="material-symbols-outlined animate-spin text-[14px]">progress_activity</span>
<span>Probing server for tools...</span>
</div>
)}
{!isLoadingTools && cache?.requiresAuth && (
<p className="text-[10px] text-amber-600 bg-amber-500/10 px-2 py-1 rounded">
🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.
</p>
)}
{!isLoadingTools && cache?.error && !cache?.requiresAuth && (
<p className="text-[10px] text-red-600 bg-red-500/10 px-2 py-1 rounded">Probe failed: {cache.error}</p>
)}
{!isLoadingTools && toolKeys.length === 0 && !cache?.requiresAuth && !cache?.error && (
<p className="text-[10px] text-text-muted">No tools advertised by server.</p>
)}
{!isLoadingTools && toolKeys.length > 0 && (
<>
<div className="flex items-center justify-between">
<span className="text-[10px] text-text-muted">{selectedCount}/{toolKeys.length} tools enabled</span>
<div className="flex gap-1">
<button onClick={() => setAllTools(s.url, true)} className="text-[10px] text-primary hover:underline">All</button>
<span className="text-[10px] text-text-muted">·</span>
<button onClick={() => setAllTools(s.url, false)} className="text-[10px] text-primary hover:underline">None</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1 max-h-40 overflow-y-auto">
{toolKeys.map((t) => (
<label key={t} className="flex items-center gap-1.5 text-[10px] cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 px-1 rounded">
<input
type="checkbox"
checked={!!sel[t]}
onChange={() => toggleTool(s.url, t)}
className="size-3"
/>
<span className="truncate">{t}</span>
</label>
))}
</div>
</>
)}
<button
onClick={() => confirmAdd(s)}
className="self-end px-2 py-1 rounded text-[10px] font-medium bg-primary text-white hover:bg-primary/90"
>
Confirm Add
</button>
</div>
)}
</div>
);
})}
</div>
)}
<div className="text-[10px] text-text-muted text-right">
{filtered.length} of {servers.length} servers
</div>
</div>
</Modal>
);
}

View file

@ -17,6 +17,7 @@ export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as ComboFormModal } from "./ComboFormModal";
export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
export { default as UsageStats } from "./UsageStats";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as NineRemoteButton } from "./NineRemoteButton";

View file

@ -100,7 +100,7 @@ export const CLI_TOOLS = {
},
codex: {
id: "codex",
name: "OpenAI Codex CLI",
name: "OpenAI Codex CLI / App",
image: "/providers/codex.png",
color: "#10A37F",
description: "OpenAI Codex CLI",

File diff suppressed because it is too large Load diff