diff --git a/README.md b/README.md index b32a299d..72547b42 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Under the hood: Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box: -**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**. +**OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**. A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk. diff --git a/coworker/mcp/client.py b/coworker/mcp/client.py index 04f8fcc0..ad343e36 100644 --- a/coworker/mcp/client.py +++ b/coworker/mcp/client.py @@ -13,8 +13,9 @@ Tool execution from the (sync) ToolRegistry bridges back here via from __future__ import annotations import asyncio +import tempfile from contextlib import AsyncExitStack -from typing import Any, Optional +from typing import Any, IO, Optional from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -23,6 +24,25 @@ from mcp.client.streamable_http import streamablehttp_client from .config import MCPServerDef +_STDERR_TAIL_LINES = 20 +_STDERR_TAIL_CHARS = 1500 + + +def _read_tail(errfile: Optional[IO[str]]) -> Optional[str]: + """Last few lines of a captured stderr file — the crash evidence, not the log.""" + if errfile is None: + return None + try: + errfile.seek(0) + text = errfile.read() + except (OSError, ValueError): + return None + lines = [ln for ln in text.strip().splitlines() if ln.strip()] + if not lines: + return None + return "\n".join(lines[-_STDERR_TAIL_LINES:])[-_STDERR_TAIL_CHARS:] + + class _Conn: def __init__(self, session: ClientSession, tools: list[Any]) -> None: self.session = session @@ -36,6 +56,7 @@ class MCPManager: def __init__(self, secrets: Any = None) -> None: self._conns: dict[str, _Conn] = {} self._tasks: dict[str, asyncio.Task] = {} + self._stderr_tails: dict[str, str] = {} self._lock = asyncio.Lock() # SecretStore for OAuth servers' token persistence (mcp/oauth.py); lazy default # so library/CLI construction without secrets keeps working. @@ -64,6 +85,10 @@ class MCPManager: async def tools(self, server: MCPServerDef) -> list[Any]: return (await self.ensure(server)).tools + def last_stderr(self, name: str) -> Optional[str]: + """Stderr tail from the most recent failed startup of `name`, if any.""" + return self._stderr_tails.get(name) + async def call( self, name: str, tool: str, arguments: Optional[dict[str, Any]] ) -> Any: @@ -88,6 +113,7 @@ class MCPManager: async def _serve( self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False ) -> None: + errfile = None try: async with AsyncExitStack() as stack: if server.transport == "http": @@ -124,18 +150,34 @@ class MCPManager: env=server.env or None, cwd=server.cwd, ) - read, write = await stack.enter_async_context(stdio_client(params)) + # Capture the child's stderr so a startup crash leaves evidence + # the UI can show (the SDK needs a real file descriptor here). + errfile = tempfile.TemporaryFile( + mode="w+", encoding="utf-8", errors="replace" + ) + read, write = await stack.enter_async_context( + stdio_client(params, errlog=errfile) + ) session = await stack.enter_async_context(ClientSession(read, write)) await session.initialize() listed = await session.list_tools() conn = _Conn(session, list(listed.tools)) + self._stderr_tails.pop(server.name, None) if not ready.done(): ready.set_result(conn) await conn.shutdown.wait() except Exception as exc: # connection / init failure + tail = _read_tail(errfile) + if tail: + self._stderr_tails[server.name] = tail if not ready.done(): ready.set_exception(exc) finally: + if errfile is not None: + try: + errfile.close() + except OSError: + pass self._conns.pop(server.name, None) self._tasks.pop(server.name, None) diff --git a/coworker/mcp/oauth.py b/coworker/mcp/oauth.py index c6043e69..d6bcf2af 100644 --- a/coworker/mcp/oauth.py +++ b/coworker/mcp/oauth.py @@ -113,6 +113,21 @@ def is_auth_required(exc: BaseException) -> bool: return is_auth_required(cause) if cause is not None else False +def is_http_auth_error(exc: BaseException) -> bool: + """True if an HTTP 401/403 is anywhere in the exception tree — an anonymous + connect hit a server that wants credentials, so the fix is sign-in (switch + the entry to `auth: oauth`), not a different config. Same tree walk as + is_auth_required: the transport's task groups wrap and chain freely.""" + status = getattr(getattr(exc, "response", None), "status_code", None) + if status in (401, 403): + return True + for sub in getattr(exc, "exceptions", None) or []: # ExceptionGroup + if is_http_auth_error(sub): + return True + cause = exc.__cause__ or exc.__context__ + return is_http_auth_error(cause) if cause is not None else False + + # -- single-slot interactive flow ------------------------------------------------ _pending: Optional[asyncio.Future] = None # The last authorize URL we sent the user to — surfaced over REST so the GUI can offer diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index bf6a878c..f48b6944 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -85,6 +85,21 @@ MATRIX: dict[str, ModelEntry] = { "gemini:gemini-2.5-flash": ModelEntry( "Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576 ), + # Ark Responses API providers (verified 2026-08-14). BytePlus pay-as-you-go and + # Volcengine Agent Plan intentionally use separate provider prefixes because their + # endpoints, credentials, regions, and model catalogs are not interchangeable. + "ark:dola-seed-evolving-latest-version": ModelEntry( + "Dola Seed Evolving · BytePlus Ark", context_window=256_000 + ), + "ark:dola-seed-2-1-turbo-260628": ModelEntry( + "Dola Seed 2.1 Turbo · BytePlus Ark", context_window=256_000 + ), + "ark-agent-plan-cn:doubao-seed-evolving": ModelEntry( + "Doubao Seed Evolving · Volcengine Agent Plan", context_window=256_000 + ), + "ark-agent-plan-cn:doubao-seed-2.1-turbo": ModelEntry( + "Doubao Seed 2.1 Turbo · Volcengine Agent Plan", context_window=256_000 + ), # -- direct OpenAI-compatible vendors ---------------------------------------- # Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via # their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls diff --git a/coworker/providers/openai_responses.py b/coworker/providers/openai_responses.py index 566c620b..9d34f84a 100644 --- a/coworker/providers/openai_responses.py +++ b/coworker/providers/openai_responses.py @@ -1,4 +1,4 @@ -"""OpenAI Responses provider — native OpenAI models via `/v1/responses`. +"""OpenAI Responses provider — native and compatible models via `/responses`. Chat Completions rejects function tools combined with any `reasoning_effort` other than `none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI @@ -8,9 +8,10 @@ reasoning + tools at real effort levels, streamed reasoning summaries (→ the s chain-of-thought continuity across tool round-trips via `store: false` + `include: ["reasoning.encrypted_content"]` — nothing retained server-side. -Routing: the `openai` provider entry with NO custom base_url builds this class; a custom -endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the -Chat Completions `OpenAIProvider` (registry.py). +Routing: the `openai` provider entry with NO custom base_url builds this class. Most custom +endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions +`OpenAIProvider`; vendors that explicitly implement the Responses wire can opt into this +class with their own base URL (registry.py). Like the other native providers, this is mostly a pair of pure converters from the canonical OpenAI-chat-shaped history to Responses `input` items. What the converters @@ -289,14 +290,20 @@ class OpenAIResponsesProvider(ProviderClient): default_model: str = "gpt-5.6-sol", api_key: Optional[str] = None, secrets: Any = None, + base_url: Optional[str] = None, + reasoning_summary: bool = True, ): # Same deferred-client contract as OpenAIProvider: built lazily so an engine can be # assembled before any key exists; key resolves at call time (explicit → env → - # SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint - # routes to the Chat Completions provider instead (registry.py). + # SecretStore). Tests inject a `client` directly. `base_url` is opt-in: stock OpenAI + # leaves it unset, while Responses-compatible vendors can supply their own endpoint. self._client = client self._api_key = api_key self._secrets = secrets + self._base_url = (base_url or "").strip().rstrip("/") or None + if not isinstance(reasoning_summary, bool): + raise TypeError("reasoning_summary must be a bool") + self._reasoning_summary = reasoning_summary self.default_model = default_model def _ensure_client(self) -> Any: @@ -310,7 +317,10 @@ class OpenAIResponsesProvider(ProviderClient): "No model API key configured. Set OPENAI_API_KEY in the environment, " "or add your key in Manage → Settings." ) - self._client = OpenAI(api_key=key) + kwargs = {"api_key": key} + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = OpenAI(**kwargs) return self._client def _request_kwargs( @@ -331,9 +341,10 @@ class OpenAIResponsesProvider(ProviderClient): # `_openai` sidecar instead, and summaries feed the GUI's thinking display. "store": False, "include": ["reasoning.encrypted_content"], - "reasoning": {"summary": "auto"}, **{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST}, } + if self._reasoning_summary: + kwargs["reasoning"] = {"summary": "auto"} if instructions: kwargs["instructions"] = instructions if tools: diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 6c147705..1f7aea8e 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -210,6 +210,37 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = return build +def _openai_responses_compat( + vendor: str, + default_base_url: str, + env_key: Optional[str] = None, + *, + reasoning_summary: bool = True, +): + """Builder factory for vendors that explicitly implement the OpenAI Responses API. + + Credentials stay isolated to the vendor's own profile/environment variable, matching the + Chat Completions compat path above. In particular, an OpenAI key is never sent to Ark. + """ + + def build(profile: dict[str, Any], secrets: Any) -> ProviderClient: + base_url = ((profile or {}).get("base_url") or "").strip() or default_base_url + api_key = ((profile or {}).get("api_key") or "").strip() or ( + os.environ.get(env_key, "").strip() if env_key else "" + ) + if not api_key: + raise RuntimeError( + f"No {vendor} API key configured — add it in Settings ▸ Models." + ) + return OpenAIResponsesProvider( + api_key=api_key, + base_url=base_url, + reasoning_summary=reasoning_summary, + ) + + return build + + def _compat( name: str, title: str, @@ -248,6 +279,49 @@ def _compat( ) +def _responses_compat( + name: str, + title: str, + *, + base_url: str, + recommended_model: str, + env_key: str, + endpoint_help: str = "", + reasoning_summary: bool = True, +) -> ProviderDescriptor: + """Descriptor for a vendor exposing the OpenAI Responses API.""" + return ProviderDescriptor( + name=name, + title=title, + needs_key=True, + fields=[ + ProviderField( + "api_key", + f"{title} API key", + secret=True, + ), + ProviderField( + "base_url", + "Endpoint", + required=False, + default=base_url, + placeholder=base_url, + help=endpoint_help + or f"Prefilled with {title}'s official Responses endpoint.", + ), + ], + build=_openai_responses_compat( + title, + base_url, + env_key, + reasoning_summary=reasoning_summary, + ), + recommended_model=recommended_model, + env_key=env_key, + blurb=f"Uses {title}'s OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", + ) + + DESCRIPTORS: list[ProviderDescriptor] = [ ProviderDescriptor( name="openai", @@ -462,6 +536,26 @@ DESCRIPTORS: list[ProviderDescriptor] = [ blurb="Runs models inside your own Google Cloud project. Gemini and Claude use " "their native APIs; open-weight models go through the Vertex MaaS endpoint.", ), + # Ark has two intentionally separate provider identities. BytePlus pay-as-you-go and + # Volcengine Agent Plan use different regions, endpoints, credentials, and model catalogs; + # combining them would let one provider profile route a model to the wrong service. + _responses_compat( + "ark", + "BytePlus Ark", + base_url="https://ark.ap-southeast.bytepluses.com/api/v3", + recommended_model="dola-seed-evolving-latest-version", + env_key="ARK_API_KEY", + endpoint_help="BytePlus Ark's Asia Pacific endpoint. This provider is separate from Volcengine Ark Agent Plan.", + reasoning_summary=False, + ), + _responses_compat( + "ark-agent-plan-cn", + "Volcengine Ark Agent Plan", + base_url="https://ark.cn-beijing.volces.com/api/plan/v3", + recommended_model="doubao-seed-evolving", + env_key="ARK_AGENT_PLAN_CN_API_KEY", + endpoint_help="Volcengine Ark Agent Plan's China (Beijing) endpoint. It requires an Agent Plan API key.", + ), # OpenAI-compatible vendors, listed as first-class providers so users don't need to know the # "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps # its own key profile; the endpoint is prefilled and editable (regional variants in `help`). @@ -803,9 +897,11 @@ def verify_provider_key( fields: Optional[dict[str, Any]] = None, timeout: float = 10.0, ) -> dict[str, Any]: - """Validate a provider's credentials with one cheap, read-only call (list models) — the same - pattern connectors use to validate tokens. Transient: callers pass the key directly so a user - can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers + """Validate a provider's credentials with one cheap call — usually list models. + + Ark's Responses-compatible data plane does not document a `/models` probe, so its Test button + sends a non-persisted one-token Responses request instead. Callers pass the key directly so a + user can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers (Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url. """ import httpx @@ -832,6 +928,22 @@ def verify_provider_key( elif name == "ollama": base = _normalize_ollama_url(base_url) resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout) + elif name in ("ark", "ark-agent-plan-cn"): + default_base = next( + (f.default for f in d.fields if f.key == "base_url" and f.default), "" + ) + base = (base_url or "").strip().rstrip("/") or default_base.rstrip("/") + resp = httpx.post( + base + "/responses", + headers={"Authorization": f"Bearer {key}"}, + json={ + "model": d.recommended_model, + "input": "Reply with OK.", + "max_output_tokens": 1, + "store": False, + }, + timeout=timeout, + ) else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…) default_base = next( (f.default for f in d.fields if f.key == "base_url" and f.default), "" diff --git a/coworker/server/app.py b/coworker/server/app.py index ec62a10b..8896a8e4 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -2359,6 +2359,16 @@ def create_app(manager: SessionManager) -> FastAPI: ) await ws.close() return + # MCP servers that failed to start while preparing this session's tools: + # leave a quiet, persistent notice instead of the session silently lacking + # them (drill 2026-08-20: three silent startup failures in a row). + for name, err in manager.pop_mcp_failures(session_id): + detail = f": {err}" if err else "" + engine._append_notice( + "mcp_error", + f"MCP server “{name}” failed to start{detail}"[:300] + + " — see Settings ▸ MCP", + ) # Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked # Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now). engine.is_attended = lambda: _visibility() == VIS_INLINE diff --git a/coworker/server/manager.py b/coworker/server/manager.py index d9344348..bbc71975 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -186,6 +186,12 @@ class SessionManager: # feeds list_mcp's status so the GUI can show "authorizing…" and failures. self._mcp_authorizing: set[str] = set() self._mcp_errors: dict[str, str] = {} + # http servers whose anonymous connect came back 401/403 — the failure is + # "needs sign-in", so the GUI offers the OAuth switch instead of a raw error. + self._mcp_auth_hints: set[str] = set() + # Servers that failed to connect while preparing a session's tools — + # drained once by the WS handler to append a transcript notice. + self._mcp_session_failures: dict[str, list[str]] = {} self.gateway: Optional[Gateway] = None self._data_base = base # Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file. @@ -1186,6 +1192,7 @@ class SessionManager: continue try: conn = await self.mcp.ensure(server) + self._mcp_errors.pop(server.name, None) except Exception as exc: if mcp_oauth.is_auth_required(exc): # Stored tokens no longer refresh (vendor rotated/expired @@ -1198,7 +1205,22 @@ class SessionManager: logger.info( "mcp %s needs re-auth; skipped for this session", server.name ) - # else: bad command / unreachable url — skip, don't break the session + else: + # Bad command / crashed child / unreachable url — the session + # still runs without the tools, but the failure must not be + # silent (three-for-three silent failures in the 2026-08-20 + # drill): record it for the MCP page and the session notice. + msg = str(exc) or exc.__class__.__name__ + tail = self.mcp.last_stderr(server.name) + if tail: + msg = f"{msg} — {tail}" + self._mcp_errors[server.name] = msg[:500] + logger.warning( + "mcp %s failed to connect: %s", server.name, msg[:500] + ) + self._mcp_session_failures.setdefault(session_id, []).append( + server.name + ) continue callables = build_callables( server, @@ -1217,6 +1239,12 @@ class SessionManager: out.extend(callables) return out + def pop_mcp_failures(self, session_id: str) -> list[tuple[str, Optional[str]]]: + """Drain (name, error) for servers that failed while preparing this session's + tools — consumed once by the WS handler to append a transcript notice.""" + names = self._mcp_session_failures.pop(session_id, []) + return [(n, self._mcp_errors.get(n)) for n in names] + def list_mcp(self) -> list[dict[str, Any]]: """Servers from the global config + connection status (does not connect).""" from ..mcp import oauth as mcp_oauth @@ -1240,6 +1268,12 @@ class SessionManager: status = "authorizing" elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets): status = "needs_auth" + elif name in self._mcp_errors and not is_oauth: + # Startup/connection failure (stdio crash, unreachable url) — the + # drill class. OAuth servers keep their softer statuses: acquiring + # tokens supersedes a stale sign-in error (the GUI still prints + # last_error under the row either way). + status = "error" else: status = "configured" out.append( @@ -1258,6 +1292,8 @@ class SessionManager: "requires_approval": bool(raw.get("requires_approval", True)), "auth": "oauth" if is_oauth else None, "status": status, + "auth_hint": name in self._mcp_auth_hints, + "last_test_at": self._prefs.get("mcp_last_test", {}).get(name), "last_error": self._mcp_errors.get(name), "tool_count": ( len(self.mcp._conns[name].tools) if connected else None @@ -1271,6 +1307,8 @@ class SessionManager: """Connect one server NOW — for OAuth servers this may open the browser and wait for the loopback callback, so callers run it as a background task and watch list_mcp for the status flip.""" + from ..mcp import oauth as mcp_oauth + for server in load_mcp_servers( self.default_workspace, secrets=self.secrets, @@ -1280,12 +1318,31 @@ class SessionManager: continue self._mcp_authorizing.add(name) self._mcp_errors.pop(name, None) + self._mcp_auth_hints.discard(name) try: # The ONE place a browser sign-in may start: an explicit connect. conn = await self.mcp.ensure(server, interactive=True) + # The Connectors row says "Ready · tested ⟨when⟩" — the claim must + # survive an app restart, so it lives in prefs, not memory. + self._prefs.setdefault("mcp_last_test", {})[name] = int(time.time()) + self._save_prefs() return {"ok": True, "tools": len(conn.tools)} except Exception as exc: - self._mcp_errors[name] = str(exc) or exc.__class__.__name__ + if ( + server.transport == "http" + and server.auth != "oauth" + and mcp_oauth.is_http_auth_error(exc) + ): + # Anonymous probe of a guarded server (the add-by-URL flow): + # the answer is sign-in, not a raw 401 dump. + self._mcp_auth_hints.add(name) + msg = "authentication required — sign in to connect" + else: + msg = str(exc) or exc.__class__.__name__ + tail = self.mcp.last_stderr(name) + if tail: + msg = f"{msg} — {tail}" + self._mcp_errors[name] = msg[:500] return {"ok": False, "error": self._mcp_errors[name]} finally: self._mcp_authorizing.discard(name) @@ -1349,6 +1406,13 @@ class SessionManager: def delete_mcp(self, name: str) -> dict[str, Any]: ok = delete_global_server(name) + if ok: + # A later re-add under the same name starts clean, not pre-failed — + # and not pre-trusted (the old entry's test says nothing about the new). + self._mcp_errors.pop(name, None) + self._mcp_auth_hints.discard(name) + if self._prefs.get("mcp_last_test", {}).pop(name, None) is not None: + self._save_prefs() return {"ok": ok, "name": name} async def mcp_tools(self, name: str) -> dict[str, Any]: diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 7017c4c2..b6fccb05 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -45,6 +45,10 @@ const SETTINGS = { model_labels: { "anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic", "zai:glm-5.2": "GLM-5.2 · Z AI", + "ark:dola-seed-evolving-latest-version": "Dola Seed Evolving · BytePlus Ark", + "ark:dola-seed-2-1-turbo-260628": "Dola Seed 2.1 Turbo · BytePlus Ark", + "ark-agent-plan-cn:doubao-seed-evolving": "Doubao Seed Evolving · Volcengine Agent Plan", + "ark-agent-plan-cn:doubao-seed-2.1-turbo": "Doubao Seed 2.1 Turbo · Volcengine Agent Plan", }, // Context windows (subset — mirrors /v1/settings.model_context_windows); drives the // composer usage chip's context-fill meter. @@ -339,6 +343,10 @@ const PROVIDERS = [ { name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null }, // zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb. { name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null }, + // Ark uses two provider identities: BytePlus pay-as-you-go and Volcengine Agent Plan CN + // have independent credentials, endpoints, and strict curated model lists. + { name: "ark", title: "BytePlus Ark", needs_key: true, blurb: "Uses BytePlus Ark's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "BytePlus Ark API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "BytePlus Ark's Asia Pacific endpoint.", placeholder: "https://ark.ap-southeast.bytepluses.com/api/v3", default: "https://ark.ap-southeast.bytepluses.com/api/v3" }], configured: false, values: {}, suggested_models: ["dola-seed-evolving-latest-version", "dola-seed-2-1-turbo-260628"], key_set_at: null, last_used_at: null }, + { name: "ark-agent-plan-cn", title: "Volcengine Ark Agent Plan", needs_key: true, blurb: "Uses Volcengine Ark Agent Plan's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Volcengine Ark Agent Plan API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Volcengine Ark Agent Plan's China (Beijing) endpoint.", placeholder: "https://ark.cn-beijing.volces.com/api/plan/v3", default: "https://ark.cn-beijing.volces.com/api/plan/v3" }], configured: false, values: {}, suggested_models: ["doubao-seed-evolving", "doubao-seed-2.1-turbo"], key_set_at: null, last_used_at: null }, // ollama: keyless local provider — "configured" without proving anything runs; the // onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39). { name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null }, @@ -1970,8 +1978,17 @@ export async function mockApi(page: import("@playwright/test").Page) { if (p.endsWith("/v1/mcp") && m === "GET") { for (const s2 of mcpServers) { if (s2.status === "authorizing" && s2._flip) { - s2.status = "connected"; - s2.tool_count = 6; + // Servers named locked-* simulate a guarded remote: the anonymous + // probe 401s (→ needs sign-in) until the entry is switched to oauth. + if (s2.name.startsWith("locked") && s2.auth !== "oauth") { + s2.status = "error"; + s2.auth_hint = true; + s2.last_error = "authentication required — sign in to connect"; + } else { + s2.status = "connected"; + s2.tool_count = 6; + s2.last_test_at = 1700000000; // the successful probe stamps the row + } } if (s2.status === "authorizing") s2._flip = true; } @@ -1986,6 +2003,8 @@ export async function mockApi(page: import("@playwright/test").Page) { requires_approval: true, auth: b.config?.auth === "oauth" ? "oauth" : null, status: b.config?.auth === "oauth" ? "needs_auth" : "configured", + auth_hint: false, + last_test_at: null, last_error: null, tool_count: null, config: b.config || {}, @@ -1996,7 +2015,12 @@ export async function mockApi(page: import("@playwright/test").Page) { const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/); if (mc && m === "POST") { const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1])); - if (s2) s2.status = "authorizing"; + if (s2) { + s2.status = "authorizing"; + s2.auth_hint = false; + s2.last_error = null; + s2._flip = false; + } return json({ ok: true, started: true }); } const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/); @@ -2009,6 +2033,29 @@ export async function mockApi(page: import("@playwright/test").Page) { } return json({ ok: true }); } + const mp = p.match(/\/v1\/mcp\/([^/]+)$/); + if (mp && m === "PATCH") { + const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mp[1])); + const b = req.postDataJSON() || {}; + if (s2) { + if (b.enabled !== undefined) s2.enabled = b.enabled; + if (b.auth === "oauth") { + // The needs-sign-in fix: entry switches to oauth; the follow-up + // connect runs the browser flow. + s2.auth = "oauth"; + s2.auth_hint = false; + s2.status = "needs_auth"; + } + s2.config = { ...s2.config, ...b }; + } + return json({ ok: !!s2, name: mp[1] }); + } + const md = p.match(/\/v1\/mcp\/([^/]+)$/); + if (md && m === "DELETE") { + const i = mcpServers.findIndex((x) => x.name === decodeURIComponent(md[1])); + if (i >= 0) mcpServers.splice(i, 1); + return json({ ok: i >= 0 }); + } } if (p.endsWith("/v1/unrouted")) return json([]); diff --git a/surfaces/gui/e2e/mcp-add-test.spec.ts b/surfaces/gui/e2e/mcp-add-test.spec.ts new file mode 100644 index 00000000..9f8a8e3e --- /dev/null +++ b/surfaces/gui/e2e/mcp-add-test.spec.ts @@ -0,0 +1,82 @@ +// UX-033/034: custom MCP servers live on the Connectors page. "Add custom server" +// (top of page) opens the two-tab modal (Remote URL / JSON); added entries land in +// the "Custom · MCP" group with honest status chips (Testing… → Live / Error / +// Needs sign-in / Not tested) and a detail subpage with Test. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function openConnectors(page) { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Connectors", exact: true }).click(); +} + +test("remote URL add: probe flips the row to Live with tool count", async ({ page }) => { + await openConnectors(page); + await page.getByTestId("add-custom-server").click(); + + // URL tab is the default door; bad URL is caught before anything is added. + const modal = page.getByTestId("add-mcp-modal"); + await modal.getByTestId("mcp-add-name").fill("notes"); + await modal.getByTestId("mcp-add-url").fill("mcp.example.com/mcp"); + await modal.getByRole("button", { name: "Add & test" }).click(); + await expect(modal.getByText("Enter the server's full URL")).toBeVisible(); + + await modal.getByTestId("mcp-add-url").fill("https://mcp.example.com/mcp"); + await modal.getByRole("button", { name: "Add & test" }).click(); + + const row = page.getByTestId("mcp-row-notes"); + await expect(row).toContainText("Testing…"); + await expect(row).toContainText("Live", { timeout: 10_000 }); + await expect(row).toContainText("6 tools"); +}); + +test("guarded server: 401 → Needs sign-in chip → OAuth switch on the detail page", async ({ + page, +}) => { + await openConnectors(page); + await page.getByTestId("add-custom-server").click(); + const modal = page.getByTestId("add-mcp-modal"); + await modal.getByTestId("mcp-add-name").fill("locked-crm"); + await modal.getByTestId("mcp-add-url").fill("https://mcp.locked.example/mcp"); + await modal.getByRole("button", { name: "Add & test" }).click(); + + // The anonymous probe 401s: the row says needs sign-in; the fix lives one + // click deep on the detail page (with the error excerpt). + const row = page.getByTestId("mcp-row-locked-crm"); + await expect(row).toContainText("Needs sign-in", { timeout: 10_000 }); + await row.click(); + + const detail = page.getByTestId("mcp-detail-locked-crm"); + await expect(detail).toContainText("authentication required"); + await detail.getByTestId("mcp-authfix-locked-crm").click(); + await expect(detail).toContainText("Signing in…"); + await expect(detail).toContainText("Live", { timeout: 10_000 }); +}); + +test("JSON tab adds stdio as Not tested; detail Test flips it to Live", async ({ page }) => { + await openConnectors(page); + await page.getByTestId("add-custom-server").click(); + const modal = page.getByTestId("add-mcp-modal"); + await modal.getByTestId("mcp-add-tab-json").click(); + await modal + .locator("textarea") + .fill('{"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}'); + await modal.getByRole("button", { name: "Add", exact: true }).click(); + + // A pasted stdio server is configured, not connected — the chip says so. + const row = page.getByTestId("mcp-row-files"); + await expect(row).toContainText("Not tested"); + await expect(row).toContainText("stdio"); + + await row.click(); + const detail = page.getByTestId("mcp-detail-files"); + await detail.getByTestId("mcp-test-files").click(); + await expect(detail).toContainText("Testing…"); + await expect(detail).toContainText("Live", { timeout: 10_000 }); + await expect(detail).toContainText("6 tools"); + + // Remove from the detail page returns to the list without the row. + await detail.getByTestId("mcp-remove-files").click(); + await expect(page.getByTestId("mcp-row-files")).toHaveCount(0); +}); diff --git a/surfaces/gui/e2e/mcp-oauth.spec.ts b/surfaces/gui/e2e/mcp-oauth.spec.ts index b6b3e9c3..05e615cc 100644 --- a/surfaces/gui/e2e/mcp-oauth.spec.ts +++ b/surfaces/gui/e2e/mcp-oauth.spec.ts @@ -1,20 +1,20 @@ -// MCP OAuth quick-add (first server: Granola): the MCP tab offers a curated Connect -// card; connecting adds the server, kicks off the browser sign-in ("signing in…"), -// and the tab's poll flips the row to connected. Sign out returns it to needs_auth. +// MCP OAuth quick-add (first server: Granola): the Custom · MCP group on the +// Connectors page offers a curated Connect card; connecting adds the server, kicks +// off the browser sign-in (Signing in…), and the poll flips the row to Live. +// Sign out (detail page) returns it to Needs sign-in. import { expect } from "@playwright/test"; import { test } from "./fixtures"; -async function openMcpTab(page) { +async function openConnectors(page) { await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Connectors", exact: true }).click(); - await page.getByRole("button", { name: "MCP servers", exact: true }).click(); } -test("granola: quick-add card → sign-in flow → connected → sign out", async ({ page }) => { - await openMcpTab(page); +test("granola: quick-add card → sign-in flow → Live → sign out", async ({ page }) => { + await openConnectors(page); - // Curated card renders while granola isn't configured. + // Curated card renders in the Custom · MCP group while granola isn't configured. const preset = page.getByTestId("mcp-preset-granola"); await expect(preset).toContainText("Granola"); await expect(preset).toContainText("Meeting notes"); @@ -22,16 +22,17 @@ test("granola: quick-add card → sign-in flow → connected → sign out", asyn // Connect: adds the server with OAuth pending and starts the browser flow. await preset.getByRole("button", { name: "Connect" }).click(); await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0); - const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first(); - await expect(row).toContainText("signing in…"); + const row = page.getByTestId("mcp-row-granola"); + await expect(row).toContainText("Signing in…"); - // The 2s status poll flips the mock to connected with its 6 tools. - await expect(row).toContainText("connected", { timeout: 10_000 }); + // The status poll flips the mock to connected with its 6 tools. + await expect(row).toContainText("Live", { timeout: 10_000 }); await expect(row).toContainText("6 tools"); - await expect(row).toContainText("oauth"); - // Sign out forgets tokens; the row needs auth again and offers Sign in. - await row.getByTestId("mcp-signout-granola").click(); - await expect(row).toContainText("needs auth"); - await expect(row.getByTestId("mcp-signin-granola")).toBeVisible(); + // Sign out on the detail page forgets tokens; the chip needs sign-in again. + await row.click(); + const detail = page.getByTestId("mcp-detail-granola"); + await detail.getByTestId("mcp-signout-granola").click(); + await expect(detail).toContainText("Needs sign-in"); + await expect(detail.getByTestId("mcp-signin-granola")).toBeVisible(); }); diff --git a/surfaces/gui/e2e/settings.spec.ts b/surfaces/gui/e2e/settings.spec.ts index a405defa..2c77c59f 100644 --- a/surfaces/gui/e2e/settings.spec.ts +++ b/surfaces/gui/e2e/settings.spec.ts @@ -79,6 +79,44 @@ test("Models: provider gallery states; vendor form previews models", async ({ pa await expect(page.getByTestId("set-provider-openai")).toBeVisible(); }); +test("Models: BytePlus and Volcengine Ark stay visually and operationally separate", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models", exact: true }).click(); + + const byteplusCard = page.getByTestId("set-provider-ark"); + const volcengineCard = page.getByTestId("set-provider-ark-agent-plan-cn"); + await expect(byteplusCard).toContainText("BytePlus Ark"); + await expect(volcengineCard).toContainText("Volcengine Ark Agent Plan"); + const byteplusLogo = await byteplusCard.locator("img").getAttribute("src"); + const volcengineLogo = await volcengineCard.locator("img").getAttribute("src"); + expect(byteplusLogo).toBeTruthy(); + expect(volcengineLogo).toBeTruthy(); + expect(byteplusLogo).not.toBe(volcengineLogo); + + await byteplusCard.click(); + await page.getByTestId("set-endpoint-link").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue( + "https://ark.ap-southeast.bytepluses.com/api/v3", + ); + let preview = page.getByTestId("model-preview"); + await expect(preview).toContainText("Dola Seed Evolving · BytePlus Ark"); + await expect(preview).toContainText("Dola Seed 2.1 Turbo · BytePlus Ark"); + await expect(preview).not.toContainText("Doubao Seed"); + + await page.getByTestId("set-back").click(); + await volcengineCard.click(); + await page.getByTestId("set-endpoint-link").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue( + "https://ark.cn-beijing.volces.com/api/plan/v3", + ); + preview = page.getByTestId("model-preview"); + await expect(preview).toContainText("Doubao Seed Evolving · Volcengine Agent Plan"); + await expect(preview).toContainText("Doubao Seed 2.1 Turbo · Volcengine Agent Plan"); + await expect(preview).not.toContainText("Dola Seed"); +}); + // UX-021: a configured provider's form shows the in-field saved state and the Remove key… // affordance; removing reverts the card to "Not set up". test("Models: Remove key reverts a configured provider", async ({ page }) => { diff --git a/surfaces/gui/e2e/sidebar-account.spec.ts b/surfaces/gui/e2e/sidebar-account.spec.ts index 1a2a8246..db49128d 100644 --- a/surfaces/gui/e2e/sidebar-account.spec.ts +++ b/surfaces/gui/e2e/sidebar-account.spec.ts @@ -46,10 +46,12 @@ test("Activity in the menu is the audit log; Unrouted lives under Inbox ▸ Conf await page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click(); await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible(); - // §28: Messaging routing left the Connectors sub-nav entirely (Connectors · MCP only)… + // §28: Messaging routing left the Connectors sub-nav entirely — and the MCP tab + // retired into the Connectors page itself (UX-034), so one sub-nav item remains. await page.getByTestId("account-row").click(); await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click(); - await expect(page.getByRole("button", { name: "MCP servers" })).toBeVisible(); + await expect(page.getByTestId("add-custom-server")).toBeVisible(); + await expect(page.getByRole("button", { name: "MCP servers" })).toHaveCount(0); await expect(page.getByRole("button", { name: /Messaging routing/ })).toHaveCount(0); // The old fourth sub-nav tab is gone — exactly one page is named Activity now. await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 1384e6d2..be3f4f39 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -457,6 +457,10 @@ export interface McpServer { // "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight) status: string; auth?: "oauth" | null; + // http server whose anonymous connect hit a 401/403 — offer OAuth sign-in. + auth_hint?: boolean; + // Epoch seconds of the last successful explicit Test (persisted server-side). + last_test_at?: number | null; last_error?: string | null; tool_count: number | null; config: Record; diff --git a/surfaces/gui/src/components/IntegrationsView.tsx b/surfaces/gui/src/components/IntegrationsView.tsx index d1674b3f..ab01a129 100644 --- a/surfaces/gui/src/components/IntegrationsView.tsx +++ b/surfaces/gui/src/components/IntegrationsView.tsx @@ -1,25 +1,16 @@ import { useEffect, useState } from "react"; import { getConnectors } from "../api"; -import { McpTab } from "./ManageTabs"; import { ConnectorsSection } from "./connectors/ConnectorsSection"; import { Icon } from "./Icon"; -// The Connectors surface (renamed from "Integrations", §26) keeps the left sub-nav, now just -// Connectors · MCP. The old "Messaging routing" tab (and its ⚠ unrouted badge) moved whole to -// Inbox ▸ Configure (§28): inbox-delivery config belongs with the Inbox, and Unrouted is -// "messages that never reached you". The one remaining Activity is the audit log, reached from -// the account menu. -type IntTab = "connectors" | "mcp"; - -// Fixed sub-nav (UX-DECISIONS §21): connector detail lives as a SUBPAGE under -// Connectors, never as a nav item — the nav must not grow per connector. -const INT_TABS: { key: IntTab; label: string; icon: "plug" | "code" }[] = [ - { key: "connectors", label: "Connectors", icon: "plug" }, - { key: "mcp", label: "MCP servers", icon: "code" }, -]; +// The Connectors surface (renamed from "Integrations", §26). The separate "MCP +// servers" tab is retired (UX-034): custom MCP servers now live on the Connectors +// page itself — a "Custom · MCP" group plus the top "Add custom server" modal — +// so the sub-nav is a single fixed item. The old "Messaging routing" tab (and its +// ⚠ unrouted badge) moved whole to Inbox ▸ Configure (§28); the one remaining +// Activity is the audit log, reached from the account menu. export function IntegrationsView() { - const [tab, setTab] = useState("connectors"); // Sub-nav count: how many connectors exist. Polled so the badge stays live. const [connCount, setConnCount] = useState(null); @@ -38,51 +29,25 @@ export function IntegrationsView() {
Connectors
- {INT_TABS.map((t) => { - const active = tab === t.key; - return ( - - ); - })} +
- {tab === "connectors" ? ( -
- - -
- ) : ( -
- - -
- )} +
+ + +
diff --git a/surfaces/gui/src/components/ManageTabs.tsx b/surfaces/gui/src/components/ManageTabs.tsx index f9ab2992..83a18ac8 100644 --- a/surfaces/gui/src/components/ManageTabs.tsx +++ b/surfaces/gui/src/components/ManageTabs.tsx @@ -1,36 +1,26 @@ import { useEffect, useState } from "react"; import { - addMcpServer, allowUser, connectConnector, connectManaged, connectMcpBacked, - connectMcp, - deleteMcpServer, disallowUser, - getMcpServers, - getMcpTools, - signoutMcp, getSettings, getSubscriptions, removeModel, resolveUnauthorized, unsubscribeChannel, - patchMcpServer, - reloadMcp, setDefaultModel, updateConnectorTools, type CloudStatus, type Connector, type Subscription, - type McpServer, type ModelSettings, type ProviderInfo, } from "../api"; import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn"; import { ModelChecklist } from "./ModelChecklist"; import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup"; -import { Toggle } from "./Toggle"; // "2h ago"-style label for the providers' Last-used line (null when never used). const relTime = (epoch?: number | null): string | null => { @@ -45,14 +35,12 @@ const relTime = (epoch?: number | null): string | null => { }; // Shared tab bodies for the Settings and Integrations pages (the old top-tab ManageModal was retired -// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab + -// McpTab → Integrations ▸ Connectors / MCP servers. +// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab → +// Integrations ▸ Connectors (the MCP tab retired into the Connectors page, UX-034). const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold"; -const CARD = "rounded-xl2 border border-line bg-panel"; const BTN_BORDERED = "text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50"; -const BTN_DANGER = "text-[12.5px] text-danger/80 hover:text-danger shrink-0"; /** Two-letter initials for a chip/avatar (first+last word, else first two chars). */ function initials(name: string): string { @@ -62,14 +50,6 @@ function initials(name: string): string { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } -const EXAMPLE = `{ - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"], - "enabled": true - } -}`; - // -- Configure Models tab (UX-021: the shared provider gallery + key form) ---- // Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two // surfaces can't drift. Settings-only extras: per-card "used Nh ago", a "Remove @@ -232,282 +212,6 @@ function ComposerPickerCard({ ); } -// Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 + DCR) — -// no keys to paste, tokens stay in the local secret store. First: Granola. -const MCP_PRESETS: { name: string; label: string; blurb: string; config: Record }[] = [ - { - name: "granola", - label: "Granola", - blurb: "Meeting notes & transcripts — sign in with your Granola account.", - config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" }, - }, -]; - -export function McpTab() { - const [servers, setServers] = useState([]); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); - - const refresh = () => getMcpServers().then(setServers).catch(() => setServers([])); - useEffect(() => { - refresh(); - }, []); - - // While a browser sign-in is in flight, poll so the row flips to connected (or - // surfaces the error) without the user having to touch anything. - const authorizing = servers.some((s) => s.status === "authorizing"); - useEffect(() => { - if (!authorizing) return; - const t = window.setInterval(refresh, 2000); - return () => window.clearInterval(t); - }, [authorizing]); - - const toggle = async (s: McpServer) => { - await patchMcpServer(s.name, { enabled: !s.enabled }); - refresh(); - }; - const remove = async (s: McpServer) => { - await deleteMcpServer(s.name); - refresh(); - }; - - return ( -
-

