feat(mcp): external MCP client with daemon-managed OAuth and 39 design-focused templates (#898)

* feat(mcp): add external MCP client with daemon-managed OAuth and 17 design-focused templates

Open Design now acts as an MCP CLIENT and surfaces tools from third-party
MCP servers to the underlying agent (Claude Code, Hermes, Kimi).

Daemon
- New mcp-config / mcp-oauth / mcp-tokens modules: persist server entries
  to .od/mcp-config.json, run the OAuth dance for HTTP/SSE servers
  end-to-end on the daemon (so cloud deployments work and tokens
  survive across turns), and inject Authorization: Bearer headers into
  the per-spawn .mcp.json the daemon writes for Claude Code (or the
  ACP mcpServers map for Hermes/Kimi).
- /api/mcp/servers and /api/mcp/oauth/{start,status,disconnect}
  endpoints, plus spawn-time wiring in agents that hands the configured
  servers to the active agent CLI.
- System-prompt directive for connected external MCPs so the model
  does not chase Claude Code's synthetic *_authenticate /
  *_complete_authentication tools when the Bearer is already pinned.

Web
- Settings -> External MCP servers panel with per-row OAuth Connect /
  Disconnect / Refresh affordances and per-row template hints.
- New "Add server" picker categorized into 7 groups
  (image-generation, image-editing, web-capture, ui-components,
  data-viz, publishing, utilities) with a search box, sticky close
  button, collapsible <details> sections (auto-expand on search),
  60vh capped scroll region, and a pinned Custom-server footer.
- ChatComposer /mcp slash and MCP picker button forward to the new
  Settings tab; AssistantMessage renders MCP tool calls inline;
  markdown autolinker handles bare http(s) URLs (incl. OAuth links)
  before italic markers so OAuth callback URLs do not get
  italic-fragmented mid-token.

Contracts
- packages/contracts/src/api/mcp.ts owns the wire shapes
  (McpServerConfig, McpTemplate with stable McpTemplateCategory
  enum, McpServersResponse, OAuth start/status/disconnect bodies, the
  postMessage payload from the OAuth callback).

Templates (17 built-in)
- image-generation: Higgsfield (OpenClaw, OAuth HTTP), Pollinations,
  Allyson (animated SVG), AWS Bedrock Image (uvx).
- image-editing: Imagician, ImageSorcery.
- web-capture: just-every screenshot-website-fast, ScreenshotOne.
- ui-components: 21st.dev Magic, shadcn/ui, FlyonUI.
- data-viz: AntV Chart, Mermaid.
- publishing: EdgeOne Pages.
- utilities: Filesystem, GitHub, Fetch.

Tests
- apps/daemon/tests/mcp-{config,oauth,tokens,spawn}.test.ts cover
  storage round-trip, OAuth helpers, token persistence, spawn-time
  wiring, every template's transport / command / args / env-field
  invariants, and the canonical category enum.
- apps/web/tests/runtime/markdown.test.tsx covers the new autolinker
  ordering rules.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): add 21 more design-focused templates and a `design-systems` category

Expands the built-in MCP picker from 17 to 38 templates so users can compose
the full Open Design craft loop (design-system intake → generate → edit →
audit → publish) without leaving the Settings dialog. Every install spec is
verified live against the upstream README; templates that needed Go binaries,
multi-step `init` ceremonies, or massive runtime stacks (PostgreSQL + Redis
+ Ollama) are intentionally deferred so picking a template still resolves to
a working server in one click.

New `design-systems` category between `web-capture` and `ui-components`
(reflects the upstream-of-components position in the workflow). Mirrored in
`McpTemplateCategory` on both contracts and daemon, and `CATEGORY_ORDER` on
the web side.

New templates by category:

- image-generation (+4): prompt-to-asset (icons / favicons / OG / logos with
  free-tier routing across Cloudflare AI / NVIDIA NIM / HF / Stable Horde),
  Nano Banana (hosted streamable HTTP, virtual try-on + product placement),
  Seedream (hosted streamable HTTP, ByteDance Seedream v3-v5 + SeedEdit),
  fal.ai (uvx, 600+ models incl. FLUX / Kling / Hunyuan / MusicGen).
- image-editing (+3): Photopea (34 layered-editor tools — closes the PSD
  gap), Topaz Labs (AI upscale / denoise / sharpen), Transloadit (86+ media
  pipeline robots).
- web-capture (+1): Pagecast (browser → demo GIF / MP4 with auto-zoom).
- design-systems (+4, NEW category): Figma-Context (Framelink, designs →
  code), Design Token Bridge (Tailwind ⇄ CSS ⇄ Figma ⇄ M3 / SwiftUI / W3C
  DTCG + WCAG contrast), Design System Extractor (Storybook scrape),
  Aesthetics Wiki (cottagecore / dark-academia / y2k / … moodboards).
- data-viz (+2): MCP Dashboards (45+ chart types + KPI dashboards),
  Excalidraw Architect (hand-drawn architecture diagrams).
- publishing (+6): PageDrop, PDFSpark, OGForge, QRMint, Slideshot
  (HTML → PDF / PPTX / PNG with 7 themes), Deckrun (Markdown → PDF / video,
  hosted free tier with no key required).
- utilities (+1): A11y axe-core (WCAG 2.0/2.1/2.2 + color-contrast + ARIA).

Tests cover every new template's wiring (command, args, env / header
required-vs-optional, secret flag), the category enum invariant, and
in-category declaration order for image-generation, design-systems and
publishing buckets where the order is what users see in the picker. 21 new
test cases pass; full mcp-config suite is green.

Templates intentionally deferred (documented in PR body): figma-use
(needs Figma desktop with --remote-debugging-port=9222), m-moire (multi-step
`memi suite init` + daemon ceremony), gemini-media-mcp + trident-mcp (Go
binaries — no npx / uvx path), Pixelle-MCP (full app with web UI + ComfyUI
backend), storybook-addon-mcp (lives inside user's Storybook, not standalone),
primitiv (multi-step init / build / serve), ReftrixMCP (PostgreSQL + Redis +
Ollama + DINOv2), narasimhaponnada/mermaid (overlap with peng-shawn).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(mcp): add figma-use template (write designs from chat) under design-systems

figma-use is the natural counterpart to Figma-Context already in this PR:
where Framelink reads Figma designs into the model, figma-use writes back
into the canvas (90+ tools — create frames / text / components / variants,
render JSX into Figma, export PNG/SVG, query nodes via XPath, lint for
WCAG / auto-layout / hardcoded colors, analyze design systems).

Wired as an HTTP MCP template (`http://localhost:38451/mcp`) because
`figma-use mcp serve` only exposes HTTP — there's no stdio mode in the
upstream `serve.ts`. No API key. Two prerequisites the user owns are
spelled out in the description so picking the template still resolves to
a working server: (1) start Figma with `--remote-debugging-port=9222`
(or `figma-use daemon start --pipe` on Figma 126+), and (2) leave
`npx figma-use mcp serve` running in a terminal.

Inserted between `design-system-extractor` and `aesthetics-wiki` so the
design-systems category reads as a workflow: read existing design (Figma
Context) → translate tokens (Token Bridge) → extract from Storybook
(Extractor) → write back to Figma (figma-use) → break creative block
(Aesthetics Wiki).

Tests cover the new template's transport (`http`), endpoint URL, the
empty header-fields invariant (no auth required), and bump the
design-systems group order to include it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(settings): i18n the External MCP / MCP server / Connectors sidebar entries and make the dialog header track the active section

The External MCP sidebar entry this PR introduces was hardcoded English
("External MCP / Add MCP tools (Higgsfield, GitHub…)"). Same for the
adjacent Connectors and MCP server entries. The dialog header was also
pinned to "Execution & model" copy, so opening Settings → External MCP
showed a header that lied about which section the user was on.

Adds six translation keys — `settings.connectorsTitle/Hint`,
`settings.mcpServerTitle/Hint`, `settings.externalMcpTitle/Hint` — and
translates them across all 17 locales (ar, de, en, es-ES, fa, fr, hu, id,
ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW).

`SettingsDialog` now derives the header title/subtitle from the active
section (11 sections total) instead of a single hardcoded pair, so each
section renders an honest header.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): pin level: 3 on dialog heading lookups for Pets and Connectors

CI's Validate workspace job (#1479) failed two Playwright cases with the
strict-mode violation:

  getByRole('dialog').getByRole('heading', { name: 'Pets' })
  resolved to 2 elements:
    1) <h2>Pets</h2>
    2) <h3>Pets</h3>

Same root cause as the unit-test fix already in this PR: the dynamic
dialog `<h2>` now echoes the section's own `<h3>` because the dialog
header tracks the active section. Disambiguate to `level: 3` so each
assertion still pins the section heading specifically (which is what
the test intends to verify).

Audit of the rest of e2e/ for `dialog.getByRole('heading', ...)` —
settings-api-protocol.test.ts looks for "OpenAI API" / "Anthropic API"
section h3s which never appear in the dialog `<h2>` (always
"Execution & model"), so those stay safe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): bind OAuth refresh to the issuing client and skip stale tokens

Persist the OAuth client context (token endpoint, client_id, client_secret,
issuer, redirect_uri, resource) alongside the bearer token so refresh hits
the same client the refresh_token was bound to (RFC 6749 §6). The previous
refresh path re-ran beginAuth with a dummy OOB redirect URI, which kept
getOrRegisterClient from finding the original DCR client and made
providers reject the refresh on the next chat turn. Refreshes now reuse
the persisted endpoint/client pair directly.

Also stop injecting expired access tokens at spawn time when refresh is
unavailable or fails. Pinning a stale Bearer made every Claude MCP call
401 while the prompt still treated the server as connected; on that path
we now skip the entry and let the UI surface a reconnect.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tom Huang 2026-05-08 17:59:20 +08:00 committed by GitHub
parent 2b5ea36f21
commit d592f6087f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 7547 additions and 392 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,601 @@
// Daemon-side OAuth 2.1 client for HTTP / SSE MCP servers.
//
// Replaces the per-agent `mcp-remote` subprocess that bound a transient
// `localhost:<port>` listener — that pattern can never work for a cloud-
// deployed daemon (the user's browser can't reach the listener) and it
// also broke locally because the listener died with the agent turn.
//
// What this module owns:
// - Discovery of the auth server for a given MCP URL
// (RFC 9728 protected-resource → RFC 8414 authorization-server).
// - Dynamic Client Registration (RFC 7591) when the server supports it,
// cached per `(authServerUrl, redirectUri)` in `<dataDir>/mcp-oauth-clients.json`
// so we register once and reuse forever.
// - PKCE (RFC 7636) code-verifier / code-challenge generation.
// - Authorization-code → token exchange and refresh-token rotation.
// - In-memory state cache keyed by the `state` parameter, used to look
// up the originating server + verifier when the browser hits our
// callback endpoint.
//
// Token persistence lives in `mcp-tokens.ts`. This file is the protocol
// layer; storage is somebody else's job.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import path from 'node:path';
// ───────────────────────────────────────────────────────────────────────
// Types — narrow subsets of the relevant RFC payloads.
// ───────────────────────────────────────────────────────────────────────
/** RFC 9728 `oauth-protected-resource` document fields we use. */
export interface ProtectedResourceMetadata {
resource?: string;
authorization_servers?: string[];
scopes_supported?: string[];
}
/** RFC 8414 / OIDC discovery document fields we use. */
export interface AuthorizationServerMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint?: string;
scopes_supported?: string[];
response_types_supported?: string[];
grant_types_supported?: string[];
code_challenge_methods_supported?: string[];
token_endpoint_auth_methods_supported?: string[];
}
/** Cached client registration for a given auth server + redirect URI. */
export interface RegisteredClient {
authServerIssuer: string;
redirectUri: string;
clientId: string;
clientSecret?: string;
registeredAt: number;
}
/** RFC 6749 §5.1 token endpoint response (subset). */
export interface OAuthTokenResponse {
access_token: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
}
/** In-flight authorization request. Stashed in memory while the user
* approves in their browser. */
export interface PendingAuthState {
serverId: string;
authServerIssuer: string;
tokenEndpoint: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
codeVerifier: string;
scope?: string;
resourceUrl?: string;
createdAt: number;
}
// ───────────────────────────────────────────────────────────────────────
// PKCE + state helpers.
// ───────────────────────────────────────────────────────────────────────
const VERIFIER_LEN = 64; // RFC 7636 §4.1: 43128 chars
function base64url(buf: Buffer): string {
return buf
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function generateCodeVerifier(): string {
return base64url(randomBytes(VERIFIER_LEN));
}
export function deriveCodeChallenge(verifier: string): string {
return base64url(createHash('sha256').update(verifier).digest());
}
export function generateState(): string {
return base64url(randomBytes(32));
}
// ───────────────────────────────────────────────────────────────────────
// Discovery.
// ───────────────────────────────────────────────────────────────────────
/**
* Try to fetch the protected-resource metadata for a given MCP URL.
*
* Per RFC 9728, the well-known is at the resource origin's
* `/.well-known/oauth-protected-resource[<path>]`. We try both the
* path-suffixed form and the bare `/.well-known/...` so servers that
* only publish at the root still work.
*/
export async function discoverProtectedResource(
resourceUrl: string,
fetchImpl: typeof fetch = fetch,
): Promise<ProtectedResourceMetadata | null> {
let parsed: URL;
try {
parsed = new URL(resourceUrl);
} catch {
return null;
}
const candidates = [
new URL(
`/.well-known/oauth-protected-resource${parsed.pathname.replace(/\/+$/u, '')}`,
`${parsed.protocol}//${parsed.host}`,
).toString(),
new URL('/.well-known/oauth-protected-resource', `${parsed.protocol}//${parsed.host}`).toString(),
];
for (const url of candidates) {
const json = await fetchJson<ProtectedResourceMetadata>(url, fetchImpl);
if (json) return json;
}
return null;
}
/**
* Fetch the authorization-server metadata for an issuer URL. Tries both
* the OAuth (RFC 8414) and OIDC layouts (`/.well-known/oauth-authorization-server`
* and `/.well-known/openid-configuration`); some providers only publish one.
*/
export async function discoverAuthServer(
issuer: string,
fetchImpl: typeof fetch = fetch,
): Promise<AuthorizationServerMetadata | null> {
let parsed: URL;
try {
parsed = new URL(issuer);
} catch {
return null;
}
const trimmed = parsed.pathname.replace(/\/+$/u, '');
const base = `${parsed.protocol}//${parsed.host}`;
const candidates = [
`${base}/.well-known/oauth-authorization-server${trimmed}`,
`${base}/.well-known/openid-configuration${trimmed}`,
`${base}/.well-known/oauth-authorization-server`,
`${base}/.well-known/openid-configuration`,
];
for (const url of candidates) {
const json = await fetchJson<AuthorizationServerMetadata>(url, fetchImpl);
if (json && typeof json.authorization_endpoint === 'string' && typeof json.token_endpoint === 'string') {
// Spread first so the explicit issuer wins (otherwise duplicate-key
// assignments under exactOptionalPropertyTypes complain).
return { ...json, issuer: json.issuer ?? issuer };
}
}
return null;
}
async function fetchJson<T>(
url: string,
fetchImpl: typeof fetch,
): Promise<T | null> {
try {
const res = await fetchImpl(url, {
headers: { accept: 'application/json' },
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
}
// ───────────────────────────────────────────────────────────────────────
// Dynamic Client Registration (RFC 7591) + cache.
// ───────────────────────────────────────────────────────────────────────
interface ClientCacheFile {
clients: RegisteredClient[];
}
function clientsFile(dataDir: string): string {
return path.join(dataDir, 'mcp-oauth-clients.json');
}
async function readClientCache(dataDir: string): Promise<ClientCacheFile> {
try {
const raw = await readFile(clientsFile(dataDir), 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.clients)) return { clients: [] };
return { clients: parsed.clients.filter(isRegisteredClient) };
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code === 'ENOENT') return { clients: [] };
throw err;
}
}
function isRegisteredClient(v: unknown): v is RegisteredClient {
if (!v || typeof v !== 'object') return false;
const r = v as Record<string, unknown>;
return (
typeof r.authServerIssuer === 'string' &&
typeof r.redirectUri === 'string' &&
typeof r.clientId === 'string'
);
}
async function writeClientCache(
dataDir: string,
next: ClientCacheFile,
): Promise<void> {
const file = clientsFile(dataDir);
await mkdir(path.dirname(file), { recursive: true });
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
await rename(tmp, file);
}
/**
* POST to the auth server's `registration_endpoint` per RFC 7591. Returns
* a freshly issued client_id (and optional client_secret). Caller is
* responsible for caching the result.
*/
export async function registerClient(
registrationEndpoint: string,
redirectUri: string,
fetchImpl: typeof fetch = fetch,
): Promise<{ clientId: string; clientSecret?: string }> {
const body = {
redirect_uris: [redirectUri],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: 'Open Design',
application_type: 'web',
};
const res = await fetchImpl(registrationEndpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const txt = await safeText(res);
throw new Error(
`dynamic client registration failed: HTTP ${res.status} ${res.statusText} ${txt}`,
);
}
const json = (await res.json()) as { client_id?: string; client_secret?: string };
if (!json.client_id) {
throw new Error('dynamic client registration response missing client_id');
}
const out: { clientId: string; clientSecret?: string } = { clientId: json.client_id };
if (json.client_secret) out.clientSecret = json.client_secret;
return out;
}
/**
* Cached version of `registerClient`. Looks up `(authServerIssuer, redirectUri)`
* in the cache file and re-uses the existing client; falls back to a fresh
* DCR call when nothing is cached.
*/
export async function getOrRegisterClient(
dataDir: string,
authServer: AuthorizationServerMetadata,
redirectUri: string,
fetchImpl: typeof fetch = fetch,
): Promise<RegisteredClient> {
const cache = await readClientCache(dataDir);
const cached = cache.clients.find(
(c) => c.authServerIssuer === authServer.issuer && c.redirectUri === redirectUri,
);
if (cached) return cached;
if (!authServer.registration_endpoint) {
throw new Error(
`auth server ${authServer.issuer} does not advertise a registration_endpoint and no client is pre-registered`,
);
}
const reg = await registerClient(
authServer.registration_endpoint,
redirectUri,
fetchImpl,
);
const next: RegisteredClient = {
authServerIssuer: authServer.issuer,
redirectUri,
clientId: reg.clientId,
registeredAt: Date.now(),
};
if (reg.clientSecret) next.clientSecret = reg.clientSecret;
cache.clients.push(next);
await writeClientCache(dataDir, cache);
return next;
}
// ───────────────────────────────────────────────────────────────────────
// Authorization URL builder.
// ───────────────────────────────────────────────────────────────────────
export interface AuthorizeUrlInput {
authServer: AuthorizationServerMetadata;
clientId: string;
redirectUri: string;
state: string;
codeChallenge: string;
scope?: string;
resource?: string;
}
export function buildAuthorizeUrl(input: AuthorizeUrlInput): string {
const u = new URL(input.authServer.authorization_endpoint);
u.searchParams.set('response_type', 'code');
u.searchParams.set('client_id', input.clientId);
u.searchParams.set('redirect_uri', input.redirectUri);
u.searchParams.set('state', input.state);
u.searchParams.set('code_challenge', input.codeChallenge);
u.searchParams.set('code_challenge_method', 'S256');
if (input.scope) u.searchParams.set('scope', input.scope);
// RFC 8707 resource indicator — narrows the issued token to the MCP
// resource we actually care about. Most authoritative MCP servers
// require it; harmless when ignored.
if (input.resource) u.searchParams.set('resource', input.resource);
return u.toString();
}
// ───────────────────────────────────────────────────────────────────────
// Token endpoint: code exchange + refresh.
// ───────────────────────────────────────────────────────────────────────
export interface ExchangeCodeInput {
tokenEndpoint: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
code: string;
codeVerifier: string;
resource?: string;
}
export async function exchangeCodeForToken(
input: ExchangeCodeInput,
fetchImpl: typeof fetch = fetch,
): Promise<OAuthTokenResponse> {
const form = new URLSearchParams();
form.set('grant_type', 'authorization_code');
form.set('code', input.code);
form.set('redirect_uri', input.redirectUri);
form.set('client_id', input.clientId);
form.set('code_verifier', input.codeVerifier);
if (input.resource) form.set('resource', input.resource);
return tokenRequest(input.tokenEndpoint, form, input.clientSecret, fetchImpl);
}
export interface RefreshTokenInput {
tokenEndpoint: string;
clientId: string;
clientSecret?: string;
refreshToken: string;
scope?: string;
resource?: string;
}
export async function refreshAccessToken(
input: RefreshTokenInput,
fetchImpl: typeof fetch = fetch,
): Promise<OAuthTokenResponse> {
const form = new URLSearchParams();
form.set('grant_type', 'refresh_token');
form.set('refresh_token', input.refreshToken);
form.set('client_id', input.clientId);
if (input.scope) form.set('scope', input.scope);
if (input.resource) form.set('resource', input.resource);
return tokenRequest(input.tokenEndpoint, form, input.clientSecret, fetchImpl);
}
async function tokenRequest(
tokenEndpoint: string,
form: URLSearchParams,
clientSecret: string | undefined,
fetchImpl: typeof fetch,
): Promise<OAuthTokenResponse> {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded',
accept: 'application/json',
};
if (clientSecret) {
// RFC 6749 §2.3.1 — confidential clients use HTTP Basic with the
// client_id we already put in the form. Public clients (PKCE-only)
// skip this branch.
const basic = Buffer.from(`${form.get('client_id')}:${clientSecret}`).toString('base64');
headers['authorization'] = `Basic ${basic}`;
}
const res = await fetchImpl(tokenEndpoint, {
method: 'POST',
headers,
body: form.toString(),
});
if (!res.ok) {
const txt = await safeText(res);
throw new Error(
`token endpoint rejected request: HTTP ${res.status} ${res.statusText} ${txt}`,
);
}
const json = (await res.json()) as OAuthTokenResponse;
if (!json.access_token) {
throw new Error('token endpoint response missing access_token');
}
return json;
}
async function safeText(res: Response): Promise<string> {
try {
const t = await res.text();
return t.slice(0, 500);
} catch {
return '';
}
}
// ───────────────────────────────────────────────────────────────────────
// In-memory pending-state cache.
// ───────────────────────────────────────────────────────────────────────
/**
* The OAuth dance is split across two HTTP requests on our side:
* 1. POST /api/mcp/oauth/start we mint state + verifier
* 2. GET /api/mcp/oauth/callback browser returns code + state
* State has to survive between (1) and (2) on the daemon. We keep it in a
* Map with a TTL sweeper; persistence isn't needed because the user has
* to complete auth in the same daemon process anyway (state is single-use).
*/
export class PendingAuthCache {
private store = new Map<string, PendingAuthState>();
private timer: ReturnType<typeof setInterval> | null = null;
constructor(private readonly ttlMs: number = 10 * 60 * 1000) {}
put(state: string, value: PendingAuthState): void {
this.store.set(state, value);
this.startSweeper();
}
/** One-shot consume any successful callback removes the state so a
* replay can't reuse it. */
consume(state: string): PendingAuthState | null {
const v = this.store.get(state);
if (!v) return null;
this.store.delete(state);
if (Date.now() - v.createdAt > this.ttlMs) return null;
return v;
}
size(): number {
return this.store.size;
}
/** Stop the background sweeper. Used by tests; production lets the
* timer ride on the daemon process lifetime. */
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private startSweeper(): void {
if (this.timer) return;
this.timer = setInterval(() => this.sweep(), Math.min(this.ttlMs, 60_000));
// unref so the cache doesn't keep the event loop alive in tests
if (typeof this.timer === 'object' && this.timer && typeof (this.timer as { unref?: () => void }).unref === 'function') {
(this.timer as { unref: () => void }).unref();
}
}
private sweep(): void {
const now = Date.now();
for (const [k, v] of this.store) {
if (now - v.createdAt > this.ttlMs) this.store.delete(k);
}
if (this.store.size === 0 && this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
// ───────────────────────────────────────────────────────────────────────
// Top-level "begin auth" helper.
// ───────────────────────────────────────────────────────────────────────
export interface BeginAuthInput {
serverId: string;
serverUrl: string;
redirectUri: string;
dataDir: string;
scope?: string;
fetchImpl?: typeof fetch;
}
export interface BeginAuthResult {
authorizeUrl: string;
state: string;
pending: PendingAuthState;
}
/**
* Run the entire pre-redirect half of the OAuth dance:
* discovery DCR (cached) PKCE authorize URL.
*
* Returns everything the caller needs to (a) push the user's browser at the
* correct authorize URL, and (b) finish the flow when the callback hits.
*/
export async function beginAuth(
input: BeginAuthInput,
): Promise<BeginAuthResult> {
const fetchImpl = input.fetchImpl ?? fetch;
// Step 1: ask the MCP server who its auth server is. If the server
// doesn't publish protected-resource metadata, fall back to assuming
// the resource origin IS the auth server — most "stand-alone" MCP
// providers (Higgsfield etc.) host both at the same host.
const prm = await discoverProtectedResource(input.serverUrl, fetchImpl);
const issuerHint = prm?.authorization_servers?.[0];
const issuer = issuerHint ?? new URL(input.serverUrl).origin;
// Step 2: discovery on the auth server.
const authServer = await discoverAuthServer(issuer, fetchImpl);
if (!authServer) {
throw new Error(`could not discover OAuth metadata for ${issuer}`);
}
// Step 3: ensure we have a registered client_id (DCR if missing).
const client = await getOrRegisterClient(
input.dataDir,
authServer,
input.redirectUri,
fetchImpl,
);
// Step 4: PKCE + state.
const codeVerifier = generateCodeVerifier();
const codeChallenge = deriveCodeChallenge(codeVerifier);
const state = generateState();
const scope =
input.scope ??
(Array.isArray(prm?.scopes_supported) && prm!.scopes_supported!.length > 0
? prm!.scopes_supported!.join(' ')
: authServer.scopes_supported?.join(' '));
const resource = prm?.resource ?? input.serverUrl;
const authUrlInput: AuthorizeUrlInput = {
authServer,
clientId: client.clientId,
redirectUri: input.redirectUri,
state,
codeChallenge,
resource,
};
if (scope) authUrlInput.scope = scope;
const authorizeUrl = buildAuthorizeUrl(authUrlInput);
const pending: PendingAuthState = {
serverId: input.serverId,
authServerIssuer: authServer.issuer,
tokenEndpoint: authServer.token_endpoint,
clientId: client.clientId,
redirectUri: input.redirectUri,
codeVerifier,
resourceUrl: resource,
createdAt: Date.now(),
};
if (client.clientSecret) pending.clientSecret = client.clientSecret;
if (scope) pending.scope = scope;
return { authorizeUrl, state, pending };
}

View file

@ -0,0 +1,258 @@
// Persistent OAuth-token storage for external HTTP / SSE MCP servers.
//
// The daemon owns the OAuth flow end-to-end so the user never needs a
// transient `localhost:<port>` listener (the killer of cloud deployments)
// and so a token survives across agent turns. Tokens are written to
// `<dataDir>/mcp-tokens.json` keyed by McpServerConfig.id, with the same
// atomic write + per-dataDir mutex pattern the rest of the daemon uses.
//
// File mode: chmod 0600 on POSIX so other local users can't read raw
// bearer tokens. This is best-effort — we log and continue if the chmod
// fails (e.g. on Windows / some networked filesystems).
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
/**
* Stored OAuth token for a single MCP server. Mirrors the relevant subset
* of an OAuth 2.0 token-endpoint response (RFC 6749 §5.1), plus the OAuth
* client context the original authorization-code exchange used. Refresh
* tokens are bound (RFC 6749 §6) to the client that received them, so we
* have to refresh against the same `client_id` / `redirect_uri` pair
* persisting the context here is what lets us do that without re-running
* authorization.
*/
export interface StoredMcpToken {
/** The bearer token to send as `Authorization: Bearer …`. */
accessToken: string;
/** Refresh token (RFC 6749 §6) if the auth server issued one. */
refreshToken?: string;
/** Absolute epoch ms at which `accessToken` expires. Optional some
* providers never expire. */
expiresAt?: number;
/** RFC 6749 §5.1 token_type. Almost always `Bearer`. */
tokenType: string;
/** Space-separated scopes granted (verbatim from the token response). */
scope?: string;
/** Wall-clock epoch ms when this record was first persisted. */
savedAt: number;
/** Token endpoint that issued this token; reused verbatim for refresh. */
tokenEndpoint?: string;
/** Client id that obtained the refresh token. */
clientId?: string;
/** Confidential-client secret, if the upstream issued one. */
clientSecret?: string;
/** Authorization-server issuer, used to look the cached client back up. */
authServerIssuer?: string;
/** Redirect URI registered with the client at authorization time. */
redirectUri?: string;
/** RFC 8707 resource indicator the original token was scoped to. */
resourceUrl?: string;
}
export interface McpTokensFile {
/** keyed by McpServerConfig.id */
servers: Record<string, StoredMcpToken>;
}
const EMPTY: McpTokensFile = { servers: {} };
function tokensFile(dataDir: string): string {
return path.join(dataDir, 'mcp-tokens.json');
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
/** Coerce a freeform JSON blob into the typed shape, dropping anything that
* doesn't deserialize cleanly. Used both at read time and as a defensive
* pass when third-party tooling has hand-edited the file. */
export function sanitizeTokensFile(raw: unknown): McpTokensFile {
if (!isPlainObject(raw)) return { servers: {} };
const servers = raw.servers;
if (!isPlainObject(servers)) return { servers: {} };
const out: Record<string, StoredMcpToken> = {};
for (const [id, value] of Object.entries(servers)) {
if (id === '__proto__' || id === 'constructor') continue;
const tok = sanitizeToken(value);
if (!tok) continue;
out[id] = tok;
}
return { servers: out };
}
function sanitizeToken(raw: unknown): StoredMcpToken | null {
if (!isPlainObject(raw)) return null;
const accessToken =
typeof raw.accessToken === 'string' ? raw.accessToken.trim() : '';
if (!accessToken) return null;
const tokenType =
typeof raw.tokenType === 'string' && raw.tokenType.trim()
? raw.tokenType.trim()
: 'Bearer';
const refreshToken =
typeof raw.refreshToken === 'string' && raw.refreshToken.trim()
? raw.refreshToken.trim()
: undefined;
const scope =
typeof raw.scope === 'string' && raw.scope.trim()
? raw.scope.trim()
: undefined;
const expiresAt =
typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt)
? raw.expiresAt
: undefined;
const savedAt =
typeof raw.savedAt === 'number' && Number.isFinite(raw.savedAt)
? raw.savedAt
: Date.now();
const tokenEndpoint =
typeof raw.tokenEndpoint === 'string' && raw.tokenEndpoint.trim()
? raw.tokenEndpoint.trim()
: undefined;
const clientId =
typeof raw.clientId === 'string' && raw.clientId.trim()
? raw.clientId.trim()
: undefined;
const clientSecret =
typeof raw.clientSecret === 'string' && raw.clientSecret.trim()
? raw.clientSecret.trim()
: undefined;
const authServerIssuer =
typeof raw.authServerIssuer === 'string' && raw.authServerIssuer.trim()
? raw.authServerIssuer.trim()
: undefined;
const redirectUri =
typeof raw.redirectUri === 'string' && raw.redirectUri.trim()
? raw.redirectUri.trim()
: undefined;
const resourceUrl =
typeof raw.resourceUrl === 'string' && raw.resourceUrl.trim()
? raw.resourceUrl.trim()
: undefined;
const out: StoredMcpToken = { accessToken, tokenType, savedAt };
if (refreshToken) out.refreshToken = refreshToken;
if (scope) out.scope = scope;
if (expiresAt !== undefined) out.expiresAt = expiresAt;
if (tokenEndpoint) out.tokenEndpoint = tokenEndpoint;
if (clientId) out.clientId = clientId;
if (clientSecret) out.clientSecret = clientSecret;
if (authServerIssuer) out.authServerIssuer = authServerIssuer;
if (redirectUri) out.redirectUri = redirectUri;
if (resourceUrl) out.resourceUrl = resourceUrl;
return out;
}
export async function readTokensFile(dataDir: string): Promise<McpTokensFile> {
try {
const raw = await readFile(tokensFile(dataDir), 'utf8');
return sanitizeTokensFile(JSON.parse(raw));
} catch (err: unknown) {
const e = err as { code?: string; name?: string; message?: string };
if (e.code === 'ENOENT') return { ...EMPTY, servers: { ...EMPTY.servers } };
if (e.name === 'SyntaxError') {
console.error('[mcp-tokens] Corrupted JSON, returning empty:', e.message);
return { ...EMPTY, servers: { ...EMPTY.servers } };
}
throw err;
}
}
const writeLocks = new Map<string, Promise<unknown>>();
async function withLock<T>(dataDir: string, fn: () => Promise<T>): Promise<T> {
const prev = writeLocks.get(dataDir) ?? Promise.resolve();
const task = prev.catch(() => {}).then(fn);
writeLocks.set(dataDir, task);
try {
return await task;
} finally {
if (writeLocks.get(dataDir) === task) writeLocks.delete(dataDir);
}
}
async function writeTokensFile(
dataDir: string,
next: McpTokensFile,
): Promise<McpTokensFile> {
const file = tokensFile(dataDir);
await mkdir(path.dirname(file), { recursive: true });
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
await rename(tmp, file);
// Best-effort lockdown of file mode. Bearer tokens can hand someone
// posting-as-you against the upstream MCP, so we restrict to owner-only
// read/write where the OS supports it.
try {
await chmod(file, 0o600);
} catch (err: unknown) {
const e = err as { code?: string; message?: string };
if (e.code !== 'ENOTSUP' && e.code !== 'EPERM') {
console.warn(
'[mcp-tokens] could not chmod 0600',
file,
e.message ?? err,
);
}
}
return next;
}
/** Get the current token for a given server, or null when none is stored
* (or the persisted entry is malformed). */
export async function getToken(
dataDir: string,
serverId: string,
): Promise<StoredMcpToken | null> {
const file = await readTokensFile(dataDir);
return file.servers[serverId] ?? null;
}
/** Atomically merge a new token for `serverId` into the tokens file. */
export async function setToken(
dataDir: string,
serverId: string,
token: StoredMcpToken,
): Promise<void> {
await withLock(dataDir, async () => {
const file = await readTokensFile(dataDir);
file.servers[serverId] = token;
await writeTokensFile(dataDir, file);
});
}
/** Atomically delete the stored token for `serverId`. No-op when absent. */
export async function clearToken(
dataDir: string,
serverId: string,
): Promise<void> {
await withLock(dataDir, async () => {
const file = await readTokensFile(dataDir);
if (!(serverId in file.servers)) return;
delete file.servers[serverId];
await writeTokensFile(dataDir, file);
});
}
/** Bulk read used by the spawn pipeline so we make one disk hit per spawn,
* not one per server. */
export async function readAllTokens(
dataDir: string,
): Promise<Record<string, StoredMcpToken>> {
const file = await readTokensFile(dataDir);
return file.servers;
}
/** True when the stored token is past its `expiresAt` (or within `skew`
* milliseconds of expiring). Returns false when no `expiresAt` is recorded
* many providers issue non-expiring tokens. */
export function isTokenExpired(
token: StoredMcpToken,
now: number = Date.now(),
skew: number = 30_000,
): boolean {
if (typeof token.expiresAt !== 'number') return false;
return token.expiresAt - skew <= now;
}

View file

@ -120,6 +120,16 @@ export interface ComposeInput {
// Skill identifier. Required when critique is enabled;
// ignored when critique is disabled or omitted.
critiqueSkill?: { id: string } | undefined;
// External MCP servers the daemon already holds a valid OAuth Bearer
// token for at spawn time. We surface the list to the model so it does
// NOT chase Claude Code's synthetic `*_authenticate` /
// `*_complete_authentication` tools that get injected when the HTTP
// transport's first connect transiently flips a server into
// needs-auth state — the Bearer is in `.mcp.json`, the real tools are
// available, and burning a turn on a redundant OAuth dance just
// confuses the user.
connectedExternalMcp?: ReadonlyArray<{ id: string; label?: string | undefined }>
| undefined;
}
export function composeSystemPrompt({
@ -137,6 +147,7 @@ export function composeSystemPrompt({
critique,
critiqueBrand,
critiqueSkill,
connectedExternalMcp,
}: ComposeInput): string {
// Discovery + philosophy goes FIRST so its hard rules ("emit a form on
// turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run
@ -233,9 +244,59 @@ export function composeSystemPrompt({
parts.push('\n\n' + renderPanelPrompt({ cfg, brand: critiqueBrand, skill: critiqueSkill }));
}
const mcpDirective = renderConnectedExternalMcpDirective(connectedExternalMcp);
if (mcpDirective) parts.push(mcpDirective);
return parts.join('');
}
// Defense-in-depth against Claude Code's synthetic OAuth tools.
//
// When Claude Code's built-in HTTP MCP transport gets a 401 on its first
// initialize (transient propagation lag, edge cache miss, header
// re-canonicalization quirk, etc.), it injects two synthetic tools per
// server — `mcp__<server>__authenticate` and
// `mcp__<server>__complete_authentication` — that drive a per-process
// OAuth dance with a `localhost:<random>/callback` redirect_uri. That
// listener dies with the agent process, so the round-trip never
// completes, and meanwhile the model burns a turn pasting an
// unreachable URL into the chat. By the time the user is back, our
// daemon-issued Bearer is already in `.mcp.json` and the real tools
// (`generate_image`, `models_explore`, …) are reachable on the next
// turn — but the model doesn't know that and keeps escalating the
// fake auth flow.
//
// The fix is to tell the model up front: these specific servers are
// already authenticated by the daemon, do NOT call any
// `*_authenticate` / `*_complete_authentication` tool for them. If
// the real tools really are missing, surface that as a separate
// failure instead of pivoting to the synthetic flow.
function renderConnectedExternalMcpDirective(
connectedExternalMcp:
| ReadonlyArray<{ id: string; label?: string | undefined }>
| undefined,
): string {
if (!connectedExternalMcp || connectedExternalMcp.length === 0) return '';
const lines = connectedExternalMcp
.map((s) => {
const id = typeof s?.id === 'string' ? s.id.trim() : '';
if (!id) return null;
const label = typeof s?.label === 'string' && s.label.trim() ? s.label.trim() : id;
return `- \`${id}\`${label !== id ? ` (${label})` : ''}`;
})
.filter((line): line is string => typeof line === 'string');
if (lines.length === 0) return '';
return [
'\n\n---\n\n',
'## External MCP servers — already authenticated\n\n',
'The following external MCP servers are already authenticated for this run via an OAuth Bearer token the daemon injected into `.mcp.json`. You can call their real tools directly:\n\n',
lines.join('\n'),
'\n\n',
'**Do NOT call any tool whose name matches `mcp__<server>__authenticate` or `mcp__<server>__complete_authentication` for the servers above.** Those are synthetic fallback tools Claude Code exposes when its first HTTP connect briefly flipped the server into a needs-auth state. The flow they drive (a `localhost:<random>/callback` redirect) cannot complete in this environment, and the real tools (e.g. `generate_image`, `models_explore`, `balance`, …) are already reachable.\n\n',
'If a real tool actually fails with an auth-related error, report the exact tool name and error text and stop — the user will reconnect the server in Settings → External MCP. Do not retry by invoking any `*_authenticate` tool.\n',
].join('');
}
const CODEX_IMAGEGEN_MODEL_IDS = new Set(
IMAGE_MODELS.filter(
(model) =>

View file

@ -75,6 +75,27 @@ import {
VIDEO_MODELS,
} from './media-models.js';
import { readMaskedConfig, writeConfig } from './media-config.js';
import {
MCP_TEMPLATES,
buildAcpMcpServers,
buildClaudeMcpJson,
isManagedProjectCwd,
readMcpConfig,
writeMcpConfig,
} from './mcp-config.js';
import {
beginAuth,
exchangeCodeForToken,
PendingAuthCache,
refreshAccessToken,
} from './mcp-oauth.js';
import {
clearToken,
getToken,
isTokenExpired,
readAllTokens,
setToken,
} from './mcp-tokens.js';
import { agentCliEnvForAgent, readAppConfig, writeAppConfig } from './app-config.js';
import { OrbitService, formatLocalProjectTimestamp, renderOrbitTemplateSystemPrompt } from './orbit.js';
import { buildMcpInstallPayload } from './mcp-install-info.js';
@ -768,6 +789,84 @@ const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects');
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const orbitService = new OrbitService(RUNTIME_DATA_DIR);
// In-memory OAuth state cache. Lives for the daemon process's lifetime.
// Maps the OAuth `state` parameter we generated in /api/mcp/oauth/start
// to the verifier + endpoint info needed to finish the exchange when the
// browser hits /api/mcp/oauth/callback.
const mcpPendingAuth = new PendingAuthCache();
/**
* Resolve the daemon's public base URL — the origin the user's browser
* (or the OAuth provider) reaches us at. Order of precedence:
*
* 1. `OD_PUBLIC_BASE_URL` env var. Cloud and packaged-electron deployments
* set this to the externally-routable URL (e.g. `https://app.example.com`).
* 2. `req.protocol://req.get('host')` from the inbound request. Works in
* local dev and most reverse-proxy setups (Express respects
* `trust proxy` so X-Forwarded-* headers are honored).
*
* The OAuth callback URI is derived from this it MUST be reachable from
* the user's browser, otherwise the redirect after auth lands on
* ERR_CONNECTION_REFUSED. Misconfiguration is loud: the OAuth provider
* will reject `redirect_uri` mismatches.
*/
function getPublicBaseUrl(req) {
const env = process.env.OD_PUBLIC_BASE_URL;
if (env && /^https?:\/\//i.test(env)) {
return env.replace(/\/+$/u, '');
}
const proto = req.protocol || 'http';
const host = req.get('host');
if (!host) return `http://localhost:${process.env.OD_PORT ?? '7456'}`;
return `${proto}://${host}`;
}
function mcpOAuthCallbackUrl(req) {
return `${getPublicBaseUrl(req)}/api/mcp/oauth/callback`;
}
/**
* Refresh an expired token using the OAuth client context that the original
* authorization-code exchange persisted alongside the token. Refresh tokens
* are bound (RFC 6749 §6) to the client that received them, so we MUST
* refresh against the same `tokenEndpoint` / `clientId` / `clientSecret`
* pair re-running discovery with a different redirect URI would risk
* registering a new client_id that the upstream then rejects the refresh
* for. Tokens persisted before that context was recorded can't be safely
* refreshed; the caller treats `null` as "needs reconnect".
*/
async function refreshAndPersistToken(dataDir, serverId, current) {
if (!current.refreshToken) return null;
if (!current.tokenEndpoint || !current.clientId) return null;
const tokenResp = await refreshAccessToken({
tokenEndpoint: current.tokenEndpoint,
clientId: current.clientId,
clientSecret: current.clientSecret,
refreshToken: current.refreshToken,
scope: current.scope,
resource: current.resourceUrl,
});
const next = {
accessToken: tokenResp.access_token,
refreshToken: tokenResp.refresh_token ?? current.refreshToken,
tokenType: tokenResp.token_type ?? 'Bearer',
scope: tokenResp.scope ?? current.scope,
expiresAt:
typeof tokenResp.expires_in === 'number'
? Date.now() + tokenResp.expires_in * 1000
: undefined,
savedAt: Date.now(),
tokenEndpoint: current.tokenEndpoint,
clientId: current.clientId,
clientSecret: current.clientSecret,
authServerIssuer: current.authServerIssuer,
redirectUri: current.redirectUri,
resourceUrl: current.resourceUrl,
};
await setToken(dataDir, serverId, next);
return next;
}
const activeChatAgentEventSinks = new Map();
const activeProjectEventSinks = new Map();
@ -1199,6 +1298,95 @@ function requireLocalDaemonRequest(req, res, next) {
next();
}
/**
* Render the small HTML page that the OAuth callback returns to the
* user's browser tab. It posts a message back to the opener (the
* Settings dialog window) and offers a manual close button. We keep
* the markup pure HTML/CSS no external scripts, no React so the
* page works even if the opener was closed and the user just sees a
* static success/failure screen.
*/
function renderOAuthResultPage(opts) {
const ok = Boolean(opts.ok);
const title = ok ? 'Connected' : 'Authorization failed';
const heading = ok ? '✅ Connected' : '⚠️ Authorization failed';
const body = ok
? `Your MCP server <code>${escapeHtml(opts.serverId ?? '')}</code> is now connected. You can close this tab and return to Open Design.`
: escapeHtml(opts.message ?? 'Authorization could not be completed.');
const accent = ok ? '#1a7f37' : '#cf222e';
const payload = ok
? { type: 'mcp-oauth', ok: true, serverId: opts.serverId ?? null }
: { type: 'mcp-oauth', ok: false, message: opts.message ?? null };
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)} Open Design</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; }
body {
display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, sans-serif;
background: #f6f7f9; color: #1f2328; padding: 24px;
}
@media (prefers-color-scheme: dark) {
body { background: #0d1117; color: #e6edf3; }
.card { background: #161b22; border-color: #30363d; }
code { background: #1f242c; }
}
.card {
max-width: 420px; width: 100%; padding: 28px 28px 22px; border-radius: 12px;
background: white; border: 1px solid #d0d7de; box-shadow: 0 8px 24px rgba(0,0,0,.06);
text-align: left;
}
h1 { margin: 0 0 8px; font-size: 18px; color: ${accent}; }
p { margin: 0 0 16px; font-size: 14px; line-height: 1.55; }
code { background: #f3f4f6; padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
button {
appearance: none; border: 1px solid #d0d7de; background: white;
border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer;
}
button:hover { background: #f6f8fa; }
@media (prefers-color-scheme: dark) {
button { background: #21262d; border-color: #30363d; color: #e6edf3; }
button:hover { background: #30363d; }
}
</style>
</head>
<body>
<div class="card">
<h1>${escapeHtml(heading)}</h1>
<p>${body}</p>
<button type="button" onclick="window.close()">Close this tab</button>
</div>
<script>
try {
var payload = ${JSON.stringify(payload)};
if (window.opener && !window.opener.closed) {
window.opener.postMessage(payload, '*');
}
if (window.BroadcastChannel) {
var bc = new BroadcastChannel('open-design-mcp-oauth');
bc.postMessage(payload);
bc.close();
}
} catch (e) { /* ignore postMessage failures */ }
</script>
</body>
</html>`;
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function setLiveArtifactPreviewHeaders(res) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
@ -1796,6 +1984,210 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
res.json(payload);
});
// External MCP server configuration. Open Design connects to these as a
// CLIENT and surfaces their tools to the underlying agent at spawn time.
// GET returns user-saved entries plus the built-in template list so the UI
// can render the "Add MCP server" picker without a second round-trip.
app.get('/api/mcp/servers', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const cfg = await readMcpConfig(RUNTIME_DATA_DIR);
res.json({ servers: cfg.servers, templates: MCP_TEMPLATES });
} catch (err) {
res
.status(500)
.json({ error: String(err && err.message ? err.message : err) });
}
});
app.put('/api/mcp/servers', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const cfg = await writeMcpConfig(RUNTIME_DATA_DIR, req.body);
res.json({ servers: cfg.servers, templates: MCP_TEMPLATES });
} catch (err) {
res
.status(400)
.json({ error: String(err && err.message ? err.message : err) });
}
});
// ─────────────────────────────────────────────────────────────────
// External MCP server OAuth — daemon-owned authorization flow.
//
// Replaces per-spawn `mcp-remote` subprocesses. The token is stored
// server-side in <dataDir>/mcp-tokens.json and injected as a Bearer
// header into the `.mcp.json` we write for Claude Code at spawn time.
// The redirect URI points at THIS daemon's public origin so the flow
// works the same in local dev (loopback) and in cloud deployments
// where OD_PUBLIC_BASE_URL pins the externally-routable URL.
// ─────────────────────────────────────────────────────────────────
app.post('/api/mcp/oauth/start', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const serverId =
typeof req.body?.serverId === 'string' ? req.body.serverId.trim() : '';
if (!serverId) {
return res.status(400).json({ error: 'serverId is required' });
}
try {
const cfg = await readMcpConfig(RUNTIME_DATA_DIR);
const server = cfg.servers.find((s) => s.id === serverId);
if (!server) {
return res.status(404).json({ error: `unknown serverId ${serverId}` });
}
if (server.transport !== 'http' && server.transport !== 'sse') {
return res
.status(400)
.json({ error: 'OAuth flow only applies to http/sse transports' });
}
if (!server.url) {
return res.status(400).json({ error: 'server has no URL configured' });
}
const redirectUri = mcpOAuthCallbackUrl(req);
console.log(
`[mcp-oauth] start serverId=${serverId} url=${server.url} redirect=${redirectUri}`,
);
const result = await beginAuth({
serverId,
serverUrl: server.url,
redirectUri,
dataDir: RUNTIME_DATA_DIR,
fetchImpl: fetch,
});
mcpPendingAuth.put(result.state, result.pending);
console.log(
`[mcp-oauth] start ok serverId=${serverId} authServer=${result.pending.authServerIssuer} clientId=${result.pending.clientId}`,
);
res.json({
authorizeUrl: result.authorizeUrl,
state: result.state,
redirectUri,
});
} catch (err) {
const msg = err && err.message ? err.message : String(err);
console.error(`[mcp-oauth] start failed serverId=${serverId}:`, msg);
res.status(502).json({ error: msg });
}
});
// Public endpoint — the OAuth provider's user-agent redirect lands here
// after the user approves. We deliberately do NOT enforce
// isLocalSameOrigin: in cloud the daemon IS the public origin, and even
// locally the request comes back from the OAuth provider's redirect
// (no Origin header at all on a top-level navigation).
app.get('/api/mcp/oauth/callback', async (req, res) => {
const code = typeof req.query.code === 'string' ? req.query.code : '';
const state = typeof req.query.state === 'string' ? req.query.state : '';
const error = typeof req.query.error === 'string' ? req.query.error : '';
if (error) {
return res.status(400).type('html').send(renderOAuthResultPage({
ok: false,
message: `Auth provider returned error: ${error}`,
}));
}
if (!code || !state) {
return res.status(400).type('html').send(renderOAuthResultPage({
ok: false,
message: 'Missing code or state — open Settings → External MCP servers and click Connect again.',
}));
}
const pending = mcpPendingAuth.consume(state);
if (!pending) {
return res.status(400).type('html').send(renderOAuthResultPage({
ok: false,
message: 'Auth state expired or already used. Click Connect again.',
}));
}
try {
const tokenResp = await exchangeCodeForToken({
tokenEndpoint: pending.tokenEndpoint,
clientId: pending.clientId,
clientSecret: pending.clientSecret,
redirectUri: pending.redirectUri,
code,
codeVerifier: pending.codeVerifier,
resource: pending.resourceUrl,
});
const stored = {
accessToken: tokenResp.access_token,
refreshToken: tokenResp.refresh_token,
tokenType: tokenResp.token_type ?? 'Bearer',
scope: tokenResp.scope ?? pending.scope,
expiresAt:
typeof tokenResp.expires_in === 'number'
? Date.now() + tokenResp.expires_in * 1000
: undefined,
savedAt: Date.now(),
// Persist the OAuth client context so refresh-token rotation can
// hit the same client_id / token endpoint the upstream issued the
// refresh_token to. Refresh tokens are client-bound (RFC 6749 §6).
tokenEndpoint: pending.tokenEndpoint,
clientId: pending.clientId,
clientSecret: pending.clientSecret,
authServerIssuer: pending.authServerIssuer,
redirectUri: pending.redirectUri,
resourceUrl: pending.resourceUrl,
};
await setToken(RUNTIME_DATA_DIR, pending.serverId, stored);
res.type('html').send(renderOAuthResultPage({
ok: true,
serverId: pending.serverId,
}));
} catch (err) {
console.error(
'[mcp-oauth] callback failed:',
err && err.message ? err.message : err,
);
res.status(502).type('html').send(renderOAuthResultPage({
ok: false,
message: String(err && err.message ? err.message : err),
}));
}
});
app.get('/api/mcp/oauth/status', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const serverId =
typeof req.query.serverId === 'string' ? req.query.serverId.trim() : '';
if (!serverId) return res.status(400).json({ error: 'serverId is required' });
try {
const tok = await getToken(RUNTIME_DATA_DIR, serverId);
if (!tok) return res.json({ connected: false });
res.json({
connected: true,
expiresAt: tok.expiresAt ?? null,
scope: tok.scope ?? null,
savedAt: tok.savedAt,
});
} catch (err) {
res.status(500).json({ error: String(err && err.message ? err.message : err) });
}
});
app.post('/api/mcp/oauth/disconnect', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const serverId =
typeof req.body?.serverId === 'string' ? req.body.serverId.trim() : '';
if (!serverId) return res.status(400).json({ error: 'serverId is required' });
try {
await clearToken(RUNTIME_DATA_DIR, serverId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: String(err && err.message ? err.message : err) });
}
});
app.get('/api/projects', (_req, res) => {
try {
const latestRunStatuses = listLatestProjectRunStatuses(db);
@ -4108,6 +4500,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
skillId,
designSystemId,
streamFormat,
connectedExternalMcp,
}) => {
const project =
typeof projectId === 'string' && projectId
@ -4227,6 +4620,9 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
critique: critiqueShouldRun ? critiqueCfg : undefined,
critiqueBrand: critiqueShouldRun ? critiqueBrand : undefined,
critiqueSkill: critiqueShouldRun ? critiqueSkill : undefined,
connectedExternalMcp: Array.isArray(connectedExternalMcp)
? connectedExternalMcp
: undefined,
});
// The chat handler also needs to know where the active skill lives
// on disk so it can stage a per-project copy of its side files
@ -4388,6 +4784,70 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
};
const runtimeToolPrompt = createAgentRuntimeToolPrompt(daemonUrl, toolTokenGrant);
const commentHint = renderCommentAttachmentHint(safeCommentAttachments);
// Resolve external MCP config + stored OAuth tokens up-front so the
// system prompt can warn the model away from Claude Code's synthetic
// `*_authenticate` / `*_complete_authentication` tools for any
// server the daemon already holds a valid Bearer for. We re-use both
// values further down at .mcp.json write time — see the spawn block
// below — instead of re-reading.
let externalMcpConfig = { servers: [] };
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
}
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
const oauthTokensForSpawn = {};
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
console.warn(
'[mcp-oauth] refresh failed for',
serverId,
err && err.message ? err.message : err,
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] skipping expired token for',
serverId,
'— reconnect required',
);
}
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
const connectedExternalMcp = enabledExternalMcp
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
.map((s) => ({ id: s.id, label: s.label }));
const { prompt: daemonSystemPrompt, activeSkillDir, critiqueShouldRun } =
await composeDaemonSystemPrompt({
agentId,
@ -4395,6 +4855,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
skillId,
designSystemId,
streamFormat: def?.streamFormat ?? 'plain',
connectedExternalMcp,
});
// Make skill side files reachable through three layers, in order of
@ -4519,6 +4980,79 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
argsPrefix: [OD_BIN],
});
// External MCP servers configured by the user in Settings → External MCP.
// Open Design relays them to the agent so the model can call those tools.
// Two delivery shapes today:
// - Claude Code: write a `.mcp.json` into the project cwd. Claude Code
// auto-loads that file at spawn (same format the CLI accepts via
// `claude mcp add` + Claude Desktop's config). Fire-and-forget; we
// deliberately do NOT block spawn on a write failure since the agent
// can still run without external tools — log a warning and continue.
// - ACP agents (Hermes/Kimi): merge stdio entries into the existing
// `mcpServers` array; SSE/HTTP entries are skipped because ACP's
// stdio-only descriptor can't represent them yet.
// Other agents (Codex, Gemini, OpenCode, Cursor, Qwen, Qoder, Copilot,
// Pi, DeepSeek) inherit the user's per-CLI MCP config from their own
// home dir for now — a future change can grow this list.
//
// The MCP config + OAuth tokens were resolved earlier (above
// composeDaemonSystemPrompt) so the system prompt could mention any
// already-authenticated servers; we reuse `enabledExternalMcp` and
// `oauthTokensForSpawn` here for the Claude `.mcp.json` write +
// ACP merge so we don't pay for a second filesystem read.
//
// Claude Code: write `.mcp.json` to the daemon-managed project cwd before
// spawn so Claude Code auto-loads the user's external MCP servers. Strict
// gating is essential here:
// - cwd must be set (no project → no `.mcp.json` write).
// - cwd must live UNDER PROJECTS_DIR. We never write to a git-linked
// baseDir (= the user's own repo), since that would silently overwrite
// a hand-crafted .mcp.json the user already keeps in their source tree.
// We also unlink a stale `.mcp.json` we previously wrote when the user has
// since disabled all servers, so removing a server actually takes effect
// on the next run.
if (def.id === 'claude' && isManagedProjectCwd(cwd, PROJECTS_DIR)) {
{
const target = path.join(cwd, '.mcp.json');
if (enabledExternalMcp.length > 0) {
try {
const claudeMcp = buildClaudeMcpJson(
enabledExternalMcp,
oauthTokensForSpawn,
);
if (claudeMcp) {
await fs.promises.mkdir(path.dirname(target), { recursive: true });
await fs.promises.writeFile(
target,
JSON.stringify(claudeMcp, null, 2),
'utf8',
);
}
} catch (err) {
console.warn(
'[mcp-config] failed to write project .mcp.json:',
err && err.message ? err.message : err,
);
}
} else {
try {
await fs.promises.unlink(target);
} catch (err) {
if ((err && err.code) !== 'ENOENT') {
console.warn(
'[mcp-config] failed to remove stale .mcp.json:',
err && err.message ? err.message : err,
);
}
}
}
}
}
if (enabledExternalMcp.length > 0 && def.streamFormat === 'acp-json-rpc') {
const acpExternal = buildAcpMcpServers(enabledExternalMcp);
mcpServers.push(...acpExternal);
}
// Pre-flight the composed prompt against any argv-byte budget the
// adapter declared (only DeepSeek TUI today — its CLI doesn't accept
// a `-` stdin sentinel, so the prompt has to ride argv). Doing this

View file

@ -0,0 +1,849 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
MCP_TEMPLATES,
buildAcpMcpServers,
buildClaudeMcpJson,
isManagedProjectCwd,
readMcpConfig,
sanitizeMcpServer,
writeMcpConfig,
} from '../src/mcp-config.js';
describe('mcp-config storage', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-mcpconfig-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('returns empty servers when no config file exists', async () => {
const cfg = await readMcpConfig(dataDir);
expect(cfg).toEqual({ servers: [] });
});
it('returns empty config for a corrupt JSON file', async () => {
await writeFile(path.join(dataDir, 'mcp-config.json'), '{not valid');
const cfg = await readMcpConfig(dataDir);
expect(cfg).toEqual({ servers: [] });
});
it('persists and re-reads a valid stdio server', async () => {
const written = await writeMcpConfig(dataDir, {
servers: [
{
id: 'github',
label: 'GitHub',
transport: 'stdio',
enabled: true,
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_xxx' },
},
],
});
expect(written.servers).toHaveLength(1);
expect(written.servers[0]?.id).toBe('github');
const reread = await readMcpConfig(dataDir);
expect(reread.servers[0]?.command).toBe('npx');
expect(reread.servers[0]?.env?.GITHUB_PERSONAL_ACCESS_TOKEN).toBe('ghp_xxx');
});
it('persists and re-reads a valid SSE server with headers', async () => {
const written = await writeMcpConfig(dataDir, {
servers: [
{
id: 'higgsfield',
transport: 'sse',
enabled: true,
url: 'https://mcp.higgsfield.ai',
headers: { Authorization: 'Bearer abc' },
},
],
});
expect(written.servers[0]?.url).toBe('https://mcp.higgsfield.ai/');
expect(written.servers[0]?.headers?.Authorization).toBe('Bearer abc');
});
it('drops invalid entries silently', async () => {
const written = await writeMcpConfig(dataDir, {
servers: [
{ id: 'bad' /* missing transport-required fields */ },
{ id: 'NOT VALID id', transport: 'stdio', command: 'x' },
{ id: 'good', transport: 'stdio', command: 'echo' },
// Duplicate id is dropped on second occurrence.
{ id: 'good', transport: 'stdio', command: 'other' },
],
});
expect(written.servers.map((s) => s.id)).toEqual(['good']);
});
it('rejects non-http(s) URLs', () => {
const out = sanitizeMcpServer({
id: 'sneaky',
transport: 'http',
url: 'file:///etc/passwd',
});
expect(out).toBeNull();
});
it('drops disabled flag default to enabled when explicit', () => {
const out = sanitizeMcpServer({
id: 'a',
transport: 'stdio',
command: 'echo',
enabled: false,
});
expect(out?.enabled).toBe(false);
});
it('writes JSON in a deterministic shape', async () => {
await writeMcpConfig(dataDir, {
servers: [
{ id: 'a', transport: 'stdio', enabled: true, command: 'echo' },
],
});
const raw = await readFile(path.join(dataDir, 'mcp-config.json'), 'utf8');
const parsed = JSON.parse(raw);
expect(parsed).toEqual({
servers: [
{ id: 'a', transport: 'stdio', enabled: true, command: 'echo' },
],
});
});
});
describe('buildClaudeMcpJson', () => {
it('returns null when no enabled servers', () => {
expect(buildClaudeMcpJson([])).toBeNull();
expect(
buildClaudeMcpJson([
{
id: 'x',
transport: 'stdio',
enabled: false,
command: 'echo',
},
]),
).toBeNull();
});
it('emits a stdio entry with command/args/env', () => {
const out = buildClaudeMcpJson([
{
id: 'github',
transport: 'stdio',
enabled: true,
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp' },
},
]);
expect(out).toEqual({
mcpServers: {
github: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp' },
},
},
});
});
it('emits an sse / http entry with url + headers + transport type', () => {
const out = buildClaudeMcpJson([
{
id: 'higgsfield',
transport: 'sse',
enabled: true,
url: 'https://mcp.higgsfield.ai',
headers: { Authorization: 'Bearer abc' },
},
]) as { mcpServers: Record<string, Record<string, unknown>> };
expect(out.mcpServers.higgsfield).toEqual({
type: 'sse',
url: 'https://mcp.higgsfield.ai',
headers: { Authorization: 'Bearer abc' },
});
});
it('skips disabled servers', () => {
const out = buildClaudeMcpJson([
{
id: 'a',
transport: 'stdio',
enabled: true,
command: 'echo',
},
{
id: 'b',
transport: 'stdio',
enabled: false,
command: 'rm',
},
]) as { mcpServers: Record<string, unknown> };
expect(Object.keys(out.mcpServers)).toEqual(['a']);
});
it('injects an Authorization Bearer header when a stored OAuth token is supplied', () => {
const out = buildClaudeMcpJson(
[
{
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
},
],
{ higgsfield: 'access-tok-xyz' },
) as { mcpServers: Record<string, Record<string, unknown>> };
expect(out.mcpServers.higgsfield?.headers).toEqual({
Authorization: 'Bearer access-tok-xyz',
});
});
it("does NOT overwrite a user-pinned Authorization header even when a token exists", () => {
const out = buildClaudeMcpJson(
[
{
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
headers: { authorization: 'Bearer manual-token' },
},
],
{ higgsfield: 'access-tok-xyz' },
) as { mcpServers: Record<string, Record<string, unknown>> };
expect(out.mcpServers.higgsfield?.headers).toEqual({
authorization: 'Bearer manual-token',
});
});
it('overwrites a blank/whitespace Authorization header with the OAuth Bearer (template-default-not-filled bug)', () => {
const out = buildClaudeMcpJson(
[
{
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
headers: { Authorization: ' ' },
},
],
{ higgsfield: 'access-tok-xyz' },
) as { mcpServers: Record<string, Record<string, unknown>> };
expect(out.mcpServers.higgsfield?.headers).toEqual({
Authorization: 'Bearer access-tok-xyz',
});
});
it('drops a blank Authorization header when no token is available either', () => {
const out = buildClaudeMcpJson([
{
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
headers: { Authorization: '' },
},
]) as { mcpServers: Record<string, Record<string, unknown>> };
// Empty Authorization is worse than missing — should be omitted.
expect(out.mcpServers.higgsfield?.headers).toBeUndefined();
});
});
describe('sanitizeMcpServer headers', () => {
it('strips empty / whitespace-only header values at persist time', () => {
const sanitized = sanitizeMcpServer({
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
headers: {
Authorization: '',
'X-Real-Header': 'kept',
' ': 'invalid-key',
Whitespace: ' ',
},
});
expect(sanitized?.headers).toEqual({ 'X-Real-Header': 'kept' });
});
it('omits the headers field entirely when every value is blank', () => {
const sanitized = sanitizeMcpServer({
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
headers: { Authorization: '' },
});
expect(sanitized?.headers).toBeUndefined();
});
it('only injects the Bearer for the matching server id', () => {
const out = buildClaudeMcpJson(
[
{
id: 'higgsfield',
transport: 'http',
enabled: true,
url: 'https://mcp.higgsfield.ai/mcp',
},
{
id: 'untouched',
transport: 'http',
enabled: true,
url: 'https://mcp.example.com/mcp',
},
],
{ higgsfield: 'tok' },
) as { mcpServers: Record<string, Record<string, unknown>> };
expect(out.mcpServers.higgsfield?.headers).toEqual({
Authorization: 'Bearer tok',
});
expect(out.mcpServers.untouched?.headers).toBeUndefined();
});
});
describe('buildAcpMcpServers', () => {
it('drops sse / http servers (ACP descriptor is stdio-only)', () => {
const out = buildAcpMcpServers([
{
id: 'a',
transport: 'stdio',
enabled: true,
command: 'echo',
},
{
id: 'b',
transport: 'sse',
enabled: true,
url: 'https://example.com',
},
]);
expect(out.map((s) => s.name)).toEqual(['a']);
});
it('flattens env to ACP {name,value} array shape', () => {
const out = buildAcpMcpServers([
{
id: 'gh',
transport: 'stdio',
enabled: true,
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { TOKEN: 'x' },
},
]);
expect(out[0]).toEqual({
type: 'stdio',
name: 'gh',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: [{ name: 'TOKEN', value: 'x' }],
});
});
});
describe('isManagedProjectCwd', () => {
const projectsDir = '/abs/.od/projects';
it('accepts a real per-project subdir', () => {
expect(isManagedProjectCwd('/abs/.od/projects/abc', projectsDir)).toBe(true);
expect(
isManagedProjectCwd('/abs/.od/projects/abc/sub', projectsDir),
).toBe(true);
});
it('rejects the projects-dir root itself (no per-project id)', () => {
expect(isManagedProjectCwd(projectsDir, projectsDir)).toBe(false);
});
it('rejects a git-linked baseDir outside of projects-dir', () => {
expect(isManagedProjectCwd('/home/me/code/repo', projectsDir)).toBe(false);
});
it('rejects PROJECT_ROOT-shaped fallback', () => {
expect(isManagedProjectCwd('/abs', projectsDir)).toBe(false);
});
it('rejects null / undefined cwd', () => {
expect(isManagedProjectCwd(null, projectsDir)).toBe(false);
expect(isManagedProjectCwd(undefined, projectsDir)).toBe(false);
expect(isManagedProjectCwd('', projectsDir)).toBe(false);
});
it('rejects path-prefix collisions (different sibling dir)', () => {
// `/abs/.od/projects-other` starts with `/abs/.od/projects` as a string,
// but is NOT a child of `/abs/.od/projects/`. Strict-separator check
// makes sure we don't accidentally write to an unrelated tree.
expect(
isManagedProjectCwd('/abs/.od/projects-other/x', projectsDir),
).toBe(false);
});
});
describe('MCP_TEMPLATES', () => {
it('includes the Higgsfield openclaw entry pointing at the streamable HTTP /mcp endpoint', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'higgsfield-openclaw');
expect(tpl).toBeDefined();
// The actual MCP endpoint (verified live) is the /mcp path with
// streamable HTTP transport. The bare host returns 404 on POST and the
// /sse path returns 404 — only /mcp speaks the protocol.
expect(tpl?.transport).toBe('http');
expect(tpl?.url).toBe('https://mcp.higgsfield.ai/mcp');
// Authorization header is optional — Claude Code attempts OAuth itself
// when no Bearer token is supplied.
expect(
tpl?.headerFields?.some((f) => f.key === 'Authorization' && !f.required),
).toBe(true);
});
it('includes the GitHub stdio template with required token field', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'github');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.command).toBe('npx');
expect(
tpl?.envFields?.some((f) => f.key === 'GITHUB_PERSONAL_ACCESS_TOKEN' && f.required),
).toBe(true);
});
it('includes the Pollinations stdio template with optional API key', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'pollinations');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', '@pollinations_ai/mcp']);
// The free tier works without a key — POLLINATIONS_API_KEY must be
// surfaced but NOT marked required (would block users from saving an
// anonymous-tier server).
const apiKey = tpl?.envFields?.find((f) => f.key === 'POLLINATIONS_API_KEY');
expect(apiKey).toBeDefined();
expect(apiKey?.required ?? false).toBe(false);
expect(apiKey?.secret).toBe(true);
});
it('includes the Allyson SVG-animation stdio template with required API_KEY', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'allyson');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', 'allyson-mcp']);
expect(
tpl?.envFields?.some((f) => f.key === 'API_KEY' && f.required && f.secret),
).toBe(true);
});
it('includes the Imagician local-image-editor template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'imagician');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', '@flowy11/imagician']);
// Local sharp-based editor: must not require any env / auth fields.
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the screenshot-website-fast template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'screenshot-website-fast');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', '@just-every/mcp-screenshot-website-fast']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the EdgeOne Pages template with optional API token', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'edgeone-pages');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', 'edgeone-pages-mcp@latest']);
// deploy_html flow works token-less; folder / project-update tools
// need EDGEONE_PAGES_API_TOKEN — surface it but keep optional.
const token = tpl?.envFields?.find((f) => f.key === 'EDGEONE_PAGES_API_TOKEN');
expect(token).toBeDefined();
expect(token?.required ?? false).toBe(false);
expect(token?.secret).toBe(true);
});
it('uses unique template ids and human labels', () => {
const ids = MCP_TEMPLATES.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
for (const t of MCP_TEMPLATES) {
expect(t.label.trim().length).toBeGreaterThan(0);
expect(t.description.trim().length).toBeGreaterThan(0);
}
});
it('every template has a category in the canonical enum', () => {
const VALID = new Set([
'image-generation',
'image-editing',
'web-capture',
'design-systems',
'ui-components',
'data-viz',
'publishing',
'utilities',
]);
for (const t of MCP_TEMPLATES) {
expect(VALID.has(t.category)).toBe(true);
}
});
it('groups image-generation templates in declaration order', () => {
const ids = MCP_TEMPLATES.filter((t) => t.category === 'image-generation').map((t) => t.id);
// Order matters — the picker renders templates in the declared order
// inside each category bucket, so the most useful default (Higgsfield
// OpenClaw, the marquee install) needs to stay first.
expect(ids).toEqual([
'higgsfield-openclaw',
'pollinations',
'allyson',
'bedrock-image',
'prompt-to-asset',
'nanobanana',
'seedream',
'fal-ai',
]);
});
it('groups design-systems templates in declaration order', () => {
const ids = MCP_TEMPLATES.filter((t) => t.category === 'design-systems').map((t) => t.id);
expect(ids).toEqual([
'figma-context',
'design-token-bridge',
'design-system-extractor',
'figma-use',
'aesthetics-wiki',
]);
});
it('groups publishing templates in declaration order', () => {
const ids = MCP_TEMPLATES.filter((t) => t.category === 'publishing').map((t) => t.id);
expect(ids).toEqual([
'edgeone-pages',
'pagedrop',
'pdfspark',
'ogforge',
'qrmint',
'slideshot',
'deckrun',
]);
});
it('includes the ImageSorcery CV-based stdio template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'imagesorcery');
expect(tpl).toBeDefined();
expect(tpl?.transport).toBe('stdio');
expect(tpl?.category).toBe('image-editing');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', '@sunriseapps/imagesorcery-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the ScreenshotOne hosted template with required api key', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'screenshotone');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('web-capture');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', '@screenshotone/mcp']);
const key = tpl?.envFields?.find((f) => f.key === 'SCREENSHOTONE_API_KEY');
expect(key?.required).toBe(true);
expect(key?.secret).toBe(true);
});
it('includes the 21st.dev Magic UI-component template (positional API_KEY arg)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === '21st-dev-magic');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('ui-components');
expect(tpl?.command).toBe('npx');
// Magic uses a positional `API_KEY=...` arg instead of an env var; the
// template ships a placeholder the user must edit before saving works.
expect(tpl?.args).toEqual([
'-y',
'@21st-dev/magic@latest',
'API_KEY=__YOUR_API_KEY__',
]);
});
it('includes the shadcn/ui template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'shadcn-ui');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('ui-components');
expect(tpl?.args).toEqual(['-y', '@jpisnice/shadcn-ui-mcp-server']);
});
it('includes the FlyonUI template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'flyonui');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('ui-components');
expect(tpl?.args).toEqual(['-y', 'flyonui-mcp']);
});
it('includes the AntV chart template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'antv-chart');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('data-viz');
expect(tpl?.args).toEqual(['-y', '@antv/mcp-server-chart']);
});
it('includes the Mermaid diagram template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'mermaid');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('data-viz');
expect(tpl?.args).toEqual(['-y', '@peng-shawn/mermaid-mcp-server']);
});
it('includes the Bedrock Image template via uvx (Python launcher)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'bedrock-image');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-generation');
// Bedrock requires the Python `uvx` launcher; the template records it
// explicitly so users know they need uv installed (vs. Node-only `npx`).
expect(tpl?.command).toBe('uvx');
expect(tpl?.args).toEqual(['bedrock-image-mcp-server@latest']);
expect(tpl?.envFields?.some((f) => f.key === 'AWS_REGION')).toBe(true);
});
it('includes the prompt-to-asset template (no required key, free-tier paths)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'prompt-to-asset');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-generation');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', 'prompt-to-asset']);
// The package routes free-tier providers first (Cloudflare / NVIDIA NIM /
// HF / Stable Horde / Pollinations / inline SVG) so the template MUST
// not surface required env fields.
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Nano Banana hosted streamable-HTTP template with required Authorization', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'nanobanana');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-generation');
expect(tpl?.transport).toBe('http');
expect(tpl?.url).toBe('https://nanobanana.mcp.acedata.cloud/mcp');
const auth = tpl?.headerFields?.find((f) => f.key === 'Authorization');
expect(auth?.required).toBe(true);
expect(auth?.secret).toBe(true);
});
it('includes the Seedream hosted streamable-HTTP template with required Authorization', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'seedream');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-generation');
expect(tpl?.transport).toBe('http');
expect(tpl?.url).toBe('https://seedream.mcp.acedata.cloud/mcp');
const auth = tpl?.headerFields?.find((f) => f.key === 'Authorization');
expect(auth?.required).toBe(true);
expect(auth?.secret).toBe(true);
});
it('includes the fal.ai template via uvx with required FAL_KEY', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'fal-ai');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-generation');
expect(tpl?.command).toBe('uvx');
// `--from` is required because the package name and bin name differ
// (fal-mcp-server vs fal-mcp).
expect(tpl?.args).toEqual(['--from', 'fal-mcp-server', 'fal-mcp']);
const key = tpl?.envFields?.find((f) => f.key === 'FAL_KEY');
expect(key?.required).toBe(true);
expect(key?.secret).toBe(true);
});
it('includes the Photopea layered-editor template (no auth, opens browser on first call)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'photopea');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-editing');
expect(tpl?.command).toBe('npx');
expect(tpl?.args).toEqual(['-y', 'photopea-mcp-server']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Topaz Labs template with required API key', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'topaz-labs');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-editing');
expect(tpl?.args).toEqual(['-y', '@topazlabs/mcp']);
const key = tpl?.envFields?.find((f) => f.key === 'TOPAZ_API_KEY');
expect(key?.required).toBe(true);
expect(key?.secret).toBe(true);
});
it('includes the Transloadit template with both KEY and SECRET required', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'transloadit');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('image-editing');
// The MCP server bin needs the `stdio` subcommand to select transport
// (default would expose HTTP locally and require an auth token).
expect(tpl?.args).toEqual(['-y', '@transloadit/mcp-server', 'stdio']);
const key = tpl?.envFields?.find((f) => f.key === 'TRANSLOADIT_KEY');
const secret = tpl?.envFields?.find((f) => f.key === 'TRANSLOADIT_SECRET');
expect(key?.required).toBe(true);
expect(secret?.required).toBe(true);
expect(key?.secret).toBe(true);
expect(secret?.secret).toBe(true);
});
it('includes the pagecast browser-recording template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'pagecast');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('web-capture');
expect(tpl?.args).toEqual(['-y', '@mcpware/pagecast']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Figma-Context design template with required FIGMA_API_KEY', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'figma-context');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('design-systems');
// `--stdio` is required — without it the package starts an HTTP listener
// on a random port and the spawn never produces stdio messages.
expect(tpl?.args).toEqual(['-y', 'figma-developer-mcp', '--stdio']);
const key = tpl?.envFields?.find((f) => f.key === 'FIGMA_API_KEY');
expect(key?.required).toBe(true);
expect(key?.secret).toBe(true);
});
it('includes the Design Token Bridge template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'design-token-bridge');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('design-systems');
expect(tpl?.args).toEqual(['-y', 'design-token-bridge-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Design System Extractor template with optional STORYBOOK_URL', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'design-system-extractor');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('design-systems');
expect(tpl?.args).toEqual(['-y', 'mcp-design-system-extractor@latest']);
// STORYBOOK_URL has a sensible default in the upstream package
// (http://localhost:6006), so the template surfaces it but does NOT
// require it — users with a localhost Storybook can save the entry as-is.
const url = tpl?.envFields?.find((f) => f.key === 'STORYBOOK_URL');
expect(url).toBeDefined();
expect(url?.required ?? false).toBe(false);
});
it('includes the figma-use HTTP template (writes to Figma, localhost endpoint)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'figma-use');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('design-systems');
// figma-use only ships an HTTP server (no stdio mode in serve.ts), so the
// template wires the daemon to its default localhost endpoint and lets
// the user run `npx figma-use mcp serve` themselves alongside Figma's
// remote-debugging port.
expect(tpl?.transport).toBe('http');
expect(tpl?.url).toBe('http://localhost:38451/mcp');
expect(tpl?.headerFields ?? []).toEqual([]);
});
it('includes the Aesthetics Wiki uvx template (no auth, moodboard MCP)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'aesthetics-wiki');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('design-systems');
expect(tpl?.command).toBe('uvx');
expect(tpl?.args).toEqual(['aesthetics-wiki-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the MCP Dashboards template with --stdio arg (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'mcp-dashboards');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('data-viz');
// `--stdio` flag selects stdio transport — without it the bin starts an
// HTTP server on :3001 and the spawn never produces stdio messages.
expect(tpl?.args).toEqual(['-y', 'mcp-dashboards', '--stdio']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Excalidraw Architect uvx template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'excalidraw-architect');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('data-viz');
expect(tpl?.command).toBe('uvx');
expect(tpl?.args).toEqual(['excalidraw-architect-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the PageDrop instant-hosting template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'pagedrop');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('publishing');
expect(tpl?.args).toEqual(['-y', 'pagedrop-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the PDFSpark HTML→PDF template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'pdfspark');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('publishing');
expect(tpl?.args).toEqual(['-y', 'pdfspark-api']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the OGForge Open-Graph image template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'ogforge');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('publishing');
expect(tpl?.args).toEqual(['-y', 'ogforge-api']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the QRMint styled-QR template (no auth, package = qr-mcp)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'qrmint');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('publishing');
// The npm package is `qr-mcp`, not `qrmint` (the brand name is QRMint).
expect(tpl?.args).toEqual(['-y', 'qr-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Slideshot HTML→PDF/PPTX template (no auth, package = slideshot-mcp)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'slideshot');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('publishing');
// The npm package for the MCP entry is `slideshot-mcp`; the bare
// `slideshot` package is the standalone CLI / REST server.
expect(tpl?.args).toEqual(['-y', 'slideshot-mcp']);
expect(tpl?.envFields ?? []).toEqual([]);
});
it('includes the Deckrun hosted-HTTP template (free tier, no required header)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'deckrun');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('publishing');
expect(tpl?.transport).toBe('http');
expect(tpl?.url).toBe('https://deckrun-mcp-free.agenticdecks.com/mcp/');
// Free-tier endpoint works token-less; Authorization header is exposed
// for the paid-tier upgrade path but NOT marked required.
const auth = tpl?.headerFields?.find((f) => f.key === 'Authorization');
expect(auth).toBeDefined();
expect(auth?.required ?? false).toBe(false);
expect(auth?.secret).toBe(true);
});
it('includes the A11y axe-core template (no auth)', () => {
const tpl = MCP_TEMPLATES.find((t) => t.id === 'a11y');
expect(tpl).toBeDefined();
expect(tpl?.category).toBe('utilities');
// The npm package is `a11y-mcp-server`, NOT `a11ymcp` (which is the
// GitHub repo slug). Getting this wrong silently 404s on the registry.
expect(tpl?.args).toEqual(['-y', 'a11y-mcp-server']);
expect(tpl?.envFields ?? []).toEqual([]);
});
});

View file

@ -0,0 +1,529 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
PendingAuthCache,
beginAuth,
buildAuthorizeUrl,
deriveCodeChallenge,
discoverAuthServer,
discoverProtectedResource,
exchangeCodeForToken,
generateCodeVerifier,
generateState,
getOrRegisterClient,
refreshAccessToken,
} from '../src/mcp-oauth.js';
// Tiny fetch mock — looks up the URL in a Map and returns canned JSON.
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
function makeFetch(routes: Record<string, { status?: number; body: unknown }>) {
return async (input: FetchInput, init?: FetchInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input.toString();
const route = routes[url];
if (!route) {
return new Response(`unknown url ${url}`, { status: 404 });
}
void init;
return new Response(JSON.stringify(route.body), {
status: route.status ?? 200,
headers: { 'content-type': 'application/json' },
});
};
}
describe('PKCE helpers', () => {
it('generates a 43+ char code_verifier per RFC 7636', () => {
const verifier = generateCodeVerifier();
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('generates a different verifier each call (randomness sanity check)', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toBe(b);
});
it('derives a S256 challenge that matches the spec example', () => {
// RFC 7636 Appendix B test vector.
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
expect(deriveCodeChallenge(verifier)).toBe(expected);
});
it('derived challenge is exactly base64url(sha256(verifier))', () => {
const verifier = generateCodeVerifier();
const expected = createHash('sha256')
.update(verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
expect(deriveCodeChallenge(verifier)).toBe(expected);
});
it('generates a base64url-safe state token', () => {
const state = generateState();
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
expect(state.length).toBeGreaterThan(20);
});
});
describe('discoverProtectedResource', () => {
it('fetches the path-suffixed metadata when present', async () => {
const fetchImpl = makeFetch({
'https://mcp.example.com/.well-known/oauth-protected-resource/mcp': {
body: {
resource: 'https://mcp.example.com/mcp',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['read'],
},
},
});
const out = await discoverProtectedResource(
'https://mcp.example.com/mcp',
fetchImpl as typeof fetch,
);
expect(out?.resource).toBe('https://mcp.example.com/mcp');
expect(out?.authorization_servers).toEqual(['https://auth.example.com']);
});
it('falls back to the bare well-known when the path-suffixed form 404s', async () => {
const fetchImpl = makeFetch({
'https://mcp.example.com/.well-known/oauth-protected-resource/mcp': {
status: 404,
body: {},
},
'https://mcp.example.com/.well-known/oauth-protected-resource': {
body: {
resource: 'https://mcp.example.com',
authorization_servers: ['https://mcp.example.com'],
},
},
});
const out = await discoverProtectedResource(
'https://mcp.example.com/mcp',
fetchImpl as typeof fetch,
);
expect(out?.authorization_servers).toEqual(['https://mcp.example.com']);
});
it('returns null when neither candidate responds', async () => {
const fetchImpl = makeFetch({});
const out = await discoverProtectedResource(
'https://mcp.example.com/mcp',
fetchImpl as typeof fetch,
);
expect(out).toBeNull();
});
});
describe('discoverAuthServer', () => {
it('parses an oauth-authorization-server document', async () => {
const fetchImpl = makeFetch({
'https://auth.example.com/.well-known/oauth-authorization-server': {
body: {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
},
},
});
const out = await discoverAuthServer(
'https://auth.example.com',
fetchImpl as typeof fetch,
);
expect(out?.token_endpoint).toBe('https://auth.example.com/token');
expect(out?.registration_endpoint).toBe('https://auth.example.com/register');
});
it('falls back to openid-configuration when oauth-authorization-server is absent', async () => {
const fetchImpl = makeFetch({
'https://auth.example.com/.well-known/openid-configuration': {
body: {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oidc/auth',
token_endpoint: 'https://auth.example.com/oidc/token',
},
},
});
const out = await discoverAuthServer(
'https://auth.example.com',
fetchImpl as typeof fetch,
);
expect(out?.authorization_endpoint).toBe('https://auth.example.com/oidc/auth');
});
});
describe('buildAuthorizeUrl', () => {
it('emits all the required PKCE-flow parameters', () => {
const url = buildAuthorizeUrl({
authServer: {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
},
clientId: 'client-xyz',
redirectUri: 'https://app.example.com/api/mcp/oauth/callback',
state: 'state-abc',
codeChallenge: 'challenge-pqr',
scope: 'openid email',
resource: 'https://mcp.example.com/mcp',
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://auth.example.com/authorize');
expect(parsed.searchParams.get('response_type')).toBe('code');
expect(parsed.searchParams.get('client_id')).toBe('client-xyz');
expect(parsed.searchParams.get('redirect_uri')).toBe(
'https://app.example.com/api/mcp/oauth/callback',
);
expect(parsed.searchParams.get('state')).toBe('state-abc');
expect(parsed.searchParams.get('code_challenge')).toBe('challenge-pqr');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('scope')).toBe('openid email');
expect(parsed.searchParams.get('resource')).toBe('https://mcp.example.com/mcp');
});
});
describe('client registration cache', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-mcp-oauth-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('registers a fresh client and persists it for reuse', async () => {
let registerHits = 0;
const fetchImpl = (async (input: FetchInput, init?: FetchInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === 'https://auth.example.com/register') {
registerHits++;
const body = init?.body ? JSON.parse(String(init.body)) : null;
expect(body?.redirect_uris).toEqual([
'https://app.example.com/api/mcp/oauth/callback',
]);
return new Response(
JSON.stringify({ client_id: 'fresh-client-id' }),
{ status: 201, headers: { 'content-type': 'application/json' } },
);
}
return new Response('?', { status: 404 });
}) as typeof fetch;
const meta = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
};
const a = await getOrRegisterClient(
dataDir,
meta,
'https://app.example.com/api/mcp/oauth/callback',
fetchImpl,
);
expect(a.clientId).toBe('fresh-client-id');
expect(registerHits).toBe(1);
const b = await getOrRegisterClient(
dataDir,
meta,
'https://app.example.com/api/mcp/oauth/callback',
fetchImpl,
);
expect(b.clientId).toBe('fresh-client-id');
expect(registerHits).toBe(1); // cached, no second register
const cacheFile = JSON.parse(
await readFile(path.join(dataDir, 'mcp-oauth-clients.json'), 'utf8'),
);
expect(cacheFile.clients).toHaveLength(1);
expect(cacheFile.clients[0].clientId).toBe('fresh-client-id');
});
it('does not register when the cache file already pins a matching client', async () => {
await writeFile(
path.join(dataDir, 'mcp-oauth-clients.json'),
JSON.stringify({
clients: [
{
authServerIssuer: 'https://auth.example.com',
redirectUri: 'https://app.example.com/api/mcp/oauth/callback',
clientId: 'pinned-client',
registeredAt: 0,
},
],
}),
);
const fetchImpl = (async () => {
throw new Error('fetch should not be called when cache hits');
}) as unknown as typeof fetch;
const out = await getOrRegisterClient(
dataDir,
{
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
},
'https://app.example.com/api/mcp/oauth/callback',
fetchImpl,
);
expect(out.clientId).toBe('pinned-client');
});
});
describe('exchangeCodeForToken / refreshAccessToken', () => {
it('POSTs the form-encoded grant_type=authorization_code with PKCE verifier', async () => {
let captured: { headers: Record<string, string>; body: string } | null = null;
const fetchImpl = (async (input: FetchInput, init?: FetchInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === 'https://auth.example.com/token') {
captured = {
headers: (init?.headers ?? {}) as Record<string, string>,
body: String(init?.body ?? ''),
};
return new Response(
JSON.stringify({
access_token: 'tok-xyz',
token_type: 'Bearer',
refresh_token: 'ref-xyz',
expires_in: 3600,
scope: 'a b',
}),
{ headers: { 'content-type': 'application/json' } },
);
}
return new Response('?', { status: 404 });
}) as typeof fetch;
const out = await exchangeCodeForToken(
{
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'cid',
redirectUri: 'https://app.example.com/cb',
code: 'AUTHCODE',
codeVerifier: 'verifier-1',
resource: 'https://mcp.example.com/mcp',
},
fetchImpl,
);
expect(out.access_token).toBe('tok-xyz');
expect(captured).not.toBeNull();
const params = new URLSearchParams(captured!.body);
expect(params.get('grant_type')).toBe('authorization_code');
expect(params.get('code')).toBe('AUTHCODE');
expect(params.get('client_id')).toBe('cid');
expect(params.get('code_verifier')).toBe('verifier-1');
expect(params.get('redirect_uri')).toBe('https://app.example.com/cb');
expect(params.get('resource')).toBe('https://mcp.example.com/mcp');
});
it('refresh exchange uses grant_type=refresh_token', async () => {
let captured = '';
const fetchImpl = (async (input: FetchInput, init?: FetchInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === 'https://auth.example.com/token') {
captured = String(init?.body ?? '');
return new Response(
JSON.stringify({ access_token: 'rotated', token_type: 'Bearer' }),
{ headers: { 'content-type': 'application/json' } },
);
}
return new Response('?', { status: 404 });
}) as typeof fetch;
const out = await refreshAccessToken(
{
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'cid',
refreshToken: 'old-refresh',
},
fetchImpl,
);
expect(out.access_token).toBe('rotated');
const params = new URLSearchParams(captured);
expect(params.get('grant_type')).toBe('refresh_token');
expect(params.get('refresh_token')).toBe('old-refresh');
});
it('throws when the token endpoint returns a non-2xx', async () => {
const fetchImpl = (async () =>
new Response('access_denied', { status: 400 })) as unknown as typeof fetch;
await expect(
exchangeCodeForToken(
{
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'cid',
redirectUri: 'https://app.example.com/cb',
code: 'AUTHCODE',
codeVerifier: 'verifier-1',
},
fetchImpl,
),
).rejects.toThrow(/HTTP 400/);
});
});
describe('PendingAuthCache', () => {
it('round-trips a value through put/consume', () => {
const cache = new PendingAuthCache(60_000);
cache.put('state-1', {
serverId: 'higgsfield',
authServerIssuer: 'https://auth.example.com',
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'cid',
redirectUri: 'https://app.example.com/cb',
codeVerifier: 'verifier',
createdAt: Date.now(),
});
expect(cache.size()).toBe(1);
const got = cache.consume('state-1');
expect(got?.serverId).toBe('higgsfield');
expect(cache.consume('state-1')).toBeNull();
cache.stop();
});
it('drops entries past TTL', () => {
const cache = new PendingAuthCache(10);
cache.put('s', {
serverId: 'x',
authServerIssuer: 'i',
tokenEndpoint: 't',
clientId: 'c',
redirectUri: 'r',
codeVerifier: 'v',
createdAt: Date.now() - 1000,
});
expect(cache.consume('s')).toBeNull();
cache.stop();
});
});
describe('beginAuth (end-to-end with mocked discovery + DCR)', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-mcp-begin-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('discovers, registers, generates PKCE, returns a usable authorize URL', async () => {
let registerHits = 0;
const fetchImpl = (async (input: FetchInput, init?: FetchInit) => {
const url = typeof input === 'string' ? input : input.toString();
const routes: Record<string, { status?: number; body: unknown }> = {
'https://mcp.example.com/.well-known/oauth-protected-resource/mcp': {
body: {
resource: 'https://mcp.example.com/mcp',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['mcp:tools'],
},
},
'https://auth.example.com/.well-known/oauth-authorization-server': {
body: {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
},
},
};
if (url === 'https://auth.example.com/register') {
registerHits++;
return new Response(
JSON.stringify({ client_id: 'cid-1' }),
{ status: 201, headers: { 'content-type': 'application/json' } },
);
}
const route = routes[url];
if (!route) return new Response('404', { status: 404 });
void init;
return new Response(JSON.stringify(route.body), { status: route.status ?? 200 });
}) as typeof fetch;
const out = await beginAuth({
serverId: 'higgsfield',
serverUrl: 'https://mcp.example.com/mcp',
redirectUri: 'https://app.example.com/api/mcp/oauth/callback',
dataDir,
fetchImpl,
});
expect(registerHits).toBe(1);
const u = new URL(out.authorizeUrl);
expect(u.origin + u.pathname).toBe('https://auth.example.com/authorize');
expect(u.searchParams.get('client_id')).toBe('cid-1');
expect(u.searchParams.get('state')).toBe(out.state);
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
expect(u.searchParams.get('scope')).toBe('mcp:tools');
expect(u.searchParams.get('resource')).toBe('https://mcp.example.com/mcp');
expect(out.pending.tokenEndpoint).toBe('https://auth.example.com/token');
expect(out.pending.codeVerifier.length).toBeGreaterThanOrEqual(43);
expect(out.pending.serverId).toBe('higgsfield');
});
it('falls back to the resource origin when no protected-resource metadata is published', async () => {
const fetchImpl = (async (input: FetchInput, init?: FetchInit) => {
const url = typeof input === 'string' ? input : input.toString();
// Both discovery probes for the resource come back 404 — caller falls
// back to assuming the origin IS the auth server.
if (url.endsWith('/oauth-protected-resource/mcp')) {
return new Response('404', { status: 404 });
}
if (url.endsWith('/oauth-protected-resource')) {
return new Response('404', { status: 404 });
}
if (url === 'https://mcp.example.com/.well-known/oauth-authorization-server') {
return new Response(
JSON.stringify({
issuer: 'https://mcp.example.com',
authorization_endpoint: 'https://mcp.example.com/authorize',
token_endpoint: 'https://mcp.example.com/token',
registration_endpoint: 'https://mcp.example.com/register',
}),
);
}
if (url === 'https://mcp.example.com/register') {
return new Response(JSON.stringify({ client_id: 'cid-fallback' }), { status: 201 });
}
void init;
return new Response('?', { status: 404 });
}) as typeof fetch;
const out = await beginAuth({
serverId: 'h',
serverUrl: 'https://mcp.example.com/mcp',
redirectUri: 'https://app.example.com/cb',
dataDir,
fetchImpl,
});
expect(new URL(out.authorizeUrl).origin).toBe('https://mcp.example.com');
});
it('throws when the auth server cannot be discovered', async () => {
const fetchImpl = (async () => new Response('404', { status: 404 })) as unknown as typeof fetch;
await expect(
beginAuth({
serverId: 'h',
serverUrl: 'https://mcp.example.com/mcp',
redirectUri: 'https://app.example.com/cb',
dataDir,
fetchImpl,
}),
).rejects.toThrow(/could not discover/i);
});
});

View file

@ -0,0 +1,233 @@
// End-to-end test for the spawn-time `.mcp.json` write path.
//
// Configures an external MCP server via the same `/api/mcp/servers` endpoint
// the web UI uses, drives a chat run with a fake `claude` binary on PATH so
// we don't need a real install, and asserts that the daemon writes the
// project-cwd `.mcp.json` Claude Code auto-loads. Then disables the server
// and verifies the stale file is removed on the next run.
//
// Mirrors the `withFakeAgent` pattern used by chat-route.test.ts so the
// shape of the spawn (PATH override, fake exec, real /api/chat round-trip)
// matches what the daemon does in production.
import type http from 'node:http';
import { randomUUID } from 'node:crypto';
import { existsSync, promises as fsp, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
async function withFakeClaude<T>(run: () => Promise<T>): Promise<T> {
const dir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-spawn-bin-'));
const oldPath = process.env.PATH;
// Fake `claude` that prints stream-json the daemon understands and exits 0.
// The single result frame is enough to drive the run to `succeeded`.
const script = `
const out = {
type: 'result',
subtype: 'success',
is_error: false,
duration_ms: 1,
total_cost_usd: 0,
usage: { input_tokens: 1, output_tokens: 1 },
result: 'ok',
};
console.log(JSON.stringify(out));
process.exit(0);
`;
try {
if (process.platform === 'win32') {
const runner = join(dir, 'claude-test-runner.cjs');
await fsp.writeFile(runner, script);
await fsp.writeFile(
join(dir, 'claude.cmd'),
`@echo off\r\nnode "${runner}" %*\r\n`,
);
} else {
const bin = join(dir, 'claude');
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
await fsp.chmod(bin, 0o755);
}
process.env.PATH = `${dir}${delimiter}${oldPath ?? ''}`;
return await run();
} finally {
process.env.PATH = oldPath;
await fsp.rm(dir, { recursive: true, force: true });
}
}
async function waitForRunStatus(
baseUrl: string,
runId: string,
): Promise<{ status: string }> {
for (let attempt = 0; attempt < 60; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
const body = (await r.json()) as { status: string };
if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('run did not finish');
}
describe('spawn writes external MCP config for Claude Code', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
});
afterAll(async () => {
for (const id of projectsToClean.splice(0)) {
await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {});
}
await new Promise<void>((resolve) => server.close(() => resolve()));
});
afterEach(async () => {
// Always reset the global MCP servers list so test ordering doesn't matter.
await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }),
}).catch(() => {});
});
async function createProject(): Promise<{ id: string; dir: string }> {
const id = `mcp-spawn-${randomUUID()}`;
const r = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, name: id }),
});
expect(r.ok).toBe(true);
projectsToClean.push(id);
// The daemon owns its data dir; we discover the on-disk project path by
// having the daemon return the upload root, then composing path manually.
// Use the same path the daemon's `ensureProject` uses.
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return { id, dir: join(projectsBase, id) };
}
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
await withFakeClaude(async () => {
// Configure one enabled SSE server. URL gets normalized (trailing slash).
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
servers: [
{
id: 'higgsfield',
transport: 'sse',
enabled: true,
url: 'https://mcp.higgsfield.ai',
},
],
}),
});
expect(putRes.ok).toBe(true);
const { id, dir } = await createProject();
// Drive a chat run. The fake `claude` exits 0 immediately; what we care
// about is the SIDE EFFECT — `.mcp.json` written to the project cwd
// before the spawn.
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello mcp',
}),
});
expect(chatRes.status).toBe(202);
const { runId } = (await chatRes.json()) as { runId: string };
await waitForRunStatus(baseUrl, runId);
const target = join(dir, '.mcp.json');
expect(existsSync(target)).toBe(true);
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
expect(written.mcpServers).toBeDefined();
expect(written.mcpServers.higgsfield).toMatchObject({
type: 'sse',
url: 'https://mcp.higgsfield.ai/',
});
// Clear the MCP config and run again. The stale .mcp.json must be
// removed so a freshly-spawned agent doesn't see the old config.
await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }),
});
const chat2 = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'second turn',
}),
});
expect(chat2.status).toBe(202);
const { runId: runId2 } = (await chat2.json()) as { runId: string };
await waitForRunStatus(baseUrl, runId2);
expect(existsSync(target)).toBe(false);
});
}, 30_000);
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
// session/new params instead of `.mcp.json`. The `.mcp.json` write path
// is gated to `def.id === 'claude'`, so this test covers the negative
// direction: configure servers, run a non-claude agent, no file written.
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
servers: [
{
id: 'fs',
transport: 'stdio',
enabled: true,
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
},
],
}),
});
expect(putRes.ok).toBe(true);
const { id, dir } = await createProject();
// Trigger any non-claude agent — it'll fail to spawn (no fake binary) but
// the .mcp.json write gate runs BEFORE bin resolution, so the absence of
// the file is the assertion that the gate held.
await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'hermes',
projectId: id,
message: 'hi',
}),
});
// Give the run a moment to reach the spawn pre-flight.
await new Promise((resolve) => setTimeout(resolve, 200));
const target = join(dir, '.mcp.json');
expect(existsSync(target)).toBe(false);
}, 15_000);
});

View file

@ -0,0 +1,212 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
clearToken,
getToken,
isTokenExpired,
readAllTokens,
readTokensFile,
sanitizeTokensFile,
setToken,
type StoredMcpToken,
} from '../src/mcp-tokens.js';
describe('mcp-tokens storage', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-mcptokens-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
it('returns no servers when the file is missing', async () => {
expect(await readTokensFile(dataDir)).toEqual({ servers: {} });
expect(await getToken(dataDir, 'higgsfield')).toBeNull();
});
it('persists, re-reads, and overwrites a token', async () => {
const tok: StoredMcpToken = {
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
scope: 'openid email',
expiresAt: Date.now() + 60_000,
savedAt: Date.now(),
};
await setToken(dataDir, 'higgsfield', tok);
const got = await getToken(dataDir, 'higgsfield');
expect(got?.accessToken).toBe('access-1');
expect(got?.refreshToken).toBe('refresh-1');
expect(got?.scope).toBe('openid email');
// Overwrite with a rotated token.
await setToken(dataDir, 'higgsfield', {
...tok,
accessToken: 'access-2',
refreshToken: 'refresh-2',
});
const got2 = await getToken(dataDir, 'higgsfield');
expect(got2?.accessToken).toBe('access-2');
expect(got2?.refreshToken).toBe('refresh-2');
});
it('isolates tokens per server', async () => {
await setToken(dataDir, 'github', {
accessToken: 'gh-token',
tokenType: 'Bearer',
savedAt: Date.now(),
});
await setToken(dataDir, 'higgsfield', {
accessToken: 'hg-token',
tokenType: 'Bearer',
savedAt: Date.now(),
});
const all = await readAllTokens(dataDir);
expect(Object.keys(all).sort()).toEqual(['github', 'higgsfield']);
expect(all.github?.accessToken).toBe('gh-token');
expect(all.higgsfield?.accessToken).toBe('hg-token');
});
it('clearToken removes only the requested entry', async () => {
await setToken(dataDir, 'github', {
accessToken: 'gh-token',
tokenType: 'Bearer',
savedAt: Date.now(),
});
await setToken(dataDir, 'higgsfield', {
accessToken: 'hg-token',
tokenType: 'Bearer',
savedAt: Date.now(),
});
await clearToken(dataDir, 'github');
expect(await getToken(dataDir, 'github')).toBeNull();
expect(await getToken(dataDir, 'higgsfield')).not.toBeNull();
});
it('clearToken on an absent server is a no-op', async () => {
await expect(clearToken(dataDir, 'never')).resolves.toBeUndefined();
});
it('writes the tokens file with mode 0600 on POSIX', async () => {
if (process.platform === 'win32') return; // mode bits are advisory on win32
await setToken(dataDir, 'higgsfield', {
accessToken: 'tok',
tokenType: 'Bearer',
savedAt: Date.now(),
});
const s = await stat(path.join(dataDir, 'mcp-tokens.json'));
// Mask off file-type bits — only the permission bits are interesting.
expect(s.mode & 0o777).toBe(0o600);
});
it('survives a corrupt tokens file by returning empty', async () => {
await writeFile(path.join(dataDir, 'mcp-tokens.json'), '{not valid');
const all = await readAllTokens(dataDir);
expect(all).toEqual({});
});
it('sanitizes incoming JSON, dropping malformed entries', () => {
const out = sanitizeTokensFile({
servers: {
good: {
accessToken: ' abc ',
tokenType: 'Bearer',
savedAt: 123,
scope: 'a b c',
},
bad_no_token: { tokenType: 'Bearer', savedAt: 123 },
bad_shape: 'not an object',
__proto__: { accessToken: 'evil', tokenType: 'Bearer', savedAt: 1 },
},
});
expect(out.servers.good?.accessToken).toBe('abc');
expect(out.servers.bad_no_token).toBeUndefined();
expect(out.servers.bad_shape).toBeUndefined();
// __proto__ is reserved — sanitizer must NOT have set it as a real
// own property (which would be a prototype-pollution vector). The
// implicit access via `.__proto__` returns Object.prototype, so we
// check Object.hasOwn instead of toBeUndefined.
expect(Object.hasOwn(out.servers, '__proto__')).toBe(false);
expect(Object.keys(out.servers)).toEqual(['good']);
});
it('round-trips JSON disk format', async () => {
await setToken(dataDir, 'higgsfield', {
accessToken: 'tok',
tokenType: 'Bearer',
savedAt: 1234,
});
const raw = await readFile(path.join(dataDir, 'mcp-tokens.json'), 'utf8');
const parsed = JSON.parse(raw);
expect(parsed.servers.higgsfield.accessToken).toBe('tok');
});
it('persists OAuth client context fields with the token', async () => {
const tok: StoredMcpToken = {
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
savedAt: Date.now(),
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'client-xyz',
clientSecret: 'secret-xyz',
authServerIssuer: 'https://auth.example.com',
redirectUri: 'https://app.example.com/api/mcp/oauth/callback',
resourceUrl: 'https://mcp.example.com/mcp',
};
await setToken(dataDir, 'higgsfield', tok);
const got = await getToken(dataDir, 'higgsfield');
expect(got).toMatchObject({
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'client-xyz',
clientSecret: 'secret-xyz',
authServerIssuer: 'https://auth.example.com',
redirectUri: 'https://app.example.com/api/mcp/oauth/callback',
resourceUrl: 'https://mcp.example.com/mcp',
});
});
});
describe('isTokenExpired', () => {
const now = 1_700_000_000_000;
it('returns false for a token with no expiresAt', () => {
expect(
isTokenExpired({ accessToken: 'a', tokenType: 'Bearer', savedAt: now }, now),
).toBe(false);
});
it('returns false when expiresAt is comfortably in the future', () => {
expect(
isTokenExpired(
{ accessToken: 'a', tokenType: 'Bearer', savedAt: now, expiresAt: now + 60_000 },
now,
),
).toBe(false);
});
it('returns true when expiresAt is in the past', () => {
expect(
isTokenExpired(
{ accessToken: 'a', tokenType: 'Bearer', savedAt: now, expiresAt: now - 5 },
now,
),
).toBe(true);
});
it('honors the skew window (default 30s) so we do not ship a token about to expire', () => {
expect(
isTokenExpired(
{ accessToken: 'a', tokenType: 'Bearer', savedAt: now, expiresAt: now + 5_000 },
now,
),
).toBe(true);
});
});

View file

@ -54,4 +54,54 @@ describe('composeSystemPrompt', () => {
expect(prompt).toContain('Prefer the `live-artifact` skill workflow when available');
expect(prompt).toContain('The first output should be a live artifact/dashboard/report');
});
describe('connectedExternalMcp directive', () => {
it('omits the directive when no servers are passed', () => {
const prompt = composeSystemPrompt({});
expect(prompt).not.toContain('External MCP servers — already authenticated');
expect(prompt).not.toContain('mcp__<server>__authenticate');
});
it('omits the directive when an empty array is passed', () => {
const prompt = composeSystemPrompt({ connectedExternalMcp: [] });
expect(prompt).not.toContain('External MCP servers — already authenticated');
});
it('lists each connected server and forbids the synthetic auth tools', () => {
const prompt = composeSystemPrompt({
connectedExternalMcp: [
{ id: 'higgsfield-openclaw', label: 'Higgsfield (OpenClaw)' },
{ id: 'github' },
],
});
expect(prompt).toContain('## External MCP servers — already authenticated');
expect(prompt).toContain('`higgsfield-openclaw`');
expect(prompt).toContain('Higgsfield (OpenClaw)');
expect(prompt).toContain('`github`');
expect(prompt).toContain(
'**Do NOT call any tool whose name matches `mcp__<server>__authenticate` or `mcp__<server>__complete_authentication`',
);
expect(prompt).toContain('localhost:<random>/callback');
expect(prompt).toContain('Settings → External MCP');
});
it('skips entries with blank ids and emits no directive when nothing usable remains', () => {
const prompt = composeSystemPrompt({
connectedExternalMcp: [
{ id: ' ', label: 'blank' },
{ id: '', label: 'empty' },
] as any,
});
expect(prompt).not.toContain('External MCP servers — already authenticated');
});
it('does not duplicate the label when it equals the id', () => {
const prompt = composeSystemPrompt({
connectedExternalMcp: [{ id: 'github', label: 'github' }],
});
expect(prompt).toContain('- `github`\n');
expect(prompt).not.toContain('- `github` (github)');
});
});
});

View file

@ -624,6 +624,12 @@ export function App() {
setSettingsOpen(true);
}, []);
const openMcpSettings = useCallback(() => {
setSettingsWelcome(false);
setSettingsInitialSection('mcpClient');
setSettingsOpen(true);
}, []);
// Explicit enabled toggle — true = wake, false = tuck. Persists to
// localStorage so the overlay state survives across reloads. We keep
// `adopted` untouched so the entry-view CTA does not regress to
@ -708,6 +714,7 @@ export function App() {
onAgentModelChange={handleAgentModelChange}
onRefreshAgents={refreshAgents}
onOpenSettings={openSettings}
onOpenMcpSettings={openMcpSettings}
onAdoptPetInline={handleAdoptPet}
onTogglePet={handleTogglePet}
onOpenPetSettings={openPetSettings}

View file

@ -1,18 +1,28 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { ToolCard } from './ToolCard';
import { renderMarkdown } from '../runtime/markdown';
import { projectFileUrl } from '../providers/registry';
import { splitOnQuestionForms, type QuestionForm } from '../artifacts/question-form';
import { QuestionFormView, parseSubmittedAnswers } from './QuestionForm';
import { Icon } from './Icon';
import { useT } from '../i18n';
import { unfinishedTodosFromEvents, type TodoItem } from '../runtime/todos';
import type { Dict } from '../i18n/types';
import { agentDisplayName, exactAgentDisplayName } from '../utils/agentLabels';
import { exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import type { AgentEvent, ChatMessage, ProjectFile } from '../types';
import { Fragment, useEffect, useMemo, useState } from "react";
import { ToolCard } from "./ToolCard";
import { renderMarkdown } from "../runtime/markdown";
import { projectFileUrl } from "../providers/registry";
import {
splitOnQuestionForms,
type QuestionForm,
} from "../artifacts/question-form";
import { QuestionFormView, parseSubmittedAnswers } from "./QuestionForm";
import { Icon } from "./Icon";
import { useT } from "../i18n";
import { unfinishedTodosFromEvents, type TodoItem } from "../runtime/todos";
import type { Dict } from "../i18n/types";
import { agentDisplayName, exactAgentDisplayName } from "../utils/agentLabels";
import {
exactDateTime,
messageTime,
relativeTimeLong,
} from "../utils/chatTime";
import type { AgentEvent, ChatMessage, ProjectFile } from "../types";
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type TranslateFn = (
key: keyof Dict,
vars?: Record<string, string | number>
) => string;
interface Props {
message: ChatMessage;
@ -57,17 +67,22 @@ export function AssistantMessage({
const t = useT();
const events = message.events ?? [];
const blocks = buildBlocks(events);
const usage = events.find((e) => e.kind === 'usage') as
| Extract<AgentEvent, { kind: 'usage' }>
const usage = events.find((e) => e.kind === "usage") as
| Extract<AgentEvent, { kind: "usage" }>
| undefined;
const produced = message.producedFiles ?? [];
const roleLabel = assistantRoleLabel(message, t);
const unfinishedTodos = streaming ? [] : unfinishedTodosFromEvents(events);
const canContinueTodos =
!streaming && !!isLast && unfinishedTodos.length > 0 && !!onContinueRemainingTasks;
!streaming &&
!!isLast &&
unfinishedTodos.length > 0 &&
!!onContinueRemainingTasks;
// Track which forms the user submitted in this session so we lock them
// immediately on click (without waiting for the parent to re-render).
const [locallySubmitted, setLocallySubmitted] = useState<Set<string>>(() => new Set());
const [locallySubmitted, setLocallySubmitted] = useState<Set<string>>(
() => new Set()
);
return (
<div className="msg assistant">
@ -77,10 +92,13 @@ export function AssistantMessage({
</div>
<div className="assistant-flow">
{blocks.length === 0 && streaming ? (
<WaitingPill startedAt={message.startedAt} latestStatus={latestStatusLabel(events)} />
<WaitingPill
startedAt={message.startedAt}
latestStatus={latestStatusLabel(events)}
/>
) : null}
{blocks.map((b, i) => {
if (b.kind === 'text')
if (b.kind === "text")
return (
<ProseBlock
key={i}
@ -99,8 +117,9 @@ export function AssistantMessage({
}}
/>
);
if (b.kind === 'thinking') return <ThinkingBlock key={i} text={b.text} />;
if (b.kind === 'tool-group') {
if (b.kind === "thinking")
return <ThinkingBlock key={i} text={b.text} />;
if (b.kind === "tool-group") {
return (
<ToolGroupCard
key={i}
@ -111,7 +130,8 @@ export function AssistantMessage({
/>
);
}
if (b.kind === 'status') return <StatusPill key={i} label={b.label} detail={b.detail} />;
if (b.kind === "status")
return <StatusPill key={i} label={b.label} detail={b.detail} />;
return null;
})}
{!streaming && produced.length > 0 && projectId ? (
@ -140,39 +160,56 @@ export function AssistantMessage({
);
}
function MessageTimestamp({ message, t }: { message: ChatMessage; t: TranslateFn }) {
function MessageTimestamp({
message,
t,
}: {
message: ChatMessage;
t: TranslateFn;
}) {
const ts = messageTime(message);
if (!ts) return null;
return (
<time className="msg-time" dateTime={new Date(ts).toISOString()} title={exactDateTime(ts)}>
<time
className="msg-time"
dateTime={new Date(ts).toISOString()}
title={exactDateTime(ts)}
>
{relativeTimeLong(ts, t)}
</time>
);
}
export function assistantRoleLabel(message: ChatMessage, t: TranslateFn): string {
export function assistantRoleLabel(
message: ChatMessage,
t: TranslateFn
): string {
const model = assistantModelDetail(message);
const fromName = message.agentName?.trim();
if (fromName) return appendRoleModel(exactAgentDisplayName(fromName) ?? fromName, model);
if (fromName)
return appendRoleModel(exactAgentDisplayName(fromName) ?? fromName, model);
const fromId = agentDisplayName(message.agentId);
if (fromId) return appendRoleModel(fromId, model);
const starting = message.events?.find(
(e) => e.kind === 'status' && e.label === 'starting' && e.detail,
) as Extract<AgentEvent, { kind: 'status' }> | undefined;
return appendRoleModel(agentDisplayName(starting?.detail) ?? t('assistant.role'), model);
(e) => e.kind === "status" && e.label === "starting" && e.detail
) as Extract<AgentEvent, { kind: "status" }> | undefined;
return appendRoleModel(
agentDisplayName(starting?.detail) ?? t("assistant.role"),
model
);
}
function assistantModelDetail(message: ChatMessage): string | null {
const initializing = message.events?.find(
(e) => e.kind === 'status' && e.label === 'initializing' && e.detail,
) as Extract<AgentEvent, { kind: 'status' }> | undefined;
(e) => e.kind === "status" && e.label === "initializing" && e.detail
) as Extract<AgentEvent, { kind: "status" }> | undefined;
const detail = initializing?.detail?.trim();
if (!detail || detail === 'default') return null;
if (!detail || detail === "default") return null;
return detail;
}
function appendRoleModel(label: string, model: string | null): string {
if (!model || label.includes(' · ')) return label;
if (!model || label.includes(" · ")) return label;
return `${label} · ${model}`;
}
@ -186,30 +223,33 @@ function AssistantFooter({
streaming: boolean;
startedAt: number | undefined;
endedAt: number | undefined;
usage: Extract<AgentEvent, { kind: 'usage' }> | undefined;
usage: Extract<AgentEvent, { kind: "usage" }> | undefined;
hasUnfinishedTodos: boolean;
}) {
const t = useT();
const elapsed = useLiveElapsed(streaming, startedAt, endedAt);
if (!streaming && !elapsed && !usage && !hasUnfinishedTodos) return null;
return (
<div className="assistant-footer" data-unfinished={hasUnfinishedTodos ? 'true' : 'false'}>
<span className="dot" data-active={streaming ? 'true' : 'false'} />
<div
className="assistant-footer"
data-unfinished={hasUnfinishedTodos ? "true" : "false"}
>
<span className="dot" data-active={streaming ? "true" : "false"} />
<span className="assistant-label">
{streaming
? t('assistant.workingLabel')
? t("assistant.workingLabel")
: hasUnfinishedTodos
? t('assistant.unfinishedLabel')
: t('assistant.doneLabel')}
? t("assistant.unfinishedLabel")
: t("assistant.doneLabel")}
</span>
<span className="assistant-stats">
{elapsed}
{usage?.outputTokens != null
? ` · ${t('assistant.outTokens', { n: usage.outputTokens })}`
: ''}
{typeof usage?.costUsd === 'number'
? ` · ${t("assistant.outTokens", { n: usage.outputTokens })}`
: ""}
{typeof usage?.costUsd === "number"
? ` · $${usage.costUsd.toFixed(4)}`
: ''}
: ""}
</span>
</div>
);
@ -231,24 +271,30 @@ function UnfinishedTodosPanel({
<div className="unfinished-todos">
<div className="unfinished-todos-head">
<span className="unfinished-todos-title">
{t('assistant.unfinishedSummary', { n: todos.length })}
{t("assistant.unfinishedSummary", { n: todos.length })}
</span>
{canContinue ? (
<button type="button" className="unfinished-todos-continue" onClick={onContinue}>
{t('assistant.continueRemaining')}
<button
type="button"
className="unfinished-todos-continue"
onClick={onContinue}
>
{t("assistant.continueRemaining")}
</button>
) : null}
</div>
<ul className="unfinished-todos-list">
{visible.map((todo, i) => (
<li key={`${todo.status}-${todo.content}-${i}`}>
{todo.status === 'in_progress' && todo.activeForm ? todo.activeForm : todo.content}
{todo.status === "in_progress" && todo.activeForm
? todo.activeForm
: todo.content}
</li>
))}
</ul>
{hiddenCount > 0 ? (
<div className="unfinished-todos-more">
{t('assistant.unfinishedMore', { n: hiddenCount })}
{t("assistant.unfinishedMore", { n: hiddenCount })}
</div>
) : null}
</div>
@ -267,14 +313,16 @@ function ProducedFiles({
const t = useT();
return (
<div className="produced-files">
<div className="produced-files-label">{t('assistant.producedFiles')}</div>
<div className="produced-files-label">{t("assistant.producedFiles")}</div>
<div className="produced-files-list">
{files.map((f) => (
<div key={f.name} className="produced-file">
<span className="produced-file-icon" aria-hidden>
<Icon name={kindIconName(f.kind)} size={14} />
</span>
<span className="produced-file-name" title={f.name}>{f.name}</span>
<span className="produced-file-name" title={f.name}>
{f.name}
</span>
<span className="produced-file-size">{humanBytes(f.size)}</span>
<div className="produced-file-actions">
{onRequestOpenFile ? (
@ -283,7 +331,7 @@ function ProducedFiles({
className="ghost"
onClick={() => onRequestOpenFile(f.name)}
>
{t('assistant.openFile')}
{t("assistant.openFile")}
</button>
) : null}
<a
@ -291,7 +339,7 @@ function ProducedFiles({
href={projectFileUrl(projectId, f.name)}
download={f.name}
>
{t('assistant.downloadFile')}
{t("assistant.downloadFile")}
</a>
</div>
</div>
@ -302,13 +350,13 @@ function ProducedFiles({
}
function kindIconName(
kind: ProjectFile['kind'],
): 'file-code' | 'image' | 'pencil' | 'file' {
if (kind === 'html') return 'file-code';
if (kind === 'image') return 'image';
if (kind === 'sketch') return 'pencil';
if (kind === 'code') return 'file-code';
return 'file';
kind: ProjectFile["kind"]
): "file-code" | "image" | "pencil" | "file" {
if (kind === "html") return "file-code";
if (kind === "image") return "image";
if (kind === "sketch") return "pencil";
if (kind === "code") return "file-code";
return "file";
}
function humanBytes(n: number): string {
@ -336,11 +384,13 @@ function WaitingPill({
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const elapsedSec = startedAt ? Math.max(0, Math.round((now - startedAt) / 1000)) : 0;
const elapsedSec = startedAt
? Math.max(0, Math.round((now - startedAt) / 1000))
: 0;
const slow = elapsedSec >= 12;
const label = latestStatus?.label
? humanizeStatus(latestStatus.label, t)
: t('assistant.waitingFirstOutput');
: t("assistant.waitingFirstOutput");
return (
<div className="op-waiting">
<span className="op-waiting-dot" aria-hidden />
@ -349,27 +399,27 @@ function WaitingPill({
<code className="op-waiting-detail">{latestStatus.detail}</code>
) : null}
{slow ? (
<span className="op-waiting-hint">{t('assistant.slowHint')}</span>
<span className="op-waiting-hint">{t("assistant.slowHint")}</span>
) : null}
</div>
);
}
function humanizeStatus(label: string, t: (k: keyof Dict) => string): string {
if (label === 'initializing') return t('assistant.statusBootingAgent');
if (label === 'starting') return t('assistant.statusStarting');
if (label === 'requesting') return t('assistant.statusRequesting');
if (label === 'thinking') return t('assistant.statusThinking');
if (label === 'streaming') return t('assistant.statusStreaming');
if (label === "initializing") return t("assistant.statusBootingAgent");
if (label === "starting") return t("assistant.statusStarting");
if (label === "requesting") return t("assistant.statusRequesting");
if (label === "thinking") return t("assistant.statusThinking");
if (label === "streaming") return t("assistant.statusStreaming");
return label.charAt(0).toUpperCase() + label.slice(1);
}
function latestStatusLabel(
events: AgentEvent[],
events: AgentEvent[]
): { label: string; detail?: string | undefined } | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]!;
if (ev.kind === 'status') return { label: ev.label, detail: ev.detail };
if (ev.kind === "status") return { label: ev.label, detail: ev.detail };
}
return undefined;
}
@ -393,26 +443,35 @@ function ProseBlock({
const segments = useMemo(() => splitOnQuestionForms(cleaned), [cleaned]);
// Each text segment is further split on `<system-reminder>` blocks so
// those render as their own collapsible chip instead of raw markup.
const renderable = segments.flatMap((seg, idx): Array<
| { key: string; kind: 'text'; text: string }
| { key: string; kind: 'reminder'; text: string }
| { key: string; kind: 'form'; form: QuestionForm }
> => {
if (seg.kind === 'form') {
return [{ key: `f-${idx}`, kind: 'form', form: seg.form }];
const renderable = segments.flatMap(
(
seg,
idx
): Array<
| { key: string; kind: "text"; text: string }
| { key: string; kind: "reminder"; text: string }
| { key: string; kind: "form"; form: QuestionForm }
> => {
if (seg.kind === "form") {
return [{ key: `f-${idx}`, kind: "form", form: seg.form }];
}
if (seg.text.trim().length === 0) return [];
const sub = splitSystemReminders(seg.text);
return sub.map((s, j) => ({
key: `t-${idx}-${j}`,
kind: s.kind,
text: s.text,
}));
}
if (seg.text.trim().length === 0) return [];
const sub = splitSystemReminders(seg.text);
return sub.map((s, j) => ({ key: `t-${idx}-${j}`, kind: s.kind, text: s.text }));
});
);
if (renderable.length === 0) return null;
return (
<div className="prose-block">
{renderable.map((seg) => {
if (seg.kind === 'reminder') {
if (seg.kind === "reminder") {
return <SystemReminderBlock key={seg.key} text={seg.text} />;
}
if (seg.kind === 'text') {
if (seg.kind === "text") {
return <Fragment key={seg.key}>{renderMarkdown(seg.text)}</Fragment>;
}
return (
@ -454,7 +513,10 @@ function FormBlock({
}, [form, nextUserContent]);
const wasSubmittedLocally = locallySubmitted.has(form.id);
const interactive =
isLastAssistant && !streaming && !submittedFromHistory && !wasSubmittedLocally;
isLastAssistant &&
!streaming &&
!submittedFromHistory &&
!wasSubmittedLocally;
return (
<QuestionFormView
form={form}
@ -469,7 +531,7 @@ function SystemReminderBlock({ text }: { text: string }) {
const t = useT();
const [open, setOpen] = useState(false);
const trimmed = text.trim();
const preview = trimmed.split('\n')[0]?.slice(0, 120) ?? '';
const preview = trimmed.split("\n")[0]?.slice(0, 120) ?? "";
return (
<div className="system-reminder-block">
<button
@ -480,13 +542,15 @@ function SystemReminderBlock({ text }: { text: string }) {
<span className="system-reminder-icon" aria-hidden>
<Icon name="settings" size={12} />
</span>
<span className="system-reminder-label">{t('assistant.systemReminder')}</span>
<span className="system-reminder-label">
{t("assistant.systemReminder")}
</span>
<span className="system-reminder-preview">
{open ? '' : preview}
{!open && trimmed.length > preview.length ? '…' : ''}
{open ? "" : preview}
{!open && trimmed.length > preview.length ? "…" : ""}
</span>
<span className="system-reminder-chev">
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
<Icon name={open ? "chevron-down" : "chevron-right"} size={11} />
</span>
</button>
{open ? <pre className="system-reminder-body">{trimmed}</pre> : null}
@ -504,10 +568,13 @@ function ThinkingBlock({ text }: { text: string }) {
<span className="thinking-icon" aria-hidden>
<Icon name="sparkles" size={12} />
</span>
<span className="thinking-label">{t('assistant.thinking')}</span>
<span className="thinking-preview">{open ? '' : preview}{!open && text.length > 140 ? '…' : ''}</span>
<span className="thinking-label">{t("assistant.thinking")}</span>
<span className="thinking-preview">
{open ? "" : preview}
{!open && text.length > 140 ? "…" : ""}
</span>
<span className="thinking-chev">
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
<Icon name={open ? "chevron-down" : "chevron-right"} size={11} />
</span>
</button>
{open ? <pre className="thinking-body">{text}</pre> : null}
@ -515,7 +582,13 @@ function ThinkingBlock({ text }: { text: string }) {
);
}
function StatusPill({ label, detail }: { label: string; detail?: string | undefined }) {
function StatusPill({
label,
detail,
}: {
label: string;
detail?: string | undefined;
}) {
return (
<div className="status-pill">
<span className="status-label">{label}</span>
@ -525,8 +598,8 @@ function StatusPill({ label, detail }: { label: string; detail?: string | undefi
}
interface ToolItem {
use: Extract<AgentEvent, { kind: 'tool_use' }>;
result?: Extract<AgentEvent, { kind: 'tool_result' }>;
use: Extract<AgentEvent, { kind: "tool_use" }>;
result?: Extract<AgentEvent, { kind: "tool_result" }>;
}
function ToolGroupCard({
@ -563,14 +636,18 @@ function ToolGroupCard({
<div className="action-card">
<button
type="button"
className={`action-card-toggle ${running ? 'running' : ''}`}
className={`action-card-toggle ${running ? "running" : ""}`}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="ico" aria-hidden>{summary.icon}</span>
<span className="summary"><strong>{summary.label}</strong></span>
<span className="ico" aria-hidden>
{summary.icon}
</span>
<span className="summary">
<strong>{summary.label}</strong>
</span>
<span className="chev" aria-hidden>
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
<Icon name={open ? "chevron-down" : "chevron-right"} size={11} />
</span>
</button>
{open ? (
@ -593,10 +670,10 @@ function ToolGroupCard({
function summarizeGroup(
items: ToolItem[],
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
t: (k: keyof Dict, vars?: Record<string, string | number>) => string
): { label: string; icon: string } {
// All items share a tool family because the grouper only merges by name.
const name = items[0]?.use.name ?? '';
const name = items[0]?.use.name ?? "";
const family = toolFamily(name);
const icon = familyIcon(family);
const verbs = items.map((it) => verbForState(it, t));
@ -609,79 +686,73 @@ function summarizeGroup(
}
function toolFamily(name: string): string {
if (name === 'Edit' || name === 'str_replace_edit') return 'edit';
if (name === 'Write' || name === 'create_file') return 'write';
if (name === 'Read' || name === 'read_file') return 'read';
if (name === 'Glob' || name === 'list_files') return 'glob';
if (name === 'Grep') return 'grep';
if (name === 'Bash') return 'bash';
if (name === 'TodoWrite') return 'todo';
if (name === 'WebFetch' || name === 'web_fetch') return 'fetch';
if (name === 'WebSearch' || name === 'web_search') return 'search';
if (name === "Edit" || name === "str_replace_edit") return "edit";
if (name === "Write" || name === "create_file") return "write";
if (name === "Read" || name === "read_file") return "read";
if (name === "Glob" || name === "list_files") return "glob";
if (name === "Grep") return "grep";
if (name === "Bash") return "bash";
if (name === "TodoWrite") return "todo";
if (name === "WebFetch" || name === "web_fetch") return "fetch";
if (name === "WebSearch" || name === "web_search") return "search";
return name.toLowerCase();
}
function familyIcon(family: string): string {
if (family === 'edit') return '✎';
if (family === 'write') return '+';
if (family === 'read') return '↗';
if (family === 'glob' || family === 'grep' || family === 'search') return '⌕';
if (family === 'bash') return '$';
if (family === 'todo') return '☐';
if (family === 'fetch') return '↬';
return '·';
if (family === "edit") return "✎";
if (family === "write") return "+";
if (family === "read") return "↗";
if (family === "glob" || family === "grep" || family === "search") return "⌕";
if (family === "bash") return "$";
if (family === "todo") return "☐";
if (family === "fetch") return "↬";
return "·";
}
function countLabel(
family: string,
n: number,
t: (k: keyof Dict) => string,
t: (k: keyof Dict) => string
): string {
const verb =
family === 'edit'
? t('assistant.verbEditing')
: family === 'write'
? t('assistant.verbWriting')
: family === 'read'
? t('assistant.verbReading')
: family === 'glob' || family === 'grep' || family === 'search'
? t('assistant.verbSearching')
: family === 'bash'
? t('assistant.verbRunning')
: family === 'todo'
? t('assistant.verbTodos')
: family === 'fetch'
? t('assistant.verbFetching')
: t('assistant.verbCalling');
family === "edit"
? t("assistant.verbEditing")
: family === "write"
? t("assistant.verbWriting")
: family === "read"
? t("assistant.verbReading")
: family === "glob" || family === "grep" || family === "search"
? t("assistant.verbSearching")
: family === "bash"
? t("assistant.verbRunning")
: family === "todo"
? t("assistant.verbTodos")
: family === "fetch"
? t("assistant.verbFetching")
: t("assistant.verbCalling");
return n > 1 ? `${verb} ×${n}` : verb;
}
function verbForState(
it: ToolItem,
t: (k: keyof Dict) => string,
): string {
if (!it.result) return t('assistant.verbRunning');
if (it.result.isError) return t('tool.error');
return t('tool.done');
function verbForState(it: ToolItem, t: (k: keyof Dict) => string): string {
if (!it.result) return t("assistant.verbRunning");
if (it.result.isError) return t("tool.error");
return t("tool.done");
}
function lastStateLabel(
verbs: string[],
t: (k: keyof Dict) => string,
): string {
function lastStateLabel(verbs: string[], t: (k: keyof Dict) => string): string {
const set = new Set(verbs);
if (set.size === 1) return verbs[verbs.length - 1] ?? '';
if (set.size === 1) return verbs[verbs.length - 1] ?? "";
// Mixed states: surface error first, else running, else any.
if (set.has(t('tool.error'))) return t('tool.error');
if (set.has(t('assistant.verbRunning'))) return t('assistant.verbRunning');
return verbs[verbs.length - 1] ?? '';
if (set.has(t("tool.error"))) return t("tool.error");
if (set.has(t("assistant.verbRunning"))) return t("assistant.verbRunning");
return verbs[verbs.length - 1] ?? "";
}
type Block =
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool-group'; items: ToolItem[] }
| { kind: 'status'; label: string; detail?: string | undefined };
| { kind: "text"; text: string }
| { kind: "thinking"; text: string }
| { kind: "tool-group"; items: ToolItem[] }
| { kind: "status"; label: string; detail?: string | undefined };
/**
* Walk the event stream and build the rendering layout list. We additionally
@ -691,45 +762,54 @@ type Block =
*/
function buildBlocks(events: AgentEvent[]): Block[] {
const out: Block[] = [];
const resultByToolId = new Map<string, Extract<AgentEvent, { kind: 'tool_result' }>>();
const resultByToolId = new Map<
string,
Extract<AgentEvent, { kind: "tool_result" }>
>();
for (const ev of events) {
if (ev.kind === 'tool_result') resultByToolId.set(ev.toolUseId, ev);
if (ev.kind === "tool_result") resultByToolId.set(ev.toolUseId, ev);
}
for (const ev of events) {
if (ev.kind === 'text') {
if (ev.kind === "text") {
const last = out[out.length - 1];
if (last && last.kind === 'text') last.text += ev.text;
else out.push({ kind: 'text', text: ev.text });
if (last && last.kind === "text") last.text += ev.text;
else out.push({ kind: "text", text: ev.text });
continue;
}
if (ev.kind === 'thinking') {
if (ev.kind === "thinking") {
const last = out[out.length - 1];
if (last && last.kind === 'thinking') last.text += ev.text;
else out.push({ kind: 'thinking', text: ev.text });
if (last && last.kind === "thinking") last.text += ev.text;
else out.push({ kind: "thinking", text: ev.text });
continue;
}
if (ev.kind === 'tool_use') {
if (ev.kind === "tool_use") {
const result = resultByToolId.get(ev.id);
const item: ToolItem = result ? { use: ev, result } : { use: ev };
const last = out[out.length - 1];
const fam = toolFamily(ev.name);
if (
last &&
last.kind === 'tool-group' &&
last.kind === "tool-group" &&
toolFamily(last.items[last.items.length - 1]!.use.name) === fam
) {
last.items.push(item);
} else {
out.push({ kind: 'tool-group', items: [item] });
out.push({ kind: "tool-group", items: [item] });
}
continue;
}
if (ev.kind === 'tool_result') continue;
if (ev.kind === 'status') {
if (ev.label === 'streaming' || ev.label === 'starting' || ev.label === 'requesting' || ev.label === 'thinking') continue;
if (ev.kind === "tool_result") continue;
if (ev.kind === "status") {
if (
ev.label === "streaming" ||
ev.label === "starting" ||
ev.label === "requesting" ||
ev.label === "thinking"
)
continue;
const last = out[out.length - 1];
if (last && last.kind === 'status' && last.label === ev.label) continue;
out.push({ kind: 'status', label: ev.label, detail: ev.detail });
if (last && last.kind === "status" && last.label === ev.label) continue;
out.push({ kind: "status", label: ev.label, detail: ev.detail });
continue;
}
}
@ -737,12 +817,13 @@ function buildBlocks(events: AgentEvent[]): Block[] {
}
function stripArtifact(content: string): string {
const open = content.indexOf('<artifact');
const open = content.indexOf("<artifact");
if (open === -1) return content;
const closeTag = content.indexOf('>', open);
const end = content.indexOf('</artifact>', closeTag);
const closeTag = content.indexOf(">", open);
const end = content.indexOf("</artifact>", closeTag);
return (
content.slice(0, open) + content.slice(end === -1 ? content.length : end + 11)
content.slice(0, open) +
content.slice(end === -1 ? content.length : end + 11)
).trim();
}
@ -752,7 +833,7 @@ function stripArtifact(content: string): string {
// echoes those tags into its response. Rendering the raw markup as prose
// looks broken — surface them as their own collapsible block, and strip stray
// orphan open/close tags from the surrounding text.
type ProseSegment = { kind: 'text' | 'reminder'; text: string };
type ProseSegment = { kind: "text" | "reminder"; text: string };
function splitSystemReminders(input: string): ProseSegment[] {
const re = /<system-reminder>([\s\S]*?)<\/system-reminder>/g;
@ -761,29 +842,29 @@ function splitSystemReminders(input: string): ProseSegment[] {
let m: RegExpExecArray | null;
while ((m = re.exec(input))) {
if (m.index > lastIndex) {
out.push({ kind: 'text', text: input.slice(lastIndex, m.index) });
out.push({ kind: "text", text: input.slice(lastIndex, m.index) });
}
out.push({ kind: 'reminder', text: m[1] ?? '' });
out.push({ kind: "reminder", text: m[1] ?? "" });
lastIndex = re.lastIndex;
}
if (lastIndex < input.length) {
out.push({ kind: 'text', text: input.slice(lastIndex) });
out.push({ kind: "text", text: input.slice(lastIndex) });
}
// Drop any orphan tags that survived (open without close, or vice versa)
// and discard text segments that became empty after stripping.
return out
.map((seg) =>
seg.kind === 'text'
? { ...seg, text: seg.text.replace(/<\/?system-reminder>/g, '') }
: seg,
seg.kind === "text"
? { ...seg, text: seg.text.replace(/<\/?system-reminder>/g, "") }
: seg
)
.filter((seg) => seg.kind === 'reminder' || seg.text.trim().length > 0);
.filter((seg) => seg.kind === "reminder" || seg.text.trim().length > 0);
}
function useLiveElapsed(
streaming: boolean,
startedAt: number | undefined,
endedAt: number | undefined,
endedAt: number | undefined
): string {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@ -791,12 +872,12 @@ function useLiveElapsed(
const id = window.setInterval(() => setNow(Date.now()), 200);
return () => window.clearInterval(id);
}, [streaming]);
if (!startedAt) return '';
const end = streaming ? now : (endedAt ?? now);
if (!startedAt) return "";
const end = streaming ? now : endedAt ?? now;
const ms = Math.max(0, end - startedAt);
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
const m = Math.floor(s / 60);
const rem = Math.floor(s - m * 60);
return `${m}m ${rem.toString().padStart(2, '0')}s`;
return `${m}m ${rem.toString().padStart(2, "0")}s`;
}

View file

@ -10,6 +10,8 @@ import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../providers/registry";
import { patchProject } from "../state/projects";
import { fetchMcpServers } from "../state/mcp";
import type { McpServerConfig } from "../state/mcp";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata } from "../types";
import type { ResearchOptions } from '@open-design/contracts';
import { Icon } from "./Icon";
@ -51,6 +53,9 @@ interface Props {
// composer's leading gear icon routes here so users can switch models
// without leaving the chat.
onOpenSettings?: () => void;
// Opens settings on the External MCP tab. Wired from ChatPane → App.
// The composer's `/mcp` slash command and the MCP picker button route here.
onOpenMcpSettings?: () => void;
// Optional pet wiring — when present, the composer renders a small
// 🐾 button + popover so users can adopt / wake / tuck a pet without
// leaving chat. Typing `/pet` (or `/pet wake|tuck|<id>`) is parsed
@ -97,6 +102,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
onSend,
onStop,
onOpenSettings,
onOpenMcpSettings,
petConfig,
onAdoptPet,
onTogglePet,
@ -126,14 +132,21 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const [slashIndex, setSlashIndex] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [petOpen, setPetOpen] = useState(false);
// External MCP servers configured by the user. Fetched lazily on mount;
// shown in the slash-command palette so `/mcp <id>` inserts a hint into
// the prompt that nudges the model to use that server's tools.
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
// Consolidated "tools" popover — a single dropdown anchored to the
// leading sliders icon that hosts MCP / Import / Pet quick actions and
// a shortcut to open the full Settings dialog. Replaces the previous
// row of three standalone buttons (which overflowed in narrow chats).
const [toolsOpen, setToolsOpen] = useState(false);
type ToolsTab = 'mcp' | 'import' | 'pet';
const [toolsTab, setToolsTab] = useState<ToolsTab>('mcp');
const fileInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const importMenuRef = useRef<HTMLDivElement | null>(null);
const importTriggerRef = useRef<HTMLButtonElement | null>(null);
const petMenuRef = useRef<HTMLDivElement | null>(null);
const petTriggerRef = useRef<HTMLButtonElement | null>(null);
const toolsMenuRef = useRef<HTMLDivElement | null>(null);
const toolsTriggerRef = useRef<HTMLButtonElement | null>(null);
const petEnabled = Boolean(onAdoptPet && onTogglePet);
const linkedDirs = projectMetadata?.linkedDirs ?? [];
// initialDraft is only honored on the first non-empty value the parent
@ -156,42 +169,65 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}, [initialDraft, draft]);
useEffect(() => {
if (!importOpen) return;
if (!toolsOpen) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (importMenuRef.current?.contains(target)) return;
if (importTriggerRef.current?.contains(target)) return;
setImportOpen(false);
if (toolsMenuRef.current?.contains(target)) return;
if (toolsTriggerRef.current?.contains(target)) return;
setToolsOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setImportOpen(false);
if (e.key === 'Escape') setToolsOpen(false);
}
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [importOpen]);
}, [toolsOpen]);
// Lazy-fetch the user's external MCP servers list once on mount so the
// `/mcp …` slash palette and the composer's MCP button popover have
// something to render. We deliberately do not reactively re-fetch when
// the user toggles servers from Settings — the dialog refreshes itself,
// and the chat composer rehydrates next time the user re-opens it. A
// background poll would be cheap but unnecessary for the typical
// edit-once-then-chat workflow.
useEffect(() => {
if (!petOpen) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (petMenuRef.current?.contains(target)) return;
if (petTriggerRef.current?.contains(target)) return;
setPetOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setPetOpen(false);
}
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
let cancelled = false;
void (async () => {
const data = await fetchMcpServers();
if (cancelled || !data) return;
setMcpServers(data.servers.filter((s) => s.enabled));
})();
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
cancelled = true;
};
}, [petOpen]);
}, []);
// Resolve which tabs to surface in the consolidated tools popover.
// We intentionally always render at least the Import tab, since it has
// unconditional folder linking. MCP and Pet tabs only show when their
// respective wiring was provided by the parent (App).
const availableTabs = useMemo<ToolsTab[]>(() => {
const tabs: ToolsTab[] = [];
if (onOpenMcpSettings) tabs.push('mcp');
tabs.push('import');
if (petEnabled) tabs.push('pet');
return tabs;
}, [onOpenMcpSettings, petEnabled]);
// When the popover opens, snap the active tab to the first available one
// so the user never lands on an empty / hidden tab if their config
// changes mid-session.
useEffect(() => {
if (!toolsOpen) return;
if (!availableTabs.includes(toolsTab)) {
const first = availableTabs[0];
if (first) setToolsTab(first);
}
}, [toolsOpen, availableTabs, toolsTab]);
// Catalog of supported slash commands. Each entry shows up in the
// popover when the user types `/` in the composer. The `insert`
@ -200,6 +236,30 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
// ready for an argument.
const slashCommands = useMemo<SlashCommand[]>(() => {
const list: SlashCommand[] = [];
// External MCP servers — `/mcp` opens settings, `/mcp <id>` inserts a
// prompt-side hint nudging the model to use that server's tools. The
// hint flows through to the agent verbatim; the daemon already wired
// the MCP config into the agent's launch so the tools are callable.
if (onOpenMcpSettings) {
list.push({
id: 'mcp',
label: '/mcp',
insert: '/mcp ',
descKey: 'pet.slashPet',
icon: 'sliders',
argHint: 'open settings · <server-id> to insert hint',
});
}
for (const s of mcpServers) {
list.push({
id: `mcp-${s.id}`,
label: `/mcp ${s.id}`,
insert: `Use the \`${s.id}\` MCP server tools. `,
descKey: 'pet.slashPet',
icon: 'sparkles',
argHint: s.label || s.transport,
});
}
if (researchAvailable) {
list.push({
id: 'search',
@ -245,7 +305,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
);
}
return list;
}, [petEnabled, researchAvailable, t]);
}, [petEnabled, researchAvailable, t, mcpServers, onOpenMcpSettings]);
const filteredSlash = useMemo(() => {
if (!slash) return [] as SlashCommand[];
@ -297,6 +357,20 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
].join('\n');
}
// `/mcp` (no arg) opens settings on the External MCP tab — pure UX hook,
// never sent to the agent. `/mcp <id>` is intentionally NOT intercepted
// here: the slash palette already replaces it with a natural-language
// hint sentence ("Use the `<id>` MCP server tools."), and the user is
// expected to keep typing the rest of the prompt before sending.
function tryHandleMcpSlash(): boolean {
if (!onOpenMcpSettings) return false;
const trimmed = draft.trim();
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
onOpenMcpSettings();
setDraft('');
return true;
}
function expandSearchCommand(input: string): { prompt: string; query: string } | null {
const m = /^\/search(?:\s+([\s\S]*))?$/i.exec(input.trim());
if (!m) return null;
@ -447,7 +521,6 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
async function handleLinkFolder() {
setImportOpen(false);
if (!projectId) return;
const selected = await openFolderDialog();
if (!selected) return;
@ -525,9 +598,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
async function submit() {
const prompt = draft.trim();
// Intercept `/pet …` before sending so the slash command never
// hits the agent — it is a local UX hook, not a model prompt.
// Intercept `/pet …` and `/mcp` before sending so the slash command
// never hits the agent — these are local UX hooks, not model prompts.
if (tryHandlePetSlash()) return;
if (tryHandleMcpSlash()) return;
// `/hatch <concept>` expands into the canonical hatch-pet skill
// prompt and *is* sent to the agent — the agent runs the skill,
// packages a Codex pet under `~/.codex/pets/`, and the user
@ -677,22 +751,147 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
data-testid="chat-file-input"
type="file"
multiple
style={{ display: "none" }}
style={{ display: 'none' }}
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
void uploadFiles(files);
e.target.value = "";
e.target.value = '';
}}
/>
<button
className="icon-btn"
onClick={() => onOpenSettings?.()}
title={t('chat.cliSettingsTitle')}
aria-label={t('chat.cliSettingsAria')}
disabled={!onOpenSettings}
>
<Icon name="sliders" size={15} />
</button>
<div className="composer-tools-wrap">
<button
ref={toolsTriggerRef}
type="button"
className={`icon-btn composer-tools-trigger${toolsOpen ? ' active' : ''}`}
onClick={() => setToolsOpen((v) => !v)}
title={t('chat.cliSettingsTitle')}
aria-haspopup="menu"
aria-expanded={toolsOpen}
aria-label={t('chat.cliSettingsAria')}
>
<Icon name="sliders" size={15} />
{mcpServers.length > 0 ? (
<span className="composer-tools-badge">{mcpServers.length}</span>
) : null}
</button>
{toolsOpen ? (
<div
ref={toolsMenuRef}
className="composer-tools-menu"
role="menu"
>
<div className="composer-tools-tabs" role="tablist">
{availableTabs.map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={toolsTab === tab}
className={`composer-tools-tab${toolsTab === tab ? ' active' : ''}`}
onClick={() => setToolsTab(tab)}
>
{tab === 'mcp' ? (
<>
<Icon name="link" size={12} />
<span>MCP</span>
{mcpServers.length > 0 ? (
<span className="composer-tools-tab-count">
{mcpServers.length}
</span>
) : null}
</>
) : null}
{tab === 'import' ? (
<>
<Icon name="import" size={12} />
<span>{t('chat.importLabel')}</span>
</>
) : null}
{tab === 'pet' ? (
<>
<span className="composer-tools-tab-glyph" aria-hidden>
{resolveActivePet(petConfig)?.glyph ?? '🐾'}
</span>
<span>{t('pet.composerMenuTitle')}</span>
</>
) : null}
</button>
))}
</div>
<div className="composer-tools-content">
{toolsTab === 'mcp' && onOpenMcpSettings ? (
<ToolsMcpPanel
servers={mcpServers}
onInsert={(serverId) => {
const ta = textareaRef.current;
const insert = `Use the \`${serverId}\` MCP server tools. `;
const cursor = ta?.selectionStart ?? draft.length;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const next = before + insert + after;
setDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
const pos = before.length + insert.length;
el.setSelectionRange(pos, pos);
});
}}
onManage={() => {
setToolsOpen(false);
onOpenMcpSettings?.();
}}
/>
) : null}
{toolsTab === 'import' ? (
<ToolsImportPanel
t={t}
onLinkFolder={async () => {
setToolsOpen(false);
await handleLinkFolder();
}}
/>
) : null}
{toolsTab === 'pet' && petEnabled ? (
<ToolsPetPanel
t={t}
petConfig={petConfig}
onTogglePet={() => {
onTogglePet?.();
setToolsOpen(false);
}}
onAdoptPet={(id) => {
onAdoptPet?.(id);
setToolsOpen(false);
}}
onOpenPetSettings={() => {
onOpenPetSettings?.();
setToolsOpen(false);
}}
/>
) : null}
</div>
{onOpenSettings ? (
<button
type="button"
role="menuitem"
className="composer-tools-settings"
onClick={() => {
setToolsOpen(false);
onOpenSettings?.();
}}
>
<Icon name="settings" size={13} />
<span>{t('pet.composerOpenSettings')}</span>
</button>
) : null}
</div>
) : null}
</div>
<button
className="icon-btn"
data-testid="chat-attach"
@ -707,143 +906,6 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
<Icon name="attach" size={15} />
)}
</button>
<span className="composer-icon-divider" aria-hidden />
<div className="composer-import-wrap">
<button
ref={importTriggerRef}
type="button"
className="composer-import"
onClick={() => setImportOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={importOpen}
title={t('chat.importTitle')}
>
<Icon name="import" size={13} />
<span>{t('chat.importLabel')}</span>
<Icon name="chevron-down" size={12} />
</button>
{importOpen ? (
<div
ref={importMenuRef}
className="composer-import-menu"
role="menu"
>
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
<ImportItem icon="link" label={t('chat.importGitHub')} t={t} />
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
<ImportItem
icon="folder"
label={t('chat.importFolder')}
t={t}
enabled
onClick={handleLinkFolder}
/>
<ImportItem
icon="sparkles"
label={t('chat.importSkills')}
t={t}
/>
<ImportItem icon="file" label={t('chat.importProject')} t={t} />
</div>
) : null}
</div>
{petEnabled ? (
<div className="composer-pet-wrap">
<button
ref={petTriggerRef}
type="button"
className={`composer-pet${petConfig?.adopted ? ' adopted' : ''}`}
onClick={() => setPetOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={petOpen}
title={t('pet.composerTitle')}
>
<span className="composer-pet-glyph" aria-hidden>
{(() => {
const active = resolveActivePet(petConfig);
if (active) return active.glyph;
return '🐾';
})()}
</span>
<span className="composer-pet-label">
{petConfig?.adopted
? petConfig.enabled
? t('pet.tuck')
: t('pet.wake')
: t('pet.adopt')}
</span>
<Icon name="chevron-down" size={12} />
</button>
{petOpen ? (
<div
ref={petMenuRef}
className="composer-pet-menu"
role="menu"
>
<div className="composer-pet-menu-head">
<strong>{t('pet.composerMenuTitle')}</strong>
<span>{t('pet.composerMenuHint')}</span>
</div>
{petConfig?.adopted ? (
<button
type="button"
role="menuitem"
className="composer-pet-menu-row toggle"
onClick={() => {
onTogglePet?.();
setPetOpen(false);
}}
>
<Icon
name={petConfig.enabled ? 'eye' : 'sparkles'}
size={12}
/>
<span>
{petConfig.enabled
? t('pet.tuck')
: t('pet.wake')}
</span>
</button>
) : null}
<div className="composer-pet-menu-grid">
{BUILT_IN_PETS.map((p) => {
const active =
petConfig?.adopted && petConfig.petId === p.id;
return (
<button
type="button"
role="menuitem"
key={p.id}
className={`composer-pet-menu-pet${active ? ' active' : ''}`}
onClick={() => {
onAdoptPet?.(p.id);
setPetOpen(false);
}}
style={{ ['--pet-accent' as string]: p.accent }}
title={p.flavor}
>
<span aria-hidden>{p.glyph}</span>
<span>{p.name}</span>
</button>
);
})}
</div>
<button
type="button"
role="menuitem"
className="composer-pet-menu-row settings"
onClick={() => {
onOpenPetSettings?.();
setPetOpen(false);
}}
>
<Icon name="settings" size={12} />
<span>{t('pet.composerOpenSettings')}</span>
</button>
</div>
) : null}
</div>
) : null}
<span className="composer-spacer" />
{streaming ? (
<button
@ -945,6 +1007,141 @@ function StagedCommentAttachments({
);
}
function ToolsMcpPanel({
servers,
onInsert,
onManage,
}: {
servers: McpServerConfig[];
onInsert: (serverId: string) => void;
onManage: () => void;
}) {
return (
<>
{servers.length === 0 ? (
<div className="composer-tools-empty">
No MCP servers configured yet. Open Settings to add Higgsfield,
GitHub, Filesystem, or a custom server.
</div>
) : (
<div className="composer-tools-list">
{servers.map((s) => (
<button
key={s.id}
type="button"
role="menuitem"
className="composer-tools-row"
onClick={() => onInsert(s.id)}
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
>
<Icon name="link" size={12} />
<span className="composer-tools-row-body">
<strong>{s.label || s.id}</strong>
<span className="composer-tools-row-meta">{s.transport}</span>
</span>
</button>
))}
</div>
)}
<button
type="button"
role="menuitem"
className="composer-tools-row composer-tools-row-action"
onClick={onManage}
>
<Icon name="settings" size={12} />
<span>Manage MCP servers</span>
</button>
</>
);
}
function ToolsImportPanel({
t,
onLinkFolder,
}: {
t: TranslateFn;
onLinkFolder: () => Promise<void> | void;
}) {
return (
<div className="composer-tools-list">
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
<ImportItem icon="link" label={t('chat.importGitHub')} t={t} />
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
<ImportItem
icon="folder"
label={t('chat.importFolder')}
t={t}
enabled
onClick={() => void onLinkFolder()}
/>
<ImportItem icon="sparkles" label={t('chat.importSkills')} t={t} />
<ImportItem icon="file" label={t('chat.importProject')} t={t} />
</div>
);
}
function ToolsPetPanel({
t,
petConfig,
onTogglePet,
onAdoptPet,
onOpenPetSettings,
}: {
t: TranslateFn;
petConfig: AppConfig['pet'] | undefined;
onTogglePet: () => void;
onAdoptPet: (id: string) => void;
onOpenPetSettings: () => void;
}) {
return (
<div className="composer-tools-pet">
<div className="composer-tools-pet-head">
<span className="hint">{t('pet.composerMenuHint')}</span>
</div>
{petConfig?.adopted ? (
<button
type="button"
role="menuitem"
className="composer-tools-row composer-tools-row-toggle"
onClick={onTogglePet}
>
<Icon name={petConfig.enabled ? 'eye' : 'sparkles'} size={12} />
<span>{petConfig.enabled ? t('pet.tuck') : t('pet.wake')}</span>
</button>
) : null}
<div className="composer-tools-pet-grid">
{BUILT_IN_PETS.map((p) => {
const active = petConfig?.adopted && petConfig.petId === p.id;
return (
<button
type="button"
role="menuitem"
key={p.id}
className={`composer-tools-pet-item${active ? ' active' : ''}`}
onClick={() => onAdoptPet(p.id)}
style={{ ['--pet-accent' as string]: p.accent }}
title={p.flavor}
>
<span aria-hidden>{p.glyph}</span>
<span>{p.name}</span>
</button>
);
})}
</div>
<button
type="button"
role="menuitem"
className="composer-tools-row composer-tools-row-action"
onClick={onOpenPetSettings}
>
<Icon name="settings" size={12} />
<span>{t('pet.composerOpenSettings')}</span>
</button>
</div>
);
}
function ImportItem({
icon,
label,

View file

@ -86,6 +86,9 @@ interface Props {
// Composer settings/CLI button forwards to here. The dialog lives in App
// (it owns the AppConfig lifecycle) so we just pass the open trigger.
onOpenSettings?: () => void;
// Same dialog, but landing on the External MCP tab. Forwarded to the
// composer's `/mcp` slash and MCP picker button.
onOpenMcpSettings?: () => void;
// Optional pet wiring forwarded straight through to ChatComposer's
// /pet button. When omitted the composer hides the button entirely.
petConfig?: AppConfig['pet'];
@ -125,6 +128,7 @@ export function ChatPane({
onDeleteConversation,
onRenameConversation,
onOpenSettings,
onOpenMcpSettings,
petConfig,
onAdoptPet,
onTogglePet,
@ -506,6 +510,7 @@ export function ChatPane({
onSend={onSend}
onStop={onStop}
onOpenSettings={onOpenSettings}
onOpenMcpSettings={onOpenMcpSettings}
petConfig={petConfig}
onAdoptPet={onAdoptPet}
onTogglePet={onTogglePet}

File diff suppressed because it is too large Load diff

View file

@ -104,6 +104,7 @@ interface Props {
) => void;
onRefreshAgents: () => void;
onOpenSettings: () => void;
onOpenMcpSettings?: () => void;
// Pet wiring forwarded to the chat composer so users can adopt /
// wake / tuck a pet without leaving the project view.
onAdoptPetInline?: (petId: string) => void;
@ -224,6 +225,7 @@ export function ProjectView({
onAgentModelChange,
onRefreshAgents,
onOpenSettings,
onOpenMcpSettings,
onAdoptPetInline,
onTogglePet,
onOpenPetSettings,
@ -1851,6 +1853,7 @@ export function ProjectView({
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
onOpenSettings={onOpenSettings}
onOpenMcpSettings={onOpenMcpSettings}
petConfig={config.pet}
onAdoptPet={onAdoptPetInline}
onTogglePet={onTogglePet}

View file

@ -41,6 +41,7 @@ import { fetchConnectors, fetchSkills } from '../providers/registry';
import { MEDIA_PROVIDERS } from '../media/models';
import type { MediaProvider } from '../media/models';
import { PetSettings } from './pet/PetSettings';
import { McpClientSection } from './McpClientSection';
import { LibrarySection } from './LibrarySection';
import { ConnectorsBrowser } from './ConnectorsBrowser';
import {
@ -62,6 +63,7 @@ export type SettingsSection =
| 'composio'
| 'orbit'
| 'integrations'
| 'mcpClient'
| 'language'
| 'appearance'
| 'notifications'
@ -638,7 +640,9 @@ export function SettingsDialog({
ReadonlySet<string>
>(() => new Set());
const languageRef = useRef<HTMLDivElement | null>(null);
// Imperative handle for the External MCP section. The dialog footer Save
// routes through this when the MCP tab is active so the user can press the
// single Save button at the bottom instead of hunting for the inner one.
useEffect(() => {
setActiveSection(initialSection);
}, [initialSection]);
@ -1075,6 +1079,26 @@ export function SettingsDialog({
? CUSTOM_MODEL_SENTINEL
: cfg.model;
// Header title/subtitle follow the active sidebar section so the dialog
// header always reflects what the user is looking at, instead of being
// pinned to "Execution & model" copy that only described one of the
// 11 sections this dialog now hosts.
const sectionHeader: Record<SettingsSection, { title: string; subtitle: string }> = {
execution: { title: t('settings.title'), subtitle: t('settings.subtitle') },
media: { title: t('settings.mediaProviders'), subtitle: t('settings.mediaProvidersHint') },
composio: { title: t('connectors.title'), subtitle: t('connectors.subtitle') },
orbit: { title: t('settings.orbit.title'), subtitle: t('settings.orbit.lede') },
integrations: { title: t('settings.mcpServerTitle'), subtitle: t('settings.mcpServerHint') },
mcpClient: { title: t('settings.externalMcpTitle'), subtitle: t('settings.externalMcpHint') },
language: { title: t('settings.language'), subtitle: t('settings.languageHint') },
appearance: { title: t('settings.appearance'), subtitle: t('settings.appearanceHint') },
notifications: { title: t('settings.notifications'), subtitle: t('settings.notificationsHint') },
pet: { title: t('pet.title'), subtitle: t('pet.subtitle') },
library: { title: t('settings.library'), subtitle: t('settings.libraryHint') },
about: { title: t('settings.about'), subtitle: t('settings.aboutHint') },
};
const activeHeader = sectionHeader[activeSection];
return (
<div className="modal-backdrop" onClick={onClose}>
<div
@ -1162,8 +1186,8 @@ export function SettingsDialog({
) : (
<>
<span className="kicker">{t('settings.kicker')}</span>
<h2>{t('settings.title')}</h2>
<p className="subtitle">{t('settings.subtitle')}</p>
<h2>{activeHeader.title}</h2>
<p className="subtitle">{activeHeader.subtitle}</p>
</>
)}
</header>
@ -1221,8 +1245,19 @@ export function SettingsDialog({
>
<Icon name="link" size={18} />
<span>
<strong>MCP server</strong>
<small>Connect your coding agent</small>
<strong>{t('settings.mcpServerTitle')}</strong>
<small>{t('settings.mcpServerHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'mcpClient' ? ' active' : ''}`}
onClick={() => setActiveSection('mcpClient')}
>
<Icon name="sparkles" size={18} />
<span>
<strong>{t('settings.externalMcpTitle')}</strong>
<small>{t('settings.externalMcpHint')}</small>
</span>
</button>
<button
@ -1852,6 +1887,8 @@ export function SettingsDialog({
) : null}
{activeSection === 'integrations' ? <IntegrationsSection /> : null}
{activeSection === 'mcpClient' ? <McpClientSection /> : null}
{activeSection === 'composio' ? (
<ConnectorSection
cfg={cfg}

View file

@ -137,6 +137,10 @@ export const ar: Dict = {
'settings.mediaProviders': 'مزودو الوسائط',
'settings.mediaProvidersHint':
'مفاتيح API لإنشاء الصور والفيديو والصوت. تخزن محلياً وتزامن مع البرنامج الخفي المحلي.',
'settings.mcpServerTitle': 'خادم MCP',
'settings.mcpServerHint': 'كشف Open Design كخادم MCP لوكيل البرمجة الخاص بك.',
'settings.externalMcpTitle': 'MCP خارجي',
'settings.externalMcpHint': 'أضف أدوات MCP من خدمات خارجية (Higgsfield، GitHub، …).',
'settings.mediaProviderApiKey': 'مفتاح API',
'settings.mediaProviderBaseUrl': 'رابط القاعدة',
'settings.mediaProviderConfigured': 'تم التكوين',

View file

@ -137,6 +137,10 @@ export const de: Dict = {
'settings.mediaProviders': 'Medienanbieter',
'settings.mediaProvidersHint':
'API-Keys für Bild-, Video- und Audiogenerierung. Lokal gespeichert und mit dem lokalen Daemon synchronisiert.',
'settings.mcpServerTitle': 'MCP-Server',
'settings.mcpServerHint': 'Stelle Open Design als MCP-Server für deinen Coding-Agent bereit.',
'settings.externalMcpTitle': 'Externes MCP',
'settings.externalMcpHint': 'MCP-Tools aus externen Diensten hinzufügen (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API-Key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Konfiguriert',

View file

@ -135,6 +135,10 @@ export const en: Dict = {
'settings.mediaProviders': 'Media providers',
'settings.mediaProvidersHint':
'API keys for image, video and audio generation. Stored locally and synced to the local daemon.',
'settings.mcpServerTitle': 'MCP server',
'settings.mcpServerHint': 'Expose Open Design as an MCP server for your coding agent.',
'settings.externalMcpTitle': 'External MCP',
'settings.externalMcpHint': 'Add MCP tools from external services (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Configured',

View file

@ -137,6 +137,10 @@ export const esES: Dict = {
'settings.mediaProviders': 'Proveedores de medios',
'settings.mediaProvidersHint':
'Claves de API para generación de imagen, vídeo y audio. Se guardan localmente y se sincronizan con el daemon local.',
'settings.mcpServerTitle': 'Servidor MCP',
'settings.mcpServerHint': 'Expón Open Design como servidor MCP para tu agente de código.',
'settings.externalMcpTitle': 'MCP externo',
'settings.externalMcpHint': 'Añade herramientas MCP desde servicios externos (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'Clave de API',
'settings.mediaProviderBaseUrl': 'URL base',
'settings.mediaProviderConfigured': 'Configurado',

View file

@ -135,6 +135,10 @@ export const fa: Dict = {
'settings.mediaProviders': 'ارائه‌دهندگان رسانه',
'settings.mediaProvidersHint':
'کلیدهای API برای تولید تصویر، ویدئو و صدا. به صورت محلی ذخیره و با daemon محلی همگام می‌شود.',
'settings.mcpServerTitle': 'سرور MCP',
'settings.mcpServerHint': 'Open Design را به‌عنوان سرور MCP برای عامل برنامه‌نویسی خود در دسترس قرار دهید.',
'settings.externalMcpTitle': 'MCP خارجی',
'settings.externalMcpHint': 'افزودن ابزارهای MCP از سرویس‌های خارجی (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'کلید API',
'settings.mediaProviderBaseUrl': 'آدرس پایه',
'settings.mediaProviderConfigured': 'پیکربندی شده',

View file

@ -137,6 +137,10 @@ export const fr: Dict = {
'settings.mediaProviders': 'Fournisseurs de médias',
'settings.mediaProvidersHint':
'Clés API pour la génération d\'images, de vidéos et d\'audio. Stockées localement et synchronisées avec le daemon local.',
'settings.mcpServerTitle': 'Serveur MCP',
'settings.mcpServerHint': 'Exposez Open Design comme serveur MCP pour votre agent de code.',
'settings.externalMcpTitle': 'MCP externe',
'settings.externalMcpHint': 'Ajoutez des outils MCP depuis des services externes (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'Clé API',
'settings.mediaProviderBaseUrl': 'URL de base',
'settings.mediaProviderConfigured': 'Configuré',

View file

@ -137,6 +137,10 @@ export const hu: Dict = {
'settings.mediaProviders': 'Média-szolgáltatók',
'settings.mediaProvidersHint':
'API-kulcsok kép-, videó- és hanggeneráláshoz. Helyben tárolva, és a helyi daemonnal szinkronizálva.',
'settings.mcpServerTitle': 'MCP-szerver',
'settings.mcpServerHint': 'Tedd elérhetővé az Open Designt MCP-szerverként a kódügynököd számára.',
'settings.externalMcpTitle': 'Külső MCP',
'settings.externalMcpHint': 'Adj hozzá MCP-eszközöket külső szolgáltatásokból (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API-kulcs',
'settings.mediaProviderBaseUrl': 'Bázis URL',
'settings.mediaProviderConfigured': 'Beállítva',

View file

@ -133,6 +133,10 @@ export const id: Dict = {
'settings.modelCustomPlaceholder': 'mis. anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'Provider media',
'settings.mediaProvidersHint': 'API key untuk generasi gambar, video, dan audio. Disimpan lokal dan disinkronkan ke daemon lokal.',
'settings.mcpServerTitle': 'Server MCP',
'settings.mcpServerHint': 'Ekspos Open Design sebagai server MCP untuk agen koding Anda.',
'settings.externalMcpTitle': 'MCP eksternal',
'settings.externalMcpHint': 'Tambahkan alat MCP dari layanan eksternal (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Sudah diatur',

View file

@ -137,6 +137,10 @@ export const ja: Dict = {
'settings.mediaProviders': 'メディアプロバイダー',
'settings.mediaProvidersHint':
'画像・動画・音声生成のための API キー。ローカルに保存され、ローカルデーモンに同期されます。',
'settings.mcpServerTitle': 'MCP サーバー',
'settings.mcpServerHint': 'Open Design を MCP サーバーとしてコーディングエージェントに公開します。',
'settings.externalMcpTitle': '外部 MCP',
'settings.externalMcpHint': '外部サービスHiggsfield、GitHub など)の MCP ツールを追加します。',
'settings.mediaProviderApiKey': 'APIキー',
'settings.mediaProviderBaseUrl': 'ベース URL',
'settings.mediaProviderConfigured': '設定済み',

View file

@ -137,6 +137,10 @@ export const ko: Dict = {
'settings.mediaProviders': '미디어 프로바이더',
'settings.mediaProvidersHint':
'이미지, 비디오, 오디오 생성을 위한 API 키입니다. 로컬에 저장되며 로컬 데몬과 동기화됩니다.',
'settings.mcpServerTitle': 'MCP 서버',
'settings.mcpServerHint': 'Open Design을 코딩 에이전트용 MCP 서버로 노출합니다.',
'settings.externalMcpTitle': '외부 MCP',
'settings.externalMcpHint': '외부 서비스(Higgsfield, GitHub 등)의 MCP 도구를 추가합니다.',
'settings.mediaProviderApiKey': 'API 키',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': '설정됨',

View file

@ -137,6 +137,10 @@ export const pl: Dict = {
'settings.mediaProviders': 'Dostawcy multimediów',
'settings.mediaProvidersHint':
'Klucze API do generowania obrazów, wideo i dźwięku. Przechowywane lokalnie i synchronizowane z lokalnym daemonem.',
'settings.mcpServerTitle': 'Serwer MCP',
'settings.mcpServerHint': 'Udostępnij Open Design jako serwer MCP dla swojego agenta kodu.',
'settings.externalMcpTitle': 'Zewnętrzny MCP',
'settings.externalMcpHint': 'Dodaj narzędzia MCP z usług zewnętrznych (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'Klucz API',
'settings.mediaProviderBaseUrl': 'Bazowy URL',
'settings.mediaProviderConfigured': 'Skonfigurowano',

View file

@ -134,6 +134,10 @@ export const ptBR: Dict = {
'settings.modelCustomPlaceholder': 'ex.: anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'Provedores de mídia',
'settings.mediaProvidersHint': 'Chaves de API para geração de imagem, vídeo e áudio. Salvas localmente e sincronizadas com o daemon local.',
'settings.mcpServerTitle': 'Servidor MCP',
'settings.mcpServerHint': 'Exponha o Open Design como servidor MCP para o seu agente de código.',
'settings.externalMcpTitle': 'MCP externo',
'settings.externalMcpHint': 'Adicione ferramentas MCP de serviços externos (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': 'Configurado',

View file

@ -134,6 +134,10 @@ export const ru: Dict = {
'settings.modelCustomPlaceholder': 'например, anthropic/claude-sonnet-4-6',
'settings.mediaProviders': 'Медиа-провайдеры',
'settings.mediaProvidersHint': 'API-ключи для генерации изображений, видео и аудио. Хранятся локально и синхронизируются с локальным демоном.',
'settings.mcpServerTitle': 'MCP-сервер',
'settings.mcpServerHint': 'Откройте Open Design как MCP-сервер для вашего кодинг-агента.',
'settings.externalMcpTitle': 'Внешний MCP',
'settings.externalMcpHint': 'Добавьте MCP-инструменты из внешних сервисов (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API-ключ',
'settings.mediaProviderBaseUrl': 'Базовый URL',
'settings.mediaProviderConfigured': 'Настроено',

View file

@ -131,6 +131,10 @@ export const tr: Dict = {
'settings.mediaProviders': 'Medya sağlayıcıları',
'settings.mediaProvidersHint':
'Görsel, video ve ses oluşumu için API anahtarları. Yerel saklanır ve yerel arka plan servisiyle senkronize edilir.',
'settings.mcpServerTitle': 'MCP sunucusu',
'settings.mcpServerHint': 'Open Design\'ı kodlama ajanınız için MCP sunucusu olarak yayınlayın.',
'settings.externalMcpTitle': 'Dış MCP',
'settings.externalMcpHint': 'Dış servislerden (Higgsfield, GitHub, …) MCP araçları ekleyin.',
'settings.mediaProviderApiKey': 'API anahtarı',
'settings.mediaProviderBaseUrl': 'Temel URL',
'settings.mediaProviderConfigured': 'Ayarlandı',

View file

@ -136,6 +136,10 @@ export const uk: Dict = {
'settings.mediaProviders': 'Медіа-провайдери',
'settings.mediaProvidersHint':
'API-ключі для генерації зображень, відео та аудіо. Зберігаються локально та синхронізуються з локальним фоновим процесом.',
'settings.mcpServerTitle': 'MCP-сервер',
'settings.mcpServerHint': 'Відкрийте Open Design як MCP-сервер для вашого кодинг-агента.',
'settings.externalMcpTitle': 'Зовнішній MCP',
'settings.externalMcpHint': 'Додайте MCP-інструменти із зовнішніх сервісів (Higgsfield, GitHub, …).',
'settings.mediaProviderApiKey': 'API-ключ',
'settings.mediaProviderBaseUrl': 'Базовий URL',
'settings.mediaProviderConfigured': 'Налаштовано',

View file

@ -133,6 +133,10 @@ export const zhCN: Dict = {
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',
'settings.mediaProviders': '媒体生成提供商',
'settings.mediaProvidersHint': '图片、视频、音频生成的 API key。存于本机并同步到本地守护进程。',
'settings.mcpServerTitle': 'MCP 服务器',
'settings.mcpServerHint': '将 Open Design 作为 MCP 服务器暴露给你的编码代理。',
'settings.externalMcpTitle': '外部 MCP',
'settings.externalMcpHint': '接入外部服务的 MCP 工具Higgsfield、GitHub 等)。',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': '已配置',

View file

@ -133,6 +133,10 @@ export const zhTW: Dict = {
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',
'settings.mediaProviders': '媒體生成提供商',
'settings.mediaProvidersHint': '圖片、影片、音訊生成的 API key。存於本機並同步到本地守護程序。',
'settings.mcpServerTitle': 'MCP 伺服器',
'settings.mcpServerHint': '將 Open Design 作為 MCP 伺服器暴露給你的編碼代理。',
'settings.externalMcpTitle': '外部 MCP',
'settings.externalMcpHint': '接入外部服務的 MCP 工具Higgsfield、GitHub 等)。',
'settings.mediaProviderApiKey': 'API key',
'settings.mediaProviderBaseUrl': 'Base URL',
'settings.mediaProviderConfigured': '已設定',

View file

@ -156,6 +156,10 @@ export interface Dict {
'settings.modelCustomPlaceholder': string;
'settings.mediaProviders': string;
'settings.mediaProvidersHint': string;
'settings.mcpServerTitle': string;
'settings.mcpServerHint': string;
'settings.externalMcpTitle': string;
'settings.externalMcpHint': string;
'settings.mediaProviderApiKey': string;
'settings.mediaProviderBaseUrl': string;
'settings.mediaProviderConfigured': string;

View file

@ -8877,7 +8877,19 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
line-height: 1.5;
}
.prose-block .md-code code { font-family: var(--mono); }
.prose-block .md-link { color: var(--accent); text-decoration: underline; }
.prose-block .md-link {
color: var(--accent);
text-decoration: underline;
word-break: break-word;
overflow-wrap: anywhere;
}
.prose-block .md-link-bare {
/* Long bare URLs (OAuth flows etc.) need to wrap mid-string so they
don't escape the chat column. break-word is enough for most agents,
but `anywhere` covers query strings with no spaces. */
word-break: break-all;
overflow-wrap: anywhere;
}
.prose-block .md-hr {
border: none;
border-top: 1px solid var(--border);
@ -12511,6 +12523,231 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
border-right: 1px solid transparent;
}
/* ============================================================
Composer consolidated Tools popover
------------------------------------------------------------
The leading sliders icon now opens a single tabbed popover
containing MCP / Import / Pet quick actions plus a shortcut
to the full Settings dialog. Replaces three standalone row
buttons that were overflowing in narrow chats.
============================================================ */
.composer-tools-wrap {
position: relative;
display: inline-flex;
}
.composer-tools-trigger {
position: relative;
}
.composer-tools-trigger.active {
background: var(--bg-subtle);
color: var(--text);
}
.composer-tools-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: var(--accent);
color: white;
font-size: 9px;
font-weight: 600;
line-height: 14px;
text-align: center;
pointer-events: none;
}
.composer-tools-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
width: 320px;
max-width: calc(100vw - 32px);
z-index: 30;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.composer-tools-tabs {
display: flex;
align-items: stretch;
gap: 0;
padding: 4px;
border-bottom: 1px solid var(--border-soft);
background: var(--bg-subtle);
}
.composer-tools-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
font-size: 11.5px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
min-width: 0;
}
.composer-tools-tab:hover { color: var(--text); }
.composer-tools-tab.active {
background: var(--bg-panel);
color: var(--text);
box-shadow: var(--shadow-xs);
}
.composer-tools-tab-count {
font-size: 10px;
background: var(--accent);
color: white;
border-radius: 7px;
padding: 0 4px;
min-width: 14px;
height: 14px;
line-height: 14px;
text-align: center;
}
.composer-tools-tab-glyph {
font-size: 13px;
line-height: 1;
}
.composer-tools-content {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
max-height: 360px;
overflow-y: auto;
}
.composer-tools-empty {
padding: 14px 10px;
font-size: 12px;
color: var(--text-muted);
text-align: center;
line-height: 1.5;
}
.composer-tools-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.composer-tools-row {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
text-align: left;
font-size: 12px;
width: 100%;
}
.composer-tools-row:hover {
background: var(--bg-subtle);
color: var(--text);
}
.composer-tools-row-body {
display: inline-flex;
align-items: baseline;
gap: 6px;
flex: 1;
min-width: 0;
}
.composer-tools-row-body strong {
font-weight: 500;
font-size: 12px;
}
.composer-tools-row-meta {
font-size: 10.5px;
color: var(--text-faint);
text-transform: lowercase;
}
.composer-tools-row-action {
border-top: 1px solid var(--border-soft);
border-radius: 0;
margin-top: 4px;
padding-top: 8px;
color: var(--text-muted);
}
.composer-tools-row-action:hover { color: var(--text); }
.composer-tools-row-toggle {
background: var(--bg-subtle);
border-color: var(--border);
}
.composer-tools-pet {
display: flex;
flex-direction: column;
gap: 6px;
}
.composer-tools-pet-head {
padding: 0 4px 4px;
}
.composer-tools-pet-head .hint {
font-size: 11px;
color: var(--text-muted);
}
.composer-tools-pet-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 0 2px;
}
.composer-tools-pet-item {
--pet-accent: var(--accent);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 2px;
background: transparent;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 10.5px;
cursor: pointer;
}
.composer-tools-pet-item:hover {
border-color: var(--border-strong);
color: var(--text);
}
.composer-tools-pet-item.active {
border-color: var(--pet-accent);
background: color-mix(in srgb, var(--pet-accent) 12%, transparent);
color: var(--text);
}
.composer-tools-pet-item span:first-child {
font-size: 18px;
line-height: 1;
}
.composer-tools-settings {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
background: transparent;
border: none;
border-top: 1px solid var(--border-soft);
border-radius: 0;
color: var(--text);
cursor: pointer;
font-size: 12px;
text-align: left;
width: 100%;
}
.composer-tools-settings:hover {
background: var(--bg-subtle);
}
/* ============================================================
Pet uploaded image / spritesheet rendering
------------------------------------------------------------
@ -13783,3 +14020,588 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
font-size: 11px;
white-space: pre-wrap;
}
/* ============================================================
External MCP servers Settings panel
------------------------------------------------------------
Compact card-per-server layout. Inputs stack vertically inside
each card so the narrow settings column (~440px after sidebar)
doesn't crush the inner grid.
============================================================ */
.mcp-add-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
white-space: nowrap;
flex-shrink: 0;
}
.mcp-picker {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--bg-subtle);
}
.mcp-picker-head {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 10px;
}
.mcp-picker-head strong { font-size: 12.5px; }
.mcp-picker-grid {
display: grid;
gap: 6px;
}
.mcp-picker-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 8px 10px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
text-align: left;
}
.mcp-picker-item:hover {
border-color: var(--border-strong);
background: var(--bg);
}
/* The clickable region of a template card. Split out from the wrapper so a
sibling homepage `<a>` can sit next to it without nesting interactives. */
.mcp-picker-item-action {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
background: transparent;
border: 0;
padding: 0;
text-align: left;
cursor: pointer;
width: 100%;
color: inherit;
}
.mcp-picker-item-head {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--text);
width: 100%;
}
.mcp-picker-transport {
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 1px 6px;
margin-left: auto;
}
.mcp-picker-desc {
font-size: 11.5px;
color: var(--text-muted);
line-height: 1.45;
}
.mcp-picker-example {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
font-size: 11px;
line-height: 1.4;
color: var(--text-muted);
margin-top: 2px;
}
.mcp-picker-example-label {
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
align-self: center;
}
.mcp-picker-example-text {
font-style: italic;
color: var(--text);
}
.mcp-picker-homepage {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
font-size: 11px;
color: var(--text-muted);
text-decoration: none;
border-top: 1px dashed var(--border);
padding-top: 6px;
}
.mcp-picker-homepage:hover {
color: var(--accent, var(--text));
text-decoration: underline;
}
/* ── Picker grouping (added when the catalog crossed ~12 templates) ─── */
/* Sticky-header row inside `.mcp-picker-head` so the close affordance is
always visible even after the user scrolls into a long category. */
.mcp-picker-head-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mcp-picker-close {
width: 22px;
height: 22px;
font-size: 14px;
line-height: 1;
border-radius: var(--radius-sm);
}
.mcp-picker-search {
margin-top: 6px;
padding: 6px 8px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text);
}
.mcp-picker-search:focus {
outline: none;
border-color: var(--border-strong, var(--text-faint));
}
/* Cap the group region so 17+ templates do not push the picker into a
2000px tall block. The footer (custom-server card) sits OUTSIDE this
scroll region so it is always reachable. */
.mcp-picker-groups {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 60vh;
overflow-y: auto;
padding-right: 2px;
}
.mcp-picker-group {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.mcp-picker-group[open] {
background: var(--bg);
}
.mcp-picker-group-summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 7px 10px;
font-size: 12px;
color: var(--text);
user-select: none;
list-style: none;
}
.mcp-picker-group-summary::-webkit-details-marker { display: none; }
.mcp-picker-group-summary::before {
content: '▸';
display: inline-block;
width: 0.9em;
flex-shrink: 0;
color: var(--text-faint);
font-size: 10px;
transition: transform 120ms ease;
}
.mcp-picker-group[open] > .mcp-picker-group-summary::before {
transform: rotate(90deg);
}
.mcp-picker-group-summary:hover {
background: var(--bg-subtle);
}
.mcp-picker-group-summary-title {
font-weight: 600;
}
.mcp-picker-group-summary-count {
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 1px 6px;
}
.mcp-picker-group-summary-hint {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
text-align: right;
/* Hide the per-group hint on narrow widths so the count + title stay
readable. The hint is supplementary so dropping it is acceptable. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
@media (max-width: 480px) {
.mcp-picker-group-summary-hint { display: none; }
}
.mcp-picker-group .mcp-picker-grid {
padding: 4px 8px 10px;
}
.mcp-picker-empty {
padding: 12px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
font-size: 12px;
}
.mcp-picker-foot {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.mcp-picker-custom {
/* Visual nudge so the custom-server card reads as a footer affordance,
not just another template card. */
border-style: dashed !important;
}
.mcp-error {
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--danger, #d54) 50%, transparent);
background: color-mix(in srgb, var(--danger, #d54) 10%, transparent);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--danger, #d54);
}
.mcp-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.mcp-row {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.mcp-row-disabled { opacity: 0.55; }
/* Template-derived hint shown on saved rows that came from a built-in
preset (filesystem, github, fetch, higgsfield). Default-collapsed
`<details>`; the summary line is the only thing visible until the user
clicks. Keeps long descriptions out of the way but still discoverable. */
.mcp-row-info {
border-left: 2px solid var(--border-strong, var(--border));
background: var(--bg-subtle);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.mcp-row-info-summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 5px 8px;
font-size: 11.5px;
color: var(--text-muted);
user-select: none;
list-style: none;
}
.mcp-row-info-summary::-webkit-details-marker { display: none; }
.mcp-row-info-summary::before {
content: '▸';
display: inline-block;
width: 0.9em;
flex-shrink: 0;
transition: transform 120ms ease;
color: var(--text-faint);
font-size: 10px;
}
.mcp-row-info[open] > .mcp-row-info-summary::before {
transform: rotate(90deg);
}
.mcp-row-info-summary:hover {
color: var(--text);
}
.mcp-row-info-summary-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcp-row-info-body {
padding: 0 8px 8px 22px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mcp-row-info-desc {
margin: 0;
font-size: 11.5px;
line-height: 1.45;
}
.mcp-row-info-example {
margin: 0;
font-size: 11px;
line-height: 1.45;
color: var(--text-muted);
overflow-wrap: anywhere;
}
.mcp-row-info-example-label {
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.mcp-row-info-example-text {
font-style: italic;
color: var(--text);
}
.mcp-row-info-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
text-decoration: none;
flex-shrink: 0;
font-size: 11px;
}
.mcp-row-info-link:hover {
color: var(--accent, var(--text));
text-decoration: underline;
}
.mcp-row-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.mcp-row-toggle {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.mcp-row-toggle input { width: auto; }
.mcp-row-label {
flex: 1;
min-width: 0;
padding: 5px 8px;
font-size: 12px;
}
.mcp-row-counter {
flex-shrink: 0;
font-size: 11px;
white-space: nowrap;
}
.mcp-row-actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.mcp-row-actions .icon-btn {
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.mcp-row-actions .icon-btn:hover {
background: var(--bg-subtle);
border-color: var(--border);
color: var(--text);
}
/* The expand/collapse chevron at the end of the row header. Uses a single
`chevron-down` icon and rotates it 180° when the row is open so the
element stays visually anchored to the same DOM node. */
.mcp-row-toggle-btn svg {
transition: transform 120ms ease;
}
.mcp-row-expanded .mcp-row-toggle-btn svg {
transform: rotate(180deg);
}
/* Read-only "title bar" shown when a row is collapsed. Acts as a click
target that expands the row, so the user doesn't have to aim for the
tiny chevron at the end of the row. */
.mcp-row-summary-title {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
.mcp-row-summary-title:hover {
background: var(--bg-subtle);
border-color: var(--border);
}
.mcp-row-summary-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text);
}
.mcp-row-summary-transport {
flex-shrink: 0;
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 1px 6px;
}
.mcp-row-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 8px;
}
@media (max-width: 720px) {
.mcp-row-grid { grid-template-columns: 1fr; }
}
.mcp-row-field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.mcp-row-field-label {
font-size: 10.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-faint);
}
.mcp-row-field input,
.mcp-row-field select,
.mcp-row-field textarea {
width: 100%;
font-size: 12px;
padding: 5px 8px;
font-family: var(--mono);
}
.mcp-row-field textarea {
font-family: var(--mono);
resize: vertical;
min-height: 38px;
}
.mcp-row-field-stack { width: 100%; }
.mcp-foot {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
flex-wrap: wrap;
}
.mcp-foot-spacer { flex: 1; }
.mcp-saved-msg { color: var(--success, #6c6); }
/* Per-row OAuth Connect/Disconnect control. Sits between the row header
* and the field grid so the connection state is the FIRST thing the user
* sees on a row that needs auth. */
.mcp-oauth-control {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
}
.mcp-oauth-control.connected {
border-color: color-mix(in srgb, var(--success, #2da44e) 60%, var(--border));
background: color-mix(in srgb, var(--success, #2da44e) 8%, var(--bg-subtle));
}
.mcp-oauth-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 1.4;
}
.mcp-oauth-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-faint);
}
.mcp-oauth-dot-ok {
background: var(--success, #2da44e);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--success, #2da44e) 25%, transparent);
}
.mcp-oauth-dot-pending {
background: var(--accent);
animation: mcp-oauth-pulse 1.2s ease-in-out infinite;
}
@keyframes mcp-oauth-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.mcp-oauth-actions {
display: inline-flex;
gap: 6px;
}
.mcp-oauth-actions button {
padding: 5px 12px;
font-size: 12px;
}
.mcp-oauth-error {
font-size: 11.5px;
padding: 6px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--danger, #d54) 14%, transparent);
color: var(--danger, #d54);
white-space: pre-wrap;
word-break: break-all;
}
.mcp-oauth-hint {
padding: 6px 10px;
border-radius: var(--radius-sm);
background: var(--bg-subtle);
font-size: 11.5px;
}
.mcp-oauth-fallback {
font-size: 11.5px;
line-height: 1.5;
}
.mcp-oauth-fallback .md-link {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.mcp-oauth-fallback .md-link:hover {
text-decoration-thickness: 2px;
}

View file

@ -152,10 +152,16 @@ function renderBlock(block: Block, key: number): ReactNode {
// span (which itself still gets autolink scanning).
function renderInline(text: string): ReactNode {
const out: ReactNode[] = [];
// Order matters: inline code first so its contents are not re-tokenized
// as bold/italic.
// Order matters:
// 1. inline code first so its contents are not re-tokenized as bold/italic.
// 2. explicit `[text](url)` markdown links before bare URL autolink so the
// autolink does not greedily swallow the closing paren.
// 3. bare http(s) URL autolink BEFORE italic markers — chat output often
// contains OAuth-style links with `_type=` / `_id=` query params, and
// leaving italic to win turns the URL into an italic-fragmented mess.
// 4. bold (**a** / __a__) before italic (*a* / _a_).
const re =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\n]+\*)|(_[^_\n]+_)|\[([^\]]+)\]\(([^)\s]+)\)/g;
/(`[^`]+`)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s)<>]+)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\n]+\*)|(_[^_\n]+_)/g;
let lastIndex = 0;
let m: RegExpExecArray | null;
let key = 0;
@ -169,26 +175,40 @@ function renderInline(text: string): ReactNode {
{m[1].slice(1, -1)}
</code>,
);
} else if (m[2]) {
out.push(<strong key={key++}>{m[2].slice(2, -2)}</strong>);
} else if (m[3]) {
out.push(<strong key={key++}>{m[3].slice(2, -2)}</strong>);
} else if (m[4]) {
out.push(<em key={key++}>{m[4].slice(1, -1)}</em>);
} else if (m[5]) {
out.push(<em key={key++}>{m[5].slice(1, -1)}</em>);
} else if (m[6] && m[7]) {
} else if (m[2] && m[3]) {
out.push(
<a
key={key++}
className="md-link"
href={m[7]}
href={m[3]}
target="_blank"
rel="noreferrer noopener"
>
{m[6]}
{m[2]}
</a>,
);
} else if (m[4]) {
// Bare URL — autolink with the URL as both href and visible text,
// matching the Markdown `<https://…>` autolink convention.
out.push(
<a
key={key++}
className="md-link md-link-bare"
href={m[4]}
target="_blank"
rel="noreferrer noopener"
>
{m[4]}
</a>,
);
} else if (m[5]) {
out.push(<strong key={key++}>{m[5].slice(2, -2)}</strong>);
} else if (m[6]) {
out.push(<strong key={key++}>{m[6].slice(2, -2)}</strong>);
} else if (m[7]) {
out.push(<em key={key++}>{m[7].slice(1, -1)}</em>);
} else if (m[8]) {
out.push(<em key={key++}>{m[8].slice(1, -1)}</em>);
}
lastIndex = re.lastIndex;
}

181
apps/web/src/state/mcp.ts Normal file
View file

@ -0,0 +1,181 @@
// Web client for the daemon's external-MCP endpoints.
//
// `GET /api/mcp/servers` returns both the user's saved entries AND the
// built-in template list, so the Settings panel hydrates with one round-trip.
// `PUT /api/mcp/servers` replaces the whole list — same pattern the media
// providers PUT uses (the daemon takes the full set rather than merging).
import type {
McpOAuthStatusResponse,
McpServerConfig,
McpServersResponse,
McpTemplate,
StartMcpOAuthResponse,
} from '@open-design/contracts';
export type {
McpOAuthStatusResponse,
McpServerConfig,
McpTemplate,
StartMcpOAuthResponse,
};
export async function fetchMcpServers(): Promise<McpServersResponse | null> {
try {
const res = await fetch('/api/mcp/servers');
if (!res.ok) return null;
const data = (await res.json()) as McpServersResponse;
return {
servers: Array.isArray(data?.servers) ? data.servers : [],
templates: Array.isArray(data?.templates) ? data.templates : [],
};
} catch {
return null;
}
}
export async function saveMcpServers(
servers: McpServerConfig[],
): Promise<McpServersResponse | null> {
try {
const res = await fetch('/api/mcp/servers', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers }),
});
if (!res.ok) return null;
const data = (await res.json()) as McpServersResponse;
return {
servers: Array.isArray(data?.servers) ? data.servers : [],
templates: Array.isArray(data?.templates) ? data.templates : [],
};
} catch {
return null;
}
}
/**
* Result of `startMcpOAuth`. Either a usable response, or a structured
* error containing the real HTTP status / body we got back so the UI can
* surface a useful message instead of a generic "could not connect".
*/
export type StartMcpOAuthResult =
| { ok: true; response: StartMcpOAuthResponse }
| { ok: false; status: number | null; message: string };
/**
* Kick off the daemon-owned OAuth dance for a saved HTTP/SSE server.
*
* Returns a structured result so the UI can show why the daemon refused
* (most useful when the daemon is older than the web client and the
* `/api/mcp/oauth/start` route 404s, or when the upstream provider's
* discovery / DCR endpoint failed).
*/
export async function startMcpOAuth(
serverId: string,
): Promise<StartMcpOAuthResult> {
let res: Response;
try {
res = await fetch('/api/mcp/oauth/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ serverId }),
});
} catch (err) {
return {
ok: false,
status: null,
message:
err instanceof Error
? `Network error: ${err.message}`
: 'Network error reaching the daemon.',
};
}
if (!res.ok) {
let detail = '';
try {
const body = await res.text();
// Try to pull a typed error message out of `{ error: '...' }` payloads.
try {
const parsed = JSON.parse(body);
if (parsed && typeof parsed.error === 'string') detail = parsed.error;
} catch {
detail = body.slice(0, 240);
}
} catch {
// ignore
}
if (res.status === 404) {
return {
ok: false,
status: 404,
message:
'Daemon does not know about /api/mcp/oauth/start (it may be running an older build). Restart the daemon (`pnpm tools-dev restart` or equivalent) and try again.',
};
}
return {
ok: false,
status: res.status,
message:
detail ||
`Daemon returned HTTP ${res.status} ${res.statusText}. Check the daemon log for details.`,
};
}
try {
const response = (await res.json()) as StartMcpOAuthResponse;
return { ok: true, response };
} catch (err) {
return {
ok: false,
status: res.status,
message: 'Daemon returned a 200 with an unparseable body.',
};
}
}
export async function fetchMcpOAuthStatus(
serverId: string,
): Promise<McpOAuthStatusResponse | null> {
try {
const url = `/api/mcp/oauth/status?serverId=${encodeURIComponent(serverId)}`;
const res = await fetch(url);
if (!res.ok) return null;
return (await res.json()) as McpOAuthStatusResponse;
} catch {
return null;
}
}
export async function disconnectMcpOAuth(serverId: string): Promise<boolean> {
try {
const res = await fetch('/api/mcp/oauth/disconnect', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ serverId }),
});
return res.ok;
} catch {
return false;
}
}
/** Generate a unique stable id from a label (lowercase, slug). Falls back to
* a short random suffix so duplicates of the same template still land at
* distinct ids. */
export function suggestMcpServerId(
label: string,
taken: ReadonlySet<string>,
): string {
const base =
label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'mcp-server';
if (!taken.has(base)) return base;
for (let i = 2; i < 1000; i++) {
const next = `${base}-${i}`;
if (!taken.has(next)) return next;
}
return `${base}-${Math.random().toString(36).slice(2, 6)}`;
}

View file

@ -1041,7 +1041,7 @@ describe('SettingsDialog MCP server interactions', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/mcp/install-info');
});
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'MCP server' })).toBeTruthy();
expect(screen.getByRole('heading', { level: 3, name: 'MCP server' })).toBeTruthy();
});
expect(screen.getByText(/Run this in your terminal/i)).toBeTruthy();
@ -1700,7 +1700,7 @@ describe('SettingsDialog about interactions', () => {
},
);
expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy();
expect(screen.getByRole('heading', { level: 3, name: 'About' })).toBeTruthy();
expect(screen.getByText('Version')).toBeTruthy();
expect(screen.getByText('0.4.1')).toBeTruthy();
expect(screen.getByText('Channel')).toBeTruthy();

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { renderMarkdown } from '../../src/runtime/markdown';
function html(input: string): string {
return renderToStaticMarkup(<>{renderMarkdown(input)}</>);
}
describe('renderMarkdown', () => {
it('autolinks bare https URLs without breaking on underscores in query params', () => {
// OAuth-style URL with underscores in `response_type`, `client_id`,
// `code_challenge`, `code_challenge_method`. The previous renderer
// greedily matched `_..._` as italic and shredded the URL into pieces.
const url =
'https://mcp.higgsfield.ai/oauth2/authorize?response_type=code&client_id=abc&code_challenge=xyz&code_challenge_method=S256';
// HTML attribute encoding swaps `&` for `&amp;` — compare against the
// encoded form rather than the raw URL we passed in.
const encoded = url.replace(/&/g, '&amp;');
const out = html(`Open this link: ${url}`);
expect(out).toContain(`href="${encoded}"`);
expect(out).toContain(`>${encoded}</a>`);
// The italic <em> tag should NOT have been emitted from the URL fragments.
expect(out).not.toContain('<em>');
});
it('keeps italic working in regular prose', () => {
const out = html('A word with _emphasis_ here.');
expect(out).toContain('<em>emphasis</em>');
});
it('renders explicit [text](url) markdown links', () => {
const out = html('Click [here](https://example.com/page) to continue.');
expect(out).toContain('<a class="md-link"');
expect(out).toContain('href="https://example.com/page"');
expect(out).toContain('>here</a>');
});
it('marks bare URLs with the bare-link class so CSS can break them mid-string', () => {
const out = html('See https://example.com/very/long/path?with=long&query=string');
expect(out).toContain('md-link-bare');
});
it('does not autolink inside inline code spans', () => {
const out = html('Use `https://example.com/x` literally.');
// The URL should appear inside a <code> tag, not turned into an anchor.
expect(out).toContain('<code class="md-inline-code">https://example.com/x</code>');
});
});

View file

@ -162,7 +162,9 @@ test('live artifact empty connector CTA opens the gated connector setup path', a
await page.getByTestId('new-project-connectors-empty').click();
const settingsDialog = page.getByRole('dialog');
await expect(settingsDialog).toBeVisible();
await expect(settingsDialog.getByRole('heading', { name: 'Connectors' })).toBeVisible();
await expect(
settingsDialog.getByRole('heading', { level: 3, name: 'Connectors' }),
).toBeVisible();
await expect(settingsDialog.getByPlaceholder('Paste Composio API key')).toBeVisible();
await expect(settingsDialog.getByTestId('connector-gate')).toBeVisible();
await expect(settingsDialog.getByTestId('connectors-search-input')).toBeDisabled();

View file

@ -349,7 +349,7 @@ test('change pet opens pet settings and updates the custom companion draft', asy
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', { name: 'Pets' })).toBeVisible();
await expect(dialog.getByRole('heading', { level: 3, name: 'Pets' })).toBeVisible();
await dialog.getByRole('tab', { name: 'Custom' }).click();
const customPanel = dialog.locator('.pet-custom');

View file

@ -0,0 +1,159 @@
// External MCP (Model Context Protocol) server configuration.
//
// Open Design acts as an MCP CLIENT here: the user configures one or more
// external MCP servers (stdio, SSE, or streamable HTTP), and the daemon
// surfaces those servers to the underlying agent (Claude Code, ACP agents,
// etc.) at spawn time so the agent can call their tools.
//
// This file is the wire-level shape between the web UI and the daemon. The
// daemon persists the same shape to <dataDir>/mcp-config.json and rewrites
// per-spawn config files (e.g. project-cwd `.mcp.json` for Claude Code).
export type McpTransport = 'stdio' | 'sse' | 'http';
export interface McpServerConfig {
/** Stable slug (lowercase, alphanumeric + dash/underscore). Doubles as the
* MCP server name passed to agents. */
id: string;
/** Optional human label shown in the UI. Falls back to `id`. */
label?: string;
/** Optional template id this entry was instantiated from. Lets the UI
* render the template's logo/help text without re-deriving from the URL. */
templateId?: string;
/** Transport selector. `http` is "streamable HTTP" per MCP spec; `sse` is
* the older Server-Sent-Events variant some servers (Higgsfield) still
* publish. Both flow through the same upstream URL field. */
transport: McpTransport;
/** Master enable switch. Disabled entries are persisted but skipped at
* spawn so users can keep credentials around without them being wired into
* every run. */
enabled: boolean;
// ── stdio ──
command?: string;
args?: string[];
env?: Record<string, string>;
// ── sse / http ──
url?: string;
headers?: Record<string, string>;
}
export interface McpConfig {
servers: McpServerConfig[];
}
/** An optional environment variable / header field a template needs the user
* to supply before the server can start. The UI renders these as inputs. */
export interface McpTemplateField {
key: string;
label?: string;
required?: boolean;
placeholder?: string;
/** Render the value with a password-style input (api keys, tokens). */
secret?: boolean;
}
/** Coarse-grained category used to group templates in the picker UI so the
* 30+ built-in entries stay scannable. Stable string union adding a new
* category requires a matching entry in `CATEGORY_ORDER` on the web side
* so the group has a label / display order. */
export type McpTemplateCategory =
| 'image-generation'
| 'image-editing'
| 'web-capture'
| 'design-systems'
| 'ui-components'
| 'data-viz'
| 'publishing'
| 'utilities';
/** A built-in MCP server preset surfaced in the Settings UI's "Add MCP
* server" picker. Selecting one fills in the form with defaults; the
* resulting `McpServerConfig` is saved like any custom entry. */
export interface McpTemplate {
id: string;
label: string;
description: string;
transport: McpTransport;
/** Picker grouping. Required so the UI can always find a home for the
* template fall back to `utilities` for true grab-bag entries. */
category: McpTemplateCategory;
/** Marketing-grade homepage / docs URL. Optional. */
homepage?: string;
/** A one-liner the user can paste into the chat composer to try this MCP
* server end-to-end. Surfaced in the Settings UI both inside the picker
* and inline on each saved row, so the user always has at least one
* concrete idea of what tools this server unlocks. Optional. */
example?: string;
// stdio template defaults
command?: string;
args?: string[];
envFields?: McpTemplateField[];
// sse / http template defaults
url?: string;
headerFields?: McpTemplateField[];
}
export interface McpServersResponse {
servers: McpServerConfig[];
templates: McpTemplate[];
}
export interface UpdateMcpServersRequest {
servers: McpServerConfig[];
}
// ─────────────────────────────────────────────────────────────────────
// Daemon-owned OAuth flow for HTTP / SSE MCP servers.
//
// The daemon hosts the OAuth client end-to-end so cloud deployments work
// without a transient `localhost:<port>` listener and so the issued token
// survives across agent turns. Tokens are persisted server-side and are
// injected as `Authorization: Bearer …` headers into the per-spawn
// `.mcp.json` the daemon writes for Claude Code.
// ─────────────────────────────────────────────────────────────────────
/** Body for `POST /api/mcp/oauth/start`. */
export interface StartMcpOAuthRequest {
/** id of an already-saved McpServerConfig (transport must be http or sse). */
serverId: string;
}
/** Response from `POST /api/mcp/oauth/start`. The web UI should
* `window.open(authorizeUrl, '_blank', 'noopener,noreferrer=no')` so the
* provider's auth page opens in a new tab; the callback HTML then
* `postMessage`s the result back to the opener. */
export interface StartMcpOAuthResponse {
authorizeUrl: string;
/** Echoed back so the UI can correlate with the postMessage payload. */
state: string;
/** The exact `redirect_uri` the daemon registered with the provider
* useful for diagnosing redirect-mismatch errors. */
redirectUri: string;
}
/** Response from `GET /api/mcp/oauth/status?serverId=…`. */
export interface McpOAuthStatusResponse {
connected: boolean;
/** Epoch ms when the access token expires. `null` when the provider
* issued a non-expiring token. Absent when not connected. */
expiresAt?: number | null;
/** Space-separated scopes the issued token is good for. */
scope?: string | null;
/** Epoch ms when the token was first persisted. */
savedAt?: number;
}
/** Body for `POST /api/mcp/oauth/disconnect`. */
export interface DisconnectMcpOAuthRequest {
serverId: string;
}
/** Shape of the `postMessage` payload the OAuth callback page emits to
* its opener (and broadcasts on the `open-design-mcp-oauth` channel). */
export type McpOAuthPostMessage =
| { type: 'mcp-oauth'; ok: true; serverId: string | null }
| { type: 'mcp-oauth'; ok: false; message: string | null };

View file

@ -9,6 +9,7 @@ export * from './api/comments';
export * from './api/connectionTest';
export * from './api/files';
export * from './api/live-artifacts';
export * from './api/mcp';
export * from './api/projects';
export * from './api/proxy';
export * from './api/registry';