Files
open-design/apps/daemon/tests/mcp-spawn.test.ts
d592f6087f 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>
2026-05-08 17:59:20 +08:00

234 lines
8.3 KiB
TypeScript

// 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);
});