- External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are - permission-gated. Changes apply to new sessions —{" "} - - . -

- - {servers.length === 0 && !adding ? ( -
- No MCP servers configured.{" "} - -
- ) : ( -
- {servers.map((s) => ( - toggle(s)} - onRemove={() => remove(s)} - onRefresh={refresh} - /> - ))} -
- )} - - {/* One-click OAuth presets not yet configured. */} - {MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => ( -
-
-
{p.label}
-
{p.blurb}
-
- -
- ))} - - {adding ? ( - { - setAdding(false); - setError(null); - }} - onError={setError} - onAdded={() => { - setAdding(false); - setError(null); - refresh(); - }} - /> - ) : servers.length > 0 ? ( - - ) : null} - {error &&
{error}
} -
- ); -} - -function McpRow({ - server, - onToggle, - onRemove, - onRefresh, -}: { - server: McpServer; - onToggle: () => void; - onRemove: () => void; - onRefresh: () => void; -}) { - const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null); - const [busy, setBusy] = useState(false); - const [toolErr, setToolErr] = useState(null); - - const isOauth = server.auth === "oauth"; - const authorizing = server.status === "authorizing"; - const signIn = async () => { - await connectMcp(server.name); // browser opens; the tab's poll flips the status - onRefresh(); - }; - const signOut = async () => { - await signoutMcp(server.name); - onRefresh(); - }; - - const loadTools = async () => { - if (tools) { - setTools(null); - return; - } - setBusy(true); - setToolErr(null); - const res = await getMcpTools(server.name); - setBusy(false); - if (res.ok) setTools(res.tools); - else setToolErr(res.error || "failed to connect"); - }; - - return ( -
-
- -
-
{server.name}
-
- {server.transport} · {authorizing ? "signing in…" : server.status.replace("_", " ")} - {server.tool_count != null ? ` · ${server.tool_count} tools` : ""} - {server.requires_approval ? " · asks" : ""} - {isOauth ? " · oauth" : ""} -
-
- {isOauth && - (server.status === "needs_auth" ? ( - - ) : authorizing ? ( - waiting for browser… - ) : server.status === "connected" ? ( - - ) : null)} - - -
- {server.last_error && server.status !== "connected" && ( -
{server.last_error}
- )} - {toolErr &&
{toolErr}
} - {tools && ( -
- {tools.length === 0 &&
No tools.
} - {tools.map((t) => ( - - {t.name} - - ))} -
- )} -
- ); -} - -function AddForm({ - onCancel, - onAdded, - onError, -}: { - onCancel: () => void; - onAdded: () => void; - onError: (e: string | null) => void; -}) { - const [text, setText] = useState(EXAMPLE); - - const save = async () => { - onError(null); - let parsed: any; - try { - parsed = JSON.parse(text); - } catch (e: any) { - onError("Invalid JSON: " + e.message); - return; - } - // Accept either {mcpServers:{...}}, {name:{...}}, or a single bare config. - const map = parsed.mcpServers || parsed; - const entries = - map && typeof map === "object" && !map.command && !map.url - ? Object.entries(map) - : null; - if (!entries || entries.length === 0) { - onError('Paste a `{ "": { … } }` object (or a full mcpServers block).'); - return; - } - for (const [name, config] of entries) { - await addMcpServer(name, config as Record); - } - onAdded(); - }; - - return ( -
-
Paste server JSON (name → config):
-