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