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:
|
Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box:
|
||||||
|
|
||||||
**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.
|
**OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.
|
||||||
|
|
||||||
A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.
|
A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -13,8 +13,9 @@ Tool execution from the (sync) ToolRegistry bridges back here via
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import tempfile
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from typing import Any, Optional
|
from typing import Any, IO, Optional
|
||||||
|
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
@@ -23,6 +24,25 @@ from mcp.client.streamable_http import streamablehttp_client
|
|||||||
from .config import MCPServerDef
|
from .config import MCPServerDef
|
||||||
|
|
||||||
|
|
||||||
|
_STDERR_TAIL_LINES = 20
|
||||||
|
_STDERR_TAIL_CHARS = 1500
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tail(errfile: Optional[IO[str]]) -> Optional[str]:
|
||||||
|
"""Last few lines of a captured stderr file — the crash evidence, not the log."""
|
||||||
|
if errfile is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
errfile.seek(0)
|
||||||
|
text = errfile.read()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
lines = [ln for ln in text.strip().splitlines() if ln.strip()]
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
return "\n".join(lines[-_STDERR_TAIL_LINES:])[-_STDERR_TAIL_CHARS:]
|
||||||
|
|
||||||
|
|
||||||
class _Conn:
|
class _Conn:
|
||||||
def __init__(self, session: ClientSession, tools: list[Any]) -> None:
|
def __init__(self, session: ClientSession, tools: list[Any]) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
@@ -36,6 +56,7 @@ class MCPManager:
|
|||||||
def __init__(self, secrets: Any = None) -> None:
|
def __init__(self, secrets: Any = None) -> None:
|
||||||
self._conns: dict[str, _Conn] = {}
|
self._conns: dict[str, _Conn] = {}
|
||||||
self._tasks: dict[str, asyncio.Task] = {}
|
self._tasks: dict[str, asyncio.Task] = {}
|
||||||
|
self._stderr_tails: dict[str, str] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
# SecretStore for OAuth servers' token persistence (mcp/oauth.py); lazy default
|
# SecretStore for OAuth servers' token persistence (mcp/oauth.py); lazy default
|
||||||
# so library/CLI construction without secrets keeps working.
|
# so library/CLI construction without secrets keeps working.
|
||||||
@@ -64,6 +85,10 @@ class MCPManager:
|
|||||||
async def tools(self, server: MCPServerDef) -> list[Any]:
|
async def tools(self, server: MCPServerDef) -> list[Any]:
|
||||||
return (await self.ensure(server)).tools
|
return (await self.ensure(server)).tools
|
||||||
|
|
||||||
|
def last_stderr(self, name: str) -> Optional[str]:
|
||||||
|
"""Stderr tail from the most recent failed startup of `name`, if any."""
|
||||||
|
return self._stderr_tails.get(name)
|
||||||
|
|
||||||
async def call(
|
async def call(
|
||||||
self, name: str, tool: str, arguments: Optional[dict[str, Any]]
|
self, name: str, tool: str, arguments: Optional[dict[str, Any]]
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -88,6 +113,7 @@ class MCPManager:
|
|||||||
async def _serve(
|
async def _serve(
|
||||||
self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False
|
self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
|
errfile = None
|
||||||
try:
|
try:
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
if server.transport == "http":
|
if server.transport == "http":
|
||||||
@@ -124,18 +150,34 @@ class MCPManager:
|
|||||||
env=server.env or None,
|
env=server.env or None,
|
||||||
cwd=server.cwd,
|
cwd=server.cwd,
|
||||||
)
|
)
|
||||||
read, write = await stack.enter_async_context(stdio_client(params))
|
# Capture the child's stderr so a startup crash leaves evidence
|
||||||
|
# the UI can show (the SDK needs a real file descriptor here).
|
||||||
|
errfile = tempfile.TemporaryFile(
|
||||||
|
mode="w+", encoding="utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
read, write = await stack.enter_async_context(
|
||||||
|
stdio_client(params, errlog=errfile)
|
||||||
|
)
|
||||||
session = await stack.enter_async_context(ClientSession(read, write))
|
session = await stack.enter_async_context(ClientSession(read, write))
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
listed = await session.list_tools()
|
listed = await session.list_tools()
|
||||||
conn = _Conn(session, list(listed.tools))
|
conn = _Conn(session, list(listed.tools))
|
||||||
|
self._stderr_tails.pop(server.name, None)
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_result(conn)
|
ready.set_result(conn)
|
||||||
await conn.shutdown.wait()
|
await conn.shutdown.wait()
|
||||||
except Exception as exc: # connection / init failure
|
except Exception as exc: # connection / init failure
|
||||||
|
tail = _read_tail(errfile)
|
||||||
|
if tail:
|
||||||
|
self._stderr_tails[server.name] = tail
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
finally:
|
finally:
|
||||||
|
if errfile is not None:
|
||||||
|
try:
|
||||||
|
errfile.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
self._conns.pop(server.name, None)
|
self._conns.pop(server.name, None)
|
||||||
self._tasks.pop(server.name, None)
|
self._tasks.pop(server.name, None)
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ def is_auth_required(exc: BaseException) -> bool:
|
|||||||
return is_auth_required(cause) if cause is not None else False
|
return is_auth_required(cause) if cause is not None else False
|
||||||
|
|
||||||
|
|
||||||
|
def is_http_auth_error(exc: BaseException) -> bool:
|
||||||
|
"""True if an HTTP 401/403 is anywhere in the exception tree — an anonymous
|
||||||
|
connect hit a server that wants credentials, so the fix is sign-in (switch
|
||||||
|
the entry to `auth: oauth`), not a different config. Same tree walk as
|
||||||
|
is_auth_required: the transport's task groups wrap and chain freely."""
|
||||||
|
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||||
|
if status in (401, 403):
|
||||||
|
return True
|
||||||
|
for sub in getattr(exc, "exceptions", None) or []: # ExceptionGroup
|
||||||
|
if is_http_auth_error(sub):
|
||||||
|
return True
|
||||||
|
cause = exc.__cause__ or exc.__context__
|
||||||
|
return is_http_auth_error(cause) if cause is not None else False
|
||||||
|
|
||||||
|
|
||||||
# -- single-slot interactive flow ------------------------------------------------
|
# -- single-slot interactive flow ------------------------------------------------
|
||||||
_pending: Optional[asyncio.Future] = None
|
_pending: Optional[asyncio.Future] = None
|
||||||
# The last authorize URL we sent the user to — surfaced over REST so the GUI can offer
|
# The last authorize URL we sent the user to — surfaced over REST so the GUI can offer
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ MATRIX: dict[str, ModelEntry] = {
|
|||||||
"gemini:gemini-2.5-flash": ModelEntry(
|
"gemini:gemini-2.5-flash": ModelEntry(
|
||||||
"Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576
|
"Gemini 2.5 Flash · Google", _AGENTIC_VISION, 1_048_576
|
||||||
),
|
),
|
||||||
|
# Ark Responses API providers (verified 2026-08-14). BytePlus pay-as-you-go and
|
||||||
|
# Volcengine Agent Plan intentionally use separate provider prefixes because their
|
||||||
|
# endpoints, credentials, regions, and model catalogs are not interchangeable.
|
||||||
|
"ark:dola-seed-evolving-latest-version": ModelEntry(
|
||||||
|
"Dola Seed Evolving · BytePlus Ark", context_window=256_000
|
||||||
|
),
|
||||||
|
"ark:dola-seed-2-1-turbo-260628": ModelEntry(
|
||||||
|
"Dola Seed 2.1 Turbo · BytePlus Ark", context_window=256_000
|
||||||
|
),
|
||||||
|
"ark-agent-plan-cn:doubao-seed-evolving": ModelEntry(
|
||||||
|
"Doubao Seed Evolving · Volcengine Agent Plan", context_window=256_000
|
||||||
|
),
|
||||||
|
"ark-agent-plan-cn:doubao-seed-2.1-turbo": ModelEntry(
|
||||||
|
"Doubao Seed 2.1 Turbo · Volcengine Agent Plan", context_window=256_000
|
||||||
|
),
|
||||||
# -- direct OpenAI-compatible vendors ----------------------------------------
|
# -- direct OpenAI-compatible vendors ----------------------------------------
|
||||||
# Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via
|
# Muse Spark (Meta Model API, public preview 2026-07-09): multimodal + tools via
|
||||||
# their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls
|
# their OpenAI-compat surface. Vision yes; PDFs unverified over compat — falls
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""OpenAI Responses provider — native OpenAI models via `/v1/responses`.
|
"""OpenAI Responses provider — native and compatible models via `/responses`.
|
||||||
|
|
||||||
Chat Completions rejects function tools combined with any `reasoning_effort` other than
|
Chat Completions rejects function tools combined with any `reasoning_effort` other than
|
||||||
`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
|
`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
|
||||||
@@ -8,9 +8,10 @@ reasoning + tools at real effort levels, streamed reasoning summaries (→ the s
|
|||||||
chain-of-thought continuity across tool round-trips via `store: false` +
|
chain-of-thought continuity across tool round-trips via `store: false` +
|
||||||
`include: ["reasoning.encrypted_content"]` — nothing retained server-side.
|
`include: ["reasoning.encrypted_content"]` — nothing retained server-side.
|
||||||
|
|
||||||
Routing: the `openai` provider entry with NO custom base_url builds this class; a custom
|
Routing: the `openai` provider entry with NO custom base_url builds this class. Most custom
|
||||||
endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the
|
endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions
|
||||||
Chat Completions `OpenAIProvider` (registry.py).
|
`OpenAIProvider`; vendors that explicitly implement the Responses wire can opt into this
|
||||||
|
class with their own base URL (registry.py).
|
||||||
|
|
||||||
Like the other native providers, this is mostly a pair of pure converters from the
|
Like the other native providers, this is mostly a pair of pure converters from the
|
||||||
canonical OpenAI-chat-shaped history to Responses `input` items. What the converters
|
canonical OpenAI-chat-shaped history to Responses `input` items. What the converters
|
||||||
@@ -289,14 +290,20 @@ class OpenAIResponsesProvider(ProviderClient):
|
|||||||
default_model: str = "gpt-5.6-sol",
|
default_model: str = "gpt-5.6-sol",
|
||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
secrets: Any = None,
|
secrets: Any = None,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
reasoning_summary: bool = True,
|
||||||
):
|
):
|
||||||
# Same deferred-client contract as OpenAIProvider: built lazily so an engine can be
|
# Same deferred-client contract as OpenAIProvider: built lazily so an engine can be
|
||||||
# assembled before any key exists; key resolves at call time (explicit → env →
|
# assembled before any key exists; key resolves at call time (explicit → env →
|
||||||
# SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint
|
# SecretStore). Tests inject a `client` directly. `base_url` is opt-in: stock OpenAI
|
||||||
# routes to the Chat Completions provider instead (registry.py).
|
# leaves it unset, while Responses-compatible vendors can supply their own endpoint.
|
||||||
self._client = client
|
self._client = client
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._secrets = secrets
|
self._secrets = secrets
|
||||||
|
self._base_url = (base_url or "").strip().rstrip("/") or None
|
||||||
|
if not isinstance(reasoning_summary, bool):
|
||||||
|
raise TypeError("reasoning_summary must be a bool")
|
||||||
|
self._reasoning_summary = reasoning_summary
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
|
|
||||||
def _ensure_client(self) -> Any:
|
def _ensure_client(self) -> Any:
|
||||||
@@ -310,7 +317,10 @@ class OpenAIResponsesProvider(ProviderClient):
|
|||||||
"No model API key configured. Set OPENAI_API_KEY in the environment, "
|
"No model API key configured. Set OPENAI_API_KEY in the environment, "
|
||||||
"or add your key in Manage → Settings."
|
"or add your key in Manage → Settings."
|
||||||
)
|
)
|
||||||
self._client = OpenAI(api_key=key)
|
kwargs = {"api_key": key}
|
||||||
|
if self._base_url:
|
||||||
|
kwargs["base_url"] = self._base_url
|
||||||
|
self._client = OpenAI(**kwargs)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def _request_kwargs(
|
def _request_kwargs(
|
||||||
@@ -331,9 +341,10 @@ class OpenAIResponsesProvider(ProviderClient):
|
|||||||
# `_openai` sidecar instead, and summaries feed the GUI's thinking display.
|
# `_openai` sidecar instead, and summaries feed the GUI's thinking display.
|
||||||
"store": False,
|
"store": False,
|
||||||
"include": ["reasoning.encrypted_content"],
|
"include": ["reasoning.encrypted_content"],
|
||||||
"reasoning": {"summary": "auto"},
|
|
||||||
**{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
|
**{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
|
||||||
}
|
}
|
||||||
|
if self._reasoning_summary:
|
||||||
|
kwargs["reasoning"] = {"summary": "auto"}
|
||||||
if instructions:
|
if instructions:
|
||||||
kwargs["instructions"] = instructions
|
kwargs["instructions"] = instructions
|
||||||
if tools:
|
if tools:
|
||||||
|
|||||||
@@ -210,6 +210,37 @@ def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] =
|
|||||||
return build
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_responses_compat(
|
||||||
|
vendor: str,
|
||||||
|
default_base_url: str,
|
||||||
|
env_key: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
reasoning_summary: bool = True,
|
||||||
|
):
|
||||||
|
"""Builder factory for vendors that explicitly implement the OpenAI Responses API.
|
||||||
|
|
||||||
|
Credentials stay isolated to the vendor's own profile/environment variable, matching the
|
||||||
|
Chat Completions compat path above. In particular, an OpenAI key is never sent to Ark.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(profile: dict[str, Any], secrets: Any) -> ProviderClient:
|
||||||
|
base_url = ((profile or {}).get("base_url") or "").strip() or default_base_url
|
||||||
|
api_key = ((profile or {}).get("api_key") or "").strip() or (
|
||||||
|
os.environ.get(env_key, "").strip() if env_key else ""
|
||||||
|
)
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No {vendor} API key configured — add it in Settings ▸ Models."
|
||||||
|
)
|
||||||
|
return OpenAIResponsesProvider(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
reasoning_summary=reasoning_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
def _compat(
|
def _compat(
|
||||||
name: str,
|
name: str,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -248,6 +279,49 @@ def _compat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _responses_compat(
|
||||||
|
name: str,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
recommended_model: str,
|
||||||
|
env_key: str,
|
||||||
|
endpoint_help: str = "",
|
||||||
|
reasoning_summary: bool = True,
|
||||||
|
) -> ProviderDescriptor:
|
||||||
|
"""Descriptor for a vendor exposing the OpenAI Responses API."""
|
||||||
|
return ProviderDescriptor(
|
||||||
|
name=name,
|
||||||
|
title=title,
|
||||||
|
needs_key=True,
|
||||||
|
fields=[
|
||||||
|
ProviderField(
|
||||||
|
"api_key",
|
||||||
|
f"{title} API key",
|
||||||
|
secret=True,
|
||||||
|
),
|
||||||
|
ProviderField(
|
||||||
|
"base_url",
|
||||||
|
"Endpoint",
|
||||||
|
required=False,
|
||||||
|
default=base_url,
|
||||||
|
placeholder=base_url,
|
||||||
|
help=endpoint_help
|
||||||
|
or f"Prefilled with {title}'s official Responses endpoint.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
build=_openai_responses_compat(
|
||||||
|
title,
|
||||||
|
base_url,
|
||||||
|
env_key,
|
||||||
|
reasoning_summary=reasoning_summary,
|
||||||
|
),
|
||||||
|
recommended_model=recommended_model,
|
||||||
|
env_key=env_key,
|
||||||
|
blurb=f"Uses {title}'s OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTORS: list[ProviderDescriptor] = [
|
DESCRIPTORS: list[ProviderDescriptor] = [
|
||||||
ProviderDescriptor(
|
ProviderDescriptor(
|
||||||
name="openai",
|
name="openai",
|
||||||
@@ -462,6 +536,26 @@ DESCRIPTORS: list[ProviderDescriptor] = [
|
|||||||
blurb="Runs models inside your own Google Cloud project. Gemini and Claude use "
|
blurb="Runs models inside your own Google Cloud project. Gemini and Claude use "
|
||||||
"their native APIs; open-weight models go through the Vertex MaaS endpoint.",
|
"their native APIs; open-weight models go through the Vertex MaaS endpoint.",
|
||||||
),
|
),
|
||||||
|
# Ark has two intentionally separate provider identities. BytePlus pay-as-you-go and
|
||||||
|
# Volcengine Agent Plan use different regions, endpoints, credentials, and model catalogs;
|
||||||
|
# combining them would let one provider profile route a model to the wrong service.
|
||||||
|
_responses_compat(
|
||||||
|
"ark",
|
||||||
|
"BytePlus Ark",
|
||||||
|
base_url="https://ark.ap-southeast.bytepluses.com/api/v3",
|
||||||
|
recommended_model="dola-seed-evolving-latest-version",
|
||||||
|
env_key="ARK_API_KEY",
|
||||||
|
endpoint_help="BytePlus Ark's Asia Pacific endpoint. This provider is separate from Volcengine Ark Agent Plan.",
|
||||||
|
reasoning_summary=False,
|
||||||
|
),
|
||||||
|
_responses_compat(
|
||||||
|
"ark-agent-plan-cn",
|
||||||
|
"Volcengine Ark Agent Plan",
|
||||||
|
base_url="https://ark.cn-beijing.volces.com/api/plan/v3",
|
||||||
|
recommended_model="doubao-seed-evolving",
|
||||||
|
env_key="ARK_AGENT_PLAN_CN_API_KEY",
|
||||||
|
endpoint_help="Volcengine Ark Agent Plan's China (Beijing) endpoint. It requires an Agent Plan API key.",
|
||||||
|
),
|
||||||
# OpenAI-compatible vendors, listed as first-class providers so users don't need to know the
|
# OpenAI-compatible vendors, listed as first-class providers so users don't need to know the
|
||||||
# "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps
|
# "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps
|
||||||
# its own key profile; the endpoint is prefilled and editable (regional variants in `help`).
|
# its own key profile; the endpoint is prefilled and editable (regional variants in `help`).
|
||||||
@@ -803,9 +897,11 @@ def verify_provider_key(
|
|||||||
fields: Optional[dict[str, Any]] = None,
|
fields: Optional[dict[str, Any]] = None,
|
||||||
timeout: float = 10.0,
|
timeout: float = 10.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate a provider's credentials with one cheap, read-only call (list models) — the same
|
"""Validate a provider's credentials with one cheap call — usually list models.
|
||||||
pattern connectors use to validate tokens. Transient: callers pass the key directly so a user
|
|
||||||
can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers
|
Ark's Responses-compatible data plane does not document a `/models` probe, so its Test button
|
||||||
|
sends a non-persisted one-token Responses request instead. Callers pass the key directly so a
|
||||||
|
user can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers
|
||||||
(Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url.
|
(Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url.
|
||||||
"""
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
@@ -832,6 +928,22 @@ def verify_provider_key(
|
|||||||
elif name == "ollama":
|
elif name == "ollama":
|
||||||
base = _normalize_ollama_url(base_url)
|
base = _normalize_ollama_url(base_url)
|
||||||
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
|
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
|
||||||
|
elif name in ("ark", "ark-agent-plan-cn"):
|
||||||
|
default_base = next(
|
||||||
|
(f.default for f in d.fields if f.key == "base_url" and f.default), ""
|
||||||
|
)
|
||||||
|
base = (base_url or "").strip().rstrip("/") or default_base.rstrip("/")
|
||||||
|
resp = httpx.post(
|
||||||
|
base + "/responses",
|
||||||
|
headers={"Authorization": f"Bearer {key}"},
|
||||||
|
json={
|
||||||
|
"model": d.recommended_model,
|
||||||
|
"input": "Reply with OK.",
|
||||||
|
"max_output_tokens": 1,
|
||||||
|
"store": False,
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
|
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
|
||||||
default_base = next(
|
default_base = next(
|
||||||
(f.default for f in d.fields if f.key == "base_url" and f.default), ""
|
(f.default for f in d.fields if f.key == "base_url" and f.default), ""
|
||||||
|
|||||||
@@ -2359,6 +2359,16 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
)
|
)
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return
|
return
|
||||||
|
# MCP servers that failed to start while preparing this session's tools:
|
||||||
|
# leave a quiet, persistent notice instead of the session silently lacking
|
||||||
|
# them (drill 2026-08-20: three silent startup failures in a row).
|
||||||
|
for name, err in manager.pop_mcp_failures(session_id):
|
||||||
|
detail = f": {err}" if err else ""
|
||||||
|
engine._append_notice(
|
||||||
|
"mcp_error",
|
||||||
|
f"MCP server “{name}” failed to start{detail}"[:300]
|
||||||
|
+ " — see Settings ▸ MCP",
|
||||||
|
)
|
||||||
# Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked
|
# Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked
|
||||||
# Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now).
|
# Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now).
|
||||||
engine.is_attended = lambda: _visibility() == VIS_INLINE
|
engine.is_attended = lambda: _visibility() == VIS_INLINE
|
||||||
|
|||||||
@@ -186,6 +186,12 @@ class SessionManager:
|
|||||||
# feeds list_mcp's status so the GUI can show "authorizing…" and failures.
|
# feeds list_mcp's status so the GUI can show "authorizing…" and failures.
|
||||||
self._mcp_authorizing: set[str] = set()
|
self._mcp_authorizing: set[str] = set()
|
||||||
self._mcp_errors: dict[str, str] = {}
|
self._mcp_errors: dict[str, str] = {}
|
||||||
|
# http servers whose anonymous connect came back 401/403 — the failure is
|
||||||
|
# "needs sign-in", so the GUI offers the OAuth switch instead of a raw error.
|
||||||
|
self._mcp_auth_hints: set[str] = set()
|
||||||
|
# Servers that failed to connect while preparing a session's tools —
|
||||||
|
# drained once by the WS handler to append a transcript notice.
|
||||||
|
self._mcp_session_failures: dict[str, list[str]] = {}
|
||||||
self.gateway: Optional[Gateway] = None
|
self.gateway: Optional[Gateway] = None
|
||||||
self._data_base = base
|
self._data_base = base
|
||||||
# Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file.
|
# Desktop/UI prefs (default model, onboarding state) — not secrets; a plain JSON file.
|
||||||
@@ -1186,6 +1192,7 @@ class SessionManager:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
conn = await self.mcp.ensure(server)
|
conn = await self.mcp.ensure(server)
|
||||||
|
self._mcp_errors.pop(server.name, None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if mcp_oauth.is_auth_required(exc):
|
if mcp_oauth.is_auth_required(exc):
|
||||||
# Stored tokens no longer refresh (vendor rotated/expired
|
# Stored tokens no longer refresh (vendor rotated/expired
|
||||||
@@ -1198,7 +1205,22 @@ class SessionManager:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"mcp %s needs re-auth; skipped for this session", server.name
|
"mcp %s needs re-auth; skipped for this session", server.name
|
||||||
)
|
)
|
||||||
# else: bad command / unreachable url — skip, don't break the session
|
else:
|
||||||
|
# Bad command / crashed child / unreachable url — the session
|
||||||
|
# still runs without the tools, but the failure must not be
|
||||||
|
# silent (three-for-three silent failures in the 2026-08-20
|
||||||
|
# drill): record it for the MCP page and the session notice.
|
||||||
|
msg = str(exc) or exc.__class__.__name__
|
||||||
|
tail = self.mcp.last_stderr(server.name)
|
||||||
|
if tail:
|
||||||
|
msg = f"{msg} — {tail}"
|
||||||
|
self._mcp_errors[server.name] = msg[:500]
|
||||||
|
logger.warning(
|
||||||
|
"mcp %s failed to connect: %s", server.name, msg[:500]
|
||||||
|
)
|
||||||
|
self._mcp_session_failures.setdefault(session_id, []).append(
|
||||||
|
server.name
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
callables = build_callables(
|
callables = build_callables(
|
||||||
server,
|
server,
|
||||||
@@ -1217,6 +1239,12 @@ class SessionManager:
|
|||||||
out.extend(callables)
|
out.extend(callables)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def pop_mcp_failures(self, session_id: str) -> list[tuple[str, Optional[str]]]:
|
||||||
|
"""Drain (name, error) for servers that failed while preparing this session's
|
||||||
|
tools — consumed once by the WS handler to append a transcript notice."""
|
||||||
|
names = self._mcp_session_failures.pop(session_id, [])
|
||||||
|
return [(n, self._mcp_errors.get(n)) for n in names]
|
||||||
|
|
||||||
def list_mcp(self) -> list[dict[str, Any]]:
|
def list_mcp(self) -> list[dict[str, Any]]:
|
||||||
"""Servers from the global config + connection status (does not connect)."""
|
"""Servers from the global config + connection status (does not connect)."""
|
||||||
from ..mcp import oauth as mcp_oauth
|
from ..mcp import oauth as mcp_oauth
|
||||||
@@ -1240,6 +1268,12 @@ class SessionManager:
|
|||||||
status = "authorizing"
|
status = "authorizing"
|
||||||
elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets):
|
elif is_oauth and not mcp_oauth.has_tokens(name, self.secrets):
|
||||||
status = "needs_auth"
|
status = "needs_auth"
|
||||||
|
elif name in self._mcp_errors and not is_oauth:
|
||||||
|
# Startup/connection failure (stdio crash, unreachable url) — the
|
||||||
|
# drill class. OAuth servers keep their softer statuses: acquiring
|
||||||
|
# tokens supersedes a stale sign-in error (the GUI still prints
|
||||||
|
# last_error under the row either way).
|
||||||
|
status = "error"
|
||||||
else:
|
else:
|
||||||
status = "configured"
|
status = "configured"
|
||||||
out.append(
|
out.append(
|
||||||
@@ -1258,6 +1292,8 @@ class SessionManager:
|
|||||||
"requires_approval": bool(raw.get("requires_approval", True)),
|
"requires_approval": bool(raw.get("requires_approval", True)),
|
||||||
"auth": "oauth" if is_oauth else None,
|
"auth": "oauth" if is_oauth else None,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"auth_hint": name in self._mcp_auth_hints,
|
||||||
|
"last_test_at": self._prefs.get("mcp_last_test", {}).get(name),
|
||||||
"last_error": self._mcp_errors.get(name),
|
"last_error": self._mcp_errors.get(name),
|
||||||
"tool_count": (
|
"tool_count": (
|
||||||
len(self.mcp._conns[name].tools) if connected else None
|
len(self.mcp._conns[name].tools) if connected else None
|
||||||
@@ -1271,6 +1307,8 @@ class SessionManager:
|
|||||||
"""Connect one server NOW — for OAuth servers this may open the browser and wait
|
"""Connect one server NOW — for OAuth servers this may open the browser and wait
|
||||||
for the loopback callback, so callers run it as a background task and watch
|
for the loopback callback, so callers run it as a background task and watch
|
||||||
list_mcp for the status flip."""
|
list_mcp for the status flip."""
|
||||||
|
from ..mcp import oauth as mcp_oauth
|
||||||
|
|
||||||
for server in load_mcp_servers(
|
for server in load_mcp_servers(
|
||||||
self.default_workspace,
|
self.default_workspace,
|
||||||
secrets=self.secrets,
|
secrets=self.secrets,
|
||||||
@@ -1280,12 +1318,31 @@ class SessionManager:
|
|||||||
continue
|
continue
|
||||||
self._mcp_authorizing.add(name)
|
self._mcp_authorizing.add(name)
|
||||||
self._mcp_errors.pop(name, None)
|
self._mcp_errors.pop(name, None)
|
||||||
|
self._mcp_auth_hints.discard(name)
|
||||||
try:
|
try:
|
||||||
# The ONE place a browser sign-in may start: an explicit connect.
|
# The ONE place a browser sign-in may start: an explicit connect.
|
||||||
conn = await self.mcp.ensure(server, interactive=True)
|
conn = await self.mcp.ensure(server, interactive=True)
|
||||||
|
# The Connectors row says "Ready · tested ⟨when⟩" — the claim must
|
||||||
|
# survive an app restart, so it lives in prefs, not memory.
|
||||||
|
self._prefs.setdefault("mcp_last_test", {})[name] = int(time.time())
|
||||||
|
self._save_prefs()
|
||||||
return {"ok": True, "tools": len(conn.tools)}
|
return {"ok": True, "tools": len(conn.tools)}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._mcp_errors[name] = str(exc) or exc.__class__.__name__
|
if (
|
||||||
|
server.transport == "http"
|
||||||
|
and server.auth != "oauth"
|
||||||
|
and mcp_oauth.is_http_auth_error(exc)
|
||||||
|
):
|
||||||
|
# Anonymous probe of a guarded server (the add-by-URL flow):
|
||||||
|
# the answer is sign-in, not a raw 401 dump.
|
||||||
|
self._mcp_auth_hints.add(name)
|
||||||
|
msg = "authentication required — sign in to connect"
|
||||||
|
else:
|
||||||
|
msg = str(exc) or exc.__class__.__name__
|
||||||
|
tail = self.mcp.last_stderr(name)
|
||||||
|
if tail:
|
||||||
|
msg = f"{msg} — {tail}"
|
||||||
|
self._mcp_errors[name] = msg[:500]
|
||||||
return {"ok": False, "error": self._mcp_errors[name]}
|
return {"ok": False, "error": self._mcp_errors[name]}
|
||||||
finally:
|
finally:
|
||||||
self._mcp_authorizing.discard(name)
|
self._mcp_authorizing.discard(name)
|
||||||
@@ -1349,6 +1406,13 @@ class SessionManager:
|
|||||||
|
|
||||||
def delete_mcp(self, name: str) -> dict[str, Any]:
|
def delete_mcp(self, name: str) -> dict[str, Any]:
|
||||||
ok = delete_global_server(name)
|
ok = delete_global_server(name)
|
||||||
|
if ok:
|
||||||
|
# A later re-add under the same name starts clean, not pre-failed —
|
||||||
|
# and not pre-trusted (the old entry's test says nothing about the new).
|
||||||
|
self._mcp_errors.pop(name, None)
|
||||||
|
self._mcp_auth_hints.discard(name)
|
||||||
|
if self._prefs.get("mcp_last_test", {}).pop(name, None) is not None:
|
||||||
|
self._save_prefs()
|
||||||
return {"ok": ok, "name": name}
|
return {"ok": ok, "name": name}
|
||||||
|
|
||||||
async def mcp_tools(self, name: str) -> dict[str, Any]:
|
async def mcp_tools(self, name: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ const SETTINGS = {
|
|||||||
model_labels: {
|
model_labels: {
|
||||||
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
|
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
|
||||||
"zai:glm-5.2": "GLM-5.2 · Z AI",
|
"zai:glm-5.2": "GLM-5.2 · Z AI",
|
||||||
|
"ark:dola-seed-evolving-latest-version": "Dola Seed Evolving · BytePlus Ark",
|
||||||
|
"ark:dola-seed-2-1-turbo-260628": "Dola Seed 2.1 Turbo · BytePlus Ark",
|
||||||
|
"ark-agent-plan-cn:doubao-seed-evolving": "Doubao Seed Evolving · Volcengine Agent Plan",
|
||||||
|
"ark-agent-plan-cn:doubao-seed-2.1-turbo": "Doubao Seed 2.1 Turbo · Volcengine Agent Plan",
|
||||||
},
|
},
|
||||||
// Context windows (subset — mirrors /v1/settings.model_context_windows); drives the
|
// Context windows (subset — mirrors /v1/settings.model_context_windows); drives the
|
||||||
// composer usage chip's context-fill meter.
|
// composer usage chip's context-fill meter.
|
||||||
@@ -339,6 +343,10 @@ const PROVIDERS = [
|
|||||||
{ name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null },
|
{ name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null },
|
||||||
// zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb.
|
// zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb.
|
||||||
{ name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null },
|
{ name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null },
|
||||||
|
// Ark uses two provider identities: BytePlus pay-as-you-go and Volcengine Agent Plan CN
|
||||||
|
// have independent credentials, endpoints, and strict curated model lists.
|
||||||
|
{ name: "ark", title: "BytePlus Ark", needs_key: true, blurb: "Uses BytePlus Ark's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "BytePlus Ark API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "BytePlus Ark's Asia Pacific endpoint.", placeholder: "https://ark.ap-southeast.bytepluses.com/api/v3", default: "https://ark.ap-southeast.bytepluses.com/api/v3" }], configured: false, values: {}, suggested_models: ["dola-seed-evolving-latest-version", "dola-seed-2-1-turbo-260628"], key_set_at: null, last_used_at: null },
|
||||||
|
{ name: "ark-agent-plan-cn", title: "Volcengine Ark Agent Plan", needs_key: true, blurb: "Uses Volcengine Ark Agent Plan's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Volcengine Ark Agent Plan API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Volcengine Ark Agent Plan's China (Beijing) endpoint.", placeholder: "https://ark.cn-beijing.volces.com/api/plan/v3", default: "https://ark.cn-beijing.volces.com/api/plan/v3" }], configured: false, values: {}, suggested_models: ["doubao-seed-evolving", "doubao-seed-2.1-turbo"], key_set_at: null, last_used_at: null },
|
||||||
// ollama: keyless local provider — "configured" without proving anything runs; the
|
// ollama: keyless local provider — "configured" without proving anything runs; the
|
||||||
// onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39).
|
// onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39).
|
||||||
{ name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null },
|
{ name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null },
|
||||||
@@ -1970,8 +1978,17 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
if (p.endsWith("/v1/mcp") && m === "GET") {
|
if (p.endsWith("/v1/mcp") && m === "GET") {
|
||||||
for (const s2 of mcpServers) {
|
for (const s2 of mcpServers) {
|
||||||
if (s2.status === "authorizing" && s2._flip) {
|
if (s2.status === "authorizing" && s2._flip) {
|
||||||
s2.status = "connected";
|
// Servers named locked-* simulate a guarded remote: the anonymous
|
||||||
s2.tool_count = 6;
|
// probe 401s (→ needs sign-in) until the entry is switched to oauth.
|
||||||
|
if (s2.name.startsWith("locked") && s2.auth !== "oauth") {
|
||||||
|
s2.status = "error";
|
||||||
|
s2.auth_hint = true;
|
||||||
|
s2.last_error = "authentication required — sign in to connect";
|
||||||
|
} else {
|
||||||
|
s2.status = "connected";
|
||||||
|
s2.tool_count = 6;
|
||||||
|
s2.last_test_at = 1700000000; // the successful probe stamps the row
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (s2.status === "authorizing") s2._flip = true;
|
if (s2.status === "authorizing") s2._flip = true;
|
||||||
}
|
}
|
||||||
@@ -1986,6 +2003,8 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
requires_approval: true,
|
requires_approval: true,
|
||||||
auth: b.config?.auth === "oauth" ? "oauth" : null,
|
auth: b.config?.auth === "oauth" ? "oauth" : null,
|
||||||
status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
|
status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
|
||||||
|
auth_hint: false,
|
||||||
|
last_test_at: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
tool_count: null,
|
tool_count: null,
|
||||||
config: b.config || {},
|
config: b.config || {},
|
||||||
@@ -1996,7 +2015,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/);
|
const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/);
|
||||||
if (mc && m === "POST") {
|
if (mc && m === "POST") {
|
||||||
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1]));
|
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1]));
|
||||||
if (s2) s2.status = "authorizing";
|
if (s2) {
|
||||||
|
s2.status = "authorizing";
|
||||||
|
s2.auth_hint = false;
|
||||||
|
s2.last_error = null;
|
||||||
|
s2._flip = false;
|
||||||
|
}
|
||||||
return json({ ok: true, started: true });
|
return json({ ok: true, started: true });
|
||||||
}
|
}
|
||||||
const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/);
|
const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/);
|
||||||
@@ -2009,6 +2033,29 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
}
|
}
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
|
const mp = p.match(/\/v1\/mcp\/([^/]+)$/);
|
||||||
|
if (mp && m === "PATCH") {
|
||||||
|
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mp[1]));
|
||||||
|
const b = req.postDataJSON() || {};
|
||||||
|
if (s2) {
|
||||||
|
if (b.enabled !== undefined) s2.enabled = b.enabled;
|
||||||
|
if (b.auth === "oauth") {
|
||||||
|
// The needs-sign-in fix: entry switches to oauth; the follow-up
|
||||||
|
// connect runs the browser flow.
|
||||||
|
s2.auth = "oauth";
|
||||||
|
s2.auth_hint = false;
|
||||||
|
s2.status = "needs_auth";
|
||||||
|
}
|
||||||
|
s2.config = { ...s2.config, ...b };
|
||||||
|
}
|
||||||
|
return json({ ok: !!s2, name: mp[1] });
|
||||||
|
}
|
||||||
|
const md = p.match(/\/v1\/mcp\/([^/]+)$/);
|
||||||
|
if (md && m === "DELETE") {
|
||||||
|
const i = mcpServers.findIndex((x) => x.name === decodeURIComponent(md[1]));
|
||||||
|
if (i >= 0) mcpServers.splice(i, 1);
|
||||||
|
return json({ ok: i >= 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (p.endsWith("/v1/unrouted")) return json([]);
|
if (p.endsWith("/v1/unrouted")) return json([]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// MCP OAuth quick-add (first server: Granola): the Custom · MCP group on the
|
||||||
// card; connecting adds the server, kicks off the browser sign-in ("signing in…"),
|
// Connectors page offers a curated Connect card; connecting adds the server, kicks
|
||||||
// and the tab's poll flips the row to connected. Sign out returns it to needs_auth.
|
// off the browser sign-in (Signing in…), and the poll flips the row to Live.
|
||||||
|
// Sign out (detail page) returns it to Needs sign-in.
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
|
|
||||||
async function openMcpTab(page) {
|
async function openConnectors(page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
await page.getByTestId("account-row").click();
|
||||||
await page.getByRole("button", { name: "Connectors", exact: true }).click();
|
await page.getByRole("button", { name: "Connectors", exact: true }).click();
|
||||||
await page.getByRole("button", { name: "MCP servers", exact: true }).click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("granola: quick-add card → sign-in flow → connected → sign out", async ({ page }) => {
|
test("granola: quick-add card → sign-in flow → Live → sign out", async ({ page }) => {
|
||||||
await openMcpTab(page);
|
await openConnectors(page);
|
||||||
|
|
||||||
// Curated card renders while granola isn't configured.
|
// Curated card renders in the Custom · MCP group while granola isn't configured.
|
||||||
const preset = page.getByTestId("mcp-preset-granola");
|
const preset = page.getByTestId("mcp-preset-granola");
|
||||||
await expect(preset).toContainText("Granola");
|
await expect(preset).toContainText("Granola");
|
||||||
await expect(preset).toContainText("Meeting notes");
|
await expect(preset).toContainText("Meeting notes");
|
||||||
@@ -22,16 +22,17 @@ test("granola: quick-add card → sign-in flow → connected → sign out", asyn
|
|||||||
// Connect: adds the server with OAuth pending and starts the browser flow.
|
// Connect: adds the server with OAuth pending and starts the browser flow.
|
||||||
await preset.getByRole("button", { name: "Connect" }).click();
|
await preset.getByRole("button", { name: "Connect" }).click();
|
||||||
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
|
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
|
||||||
const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first();
|
const row = page.getByTestId("mcp-row-granola");
|
||||||
await expect(row).toContainText("signing in…");
|
await expect(row).toContainText("Signing in…");
|
||||||
|
|
||||||
// The 2s status poll flips the mock to connected with its 6 tools.
|
// The status poll flips the mock to connected with its 6 tools.
|
||||||
await expect(row).toContainText("connected", { timeout: 10_000 });
|
await expect(row).toContainText("Live", { timeout: 10_000 });
|
||||||
await expect(row).toContainText("6 tools");
|
await expect(row).toContainText("6 tools");
|
||||||
await expect(row).toContainText("oauth");
|
|
||||||
|
|
||||||
// Sign out forgets tokens; the row needs auth again and offers Sign in.
|
// Sign out on the detail page forgets tokens; the chip needs sign-in again.
|
||||||
await row.getByTestId("mcp-signout-granola").click();
|
await row.click();
|
||||||
await expect(row).toContainText("needs auth");
|
const detail = page.getByTestId("mcp-detail-granola");
|
||||||
await expect(row.getByTestId("mcp-signin-granola")).toBeVisible();
|
await detail.getByTestId("mcp-signout-granola").click();
|
||||||
|
await expect(detail).toContainText("Needs sign-in");
|
||||||
|
await expect(detail.getByTestId("mcp-signin-granola")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,44 @@ test("Models: provider gallery states; vendor form previews models", async ({ pa
|
|||||||
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
|
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Models: BytePlus and Volcengine Ark stay visually and operationally separate", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("account-row").click();
|
||||||
|
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Models", exact: true }).click();
|
||||||
|
|
||||||
|
const byteplusCard = page.getByTestId("set-provider-ark");
|
||||||
|
const volcengineCard = page.getByTestId("set-provider-ark-agent-plan-cn");
|
||||||
|
await expect(byteplusCard).toContainText("BytePlus Ark");
|
||||||
|
await expect(volcengineCard).toContainText("Volcengine Ark Agent Plan");
|
||||||
|
const byteplusLogo = await byteplusCard.locator("img").getAttribute("src");
|
||||||
|
const volcengineLogo = await volcengineCard.locator("img").getAttribute("src");
|
||||||
|
expect(byteplusLogo).toBeTruthy();
|
||||||
|
expect(volcengineLogo).toBeTruthy();
|
||||||
|
expect(byteplusLogo).not.toBe(volcengineLogo);
|
||||||
|
|
||||||
|
await byteplusCard.click();
|
||||||
|
await page.getByTestId("set-endpoint-link").click();
|
||||||
|
await expect(page.getByTestId("set-field-base_url")).toHaveValue(
|
||||||
|
"https://ark.ap-southeast.bytepluses.com/api/v3",
|
||||||
|
);
|
||||||
|
let preview = page.getByTestId("model-preview");
|
||||||
|
await expect(preview).toContainText("Dola Seed Evolving · BytePlus Ark");
|
||||||
|
await expect(preview).toContainText("Dola Seed 2.1 Turbo · BytePlus Ark");
|
||||||
|
await expect(preview).not.toContainText("Doubao Seed");
|
||||||
|
|
||||||
|
await page.getByTestId("set-back").click();
|
||||||
|
await volcengineCard.click();
|
||||||
|
await page.getByTestId("set-endpoint-link").click();
|
||||||
|
await expect(page.getByTestId("set-field-base_url")).toHaveValue(
|
||||||
|
"https://ark.cn-beijing.volces.com/api/plan/v3",
|
||||||
|
);
|
||||||
|
preview = page.getByTestId("model-preview");
|
||||||
|
await expect(preview).toContainText("Doubao Seed Evolving · Volcengine Agent Plan");
|
||||||
|
await expect(preview).toContainText("Doubao Seed 2.1 Turbo · Volcengine Agent Plan");
|
||||||
|
await expect(preview).not.toContainText("Dola Seed");
|
||||||
|
});
|
||||||
|
|
||||||
// UX-021: a configured provider's form shows the in-field saved state and the Remove key…
|
// UX-021: a configured provider's form shows the in-field saved state and the Remove key…
|
||||||
// affordance; removing reverts the card to "Not set up".
|
// affordance; removing reverts the card to "Not set up".
|
||||||
test("Models: Remove key reverts a configured provider", async ({ page }) => {
|
test("Models: Remove key reverts a configured provider", async ({ page }) => {
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ test("Activity in the menu is the audit log; Unrouted lives under Inbox ▸ Conf
|
|||||||
await page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click();
|
await page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible();
|
||||||
|
|
||||||
// §28: Messaging routing left the Connectors sub-nav entirely (Connectors · MCP only)…
|
// §28: Messaging routing left the Connectors sub-nav entirely — and the MCP tab
|
||||||
|
// retired into the Connectors page itself (UX-034), so one sub-nav item remains.
|
||||||
await page.getByTestId("account-row").click();
|
await page.getByTestId("account-row").click();
|
||||||
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
|
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
|
||||||
await expect(page.getByRole("button", { name: "MCP servers" })).toBeVisible();
|
await expect(page.getByTestId("add-custom-server")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "MCP servers" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: /Messaging routing/ })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: /Messaging routing/ })).toHaveCount(0);
|
||||||
// The old fourth sub-nav tab is gone — exactly one page is named Activity now.
|
// The old fourth sub-nav tab is gone — exactly one page is named Activity now.
|
||||||
await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0);
|
||||||
|
|||||||
@@ -457,6 +457,10 @@ export interface McpServer {
|
|||||||
// "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight)
|
// "needs_auth" (no tokens yet) | "authorizing" (browser sign-in in flight)
|
||||||
status: string;
|
status: string;
|
||||||
auth?: "oauth" | null;
|
auth?: "oauth" | null;
|
||||||
|
// http server whose anonymous connect hit a 401/403 — offer OAuth sign-in.
|
||||||
|
auth_hint?: boolean;
|
||||||
|
// Epoch seconds of the last successful explicit Test (persisted server-side).
|
||||||
|
last_test_at?: number | null;
|
||||||
last_error?: string | null;
|
last_error?: string | null;
|
||||||
tool_count: number | null;
|
tool_count: number | null;
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getConnectors } from "../api";
|
import { getConnectors } from "../api";
|
||||||
import { McpTab } from "./ManageTabs";
|
|
||||||
import { ConnectorsSection } from "./connectors/ConnectorsSection";
|
import { ConnectorsSection } from "./connectors/ConnectorsSection";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
// The Connectors surface (renamed from "Integrations", §26) keeps the left sub-nav, now just
|
// The Connectors surface (renamed from "Integrations", §26). The separate "MCP
|
||||||
// Connectors · MCP. The old "Messaging routing" tab (and its ⚠ unrouted badge) moved whole to
|
// servers" tab is retired (UX-034): custom MCP servers now live on the Connectors
|
||||||
// Inbox ▸ Configure (§28): inbox-delivery config belongs with the Inbox, and Unrouted is
|
// page itself — a "Custom · MCP" group plus the top "Add custom server" modal —
|
||||||
// "messages that never reached you". The one remaining Activity is the audit log, reached from
|
// so the sub-nav is a single fixed item. The old "Messaging routing" tab (and its
|
||||||
// the account menu.
|
// ⚠ unrouted badge) moved whole to Inbox ▸ Configure (§28); the one remaining
|
||||||
type IntTab = "connectors" | "mcp";
|
// Activity is the audit log, reached from the account menu.
|
||||||
|
|
||||||
// Fixed sub-nav (UX-DECISIONS §21): connector detail lives as a SUBPAGE under
|
|
||||||
// Connectors, never as a nav item — the nav must not grow per connector.
|
|
||||||
const INT_TABS: { key: IntTab; label: string; icon: "plug" | "code" }[] = [
|
|
||||||
{ key: "connectors", label: "Connectors", icon: "plug" },
|
|
||||||
{ key: "mcp", label: "MCP servers", icon: "code" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function IntegrationsView() {
|
export function IntegrationsView() {
|
||||||
const [tab, setTab] = useState<IntTab>("connectors");
|
|
||||||
// Sub-nav count: how many connectors exist. Polled so the badge stays live.
|
// Sub-nav count: how many connectors exist. Polled so the badge stays live.
|
||||||
const [connCount, setConnCount] = useState<number | null>(null);
|
const [connCount, setConnCount] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -38,51 +29,25 @@ export function IntegrationsView() {
|
|||||||
<div className="px-2 text-[13.5px] font-semibold mb-3 flex items-center gap-2">
|
<div className="px-2 text-[13.5px] font-semibold mb-3 flex items-center gap-2">
|
||||||
<Icon name="plug" size={16} /> Connectors
|
<Icon name="plug" size={16} /> Connectors
|
||||||
</div>
|
</div>
|
||||||
{INT_TABS.map((t) => {
|
<button className="w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between bg-paper text-accent font-medium">
|
||||||
const active = tab === t.key;
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
return (
|
<Icon name="plug" size={15} /> Connectors
|
||||||
<button
|
</span>
|
||||||
key={t.key}
|
{connCount != null && (
|
||||||
className={
|
<span className="text-[11px] shrink-0 text-accent">{connCount}</span>
|
||||||
"w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between " +
|
)}
|
||||||
(active
|
</button>
|
||||||
? "bg-paper text-accent font-medium"
|
|
||||||
: "text-muted hover:bg-paper hover:text-ink")
|
|
||||||
}
|
|
||||||
onClick={() => setTab(t.key)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
|
||||||
<Icon name={t.icon} size={15} /> {t.label}
|
|
||||||
</span>
|
|
||||||
{t.key === "connectors" && connCount != null && (
|
|
||||||
<span className={"text-[11px] shrink-0 " + (active ? "text-accent" : "text-faint")}>
|
|
||||||
{connCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
|
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
|
||||||
<div className="max-w-4xl mx-auto px-7 py-6">
|
<div className="max-w-4xl mx-auto px-7 py-6">
|
||||||
{tab === "connectors" ? (
|
<section>
|
||||||
<section>
|
<PanelHead
|
||||||
<PanelHead
|
title="Connectors"
|
||||||
title="Connectors"
|
sub="Apps and tools your coworkers can use. Connected ones come first."
|
||||||
sub="Apps and tools your coworkers can use. Connected ones come first."
|
/>
|
||||||
/>
|
<ConnectorsSection />
|
||||||
<ConnectorsSection />
|
</section>
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section>
|
|
||||||
<PanelHead
|
|
||||||
title="MCP servers"
|
|
||||||
sub="External tool servers (stdio or HTTP), shared across all agents."
|
|
||||||
/>
|
|
||||||
<McpTab />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,36 +1,26 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addMcpServer,
|
|
||||||
allowUser,
|
allowUser,
|
||||||
connectConnector,
|
connectConnector,
|
||||||
connectManaged,
|
connectManaged,
|
||||||
connectMcpBacked,
|
connectMcpBacked,
|
||||||
connectMcp,
|
|
||||||
deleteMcpServer,
|
|
||||||
disallowUser,
|
disallowUser,
|
||||||
getMcpServers,
|
|
||||||
getMcpTools,
|
|
||||||
signoutMcp,
|
|
||||||
getSettings,
|
getSettings,
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
removeModel,
|
removeModel,
|
||||||
resolveUnauthorized,
|
resolveUnauthorized,
|
||||||
unsubscribeChannel,
|
unsubscribeChannel,
|
||||||
patchMcpServer,
|
|
||||||
reloadMcp,
|
|
||||||
setDefaultModel,
|
setDefaultModel,
|
||||||
updateConnectorTools,
|
updateConnectorTools,
|
||||||
type CloudStatus,
|
type CloudStatus,
|
||||||
type Connector,
|
type Connector,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
type McpServer,
|
|
||||||
type ModelSettings,
|
type ModelSettings,
|
||||||
type ProviderInfo,
|
type ProviderInfo,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
|
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
|
||||||
import { ModelChecklist } from "./ModelChecklist";
|
import { ModelChecklist } from "./ModelChecklist";
|
||||||
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup";
|
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup";
|
||||||
import { Toggle } from "./Toggle";
|
|
||||||
|
|
||||||
// "2h ago"-style label for the providers' Last-used line (null when never used).
|
// "2h ago"-style label for the providers' Last-used line (null when never used).
|
||||||
const relTime = (epoch?: number | null): string | null => {
|
const relTime = (epoch?: number | null): string | null => {
|
||||||
@@ -45,14 +35,12 @@ const relTime = (epoch?: number | null): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Shared tab bodies for the Settings and Integrations pages (the old top-tab ManageModal was retired
|
// Shared tab bodies for the Settings and Integrations pages (the old top-tab ManageModal was retired
|
||||||
// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab +
|
// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab →
|
||||||
// McpTab → Integrations ▸ Connectors / MCP servers.
|
// Integrations ▸ Connectors (the MCP tab retired into the Connectors page, UX-034).
|
||||||
const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
|
const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
|
||||||
const CARD = "rounded-xl2 border border-line bg-panel";
|
|
||||||
const BTN_BORDERED =
|
const BTN_BORDERED =
|
||||||
"text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
|
"text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
|
||||||
const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50";
|
const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50";
|
||||||
const BTN_DANGER = "text-[12.5px] text-danger/80 hover:text-danger shrink-0";
|
|
||||||
|
|
||||||
/** Two-letter initials for a chip/avatar (first+last word, else first two chars). */
|
/** Two-letter initials for a chip/avatar (first+last word, else first two chars). */
|
||||||
function initials(name: string): string {
|
function initials(name: string): string {
|
||||||
@@ -62,14 +50,6 @@ function initials(name: string): string {
|
|||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXAMPLE = `{
|
|
||||||
"filesystem": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// -- Configure Models tab (UX-021: the shared provider gallery + key form) ----
|
// -- Configure Models tab (UX-021: the shared provider gallery + key form) ----
|
||||||
// Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two
|
// Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two
|
||||||
// surfaces can't drift. Settings-only extras: per-card "used Nh ago", a "Remove
|
// surfaces can't drift. Settings-only extras: per-card "used Nh ago", a "Remove
|
||||||
@@ -232,282 +212,6 @@ function ComposerPickerCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 + DCR) —
|
|
||||||
// no keys to paste, tokens stay in the local secret store. First: Granola.
|
|
||||||
const MCP_PRESETS: { name: string; label: string; blurb: string; config: Record<string, any> }[] = [
|
|
||||||
{
|
|
||||||
name: "granola",
|
|
||||||
label: "Granola",
|
|
||||||
blurb: "Meeting notes & transcripts — sign in with your Granola account.",
|
|
||||||
config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function McpTab() {
|
|
||||||
const [servers, setServers] = useState<McpServer[]>([]);
|
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const refresh = () => getMcpServers().then(setServers).catch(() => setServers([]));
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// While a browser sign-in is in flight, poll so the row flips to connected (or
|
|
||||||
// surfaces the error) without the user having to touch anything.
|
|
||||||
const authorizing = servers.some((s) => s.status === "authorizing");
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authorizing) return;
|
|
||||||
const t = window.setInterval(refresh, 2000);
|
|
||||||
return () => window.clearInterval(t);
|
|
||||||
}, [authorizing]);
|
|
||||||
|
|
||||||
const toggle = async (s: McpServer) => {
|
|
||||||
await patchMcpServer(s.name, { enabled: !s.enabled });
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const remove = async (s: McpServer) => {
|
|
||||||
await deleteMcpServer(s.name);
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-[12.5px] text-muted leading-relaxed">
|
|
||||||
External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are
|
|
||||||
permission-gated. Changes apply to new sessions —{" "}
|
|
||||||
<button
|
|
||||||
className="text-accent font-medium hover:underline"
|
|
||||||
onClick={() => reloadMcp().then(refresh)}
|
|
||||||
>
|
|
||||||
reload now
|
|
||||||
</button>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{servers.length === 0 && !adding ? (
|
|
||||||
<div className={CARD + " p-4 text-[13px] text-muted"}>
|
|
||||||
No MCP servers configured.{" "}
|
|
||||||
<button className="text-accent font-medium" onClick={() => setAdding(true)}>
|
|
||||||
Add a server
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map((s) => (
|
|
||||||
<McpRow
|
|
||||||
key={s.name}
|
|
||||||
server={s}
|
|
||||||
onToggle={() => toggle(s)}
|
|
||||||
onRemove={() => remove(s)}
|
|
||||||
onRefresh={refresh}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* One-click OAuth presets not yet configured. */}
|
|
||||||
{MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => (
|
|
||||||
<div key={p.name} className={CARD + " p-3.5 flex items-center gap-3"} data-testid={`mcp-preset-${p.name}`}>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[14px] font-medium">{p.label}</div>
|
|
||||||
<div className="text-[11.5px] text-faint">{p.blurb}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={BTN_ACCENT}
|
|
||||||
onClick={async () => {
|
|
||||||
await addMcpServer(p.name, p.config);
|
|
||||||
await connectMcp(p.name); // opens the browser sign-in right away
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{adding ? (
|
|
||||||
<AddForm
|
|
||||||
onCancel={() => {
|
|
||||||
setAdding(false);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
onError={setError}
|
|
||||||
onAdded={() => {
|
|
||||||
setAdding(false);
|
|
||||||
setError(null);
|
|
||||||
refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : servers.length > 0 ? (
|
|
||||||
<button className={BTN_ACCENT} onClick={() => setAdding(true)}>
|
|
||||||
+ Add server
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{error && <div className="text-[12.5px] text-danger">{error}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function McpRow({
|
|
||||||
server,
|
|
||||||
onToggle,
|
|
||||||
onRemove,
|
|
||||||
onRefresh,
|
|
||||||
}: {
|
|
||||||
server: McpServer;
|
|
||||||
onToggle: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
|
||||||
const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [toolErr, setToolErr] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isOauth = server.auth === "oauth";
|
|
||||||
const authorizing = server.status === "authorizing";
|
|
||||||
const signIn = async () => {
|
|
||||||
await connectMcp(server.name); // browser opens; the tab's poll flips the status
|
|
||||||
onRefresh();
|
|
||||||
};
|
|
||||||
const signOut = async () => {
|
|
||||||
await signoutMcp(server.name);
|
|
||||||
onRefresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTools = async () => {
|
|
||||||
if (tools) {
|
|
||||||
setTools(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
|
||||||
setToolErr(null);
|
|
||||||
const res = await getMcpTools(server.name);
|
|
||||||
setBusy(false);
|
|
||||||
if (res.ok) setTools(res.tools);
|
|
||||||
else setToolErr(res.error || "failed to connect");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={CARD + " p-3.5"}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Toggle checked={server.enabled} onChange={onToggle} title="Enable this server" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[14px] font-medium">{server.name}</div>
|
|
||||||
<div className="text-[11.5px] text-faint">
|
|
||||||
{server.transport} · {authorizing ? "signing in…" : server.status.replace("_", " ")}
|
|
||||||
{server.tool_count != null ? ` · ${server.tool_count} tools` : ""}
|
|
||||||
{server.requires_approval ? " · asks" : ""}
|
|
||||||
{isOauth ? " · oauth" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isOauth &&
|
|
||||||
(server.status === "needs_auth" ? (
|
|
||||||
<button className={BTN_ACCENT} onClick={signIn} data-testid={`mcp-signin-${server.name}`}>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
) : authorizing ? (
|
|
||||||
<span className="text-[12px] text-muted shrink-0">waiting for browser…</span>
|
|
||||||
) : server.status === "connected" ? (
|
|
||||||
<button
|
|
||||||
className="text-[12px] text-muted hover:text-ink shrink-0"
|
|
||||||
onClick={signOut}
|
|
||||||
data-testid={`mcp-signout-${server.name}`}
|
|
||||||
>
|
|
||||||
sign out
|
|
||||||
</button>
|
|
||||||
) : null)}
|
|
||||||
<button
|
|
||||||
className="text-[12px] text-muted hover:text-ink shrink-0"
|
|
||||||
onClick={loadTools}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{busy ? "…" : tools ? "hide tools" : "tools"}
|
|
||||||
</button>
|
|
||||||
<button className={BTN_DANGER} onClick={onRemove}>
|
|
||||||
remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{server.last_error && server.status !== "connected" && (
|
|
||||||
<div className="text-[12.5px] text-danger mt-1.5">{server.last_error}</div>
|
|
||||||
)}
|
|
||||||
{toolErr && <div className="text-[12.5px] text-danger mt-1.5">{toolErr}</div>}
|
|
||||||
{tools && (
|
|
||||||
<div className="mt-2.5 pt-2.5 border-t border-line flex flex-wrap gap-1.5">
|
|
||||||
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
|
|
||||||
{tools.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t.name}
|
|
||||||
title={t.description}
|
|
||||||
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddForm({
|
|
||||||
onCancel,
|
|
||||||
onAdded,
|
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
onCancel: () => void;
|
|
||||||
onAdded: () => void;
|
|
||||||
onError: (e: string | null) => void;
|
|
||||||
}) {
|
|
||||||
const [text, setText] = useState(EXAMPLE);
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
onError(null);
|
|
||||||
let parsed: any;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text);
|
|
||||||
} catch (e: any) {
|
|
||||||
onError("Invalid JSON: " + e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Accept either {mcpServers:{...}}, {name:{...}}, or a single bare config.
|
|
||||||
const map = parsed.mcpServers || parsed;
|
|
||||||
const entries =
|
|
||||||
map && typeof map === "object" && !map.command && !map.url
|
|
||||||
? Object.entries(map)
|
|
||||||
: null;
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
onError('Paste a `{ "<name>": { … } }` object (or a full mcpServers block).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [name, config] of entries) {
|
|
||||||
await addMcpServer(name, config as Record<string, any>);
|
|
||||||
}
|
|
||||||
onAdded();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-[12.5px] text-muted">Paste server JSON (name → config):</div>
|
|
||||||
<textarea
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
spellCheck={false}
|
|
||||||
rows={9}
|
|
||||||
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button className={BTN_ACCENT} onClick={save}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onCancel}>
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Connectors ---------------------------------------------------------------
|
// -- Connectors ---------------------------------------------------------------
|
||||||
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
|
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
|
||||||
// §21: connected-first list + per-connector detail subpages). This file keeps the
|
// §21: connected-first list + per-connector detail subpages). This file keeps the
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { type CloudStatus, type Connector, type SlackStatus } from "../../api";
|
import { type CloudStatus, type Connector, type McpServer, type SlackStatus } from "../../api";
|
||||||
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
||||||
import { AddConnectionModal } from "./AddConnectionModal";
|
import { AddConnectionModal } from "./AddConnectionModal";
|
||||||
|
import { AddMcpModal, CustomMcpGroup } from "./CustomMcp";
|
||||||
import { CHIP_OK, CHIP_OFF, CHIP_WARN, GRP, GRP_H, FOOT, PILL_QUIET, ROW } from "./ui";
|
import { CHIP_OK, CHIP_OFF, CHIP_WARN, GRP, GRP_H, FOOT, PILL_QUIET, ROW } from "./ui";
|
||||||
|
|
||||||
// The Connectors LIST (UX-DECISIONS §21): connected first in their own inset group —
|
// The Connectors LIST (UX-DECISIONS §21): connected first in their own inset group —
|
||||||
// rows navigate to the connector's detail subpage; problems surface as a chip in the
|
// rows navigate to the connector's detail subpage; problems surface as a chip in the
|
||||||
// list, never one click deep. Available connectors below with a Connect pill.
|
// list, never one click deep. Available connectors below with a Connect pill.
|
||||||
|
// Custom MCP servers (UX-034) render as their own group after Connected; the "Add
|
||||||
|
// custom server" affordance sits at the top of the page (owner ruling: top).
|
||||||
|
|
||||||
const AVAILABLE_FOLD = 8; // rows shown before "show all"
|
const AVAILABLE_FOLD = 8; // rows shown before "show all"
|
||||||
|
|
||||||
export function ConnectorsList({
|
export function ConnectorsList({
|
||||||
connectors,
|
connectors,
|
||||||
|
mcpServers,
|
||||||
cloud,
|
cloud,
|
||||||
slack,
|
slack,
|
||||||
onOpen,
|
onOpen,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
connectors: Connector[];
|
connectors: Connector[];
|
||||||
|
mcpServers: McpServer[];
|
||||||
cloud: CloudStatus | null;
|
cloud: CloudStatus | null;
|
||||||
slack: SlackStatus | null;
|
slack: SlackStatus | null;
|
||||||
onOpen: (name: string) => void;
|
onOpen: (name: string) => void;
|
||||||
@@ -26,17 +31,26 @@ export function ConnectorsList({
|
|||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const [connecting, setConnecting] = useState<string | null>(null);
|
const [connecting, setConnecting] = useState<string | null>(null);
|
||||||
|
const [addingMcp, setAddingMcp] = useState(false);
|
||||||
|
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q);
|
const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q);
|
||||||
const connected = connectors.filter((c) => c.connected && match(c));
|
const connected = connectors.filter((c) => c.connected && match(c));
|
||||||
const available = connectors.filter((c) => !c.connected && c.available && match(c));
|
const available = connectors.filter((c) => !c.connected && c.available && match(c));
|
||||||
|
const customMcp = mcpServers.filter((s) => !q || s.name.toLowerCase().includes(q));
|
||||||
const shown = showAll || q ? available : available.slice(0, AVAILABLE_FOLD);
|
const shown = showAll || q ? available : available.slice(0, AVAILABLE_FOLD);
|
||||||
const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null;
|
const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-end mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
className={PILL_QUIET}
|
||||||
|
onClick={() => setAddingMcp(true)}
|
||||||
|
data-testid="add-custom-server"
|
||||||
|
>
|
||||||
|
+ Add custom server
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
value={filter}
|
value={filter}
|
||||||
@@ -71,6 +85,12 @@ export function ConnectorsList({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CustomMcpGroup
|
||||||
|
servers={customMcp}
|
||||||
|
onOpen={(name) => onOpen("mcp:" + name)}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={GRP_H}>Available</div>
|
<div className={GRP_H}>Available</div>
|
||||||
<div className={GRP}>
|
<div className={GRP}>
|
||||||
{shown.map((c) => (
|
{shown.map((c) => (
|
||||||
@@ -120,6 +140,7 @@ export function ConnectorsList({
|
|||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{addingMcp && <AddMcpModal onClose={() => setAddingMcp(false)} onChanged={onChanged} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import {
|
|||||||
disconnectConnector,
|
disconnectConnector,
|
||||||
getCloudStatus,
|
getCloudStatus,
|
||||||
getConnectors,
|
getConnectors,
|
||||||
|
getMcpServers,
|
||||||
getSlackStatus,
|
getSlackStatus,
|
||||||
type CloudStatus,
|
type CloudStatus,
|
||||||
type Connector,
|
type Connector,
|
||||||
|
type McpServer,
|
||||||
type SlackStatus,
|
type SlackStatus,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
|
import { McpServerDetail } from "./CustomMcp";
|
||||||
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
||||||
import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs";
|
import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs";
|
||||||
import { AccountsDetail } from "./AccountsDetail";
|
import { AccountsDetail } from "./AccountsDetail";
|
||||||
@@ -52,11 +55,13 @@ const DETAIL_PAGES: Record<string, (p: DetailProps) => JSX.Element> = {
|
|||||||
export function ConnectorsSection() {
|
export function ConnectorsSection() {
|
||||||
const [detail, setDetail] = useState<string | null>(null);
|
const [detail, setDetail] = useState<string | null>(null);
|
||||||
const [connectors, setConnectors] = useState<Connector[]>([]);
|
const [connectors, setConnectors] = useState<Connector[]>([]);
|
||||||
|
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||||
const [cloud, setCloud] = useState<CloudStatus | null>(null);
|
const [cloud, setCloud] = useState<CloudStatus | null>(null);
|
||||||
const [slack, setSlack] = useState<SlackStatus | null>(null);
|
const [slack, setSlack] = useState<SlackStatus | null>(null);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
getConnectors().then(setConnectors).catch(() => setConnectors([]));
|
getConnectors().then(setConnectors).catch(() => setConnectors([]));
|
||||||
|
getMcpServers().then(setMcpServers).catch(() => setMcpServers([]));
|
||||||
getCloudStatus().then(setCloud).catch(() => setCloud(null));
|
getCloudStatus().then(setCloud).catch(() => setCloud(null));
|
||||||
getSlackStatus().then(setSlack).catch(() => setSlack(null));
|
getSlackStatus().then(setSlack).catch(() => setSlack(null));
|
||||||
};
|
};
|
||||||
@@ -68,6 +73,37 @@ export function ConnectorsSection() {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// While an MCP test/sign-in is in flight, poll fast so the chip flips to its
|
||||||
|
// result (Live / Error / Needs sign-in) without the user touching anything.
|
||||||
|
const mcpBusy = mcpServers.some((s) => s.status === "authorizing");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mcpBusy) return;
|
||||||
|
const t = setInterval(refresh, 2000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [mcpBusy]);
|
||||||
|
|
||||||
|
// Custom MCP entries route as "mcp:<name>" so they can never collide with a
|
||||||
|
// connector detail page of the same name.
|
||||||
|
if (detail?.startsWith("mcp:")) {
|
||||||
|
const s = mcpServers.find((x) => "mcp:" + x.name === detail);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="text-[13px] text-accent mb-3"
|
||||||
|
data-testid="connectors-breadcrumb"
|
||||||
|
onClick={() => setDetail(null)}
|
||||||
|
>
|
||||||
|
‹ Connectors
|
||||||
|
</button>
|
||||||
|
{!s ? (
|
||||||
|
<div className="text-[13px] text-muted">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<McpServerDetail server={s} onChanged={refresh} onGone={() => setDetail(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (detail) {
|
if (detail) {
|
||||||
const c = connectors.find((x) => x.name === detail);
|
const c = connectors.find((x) => x.name === detail);
|
||||||
const Page = DETAIL_PAGES[detail];
|
const Page = DETAIL_PAGES[detail];
|
||||||
@@ -104,6 +140,7 @@ export function ConnectorsSection() {
|
|||||||
return (
|
return (
|
||||||
<ConnectorsList
|
<ConnectorsList
|
||||||
connectors={connectors}
|
connectors={connectors}
|
||||||
|
mcpServers={mcpServers}
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
slack={slack}
|
slack={slack}
|
||||||
onOpen={setDetail}
|
onOpen={setDetail}
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
addMcpServer,
|
||||||
|
connectMcp,
|
||||||
|
deleteMcpServer,
|
||||||
|
getMcpTools,
|
||||||
|
patchMcpServer,
|
||||||
|
signoutMcp,
|
||||||
|
type McpServer,
|
||||||
|
} from "../../api";
|
||||||
|
import { relTime } from "../../providers/ProviderSetup";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
import { Toggle } from "../Toggle";
|
||||||
|
import {
|
||||||
|
CHIP_ERR,
|
||||||
|
CHIP_OFF,
|
||||||
|
CHIP_OK,
|
||||||
|
CHIP_WARN,
|
||||||
|
GRP,
|
||||||
|
GRP_H,
|
||||||
|
PILL_ACCENT,
|
||||||
|
PILL_QUIET,
|
||||||
|
ROW,
|
||||||
|
} from "./ui";
|
||||||
|
|
||||||
|
// Custom/BYO MCP servers on the Connectors page (UX-DECISIONS §21 + UX-034: the
|
||||||
|
// separate MCP tab is retired). They render as a "Custom · MCP" group at the end
|
||||||
|
// of the Connected section — grouped, not interleaved with first-party rows, so
|
||||||
|
// the user-supplied trust tier stays visible. Status never claims "Connected"
|
||||||
|
// for a stdio entry: Live = a connection is open right now; Ready = the one-time
|
||||||
|
// Test passed (subtitle carries "tested ⟨when⟩", persisted server-side).
|
||||||
|
|
||||||
|
// Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 +
|
||||||
|
// DCR) — no keys to paste, tokens stay in the local secret store.
|
||||||
|
export const MCP_PRESETS: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
blurb: string;
|
||||||
|
config: Record<string, any>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: "granola",
|
||||||
|
label: "Granola",
|
||||||
|
blurb: "Meeting notes & transcripts — sign in with your Granola account.",
|
||||||
|
config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mcpChip(s: McpServer) {
|
||||||
|
const isOauth = s.auth === "oauth";
|
||||||
|
if (!s.enabled) return <span className={CHIP_OFF}>● Off</span>;
|
||||||
|
if (s.status === "authorizing")
|
||||||
|
return <span className={CHIP_WARN}>● {isOauth ? "Signing in…" : "Testing…"}</span>;
|
||||||
|
if (s.status === "connected") return <span className={CHIP_OK}>● Live</span>;
|
||||||
|
if (s.auth_hint || s.status === "needs_auth")
|
||||||
|
return <span className={CHIP_WARN}>● Needs sign-in</span>;
|
||||||
|
if (s.status === "error") return <span className={CHIP_ERR}>● Error</span>;
|
||||||
|
if (s.last_test_at) return <span className={CHIP_OK}>● Ready</span>;
|
||||||
|
return <span className={CHIP_OFF}>● Not tested</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mcpStatusLine(s: McpServer): string {
|
||||||
|
const bits: string[] = [s.transport];
|
||||||
|
if (s.status === "connected" && s.tool_count != null) bits.push(`${s.tool_count} tools`);
|
||||||
|
else if (s.transport === "http" && s.config?.url) {
|
||||||
|
try {
|
||||||
|
bits.push(new URL(s.config.url).host);
|
||||||
|
} catch {
|
||||||
|
/* leave the host off a malformed url */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.status !== "connected" && s.last_test_at) {
|
||||||
|
const rel = relTime(s.last_test_at);
|
||||||
|
if (rel) bits.push(`tested ${rel}`);
|
||||||
|
}
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral square badge for custom servers (no vendor logo to show). */
|
||||||
|
function McpGlyph() {
|
||||||
|
return (
|
||||||
|
<span className="w-[34px] h-[34px] rounded-lg bg-paper border border-line flex items-center justify-center text-muted shrink-0">
|
||||||
|
<Icon name="code" size={16} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomMcpGroup({
|
||||||
|
servers,
|
||||||
|
onOpen,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
servers: McpServer[];
|
||||||
|
onOpen: (name: string) => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const presets = MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name));
|
||||||
|
if (servers.length === 0 && presets.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={GRP_H}>Custom · MCP</div>
|
||||||
|
<div className={GRP} data-testid="custom-mcp-group">
|
||||||
|
{servers.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
data-testid={`mcp-row-${s.name}`}
|
||||||
|
className={ROW + " w-full text-left hover:bg-paper/60"}
|
||||||
|
onClick={() => onOpen(s.name)}
|
||||||
|
>
|
||||||
|
<McpGlyph />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-[13.5px]">{s.name}</span>
|
||||||
|
<span className="block text-[12px] text-muted truncate">{mcpStatusLine(s)}</span>
|
||||||
|
</span>
|
||||||
|
{mcpChip(s)}
|
||||||
|
<span className="text-faint text-[15px] shrink-0">›</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{presets.map((p) => (
|
||||||
|
<div key={p.name} className={ROW} data-testid={`mcp-preset-${p.name}`}>
|
||||||
|
<McpGlyph />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-[13.5px]">{p.label}</span>
|
||||||
|
<span className="block text-[12px] text-muted truncate">{p.blurb}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={PILL_QUIET + " cursor-pointer"}
|
||||||
|
role="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await addMcpServer(p.name, p.config);
|
||||||
|
await connectMcp(p.name); // opens the browser sign-in right away
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Add custom server (UX-033 two-tab form, in the page's modal chrome) --------
|
||||||
|
|
||||||
|
const EXAMPLE = `{
|
||||||
|
"filesystem": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full text-[13px] px-3 py-2 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent";
|
||||||
|
|
||||||
|
export function AddMcpModal({
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<"url" | "json">("url");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [text, setText] = useState(EXAMPLE);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const saveUrl = async () => {
|
||||||
|
setError(null);
|
||||||
|
const n = name.trim();
|
||||||
|
const u = url.trim();
|
||||||
|
if (!n) {
|
||||||
|
setError("Give the server a name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\/\S+$/.test(u)) {
|
||||||
|
setError("Enter the server's full URL (https://…).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await addMcpServer(n, { type: "http", url: u });
|
||||||
|
// Probe anonymously right away — the row shows Testing…, then Live, an
|
||||||
|
// error, or Needs sign-in (401 → the OAuth switch on the detail page).
|
||||||
|
await connectMcp(n);
|
||||||
|
onChanged();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveJson = async () => {
|
||||||
|
setError(null);
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError("Invalid JSON: " + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Accept either {mcpServers:{...}}, {name:{...}}, or a single bare config.
|
||||||
|
const map = parsed.mcpServers || parsed;
|
||||||
|
const entries =
|
||||||
|
map && typeof map === "object" && !map.command && !map.url ? Object.entries(map) : null;
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
setError('Paste a `{ "<name>": { … } }` object (or a full mcpServers block).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [n, config] of entries) {
|
||||||
|
await addMcpServer(n, config as Record<string, any>);
|
||||||
|
}
|
||||||
|
onChanged();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabBtn = (active: boolean) =>
|
||||||
|
"text-[12px] px-2.5 py-1 rounded-md border shrink-0 " +
|
||||||
|
(active ? "border-accent text-accent font-medium" : "border-line text-muted hover:text-ink");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40" data-testid="add-mcp-modal">
|
||||||
|
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
|
||||||
|
<div className="absolute left-1/2 top-24 -translate-x-1/2 w-[540px] max-w-[92vw] rounded-xl2 border border-line bg-panel shadow-xl p-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[15px] font-semibold">Add custom MCP server</div>
|
||||||
|
<button className="text-faint hover:text-ink text-[16px] leading-none" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button className={tabBtn(tab === "url")} onClick={() => setTab("url")} data-testid="mcp-add-tab-url">
|
||||||
|
Remote URL
|
||||||
|
</button>
|
||||||
|
<button className={tabBtn(tab === "json")} onClick={() => setTab("json")} data-testid="mcp-add-tab-json">
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tab === "url" ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[12.5px] text-muted">
|
||||||
|
Connect a hosted MCP server. If it needs sign-in, the row will offer it after the
|
||||||
|
first test.
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Name (shown in the connectors list)"
|
||||||
|
spellCheck={false}
|
||||||
|
className={INPUT}
|
||||||
|
data-testid="mcp-add-name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://mcp.example.com/mcp"
|
||||||
|
spellCheck={false}
|
||||||
|
className={INPUT + " font-mono text-[12px]"}
|
||||||
|
data-testid="mcp-add-url"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-[12.5px] text-muted">Paste server JSON (name → config):</div>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
rows={9}
|
||||||
|
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button className={PILL_ACCENT} onClick={tab === "url" ? saveUrl : saveJson}>
|
||||||
|
{tab === "url" ? "Add & test" : "Add"}
|
||||||
|
</button>
|
||||||
|
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onClose}>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-[12.5px] text-danger">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Detail subpage (§21): tools, Test, config, error excerpt, remove -----------
|
||||||
|
|
||||||
|
export function McpServerDetail({
|
||||||
|
server,
|
||||||
|
onChanged,
|
||||||
|
onGone,
|
||||||
|
}: {
|
||||||
|
server: McpServer;
|
||||||
|
onChanged: () => void;
|
||||||
|
onGone: () => void;
|
||||||
|
}) {
|
||||||
|
const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [toolErr, setToolErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isOauth = server.auth === "oauth";
|
||||||
|
const authorizing = server.status === "authorizing";
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
await connectMcp(server.name);
|
||||||
|
onChanged();
|
||||||
|
// The connect runs as a background task; if the first refresh outpaced its
|
||||||
|
// start, the chip never shows Testing and the page's poll misses the flip.
|
||||||
|
window.setTimeout(onChanged, 600);
|
||||||
|
};
|
||||||
|
// Anonymous connect came back 401/403: the fix is sign-in, so switch the entry
|
||||||
|
// to OAuth (DCR — nothing to register) and start the browser flow right away.
|
||||||
|
const signInWithOauth = async () => {
|
||||||
|
await patchMcpServer(server.name, { auth: "oauth" });
|
||||||
|
await connectMcp(server.name);
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTools = async () => {
|
||||||
|
if (tools) {
|
||||||
|
setTools(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setToolErr(null);
|
||||||
|
const res = await getMcpTools(server.name);
|
||||||
|
setBusy(false);
|
||||||
|
if (res.ok) setTools(res.tools);
|
||||||
|
else setToolErr(res.error || "failed to connect");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid={`mcp-detail-${server.name}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<McpGlyph />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[16px] font-semibold">{server.name}</div>
|
||||||
|
<div className="text-[12px] text-muted">{mcpStatusLine(server)}</div>
|
||||||
|
</div>
|
||||||
|
{mcpChip(server)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={GRP}>
|
||||||
|
<div className={ROW}>
|
||||||
|
<span className="text-[13px] flex-1">Enabled</span>
|
||||||
|
<Toggle
|
||||||
|
checked={server.enabled}
|
||||||
|
onChange={async () => {
|
||||||
|
await patchMcpServer(server.name, { enabled: !server.enabled });
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
title="Enable this server"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={ROW}>
|
||||||
|
<span className="text-[13px] flex-1">
|
||||||
|
Test connection
|
||||||
|
<span className="block text-[11.5px] text-faint">
|
||||||
|
Starts the server and lists its tools — without opening a session.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{server.auth_hint && !isOauth ? (
|
||||||
|
<span
|
||||||
|
className={PILL_ACCENT + " cursor-pointer"}
|
||||||
|
role="button"
|
||||||
|
onClick={signInWithOauth}
|
||||||
|
data-testid={`mcp-authfix-${server.name}`}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</span>
|
||||||
|
) : isOauth && server.status === "needs_auth" ? (
|
||||||
|
<span
|
||||||
|
className={PILL_ACCENT + " cursor-pointer"}
|
||||||
|
role="button"
|
||||||
|
onClick={runTest}
|
||||||
|
data-testid={`mcp-signin-${server.name}`}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={PILL_QUIET + " cursor-pointer" + (authorizing ? " opacity-50" : "")}
|
||||||
|
role="button"
|
||||||
|
onClick={authorizing ? undefined : runTest}
|
||||||
|
data-testid={`mcp-test-${server.name}`}
|
||||||
|
>
|
||||||
|
{authorizing ? "Testing…" : "Test"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{server.last_error && server.status !== "connected" && (
|
||||||
|
<div className="px-4 py-2.5 text-[12.5px] text-danger break-words">
|
||||||
|
{server.last_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={ROW}>
|
||||||
|
<span className="text-[13px] flex-1">Tools</span>
|
||||||
|
<button className="text-[12.5px] text-muted hover:text-ink" onClick={loadTools} disabled={busy}>
|
||||||
|
{busy ? "…" : tools ? "hide" : "show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{toolErr && <div className="px-4 py-2.5 text-[12.5px] text-danger">{toolErr}</div>}
|
||||||
|
{tools && (
|
||||||
|
<div className="px-4 py-3 flex flex-wrap gap-1.5">
|
||||||
|
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
|
||||||
|
{tools.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.name}
|
||||||
|
title={t.description}
|
||||||
|
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={GRP}>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="text-[12px] font-semibold text-muted mb-1.5">Configuration</div>
|
||||||
|
<pre className="font-mono text-[11.5px] text-muted whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(server.config, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isOauth && server.status === "connected" && (
|
||||||
|
<button
|
||||||
|
className="text-[12.5px] text-muted hover:text-ink"
|
||||||
|
onClick={async () => {
|
||||||
|
await signoutMcp(server.name);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
data-testid={`mcp-signout-${server.name}`}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="text-[12.5px] text-danger/80 hover:text-danger"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteMcpServer(server.name);
|
||||||
|
onChanged();
|
||||||
|
onGone();
|
||||||
|
}}
|
||||||
|
data-testid={`mcp-remove-${server.name}`}
|
||||||
|
>
|
||||||
|
Remove server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ export const CHIP_WARN =
|
|||||||
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-warnSoft text-warnInk border border-warnInk/20 shrink-0";
|
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-warnSoft text-warnInk border border-warnInk/20 shrink-0";
|
||||||
export const CHIP_OFF =
|
export const CHIP_OFF =
|
||||||
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-paper text-muted border border-lineStrong shrink-0";
|
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-paper text-muted border border-lineStrong shrink-0";
|
||||||
|
export const CHIP_ERR =
|
||||||
|
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-danger/10 text-danger border border-danger/25 shrink-0";
|
||||||
|
|
||||||
/** Small × affordance (danger on hover). */
|
/** Small × affordance (danger on hover). */
|
||||||
export const XBTN = "text-faint hover:text-danger shrink-0 leading-none";
|
export const XBTN = "text-faint hover:text-danger shrink-0 leading-none";
|
||||||
|
|||||||
@@ -108,3 +108,17 @@ describe("itemsFromMessages reasoning", () => {
|
|||||||
expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" });
|
expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("itemsFromMessages mcp failure", () => {
|
||||||
|
it("replays the persisted mcp_error marker as a warn notice WITHOUT retry", () => {
|
||||||
|
const items = itemsFromMessages([
|
||||||
|
{ role: "user", content: "hi" },
|
||||||
|
{ role: "notice", kind: "mcp_error", text: "MCP server “sales-db” failed to start — see Settings ▸ MCP" },
|
||||||
|
] as any);
|
||||||
|
expect(items[1]).toEqual({
|
||||||
|
kind: "notice",
|
||||||
|
tone: "warn",
|
||||||
|
text: "MCP server “sales-db” failed to start — see Settings ▸ MCP",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
|||||||
: m.kind === "compacted"
|
: m.kind === "compacted"
|
||||||
? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
|
? // The subtle "compacted here" divider (OPE-27) — the transcript itself is intact.
|
||||||
{ kind: "notice", tone: "info", text: m.text || "Context compacted" }
|
{ kind: "notice", tone: "info", text: m.text || "Context compacted" }
|
||||||
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
: m.kind === "mcp_error"
|
||||||
|
? // A configured MCP server failed to start for this session — informational,
|
||||||
|
// NOT retriable (retry re-runs the model turn, which can't fix a dead server).
|
||||||
|
{ kind: "notice", tone: "warn", text: m.text || "An MCP server failed to start" }
|
||||||
|
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// system messages are omitted; tool-result messages are folded into the tool row above
|
// system messages are omitted; tool-result messages are folded into the tool row above
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// only the selected method's fields render, and clicking a segment switches them.
|
// only the selected method's fields render, and clicking a segment switches them.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { ProviderForm, type ProviderSetupState } from "./ProviderSetup";
|
import { KEY_HELP, ProviderForm, ProviderMark, type ProviderSetupState } from "./ProviderSetup";
|
||||||
import type { ProviderInfo } from "../api";
|
import type { ProviderInfo } from "../api";
|
||||||
|
|
||||||
vi.mock("../tauri", () => ({ openExternal: vi.fn() }));
|
vi.mock("../tauri", () => ({ openExternal: vi.fn() }));
|
||||||
@@ -94,3 +94,29 @@ describe("ProviderForm auth-method choice", () => {
|
|||||||
expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull();
|
expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ark provider presentation", () => {
|
||||||
|
it("uses separate BytePlus and Volcengine brand marks", () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<ProviderMark name="ark" title="BytePlus Ark" />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("img")).toBeTruthy();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ProviderMark
|
||||||
|
name="ark-agent-plan-cn"
|
||||||
|
title="Volcengine Ark Agent Plan"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("img")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links each provider to its own API key console", () => {
|
||||||
|
expect(KEY_HELP.ark.url).toBe(
|
||||||
|
"https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey",
|
||||||
|
);
|
||||||
|
expect(KEY_HELP["ark-agent-plan-cn"].url).toContain(
|
||||||
|
"advancedActiveKey=agentPlan",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const KEY_HELP: Record<string, { url: string; label: string }> = {
|
|||||||
anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" },
|
anthropic: { url: "https://console.anthropic.com/settings/keys", label: "console.anthropic.com" },
|
||||||
openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" },
|
openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" },
|
||||||
gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" },
|
gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" },
|
||||||
|
ark: { url: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey", label: "console.byteplus.com" },
|
||||||
|
"ark-agent-plan-cn": { url: "https://console.volcengine.com/ark/region:cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=agentPlan", label: "console.volcengine.com" },
|
||||||
openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" },
|
openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" },
|
||||||
bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" },
|
bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" },
|
||||||
fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" },
|
fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" },
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
// Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding
|
// Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding
|
||||||
// provider gallery, vendored from the MIT-licensed lobe-icons set (same
|
// provider gallery. Most are vendored from the MIT-licensed lobe-icons set; BytePlus is
|
||||||
// bundled-asset posture as the connector registry — no CDN at runtime). Keys are
|
// its official website mark, used with permission. All stay bundled like connector assets
|
||||||
// /v1/providers names; unknown names get no mark (the gallery falls back to a
|
// (no CDN at runtime). Keys are /v1/providers names; unknown names get no mark (the gallery
|
||||||
// neutral monogram). PROVIDER_ORDER is the gallery order — recognition first,
|
// falls back to a neutral monogram). PROVIDER_ORDER is the gallery order — recognition
|
||||||
// long tail behind the scroll fold.
|
// first, long tail behind the scroll fold.
|
||||||
|
|
||||||
import anthropic from "./logos/anthropic.svg";
|
import anthropic from "./logos/anthropic.svg";
|
||||||
import openai from "./logos/openai.svg";
|
import openai from "./logos/openai.svg";
|
||||||
import gemini from "./logos/gemini.svg";
|
import gemini from "./logos/gemini.svg";
|
||||||
|
import byteplus from "./logos/byteplus.svg";
|
||||||
|
import volcengine from "./logos/volcengine.svg";
|
||||||
import ollama from "./logos/ollama.svg";
|
import ollama from "./logos/ollama.svg";
|
||||||
import bedrock from "./logos/bedrock.svg";
|
import bedrock from "./logos/bedrock.svg";
|
||||||
import vertex from "./logos/vertex.svg";
|
import vertex from "./logos/vertex.svg";
|
||||||
@@ -27,6 +29,8 @@ export const PROVIDER_LOGOS: Record<string, string> = {
|
|||||||
anthropic,
|
anthropic,
|
||||||
openai,
|
openai,
|
||||||
gemini,
|
gemini,
|
||||||
|
ark: byteplus,
|
||||||
|
"ark-agent-plan-cn": volcengine,
|
||||||
meta,
|
meta,
|
||||||
ollama,
|
ollama,
|
||||||
bedrock,
|
bedrock,
|
||||||
@@ -47,6 +51,8 @@ export const PROVIDER_ORDER = [
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"openai",
|
"openai",
|
||||||
"gemini",
|
"gemini",
|
||||||
|
"ark",
|
||||||
|
"ark-agent-plan-cn",
|
||||||
"meta",
|
"meta",
|
||||||
"ollama",
|
"ollama",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" shape-rendering="geometricPrecision" viewBox="0 0 27.9 22.01" xmlns="http://www.w3.org/2000/svg"><title>BytePlus</title><path fill="#0066FC" d="M17.196 9.442a.252.252 0 0 1-.419-.201V.612c0-.217-.263-.34-.418-.201L6.042 9.163a.252.252 0 0 1-.419-.201v-5.98a.31.31 0 0 0-.31-.31H.31a.31.31 0 0 0-.31.31v18.73c0 .216.263.34.418.2L10.72 13.16a.252.252 0 0 1 .418.202v8.644c0 .217.264.34.419.201l10.302-8.753a.252.252 0 0 1 .418.202v5.98c0 .17.14.31.31.31h5.003c.17 0 .31-.14.31-.31V.89c0-.217-.263-.341-.418-.202z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 543 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>
|
||||||
|
After Width: | Height: | Size: 859 B |
@@ -254,3 +254,166 @@ def test_rest_crud(tmp_path, monkeypatch):
|
|||||||
assert client.delete("/v1/mcp/fs").json()["ok"] is True
|
assert client.delete("/v1/mcp/fs").json()["ok"] is True
|
||||||
assert client.get("/v1/mcp").json()["servers"] == []
|
assert client.get("/v1/mcp").json()["servers"] == []
|
||||||
assert client.delete("/v1/mcp/fs").json()["ok"] is False
|
assert client.delete("/v1/mcp/fs").json()["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- failure surfacing (drill 2026-08-20: silent startup crashes) ----------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stdio_startup_crash_captures_stderr_tail(tmp_path, monkeypatch):
|
||||||
|
"""A stdio server that dies before initialize leaves its stderr tail behind."""
|
||||||
|
from coworker.mcp.client import MCPManager
|
||||||
|
|
||||||
|
mgr = MCPManager()
|
||||||
|
server = MCPServerDef(
|
||||||
|
name="doomed",
|
||||||
|
transport="stdio",
|
||||||
|
command="/bin/sh",
|
||||||
|
args=["-c", "echo 'usage: doomed --flag' >&2; exit 7"],
|
||||||
|
)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await mgr.ensure(server)
|
||||||
|
tail = mgr.last_stderr("doomed")
|
||||||
|
assert tail is not None and "usage: doomed --flag" in tail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prepare_records_failure_status_and_session_notice(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""A crashing global server surfaces: last_error + status=error + one-shot
|
||||||
|
session failure drain — instead of the pre-drill silent skip."""
|
||||||
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||||
|
_write_json(
|
||||||
|
tmp_path / "state" / "mcp.json",
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"sales-db": {
|
||||||
|
"command": "/bin/sh",
|
||||||
|
"args": ["-c", "echo 'boom: bad args' >&2; exit 2"],
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
manager = SessionManager(data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
tools = await manager.prepare_mcp_tools("s1", workspace=str(tmp_path / "wsp"))
|
||||||
|
assert tools == []
|
||||||
|
|
||||||
|
err = manager._mcp_errors.get("sales-db")
|
||||||
|
assert err and "boom: bad args" in err
|
||||||
|
|
||||||
|
listed = {s["name"]: s for s in manager.list_mcp()}
|
||||||
|
assert listed["sales-db"]["status"] == "error"
|
||||||
|
assert "boom: bad args" in (listed["sales-db"]["last_error"] or "")
|
||||||
|
|
||||||
|
drained = manager.pop_mcp_failures("s1")
|
||||||
|
assert [n for n, _ in drained] == ["sales-db"]
|
||||||
|
assert "boom: bad args" in (drained[0][1] or "")
|
||||||
|
assert manager.pop_mcp_failures("s1") == [] # one-shot
|
||||||
|
|
||||||
|
|
||||||
|
# -- explicit connect (UX-033: add → Test → fix, without opening a session) ------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_mcp_failure_includes_stderr_tail(tmp_path, monkeypatch):
|
||||||
|
"""The Test button's connect path reports the same stderr evidence as the
|
||||||
|
session path — a crashing stdio server yields error + status=error."""
|
||||||
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||||
|
_write_json(
|
||||||
|
tmp_path / "state" / "mcp.json",
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"doomed": {
|
||||||
|
"command": "/bin/sh",
|
||||||
|
"args": ["-c", "echo 'usage: doomed --flag' >&2; exit 7"],
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
manager = SessionManager(data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
result = await manager.connect_mcp("doomed")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "usage: doomed --flag" in result["error"]
|
||||||
|
|
||||||
|
listed = {s["name"]: s for s in manager.list_mcp()}
|
||||||
|
assert listed["doomed"]["status"] == "error"
|
||||||
|
assert listed["doomed"]["auth_hint"] is False
|
||||||
|
|
||||||
|
# Removing the server takes its stale failure state with it.
|
||||||
|
manager.delete_mcp("doomed")
|
||||||
|
assert manager._mcp_errors.get("doomed") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_mcp_http_401_sets_auth_hint(tmp_path, monkeypatch):
|
||||||
|
"""An anonymous connect that hits 401 is reported as "needs sign-in" (the GUI
|
||||||
|
offers the OAuth switch), not as a raw HTTP error dump."""
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
|
||||||
|
class _Deny(http.server.BaseHTTPRequestHandler):
|
||||||
|
def _deny(self):
|
||||||
|
self.send_response(401)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
do_GET = do_POST = do_DELETE = _deny
|
||||||
|
|
||||||
|
def log_message(self, *args): # keep pytest output clean
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Deny)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||||
|
_write_json(
|
||||||
|
tmp_path / "state" / "mcp.json",
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"guarded": {
|
||||||
|
"url": f"http://127.0.0.1:{srv.server_address[1]}/mcp",
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
manager = SessionManager(data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
result = await manager.connect_mcp("guarded")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "sign in" in result["error"]
|
||||||
|
|
||||||
|
listed = {s["name"]: s for s in manager.list_mcp()}
|
||||||
|
assert listed["guarded"]["auth_hint"] is True
|
||||||
|
assert listed["guarded"]["status"] == "error"
|
||||||
|
assert listed["guarded"]["last_test_at"] is None # failed probe stamps nothing
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_test_at_persists_and_clears_on_delete(tmp_path, monkeypatch):
|
||||||
|
"""The "Ready · tested ⟨when⟩" claim lives in prefs: it survives a manager
|
||||||
|
restart and is dropped with the server (a re-add is not pre-trusted)."""
|
||||||
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
||||||
|
_write_json(
|
||||||
|
tmp_path / "state" / "mcp.json",
|
||||||
|
{"mcpServers": {"fs": {"command": "echo", "enabled": True}}},
|
||||||
|
)
|
||||||
|
manager = SessionManager(data_dir=tmp_path / "data")
|
||||||
|
manager._prefs.setdefault("mcp_last_test", {})["fs"] = 1_700_000_000
|
||||||
|
manager._save_prefs()
|
||||||
|
|
||||||
|
# A fresh manager (same data dir) still reports the stamp.
|
||||||
|
manager2 = SessionManager(data_dir=tmp_path / "data")
|
||||||
|
listed = {s["name"]: s for s in manager2.list_mcp()}
|
||||||
|
assert listed["fs"]["last_test_at"] == 1_700_000_000
|
||||||
|
|
||||||
|
manager2.delete_mcp("fs")
|
||||||
|
manager3 = SessionManager(data_dir=tmp_path / "data")
|
||||||
|
assert manager3._prefs.get("mcp_last_test", {}).get("fs") is None
|
||||||
|
|||||||
@@ -17,6 +17,43 @@ from coworker.providers.openai_responses import (
|
|||||||
convert_tools,
|
convert_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_responses_custom_base_url_reaches_sdk(monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def fake_openai(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return SimpleNamespace()
|
||||||
|
|
||||||
|
monkeypatch.setattr("openai.OpenAI", fake_openai)
|
||||||
|
provider = OpenAIResponsesProvider(
|
||||||
|
api_key="ark-key",
|
||||||
|
base_url="https://ark.example/api/v3/",
|
||||||
|
)
|
||||||
|
|
||||||
|
provider._ensure_client()
|
||||||
|
|
||||||
|
assert captured == {
|
||||||
|
"api_key": "ark-key",
|
||||||
|
"base_url": "https://ark.example/api/v3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stock_openai_responses_path_unchanged(monkeypatch):
|
||||||
|
"""Lockdown: stock OpenAI must not receive a vendor base URL."""
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def fake_openai(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return SimpleNamespace()
|
||||||
|
|
||||||
|
monkeypatch.setattr("openai.OpenAI", fake_openai)
|
||||||
|
provider = OpenAIResponsesProvider(api_key="openai-key")
|
||||||
|
|
||||||
|
provider._ensure_client()
|
||||||
|
|
||||||
|
assert captured == {"api_key": "openai-key"}
|
||||||
|
|
||||||
# -- fakes ------------------------------------------------------------------------
|
# -- fakes ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -254,7 +291,7 @@ def test_convert_tools_flattens_function_schemas():
|
|||||||
# -- complete() ----------------------------------------------------------------------
|
# -- complete() ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_complete_text_turn_and_request_shape():
|
def test_complete_default_request_shape_PathsUnchanged():
|
||||||
fake = _FakeClient(response=_response([_message_item("hello")]))
|
fake = _FakeClient(response=_response([_message_item("hello")]))
|
||||||
provider = OpenAIResponsesProvider(client=fake)
|
provider = OpenAIResponsesProvider(client=fake)
|
||||||
turn = provider.complete(
|
turn = provider.complete(
|
||||||
@@ -273,6 +310,22 @@ def test_complete_text_turn_and_request_shape():
|
|||||||
assert fake.kwargs["reasoning"] == {"summary": "auto"}
|
assert fake.kwargs["reasoning"] == {"summary": "auto"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_can_omit_reasoning_summary_but_keep_encrypted_content():
|
||||||
|
"""BytePlus accepts encrypted reasoning output but rejects reasoning.summary."""
|
||||||
|
fake = _FakeClient(response=_response([_message_item("hello")]))
|
||||||
|
provider = OpenAIResponsesProvider(client=fake, reasoning_summary=False)
|
||||||
|
|
||||||
|
provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
|
||||||
|
|
||||||
|
assert "reasoning" not in fake.kwargs
|
||||||
|
assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_summary_capability_rejects_unknown_mode():
|
||||||
|
with pytest.raises(TypeError, match="reasoning_summary must be a bool"):
|
||||||
|
OpenAIResponsesProvider(client=SimpleNamespace(), reasoning_summary="auto")
|
||||||
|
|
||||||
|
|
||||||
def test_complete_parses_function_calls_with_call_ids():
|
def test_complete_parses_function_calls_with_call_ids():
|
||||||
fake = _FakeClient(
|
fake = _FakeClient(
|
||||||
response=_response(
|
response=_response(
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ def _patch_get(monkeypatch, status=200, capture=None, raise_exc=None):
|
|||||||
monkeypatch.setattr("httpx.get", fake_get)
|
monkeypatch.setattr("httpx.get", fake_get)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_post(monkeypatch, status=200, capture=None, raise_exc=None):
|
||||||
|
def fake_post(url, **kwargs):
|
||||||
|
if capture is not None:
|
||||||
|
capture["url"] = url
|
||||||
|
capture.update(kwargs)
|
||||||
|
if raise_exc is not None:
|
||||||
|
raise raise_exc
|
||||||
|
return SimpleNamespace(status_code=status)
|
||||||
|
|
||||||
|
monkeypatch.setattr("httpx.post", fake_post)
|
||||||
|
|
||||||
|
|
||||||
def test_verify_openai_ok(monkeypatch):
|
def test_verify_openai_ok(monkeypatch):
|
||||||
cap: dict = {}
|
cap: dict = {}
|
||||||
_patch_get(monkeypatch, status=200, capture=cap)
|
_patch_get(monkeypatch, status=200, capture=cap)
|
||||||
@@ -91,6 +103,52 @@ def test_verify_ollama_uses_v1_models_no_key(monkeypatch):
|
|||||||
assert "headers" not in cap # keyless
|
assert "headers" not in cap # keyless
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name,base_url,model",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"ark",
|
||||||
|
"https://ark.ap-southeast.bytepluses.com/api/v3",
|
||||||
|
"dola-seed-evolving-latest-version",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ark-agent-plan-cn",
|
||||||
|
"https://ark.cn-beijing.volces.com/api/plan/v3",
|
||||||
|
"doubao-seed-evolving",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_verify_ark_uses_non_persisted_responses_probe(
|
||||||
|
monkeypatch, name, base_url, model
|
||||||
|
):
|
||||||
|
"""Reverse-verified probe: the captured fixture must be non-empty and provider-specific."""
|
||||||
|
cap: dict = {}
|
||||||
|
_patch_post(monkeypatch, status=200, capture=cap)
|
||||||
|
|
||||||
|
assert verify_provider_key(name, api_key="ark-key") == {"ok": True}
|
||||||
|
assert cap["url"] == base_url + "/responses"
|
||||||
|
assert cap["headers"]["Authorization"] == "Bearer ark-key"
|
||||||
|
assert cap["json"] == {
|
||||||
|
"model": model,
|
||||||
|
"input": "Reply with OK.",
|
||||||
|
"max_output_tokens": 1,
|
||||||
|
"store": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_ark_profile_endpoint_override(monkeypatch):
|
||||||
|
cap: dict = {}
|
||||||
|
_patch_post(monkeypatch, status=200, capture=cap)
|
||||||
|
|
||||||
|
verify_provider_key(
|
||||||
|
"ark",
|
||||||
|
api_key="ark-key",
|
||||||
|
base_url="https://gateway.example/ark/v3/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cap["url"] == "https://gateway.example/ark/v3/responses"
|
||||||
|
|
||||||
|
|
||||||
def test_verify_network_error_is_clean(monkeypatch):
|
def test_verify_network_error_is_clean(monkeypatch):
|
||||||
_patch_get(monkeypatch, raise_exc=ConnectionError("boom"))
|
_patch_get(monkeypatch, raise_exc=ConnectionError("boom"))
|
||||||
res = verify_provider_key("openai", api_key="sk-x")
|
res = verify_provider_key("openai", api_key="sk-x")
|
||||||
|
|||||||
@@ -338,6 +338,121 @@ def test_compat_builder_never_leaks_the_openai_key(monkeypatch):
|
|||||||
build_provider_client("kimi", {}, None)
|
build_provider_client("kimi", {}, None)
|
||||||
|
|
||||||
|
|
||||||
|
ARK_RESPONSES_VENDORS = {
|
||||||
|
"ark": {
|
||||||
|
"base_url": "https://ark.ap-southeast.bytepluses.com/api/v3",
|
||||||
|
"env_key": "ARK_API_KEY",
|
||||||
|
"recommended_model": "dola-seed-evolving-latest-version",
|
||||||
|
"reasoning_summary": False,
|
||||||
|
},
|
||||||
|
"ark-agent-plan-cn": {
|
||||||
|
"base_url": "https://ark.cn-beijing.volces.com/api/plan/v3",
|
||||||
|
"env_key": "ARK_AGENT_PLAN_CN_API_KEY",
|
||||||
|
"recommended_model": "doubao-seed-evolving",
|
||||||
|
"reasoning_summary": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ark_responses_descriptors_are_separate():
|
||||||
|
from coworker.providers.registry import get_descriptor
|
||||||
|
|
||||||
|
for name, expected in ARK_RESPONSES_VENDORS.items():
|
||||||
|
d = get_descriptor(name)
|
||||||
|
assert d is not None and d.needs_key, name
|
||||||
|
assert d.env_key == expected["env_key"]
|
||||||
|
assert d.recommended_model == expected["recommended_model"]
|
||||||
|
assert "Responses API" in d.blurb
|
||||||
|
base = next(f for f in d.fields if f.key == "base_url")
|
||||||
|
assert base.default == expected["base_url"]
|
||||||
|
assert not base.required
|
||||||
|
|
||||||
|
|
||||||
|
def test_ark_responses_builder_capabilities_PathsUnchanged(monkeypatch):
|
||||||
|
from coworker.providers.openai_responses import OpenAIResponsesProvider
|
||||||
|
from coworker.providers.registry import build_provider_client
|
||||||
|
|
||||||
|
monkeypatch.setenv("ARK_AGENT_PLAN_CN_API_KEY", "plan-key")
|
||||||
|
bp = build_provider_client("ark", {"api_key": "bp-key"}, None)
|
||||||
|
plan = build_provider_client("ark-agent-plan-cn", {}, None)
|
||||||
|
|
||||||
|
assert isinstance(bp, OpenAIResponsesProvider)
|
||||||
|
assert (bp._api_key, bp._base_url) == (
|
||||||
|
"bp-key",
|
||||||
|
ARK_RESPONSES_VENDORS["ark"]["base_url"],
|
||||||
|
)
|
||||||
|
assert isinstance(plan, OpenAIResponsesProvider)
|
||||||
|
assert (plan._api_key, plan._base_url) == (
|
||||||
|
"plan-key",
|
||||||
|
ARK_RESPONSES_VENDORS["ark-agent-plan-cn"]["base_url"],
|
||||||
|
)
|
||||||
|
assert bp._reasoning_summary is ARK_RESPONSES_VENDORS["ark"]["reasoning_summary"]
|
||||||
|
assert plan._reasoning_summary is ARK_RESPONSES_VENDORS["ark-agent-plan-cn"][
|
||||||
|
"reasoning_summary"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ark_responses_never_leak_the_openai_key(monkeypatch):
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from coworker.providers.registry import build_provider_client
|
||||||
|
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real")
|
||||||
|
monkeypatch.delenv("ARK_API_KEY", raising=False)
|
||||||
|
with pytest.raises(RuntimeError, match="BytePlus Ark"):
|
||||||
|
build_provider_client("ark", {}, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_chat_compat_paths_unchanged():
|
||||||
|
"""Lockdown: adding Responses vendors must not migrate existing compat providers."""
|
||||||
|
from coworker.providers.registry import build_provider_client
|
||||||
|
|
||||||
|
provider = build_provider_client("deepseek", {"api_key": "ds-key"}, None)
|
||||||
|
assert isinstance(provider, OpenAIProvider)
|
||||||
|
assert provider._base_url == COMPAT_VENDORS["deepseek"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ark_curated_models_are_strict_allowlists():
|
||||||
|
from coworker.providers.matrix import models_for_provider
|
||||||
|
|
||||||
|
assert models_for_provider("ark") == [
|
||||||
|
"dola-seed-evolving-latest-version",
|
||||||
|
"dola-seed-2-1-turbo-260628",
|
||||||
|
]
|
||||||
|
assert models_for_provider("ark-agent-plan-cn") == [
|
||||||
|
"doubao-seed-evolving",
|
||||||
|
"doubao-seed-2.1-turbo",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ark_models_route_and_get_verified_agent_capabilities():
|
||||||
|
from coworker.providers.router import ProviderRouter
|
||||||
|
|
||||||
|
models = (
|
||||||
|
"ark:dola-seed-evolving-latest-version",
|
||||||
|
"ark:dola-seed-2-1-turbo-260628",
|
||||||
|
"ark-agent-plan-cn:doubao-seed-evolving",
|
||||||
|
"ark-agent-plan-cn:doubao-seed-2.1-turbo",
|
||||||
|
)
|
||||||
|
router = ProviderRouter.__new__(ProviderRouter)
|
||||||
|
for model in models:
|
||||||
|
prefix, bare = model.split(":", 1)
|
||||||
|
assert router._provider_name(model) == prefix
|
||||||
|
assert ProviderRouter._bare(model) == bare
|
||||||
|
caps = capabilities_for(model)
|
||||||
|
assert caps.tools and caps.parallel_tool_calls and caps.streaming
|
||||||
|
assert not caps.vision
|
||||||
|
|
||||||
|
|
||||||
|
def test_ark_recommended_models_are_curated():
|
||||||
|
from coworker.providers.matrix import models_for_provider
|
||||||
|
from coworker.providers.registry import get_descriptor
|
||||||
|
|
||||||
|
for name in ARK_RESPONSES_VENDORS:
|
||||||
|
d = get_descriptor(name)
|
||||||
|
assert d.recommended_model in models_for_provider(name)
|
||||||
|
|
||||||
|
|
||||||
def test_compat_models_route_and_get_tool_capabilities():
|
def test_compat_models_route_and_get_tool_capabilities():
|
||||||
from coworker.providers.router import ProviderRouter
|
from coworker.providers.router import ProviderRouter
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user