mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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>
529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|