mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
2b5ea36f21
commit
d592f6087f
44 changed files with 7547 additions and 392 deletions
1041
apps/daemon/src/mcp-config.ts
Normal file
1041
apps/daemon/src/mcp-config.ts
Normal file
File diff suppressed because it is too large
Load diff
601
apps/daemon/src/mcp-oauth.ts
Normal file
601
apps/daemon/src/mcp-oauth.ts
Normal 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: 43–128 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 };
|
||||
}
|
||||
258
apps/daemon/src/mcp-tokens.ts
Normal file
258
apps/daemon/src/mcp-tokens.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
849
apps/daemon/tests/mcp-config.test.ts
Normal file
849
apps/daemon/tests/mcp-config.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
529
apps/daemon/tests/mcp-oauth.test.ts
Normal file
529
apps/daemon/tests/mcp-oauth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
233
apps/daemon/tests/mcp-spawn.test.ts
Normal file
233
apps/daemon/tests/mcp-spawn.test.ts
Normal 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);
|
||||
});
|
||||
212
apps/daemon/tests/mcp-tokens.test.ts
Normal file
212
apps/daemon/tests/mcp-tokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
1152
apps/web/src/components/McpClientSection.tsx
Normal file
1152
apps/web/src/components/McpClientSection.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': 'تم التكوين',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'پیکربندی شده',
|
||||
|
|
|
|||
|
|
@ -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é',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '設定済み',
|
||||
|
|
|
|||
|
|
@ -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': '설정됨',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Настроено',
|
||||
|
|
|
|||
|
|
@ -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ı',
|
||||
|
|
|
|||
|
|
@ -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': 'Налаштовано',
|
||||
|
|
|
|||
|
|
@ -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': '已配置',
|
||||
|
|
|
|||
|
|
@ -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': '已設定',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
181
apps/web/src/state/mcp.ts
Normal 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)}`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
48
apps/web/tests/runtime/markdown.test.tsx
Normal file
48
apps/web/tests/runtime/markdown.test.tsx
Normal 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 `&` — compare against the
|
||||
// encoded form rather than the raw URL we passed in.
|
||||
const encoded = url.replace(/&/g, '&');
|
||||
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>');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
159
packages/contracts/src/api/mcp.ts
Normal file
159
packages/contracts/src/api/mcp.ts
Normal 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 };
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue