From df4e9f72e3144f1f9455d78978ff5768023448ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 12:33:41 +0000 Subject: [PATCH 1/2] Make MCP OAuth plug-and-play across clients - Add path-aware RFC 9728/8414 discovery routes (/.well-known/ oauth-protected-resource/api/mcp + authorization-server) to fix the 404 that aborted OAuth in modern Claude/Cursor/Codex/VS Code builds - Centralize discovery metadata + CORS in mcp-oauth.server.ts - /api/mcp now sends CORS, an OPTIONS handler, and exposes the WWW-Authenticate challenge so web connectors can discover OAuth - CLI: super-agent connect/login/status/logout/setup + a local stdio<->HTTP bridge that injects and refreshes the OAuth token - Document the OAuth + CLI flow on /connect and /docs/mcp with ready-to-paste connection prompts https://claude.ai/code/session_01QjRrzVxTXxMAwWsU65Avaf --- cli/README.md | 54 +- cli/package.json | 4 +- cli/super-agent.mjs | 554 ++++++++++++++++-- src/lib/oauth/mcp-oauth.server.ts | 53 +- ...nown.oauth-authorization-server.api.mcp.ts | 18 + ....]well-known.oauth-authorization-server.ts | 35 +- ...-known.oauth-protected-resource.api.mcp.ts | 19 + .../[.]well-known.oauth-protected-resource.ts | 29 +- src/routes/api/mcp.ts | 26 +- ....]well-known.oauth-authorization-server.ts | 30 +- .../[.]well-known.oauth-protected-resource.ts | 24 +- src/routes/connect.tsx | 59 ++ src/routes/docs.mcp.tsx | 74 ++- 13 files changed, 824 insertions(+), 155 deletions(-) create mode 100644 src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts create mode 100644 src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts diff --git a/cli/README.md b/cli/README.md index df89e19e..1c63f3f3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,8 +1,45 @@ # super-agent -One-line installer for Super Agent Skill packages. Drops the right files -into your project so Claude Code, Cursor, Continue, or Cline picks the -skill up automatically. +Plug-and-play CLI for Super Agent Skill: install skill packages locally **and** +get a fully authenticated MCP connection in one command. + +## Connect an MCP client (OAuth, zero JSON editing) + +```bash +npx super-agent connect --client claude-code +# logs in via your browser (loopback PKCE), then wires the client for you +``` + +`connect` runs the full OAuth flow (dynamic client registration → PKCE → +browser consent → token exchange), stores the token in +`~/.superagentskill/credentials.json` (chmod 600), and writes/patches the +target client's MCP config so even the auth-gated write tools work. + +Supported clients: `claude-code`, `claude` (Desktop), `cursor`, `codex`, +`vscode`, `windsurf`. + +```bash +npx super-agent login # OAuth only +npx super-agent status # show login state / token expiry +npx super-agent logout # revoke + forget the token +npx super-agent setup cursor # (re)write a client config from saved creds +npx super-agent mcp # local stdio <-> remote HTTP MCP bridge +``` + +### The local bridge + +`super-agent mcp` is a stdio MCP server that proxies to +`https://superagentskill.com/api/mcp`, injecting your OAuth token and +auto-refreshing it. Point any stdio-only client at it: + +```jsonc +{ "mcpServers": { "super-agent-skill": { "command": "npx", "args": ["-y", "super-agent", "mcp"] } } } +``` + +This is what makes the connection truly plug and play for clients whose MCP +OAuth support is flaky or absent — auth is handled entirely by the CLI. + +## Install skill packages ```bash npx super-agent install code-reviewer @@ -10,11 +47,7 @@ npx super-agent install code-reviewer # → .cursor/rules/code-reviewer.mdc # → .continue/skills/code-reviewer.md # → .cline/skills/code-reviewer.md -``` -## Commands - -```bash npx super-agent install [--target claude|cursor|continue|cline|all] npx super-agent list [--query ] npx super-agent search @@ -28,7 +61,6 @@ npx super-agent info ## Why use it -- **No login** for public packages — pulls straight from the public registry. -- **Trust signal** — every package comes with a verifiable Trust Score badge - (`/api/badges/trust/.svg`). -- **Cross-IDE** — one command installs to every agent you use. +- **One command** to a working, authenticated MCP connection — no hand-edited JSON. +- **No login** needed for public skill packages; OAuth only when you want write tools. +- **Cross-IDE** — installs to every agent you use, with a token-injecting bridge fallback. diff --git a/cli/package.json b/cli/package.json index a739145e..27c7518a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "super-agent", - "version": "0.1.0", - "description": "Install Super Agent Skill packages locally for Claude / Cursor / Continue / Cline.", + "version": "0.2.0", + "description": "Plug-and-play Super Agent Skill MCP: OAuth login, client auto-config, local stdio bridge, and skill package install for Claude / Cursor / Codex / VS Code / Windsurf.", "type": "module", "bin": { "super-agent": "./super-agent.mjs" diff --git a/cli/super-agent.mjs b/cli/super-agent.mjs index 76a58957..6c8b2c25 100644 --- a/cli/super-agent.mjs +++ b/cli/super-agent.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Super Agent Skill CLI — one-line distribution for any IDE/agent that reads -// local instruction files. +// Super Agent Skill CLI — install skills locally AND get a fully plug-and-play +// MCP connection (OAuth login, config writing, and a local stdio bridge). // // Usage: // npx super-agent install [--target claude|cursor|continue|cline|all] @@ -8,19 +8,37 @@ // npx super-agent search // npx super-agent info // -// Installs an Anthropic-compatible SKILL.md (or each target's local convention) -// into the current working directory. No login required for public packages — -// downloads via the public registry HTTPS endpoints. +// npx super-agent connect [--client claude-code|claude|cursor|codex|vscode|windsurf] +// npx super-agent login # OAuth (browser, loopback PKCE) only +// npx super-agent status +// npx super-agent logout +// npx super-agent setup # write/patch the MCP config for a client +// npx super-agent mcp # local stdio <-> remote HTTP MCP bridge +// +// `connect` is the one-shot path: it logs you in via OAuth and wires the +// chosen client so write tools work with zero manual JSON editing. import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { homedir, platform } from "node:os"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; -const REGISTRY = process.env.SUPER_AGENT_REGISTRY ?? "https://superagentskill.com"; +const REGISTRY = (process.env.SUPER_AGENT_REGISTRY ?? "https://superagentskill.com").replace(/\/+$/, ""); const TELEMETRY = process.env.SUPER_AGENT_TELEMETRY !== "0"; +const MCP_ENDPOINT = `${REGISTRY}/api/mcp`; +const CRED_DIR = join(homedir(), ".superagentskill"); +const CRED_FILE = join(CRED_DIR, "credentials.json"); + +// Commands that run a server / long process must NOT be force-exited. +const LONG_RUNNING = new Set(["connect", "login", "mcp"]); const [cmd, ...rest] = process.argv.slice(2); if (!cmd || cmd === "--help" || cmd === "-h") { - printHelp(); process.exit(0); + printHelp(); + process.exit(0); } try { @@ -28,23 +46,45 @@ try { else if (cmd === "list") await cmdList(rest); else if (cmd === "search") await cmdSearch(rest); else if (cmd === "info") await cmdInfo(rest); - else { console.error(`unknown command: ${cmd}`); printHelp(); process.exit(1); } + else if (cmd === "connect") await cmdConnect(rest); + else if (cmd === "login") await cmdLogin(); + else if (cmd === "status") await cmdStatus(); + else if (cmd === "logout") await cmdLogout(); + else if (cmd === "setup") await cmdSetup(rest); + else if (cmd === "mcp") await cmdMcpBridge(); + else { + console.error(`unknown command: ${cmd}`); + printHelp(); + process.exit(1); + } } catch (e) { console.error(`✗ ${e.message}`); process.exit(2); } -// fetch keep-alive sockets can keep the loop alive ~30s; force a clean exit. -setImmediate(() => process.exit(0)); + +// fetch keep-alive sockets can keep the loop alive ~30s; force a clean exit — +// but never for commands that intentionally keep running. +if (!LONG_RUNNING.has(cmd)) setImmediate(() => process.exit(0)); function printHelp() { - console.log(`super-agent — install AI agent skills locally + console.log(`super-agent — install AI agent skills + plug-and-play MCP -Commands: +Skill install: install [--target claude|cursor|continue|cline|all] default: all list [--query ] search info +MCP connection (plug and play): + connect [--client ] OAuth login + auto-wire a client in one step + login OAuth login only (browser, loopback PKCE) + status show login state / token expiry + logout revoke and forget the stored token + setup write/patch the MCP config for a client + mcp run a local stdio <-> remote HTTP MCP bridge + + clients: claude-code | claude | cursor | codex | vscode | windsurf + Environment: SUPER_AGENT_REGISTRY override registry origin (default https://superagentskill.com) SUPER_AGENT_TELEMETRY set to 0 to disable anonymized install telemetry @@ -52,11 +92,18 @@ Environment: } function parseFlags(args) { - const positional = []; const flags = {}; + const positional = []; + const flags = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; - if (a.startsWith("--")) { const n = args[i + 1]; if (!n || n.startsWith("--")) flags[a.slice(2)] = true; else { flags[a.slice(2)] = n; i++; } } - else positional.push(a); + if (a.startsWith("--")) { + const n = args[i + 1]; + if (!n || n.startsWith("--")) flags[a.slice(2)] = true; + else { + flags[a.slice(2)] = n; + i++; + } + } else positional.push(a); } return { positional, flags }; } @@ -73,6 +120,8 @@ async function getText(path) { return res.text(); } +/* ---------------- skill install (unchanged behavior) ---------------- */ + async function cmdInstall(args) { const { positional, flags } = parseFlags(args); const slug = positional[0]; @@ -81,10 +130,7 @@ async function cmdInstall(args) { console.log(`→ fetching ${slug} from ${REGISTRY}`); const info = await getJson(`/api/public/packages/${slug}`); - const skillMd = await getText(`/api/skills/${slug}/export.md`).catch(async () => { - // Fallback: synthesize from the public manifest fields. - return synthesizeSkillMd(info); - }); + const skillMd = await getText(`/api/skills/${slug}/export.md`).catch(async () => synthesizeSkillMd(info)); const targets = target === "all" ? ["claude", "cursor", "continue", "cline"] : [target]; for (const t of targets) writeForTarget(t, slug, skillMd); @@ -97,13 +143,16 @@ async function cmdInstall(args) { function writeForTarget(target, slug, skillMd) { const map = { - claude: `.claude/skills/${slug}/SKILL.md`, - cursor: `.cursor/rules/${slug}.mdc`, + claude: `.claude/skills/${slug}/SKILL.md`, + cursor: `.cursor/rules/${slug}.mdc`, continue: `.continue/skills/${slug}.md`, - cline: `.cline/skills/${slug}.md`, + cline: `.cline/skills/${slug}.md`, }; const path = map[target]; - if (!path) { console.warn(` ! unknown target: ${target}`); return; } + if (!path) { + console.warn(` ! unknown target: ${target}`); + return; + } mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, skillMd); console.log(` + ${path}`); @@ -113,28 +162,24 @@ async function cmdList(args) { const { flags } = parseFlags(args); const q = flags.query ?? ""; const list = await getJson(`/api/public/packages?type=skill${q ? `&q=${encodeURIComponent(q)}` : ""}`); - for (const p of list.items ?? list) { - console.log(`${p.slug.padEnd(36)} ${p.name ?? ""}`); - } + for (const p of list.items ?? list) console.log(`${p.slug.padEnd(36)} ${p.name ?? ""}`); } async function cmdSearch(args) { if (!args[0]) throw new Error("search requires a query"); const res = await getJson(`/api/public/search?q=${encodeURIComponent(args.join(" "))}`); - for (const r of res.results ?? []) { + for (const r of res.results ?? []) console.log(`${r.type.padEnd(10)} ${r.slug.padEnd(32)} ${r.score?.toFixed?.(2) ?? ""} ${r.snippet ?? ""}`); - } } async function cmdInfo(args) { const slug = args[0]; if (!slug) throw new Error("info requires "); - const info = await getJson(`/api/public/packages/${slug}`); - console.log(JSON.stringify(info, null, 2)); + console.log(JSON.stringify(await getJson(`/api/public/packages/${slug}`), null, 2)); } function synthesizeSkillMd(info) { - const lines = [ + return [ `# ${info.name ?? info.slug}`, ``, info.description ?? "", @@ -147,16 +192,16 @@ function synthesizeSkillMd(info) { ``, `---`, `Installed from ${REGISTRY}/packs/${info.slug}`, - ]; - return lines.join("\n"); + ].join("\n"); } function reportTelemetry(event) { if (!TELEMETRY) return; const body = JSON.stringify({ ...event, latency_ms: 0, workspace_id: hashCwd() }); - // Fire-and-forget; never block the CLI. fetch(`${REGISTRY}/api/telemetry`, { - method: "POST", headers: { "content-type": "application/json" }, body, + method: "POST", + headers: { "content-type": "application/json" }, + body, }).catch(() => {}); } @@ -164,7 +209,442 @@ function hashCwd() { try { const cwd = process.cwd(); const pkg = existsSync("package.json") ? JSON.parse(readFileSync("package.json", "utf8"))?.name : ""; - // No salt — server-side anonymization will hash again. return `${pkg || "anon"}:${cwd.split("/").slice(-2).join("/")}`; - } catch { return "anon"; } + } catch { + return "anon"; + } +} + +/* ---------------- OAuth (loopback PKCE) ---------------- */ + +function b64url(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function loadCreds() { + try { + return JSON.parse(readFileSync(CRED_FILE, "utf8")); + } catch { + return null; + } +} + +function saveCreds(creds) { + mkdirSync(CRED_DIR, { recursive: true }); + writeFileSync(CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); +} + +function openBrowser(url) { + const cmds = + platform() === "darwin" + ? ["open", [url]] + : platform() === "win32" + ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + try { + const p = spawn(cmds[0], cmds[1], { stdio: "ignore", detached: true }); + p.on("error", () => {}); + p.unref(); + } catch { + /* fall back to manual paste */ + } +} + +async function discover() { + // RFC 9728 → RFC 8414. Fail soft to known defaults if discovery is blocked. + const fallback = { + authorization_endpoint: `${REGISTRY}/oauth/authorize`, + token_endpoint: `${REGISTRY}/api/public/oauth/token`, + registration_endpoint: `${REGISTRY}/api/public/oauth/register`, + revocation_endpoint: `${REGISTRY}/api/public/oauth/revoke`, + }; + try { + const pr = await getJson(`/.well-known/oauth-protected-resource/api/mcp`).catch(() => + getJson(`/.well-known/oauth-protected-resource`), + ); + const as = (pr.authorization_servers?.[0] ?? REGISTRY).replace(/\/+$/, ""); + const meta = await fetch(`${as}/.well-known/oauth-authorization-server`, { + headers: { accept: "application/json" }, + }).then((r) => (r.ok ? r.json() : null)); + return meta ?? fallback; + } catch { + return fallback; + } +} + +function startCallbackServer() { + return new Promise((resolve) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + const redirectUri = `http://127.0.0.1:${port}/callback`; + const codePromise = new Promise((res, rej) => { + server.on("request", (req, response) => { + const u = new URL(req.url, redirectUri); + if (u.pathname !== "/callback") { + response.writeHead(404).end(); + return; + } + const err = u.searchParams.get("error"); + const code = u.searchParams.get("code"); + const state = u.searchParams.get("state"); + response.writeHead(200, { "content-type": "text/html" }); + response.end( + `Super Agent Skill` + + `` + + (err + ? `

Authorization failed

${err}

` + : `

✓ Connected

You can close this tab and return to your terminal.

`) + + ``, + ); + server.close(); + if (err) rej(new Error(`authorization denied: ${err}`)); + else res({ code, state }); + }); + }); + resolve({ redirectUri, codePromise }); + }); + }); +} + +async function oauthLogin() { + const meta = await discover(); + const { redirectUri, codePromise } = await startCallbackServer(); + + // Dynamic client registration with the real loopback redirect. + const reg = await fetch(meta.registration_endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "Super Agent CLI", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + software_id: "super-agent-cli", + }), + }); + if (!reg.ok) throw new Error(`client registration failed: HTTP ${reg.status} ${await reg.text()}`); + const client = await reg.json(); + if (!client.client_id) throw new Error("registration response missing client_id"); + + const verifier = b64url(randomBytes(32)); + const challenge = b64url(createHash("sha256").update(verifier).digest()); + const state = b64url(randomBytes(16)); + + const authUrl = new URL(meta.authorization_endpoint); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", client.client_id); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", "mcp:read mcp:write"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("resource", MCP_ENDPOINT); + + console.log(`\n→ Opening your browser to authorize Super Agent Skill…`); + console.log(` If it doesn't open, paste this URL:\n ${authUrl}\n`); + openBrowser(authUrl.toString()); + + const { code, state: returnedState } = await codePromise; + if (returnedState !== state) throw new Error("state mismatch — aborting (possible CSRF)"); + + const tok = await fetch(meta.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: client.client_id, + code_verifier: verifier, + }), + }); + if (!tok.ok) throw new Error(`token exchange failed: HTTP ${tok.status} ${await tok.text()}`); + const t = await tok.json(); + + const creds = { + client_id: client.client_id, + access_token: t.access_token, + refresh_token: t.refresh_token ?? null, + scope: t.scope ?? "mcp:read mcp:write", + expires_at: Date.now() + (t.expires_in ?? 3600) * 1000, + token_endpoint: meta.token_endpoint, + revocation_endpoint: meta.revocation_endpoint, + }; + saveCreds(creds); + return creds; +} + +async function ensureToken() { + let creds = loadCreds(); + if (!creds) throw new Error('not logged in — run "super-agent login" first'); + if (Date.now() < creds.expires_at - 60_000) return creds; + if (!creds.refresh_token) return creds; // will 401 → caller can re-login + const res = await fetch(creds.token_endpoint ?? `${REGISTRY}/api/public/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: creds.refresh_token, + client_id: creds.client_id, + }), + }); + if (!res.ok) return creds; + const t = await res.json(); + creds = { + ...creds, + access_token: t.access_token, + refresh_token: t.refresh_token ?? creds.refresh_token, + expires_at: Date.now() + (t.expires_in ?? 3600) * 1000, + }; + saveCreds(creds); + return creds; +} + +/* ---------------- connect / login / status / logout ---------------- */ + +async function cmdLogin() { + const creds = await oauthLogin(); + console.log(`\n✓ Logged in. Token saved to ${CRED_FILE}`); + console.log(` scope: ${creds.scope}`); + process.exit(0); +} + +async function cmdStatus() { + const creds = loadCreds(); + if (!creds) { + console.log("Not logged in. Run: super-agent login"); + return; + } + const expired = Date.now() >= creds.expires_at; + console.log(`Logged in (${REGISTRY})`); + console.log(` scope: ${creds.scope}`); + console.log(` access: ${expired ? "expired" : "valid"}${creds.refresh_token ? " (auto-refresh on)" : ""}`); + console.log(` expires_at: ${new Date(creds.expires_at).toISOString()}`); + console.log(` file: ${CRED_FILE}`); +} + +async function cmdLogout() { + const creds = loadCreds(); + if (!creds) { + console.log("Already logged out."); + return; + } + try { + await fetch(creds.revocation_endpoint ?? `${REGISTRY}/api/public/oauth/revoke`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: creds.refresh_token ?? creds.access_token }), + }); + } catch { + /* best effort */ + } + try { + writeFileSync(CRED_FILE, "{}", { mode: 0o600 }); + } catch { + /* ignore */ + } + console.log("✓ Logged out and token revoked."); +} + +async function cmdConnect(args) { + const { flags } = parseFlags(args); + console.log(`Super Agent Skill — connecting (${REGISTRY})`); + if (!loadCreds()?.access_token || Date.now() >= (loadCreds()?.expires_at ?? 0)) { + await oauthLogin(); + console.log(`✓ OAuth complete.`); + } else { + console.log(`✓ Reusing existing login (super-agent status to inspect).`); + } + const client = (flags.client ?? "claude-code").toLowerCase(); + await writeClientConfig(client); + console.log(`\nDone. Restart ${client} and the Super Agent Skill tools (incl. write tools) will be available.`); + process.exit(0); +} + +/* ---------------- client config writers ---------------- */ + +function patchJsonFile(file, mutate) { + mkdirSync(dirname(file), { recursive: true }); + let json = {}; + if (existsSync(file)) { + try { + json = JSON.parse(readFileSync(file, "utf8") || "{}"); + } catch { + throw new Error(`existing ${file} is not valid JSON — fix or remove it first`); + } + } + mutate(json); + writeFileSync(file, JSON.stringify(json, null, 2)); + console.log(` + ${file}`); +} + +async function writeClientConfig(client) { + const creds = loadCreds(); + const bearer = creds?.access_token; + const home = homedir(); + const httpEntry = { + url: MCP_ENDPOINT, + ...(bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : {}), + }; + + switch (client) { + case "claude-code": { + // Claude Code reuses the OAuth-capable HTTP transport directly: it will + // run the same browser flow on first use. The CLI command is the + // canonical install path; print it (and a bridge fallback). + console.log(`\nRun this once (Claude Code handles OAuth natively):\n`); + console.log(` claude mcp add --transport http super-agent-skill ${MCP_ENDPOINT}\n`); + console.log(`If your Claude Code build can't do MCP OAuth, use the local bridge instead:\n`); + console.log( + ` claude mcp add super-agent-skill -- npx -y super-agent mcp\n` + + ` (the bridge injects your saved token automatically)\n`, + ); + return; + } + case "claude": { + const file = + platform() === "darwin" + ? join(home, "Library/Application Support/Claude/claude_desktop_config.json") + : platform() === "win32" + ? join(process.env.APPDATA ?? join(home, "AppData/Roaming"), "Claude/claude_desktop_config.json") + : join(home, ".config/Claude/claude_desktop_config.json"); + patchJsonFile(file, (j) => { + j.mcpServers = j.mcpServers ?? {}; + // Desktop is most reliable via the local bridge (handles auth refresh). + j.mcpServers["super-agent-skill"] = { command: "npx", args: ["-y", "super-agent", "mcp"] }; + }); + return; + } + case "cursor": { + patchJsonFile(join(process.cwd(), ".cursor/mcp.json"), (j) => { + j.mcpServers = j.mcpServers ?? {}; + j.mcpServers["super-agent-skill"] = httpEntry; + }); + return; + } + case "vscode": { + patchJsonFile(join(home, ".config/Code/User/settings.json"), (j) => { + j.mcp = j.mcp ?? {}; + j.mcp.servers = j.mcp.servers ?? {}; + j.mcp.servers["super-agent-skill"] = { type: "http", ...httpEntry }; + }); + return; + } + case "windsurf": { + patchJsonFile(join(home, ".codeium/windsurf/mcp_config.json"), (j) => { + j.mcpServers = j.mcpServers ?? {}; + j.mcpServers["super-agent-skill"] = { + serverUrl: MCP_ENDPOINT, + ...(bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : {}), + }; + }); + return; + } + case "codex": { + // Codex config.toml has no JSON; the local bridge is the clean path. + const file = join(home, ".codex", "config.toml"); + mkdirSync(dirname(file), { recursive: true }); + const block = + `\n[mcp_servers.super-agent-skill]\n` + + `command = "npx"\n` + + `args = ["-y", "super-agent", "mcp"]\n`; + const prev = existsSync(file) ? readFileSync(file, "utf8") : ""; + if (prev.includes("[mcp_servers.super-agent-skill]")) { + console.log(` = ${file} already has super-agent-skill (left as-is)`); + } else { + writeFileSync(file, prev + block); + console.log(` + ${file}`); + } + return; + } + default: + throw new Error( + `unknown client "${client}". Use: claude-code | claude | cursor | codex | vscode | windsurf`, + ); + } +} + +async function cmdSetup(args) { + const client = (args[0] ?? "").toLowerCase(); + if (!client) throw new Error("setup requires (claude-code|claude|cursor|codex|vscode|windsurf)"); + if (!loadCreds()?.access_token) + console.log('! not logged in — run "super-agent login" first for write-tool access.'); + await writeClientConfig(client); +} + +/* ---------------- local stdio <-> HTTP MCP bridge ---------------- */ +// Lets ANY stdio-only MCP client speak to the remote Streamable HTTP server +// with the saved OAuth token injected and auto-refreshed. This is the +// "runs locally" path that makes the connection truly plug and play. + +async function cmdMcpBridge() { + let sessionId = null; + const rl = createInterface({ input: process.stdin }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + let creds; + try { + creds = await ensureToken(); + } catch { + creds = null; // anonymous: read-only tools still work + } + const headers = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }; + if (creds?.access_token) headers.authorization = `Bearer ${creds.access_token}`; + if (sessionId) headers["mcp-session-id"] = sessionId; + + let res; + try { + res = await fetch(MCP_ENDPOINT, { method: "POST", headers, body: trimmed }); + } catch (e) { + emit({ jsonrpc: "2.0", id: safeId(trimmed), error: { code: -32000, message: `bridge fetch failed: ${e.message}` } }); + continue; + } + const sid = res.headers.get("mcp-session-id"); + if (sid) sessionId = sid; + + if (res.status === 401) { + emit({ + jsonrpc: "2.0", + id: safeId(trimmed), + error: { code: -32001, message: "unauthorized — run `super-agent login` to enable write tools" }, + }); + continue; + } + + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("text/event-stream")) { + const text = await res.text(); + for (const chunk of text.split(/\n\n/)) { + const dataLines = chunk + .split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()); + if (!dataLines.length) continue; + const payload = dataLines.join(""); + if (payload && payload !== "[DONE]") process.stdout.write(payload + "\n"); + } + } else { + const text = (await res.text()).trim(); + if (text) process.stdout.write(text + "\n"); + } + } +} + +function emit(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function safeId(line) { + try { + return JSON.parse(line).id ?? null; + } catch { + return null; + } } diff --git a/src/lib/oauth/mcp-oauth.server.ts b/src/lib/oauth/mcp-oauth.server.ts index d94e950b..cc3e497f 100644 --- a/src/lib/oauth/mcp-oauth.server.ts +++ b/src/lib/oauth/mcp-oauth.server.ts @@ -50,11 +50,60 @@ export function verifyPkceS256(verifier: string, challenge: string): boolean { export const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version", + // Browser MCP clients (Claude.ai web connectors) can only read the OAuth + // challenge if WWW-Authenticate is explicitly exposed. + "Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id", "Access-Control-Max-Age": "86400", }; +/** + * RFC 8414 authorization-server metadata. Served at both the bare + * `/.well-known/oauth-authorization-server` and the path-aware + * `/.well-known/oauth-authorization-server/api/mcp` location so every + * client generation (Claude, Codex, Cursor, VS Code, …) finds it. + */ +export function authorizationServerMetadata() { + return { + issuer: ORIGIN, + authorization_endpoint: `${ORIGIN}/oauth/authorize`, + token_endpoint: `${ORIGIN}/api/public/oauth/token`, + registration_endpoint: `${ORIGIN}/api/public/oauth/register`, + revocation_endpoint: `${ORIGIN}/api/public/oauth/revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + revocation_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:read", "mcp:write"], + service_documentation: `${ORIGIN}/docs/mcp`, + }; +} + +/** RFC 9728 protected-resource metadata. */ +export function protectedResourceMetadata() { + return { + resource: MCP_RESOURCE, + authorization_servers: [ORIGIN], + scopes_supported: ["mcp:read", "mcp:write"], + bearer_methods_supported: ["header"], + resource_documentation: `${ORIGIN}/docs/mcp`, + }; +} + +/** Cached, CORS-enabled JSON response for the discovery documents. */ +export function discoveryResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=300", + ...CORS_HEADERS, + }, + }); +} + export function jsonResponse(body: unknown, status = 200, extraHeaders: Record = {}) { return new Response(JSON.stringify(body), { status, diff --git a/src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts b/src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts new file mode 100644 index 00000000..e4f7d78c --- /dev/null +++ b/src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + CORS_HEADERS, + authorizationServerMetadata, + discoveryResponse, +} from "@/lib/oauth/mcp-oauth.server"; + +// Path-aware RFC 8414 fallback. Some MCP clients append the resource path to +// the authorization-server well-known URL too; serve the same metadata so +// discovery never 404s regardless of which convention the client follows. +export const Route = createFileRoute("/.well-known/oauth-authorization-server/api/mcp")({ + server: { + handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), + GET: async () => discoveryResponse(authorizationServerMetadata()), + }, + }, +}); diff --git a/src/routes/[.]well-known.oauth-authorization-server.ts b/src/routes/[.]well-known.oauth-authorization-server.ts index 27dfe032..f689f5a9 100644 --- a/src/routes/[.]well-known.oauth-authorization-server.ts +++ b/src/routes/[.]well-known.oauth-authorization-server.ts @@ -1,36 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + authorizationServerMetadata, + discoveryResponse, +} from "@/lib/oauth/mcp-oauth.server"; -// MCP/OAuth clients (Claude, Codex, etc.) discover the authorization server -// metadata at the origin root per RFC 8414. Keep this in sync with the -// /api/public/.well-known/oauth-authorization-server route. +// RFC 8414 — authorization server metadata at the origin root. Keep the body +// in sync via the shared builder in mcp-oauth.server.ts. export const Route = createFileRoute("/.well-known/oauth-authorization-server")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - issuer: ORIGIN, - authorization_endpoint: `${ORIGIN}/oauth/authorize`, - token_endpoint: `${ORIGIN}/api/public/oauth/token`, - registration_endpoint: `${ORIGIN}/api/public/oauth/register`, - revocation_endpoint: `${ORIGIN}/api/public/oauth/revoke`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - scopes_supported: ["mcp:read", "mcp:write"], - service_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(authorizationServerMetadata()), }, }, }); diff --git a/src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts b/src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts new file mode 100644 index 00000000..46212671 --- /dev/null +++ b/src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + CORS_HEADERS, + discoveryResponse, + protectedResourceMetadata, +} from "@/lib/oauth/mcp-oauth.server"; + +// RFC 9728 path-aware location. The MCP resource lives at /api/mcp, so modern +// clients (Claude, Cursor, VS Code, Codex) probe +// /.well-known/oauth-protected-resource/api/mcp first. Serving this prevents +// the 404 that aborts the OAuth handshake before it begins. +export const Route = createFileRoute("/.well-known/oauth-protected-resource/api/mcp")({ + server: { + handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), + GET: async () => discoveryResponse(protectedResourceMetadata()), + }, + }, +}); diff --git a/src/routes/[.]well-known.oauth-protected-resource.ts b/src/routes/[.]well-known.oauth-protected-resource.ts index 7edf5564..f555b074 100644 --- a/src/routes/[.]well-known.oauth-protected-resource.ts +++ b/src/routes/[.]well-known.oauth-protected-resource.ts @@ -1,30 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, MCP_RESOURCE, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + discoveryResponse, + protectedResourceMetadata, +} from "@/lib/oauth/mcp-oauth.server"; -// MCP clients (Claude, Codex, etc.) discover the protected resource metadata -// at the origin root per RFC 9728. Keep this in sync with the -// /api/public/.well-known/oauth-protected-resource route. +// RFC 9728 — protected resource metadata at the origin root. Keep the body +// in sync via the shared builder in mcp-oauth.server.ts. export const Route = createFileRoute("/.well-known/oauth-protected-resource")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - resource: MCP_RESOURCE, - authorization_servers: [ORIGIN], - scopes_supported: ["mcp:read", "mcp:write"], - bearer_methods_supported: ["header"], - resource_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(protectedResourceMetadata()), }, }, }); diff --git a/src/routes/api/mcp.ts b/src/routes/api/mcp.ts index 6efe7396..ee9711bf 100644 --- a/src/routes/api/mcp.ts +++ b/src/routes/api/mcp.ts @@ -14,7 +14,7 @@ import { } from "@/lib/mcp/tools/skills"; import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; const supabaseAdmin = _supabaseAdmin as any; -import { ORIGIN, sha256 } from "@/lib/oauth/mcp-oauth.server"; +import { ORIGIN, sha256, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; import { hashToken } from "@/lib/account/tokens.server"; const mcp = createMcpServer({ @@ -66,7 +66,14 @@ const mcp = createMcpServer({ // Canonical RFC 9728 location at the origin root. Clients (Claude, Codex, …) // read this URL from the WWW-Authenticate header to start the OAuth dance. -const RESOURCE_METADATA_URL = `${ORIGIN}/.well-known/oauth-protected-resource`; +const RESOURCE_METADATA_URL = `${ORIGIN}/.well-known/oauth-protected-resource/api/mcp`; + +/** Attach CORS headers to any Response without dropping its existing headers. */ +function withCors(res: Response): Response { + const headers = new Headers(res.headers); + for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} /** Try OAuth tokens first, then fall back to legacy MCP personal tokens. */ async function verifyBearer(token: string): Promise<{ user_id: string; source: "oauth" | "pat" } | null> { @@ -97,7 +104,8 @@ function unauthorized(reason: string) { status: 401, headers: { "Content-Type": "application/json", - "WWW-Authenticate": `Bearer realm="MCP", resource_metadata="${RESOURCE_METADATA_URL}", error="invalid_token"`, + "WWW-Authenticate": `Bearer realm="MCP", resource_metadata="${RESOURCE_METADATA_URL}", error="invalid_token", error_description="${reason}"`, + ...CORS_HEADERS, }, }); } @@ -136,6 +144,7 @@ function rateLimited(quota: any, id: string | number | null) { headers: { "Content-Type": "application/json", "Retry-After": quota?.window === "hour" ? "3600" : "86400", + ...CORS_HEADERS, }, }, ); @@ -198,16 +207,19 @@ async function handle(request: Request): Promise { } if (userId && authSource) { - return mcp.handleRequest(request, { - auth: { token, claims: { user_id: userId, source: authSource } }, - }); + return withCors( + await mcp.handleRequest(request, { + auth: { token, claims: { user_id: userId, source: authSource } }, + }), + ); } - return mcp.handleRequest(request); + return withCors(await mcp.handleRequest(request)); } export const Route = createFileRoute("/api/mcp")({ server: { handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), GET: async ({ request }) => handle(request), POST: async ({ request }) => handle(request), DELETE: async ({ request }) => handle(request), diff --git a/src/routes/api/public/[.]well-known.oauth-authorization-server.ts b/src/routes/api/public/[.]well-known.oauth-authorization-server.ts index 288d9b4c..cd9e6c4a 100644 --- a/src/routes/api/public/[.]well-known.oauth-authorization-server.ts +++ b/src/routes/api/public/[.]well-known.oauth-authorization-server.ts @@ -1,33 +1,15 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + authorizationServerMetadata, + discoveryResponse, +} from "@/lib/oauth/mcp-oauth.server"; export const Route = createFileRoute("/api/public/.well-known/oauth-authorization-server")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - issuer: ORIGIN, - authorization_endpoint: `${ORIGIN}/oauth/authorize`, - token_endpoint: `${ORIGIN}/api/public/oauth/token`, - registration_endpoint: `${ORIGIN}/api/public/oauth/register`, - revocation_endpoint: `${ORIGIN}/api/public/oauth/revoke`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - scopes_supported: ["mcp:read", "mcp:write"], - service_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(authorizationServerMetadata()), }, }, }); diff --git a/src/routes/api/public/[.]well-known.oauth-protected-resource.ts b/src/routes/api/public/[.]well-known.oauth-protected-resource.ts index fe41cb63..aa28c4d1 100644 --- a/src/routes/api/public/[.]well-known.oauth-protected-resource.ts +++ b/src/routes/api/public/[.]well-known.oauth-protected-resource.ts @@ -1,27 +1,15 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, MCP_RESOURCE, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + discoveryResponse, + protectedResourceMetadata, +} from "@/lib/oauth/mcp-oauth.server"; export const Route = createFileRoute("/api/public/.well-known/oauth-protected-resource")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - resource: MCP_RESOURCE, - authorization_servers: [ORIGIN], - scopes_supported: ["mcp:read", "mcp:write"], - bearer_methods_supported: ["header"], - resource_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(protectedResourceMetadata()), }, }, }); diff --git a/src/routes/connect.tsx b/src/routes/connect.tsx index 3a1ba40a..10e7c6aa 100644 --- a/src/routes/connect.tsx +++ b/src/routes/connect.tsx @@ -273,6 +273,30 @@ Headers: Accept: application/json, text/event-stream`, ]; const QUICK_PROMPTS: Prompt[] = [ + { + title: "One prompt → fully connected (recommended)", + code: `Connect me to the "Super Agent Skill" MCP server with full access (read + write tools). + +Run this in my shell and follow the browser login it opens: + + npx -y super-agent connect --client claude-code + +Notes: +- It does OAuth (loopback PKCE), stores the token in ~/.superagentskill, and writes my client's MCP config automatically. +- Swap --client for: claude | cursor | codex | vscode | windsurf. +- After it finishes, restart the client, then call the MCP tool "overview" and show me the intent→tool map. +If anything fails, paste the exact command output and the config file it wrote.`, + }, + { + title: "Enable write tools (OAuth)", + code: `I want to use the auth-gated write tools (upload_packages, request_primitive, report_execution) of the Super Agent Skill MCP. + +1. Run: npx -y super-agent login (complete the browser OAuth login) +2. Run: npx -y super-agent status (confirm the token is valid) +3. If my client supports header auth, ensure the MCP config sends Authorization: Bearer . + Otherwise wire it through the local bridge: command "npx", args ["-y","super-agent","mcp"]. +4. Restart the MCP connection and call "overview" to verify write tools are listed.`, + }, { title: "Codex CLI / OpenCode", code: `Install the "Super Agent Skill" MCP server using this Streamable HTTP endpoint: @@ -512,6 +536,41 @@ function ConnectPage() { + {/* ============ ONE-COMMAND (CLI) PATH ============ */} +
+
+ +

+ Plug and play · one command +

+
+

+ OAuth login + client auto-config, no JSON editing +

+

+ The CLI runs the full OAuth flow in your browser (loopback PKCE), + stores the token locally, and writes the MCP config for your client + — including the auth-gated write tools. It also ships a local stdio + bridge that injects and refreshes the token automatically. +

+
+ +
+

+ Stdio-only client? Point it at{" "} + npx -y super-agent mcp — the + bridge handles auth for you. +

+
+ {/* ============ JUMP NAV ============ */}