Merge rp/mcp-add-test: custom MCP add/test flow, single Connectors page

This commit is contained in:
Rohit C Prasad
2026-08-21 10:28:47 -07:00
31 changed files with 1478 additions and 402 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ Under the hood:
Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box: 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. A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.
+44 -2
View File
@@ -13,8 +13,9 @@ Tool execution from the (sync) ToolRegistry bridges back here via
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import tempfile
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from typing import Any, Optional from typing import Any, IO, Optional
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
@@ -23,6 +24,25 @@ from mcp.client.streamable_http import streamablehttp_client
from .config import MCPServerDef 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: class _Conn:
def __init__(self, session: ClientSession, tools: list[Any]) -> None: def __init__(self, session: ClientSession, tools: list[Any]) -> None:
self.session = session self.session = session
@@ -36,6 +56,7 @@ class MCPManager:
def __init__(self, secrets: Any = None) -> None: def __init__(self, secrets: Any = None) -> None:
self._conns: dict[str, _Conn] = {} self._conns: dict[str, _Conn] = {}
self._tasks: dict[str, asyncio.Task] = {} self._tasks: dict[str, asyncio.Task] = {}
self._stderr_tails: dict[str, str] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# SecretStore for OAuth servers' token persistence (mcp/oauth.py); lazy default # SecretStore for OAuth servers' token persistence (mcp/oauth.py); lazy default
# so library/CLI construction without secrets keeps working. # so library/CLI construction without secrets keeps working.
@@ -64,6 +85,10 @@ class MCPManager:
async def tools(self, server: MCPServerDef) -> list[Any]: async def tools(self, server: MCPServerDef) -> list[Any]:
return (await self.ensure(server)).tools 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( async def call(
self, name: str, tool: str, arguments: Optional[dict[str, Any]] self, name: str, tool: str, arguments: Optional[dict[str, Any]]
) -> Any: ) -> Any:
@@ -88,6 +113,7 @@ class MCPManager:
async def _serve( async def _serve(
self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False
) -> None: ) -> None:
errfile = None
try: try:
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:
if server.transport == "http": if server.transport == "http":
@@ -124,18 +150,34 @@ class MCPManager:
env=server.env or None, env=server.env or None,
cwd=server.cwd, 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)) session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize() await session.initialize()
listed = await session.list_tools() listed = await session.list_tools()
conn = _Conn(session, list(listed.tools)) conn = _Conn(session, list(listed.tools))
self._stderr_tails.pop(server.name, None)
if not ready.done(): if not ready.done():
ready.set_result(conn) ready.set_result(conn)
await conn.shutdown.wait() await conn.shutdown.wait()
except Exception as exc: # connection / init failure except Exception as exc: # connection / init failure
tail = _read_tail(errfile)
if tail:
self._stderr_tails[server.name] = tail
if not ready.done(): if not ready.done():
ready.set_exception(exc) ready.set_exception(exc)
finally: finally:
if errfile is not None:
try:
errfile.close()
except OSError:
pass
self._conns.pop(server.name, None) self._conns.pop(server.name, None)
self._tasks.pop(server.name, None) self._tasks.pop(server.name, None)
+15
View File
@@ -113,6 +113,21 @@ def is_auth_required(exc: BaseException) -> bool:
return is_auth_required(cause) if cause is not None else False 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 ------------------------------------------------ # -- single-slot interactive flow ------------------------------------------------
_pending: Optional[asyncio.Future] = None _pending: Optional[asyncio.Future] = None
# The last authorize URL we sent the user to — surfaced over REST so the GUI can offer # The last authorize URL we sent the user to — surfaced over REST so the GUI can offer
+15
View File
@@ -85,6 +85,21 @@ MATRIX: dict[str, ModelEntry] = {
"gemini:gemini-2.5-flash": ModelEntry( "gemini:gemini-2.5-flash": ModelEntry(
"Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576 "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 ---------------------------------------- # -- direct OpenAI-compatible vendors ----------------------------------------
# Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via # Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via
# their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls # their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls
+19 -8
View File
@@ -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 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 `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` + chain-of-thought continuity across tool round-trips via `store: false` +
`include: ["reasoning.encrypted_content"]` nothing retained server-side. `include: ["reasoning.encrypted_content"]` nothing retained server-side.
Routing: the `openai` provider entry with NO custom base_url builds this class; a custom Routing: the `openai` provider entry with NO custom base_url builds this class. Most custom
endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions
Chat Completions `OpenAIProvider` (registry.py). `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 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 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", default_model: str = "gpt-5.6-sol",
api_key: Optional[str] = None, api_key: Optional[str] = None,
secrets: Any = 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 # 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 → # assembled before any key exists; key resolves at call time (explicit → env →
# SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint # SecretStore). Tests inject a `client` directly. `base_url` is opt-in: stock OpenAI
# routes to the Chat Completions provider instead (registry.py). # leaves it unset, while Responses-compatible vendors can supply their own endpoint.
self._client = client self._client = client
self._api_key = api_key self._api_key = api_key
self._secrets = secrets 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 self.default_model = default_model
def _ensure_client(self) -> Any: def _ensure_client(self) -> Any:
@@ -310,7 +317,10 @@ class OpenAIResponsesProvider(ProviderClient):
"No model API key configured. Set OPENAI_API_KEY in the environment, " "No model API key configured. Set OPENAI_API_KEY in the environment, "
"or add your key in Manage → Settings." "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 return self._client
def _request_kwargs( def _request_kwargs(
@@ -331,9 +341,10 @@ class OpenAIResponsesProvider(ProviderClient):
# `_openai` sidecar instead, and summaries feed the GUI's thinking display. # `_openai` sidecar instead, and summaries feed the GUI's thinking display.
"store": False, "store": False,
"include": ["reasoning.encrypted_content"], "include": ["reasoning.encrypted_content"],
"reasoning": {"summary": "auto"},
**{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST}, **{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
} }
if self._reasoning_summary:
kwargs["reasoning"] = {"summary": "auto"}
if instructions: if instructions:
kwargs["instructions"] = instructions kwargs["instructions"] = instructions
if tools: if tools:
+115 -3
View File
@@ -210,6 +210,37 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] =
return build 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( def _compat(
name: str, name: str,
title: 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] = [ DESCRIPTORS: list[ProviderDescriptor] = [
ProviderDescriptor( ProviderDescriptor(
name="openai", name="openai",
@@ -462,6 +536,26 @@ DESCRIPTORS: list[ProviderDescriptor] = [
blurb="Runs models inside your own Google Cloud project. Gemini and Claude use " 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.", "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 # 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 # "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`). # 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, fields: Optional[dict[str, Any]] = None,
timeout: float = 10.0, timeout: float = 10.0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate a provider's credentials with one cheap, read-only call (list models) — the same """Validate a provider's credentials with one cheap call — usually list models.
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 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. (Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url.
""" """
import httpx import httpx
@@ -832,6 +928,22 @@ def verify_provider_key(
elif name == "ollama": elif name == "ollama":
base = _normalize_ollama_url(base_url) base = _normalize_ollama_url(base_url)
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout) 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…) else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
default_base = next( default_base = next(
(f.default for f in d.fields if f.key == "base_url" and f.default), "" (f.default for f in d.fields if f.key == "base_url" and f.default), ""
+10
View File
@@ -2359,6 +2359,16 @@ def create_app(manager: SessionManager) -> FastAPI:
) )
await ws.close() await ws.close()
return 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 # 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). # Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now).
engine.is_attended = lambda: _visibility() == VIS_INLINE engine.is_attended = lambda: _visibility() == VIS_INLINE
+66 -2
View File
@@ -186,6 +186,12 @@ class SessionManager:
# feeds list_mcp's status so the GUI can show "authorizing…" and failures. # feeds list_mcp's status so the GUI can show "authorizing…" and failures.
self._mcp_authorizing: set[str] = set() self._mcp_authorizing: set[str] = set()
self._mcp_errors: dict[str, str] = {} 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.gateway: Optional[Gateway] = None
self._data_base = base self._data_base = base
# Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file. # Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file.
@@ -1186,6 +1192,7 @@ class SessionManager:
continue continue
try: try:
conn = await self.mcp.ensure(server) conn = await self.mcp.ensure(server)
self._mcp_errors.pop(server.name, None)
except Exception as exc: except Exception as exc:
if mcp_oauth.is_auth_required(exc): if mcp_oauth.is_auth_required(exc):
# Stored tokens no longer refresh (vendor rotated/expired # Stored tokens no longer refresh (vendor rotated/expired
@@ -1198,7 +1205,22 @@ class SessionManager:
logger.info( logger.info(
"mcp %s needs re-auth; skipped for this session", server.name "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 continue
callables = build_callables( callables = build_callables(
server, server,
@@ -1217,6 +1239,12 @@ class SessionManager:
out.extend(callables) out.extend(callables)
return out 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]]: def list_mcp(self) -> list[dict[str, Any]]:
"""Servers from the global config + connection status (does not connect).""" """Servers from the global config + connection status (does not connect)."""
from ..mcp import oauth as mcp_oauth from ..mcp import oauth as mcp_oauth
@@ -1240,6 +1268,12 @@ class SessionManager:
status = "authorizing" status = "authorizing"
elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets): elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets):
status = "needs_auth" 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: else:
status = "configured" status = "configured"
out.append( out.append(
@@ -1258,6 +1292,8 @@ class SessionManager:
"requires_approval": bool(raw.get("requires_approval", True)), "requires_approval": bool(raw.get("requires_approval", True)),
"auth": "oauth" if is_oauth else None, "auth": "oauth" if is_oauth else None,
"status": status, "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), "last_error": self._mcp_errors.get(name),
"tool_count": ( "tool_count": (
len(self.mcp._conns[name].tools) if connected else None 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 """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 for the loopback callback, so callers run it as a background task and watch
list_mcp for the status flip.""" list_mcp for the status flip."""
from ..mcp import oauth as mcp_oauth
for server in load_mcp_servers( for server in load_mcp_servers(
self.default_workspace, self.default_workspace,
secrets=self.secrets, secrets=self.secrets,
@@ -1280,12 +1318,31 @@ class SessionManager:
continue continue
self._mcp_authorizing.add(name) self._mcp_authorizing.add(name)
self._mcp_errors.pop(name, None) self._mcp_errors.pop(name, None)
self._mcp_auth_hints.discard(name)
try: try:
# The ONE place a browser sign-in may start: an explicit connect. # The ONE place a browser sign-in may start: an explicit connect.
conn = await self.mcp.ensure(server, interactive=True) 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)} return {"ok": True, "tools": len(conn.tools)}
except Exception as exc: 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]} return {"ok": False, "error": self._mcp_errors[name]}
finally: finally:
self._mcp_authorizing.discard(name) self._mcp_authorizing.discard(name)
@@ -1349,6 +1406,13 @@ class SessionManager:
def delete_mcp(self, name: str) -> dict[str, Any]: def delete_mcp(self, name: str) -> dict[str, Any]:
ok = delete_global_server(name) 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} return {"ok": ok, "name": name}
async def mcp_tools(self, name: str) -> dict[str, Any]: async def mcp_tools(self, name: str) -> dict[str, Any]:
+50 -3
View File
@@ -45,6 +45,10 @@ const SETTINGS = {
model_labels: { model_labels: {
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic", "anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
"zai:glm-5.2": "GLM-5.2 · Z AI", "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 // Context windows (subset — mirrors /v1/settings.model_context_windows); drives the
// composer usage chip's context-fill meter. // 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 }, { 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. // 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 }, { 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 // ollama: keyless local provider — "configured" without proving anything runs; the
// onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39). // 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 }, { 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") { if (p.endsWith("/v1/mcp") && m === "GET") {
for (const s2 of mcpServers) { for (const s2 of mcpServers) {
if (s2.status === "authorizing" && s2._flip) { if (s2.status === "authorizing" && s2._flip) {
s2.status = "connected"; // Servers named locked-* simulate a guarded remote: the anonymous
s2.tool_count = 6; // 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; if (s2.status === "authorizing") s2._flip = true;
} }
@@ -1986,6 +2003,8 @@ export async function mockApi(page: import("@playwright/test").Page) {
requires_approval: true, requires_approval: true,
auth: b.config?.auth === "oauth" ? "oauth" : null, auth: b.config?.auth === "oauth" ? "oauth" : null,
status: b.config?.auth === "oauth" ? "needs_auth" : "configured", status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
auth_hint: false,
last_test_at: null,
last_error: null, last_error: null,
tool_count: null, tool_count: null,
config: b.config || {}, config: b.config || {},
@@ -1996,7 +2015,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/); const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/);
if (mc && m === "POST") { if (mc && m === "POST") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1])); 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 }); return json({ ok: true, started: true });
} }
const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/); const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/);
@@ -2009,6 +2033,29 @@ export async function mockApi(page: import("@playwright/test").Page) {
} }
return json({ ok: true }); 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([]); if (p.endsWith("/v1/unrouted")) return json([]);
+82
View File
@@ -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);
});
+18 -17
View File
@@ -1,20 +1,20 @@
// MCP OAuth quick-add (first server: Granola): the MCP tab offers a curated Connect // MCP OAuth quick-add (first server: Granola): the Custom · MCP group on the
// card; connecting adds the server, kicks off the browser sign-in ("signing in…"), // Connectors page offers a curated Connect card; connecting adds the server, kicks
// and the tab's poll flips the row to connected. Sign out returns it to needs_auth. // 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 { expect } from "@playwright/test";
import { test } from "./fixtures"; import { test } from "./fixtures";
async function openMcpTab(page) { async function openConnectors(page) {
await page.goto("/"); await page.goto("/");
await page.getByTestId("account-row").click(); await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).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 }) => { test("granola: quick-add card → sign-in flow → Live → sign out", async ({ page }) => {
await openMcpTab(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"); const preset = page.getByTestId("mcp-preset-granola");
await expect(preset).toContainText("Granola"); await expect(preset).toContainText("Granola");
await expect(preset).toContainText("Meeting notes"); 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. // Connect: adds the server with OAuth pending and starts the browser flow.
await preset.getByRole("button", { name: "Connect" }).click(); await preset.getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0); await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first(); const row = page.getByTestId("mcp-row-granola");
await expect(row).toContainText("signing in…"); await expect(row).toContainText("Signing in…");
// The 2s status poll flips the mock to connected with its 6 tools. // The status poll flips the mock to connected with its 6 tools.
await expect(row).toContainText("connected", { timeout: 10_000 }); await expect(row).toContainText("Live", { timeout: 10_000 });
await expect(row).toContainText("6 tools"); await expect(row).toContainText("6 tools");
await expect(row).toContainText("oauth");
// Sign out forgets tokens; the row needs auth again and offers Sign in. // Sign out on the detail page forgets tokens; the chip needs sign-in again.
await row.getByTestId("mcp-signout-granola").click(); await row.click();
await expect(row).toContainText("needs auth"); const detail = page.getByTestId("mcp-detail-granola");
await expect(row.getByTestId("mcp-signin-granola")).toBeVisible(); await detail.getByTestId("mcp-signout-granola").click();
await expect(detail).toContainText("Needs sign-in");
await expect(detail.getByTestId("mcp-signin-granola")).toBeVisible();
}); });
+38
View File
@@ -79,6 +79,44 @@ test("Models: provider gallery states; vendor form previews models", async ({ pa
await expect(page.getByTestId("set-provider-openai")).toBeVisible(); 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… // 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". // affordance; removing reverts the card to "Not set up".
test("Models: Remove key reverts a configured provider", async ({ page }) => { test("Models: Remove key reverts a configured provider", async ({ page }) => {
+4 -2
View File
@@ -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 page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click();
await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible(); 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-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).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); 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. // 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); await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0);
+4
View File
@@ -457,6 +457,10 @@ export interface McpServer {
// "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight) // "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight)
status: string; status: string;
auth?: "oauth" | null; 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; last_error?: string | null;
tool_count: number | null; tool_count: number | null;
config: Record<string, any>; config: Record<string, any>;
@@ -1,25 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getConnectors } from "../api"; import { getConnectors } from "../api";
import { McpTab } from "./ManageTabs";
import { ConnectorsSection } from "./connectors/ConnectorsSection"; import { ConnectorsSection } from "./connectors/ConnectorsSection";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
// The Connectors surface (renamed from "Integrations", §26) keeps the left sub-nav, now just // The Connectors surface (renamed from "Integrations", §26). The separate "MCP
// Connectors · MCP. The old "Messaging routing" tab (and its ⚠ unrouted badge) moved whole to // servers" tab is retired (UX-034): custom MCP servers now live on the Connectors
// Inbox ▸ Configure (§28): inbox-delivery config belongs with the Inbox, and Unrouted is // page itself — a "Custom · MCP" group plus the top "Add custom server" modal —
// "messages that never reached you". The one remaining Activity is the audit log, reached from // so the sub-nav is a single fixed item. The old "Messaging routing" tab (and its
// the account menu. // ⚠ unrouted badge) moved whole to Inbox ▸ Configure (§28); the one remaining
type IntTab = "connectors" | "mcp"; // Activity is the audit log, reached from the account menu.
// 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" },
];
export function IntegrationsView() { export function IntegrationsView() {
const [tab, setTab] = useState<IntTab>("connectors");
// Sub-nav count: how many connectors exist. Polled so the badge stays live. // Sub-nav count: how many connectors exist. Polled so the badge stays live.
const [connCount, setConnCount] = useState<number | null>(null); const [connCount, setConnCount] = useState<number | null>(null);
@@ -38,51 +29,25 @@ export function IntegrationsView() {
<div className="px-2 text-[13.5px] font-semibold mb-3 flex items-center gap-2"> <div className="px-2 text-[13.5px] font-semibold mb-3 flex items-center gap-2">
<Icon name="plug" size={16} /> Connectors <Icon name="plug" size={16} /> Connectors
</div> </div>
{INT_TABS.map((t) => { <button className="w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between bg-paper text-accent font-medium">
const active = tab === t.key; <span className="flex items-center gap-2 min-w-0">
return ( <Icon name="plug" size={15} /> Connectors
<button </span>
key={t.key} {connCount != null && (
className={ <span className="text-[11px] shrink-0 text-accent">{connCount}</span>
"w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between " + )}
(active </button>
? "bg-paper text-accent font-medium"
: "text-muted hover:bg-paper hover:text-ink")
}
onClick={() => setTab(t.key)}
>
<span className="flex items-center gap-2 min-w-0">
<Icon name={t.icon} size={15} /> {t.label}
</span>
{t.key === "connectors" && connCount != null && (
<span className={"text-[11px] shrink-0 " + (active ? "text-accent" : "text-faint")}>
{connCount}
</span>
)}
</button>
);
})}
</nav> </nav>
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll"> <div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
<div className="max-w-4xl mx-auto px-7 py-6"> <div className="max-w-4xl mx-auto px-7 py-6">
{tab === "connectors" ? ( <section>
<section> <PanelHead
<PanelHead title="Connectors"
title="Connectors" sub="Apps and tools your coworkers can use. Connected ones come first."
sub="Apps and tools your coworkers can use. Connected ones come first." />
/> <ConnectorsSection />
<ConnectorsSection /> </section>
</section>
) : (
<section>
<PanelHead
title="MCP servers"
sub="External tool servers (stdio or HTTP), shared across all agents."
/>
<McpTab />
</section>
)}
</div> </div>
</div> </div>
</main> </main>
+2 -298
View File
@@ -1,36 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
addMcpServer,
allowUser, allowUser,
connectConnector, connectConnector,
connectManaged, connectManaged,
connectMcpBacked, connectMcpBacked,
connectMcp,
deleteMcpServer,
disallowUser, disallowUser,
getMcpServers,
getMcpTools,
signoutMcp,
getSettings, getSettings,
getSubscriptions, getSubscriptions,
removeModel, removeModel,
resolveUnauthorized, resolveUnauthorized,
unsubscribeChannel, unsubscribeChannel,
patchMcpServer,
reloadMcp,
setDefaultModel, setDefaultModel,
updateConnectorTools, updateConnectorTools,
type CloudStatus, type CloudStatus,
type Connector, type Connector,
type Subscription, type Subscription,
type McpServer,
type ModelSettings, type ModelSettings,
type ProviderInfo, type ProviderInfo,
} from "../api"; } from "../api";
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn"; import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
import { ModelChecklist } from "./ModelChecklist"; import { ModelChecklist } from "./ModelChecklist";
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup"; 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). // "2h ago"-style label for the providers' Last-used line (null when never used).
const relTime = (epoch?: number | null): string | null => { 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 // 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 + // when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab
// McpTab → Integrations ▸ Connectors / MCP servers. // 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 SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
const CARD = "rounded-xl2 border border-line bg-panel";
const BTN_BORDERED = const BTN_BORDERED =
"text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; "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_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). */ /** Two-letter initials for a chip/avatar (first+last word, else first two chars). */
function initials(name: string): string { function initials(name: string): string {
@@ -62,14 +50,6 @@ function initials(name: string): string {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); 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) ---- // -- Configure Models tab (UX-021: the shared provider gallery + key form) ----
// Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two // 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 // 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<string, any> }[] = [
{
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<McpServer[]>([]);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-3">
<p className="text-[12.5px] text-muted leading-relaxed">
External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are
permission-gated. Changes apply to new sessions {" "}
<button
className="text-accent font-medium hover:underline"
onClick={() => reloadMcp().then(refresh)}
>
reload now
</button>
.
</p>
{servers.length === 0 && !adding ? (
<div className={CARD + " p-4 text-[13px] text-muted"}>
No MCP servers configured.{" "}
<button className="text-accent font-medium" onClick={() => setAdding(true)}>
Add a server
</button>
</div>
) : (
<div className="space-y-2">
{servers.map((s) => (
<McpRow
key={s.name}
server={s}
onToggle={() => toggle(s)}
onRemove={() => remove(s)}
onRefresh={refresh}
/>
))}
</div>
)}
{/* One-click OAuth presets not yet configured. */}
{MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => (
<div key={p.name} className={CARD + " p-3.5 flex items-center gap-3"} data-testid={`mcp-preset-${p.name}`}>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium">{p.label}</div>
<div className="text-[11.5px] text-faint">{p.blurb}</div>
</div>
<button
className={BTN_ACCENT}
onClick={async () => {
await addMcpServer(p.name, p.config);
await connectMcp(p.name); // opens the browser sign-in right away
refresh();
}}
>
Connect
</button>
</div>
))}
{adding ? (
<AddForm
onCancel={() => {
setAdding(false);
setError(null);
}}
onError={setError}
onAdded={() => {
setAdding(false);
setError(null);
refresh();
}}
/>
) : servers.length > 0 ? (
<button className={BTN_ACCENT} onClick={() => setAdding(true)}>
+ Add server
</button>
) : null}
{error && <div className="text-[12.5px] text-danger">{error}</div>}
</div>
);
}
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<string | null>(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 (
<div className={CARD + " p-3.5"}>
<div className="flex items-center gap-3">
<Toggle checked={server.enabled} onChange={onToggle} title="Enable this server" />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium">{server.name}</div>
<div className="text-[11.5px] text-faint">
{server.transport} · {authorizing ? "signing in…" : server.status.replace("_", " ")}
{server.tool_count != null ? ` · ${server.tool_count} tools` : ""}
{server.requires_approval ? " · asks" : ""}
{isOauth ? " · oauth" : ""}
</div>
</div>
{isOauth &&
(server.status === "needs_auth" ? (
<button className={BTN_ACCENT} onClick={signIn} data-testid={`mcp-signin-${server.name}`}>
Sign in
</button>
) : authorizing ? (
<span className="text-[12px] text-muted shrink-0">waiting for browser</span>
) : server.status === "connected" ? (
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={signOut}
data-testid={`mcp-signout-${server.name}`}
>
sign out
</button>
) : null)}
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={loadTools}
disabled={busy}
>
{busy ? "…" : tools ? "hide tools" : "tools"}
</button>
<button className={BTN_DANGER} onClick={onRemove}>
remove
</button>
</div>
{server.last_error && server.status !== "connected" && (
<div className="text-[12.5px] text-danger mt-1.5">{server.last_error}</div>
)}
{toolErr && <div className="text-[12.5px] text-danger mt-1.5">{toolErr}</div>}
{tools && (
<div className="mt-2.5 pt-2.5 border-t border-line flex flex-wrap gap-1.5">
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
{tools.map((t) => (
<span
key={t.name}
title={t.description}
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
>
{t.name}
</span>
))}
</div>
)}
</div>
);
}
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 `{ "<name>": { … } }` object (or a full mcpServers block).');
return;
}
for (const [name, config] of entries) {
await addMcpServer(name, config as Record<string, any>);
}
onAdded();
};
return (
<div className="space-y-2">
<div className="text-[12.5px] text-muted">Paste server JSON (name config):</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={9}
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
/>
<div className="flex items-center gap-3">
<button className={BTN_ACCENT} onClick={save}>
Add
</button>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onCancel}>
cancel
</button>
</div>
</div>
);
}
// -- Connectors --------------------------------------------------------------- // -- Connectors ---------------------------------------------------------------
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS // The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
// §21: connected-first list + per-connector detail subpages). This file keeps the // §21: connected-first list + per-connector detail subpages). This file keeps the
@@ -1,23 +1,28 @@
import { useState } from "react"; import { useState } from "react";
import { type CloudStatus, type Connector, type SlackStatus } from "../../api"; import { type CloudStatus, type Connector, type McpServer, type SlackStatus } from "../../api";
import { ConnectorBadge } from "../../connectors/ConnectorIcon"; import { ConnectorBadge } from "../../connectors/ConnectorIcon";
import { AddConnectionModal } from "./AddConnectionModal"; import { AddConnectionModal } from "./AddConnectionModal";
import { AddMcpModal, CustomMcpGroup } from "./CustomMcp";
import { CHIP_OK, CHIP_OFF, CHIP_WARN, GRP, GRP_H, FOOT, PILL_QUIET, ROW } from "./ui"; import { CHIP_OK, CHIP_OFF, CHIP_WARN, GRP, GRP_H, FOOT, PILL_QUIET, ROW } from "./ui";
// The Connectors LIST (UX-DECISIONS §21): connected first in their own inset group — // The Connectors LIST (UX-DECISIONS §21): connected first in their own inset group —
// rows navigate to the connector's detail subpage; problems surface as a chip in the // rows navigate to the connector's detail subpage; problems surface as a chip in the
// list, never one click deep. Available connectors below with a Connect pill. // list, never one click deep. Available connectors below with a Connect pill.
// Custom MCP servers (UX-034) render as their own group after Connected; the "Add
// custom server" affordance sits at the top of the page (owner ruling: top).
const AVAILABLE_FOLD = 8; // rows shown before "show all" const AVAILABLE_FOLD = 8; // rows shown before "show all"
export function ConnectorsList({ export function ConnectorsList({
connectors, connectors,
mcpServers,
cloud, cloud,
slack, slack,
onOpen, onOpen,
onChanged, onChanged,
}: { }: {
connectors: Connector[]; connectors: Connector[];
mcpServers: McpServer[];
cloud: CloudStatus | null; cloud: CloudStatus | null;
slack: SlackStatus | null; slack: SlackStatus | null;
onOpen: (name: string) => void; onOpen: (name: string) => void;
@@ -26,17 +31,26 @@ export function ConnectorsList({
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const [connecting, setConnecting] = useState<string | null>(null); const [connecting, setConnecting] = useState<string | null>(null);
const [addingMcp, setAddingMcp] = useState(false);
const q = filter.trim().toLowerCase(); const q = filter.trim().toLowerCase();
const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q); const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q);
const connected = connectors.filter((c) => c.connected && match(c)); const connected = connectors.filter((c) => c.connected && match(c));
const available = connectors.filter((c) => !c.connected && c.available && match(c)); const available = connectors.filter((c) => !c.connected && c.available && match(c));
const customMcp = mcpServers.filter((s) => !q || s.name.toLowerCase().includes(q));
const shown = showAll || q ? available : available.slice(0, AVAILABLE_FOLD); const shown = showAll || q ? available : available.slice(0, AVAILABLE_FOLD);
const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null; const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null;
return ( return (
<div> <div>
<div className="flex items-center justify-end mb-4"> <div className="flex items-center justify-between mb-4">
<button
className={PILL_QUIET}
onClick={() => setAddingMcp(true)}
data-testid="add-custom-server"
>
+ Add custom server
</button>
<input <input
placeholder="Search" placeholder="Search"
value={filter} value={filter}
@@ -71,6 +85,12 @@ export function ConnectorsList({
</> </>
)} )}
<CustomMcpGroup
servers={customMcp}
onOpen={(name) => onOpen("mcp:" + name)}
onChanged={onChanged}
/>
<div className={GRP_H}>Available</div> <div className={GRP_H}>Available</div>
<div className={GRP}> <div className={GRP}>
{shown.map((c) => ( {shown.map((c) => (
@@ -120,6 +140,7 @@ export function ConnectorsList({
onChanged={onChanged} onChanged={onChanged}
/> />
)} )}
{addingMcp && <AddMcpModal onClose={() => setAddingMcp(false)} onChanged={onChanged} />}
</div> </div>
); );
} }
@@ -3,11 +3,14 @@ import {
disconnectConnector, disconnectConnector,
getCloudStatus, getCloudStatus,
getConnectors, getConnectors,
getMcpServers,
getSlackStatus, getSlackStatus,
type CloudStatus, type CloudStatus,
type Connector, type Connector,
type McpServer,
type SlackStatus, type SlackStatus,
} from "../../api"; } from "../../api";
import { McpServerDetail } from "./CustomMcp";
import { ConnectorBadge } from "../../connectors/ConnectorIcon"; import { ConnectorBadge } from "../../connectors/ConnectorIcon";
import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs"; import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs";
import { AccountsDetail } from "./AccountsDetail"; import { AccountsDetail } from "./AccountsDetail";
@@ -52,11 +55,13 @@ const DETAIL_PAGES: Record<string, (p: DetailProps) => JSX.Element> = {
export function ConnectorsSection() { export function ConnectorsSection() {
const [detail, setDetail] = useState<string | null>(null); const [detail, setDetail] = useState<string | null>(null);
const [connectors, setConnectors] = useState<Connector[]>([]); const [connectors, setConnectors] = useState<Connector[]>([]);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [cloud, setCloud] = useState<CloudStatus | null>(null); const [cloud, setCloud] = useState<CloudStatus | null>(null);
const [slack, setSlack] = useState<SlackStatus | null>(null); const [slack, setSlack] = useState<SlackStatus | null>(null);
const refresh = () => { const refresh = () => {
getConnectors().then(setConnectors).catch(() => setConnectors([])); getConnectors().then(setConnectors).catch(() => setConnectors([]));
getMcpServers().then(setMcpServers).catch(() => setMcpServers([]));
getCloudStatus().then(setCloud).catch(() => setCloud(null)); getCloudStatus().then(setCloud).catch(() => setCloud(null));
getSlackStatus().then(setSlack).catch(() => setSlack(null)); getSlackStatus().then(setSlack).catch(() => setSlack(null));
}; };
@@ -68,6 +73,37 @@ export function ConnectorsSection() {
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
// While an MCP test/sign-in is in flight, poll fast so the chip flips to its
// result (Live / Error / Needs sign-in) without the user touching anything.
const mcpBusy = mcpServers.some((s) => s.status === "authorizing");
useEffect(() => {
if (!mcpBusy) return;
const t = setInterval(refresh, 2000);
return () => clearInterval(t);
}, [mcpBusy]);
// Custom MCP entries route as "mcp:<name>" so they can never collide with a
// connector detail page of the same name.
if (detail?.startsWith("mcp:")) {
const s = mcpServers.find((x) => "mcp:" + x.name === detail);
return (
<div>
<button
className="text-[13px] text-accent mb-3"
data-testid="connectors-breadcrumb"
onClick={() => setDetail(null)}
>
Connectors
</button>
{!s ? (
<div className="text-[13px] text-muted">Loading</div>
) : (
<McpServerDetail server={s} onChanged={refresh} onGone={() => setDetail(null)} />
)}
</div>
);
}
if (detail) { if (detail) {
const c = connectors.find((x) => x.name === detail); const c = connectors.find((x) => x.name === detail);
const Page = DETAIL_PAGES[detail]; const Page = DETAIL_PAGES[detail];
@@ -104,6 +140,7 @@ export function ConnectorsSection() {
return ( return (
<ConnectorsList <ConnectorsList
connectors={connectors} connectors={connectors}
mcpServers={mcpServers}
cloud={cloud} cloud={cloud}
slack={slack} slack={slack}
onOpen={setDetail} onOpen={setDetail}
@@ -0,0 +1,461 @@
import { useEffect, useState } from "react";
import {
addMcpServer,
connectMcp,
deleteMcpServer,
getMcpTools,
patchMcpServer,
signoutMcp,
type McpServer,
} from "../../api";
import { relTime } from "../../providers/ProviderSetup";
import { Icon } from "../Icon";
import { Toggle } from "../Toggle";
import {
CHIP_ERR,
CHIP_OFF,
CHIP_OK,
CHIP_WARN,
GRP,
GRP_H,
PILL_ACCENT,
PILL_QUIET,
ROW,
} from "./ui";
// Custom/BYO MCP servers on the Connectors page (UX-DECISIONS §21 + UX-034: the
// separate MCP tab is retired). They render as a "Custom · MCP" group at the end
// of the Connected section — grouped, not interleaved with first-party rows, so
// the user-supplied trust tier stays visible. Status never claims "Connected"
// for a stdio entry: Live = a connection is open right now; Ready = the one-time
// Test passed (subtitle carries "tested ⟨when⟩", persisted server-side).
// 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.
export const MCP_PRESETS: {
name: string;
label: string;
blurb: string;
config: Record<string, any>;
}[] = [
{
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 mcpChip(s: McpServer) {
const isOauth = s.auth === "oauth";
if (!s.enabled) return <span className={CHIP_OFF}> Off</span>;
if (s.status === "authorizing")
return <span className={CHIP_WARN}> {isOauth ? "Signing in…" : "Testing…"}</span>;
if (s.status === "connected") return <span className={CHIP_OK}> Live</span>;
if (s.auth_hint || s.status === "needs_auth")
return <span className={CHIP_WARN}> Needs sign-in</span>;
if (s.status === "error") return <span className={CHIP_ERR}> Error</span>;
if (s.last_test_at) return <span className={CHIP_OK}> Ready</span>;
return <span className={CHIP_OFF}> Not tested</span>;
}
export function mcpStatusLine(s: McpServer): string {
const bits: string[] = [s.transport];
if (s.status === "connected" && s.tool_count != null) bits.push(`${s.tool_count} tools`);
else if (s.transport === "http" && s.config?.url) {
try {
bits.push(new URL(s.config.url).host);
} catch {
/* leave the host off a malformed url */
}
}
if (s.status !== "connected" && s.last_test_at) {
const rel = relTime(s.last_test_at);
if (rel) bits.push(`tested ${rel}`);
}
return bits.join(" · ");
}
/** Neutral square badge for custom servers (no vendor logo to show). */
function McpGlyph() {
return (
<span className="w-[34px] h-[34px] rounded-lg bg-paper border border-line flex items-center justify-center text-muted shrink-0">
<Icon name="code" size={16} />
</span>
);
}
export function CustomMcpGroup({
servers,
onOpen,
onChanged,
}: {
servers: McpServer[];
onOpen: (name: string) => void;
onChanged: () => void;
}) {
const presets = MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name));
if (servers.length === 0 && presets.length === 0) return null;
return (
<>
<div className={GRP_H}>Custom · MCP</div>
<div className={GRP} data-testid="custom-mcp-group">
{servers.map((s) => (
<button
key={s.name}
data-testid={`mcp-row-${s.name}`}
className={ROW + " w-full text-left hover:bg-paper/60"}
onClick={() => onOpen(s.name)}
>
<McpGlyph />
<span className="min-w-0 flex-1">
<span className="font-medium text-[13.5px]">{s.name}</span>
<span className="block text-[12px] text-muted truncate">{mcpStatusLine(s)}</span>
</span>
{mcpChip(s)}
<span className="text-faint text-[15px] shrink-0"></span>
</button>
))}
{presets.map((p) => (
<div key={p.name} className={ROW} data-testid={`mcp-preset-${p.name}`}>
<McpGlyph />
<span className="min-w-0 flex-1">
<span className="font-medium text-[13.5px]">{p.label}</span>
<span className="block text-[12px] text-muted truncate">{p.blurb}</span>
</span>
<span
className={PILL_QUIET + " cursor-pointer"}
role="button"
onClick={async () => {
await addMcpServer(p.name, p.config);
await connectMcp(p.name); // opens the browser sign-in right away
onChanged();
}}
>
Connect
</span>
</div>
))}
</div>
</>
);
}
// -- Add custom server (UX-033 two-tab form, in the page's modal chrome) --------
const EXAMPLE = `{
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"enabled": true
}
}`;
const INPUT =
"w-full text-[13px] px-3 py-2 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent";
export function AddMcpModal({
onClose,
onChanged,
}: {
onClose: () => void;
onChanged: () => void;
}) {
const [tab, setTab] = useState<"url" | "json">("url");
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [text, setText] = useState(EXAMPLE);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const saveUrl = async () => {
setError(null);
const n = name.trim();
const u = url.trim();
if (!n) {
setError("Give the server a name.");
return;
}
if (!/^https?:\/\/\S+$/.test(u)) {
setError("Enter the server's full URL (https://…).");
return;
}
await addMcpServer(n, { type: "http", url: u });
// Probe anonymously right away — the row shows Testing…, then Live, an
// error, or Needs sign-in (401 → the OAuth switch on the detail page).
await connectMcp(n);
onChanged();
onClose();
};
const saveJson = async () => {
setError(null);
let parsed: any;
try {
parsed = JSON.parse(text);
} catch (e: any) {
setError("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) {
setError('Paste a `{ "<name>": { … } }` object (or a full mcpServers block).');
return;
}
for (const [n, config] of entries) {
await addMcpServer(n, config as Record<string, any>);
}
onChanged();
onClose();
};
const tabBtn = (active: boolean) =>
"text-[12px] px-2.5 py-1 rounded-md border shrink-0 " +
(active ? "border-accent text-accent font-medium" : "border-line text-muted hover:text-ink");
return (
<div className="fixed inset-0 z-40" data-testid="add-mcp-modal">
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
<div className="absolute left-1/2 top-24 -translate-x-1/2 w-[540px] max-w-[92vw] rounded-xl2 border border-line bg-panel shadow-xl p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="text-[15px] font-semibold">Add custom MCP server</div>
<button className="text-faint hover:text-ink text-[16px] leading-none" onClick={onClose}>
×
</button>
</div>
<div className="flex items-center gap-1.5">
<button className={tabBtn(tab === "url")} onClick={() => setTab("url")} data-testid="mcp-add-tab-url">
Remote URL
</button>
<button className={tabBtn(tab === "json")} onClick={() => setTab("json")} data-testid="mcp-add-tab-json">
JSON
</button>
</div>
{tab === "url" ? (
<>
<div className="text-[12.5px] text-muted">
Connect a hosted MCP server. If it needs sign-in, the row will offer it after the
first test.
</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (shown in the connectors list)"
spellCheck={false}
className={INPUT}
data-testid="mcp-add-name"
/>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://mcp.example.com/mcp"
spellCheck={false}
className={INPUT + " font-mono text-[12px]"}
data-testid="mcp-add-url"
/>
</>
) : (
<>
<div className="text-[12.5px] text-muted">Paste server JSON (name config):</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={9}
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
/>
</>
)}
<div className="flex items-center gap-3">
<button className={PILL_ACCENT} onClick={tab === "url" ? saveUrl : saveJson}>
{tab === "url" ? "Add & test" : "Add"}
</button>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onClose}>
cancel
</button>
</div>
{error && <div className="text-[12.5px] text-danger">{error}</div>}
</div>
</div>
);
}
// -- Detail subpage (§21): tools, Test, config, error excerpt, remove -----------
export function McpServerDetail({
server,
onChanged,
onGone,
}: {
server: McpServer;
onChanged: () => void;
onGone: () => void;
}) {
const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null);
const [busy, setBusy] = useState(false);
const [toolErr, setToolErr] = useState<string | null>(null);
const isOauth = server.auth === "oauth";
const authorizing = server.status === "authorizing";
const runTest = async () => {
await connectMcp(server.name);
onChanged();
// The connect runs as a background task; if the first refresh outpaced its
// start, the chip never shows Testing and the page's poll misses the flip.
window.setTimeout(onChanged, 600);
};
// Anonymous connect came back 401/403: the fix is sign-in, so switch the entry
// to OAuth (DCR — nothing to register) and start the browser flow right away.
const signInWithOauth = async () => {
await patchMcpServer(server.name, { auth: "oauth" });
await connectMcp(server.name);
onChanged();
};
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 (
<div className="space-y-4" data-testid={`mcp-detail-${server.name}`}>
<div className="flex items-center gap-3">
<McpGlyph />
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold">{server.name}</div>
<div className="text-[12px] text-muted">{mcpStatusLine(server)}</div>
</div>
{mcpChip(server)}
</div>
<div className={GRP}>
<div className={ROW}>
<span className="text-[13px] flex-1">Enabled</span>
<Toggle
checked={server.enabled}
onChange={async () => {
await patchMcpServer(server.name, { enabled: !server.enabled });
onChanged();
}}
title="Enable this server"
/>
</div>
<div className={ROW}>
<span className="text-[13px] flex-1">
Test connection
<span className="block text-[11.5px] text-faint">
Starts the server and lists its tools without opening a session.
</span>
</span>
{server.auth_hint && !isOauth ? (
<span
className={PILL_ACCENT + " cursor-pointer"}
role="button"
onClick={signInWithOauth}
data-testid={`mcp-authfix-${server.name}`}
>
Sign in
</span>
) : isOauth && server.status === "needs_auth" ? (
<span
className={PILL_ACCENT + " cursor-pointer"}
role="button"
onClick={runTest}
data-testid={`mcp-signin-${server.name}`}
>
Sign in
</span>
) : (
<span
className={PILL_QUIET + " cursor-pointer" + (authorizing ? " opacity-50" : "")}
role="button"
onClick={authorizing ? undefined : runTest}
data-testid={`mcp-test-${server.name}`}
>
{authorizing ? "Testing…" : "Test"}
</span>
)}
</div>
{server.last_error && server.status !== "connected" && (
<div className="px-4 py-2.5 text-[12.5px] text-danger break-words">
{server.last_error}
</div>
)}
<div className={ROW}>
<span className="text-[13px] flex-1">Tools</span>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={loadTools} disabled={busy}>
{busy ? "…" : tools ? "hide" : "show"}
</button>
</div>
{toolErr && <div className="px-4 py-2.5 text-[12.5px] text-danger">{toolErr}</div>}
{tools && (
<div className="px-4 py-3 flex flex-wrap gap-1.5">
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
{tools.map((t) => (
<span
key={t.name}
title={t.description}
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
>
{t.name}
</span>
))}
</div>
)}
</div>
<div className={GRP}>
<div className="px-4 py-3">
<div className="text-[12px] font-semibold text-muted mb-1.5">Configuration</div>
<pre className="font-mono text-[11.5px] text-muted whitespace-pre-wrap break-all">
{JSON.stringify(server.config, null, 2)}
</pre>
</div>
</div>
<div className="flex items-center gap-4">
{isOauth && server.status === "connected" && (
<button
className="text-[12.5px] text-muted hover:text-ink"
onClick={async () => {
await signoutMcp(server.name);
onChanged();
}}
data-testid={`mcp-signout-${server.name}`}
>
Sign out
</button>
)}
<button
className="text-[12.5px] text-danger/80 hover:text-danger"
onClick={async () => {
await deleteMcpServer(server.name);
onChanged();
onGone();
}}
data-testid={`mcp-remove-${server.name}`}
>
Remove server
</button>
</div>
</div>
);
}
@@ -35,6 +35,8 @@ export const CHIP_WARN =
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-warnSoft text-warnInk border border-warnInk/20 shrink-0"; "text-[11px] font-medium px-2 py-0.5 rounded-full bg-warnSoft text-warnInk border border-warnInk/20 shrink-0";
export const CHIP_OFF = export const CHIP_OFF =
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-paper text-muted border border-lineStrong shrink-0"; "text-[11px] font-medium px-2 py-0.5 rounded-full bg-paper text-muted border border-lineStrong shrink-0";
export const CHIP_ERR =
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-danger/10 text-danger border border-danger/25 shrink-0";
/** Small × affordance (danger on hover). */ /** Small × affordance (danger on hover). */
export const XBTN = "text-faint hover:text-danger shrink-0 leading-none"; export const XBTN = "text-faint hover:text-danger shrink-0 leading-none";
@@ -108,3 +108,17 @@ describe("itemsFromMessages reasoning", () => {
expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" }); expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" });
}); });
}); });
describe("itemsFromMessages mcp failure", () => {
it("replays the persisted mcp_error marker as a warn notice WITHOUT retry", () => {
const items = itemsFromMessages([
{ role: "user", content: "hi" },
{ role: "notice", kind: "mcp_error", text: "MCP server “sales-db” failed to start — see Settings ▸ MCP" },
] as any);
expect(items[1]).toEqual({
kind: "notice",
tone: "warn",
text: "MCP server “sales-db” failed to start — see Settings ▸ MCP",
});
});
});
+5 -1
View File
@@ -78,7 +78,11 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
: m.kind === "compacted" : m.kind === "compacted"
? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact. ? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
{ kind: "notice", tone: "info", text: m.text || "Context compacted" } { kind: "notice", tone: "info", text: m.text || "Context compacted" }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true }, : m.kind === "mcp_error"
? // A configured MCP server failed to start for this session — informational,
// NOT retriable (retry re-runs the model turn, which can't fix a dead server).
{ kind: "notice", tone: "warn", text: m.text || "An MCP server failed to start" }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
); );
} }
// system messages are omitted; tool-result messages are folded into the tool row above // system messages are omitted; tool-result messages are folded into the tool row above
@@ -2,7 +2,7 @@
// only the selected method's fields render, and clicking a segment switches them. // only the selected method's fields render, and clicking a segment switches them.
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ProviderForm, type ProviderSetupState } from "./ProviderSetup"; import { KEY_HELP, ProviderForm, ProviderMark, type ProviderSetupState } from "./ProviderSetup";
import type { ProviderInfo } from "../api"; import type { ProviderInfo } from "../api";
vi.mock("../tauri", () => ({ openExternal: vi.fn() })); vi.mock("../tauri", () => ({ openExternal: vi.fn() }));
@@ -94,3 +94,29 @@ describe("ProviderForm auth-method choice", () => {
expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull(); expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull();
}); });
}); });
describe("Ark provider presentation", () => {
it("uses separate BytePlus and Volcengine brand marks", () => {
const { container, rerender } = render(
<ProviderMark name="ark" title="BytePlus Ark" />,
);
expect(container.querySelector("img")).toBeTruthy();
rerender(
<ProviderMark
name="ark-agent-plan-cn"
title="Volcengine Ark Agent Plan"
/>,
);
expect(container.querySelector("img")).toBeTruthy();
});
it("links each provider to its own API key console", () => {
expect(KEY_HELP.ark.url).toBe(
"https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey",
);
expect(KEY_HELP["ark-agent-plan-cn"].url).toContain(
"advancedActiveKey=agentPlan",
);
});
});
@@ -21,6 +21,8 @@ export const KEY_HELP: Record<string, { url: string; label: string }> = {
anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" }, anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" },
openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" }, openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" },
gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" }, gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" },
ark: { url: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey", label: "console.byteplus.com" },
"ark-agent-plan-cn": { url: "https://console.volcengine.com/ark/region:cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=agentPlan", label: "console.volcengine.com" },
openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" }, openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" },
bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" }, bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" },
fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" }, fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" },
+11 -5
View File
@@ -1,13 +1,15 @@
// Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding // Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding
// provider gallery, vendored from the MIT-licensed lobe-icons set (same // provider gallery. Most are vendored from the MIT-licensed lobe-icons set; BytePlus is
// bundled-asset posture as the connector registry — no CDN at runtime). Keys are // its official website mark, used with permission. All stay bundled like connector assets
// /v1/providers names; unknown names get no mark (the gallery falls back to a // (no CDN at runtime). Keys are /v1/providers names; unknown names get no mark (the gallery
// neutral monogram). PROVIDER_ORDER is the gallery order — recognition first, // falls back to a neutral monogram). PROVIDER_ORDER is the gallery order — recognition
// long tail behind the scroll fold. // first, long tail behind the scroll fold.
import anthropic from "./logos/anthropic.svg"; import anthropic from "./logos/anthropic.svg";
import openai from "./logos/openai.svg"; import openai from "./logos/openai.svg";
import gemini from "./logos/gemini.svg"; import gemini from "./logos/gemini.svg";
import byteplus from "./logos/byteplus.svg";
import volcengine from "./logos/volcengine.svg";
import ollama from "./logos/ollama.svg"; import ollama from "./logos/ollama.svg";
import bedrock from "./logos/bedrock.svg"; import bedrock from "./logos/bedrock.svg";
import vertex from "./logos/vertex.svg"; import vertex from "./logos/vertex.svg";
@@ -27,6 +29,8 @@ export const PROVIDER_LOGOS: Record<string, string> = {
anthropic, anthropic,
openai, openai,
gemini, gemini,
ark: byteplus,
"ark-agent-plan-cn": volcengine,
meta, meta,
ollama, ollama,
bedrock, bedrock,
@@ -47,6 +51,8 @@ export const PROVIDER_ORDER = [
"anthropic", "anthropic",
"openai", "openai",
"gemini", "gemini",
"ark",
"ark-agent-plan-cn",
"meta", "meta",
"ollama", "ollama",
"bedrock", "bedrock",
@@ -0,0 +1 @@
<svg fill="none" shape-rendering="geometricPrecision" viewBox="0 0 27.9 22.01" xmlns="http://www.w3.org/2000/svg"><title>BytePlus</title><path fill="#0066FC" d="M17.196 9.442a.252.252 0 0 1-.419-.201V.612c0-.217-.263-.34-.418-.201L6.042 9.163a.252.252 0 0 1-.419-.201v-5.98a.31.31 0 0 0-.31-.31H.31a.31.31 0 0 0-.31.31v18.73c0 .216.263.34.418.2L10.72 13.16a.252.252 0 0 1 .418.202v8.644c0 .217.264.34.419.201l10.302-8.753a.252.252 0 0 1 .418.202v5.98c0 .17.14.31.31.31h5.003c.17 0 .31-.14.31-.31V.89c0-.217-.263-.341-.418-.202z"></path></svg>

After

Width:  |  Height:  |  Size: 543 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>

After

Width:  |  Height:  |  Size: 859 B

+163
View File
@@ -254,3 +254,166 @@ def test_rest_crud(tmp_path, monkeypatch):
assert client.delete("/v1/mcp/fs").json()["ok"] is True assert client.delete("/v1/mcp/fs").json()["ok"] is True
assert client.get("/v1/mcp").json()["servers"] == [] assert client.get("/v1/mcp").json()["servers"] == []
assert client.delete("/v1/mcp/fs").json()["ok"] is False assert client.delete("/v1/mcp/fs").json()["ok"] is False
# -- failure surfacing (drill 2026-08-20: silent startup crashes) ----------------
@pytest.mark.asyncio
async def test_stdio_startup_crash_captures_stderr_tail(tmp_path, monkeypatch):
"""A stdio server that dies before initialize leaves its stderr tail behind."""
from coworker.mcp.client import MCPManager
mgr = MCPManager()
server = MCPServerDef(
name="doomed",
transport="stdio",
command="/bin/sh",
args=["-c", "echo 'usage: doomed --flag' >&2; exit 7"],
)
with pytest.raises(Exception):
await mgr.ensure(server)
tail = mgr.last_stderr("doomed")
assert tail is not None and "usage: doomed --flag" in tail
@pytest.mark.asyncio
async def test_prepare_records_failure_status_and_session_notice(
tmp_path, monkeypatch
):
"""A crashing global server surfaces: last_error + status=error + one-shot
session failure drain instead of the pre-drill silent skip."""
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
_write_json(
tmp_path / "state" / "mcp.json",
{
"mcpServers": {
"sales-db": {
"command": "/bin/sh",
"args": ["-c", "echo 'boom: bad args' >&2; exit 2"],
"enabled": True,
}
}
},
)
manager = SessionManager(data_dir=tmp_path / "data")
tools = await manager.prepare_mcp_tools("s1", workspace=str(tmp_path / "wsp"))
assert tools == []
err = manager._mcp_errors.get("sales-db")
assert err and "boom: bad args" in err
listed = {s["name"]: s for s in manager.list_mcp()}
assert listed["sales-db"]["status"] == "error"
assert "boom: bad args" in (listed["sales-db"]["last_error"] or "")
drained = manager.pop_mcp_failures("s1")
assert [n for n, _ in drained] == ["sales-db"]
assert "boom: bad args" in (drained[0][1] or "")
assert manager.pop_mcp_failures("s1") == [] # one-shot
# -- explicit connect (UX-033: add → Test → fix, without opening a session) ------
@pytest.mark.asyncio
async def test_connect_mcp_failure_includes_stderr_tail(tmp_path, monkeypatch):
"""The Test button's connect path reports the same stderr evidence as the
session path a crashing stdio server yields error + status=error."""
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
_write_json(
tmp_path / "state" / "mcp.json",
{
"mcpServers": {
"doomed": {
"command": "/bin/sh",
"args": ["-c", "echo 'usage: doomed --flag' >&2; exit 7"],
"enabled": True,
}
}
},
)
manager = SessionManager(data_dir=tmp_path / "data")
result = await manager.connect_mcp("doomed")
assert result["ok"] is False
assert "usage: doomed --flag" in result["error"]
listed = {s["name"]: s for s in manager.list_mcp()}
assert listed["doomed"]["status"] == "error"
assert listed["doomed"]["auth_hint"] is False
# Removing the server takes its stale failure state with it.
manager.delete_mcp("doomed")
assert manager._mcp_errors.get("doomed") is None
@pytest.mark.asyncio
async def test_connect_mcp_http_401_sets_auth_hint(tmp_path, monkeypatch):
"""An anonymous connect that hits 401 is reported as "needs sign-in" (the GUI
offers the OAuth switch), not as a raw HTTP error dump."""
import http.server
import threading
class _Deny(http.server.BaseHTTPRequestHandler):
def _deny(self):
self.send_response(401)
self.send_header("Content-Length", "0")
self.end_headers()
do_GET = do_POST = do_DELETE = _deny
def log_message(self, *args): # keep pytest output clean
pass
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Deny)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
_write_json(
tmp_path / "state" / "mcp.json",
{
"mcpServers": {
"guarded": {
"url": f"http://127.0.0.1:{srv.server_address[1]}/mcp",
"enabled": True,
}
}
},
)
manager = SessionManager(data_dir=tmp_path / "data")
result = await manager.connect_mcp("guarded")
assert result["ok"] is False
assert "sign in" in result["error"]
listed = {s["name"]: s for s in manager.list_mcp()}
assert listed["guarded"]["auth_hint"] is True
assert listed["guarded"]["status"] == "error"
assert listed["guarded"]["last_test_at"] is None # failed probe stamps nothing
finally:
srv.shutdown()
srv.server_close()
def test_last_test_at_persists_and_clears_on_delete(tmp_path, monkeypatch):
"""The "Ready · tested ⟨when⟩" claim lives in prefs: it survives a manager
restart and is dropped with the server (a re-add is not pre-trusted)."""
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
_write_json(
tmp_path / "state" / "mcp.json",
{"mcpServers": {"fs": {"command": "echo", "enabled": True}}},
)
manager = SessionManager(data_dir=tmp_path / "data")
manager._prefs.setdefault("mcp_last_test", {})["fs"] = 1_700_000_000
manager._save_prefs()
# A fresh manager (same data dir) still reports the stamp.
manager2 = SessionManager(data_dir=tmp_path / "data")
listed = {s["name"]: s for s in manager2.list_mcp()}
assert listed["fs"]["last_test_at"] == 1_700_000_000
manager2.delete_mcp("fs")
manager3 = SessionManager(data_dir=tmp_path / "data")
assert manager3._prefs.get("mcp_last_test", {}).get("fs") is None
+54 -1
View File
@@ -17,6 +17,43 @@ from coworker.providers.openai_responses import (
convert_tools, convert_tools,
) )
def test_responses_custom_base_url_reaches_sdk(monkeypatch):
captured: dict = {}
def fake_openai(**kwargs):
captured.update(kwargs)
return SimpleNamespace()
monkeypatch.setattr("openai.OpenAI", fake_openai)
provider = OpenAIResponsesProvider(
api_key="ark-key",
base_url="https://ark.example/api/v3/",
)
provider._ensure_client()
assert captured == {
"api_key": "ark-key",
"base_url": "https://ark.example/api/v3",
}
def test_stock_openai_responses_path_unchanged(monkeypatch):
"""Lockdown: stock OpenAI must not receive a vendor base URL."""
captured: dict = {}
def fake_openai(**kwargs):
captured.update(kwargs)
return SimpleNamespace()
monkeypatch.setattr("openai.OpenAI", fake_openai)
provider = OpenAIResponsesProvider(api_key="openai-key")
provider._ensure_client()
assert captured == {"api_key": "openai-key"}
# -- fakes ------------------------------------------------------------------------ # -- fakes ------------------------------------------------------------------------
@@ -254,7 +291,7 @@ def test_convert_tools_flattens_function_schemas():
# -- complete() ---------------------------------------------------------------------- # -- complete() ----------------------------------------------------------------------
def test_complete_text_turn_and_request_shape(): def test_complete_default_request_shape_PathsUnchanged():
fake = _FakeClient(response=_response([_message_item("hello")])) fake = _FakeClient(response=_response([_message_item("hello")]))
provider = OpenAIResponsesProvider(client=fake) provider = OpenAIResponsesProvider(client=fake)
turn = provider.complete( turn = provider.complete(
@@ -273,6 +310,22 @@ def test_complete_text_turn_and_request_shape():
assert fake.kwargs["reasoning"] == {"summary": "auto"} assert fake.kwargs["reasoning"] == {"summary": "auto"}
def test_complete_can_omit_reasoning_summary_but_keep_encrypted_content():
"""BytePlus accepts encrypted reasoning output but rejects reasoning.summary."""
fake = _FakeClient(response=_response([_message_item("hello")]))
provider = OpenAIResponsesProvider(client=fake, reasoning_summary=False)
provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
assert "reasoning" not in fake.kwargs
assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
def test_reasoning_summary_capability_rejects_unknown_mode():
with pytest.raises(TypeError, match="reasoning_summary must be a bool"):
OpenAIResponsesProvider(client=SimpleNamespace(), reasoning_summary="auto")
def test_complete_parses_function_calls_with_call_ids(): def test_complete_parses_function_calls_with_call_ids():
fake = _FakeClient( fake = _FakeClient(
response=_response( response=_response(
+58
View File
@@ -41,6 +41,18 @@ def _patch_get(monkeypatch, status=200, capture=None, raise_exc=None):
monkeypatch.setattr("httpx.get", fake_get) monkeypatch.setattr("httpx.get", fake_get)
def _patch_post(monkeypatch, status=200, capture=None, raise_exc=None):
def fake_post(url, **kwargs):
if capture is not None:
capture["url"] = url
capture.update(kwargs)
if raise_exc is not None:
raise raise_exc
return SimpleNamespace(status_code=status)
monkeypatch.setattr("httpx.post", fake_post)
def test_verify_openai_ok(monkeypatch): def test_verify_openai_ok(monkeypatch):
cap: dict = {} cap: dict = {}
_patch_get(monkeypatch, status=200, capture=cap) _patch_get(monkeypatch, status=200, capture=cap)
@@ -91,6 +103,52 @@ def test_verify_ollama_uses_v1_models_no_key(monkeypatch):
assert "headers" not in cap # keyless assert "headers" not in cap # keyless
@pytest.mark.parametrize(
"name,base_url,model",
[
(
"ark",
"https://ark.ap-southeast.bytepluses.com/api/v3",
"dola-seed-evolving-latest-version",
),
(
"ark-agent-plan-cn",
"https://ark.cn-beijing.volces.com/api/plan/v3",
"doubao-seed-evolving",
),
],
)
def test_verify_ark_uses_non_persisted_responses_probe(
monkeypatch, name, base_url, model
):
"""Reverse-verified probe: the captured fixture must be non-empty and provider-specific."""
cap: dict = {}
_patch_post(monkeypatch, status=200, capture=cap)
assert verify_provider_key(name, api_key="ark-key") == {"ok": True}
assert cap["url"] == base_url + "/responses"
assert cap["headers"]["Authorization"] == "Bearer ark-key"
assert cap["json"] == {
"model": model,
"input": "Reply with OK.",
"max_output_tokens": 1,
"store": False,
}
def test_verify_ark_profile_endpoint_override(monkeypatch):
cap: dict = {}
_patch_post(monkeypatch, status=200, capture=cap)
verify_provider_key(
"ark",
api_key="ark-key",
base_url="https://gateway.example/ark/v3/",
)
assert cap["url"] == "https://gateway.example/ark/v3/responses"
def test_verify_network_error_is_clean(monkeypatch): def test_verify_network_error_is_clean(monkeypatch):
_patch_get(monkeypatch, raise_exc=ConnectionError("boom")) _patch_get(monkeypatch, raise_exc=ConnectionError("boom"))
res = verify_provider_key("openai", api_key="sk-x") res = verify_provider_key("openai", api_key="sk-x")
+115
View File
@@ -338,6 +338,121 @@ def test_compat_builder_never_leaks_the_openai_key(monkeypatch):
build_provider_client("kimi", {}, None) build_provider_client("kimi", {}, None)
ARK_RESPONSES_VENDORS = {
"ark": {
"base_url": "https://ark.ap-southeast.bytepluses.com/api/v3",
"env_key": "ARK_API_KEY",
"recommended_model": "dola-seed-evolving-latest-version",
"reasoning_summary": False,
},
"ark-agent-plan-cn": {
"base_url": "https://ark.cn-beijing.volces.com/api/plan/v3",
"env_key": "ARK_AGENT_PLAN_CN_API_KEY",
"recommended_model": "doubao-seed-evolving",
"reasoning_summary": True,
},
}
def test_ark_responses_descriptors_are_separate():
from coworker.providers.registry import get_descriptor
for name, expected in ARK_RESPONSES_VENDORS.items():
d = get_descriptor(name)
assert d is not None and d.needs_key, name
assert d.env_key == expected["env_key"]
assert d.recommended_model == expected["recommended_model"]
assert "Responses API" in d.blurb
base = next(f for f in d.fields if f.key == "base_url")
assert base.default == expected["base_url"]
assert not base.required
def test_ark_responses_builder_capabilities_PathsUnchanged(monkeypatch):
from coworker.providers.openai_responses import OpenAIResponsesProvider
from coworker.providers.registry import build_provider_client
monkeypatch.setenv("ARK_AGENT_PLAN_CN_API_KEY", "plan-key")
bp = build_provider_client("ark", {"api_key": "bp-key"}, None)
plan = build_provider_client("ark-agent-plan-cn", {}, None)
assert isinstance(bp, OpenAIResponsesProvider)
assert (bp._api_key, bp._base_url) == (
"bp-key",
ARK_RESPONSES_VENDORS["ark"]["base_url"],
)
assert isinstance(plan, OpenAIResponsesProvider)
assert (plan._api_key, plan._base_url) == (
"plan-key",
ARK_RESPONSES_VENDORS["ark-agent-plan-cn"]["base_url"],
)
assert bp._reasoning_summary is ARK_RESPONSES_VENDORS["ark"]["reasoning_summary"]
assert plan._reasoning_summary is ARK_RESPONSES_VENDORS["ark-agent-plan-cn"][
"reasoning_summary"
]
def test_ark_responses_never_leak_the_openai_key(monkeypatch):
import pytest
from coworker.providers.registry import build_provider_client
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real")
monkeypatch.delenv("ARK_API_KEY", raising=False)
with pytest.raises(RuntimeError, match="BytePlus Ark"):
build_provider_client("ark", {}, None)
def test_existing_chat_compat_paths_unchanged():
"""Lockdown: adding Responses vendors must not migrate existing compat providers."""
from coworker.providers.registry import build_provider_client
provider = build_provider_client("deepseek", {"api_key": "ds-key"}, None)
assert isinstance(provider, OpenAIProvider)
assert provider._base_url == COMPAT_VENDORS["deepseek"]
def test_ark_curated_models_are_strict_allowlists():
from coworker.providers.matrix import models_for_provider
assert models_for_provider("ark") == [
"dola-seed-evolving-latest-version",
"dola-seed-2-1-turbo-260628",
]
assert models_for_provider("ark-agent-plan-cn") == [
"doubao-seed-evolving",
"doubao-seed-2.1-turbo",
]
def test_ark_models_route_and_get_verified_agent_capabilities():
from coworker.providers.router import ProviderRouter
models = (
"ark:dola-seed-evolving-latest-version",
"ark:dola-seed-2-1-turbo-260628",
"ark-agent-plan-cn:doubao-seed-evolving",
"ark-agent-plan-cn:doubao-seed-2.1-turbo",
)
router = ProviderRouter.__new__(ProviderRouter)
for model in models:
prefix, bare = model.split(":", 1)
assert router._provider_name(model) == prefix
assert ProviderRouter._bare(model) == bare
caps = capabilities_for(model)
assert caps.tools and caps.parallel_tool_calls and caps.streaming
assert not caps.vision
def test_ark_recommended_models_are_curated():
from coworker.providers.matrix import models_for_provider
from coworker.providers.registry import get_descriptor
for name in ARK_RESPONSES_VENDORS:
d = get_descriptor(name)
assert d.recommended_model in models_for_provider(name)
def test_compat_models_route_and_get_tool_capabilities(): def test_compat_models_route_and_get_tool_capabilities():
from coworker.providers.router import ProviderRouter from coworker.providers.router import ProviderRouter