diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/helpers/geminiHelper.js index 35768f06..47fb9f94 100644 --- a/open-sse/translator/helpers/geminiHelper.js +++ b/open-sse/translator/helpers/geminiHelper.js @@ -270,6 +270,13 @@ function flattenTypeArrays(obj) { } } +// Infer missing type=object when properties exist (Gemini requires explicit type) +function ensureObjectType(obj) { + if (!obj || typeof obj !== "object") return; + if (obj.properties && !obj.type) obj.type = "object"; + for (const v of Object.values(obj)) if (v && typeof v === "object") ensureObjectType(v); +} + // Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively export function cleanJSONSchemaForAntigravity(schema) { if (!schema || typeof schema !== "object") return schema; @@ -286,6 +293,9 @@ export function cleanJSONSchemaForAntigravity(schema) { flattenAnyOfOneOf(cleaned); flattenTypeArrays(cleaned); + // Phase 2.5: Infer missing type=object when properties exist (Gemini requirement) + ensureObjectType(cleaned); + // Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays) removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS); diff --git a/skills/9router-chat/SKILL.md b/skills/9router-chat/SKILL.md index d385b669..d109ac7f 100644 --- a/skills/9router-chat/SKILL.md +++ b/skills/9router-chat/SKILL.md @@ -12,10 +12,12 @@ Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://ra - `POST $NINEROUTER_URL/v1/chat/completions` — OpenAI format - `POST $NINEROUTER_URL/v1/messages` — Anthropic format -## Discover models +## Discover ```bash curl $NINEROUTER_URL/v1/models | jq '.data[].id' +# Per-model metadata (contextWindow, params) +curl "$NINEROUTER_URL/v1/models/info?id=openai/gpt-4o" ``` Combos (e.g. `vip`, `mycodex`) auto-fallback through multiple providers. diff --git a/skills/9router-embeddings/SKILL.md b/skills/9router-embeddings/SKILL.md index 04286b2e..648e4ca7 100644 --- a/skills/9router-embeddings/SKILL.md +++ b/skills/9router-embeddings/SKILL.md @@ -7,10 +7,12 @@ description: Generate vector embeddings via 9Router /v1/embeddings using OpenAI Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. -## Discover models +## Discover ```bash curl $NINEROUTER_URL/v1/models/embedding | jq '.data[].id' +# Per-model dimensions +curl "$NINEROUTER_URL/v1/models/info?id=openai/text-embedding-3-small" ``` ## Endpoint diff --git a/skills/9router-image/SKILL.md b/skills/9router-image/SKILL.md index b6a9e32e..f5bbfad1 100644 --- a/skills/9router-image/SKILL.md +++ b/skills/9router-image/SKILL.md @@ -7,10 +7,12 @@ description: Generate images via 9Router /v1/images/generations using OpenAI / G Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. -## Discover models +## Discover ```bash curl $NINEROUTER_URL/v1/models/image | jq '.data[].id' +# Per-model params/options (size enum, quality enum, capabilities like edit) +curl "$NINEROUTER_URL/v1/models/info?id=openai/dall-e-3" ``` ## Endpoint diff --git a/skills/9router-stt/SKILL.md b/skills/9router-stt/SKILL.md index 32ac3c38..1561c9dc 100644 --- a/skills/9router-stt/SKILL.md +++ b/skills/9router-stt/SKILL.md @@ -7,10 +7,12 @@ description: Speech-to-text via 9Router /v1/audio/transcriptions using OpenAI Wh Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. -## Discover models +## Discover ```bash curl $NINEROUTER_URL/v1/models/stt | jq '.data[].id' +# Per-model params (language, response_format, prompt, temperature support) +curl "$NINEROUTER_URL/v1/models/info?id=openai/whisper-1" ``` `model` = STT model ID (e.g. `openai/whisper-1`, `groq/whisper-large-v3`, `deepgram/nova-3`, `gemini/gemini-2.5-flash`). diff --git a/skills/9router-tts/SKILL.md b/skills/9router-tts/SKILL.md index 6a7fecf8..4a9536cb 100644 --- a/skills/9router-tts/SKILL.md +++ b/skills/9router-tts/SKILL.md @@ -7,13 +7,18 @@ description: Text-to-speech via 9Router /v1/audio/speech using OpenAI / ElevenLa Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. -## Discover voices +## Discover ```bash +# 1) List models curl $NINEROUTER_URL/v1/models/tts | jq '.data[].id' +# 2) Per-model metadata (params, voicesUrl if voice-by-id) +curl "$NINEROUTER_URL/v1/models/info?id=el/eleven_multilingual_v2" +# 3) List voices (elevenlabs, edge-tts, deepgram, inworld, local-device). Optional ?lang=vi +curl "$NINEROUTER_URL/v1/audio/voices?provider=edge-tts&lang=vi" | jq '.data[].model' ``` -`model` = voice ID (e.g. `openai/tts-1`, `el/eleven_multilingual_v2`, `edge-tts/en-US-AriaNeural`). +`model` field in `/v1/audio/speech` = voice ID directly (e.g. `edge-tts/vi-VN-HoaiMyNeural`, `el/`, or `openai/tts-1` model+default voice). ## Endpoint diff --git a/skills/9router-web-fetch/SKILL.md b/skills/9router-web-fetch/SKILL.md index f6b8be90..764aa37f 100644 --- a/skills/9router-web-fetch/SKILL.md +++ b/skills/9router-web-fetch/SKILL.md @@ -7,10 +7,12 @@ description: Fetch URL → markdown / text / HTML via 9Router /v1/web/fetch usin Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. -## Discover providers +## Discover ```bash curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webFetch") | .id' +# Per-provider params +curl "$NINEROUTER_URL/v1/models/info?id=firecrawl/fetch" ``` IDs end in `/fetch` (e.g. `firecrawl/fetch`, `jina/fetch`). `fetch-combo` chains providers with auto-fallback. diff --git a/skills/9router-web-search/SKILL.md b/skills/9router-web-search/SKILL.md index 57a36b21..e88594c5 100644 --- a/skills/9router-web-search/SKILL.md +++ b/skills/9router-web-search/SKILL.md @@ -7,10 +7,12 @@ description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serp Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. -## Discover providers +## Discover ```bash curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webSearch") | .id' +# Per-provider params (searchTypes, maxResults, required options like cx for google-pse) +curl "$NINEROUTER_URL/v1/models/info?id=tavily/search" ``` IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain providers with auto-fallback. diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index d2f43840..58482a9b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { APP_CONFIG } from "@/shared/constants/config"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { UPDATER_CONFIG } from "@/shared/constants/config"; const STORAGE_KEY = "9router.cliToolEndpointPresets"; const CUSTOM_VALUE = "__custom__"; @@ -13,8 +13,6 @@ const ensureV1 = (url) => { return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; }; -const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, ""); - const readSavedPresets = () => { if (typeof window === "undefined") return []; try { @@ -31,21 +29,27 @@ const writeSavedPresets = (presets) => { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); }; -// Build endpoint options ordered by priority -const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }) => { +const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => { const opts = []; - const wrap = (url) => (withV1 ? ensureV1(url) : url.replace(/\/+$/, "")); + const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); if (!requiresExternalUrl) { - opts.push({ value: "local", label: `Localhost (127.0.0.1)`, url: wrap(`http://127.0.0.1:${APP_CONFIG.appPort}`) }); + const localUrl = wrap(`http://127.0.0.1:${UPDATER_CONFIG.appPort}`); + opts.push({ value: "local", label: localUrl, url: localUrl }); } if (tunnelEnabled && tunnelPublicUrl) { - opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: wrap(tunnelPublicUrl) }); + const u = wrap(tunnelPublicUrl); + opts.push({ value: "tunnel", label: u, url: u }); } if (tailscaleEnabled && tailscaleUrl) { - opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: wrap(tailscaleUrl) }); + const u = wrap(tailscaleUrl); + opts.push({ value: "tailscale", label: u, url: u }); + } + if (cloudEnabled && cloudUrl) { + const u = wrap(cloudUrl); + opts.push({ value: "cloud", label: u, url: u }); } savedPresets.forEach((p) => { - opts.push({ value: `saved:${p.name}`, label: `★ ${p.name} - ${p.baseUrl}`, url: p.baseUrl, saved: true }); + opts.push({ value: `saved:${p.name}`, label: p.baseUrl, url: p.baseUrl, saved: true }); }); opts.push({ value: CUSTOM_VALUE, label: "Custom URL...", url: "" }); return opts; @@ -59,32 +63,37 @@ export default function BaseUrlSelect({ tunnelPublicUrl = "", tailscaleEnabled = false, tailscaleUrl = "", + cloudEnabled = false, + cloudUrl = "", withV1 = true, }) { const [savedPresets, setSavedPresets] = useState([]); const [mode, setMode] = useState(""); + const [customInput, setCustomInput] = useState(""); + const initializedRef = useRef(false); useEffect(() => { setSavedPresets(readSavedPresets()); }, []); const options = useMemo( - () => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }), - [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1] + () => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }), + [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] ); - // Auto-detect mode based on current value matching an option + // Always default to first option (127.0.0.1) on mount, ignore persisted value useEffect(() => { - if (!value) { - if (options[0] && options[0].value !== CUSTOM_VALUE) { - setMode(options[0].value); - onChange(options[0].url); - } - return; + if (initializedRef.current) return; + if (options.length === 0) return; + initializedRef.current = true; + const first = options.find((o) => o.value !== CUSTOM_VALUE); + if (first) { + setMode(first.value); + onChange(first.url); + } else { + setMode(CUSTOM_VALUE); } - const match = options.find((o) => o.url && o.url === value); - setMode(match ? match.value : CUSTOM_VALUE); - }, [value, options]); + }, [options, onChange]); const handleSelect = (e) => { const next = e.target.value; @@ -95,14 +104,15 @@ export default function BaseUrlSelect({ try { defaultName = new URL(trimmed).host; } catch {} const name = window.prompt("Save endpoint as:", defaultName); if (!name?.trim()) return; - const next = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }] + const updated = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }] .sort((a, b) => a.name.localeCompare(b.name)); - setSavedPresets(next); - writeSavedPresets(next); + setSavedPresets(updated); + writeSavedPresets(updated); return; } setMode(next); if (next === CUSTOM_VALUE) { + setCustomInput(""); onChange(""); return; } @@ -110,18 +120,26 @@ export default function BaseUrlSelect({ if (opt) onChange(opt.url); }; + const handleCustomInput = (e) => { + const v = e.target.value; + setCustomInput(v); + onChange(v); + }; + const handleDeleteSaved = () => { if (!mode.startsWith("saved:")) return; const name = mode.slice(6); - const next = savedPresets.filter((p) => p.name !== name); - setSavedPresets(next); - writeSavedPresets(next); + const updated = savedPresets.filter((p) => p.name !== name); + setSavedPresets(updated); + writeSavedPresets(updated); setMode(CUSTOM_VALUE); + setCustomInput(""); + onChange(""); }; const isSaved = mode.startsWith("saved:"); const isCustom = mode === CUSTOM_VALUE; - const canSave = isCustom && (value || "").trim().length > 0; + const canSave = isCustom && (customInput || "").trim().length > 0; return (
@@ -145,8 +163,8 @@ export default function BaseUrlSelect({ {isCustom && ( onChange(e.target.value)} + value={customInput} + onChange={handleCustomInput} placeholder={withV1 ? "https://example.com/v1" : "https://example.com"} className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 739d513e..a25767c9 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -43,10 +44,7 @@ export default function ClaudeToolCard({ if (!claudeStatus?.installed) return null; const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL; if (!currentUrl) return "not_configured"; - const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1"); - const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL); - const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl); - if (localMatch || cloudMatch || tunnelMatch) return "configured"; + if (matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null })) return "configured"; return "other"; }; @@ -296,20 +294,9 @@ export default function ClaudeToolCard({ {!checkingClaude && claudeStatus?.installed && ( <>
- {/* Current Base URL */} - {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && ( -
- Current - arrow_forward - - {claudeStatus.settings.env.ANTHROPIC_BASE_URL} - -
- )} - - {/* Base URL */} + {/* Endpoint (selector) */}
- Base URL + Select Endpoint arrow_forward
+ {/* Current configured */} + {claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && ( +
+ Current + arrow_forward + + {claudeStatus.settings.env.ANTHROPIC_BASE_URL} + +
+ )} + {/* API Key */}
API Key diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index 360db6cd..f53b3695 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [codexStatus, setCodexStatus] = useState(initialStatus || null); @@ -64,8 +65,9 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api const getConfigStatus = () => { if (!codexStatus?.installed) return null; if (!codexStatus.config) return "not_configured"; - const hasBaseUrl = codexStatus.config.includes(baseUrl) || codexStatus.config.includes("localhost") || codexStatus.config.includes("127.0.0.1"); - return hasBaseUrl ? "configured" : "other"; + const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); + const currentUrl = parsed ? parsed[1] : ""; + return matchKnownEndpoint(currentUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -266,7 +268,22 @@ model = "${effectiveSubagentModel}" {!checkingCodex && codexStatus?.installed && ( <>
- {/* Current Base URL */} + {/* Endpoint (selector) */} +
+ Select Endpoint + arrow_forward + +
+ + {/* Current configured */} {codexStatus?.config && (() => { const parsed = codexStatus.config.match(/base_url\s*=\s*"([^"]+)"/); const currentBaseUrl = parsed ? parsed[1] : null; @@ -281,21 +298,6 @@ model = "${effectiveSubagentModel}" ) : null; })()} - {/* Base URL */} -
- Base URL - arrow_forward - -
- {/* API Key */}
API Key diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js index c2334c6f..b8084d7b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [status, setStatus] = useState(initialStatus || null); @@ -63,8 +64,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a if (!status) return null; if (!status.has9Router) return "not_configured"; const url = status.currentUrl || ""; - return url.includes("localhost") || url.includes("127.0.0.1") || url.includes(baseUrl) - ? "configured" : "other"; + return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -207,7 +207,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
- + /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || ""); - const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, ""); const ensureV1 = (url) => { const trimmed = (url || "").replace(/\/+$/, ""); @@ -38,26 +37,11 @@ export default function CoworkToolCard({ const [message, setMessage] = useState(null); const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedModels, setSelectedModels] = useState([]); - const [modalOpen, setModalOpen] = useState(false); - const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); - const [endpointMode, setEndpointMode] = useState("custom"); const [customBaseUrl, setCustomBaseUrl] = useState(""); - - const endpointOptions = useMemo(() => { - const opts = []; - if (tunnelEnabled && tunnelPublicUrl) { - opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: ensureV1(tunnelPublicUrl) }); - } - if (tailscaleEnabled && tailscaleUrl) { - opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: ensureV1(tailscaleUrl) }); - } - if (cloudEnabled && cloudUrl) { - opts.push({ value: "cloud", label: `Cloud - ${cloudUrl}`, url: ensureV1(cloudUrl) }); - } - opts.push({ value: "custom", label: "Custom URL (VPS / public host)", url: "" }); - return opts; - }, [tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl]); + const [selectedPlugins, setSelectedPlugins] = useState([]); + const [pluginsExpanded, setPluginsExpanded] = useState(false); + const [comboModalOpen, setComboModalOpen] = useState(false); useEffect(() => { if (apiKeys?.length > 0 && !selectedApiKey) { @@ -70,11 +54,7 @@ export default function CoworkToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !status) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); + if (isExpanded && !status) checkStatus(); }, [isExpanded]); useEffect(() => { @@ -83,28 +63,12 @@ export default function CoworkToolCard({ } if (status?.cowork?.baseUrl && !customBaseUrl) { setCustomBaseUrl(stripV1(status.cowork.baseUrl)); - setEndpointMode("custom"); + } + if (Array.isArray(status?.cowork?.selectedPlugins)) { + setSelectedPlugins(status.cowork.selectedPlugins); } }, [status]); - // Auto-pick first available preset when expand if user has not set anything - useEffect(() => { - if (!customBaseUrl && endpointOptions[0]?.url) { - setEndpointMode(endpointOptions[0].value); - setCustomBaseUrl(stripV1(endpointOptions[0].url)); - } - }, [endpointOptions]); - - const fetchModelAliases = async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) setModelAliases(data.aliases || {}); - } catch (error) { - console.log("Error fetching model aliases:", error); - } - }; - const checkStatus = async () => { setChecking(true); try { @@ -124,31 +88,16 @@ export default function CoworkToolCard({ if (!status?.installed) return null; const url = status?.cowork?.baseUrl; if (!url) return "not_configured"; - if (isLocalhostUrl(url)) return "invalid"; return status.has9Router ? "configured" : "other"; }; const configStatus = getConfigStatus(); const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); - const handleEndpointModeChange = (value) => { - setEndpointMode(value); - const opt = endpointOptions.find((o) => o.value === value); - if (opt?.url) { - setCustomBaseUrl(stripV1(opt.url)); - } else { - setCustomBaseUrl(""); - } - }; - const handleApply = async () => { setMessage(null); const effectiveUrl = getEffectiveBaseUrl(); - if (isLocalhostUrl(effectiveUrl)) { - setMessage({ type: "error", text: "Localhost is not allowed. Enable Tunnel/Tailscale or use VPS." }); - return; - } if (selectedModels.length === 0) { setMessage({ type: "error", text: "Please select at least one model" }); return; @@ -167,6 +116,7 @@ export default function CoworkToolCard({ baseUrl: effectiveUrl, apiKey: keyToUse, models: selectedModels, + plugins: selectedPlugins, }), }); const data = await res.json(); @@ -183,6 +133,29 @@ export default function CoworkToolCard({ } }; + const handleCreateCombo = async ({ name, models }) => { + try { + const res = await fetch("/api/combos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, models }), + }); + if (!res.ok) { + const err = await res.json(); + 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]); + } + setComboModalOpen(false); + setMessage({ type: "success", text: `Combo "${name}" created and added.` }); + } catch (error) { + setMessage({ type: "error", text: error.message }); + } + }; + const handleReset = async () => { setRestoring(true); setMessage(null); @@ -234,7 +207,6 @@ export default function CoworkToolCard({

{tool.name}

{configStatus === "configured" && Connected} {configStatus === "not_configured" && Not configured} - {configStatus === "invalid" && Localhost (invalid)} {configStatus === "other" && Other}

{tool.description}

@@ -245,11 +217,6 @@ export default function CoworkToolCard({ {isExpanded && (
-
- info - Claude Cowork runs in a sandboxed VM and cannot reach localhost. Use Tunnel, Tailscale, or VPS public URL. -
- {checking && (
progress_activity @@ -278,6 +245,21 @@ export default function CoworkToolCard({ {!checking && status?.installed && ( <>
+
+ Select Endpoint + arrow_forward + setCustomBaseUrl(stripV1(url))} + tunnelEnabled={tunnelEnabled} + tunnelPublicUrl={tunnelPublicUrl} + tailscaleEnabled={tailscaleEnabled} + tailscaleUrl={tailscaleUrl} + cloudEnabled={cloudEnabled} + cloudUrl={cloudUrl} + /> +
+ {status?.cowork?.baseUrl && (
Current @@ -288,32 +270,6 @@ export default function CoworkToolCard({
)} -
- Endpoint Mode - arrow_forward - -
- -
- Base URL - arrow_forward - setCustomBaseUrl(stripV1(e.target.value))} - placeholder="https://your-host.com/v1" - className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" - /> -
-
API Key arrow_forward @@ -347,9 +303,55 @@ export default function CoworkToolCard({ )) )}
- +
+ + {false && (
+ Connectors + arrow_forward +
+
+ {selectedPlugins.length} of {(status?.availablePlugins || []).length} selected + +
+ {pluginsExpanded && ( +
+ {(status?.availablePlugins || []).map((p) => { + const checked = selectedPlugins.includes(p.name); + return ( + + ); + })} +
+ )} + {!pluginsExpanded && selectedPlugins.length > 0 && ( +
+ {selectedPlugins.map((name) => ( + + {name} + + + ))} +
+ )} +
+
)}
{message && ( @@ -370,32 +372,28 @@ export default function CoworkToolCard({ content_copyManual Config
+ )}
)} - setModalOpen(false)} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - } - setModalOpen(false); - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Add Model for Claude Cowork" - /> - setShowManualConfigModal(false)} title="Claude Cowork - Manual Configuration" configs={getManualConfigs()} /> + + setComboModalOpen(false)} + onSave={handleCreateCombo} + activeProviders={activeProviders} + forcePrefix="claude-" + title="Create Cowork Combo" + /> ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index a148215e..b45b3b28 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -42,11 +43,7 @@ export default function DroidToolCard({ // Check for any 9Router model entry (support multi-model: custom:9Router-0, custom:9Router-1, ...) const currentConfig = droidStatus.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router")); if (!currentConfig) return "not_configured"; - const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1"); - const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL); - const tunnelMatch = baseUrl && currentConfig.baseUrl?.startsWith(baseUrl); - if (localMatch || cloudMatch || tunnelMatch) return "configured"; - return "other"; + return matchKnownEndpoint(currentConfig.baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl: cloudEnabled ? CLOUD_URL : null }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -291,20 +288,9 @@ export default function DroidToolCard({ {!checkingDroid && droidStatus?.installed && ( <>
- {/* Current Base URL */} - {droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && ( -
- Current - arrow_forward - - {droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl} - -
- )} - - {/* Base URL */} + {/* Endpoint (selector) */}
- Base URL + Select Endpoint arrow_forward
+ {/* Current configured */} + {droidStatus?.settings?.customModels?.find(m => m.id?.startsWith("custom:9Router"))?.baseUrl && ( +
+ Current + arrow_forward + + {droidStatus.settings.customModels.find(m => m.id?.startsWith("custom:9Router")).baseUrl} + +
+ )} + {/* API Key */}
API Key diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 13620b05..df9c1150 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; const ENDPOINT = "/api/cli-tools/hermes-settings"; @@ -39,9 +40,7 @@ export default function HermesToolCard({ if (!hermesStatus?.installed) return null; const cfg = hermesStatus.settings?.model; if (!cfg?.base_url) return "not_configured"; - const localMatch = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(cfg.base_url); - const tunnelMatch = baseUrl && cfg.base_url.startsWith(baseUrl); - if (localMatch || tunnelMatch) return "configured"; + if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; return "other"; }; @@ -233,18 +232,8 @@ export default function HermesToolCard({ {!checking && hermesStatus?.installed && ( <>
- {hermesStatus?.settings?.model?.base_url && ( -
- Current - arrow_forward - - {hermesStatus.settings.model.base_url} - -
- )} -
- Base URL + Select Endpoint arrow_forward
+ {hermesStatus?.settings?.model?.base_url && ( +
+ Current + arrow_forward + + {hermesStatus.settings.model.base_url} + +
+ )} +
API Key arrow_forward diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 82758086..32c1b15f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; export default function OpenClawToolCard({ tool, @@ -39,10 +40,7 @@ export default function OpenClawToolCard({ if (!openclawStatus?.installed) return null; const currentProvider = openclawStatus.settings?.models?.providers?.["9router"]; if (!currentProvider) return "not_configured"; - const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1") || currentProvider.baseUrl?.includes("0.0.0.0"); - const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl); - if (localMatch || tunnelMatch) return "configured"; - return "other"; + return matchKnownEndpoint(currentProvider.baseUrl, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -282,20 +280,9 @@ export default function OpenClawToolCard({ {!checkingOpenclaw && openclawStatus?.installed && ( <>
- {/* Current Base URL */} - {openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && ( -
- Current - arrow_forward - - {openclawStatus.settings.models.providers["9router"].baseUrl} - -
- )} - - {/* Base URL */} + {/* Endpoint (selector) */}
- Base URL + Select Endpoint arrow_forward
+ {/* Current configured */} + {openclawStatus?.settings?.models?.providers?.["9router"]?.baseUrl && ( +
+ Current + arrow_forward + + {openclawStatus.settings.models.providers["9router"].baseUrl} + +
+ )} + {/* API Key */}
API Key diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index 1f1f1d3b..5d8a34c7 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import BaseUrlSelect from "./BaseUrlSelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { const [status, setStatus] = useState(initialStatus || null); @@ -69,9 +70,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, const getConfigStatus = () => { if (!status?.installed) return null; if (!status.config) return "not_configured"; + if (!status.has9Router) return "not_configured"; const url = status.config?.provider?.["9router"]?.options?.baseURL || ""; - const isLocal = url.includes("localhost") || url.includes("127.0.0.1"); - return status.has9Router && (isLocal || url.includes(baseUrl)) ? "configured" : status.has9Router ? "other" : "not_configured"; + return matchKnownEndpoint(url, { tunnelPublicUrl, tailscaleUrl }) ? "configured" : "other"; }; const configStatus = getConfigStatus(); @@ -258,19 +259,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, <>
{/* Current base URL */} - {status?.config?.provider?.["9router"]?.options?.baseURL && ( -
- Current - arrow_forward - - {status.config.provider["9router"].options.baseURL} - -
- )} - - {/* Base URL */} + {/* Endpoint (selector) */}
- Base URL + Select Endpoint arrow_forward
+ {/* Current configured */} + {status?.config?.provider?.["9router"]?.options?.baseURL && ( +
+ Current + arrow_forward + + {status.config.provider["9router"].options.baseURL} + +
+ )} + {/* API Key */}
API Key diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js new file mode 100644 index 00000000..0d7b7ca1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/cliEndpointMatch.js @@ -0,0 +1,13 @@ +// Match a configured CLI base URL against all known endpoints (local/tunnel/tailscale/cloud) +const stripTrailingSlash = (s) => (s || "").replace(/\/+$/, ""); + +export function matchKnownEndpoint(currentUrl, opts = {}) { + if (!currentUrl) return false; + const url = stripTrailingSlash(currentUrl); + const { tunnelPublicUrl, tailscaleUrl, cloudUrl } = opts; + if (/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(url)) return true; + if (tunnelPublicUrl && url.startsWith(stripTrailingSlash(tunnelPublicUrl))) return true; + if (tailscaleUrl && url.startsWith(stripTrailingSlash(tailscaleUrl))) return true; + if (cloudUrl && url.startsWith(stripTrailingSlash(cloudUrl))) return true; + return false; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 390c50fe..e7300ca6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -485,20 +485,20 @@ export default function ProviderDetailPage() { const applyProxyAssignments = async (assignments) => { setBulkUpdatingProxy(true); try { - const results = await Promise.all(assignments.map(async ({ connectionId, proxyPoolId }) => { + let failed = 0; + for (const { connectionId, proxyPoolId } of assignments) { try { const res = await fetch(`/api/providers/${connectionId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyPoolId }), }); - return res.ok; + if (!res.ok) failed += 1; } catch (e) { console.log("Error applying proxy for", connectionId, e); - return false; + failed += 1; } - })); - const failed = results.filter((ok) => !ok).length; + } if (failed > 0) alert(`Updated with ${failed} failed request(s).`); await fetchConnections(); setShowBulkProxyModal(false); @@ -582,53 +582,37 @@ export default function ProviderDetailPage() { title={`Apply Proxy (${connections.length} connections)`} >
- - -
-

Apply single pool to all

-
+
+ + + {proxyPools.map((pool) => ( - {proxyPools.map((pool) => ( - - ))} -
+ ))}
{bulkUpdatingProxy &&

Applying...

} diff --git a/src/app/api/cli-tools/cowork-mcp-registry/route.js b/src/app/api/cli-tools/cowork-mcp-registry/route.js new file mode 100644 index 00000000..b511e12c --- /dev/null +++ b/src/app/api/cli-tools/cowork-mcp-registry/route.js @@ -0,0 +1,104 @@ +"use server"; + +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 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() { + 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" } }); + if (!r.ok) break; + const j = await r.json(); + for (const item of j.servers || []) { + const s = item.server || {}; + const remote = (s.remotes || [])[0]; + if (!remote?.url) continue; + const transport = remote.type === "streamable-http" ? "http" : (remote.type === "sse" ? "sse" : "http"); + out.push({ + source: "registry", + name: s.name, + title: s.title || s.name, + description: s.description || "", + url: remote.url, + transport, + }); + } + 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; +} + +export async function GET(request) { + const { searchParams } = new URL(request.url); + const force = searchParams.get("refresh") === "1"; + const cache = gcache(); + if (!force && cache.data && Date.now() - cache.ts < CACHE_TTL_MS) { + 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 } }; + 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 }); + } +} diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js index 62b50a0e..2b00861a 100644 --- a/src/app/api/cli-tools/cowork-settings/route.js +++ b/src/app/api/cli-tools/cowork-settings/route.js @@ -5,9 +5,115 @@ 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"; 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"]; +}; + +// 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; +} + +// Create plugin folder mount: org-plugins//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") { @@ -107,8 +213,6 @@ const checkInstalled = async () => { return false; }; -const isLocalhostUrl = (url) => /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(url || ""); - const readJson = async (filePath) => { try { const content = await fs.readFile(filePath, "utf-8"); @@ -160,6 +264,12 @@ export async function GET() { ? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean) : []; + // managedMcpServers stored as native array in configLibrary .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 has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl); return NextResponse.json({ @@ -172,7 +282,9 @@ export async function GET() { baseUrl, models, provider: config?.inferenceProvider || null, + selectedPlugins, }, + availablePlugins: COWORK_PLUGINS.map((p) => ({ name: p.name, description: p.description })), }); } catch (error) { console.log("Error reading cowork settings:", error); @@ -182,23 +294,20 @@ export async function GET() { export async function POST(request) { try { - const { baseUrl, apiKey, models } = await request.json(); + const { baseUrl, apiKey, models, plugins } = await request.json(); if (!baseUrl || !apiKey) { return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 }); } - if (isLocalhostUrl(baseUrl)) { - return NextResponse.json({ - error: "Claude Cowork sandbox cannot reach localhost. Enable Tunnel/Cloud Endpoint or use Tailscale/VPS.", - }, { 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") : []; + const managedMcpServers = buildManagedMcpServers(pluginsArray); + const bootstrapped = await bootstrapDeploymentMode(); const meta = await ensureMeta(); const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`); @@ -208,10 +317,21 @@ export async function POST(request) { inferenceGatewayBaseUrl: baseUrl, inferenceGatewayApiKey: apiKey, inferenceModels: modelsArray.map((name) => ({ name })), + isLocalDevMcpEnabled: true, + isDesktopExtensionEnabled: true, }; + 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 }; } + return NextResponse.json({ success: true, bootstrapped, @@ -219,6 +339,8 @@ 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) { console.log("Error applying cowork settings:", error); @@ -238,6 +360,8 @@ export async function DELETE() { } catch (error) { if (error.code !== "ENOENT") throw error; } + await writeOrgPluginsFolder([]); + try { await writeSkipApprovals([]); } catch { /* ignore */ } return NextResponse.json({ success: true, message: "Cowork config reset" }); } catch (error) { console.log("Error resetting cowork settings:", error); diff --git a/src/app/api/v1/audio/voices/route.js b/src/app/api/v1/audio/voices/route.js new file mode 100644 index 00000000..69d37bd9 --- /dev/null +++ b/src/app/api/v1/audio/voices/route.js @@ -0,0 +1,68 @@ +import { AI_PROVIDERS } from "@/shared/constants/providers"; + +// Provider → internal voices API. Edge/local-device share the generic endpoint. +const PROVIDER_API = { + elevenlabs: (origin) => `${origin}/api/media-providers/tts/elevenlabs/voices`, + deepgram: (origin) => `${origin}/api/media-providers/tts/deepgram/voices`, + inworld: (origin) => `${origin}/api/media-providers/tts/inworld/voices`, + "edge-tts": (origin) => `${origin}/api/media-providers/tts/voices?provider=edge-tts`, + "local-device": (origin) => `${origin}/api/media-providers/tts/voices?provider=local-device`, +}; + +export async function OPTIONS() { + return new Response(null, { + headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" }, + }); +} + +// GET /v1/audio/voices?provider={p}[&lang=xx] +// Returns OpenAI-style list with each voice's full model id ready for /v1/audio/speech +export async function GET(request) { + try { + const { searchParams, origin } = new URL(request.url); + const provider = searchParams.get("provider"); + const lang = searchParams.get("lang"); + + if (!provider || !PROVIDER_API[provider]) { + return Response.json( + { error: { message: `provider must be one of: ${Object.keys(PROVIDER_API).join(", ")}`, type: "invalid_request_error" } }, + { status: 400, headers: { "Access-Control-Allow-Origin": "*" } }, + ); + } + + const baseUrl = PROVIDER_API[provider](origin); + const url = lang ? `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}lang=${encodeURIComponent(lang)}` : baseUrl; + const res = await fetch(url, { cache: "no-store" }); + const data = await res.json(); + if (!res.ok || data.error) { + return Response.json( + { error: { message: data.error || `Upstream ${res.status}`, type: "server_error" } }, + { status: res.status, headers: { "Access-Control-Allow-Origin": "*" } }, + ); + } + + // Internal API shape: { voices } when lang filter, else { byLang, languages } + const rawVoices = lang + ? (data.voices || []) + : Object.values(data.byLang || {}).flatMap((l) => l.voices || []); + + // Use provider alias for /v1/audio/speech model param (matches skill convention e.g. el/, dg/, edge-tts/) + const alias = AI_PROVIDERS[provider]?.alias || provider; + const data_out = rawVoices.map((v) => ({ + id: v.id, + name: v.name, + lang: v.lang || "", + gender: v.gender || "", + model: `${alias}/${v.id}`, + })); + + return Response.json({ object: "list", data: data_out }, { + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } catch (err) { + return Response.json( + { error: { message: err.message || "Failed", type: "server_error" } }, + { status: 502, headers: { "Access-Control-Allow-Origin": "*" } }, + ); + } +} diff --git a/src/app/api/v1/models/info/route.js b/src/app/api/v1/models/info/route.js new file mode 100644 index 00000000..1993af96 --- /dev/null +++ b/src/app/api/v1/models/info/route.js @@ -0,0 +1,110 @@ +import { PROVIDER_MODELS } from "open-sse/config/providerModels.js"; +import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers"; + +const KIND_ENDPOINT = { + llm: "/v1/chat/completions", + image: "/v1/images/generations", + tts: "/v1/audio/speech", + stt: "/v1/audio/transcriptions", + embedding: "/v1/embeddings", + imageToText: "/v1/chat/completions", + webSearch: "/v1/search", + webFetch: "/v1/fetch", +}; + +const TTS_VOICES_API = new Set(["elevenlabs", "edge-tts", "deepgram", "inworld", "local-device"]); + +function buildInfo({ alias, providerId, model, kind, providerInfo }) { + const out = { + id: `${alias}/${model.id}`, + name: model.name || model.id, + kind, + owned_by: alias, + endpoint: KIND_ENDPOINT[kind] || null, + }; + if (model.params) out.params = model.params; + if (model.capabilities) out.capabilities = model.capabilities; + if (model.options) out.options = model.options; + if (model.dimensions) out.dimensions = model.dimensions; + if (model.contextWindow) out.contextWindow = model.contextWindow; + if (kind === "tts" && TTS_VOICES_API.has(providerId)) { + out.voicesUrl = `/v1/audio/voices?provider=${providerId}`; + } + if (kind === "webSearch" && providerInfo?.searchConfig) { + const cfg = providerInfo.searchConfig; + if (cfg.searchTypes) out.searchTypes = cfg.searchTypes; + if (cfg.maxMaxResults) out.maxResults = cfg.maxMaxResults; + if (cfg.requiredOptions) out.required = cfg.requiredOptions; + } + return out; +} + +// id format: "{alias}/{modelId}" - alias may also be providerId +function lookup(fullId) { + if (!fullId || !fullId.includes("/")) return null; + const slash = fullId.indexOf("/"); + const alias = fullId.slice(0, slash); + const modelId = fullId.slice(slash + 1); + const providerId = ALIAS_TO_ID[alias] || alias; + const providerInfo = AI_PROVIDERS[providerId]; + + // PROVIDER_MODELS lookup (by alias key, fallback to providerId) + const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || []; + const m = list.find((x) => x.id === modelId); + if (m) { + const kind = m.type || "llm"; + return buildInfo({ alias, providerId, model: m, kind, providerInfo }); + } + + // Sub-configs (TTS/STT/embedding only-in-config) + const subs = [ + ["tts", providerInfo?.ttsConfig], + ["stt", providerInfo?.sttConfig], + ["embedding", providerInfo?.embeddingConfig], + ]; + for (const [kind, cfg] of subs) { + const sm = cfg?.models?.find((x) => x.id === modelId); + if (sm) return buildInfo({ alias, providerId, model: sm, kind, providerInfo }); + } + + // Web search/fetch — virtual model id "search" / "fetch" + if (modelId === "search" && providerInfo?.searchConfig) { + return buildInfo({ + alias, providerId, kind: "webSearch", providerInfo, + model: { id: "search", name: `${providerInfo.name} Search`, params: ["query", "max_results", "country", "language", "time_range", "domain_filter", "search_type"] }, + }); + } + if (modelId === "fetch" && providerInfo?.fetchConfig) { + return buildInfo({ + alias, providerId, kind: "webFetch", providerInfo, + model: { id: "fetch", name: `${providerInfo.name} Fetch`, params: ["url", "format", "max_characters"] }, + }); + } + return null; +} + +export async function OPTIONS() { + return new Response(null, { + headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS" }, + }); +} + +// GET /v1/models/info?id={alias}/{modelId} — metadata for a single model +export async function GET(request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + if (!id) { + return Response.json( + { error: { message: "Missing required query param: id (e.g. ?id=openai/dall-e-3)", type: "invalid_request_error" } }, + { status: 400, headers: { "Access-Control-Allow-Origin": "*" } }, + ); + } + const info = lookup(id); + if (!info) { + return Response.json( + { error: { message: `Model not found: ${id}`, type: "not_found" } }, + { status: 404, headers: { "Access-Control-Allow-Origin": "*" } }, + ); + } + return Response.json(info, { headers: { "Access-Control-Allow-Origin": "*" } }); +} diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index a08c8976..89b03299 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -6,6 +6,7 @@ import { isOpenAICompatibleProvider, } from "@/shared/constants/providers"; import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; +import { getDisabledModels } from "@/lib/disabledModelsDb"; const parseOpenAIStyleModels = (data) => { if (Array.isArray(data)) return data; @@ -151,6 +152,14 @@ export async function buildModelsList(kindFilter) { console.log("Could not fetch model aliases"); } + let disabledByAlias = {}; + try { + disabledByAlias = await getDisabledModels(); + } catch (e) { + console.log("Could not fetch disabled models"); + } + const isDisabled = (alias, modelId) => Array.isArray(disabledByAlias[alias]) && disabledByAlias[alias].includes(modelId); + const activeConnectionByProvider = new Map(); for (const conn of connections) { if (!activeConnectionByProvider.has(conn.provider)) { @@ -159,7 +168,6 @@ export async function buildModelsList(kindFilter) { } const models = []; - const timestamp = Math.floor(Date.now() / 1000); // Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch. for (const combo of combos) { @@ -167,7 +175,6 @@ export async function buildModelsList(kindFilter) { const entry = { id: combo.name, object: "model", - created: timestamp, owned_by: "combo", }; if (combo.kind === "webSearch" || combo.kind === "webFetch") { @@ -186,10 +193,10 @@ export async function buildModelsList(kindFilter) { if (!providerMatchesKinds(providerId, kindFilter)) continue; for (const model of providerModels) { if (!kindFilter.includes(modelKind(model))) continue; + if (isDisabled(alias, model.id)) continue; models.push({ id: `${alias}/${model.id}`, object: "model", - created: timestamp, owned_by: alias, }); } @@ -208,7 +215,6 @@ export async function buildModelsList(kindFilter) { models.push({ id: `${providerAlias}/${modelId}`, object: "model", - created: timestamp, owned_by: providerAlias, }); } @@ -301,11 +307,11 @@ export async function buildModelsList(kindFilter) { // Resolve kind: prefer static metadata, otherwise infer from ID heuristics const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId); if (!kindFilter.includes(kind)) continue; + if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue; models.push({ id: `${outputAlias}/${modelId}`, object: "model", - created: timestamp, owned_by: outputAlias, }); } @@ -324,10 +330,10 @@ export async function buildModelsList(kindFilter) { } } for (const subId of subConfigModels) { + if (isDisabled(outputAlias, subId) || isDisabled(staticAlias, subId)) continue; models.push({ id: `${outputAlias}/${subId}`, object: "model", - created: timestamp, owned_by: outputAlias, }); } @@ -338,7 +344,6 @@ export async function buildModelsList(kindFilter) { id: `${outputAlias}/search`, object: "model", kind: "webSearch", - created: timestamp, owned_by: outputAlias, }); } @@ -347,7 +352,6 @@ export async function buildModelsList(kindFilter) { id: `${outputAlias}/fetch`, object: "model", kind: "webFetch", - created: timestamp, owned_by: outputAlias, }); } diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js new file mode 100644 index 00000000..a6719301 --- /dev/null +++ b/src/shared/components/ComboFormModal.js @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Modal from "./Modal"; +import Input from "./Input"; +import Button from "./Button"; +import ModelSelectModal from "./ModelSelectModal"; + +const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; + +// Inline editable model item +function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(model); + const commit = () => { + const trimmed = draft.trim(); + if (trimmed && trimmed !== model) onEdit(trimmed); + else setDraft(model); + setEditing(false); + }; + const handleKeyDown = (e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { setDraft(model); setEditing(false); } + }; + return ( +
+ {index + 1} + {editing ? ( + setDraft(e.target.value)} onBlur={commit} onKeyDown={handleKeyDown} + className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" /> + ) : ( +
setEditing(true)} title="Click to edit">{model}
+ )} +
+ + +
+ +
+ ); +} + +// Reusable Combo create/edit modal. forcePrefix auto-prepends to name. +export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null, forcePrefix = "", title }) { + // Strip prefix when editing existing combo so user only edits suffix + const initialName = combo?.name + ? (forcePrefix && combo.name.startsWith(forcePrefix) ? combo.name.slice(forcePrefix.length) : combo.name) + : ""; + const [name, setName] = useState(initialName); + const [models, setModels] = useState(combo?.models || []); + const [showModelSelect, setShowModelSelect] = useState(false); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(""); + const [modelAliases, setModelAliases] = useState({}); + + useEffect(() => { + if (!isOpen) return; + fetch("/api/models/alias").then((r) => r.ok ? r.json() : null).then((d) => d && setModelAliases(d.aliases || {})).catch(() => {}); + }, [isOpen]); + + const validateName = (value) => { + if (!value.trim()) { setNameError("Name is required"); return false; } + const full = forcePrefix + value; + if (!VALID_NAME_REGEX.test(full)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; } + setNameError(""); + return true; + }; + + const handleNameChange = (e) => { + let value = e.target.value; + // If user types prefix manually, strip it (we always prepend) + if (forcePrefix && value.startsWith(forcePrefix)) value = value.slice(forcePrefix.length); + setName(value); + if (value) validateName(value); else setNameError(""); + }; + + const handleAddModel = (model) => { + if (!models.includes(model.value)) setModels([...models, model.value]); + }; + const handleRemoveModel = (i) => setModels(models.filter((_, idx) => idx !== i)); + const handleMoveUp = (i) => { + if (i === 0) return; + const a = [...models]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; setModels(a); + }; + const handleMoveDown = (i) => { + if (i === models.length - 1) return; + const a = [...models]; [a[i], a[i + 1]] = [a[i + 1], a[i]]; setModels(a); + }; + + const handleSave = async () => { + if (!validateName(name)) return; + setSaving(true); + await onSave({ name: forcePrefix + name.trim(), models }); + setSaving(false); + }; + + const isEdit = !!combo; + + return ( + <> + +
+
+ {forcePrefix ? ( + <> + +
+ {forcePrefix} + +
+ {nameError &&

{nameError}

} + + ) : ( + + )} +

+ {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed +

+
+ +
+ + {models.length === 0 ? ( +
+ layers +

No models added yet

+
+ ) : ( +
+ {models.map((model, index) => ( + { const a = [...models]; a[index] = v; setModels(a); }} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemoveModel(index)} /> + ))} +
+ )} + +
+ +
+ + +
+
+
+ + setShowModelSelect(false)} + onSelect={handleAddModel} activeProviders={activeProviders} modelAliases={modelAliases} + title="Add Model to Combo" kindFilter={kindFilter} /> + + ); +} diff --git a/src/shared/components/index.js b/src/shared/components/index.js index 4ab0118c..b7e7dcc2 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -16,6 +16,7 @@ export { default as Footer } from "./Footer"; 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 UsageStats } from "./UsageStats"; export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as NineRemoteButton } from "./NineRemoteButton"; diff --git a/src/shared/constants/coworkPlugins.js b/src/shared/constants/coworkPlugins.js new file mode 100644 index 00000000..b67d1ab7 --- /dev/null +++ b/src/shared/constants/coworkPlugins.js @@ -0,0 +1,955 @@ +// Cowork plugins extracted from anthropics/knowledge-work-plugins marketplace. +// Used to inject managedMcpServers into Claude Cowork (3p mode) configLibrary entries. + +const COWORK_PLUGINS = [ + { + "name": "tavily", + "description": "Tavily - Real-time web search API optimized for LLM agents. Search and extract content from the web.", + "servers": [ + { + "key": "tavily", + "url": "https://mcp.tavily.com/mcp", + "type": "http" + } + ] + }, + { + "name": "lseg", + "description": "Price bonds, analyze yield curves, evaluate FX carry trades, value options, and build macro dashboards using LSEG financial data and analytics.", + "servers": [ + { + "key": "lseg", + "url": "https://api.analytics.lseg.com/lfa/mcp", + "type": "http" + } + ] + }, + { + "name": "sp-global", + "description": "S&P Global - Financial data and analytics skills including company tearsheets, earnings previews, and transaction summaries", + "servers": [ + { + "key": "spglobal", + "url": "https://kfinance.kensho.com/integrations/mcp", + "type": "http" + } + ] + }, + { + "name": "adobe-for-creativity", + "description": "Brings together Adobe Creative Cloud tools for images, vectors, design, and video. Edit multiple assets at once, adapt for different platforms, and complete multi-step creative workflows for polished ", + "servers": [ + { + "key": "Adobe for creativity", + "url": "https://adobe-creativity.adobe.io/mcp", + "type": "http" + } + ] + }, + { + "name": "figma", + "description": "Figma design platform integration. Access design files, extract component information, read design tokens, and translate designs into code. Bridge the gap between design and development workflows.", + "servers": [ + { + "key": "figma", + "url": "https://mcp.figma.com/mcp", + "type": "http" + } + ] + }, + { + "name": "atlan", + "description": "Atlan data catalog plugin for Claude Code. Search, explore, govern, and manage your data assets through natural language. Powered by the Atlan MCP server with semantic search, lineage traversal, gloss", + "servers": [ + { + "key": "atlan", + "url": "https://mcp.atlan.com/mcp", + "type": "http" + } + ] + }, + { + "name": "cloudinary", + "description": "Use Cloudinary directly in Claude. Manage assets, apply transformations, optimize media, and more through natural conversation.", + "servers": [ + { + "key": "cloudinary-asset-mgmt", + "url": "https://asset-management.mcp.cloudinary.com/mcp", + "type": "http" + }, + { + "key": "cloudinary-env-config", + "url": "https://environment-config.mcp.cloudinary.com/mcp", + "type": "http" + }, + { + "key": "cloudinary-smd", + "url": "https://structured-metadata.mcp.cloudinary.com/mcp", + "type": "http" + }, + { + "key": "cloudinary-analysis", + "url": "https://analysis.mcp.cloudinary.com/sse", + "type": "http" + }, + { + "key": "cloudinary-mediaflows", + "url": "https://mediaflows.mcp.cloudinary.com/v2/mcp", + "type": "http" + } + ] + }, + { + "name": "prisma", + "description": "Prisma MCP integration for Postgres database management, schema migrations, SQL queries, and connection string management. Provision Prisma Postgres databases, run migrations, and interact with your d", + "servers": [ + { + "key": "Prisma-Remote", + "url": "https://mcp.prisma.io/mcp", + "type": "http" + } + ] + }, + { + "name": "cockroachdb", + "description": "CockroachDB plugin for Claude Code — explore schemas, write optimized SQL, debug queries, and manage distributed database clusters directly from your AI coding agent.", + "servers": [ + { + "key": "cockroachdb-toolbox-http", + "url": "http://127.0.0.1:5000/mcp", + "type": "http" + }, + { + "key": "cockroachdb-cloud", + "url": "https://cockroachlabs.cloud/mcp", + "type": "http" + } + ] + }, + { + "name": "daloopa", + "description": "Financial analysis skills powered by Daloopa's institutional-grade data", + "servers": [ + { + "key": "daloopa", + "url": "https://mcp.daloopa.com/server/mcp", + "type": "http" + }, + { + "key": "daloopa-docs", + "url": "https://docs.daloopa.com/mcp", + "type": "http" + } + ] + }, + { + "name": "intercom", + "description": "Intercom integration for Claude Code. Search conversations, analyze customer support patterns, look up contacts and companies, and install the Intercom Messenger. Connect your Intercom workspace to ge", + "servers": [ + { + "key": "intercom", + "url": "https://mcp.intercom.com/mcp", + "type": "http" + } + ] + }, + { + "name": "zoominfo", + "description": "Search companies and contacts, enrich leads, find lookalikes, and get AI-ranked contact recommendations. Pre-built skills chain multiple ZoomInfo tools into complete B2B sales workflows.", + "servers": [ + { + "key": "zoominfo", + "url": "https://mcp.zoominfo.com/mcp", + "type": "http" + } + ] + }, + { + "name": "sanity-plugin", + "description": "Sanity content platform integration with MCP server, agent skills, and slash commands. Query and author content, build and optimize GROQ queries, design schemas, and set up Visual Editing.", + "servers": [ + { + "key": "Sanity", + "url": "https://mcp.sanity.io", + "type": "http" + } + ] + }, + { + "name": "adspirer-ads-agent", + "description": "Cross-platform ad management for Google Ads, Meta Ads, TikTok Ads, and LinkedIn Ads. 91 tools for keyword research, campaign creation, performance analysis, and budget optimization.", + "servers": [ + { + "key": "adspirer", + "url": "https://mcp.adspirer.com/mcp", + "type": "http" + } + ] + }, + { + "name": "planetscale", + "description": "An authenticated hosted MCP server that accesses your PlanetScale organizations, databases, branches, schema, and Insights data. Query against your data, surface slow queries, and get organizational a", + "servers": [ + { + "key": "planetscale", + "url": "https://mcp.pscale.dev/mcp/planetscale", + "type": "http" + } + ] + }, + { + "name": "miro", + "description": "Secure access to Miro boards. Enables AI to read board context, create diagrams, and generate code with enterprise-grade security.", + "servers": [ + { + "key": "miro", + "url": "https://mcp.miro.com/", + "type": "http" + } + ] + }, + { + "name": "zoom-plugin", + "description": "Plan, build, and debug Zoom integrations across REST APIs, Meeting SDK, Video SDK, webhooks, bots, and MCP workflows. Search meetings, retrieve recordings, access transcripts, and design AI-powered Zo", + "servers": [ + { + "key": "zoom-mcp", + "url": "https://mcp-us.zoom.us/mcp/zoom/streamable", + "type": "http" + }, + { + "key": "zoom-docs-mcp", + "url": "https://mcp.zoom.us/mcp/docs/streamable", + "type": "http" + }, + { + "key": "zoom-whiteboard-mcp", + "url": "https://mcp-us.zoom.us/mcp/whiteboard/streamable", + "type": "http" + } + ] + }, + { + "name": "bigdata-com", + "description": "Official Bigdata.com plugin providing financial research, analytics, and intelligence tools powered by Bigdata MCP.", + "servers": [ + { + "key": "bigdata.com", + "url": "https://mcp.bigdata.com", + "type": "http" + } + ] + }, + { + "name": "operations", + "description": "Optimize business operations — vendor management, process documentation, change management, capacity planning, and compliance tracking. Keep your organization running efficiently.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "asana", + "url": "https://mcp.asana.com/v2/mcp", + "type": "http" + }, + { + "key": "servicenow", + "url": "https://mcp.servicenow.com/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + } + ] + }, + { + "name": "brand-voice", + "description": "Discover your brand voice from existing documents and conversations, generate enforceable guidelines, and validate AI-generated content against your established tone and positioning.", + "servers": [ + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "box", + "url": "https://mcp.box.com", + "type": "http" + }, + { + "key": "figma", + "url": "https://mcp.figma.com/mcp", + "type": "http" + }, + { + "key": "gong", + "url": "https://mcp.gong.io/mcp", + "type": "http" + }, + { + "key": "microsoft-365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + }, + { + "key": "granola", + "url": "https://mcp.granola.ai/mcp", + "type": "http" + } + ] + }, + { + "name": "human-resources", + "description": "Streamline people operations — recruiting, onboarding, performance reviews, compensation analysis, and policy guidance. Maintain compliance and keep your team running smoothly.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + } + ] + }, + { + "name": "design", + "description": "Accelerate design workflows — critique, design system management, UX writing, accessibility audits, research synthesis, and dev handoff. From exploration to pixel-perfect specs.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "figma", + "url": "https://mcp.figma.com/mcp", + "type": "http" + }, + { + "key": "linear", + "url": "https://mcp.linear.app/mcp", + "type": "http" + }, + { + "key": "asana", + "url": "https://mcp.asana.com/v2/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "intercom", + "url": "https://mcp.intercom.com/mcp", + "type": "http" + } + ] + }, + { + "name": "engineering", + "description": "Streamline engineering workflows — standups, code review, architecture decisions, incident response, and technical documentation. Works with your existing tools or standalone.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "linear", + "url": "https://mcp.linear.app/mcp", + "type": "http" + }, + { + "key": "asana", + "url": "https://mcp.asana.com/v2/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "github", + "url": "https://api.githubcopilot.com/mcp/", + "type": "http" + }, + { + "key": "pagerduty", + "url": "https://mcp.pagerduty.com/mcp", + "type": "http" + }, + { + "key": "datadog", + "url": "https://mcp.datadoghq.com/mcp", + "type": "http" + } + ] + }, + { + "name": "common-room", + "description": "Turn Common Room into your GTM copilot. Research accounts and contacts, prep for calls with attendee profiles and talking points, and draft personalized outreach across email, LinkedIn, and phone.", + "servers": [ + { + "key": "common-room", + "url": "https://mcp.commonroom.io/mcp", + "type": "http" + } + ] + }, + { + "name": "apollo", + "description": "Prospect, enrich leads, and load outreach sequences with Apollo.io — one-click MCP server integration for Claude Code and Cowork.", + "servers": [ + { + "key": "apollo", + "url": "https://mcp.apollo.io/mcp", + "type": "http" + } + ] + }, + { + "name": "slack-by-salesforce", + "description": "Slack integration for searching messages, sending communications, managing canvases, and more", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + } + ] + }, + { + "name": "bio-research", + "description": "Connect to preclinical research tools and databases (literature search, genomics analysis, target prioritization) to accelerate early-stage life sciences R&D", + "servers": [ + { + "key": "pubmed", + "url": "https://pubmed.mcp.claude.com/mcp", + "type": "http" + }, + { + "key": "biorender", + "url": "https://mcp.services.biorender.com/mcp", + "type": "http" + }, + { + "key": "biorxiv", + "url": "https://mcp.deepsense.ai/biorxiv/mcp", + "type": "http" + }, + { + "key": "c-trials", + "url": "https://mcp.deepsense.ai/clinical_trials/mcp", + "type": "http" + }, + { + "key": "chembl", + "url": "https://mcp.deepsense.ai/chembl/mcp", + "type": "http" + }, + { + "key": "synapse", + "url": "https://mcp.synapse.org/mcp", + "type": "http" + }, + { + "key": "wiley", + "url": "https://connector.scholargateway.ai/mcp", + "type": "http" + }, + { + "key": "owkin", + "url": "https://mcp.k.owkin.com/mcp", + "type": "http" + }, + { + "key": "ot", + "url": "https://mcp.platform.opentargets.org/mcp", + "type": "http" + } + ] + }, + { + "name": "sales", + "description": "Prospect, craft outreach, and build deal strategy faster. Prep for calls, manage your pipeline, and write personalized messaging that moves deals forward.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "hubspot", + "url": "https://mcp.hubspot.com/anthropic", + "type": "http" + }, + { + "key": "close", + "url": "https://mcp.close.com/mcp", + "type": "http" + }, + { + "key": "clay", + "url": "https://api.clay.com/v3/mcp", + "type": "http" + }, + { + "key": "zoominfo", + "url": "https://mcp.zoominfo.com/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "fireflies", + "url": "https://api.fireflies.ai/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + }, + { + "key": "apollo", + "url": "https://api.apollo.io/mcp", + "type": "http" + }, + { + "key": "outreach", + "url": "https://mcp.outreach.io/mcp", + "type": "http" + }, + { + "key": "similarweb", + "url": "https://mcp.similarweb.com/mcp", + "type": "http" + } + ] + }, + { + "name": "legal", + "description": "Speed up contract review, NDA triage, and compliance workflows for in-house legal teams. Draft legal briefs, organize precedent research, and manage institutional knowledge.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "box", + "url": "https://mcp.box.com", + "type": "http" + }, + { + "key": "egnyte", + "url": "https://mcp-server.egnyte.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + }, + { + "key": "docusign", + "url": "https://mcp.docusign.com/mcp", + "type": "http" + } + ] + }, + { + "name": "product-management", + "description": "Write feature specs, plan roadmaps, and synthesize user research faster. Keep stakeholders updated and stay ahead of the competitive landscape.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "linear", + "url": "https://mcp.linear.app/mcp", + "type": "http" + }, + { + "key": "asana", + "url": "https://mcp.asana.com/v2/mcp", + "type": "http" + }, + { + "key": "monday", + "url": "https://mcp.monday.com/mcp", + "type": "http" + }, + { + "key": "clickup", + "url": "https://mcp.clickup.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "figma", + "url": "https://mcp.figma.com/mcp", + "type": "http" + }, + { + "key": "amplitude", + "url": "https://mcp.amplitude.com/mcp", + "type": "http" + }, + { + "key": "amplitude-eu", + "url": "https://mcp.eu.amplitude.com/mcp", + "type": "http" + }, + { + "key": "pendo", + "url": "https://app.pendo.io/mcp/v0/shttp", + "type": "http" + }, + { + "key": "intercom", + "url": "https://mcp.intercom.com/mcp", + "type": "http" + }, + { + "key": "fireflies", + "url": "https://api.fireflies.ai/mcp", + "type": "http" + }, + { + "key": "similarweb", + "url": "https://mcp.similarweb.com/mcp", + "type": "http" + } + ] + }, + { + "name": "productivity", + "description": "Manage tasks, plan your day, and build up memory of important context about your work. Syncs with your calendar, email, and chat to keep everything organized and on track.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "asana", + "url": "https://mcp.asana.com/v2/mcp", + "type": "http" + }, + { + "key": "linear", + "url": "https://mcp.linear.app/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + }, + { + "key": "monday", + "url": "https://mcp.monday.com/mcp", + "type": "http" + }, + { + "key": "clickup", + "url": "https://mcp.clickup.com/mcp", + "type": "http" + } + ] + }, + { + "name": "marketing", + "description": "Create content, plan campaigns, and analyze performance across marketing channels. Maintain brand voice consistency, track competitors, and report on what's working.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "canva", + "url": "https://mcp.canva.com/mcp", + "type": "http" + }, + { + "key": "figma", + "url": "https://mcp.figma.com/mcp", + "type": "http" + }, + { + "key": "hubspot", + "url": "https://mcp.hubspot.com/anthropic", + "type": "http" + }, + { + "key": "amplitude", + "url": "https://mcp.amplitude.com/mcp", + "type": "http" + }, + { + "key": "amplitude-eu", + "url": "https://mcp.eu.amplitude.com/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "ahrefs", + "url": "https://api.ahrefs.com/mcp/mcp", + "type": "http" + }, + { + "key": "similarweb", + "url": "https://mcp.similarweb.com", + "type": "http" + }, + { + "key": "klaviyo", + "url": "https://mcp.klaviyo.com/mcp", + "type": "http" + }, + { + "key": "supermetrics", + "url": "https://mcp.supermetrics.com/mcp", + "type": "http" + } + ] + }, + { + "name": "finance", + "description": "Streamline finance and accounting workflows, from journal entries and reconciliation to financial statements and variance analysis. Speed up audit prep, month-end close, and keeping your books clean.", + "servers": [ + { + "key": "bigquery", + "url": "https://bigquery.googleapis.com/mcp", + "type": "http" + }, + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + } + ] + }, + { + "name": "enterprise-search", + "description": "Search across all of your company's tools in one place. Find anything across email, chat, documents, and wikis without switching between apps.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "guru", + "url": "https://mcp.api.getguru.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "asana", + "url": "https://mcp.asana.com/v2/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + } + ] + }, + { + "name": "data", + "description": "Write SQL, explore datasets, and generate insights faster. Build visualizations and dashboards, and turn raw data into clear stories for stakeholders.", + "servers": [ + { + "key": "bigquery", + "url": "https://bigquery.googleapis.com/mcp", + "type": "http" + }, + { + "key": "hex", + "url": "https://app.hex.tech/mcp", + "type": "http" + }, + { + "key": "amplitude", + "url": "https://mcp.amplitude.com/mcp", + "type": "http" + }, + { + "key": "amplitude-eu", + "url": "https://mcp.eu.amplitude.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "definite", + "url": "https://api.definite.app/v3/mcp/http", + "type": "http" + } + ] + }, + { + "name": "customer-support", + "description": "Triage tickets, draft responses, escalate issues, and build your knowledge base. Research customer context and turn resolved issues into self-service content.", + "servers": [ + { + "key": "slack", + "url": "https://mcp.slack.com/mcp", + "type": "http" + }, + { + "key": "intercom", + "url": "https://mcp.intercom.com/mcp", + "type": "http" + }, + { + "key": "hubspot", + "url": "https://mcp.hubspot.com/anthropic", + "type": "http" + }, + { + "key": "guru", + "url": "https://mcp.api.getguru.com/mcp", + "type": "http" + }, + { + "key": "atlassian", + "url": "https://mcp.atlassian.com/v1/mcp", + "type": "http" + }, + { + "key": "notion", + "url": "https://mcp.notion.com/mcp", + "type": "http" + }, + { + "key": "ms365", + "url": "https://microsoft365.mcp.claude.com/mcp", + "type": "http" + } + ] + } +]; + +// Build managedMcpServers ARRAY (Anthropic schema) from selected plugin names. +// Schema: [{name, url, transport: "http"|"sse", oauth?: true}] +// Most enterprise SaaS MCPs require OAuth → enable PKCE auto-flow. +function buildManagedMcpServers(selectedPluginNames) { + const set = new Set(selectedPluginNames || []); + const out = []; + for (const p of COWORK_PLUGINS) { + if (!set.has(p.name)) continue; + for (const s of p.servers) { + const name = p.servers.length === 1 ? p.name : `${p.name}-${s.key}`; + const transport = /\/sse(\b|\/)/i.test(s.url) ? "sse" : "http"; + out.push({ name, url: s.url, transport, oauth: true }); + } + } + return out; +} + +module.exports = { COWORK_PLUGINS, buildManagedMcpServers }; diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index bf5877dd..5c75f31d 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -41,6 +41,8 @@ const g = global.__appSingleton ??= { lastNetworkFingerprint: null, lastWatchdogTick: Date.now(), mitmStartInProgress: false, + tunnelAutoResumed: false, + tailscaleAutoResumed: false, }; export async function initializeApp() { @@ -48,14 +50,16 @@ export async function initializeApp() { await cleanupProviderConnections(); const settings = await getSettings(); - // Auto-resume tunnel - if (settings.tunnelEnabled) { + // Auto-resume tunnel (once per process) + if (settings.tunnelEnabled && !g.tunnelAutoResumed) { + g.tunnelAutoResumed = true; console.log("[InitApp] Tunnel was enabled, auto-resuming..."); safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message)); } - // Auto-resume tailscale - if (settings.tailscaleEnabled) { + // Auto-resume tailscale (once per process) + if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) { + g.tailscaleAutoResumed = true; console.log("[InitApp] Tailscale was enabled, auto-resuming..."); safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message)); }