From b941cb40631a174ee34f754972239c1168883d2c Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:17:17 -0400 Subject: [PATCH 01/11] feat(mcp): consent-gate server installs and disclose what connected A local MCP server is an arbitrary command run with the user's own privileges, and two of the three places Roxy reads server definitions from are not the user: a workspace `.roxy/mcp.json` arrives with `git clone`, and the `mcp` agent tool is driven by a model that reads web pages. Both connected automatically, so cloning a repo and sending any message was enough to execute attacker-chosen code. The default stays YES. Installing a server IS the decision, and a dialog people always approve is worse than none because it trains the click-through reflex. What changes is that Roxy now says what happened: a sheet naming the tools that appeared, the source they came from, and one honest line that an MCP server runs with your access. Only two things block: - a trusted server id now pointing at a DIFFERENT command, which is a substitution the user did not make, and - the opt-in "confirm before running" posture, for shared machines. Consent is keyed by a fingerprint of what executes (argv, cwd, env var NAMES, never their values), so renaming an entry keeps its approval while swapping its command revokes it. Decisions are scoped per workspace, so one repo's `db` server never pre-approves another's. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- src/main/services/mcp-trust.ts | 314 ++++++++++++ src/main/services/portable.ts | 6 +- .../src/components/McpConsentDialog.tsx | 225 +++++++++ .../src/components/McpInstallSheet.tsx | 217 ++++++++ src/renderer/src/components/McpTrustPanel.tsx | 174 +++++++ src/shared/mcp-trust.ts | 478 ++++++++++++++++++ 6 files changed, 1413 insertions(+), 1 deletion(-) create mode 100644 src/main/services/mcp-trust.ts create mode 100644 src/renderer/src/components/McpConsentDialog.tsx create mode 100644 src/renderer/src/components/McpInstallSheet.tsx create mode 100644 src/renderer/src/components/McpTrustPanel.tsx create mode 100644 src/shared/mcp-trust.ts diff --git a/src/main/services/mcp-trust.ts b/src/main/services/mcp-trust.ts new file mode 100644 index 0000000..b924860 --- /dev/null +++ b/src/main/services/mcp-trust.ts @@ -0,0 +1,314 @@ +/** + * MCP trust service — decides whether a server starts, and makes sure the user + * finds out what it exposed. + * + * The rules live in `src/shared/mcp-trust.ts` (pure, unit-tested); this file + * owns the side effects: reading the store, sending the disclosure, asking the + * rare blocking question, remembering answers. + * + * The normal path does NOT prompt. `gateRecords` lets servers through and the + * caller reports what connected via `discloseConnected`, which is where the tool + * list comes from — tools are only knowable AFTER the handshake, which is + * exactly why disclosure-after beats permission-before here: the receipt can + * name the tools, a pre-flight dialog can only name the command. + * + * A prompt is raised in two cases only: a trusted entry whose command changed, + * and users who opted into confirming first. + */ +import { BrowserWindow, dialog } from 'electron' +import { randomUUID } from 'node:crypto' +import { CHANNELS } from '../../shared/ipc' +import * as repo from '../db/repo' +import { + decideTrust, + describeConfig, + fingerprintConfig, + summarizeConfig, + type McpConsentRequest, + type McpConsentResponse, + type McpInstallNotice, + type McpProvenance, + type McpTrustPolicy +} from '../../shared/mcp-trust' +import type { McpServerRecord } from '../../shared/mcp' + +/** + * How long an unanswered prompt stays open before it is treated as a denial. + * Only reachable on the two blocking paths, so it can afford to be patient + * without ever wedging an ordinary turn. + */ +const CONSENT_TIMEOUT_MS = 120_000 + +/** + * Escape hatch for automation: `ROXY_MCP_CONFIRM=1` forces the opt-in + * confirm-first posture. Deliberately an env var and not a config-file key, so + * a repo you cloned cannot change your posture in either direction. + */ +function envConfirm(): boolean { + return process.env.ROXY_MCP_CONFIRM === '1' +} + +/** A record paired with where it came from, which is what drives disclosure. */ +export interface McpCandidate { + record: McpServerRecord + provenance: McpProvenance + /** Workspace the record is scoped to (workspace-file records); null otherwise. */ + workspace: string | null +} + +/** Current policy: the persisted toggle, OR-ed with the env override. */ +export function trustPolicy(): McpTrustPolicy { + return { confirmBeforeRun: envConfirm() || repo.getMcpConfirmBeforeRun() } +} + +export function setConfirmBeforeRun(enabled: boolean): void { + repo.setMcpConfirmBeforeRun(enabled) +} + +// --------------------------------------------------------------------------- +// Prompting (the exceptional path) +// --------------------------------------------------------------------------- + +interface PendingPrompt { + resolve: (r: McpConsentResponse) => void + timer: NodeJS.Timeout +} + +const pending = new Map() +/** Dedupe key → in-flight prompt, so concurrent turns share one dialog. */ +const inFlight = new Map>() + +/** Called by the IPC handler when the renderer answers. */ +export function resolveConsent(response: McpConsentResponse): void { + const entry = pending.get(response.requestId) + if (!entry) return // already timed out, or a stale/duplicate answer + clearTimeout(entry.timer) + pending.delete(response.requestId) + entry.resolve(response) +} + +/** Drop every pending prompt (window closing / app quit): unanswered = denied. */ +export function cancelAllConsent(): void { + for (const [requestId, entry] of pending) { + clearTimeout(entry.timer) + entry.resolve({ requestId, decision: 'deny', scope: 'once' }) + } + pending.clear() +} + +/** The window that should host UI, or null when running headless. */ +function hostWindow(): BrowserWindow | null { + const focused = BrowserWindow.getFocusedWindow() + if (focused && !focused.isDestroyed()) return focused + return BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) ?? null +} + +/** + * Native fallback for when there is no renderer to ask. Only reached on the two + * blocking paths, and both of them mean "something is off", so the fallback + * denies rather than guessing on the user's behalf. + */ +async function askNatively(request: McpConsentRequest): Promise { + const win = hostWindow() + if (!win) return { requestId: request.requestId, decision: 'deny', scope: 'once' } + const { response } = await dialog.showMessageBox(win, { + type: 'warning', + buttons: ['Cancel', 'Run it'], + defaultId: 0, + cancelId: 0, + title: 'MCP server changed', + message: `"${request.id}" is not what it was.`, + detail: `Now runs: ${summarizeConfig(request.config)}${ + request.previousSummary ? `\nPreviously: ${request.previousSummary}` : '' + }\n\nOnly continue if you made this change.` + }) + return { + requestId: request.requestId, + decision: response === 1 ? 'allow' : 'deny', + scope: response === 1 ? 'server' : 'once' + } +} + +/** Ask the user about one server, deduping concurrent asks for the same thing. */ +function prompt(request: McpConsentRequest, key: string): Promise { + const existing = inFlight.get(key) + if (existing) return existing + + const win = hostWindow() + const p: Promise = (async () => { + if (!win) return askNatively(request) + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(request.requestId) + // Unanswered is DENIED. This path only triggers when a command changed + // under a trusted name; letting that expire into a yes would be the + // wrong way to resolve the one question worth asking. + resolve({ requestId: request.requestId, decision: 'deny', scope: 'once' }) + }, CONSENT_TIMEOUT_MS) + pending.set(request.requestId, { resolve, timer }) + win.webContents.send(CHANNELS.mcpConsentRequest, request) + }) + })() + + inFlight.set(key, p) + return p.finally(() => inFlight.delete(key)) +} + +// --------------------------------------------------------------------------- +// Disclosure (the normal path) +// --------------------------------------------------------------------------- + +/** Candidates allowed to run, paired with whether the user should be told. */ +export interface GateResult { + records: McpServerRecord[] + /** Servers to disclose once connected, keyed by id. */ + disclose: Map +} + +/** + * Tell the user what a server turned out to be, now that it has connected and + * its tools are known. Also records the allow, so this is a one-time notice + * rather than a recurring one. + */ +export function discloseConnected(candidate: McpCandidate, tools: string[], error?: string): void { + const { record, provenance, workspace } = candidate + try { + // Remember it even when it failed: a server that is broken today should not + // re-announce itself on every single turn until it is fixed. + repo.recordMcpTrust({ + id: record.id, + fingerprint: fingerprintConfig(record.config), + provenance, + scope: workspace, + decision: 'allow', + decidedAt: Date.now() + }) + const win = hostWindow() + if (!win) return + const notice: McpInstallNotice = { + id: record.id, + provenance, + workspace, + disclosure: describeConfig(record.config), + tools, + error + } + win.webContents.send(CHANNELS.mcpInstallNotice, notice) + } catch { + /* disclosure is best-effort; never break a turn over a notification */ + } +} + +// --------------------------------------------------------------------------- +// The gate +// --------------------------------------------------------------------------- + +/** Stable dedupe key for one candidate. */ +function candidateKey(id: string, fingerprint: string, workspace: string | null): string { + return `${id}\u0000${fingerprint}\u0000${workspace ?? ''}` +} + +/** + * Decide whether ONE server may start. + * + * Returns `disclose: true` when it may run but the user should be told about it + * afterwards (call `discloseConnected` once the tools are known). Never throws. + */ +export async function ensureTrusted( + candidate: McpCandidate +): Promise<{ allowed: boolean; disclose: boolean }> { + const { record, provenance, workspace } = candidate + try { + const decision = decideTrust({ + id: record.id, + config: record.config, + provenance, + workspace, + store: repo.getMcpTrustStore(), + policy: trustPolicy() + }) + if (!decision.needsPrompt) { + return { allowed: decision.allowed, disclose: decision.needsDisclosure } + } + + const fingerprint = fingerprintConfig(record.config) + const request: McpConsentRequest = { + requestId: randomUUID(), + id: record.id, + config: record.config, + provenance, + workspace, + disclosure: describeConfig(record.config), + reason: decision.reason === 'changed' ? 'changed' : 'confirm-first-run', + previousSummary: decision.previousFingerprint + ? previousSummaryFor(decision.previousFingerprint) + : undefined + } + + const answer = await prompt(request, candidateKey(record.id, fingerprint, workspace)) + + if (answer.scope === 'workspace' && answer.decision === 'allow' && workspace) { + repo.trustMcpWorkspace(workspace) + return { allowed: true, disclose: false } + } + if (answer.scope === 'server' || answer.decision === 'deny') { + repo.recordMcpTrust({ + id: record.id, + fingerprint, + provenance, + scope: workspace, + decision: answer.decision, + decidedAt: Date.now() + }) + } + return { allowed: answer.decision === 'allow', disclose: false } + } catch { + // A failure in the trust path must not silently swallow a server the user + // expects to work; the default posture is to run it and disclose. + return { allowed: true, disclose: true } + } +} + +/** + * A human-readable rendering of the config previously approved under this id, + * for the "this changed" prompt. + * + * Derived from the fingerprint rather than looked up, because the store keeps + * IDENTITY and not a copy of the old config. The fingerprint's argv answers the + * only question being asked: "is this the command you had before?" + */ +function previousSummaryFor(previousFingerprint: string): string { + const [kind, detail] = previousFingerprint.split('\u0001') + if (kind === 'local' && detail) return detail.split('\u0000').join(' ') + if (kind === 'remote' && detail) return detail + return previousFingerprint +} + +/** + * Filter a turn's records down to the ones allowed to run, and report which of + * them the user should be told about once they connect. + * + * Sequential because the rare prompt path must not stack dialogs; the common + * path does no I/O beyond a single store read per candidate. + */ +export async function gateRecords(candidates: McpCandidate[]): Promise { + const records: McpServerRecord[] = [] + const disclose = new Map() + for (const candidate of candidates) { + const { allowed, disclose: tell } = await ensureTrusted(candidate) + if (!allowed) continue + records.push(candidate.record) + if (tell) disclose.set(candidate.record.id, candidate) + } + return { records, disclose } +} + +// --------------------------------------------------------------------------- +// Test-only helpers +// --------------------------------------------------------------------------- + +/** Clear in-memory prompt state between smoke cases (does not touch the DB). */ +export function _resetTrustForTests(): void { + cancelAllConsent() + inFlight.clear() +} diff --git a/src/main/services/portable.ts b/src/main/services/portable.ts index 84f08dc..2fc34f3 100644 --- a/src/main/services/portable.ts +++ b/src/main/services/portable.ts @@ -77,7 +77,11 @@ export async function applyImport(text: string): Promise { const existingIds = new Set(repo.listMcpServers().map((r) => r.id)) for (const s of bundle.mcpServers) { try { - repo.upsertMcpServer({ id: s.id, config: s.config, enabled: s.enabled }) + // Imported, not authored: the user consented to reading a FILE, which is + // not the same as approving each command inside it (a bundle is a + // share-a-link vector). Left as agent-origin so the consent gate still + // discloses each server before it is ever spawned. + repo.upsertMcpServer({ id: s.id, config: s.config, enabled: s.enabled, origin: 'agent' }) mcpServers.push({ id: s.id, replaced: existingIds.has(s.id) }) } catch (e) { skillRes.skipped.push({ name: s.id, reason: (e as Error).message }) diff --git a/src/renderer/src/components/McpConsentDialog.tsx b/src/renderer/src/components/McpConsentDialog.tsx new file mode 100644 index 0000000..a658275 --- /dev/null +++ b/src/renderer/src/components/McpConsentDialog.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + AlertTriangle, + FolderGit2, + Globe, + KeyRound, + ShieldAlert, + TerminalSquare +} from 'lucide-react' +import type { McpConsentRequest, McpConsentResponse } from '@shared/mcp-trust' +import { api } from '../lib/api' +import { Button } from './ui' + +/** + * The MCP consent prompt — the rare blocking question. + * + * This is NOT the normal path. Installing an MCP server is the user's own + * decision, so it runs and reports what it exposed (see McpInstallSheet). This + * dialog appears in exactly two cases: + * + * - **`changed`** — a server the user already trusted now runs a DIFFERENT + * command. Approving `npx server-github` is not approving whatever replaced + * it, and this substitution is the one thing the user did not do themselves. + * - **`confirm-first-run`** — the user opted into confirming new servers. + * + * Keeping the interruption this rare is what makes it mean something: a dialog + * people see twice a year gets read, a dialog they see daily gets dismissed. + * + * Rules: the command is stated verbatim, deny is focused, Escape and the + * backdrop both deny, and env/header VALUES are never rendered. + */ +export function McpConsentDialog(): JSX.Element | null { + const { t } = useTranslation() + const [queue, setQueue] = useState([]) + + // Requests are queued, never dropped: two turns can each hit an unapproved + // server, and silently discarding the second would leave the main process + // waiting on an answer that can no longer be given (until it times out and + // denies - correct, but confusing to a user who never saw a prompt). + useEffect(() => { + return api.mcp.trust.onRequest((request) => { + setQueue((q) => (q.some((r) => r.requestId === request.requestId) ? q : [...q, request])) + }) + }, []) + + const current = queue[0] + + const answer = (decision: 'allow' | 'deny', scope: McpConsentResponse['scope']): void => { + if (!current) return + api.mcp.trust.respond({ requestId: current.requestId, decision, scope }) + setQueue((q) => q.slice(1)) + } + + // Escape denies. A dialog you can dismiss into an approval is not a gate. + useEffect(() => { + if (!current) return + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') answer('deny', 'once') + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current]) + + if (!current) return null + + const { disclosure: d } = current + const isLocal = d.transport === 'local' + const changed = current.reason === 'changed' + + return ( +
answer('deny', 'once')} + > +
e.stopPropagation()} + > +
+
+ {changed ? : } +
+
+ +

+ {t(`mcpTrust.origin.${current.provenance}`, { + name: current.id, + defaultValue: t('mcpTrust.origin.workspace', { name: current.id }) + })} +

+
+
+ +
+ {changed && ( +

+ {t('mcpTrust.changedWarning')} + {current.previousSummary && ( + + {t('mcpTrust.previously')} {current.previousSummary} + + )} +

+ )} + +

+ {isLocal ? t('mcpTrust.explainLocal') : t('mcpTrust.explainRemote')} +

+ +
+ {isLocal ? ( + <> + } + label={t('mcpTrust.command')} + > + + {[d.executable, ...(d.args ?? [])].filter(Boolean).join(' ')} + + + } label={t('mcpTrust.workingDir')}> + + {d.cwd || current.workspace || t('mcpTrust.workspaceRoot')} + + + + ) : ( + } label={t('mcpTrust.url')}> + {d.url} + + )} + + {/* Names only, never values: a modal is a screenshot waiting to happen. */} + {!!(isLocal ? d.envNames : d.headerNames)?.length && ( + } + label={isLocal ? t('mcpTrust.envVars') : t('mcpTrust.headers')} + > +
+ {(isLocal ? d.envNames : d.headerNames)!.map((name) => ( + + {name} + + ))} +
+ {d.injectsSecrets && ( +

{t('mcpTrust.secretsNote')}

+ )} +
+ )} + + {current.workspace && ( + } label={t('mcpTrust.project')}> + + {current.workspace} + + + )} +
+
+ +
+
+ {/* Deny is first and autofocused: the safe answer should be the one + a reflexive Enter or click lands on. */} + + + + {t('mcpTrust.rememberNote')} + +
+ {/* Trusting the whole project is offered only for workspace-declared + servers - it is scoped to a folder, so it is meaningless (and + would be dangerously broad) for anything else. */} + {current.provenance === 'workspace' && current.workspace && ( + + )} +
+
+
+ ) +} + +function Row({ + icon, + label, + children +}: { + icon: JSX.Element + label: string + children: React.ReactNode +}): JSX.Element { + return ( +
+
+ {icon} + {label} +
+
{children}
+
+ ) +} diff --git a/src/renderer/src/components/McpInstallSheet.tsx b/src/renderer/src/components/McpInstallSheet.tsx new file mode 100644 index 0000000..7701fc9 --- /dev/null +++ b/src/renderer/src/components/McpInstallSheet.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Boxes, + CircleAlert, + FolderGit2, + Globe, + KeyRound, + Package, + ShieldAlert, + TerminalSquare, + Wrench, + X +} from 'lucide-react' +import type { McpInstallNotice } from '@shared/mcp-trust' +import { api } from '../lib/api' +import { Button } from './ui' + +/** + * "Here's what you just installed" — shown AFTER an MCP server connects, once. + * + * This is the disclosure that replaces an approval dialog. Installing a server + * is the user's decision; asking them to re-confirm it teaches nothing and + * trains the click-through reflex. What they genuinely can't know in advance is + * WHAT THE SERVER GAVE THEM — and that only exists after the handshake. + * + * So the hierarchy is: + * 1. The tools it added, by name. The reason you installed it. + * 2. The source it came from. The thing actually worth trusting. + * 3. The command/URL, for anyone who wants to check. + * 4. One honest line: an MCP server runs with your access — trust the source. + * + * Dismissable, non-blocking, and never shown twice for the same server. + */ +export function McpInstallSheet(): JSX.Element | null { + const { t } = useTranslation() + const [queue, setQueue] = useState([]) + + useEffect(() => { + return api.mcp.trust.onInstall((notice) => { + setQueue((q) => (q.some((n) => n.id === notice.id) ? q : [...q, notice])) + }) + }, []) + + const current = queue[0] + const dismiss = (): void => setQueue((q) => q.slice(1)) + + useEffect(() => { + if (!current) return + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') dismiss() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [current]) + + if (!current) return null + + const { disclosure: d, tools, error } = current + const isLocal = d.transport === 'local' + const credentialNames = (isLocal ? d.envNames : d.headerNames) ?? [] + + return ( +
+
e.stopPropagation()} + > +
+
+ {error ? : } +
+
+

+ {error + ? t('mcpInstall.failedTitle', { name: current.id }) + : t('mcpInstall.title', { name: current.id })} +

+

+ {t('mcpInstall.from')} {d.source} +

+
+ +
+ +
+ {error ? ( +

+ {error} +

+ ) : ( + <> + {/* The tools ARE the headline: this is what the user gained. */} +

+ {tools.length + ? t('mcpInstall.toolsAdded', { count: tools.length }) + : t('mcpInstall.noTools')} +

+ {tools.length > 0 && ( +
+ {tools.map((name) => ( + + + {name} + + ))} +
+ )} + + )} + +
+ : + } + label={t('mcpInstall.source')} + > + {d.source} + + } + label={isLocal ? t('mcpInstall.command') : t('mcpInstall.url')} + > + + {isLocal ? [d.executable, ...(d.args ?? [])].filter(Boolean).join(' ') : d.url} + + + {credentialNames.length > 0 && ( + } + label={isLocal ? t('mcpInstall.envVars') : t('mcpInstall.headers')} + > +
+ {credentialNames.map((name) => ( + + {name} + + ))} +
+ {d.injectsSecrets && ( +

{t('mcpInstall.secretsNote')}

+ )} +
+ )} + {current.workspace && ( + } label={t('mcpInstall.project')}> + + {current.workspace} + + + )} +
+ + {/* The one honest warning. Not "are you sure?" - the user already + decided - but the fact that makes the decision theirs to own. */} +
+ +

+ {t('mcpInstall.trustSourceTitle')}{' '} + {isLocal ? t('mcpInstall.trustSourceLocal') : t('mcpInstall.trustSourceRemote')} +

+
+
+ +
+ + {t('mcpInstall.manageNote')} +
+
+
+ ) +} + +function Row({ + icon, + label, + children +}: { + icon: JSX.Element + label: string + children: React.ReactNode +}): JSX.Element { + return ( +
+
+ {icon} + {label} +
+
{children}
+
+ ) +} diff --git a/src/renderer/src/components/McpTrustPanel.tsx b/src/renderer/src/components/McpTrustPanel.tsx new file mode 100644 index 0000000..ffa54bb --- /dev/null +++ b/src/renderer/src/components/McpTrustPanel.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FolderGit2, Plug, ShieldCheck, Trash2 } from 'lucide-react' +import type { McpTrustView } from '@shared/api' +import { api } from '../lib/api' +import { Button, Switch } from './ui' + +/** + * The MCP trust panel — the servers Roxy has run, and the posture it runs them + * under. + * + * Roxy's default is to run a server you installed and then tell you what it + * exposed, so this list is mostly a RECORD rather than a set of permissions: + * what has connected, what you blocked, which projects you trusted wholesale. + * Everything here is revocable, which is what keeps the default honest — a + * permissive default is only defensible if the consequences stay visible and + * reversible. + * + * The one switch flips the posture to ask-first, for shared machines and repos + * you don't control. + */ +export function McpTrustPanel(): JSX.Element { + const { t } = useTranslation() + const [view, setView] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + void api.mcp.trust.list().then(setView) + }, []) + + const revoke = async ( + target: { kind: 'server'; id: string } | { kind: 'workspace'; path: string } + ): Promise => { + setBusy(true) + try { + setView(await api.mcp.trust.revoke(target)) + } finally { + setBusy(false) + } + } + + const setConfirm = async (confirmBeforeRun: boolean): Promise => { + setBusy(true) + try { + const policy = await api.mcp.trust.setPolicy(confirmBeforeRun) + setView((v) => (v ? { ...v, policy } : v)) + } finally { + setBusy(false) + } + } + + if (!view) return

{t('common.loading')}

+ + const allowed = view.entries.filter((e) => e.decision === 'allow') + const denied = view.entries.filter((e) => e.decision === 'deny') + + return ( +
+

{t('mcpTrust.panelIntro')}

+ + {view.workspaces.length > 0 && ( +
+ {view.workspaces.map((w) => ( + } + title={w.path} + subtitle={t('mcpTrust.trustedProjectSub')} + busy={busy} + onRevoke={() => void revoke({ kind: 'workspace', path: w.path })} + /> + ))} +
+ )} + + {allowed.length > 0 && ( +
+ {allowed.map((e) => ( + } + title={e.id} + subtitle={e.scope ?? t('mcpTrust.scopeAnywhere')} + busy={busy} + onRevoke={() => void revoke({ kind: 'server', id: e.id })} + /> + ))} +
+ )} + + {denied.length > 0 && ( +
+ {denied.map((e) => ( + } + title={e.id} + subtitle={e.scope ?? t('mcpTrust.scopeAnywhere')} + busy={busy} + onRevoke={() => void revoke({ kind: 'server', id: e.id })} + /> + ))} +
+ )} + + {!view.workspaces.length && !view.entries.length && ( +

{t('mcpTrust.noneYet')}

+ )} + + {/* The stricter posture, for people who want it. Off by default: choosing + to install a server is already the decision. */} +
+
+

{t('mcpTrust.confirmLabel')}

+

+ {t('mcpTrust.confirmHelp')} +

+
+ void setConfirm(v)} + /> +
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function Row({ + icon, + title, + subtitle, + busy, + onRevoke +}: { + icon: JSX.Element + title: string + subtitle: string + busy: boolean + onRevoke: () => void +}): JSX.Element { + const { t } = useTranslation() + return ( +
+ {icon} +
+

+ {title} +

+

+ {subtitle} +

+
+ +
+ ) +} diff --git a/src/shared/mcp-trust.ts b/src/shared/mcp-trust.ts new file mode 100644 index 0000000..fe1b7dc --- /dev/null +++ b/src/shared/mcp-trust.ts @@ -0,0 +1,478 @@ +/** + * MCP server trust — deciding when Roxy may start a server, and what to tell the + * user when it does. + * + * Pure logic only (no Node/Electron/SDK imports) so every rule here is unit + * tested in smoke:shared. Store + prompt plumbing lives in + * `src/main/services/mcp-trust.ts`. + * + * ## The stance + * + * Installing an MCP server is like installing an extension: the user picked the + * source, and picking the source IS the decision. Roxy is not in a position to + * second-guess it — an approval dialog in front of an action the user just took + * teaches nothing, and a dialog people always approve is worse than no dialog, + * because it trains the reflex that later waves through the one that mattered. + * + * So the default is YES: servers connect, and the user is TOLD what happened — + * which tools appeared, and where they came from. Disclosure after the fact, + * not permission before it. + * + * Two things are still gated, because neither is a decision the user made: + * + * - **A definition Roxy has never shown anyone.** Not a veto, a notification: + * the servers a project declares are surfaced with their tools, once. + * - **A trusted entry whose command changed.** Approving `npx server-github` + * is not approving whatever replaced it. This is the one case that gets a + * real, blocking prompt, because it is the one case the user did not do. + * + * The warning belongs where the trust actually lives: the SOURCE. An MCP server + * runs with your privileges, so the honest message is "trust the source you got + * this from", not "are you sure?" — a question the user cannot answer better + * than they already did when they chose it. + */ + +import type { McpServerConfig, McpLocalConfig, McpRemoteConfig } from './mcp' + +// --------------------------------------------------------------------------- +// Provenance +// --------------------------------------------------------------------------- + +/** + * Where a server definition came from. Drives what the user is TOLD, and (for + * the one blocking case) whether they are asked. + * + * - `user` added in Settings / the MCP page. + * - `workspace` declared by a project file (`.roxy/mcp.json`). + * - `agent` added by the model via the `mcp` tool. + * - `import` arrived in a portable config bundle. + */ +export type McpProvenance = 'user' | 'workspace' | 'agent' | 'import' + +// --------------------------------------------------------------------------- +// Fingerprinting — the identity a remembered decision is bound to +// --------------------------------------------------------------------------- + +/** + * Canonical string identifying WHAT WILL EXECUTE, so Roxy can tell "the server + * you already know" from "something else wearing its name". + * + * Included, because changing any of them changes what runs: + * local → argv, cwd, and the NAMES of injected env vars + * remote → scheme, host, port, path, and the NAMES of injected headers + * + * Excluded, deliberately: + * - env/header VALUES. They are secrets (`GITHUB_TOKEN`), and rotating a token + * is not a new decision. The NAMES are in, because gaining + * `AWS_SECRET_ACCESS_KEY` where there was none is. + * - `timeout`. A number that cannot change what executes. + * - the server id. Renaming an entry is not re-installing it. + * + * Not a hash: this string is shown in the UI and diffed in tests, so it is kept + * legible on purpose. It is an identity key, never a security token. + */ +export function fingerprintConfig(config: McpServerConfig): string { + if (config.type === 'local') { + const cfg = config as McpLocalConfig + const argv = cfg.command.join('\u0000') + const cwd = cfg.cwd ?? '' + const envNames = Object.keys(cfg.environment ?? {}) + .sort() + .join(',') + return `local\u0001${argv}\u0001${cwd}\u0001${envNames}` + } + const cfg = config as McpRemoteConfig + const headerNames = Object.keys(cfg.headers ?? {}) + .sort() + .join(',') + return `remote\u0001${canonicalUrl(cfg.url)}\u0001${headerNames}` +} + +/** + * Normalize a URL for fingerprinting so cosmetic differences (case, a trailing + * slash, the default port) don't read as a different server. + * + * Query and fragment are KEPT: `?tenant=acme` can select an entirely different + * backend. An unparseable URL falls back to the trimmed raw string — a config + * that cannot be parsed must never silently collapse onto another's identity. + */ +function canonicalUrl(raw: string): string { + try { + const u = new URL(raw.trim()) + const protocol = u.protocol.toLowerCase() + const host = u.hostname.toLowerCase() + const port = u.port && !isDefaultPort(protocol, u.port) ? `:${u.port}` : '' + const path = u.pathname.replace(/\/+$/, '') + return `${protocol}//${host}${port}${path}${u.search}${u.hash}` + } catch { + return raw.trim() + } +} + +function isDefaultPort(protocol: string, port: string): boolean { + return (protocol === 'http:' && port === '80') || (protocol === 'https:' && port === '443') +} + +// --------------------------------------------------------------------------- +// Disclosure — what the user is shown +// --------------------------------------------------------------------------- + +/** + * The facts about a server, derived from its config so the UI cannot drift from + * what actually runs, and so the copy is testable without rendering React. + */ +export interface McpDisclosure { + transport: 'local' | 'remote' + /** The resolved executable — argv[0] for a local server. */ + executable?: string + /** Arguments after argv[0], verbatim (never re-quoted or shortened). */ + args?: string[] + /** Working directory as written; `undefined` means the workspace root. */ + cwd?: string + /** NAMES of injected env vars, sorted. Values are never included. */ + envNames?: string[] + /** Full URL for a remote server. */ + url?: string + /** Host of the remote URL — the thing to actually recognise or not. */ + host?: string + /** NAMES of injected headers, sorted. Values are never included. */ + headerNames?: string[] + /** Whether any credential-shaped env var / header is being injected. */ + injectsSecrets: boolean + /** + * The package or host the server comes FROM (`@modelcontextprotocol/server-github`, + * `api.acme.com`). This is the thing worth trusting or not, so it is the thing + * the UI leads with. + */ + source: string +} + +/** Env/header names that look like credentials, for the "shares a secret" note. */ +const SECRET_NAME = + /(?:^|_)(?:token|key|secret|password|passwd|credential|auth|apikey|session)s?(?:_|$)/i + +/** Runners that front for a package: the interesting name is their argument. */ +const PACKAGE_RUNNERS = /^(?:npx|pnpx|bunx|uvx|pipx|deno|dlx)$/i + +/** Flags to skip when hunting for the package name after a runner. */ +const RUNNER_FLAGS = /^-/ + +/** + * The source a server actually comes from. + * + * `npx -y @modelcontextprotocol/server-github` is not meaningfully "npx" — it is + * the package, and the package is what the user is being asked to trust. Same + * for a remote server: the host, not the full URL with its path and query. + */ +export function sourceOf(config: McpServerConfig): string { + if (config.type === 'remote') { + try { + return new URL(config.url).host + } catch { + return config.url + } + } + const [exe, ...rest] = (config as McpLocalConfig).command + if (!exe) return '' + const base = exe.replace(/\\/g, '/').split('/').pop() ?? exe + if (PACKAGE_RUNNERS.test(base.replace(/\.(?:exe|cmd|bat)$/i, ''))) { + const pkg = rest.find((a) => !RUNNER_FLAGS.test(a)) + if (pkg) return pkg + } + return base +} + +/** Build the disclosure shown alongside a server. */ +export function describeConfig(config: McpServerConfig): McpDisclosure { + if (config.type === 'local') { + const cfg = config as McpLocalConfig + const [executable, ...args] = cfg.command + const envNames = Object.keys(cfg.environment ?? {}).sort() + return { + transport: 'local', + executable: executable ?? '', + args, + cwd: cfg.cwd, + envNames, + injectsSecrets: envNames.some((n) => SECRET_NAME.test(n)), + source: sourceOf(config) + } + } + const cfg = config as McpRemoteConfig + const headerNames = Object.keys(cfg.headers ?? {}).sort() + let host: string | undefined + try { + host = new URL(cfg.url).host + } catch { + host = undefined + } + return { + transport: 'remote', + url: cfg.url, + host, + headerNames, + injectsSecrets: headerNames.some((n) => SECRET_NAME.test(n) || /^authorization$/i.test(n)), + source: sourceOf(config) + } +} + +/** + * A one-line summary for compact UI (list rows, the agent's tool output). + * Local servers show the command; remote servers show the host, since the full + * URL of an API endpoint is noise in a list. + */ +export function summarizeConfig(config: McpServerConfig): string { + const d = describeConfig(config) + if (d.transport === 'local') { + return [d.executable, ...(d.args ?? [])].filter(Boolean).join(' ') + } + return d.host || d.url || '' +} + +// --------------------------------------------------------------------------- +// Stored decisions +// --------------------------------------------------------------------------- + +/** A remembered decision about one server. */ +export interface McpTrustEntry { + /** Server id as it was when decided (display only — identity is the fingerprint). */ + id: string + /** `fingerprintConfig` of the config this decision covers. */ + fingerprint: string + /** Where it came from when the decision was made. */ + provenance: McpProvenance + /** + * Absolute workspace path this decision is scoped to, or null for "anywhere". + * + * Workspace-declared servers are scoped so that one project's `db` server is + * not confused with a different project's `db` server. + */ + scope: string | null + decision: 'allow' | 'deny' + decidedAt: number +} + +/** A whole workspace the user chose to trust, covering its current + future servers. */ +export interface McpWorkspaceTrust { + /** Absolute workspace path. */ + path: string + trustedAt: number +} + +/** The persisted trust state, as handed to the pure resolver. */ +export interface McpTrustStore { + entries: McpTrustEntry[] + workspaces: McpWorkspaceTrust[] +} + +/** User-controlled policy knobs. */ +export interface McpTrustPolicy { + /** + * Ask before starting any server Roxy hasn't run before, instead of starting + * it and reporting what it exposed. + * + * OFF by default: installing a server is itself the decision, so the useful + * output is "here is what it gave you", not "are you sure?". People who want + * the stricter posture (shared machines, untrusted repos) can opt in. + */ + confirmBeforeRun: boolean +} + +export const DEFAULT_TRUST_POLICY: McpTrustPolicy = { confirmBeforeRun: false } + +// --------------------------------------------------------------------------- +// The decision +// --------------------------------------------------------------------------- + +/** Why a server may start (or must not) — surfaced in logs, tests, and the UI. */ +export type McpTrustReason = + /** The user configured it themselves. */ + | 'self-consented' + /** The user trusted the whole workspace. */ + | 'workspace-trusted' + /** A stored allow decision matched this exact fingerprint. */ + | 'remembered-allow' + /** A stored deny decision matched this exact fingerprint. */ + | 'remembered-deny' + /** Not seen before; allowed, and disclosed to the user afterwards. */ + | 'first-run' + /** Not seen before, and the user opted into confirming first. */ + | 'confirm-first-run' + /** Approved before, but what would execute has changed since. */ + | 'changed' + +export interface McpTrustDecision { + allowed: boolean + /** True when the user must be asked BEFORE this server may start. */ + needsPrompt: boolean + /** + * True when the server may start, but the user should be TOLD - with its tool + * list and source - once it is up. The disclosure that replaces a prompt. + */ + needsDisclosure: boolean + reason: McpTrustReason + /** For `changed`, the fingerprint that was previously approved. */ + previousFingerprint?: string +} + +/** + * Decide what happens with a server. Pure: same inputs, same answer, no I/O. + * + * Order matters: + * 1. A remembered DENY — the user said no; honour it without re-asking. + * 2. A remembered ALLOW for this exact fingerprint — silent, the steady state. + * 3. A fingerprint MISMATCH → `changed`. The one blocking prompt: the user + * approved a different command, and swapping it is not their decision. + * Checked before workspace trust so a swap resurfaces even in a trusted + * project. + * 4. Self-consent / workspace trust → run silently. + * 5. Anything else → RUN, and disclose (or prompt, if the user opted in). + */ +export function decideTrust(input: { + id: string + config: McpServerConfig + provenance: McpProvenance + /** Absolute workspace path, when the server is scoped to one. */ + workspace?: string | null + store: McpTrustStore + policy?: McpTrustPolicy +}): McpTrustDecision { + const { id, config, provenance, store } = input + const policy = input.policy ?? DEFAULT_TRUST_POLICY + const workspace = input.workspace ?? null + + const fingerprint = fingerprintConfig(config) + const relevant = store.entries.filter((e) => e.id === id && inScope(e.scope, workspace)) + + const denied = relevant.find((e) => e.decision === 'deny' && e.fingerprint === fingerprint) + if (denied) { + return { + allowed: false, + needsPrompt: false, + needsDisclosure: false, + reason: 'remembered-deny' + } + } + + const allowed = relevant.find((e) => e.decision === 'allow' && e.fingerprint === fingerprint) + if (allowed) { + return { + allowed: true, + needsPrompt: false, + needsDisclosure: false, + reason: 'remembered-allow' + } + } + + // The one case worth interrupting for: this name was approved running + // something ELSE. Not a first install - a substitution. + const prior = relevant.find((e) => e.decision === 'allow') + if (prior) { + return { + allowed: false, + needsPrompt: true, + needsDisclosure: false, + reason: 'changed', + previousFingerprint: prior.fingerprint + } + } + + if (provenance === 'user') { + return { allowed: true, needsPrompt: false, needsDisclosure: false, reason: 'self-consented' } + } + + if (workspace && store.workspaces.some((w) => samePath(w.path, workspace))) { + return { + allowed: true, + needsPrompt: false, + needsDisclosure: false, + reason: 'workspace-trusted' + } + } + + if (policy.confirmBeforeRun) { + return { + allowed: false, + needsPrompt: true, + needsDisclosure: false, + reason: 'confirm-first-run' + } + } + + // Default: run it, then tell the user what it exposed and where it came from. + return { allowed: true, needsPrompt: false, needsDisclosure: true, reason: 'first-run' } +} + +/** + * Whether a stored decision applies here. A `null` scope is global (it came from + * a non-workspace source); a scoped entry only applies in its own workspace, so + * one repo's decision never leaks into another's. + */ +function inScope(scope: string | null, workspace: string | null): boolean { + if (scope === null) return true + if (!workspace) return false + return samePath(scope, workspace) +} + +/** + * Compare two absolute paths. Case-insensitive (Windows and macOS both have + * case-insensitive filesystems by default, and treating `C:\Repo` as a + * different project from `c:\repo` would re-disclose the same folder). + * Separators are normalized so a path that arrived over IPC still matches. + */ +export function samePath(a: string, b: string): boolean { + return normalizePath(a) === normalizePath(b) +} + +function normalizePath(p: string): string { + return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() +} + +// --------------------------------------------------------------------------- +// Payloads +// --------------------------------------------------------------------------- + +/** + * "Here's what you just installed" — sent AFTER a server connects, listing the + * tools it exposes and the source it came from. + * + * This is the normal path. It is not a permission request; it is the receipt. + */ +export interface McpInstallNotice { + id: string + provenance: McpProvenance + /** Workspace the server is scoped to, when it came from one. */ + workspace: string | null + disclosure: McpDisclosure + /** Unqualified tool names the server exposed, in discovery order. */ + tools: string[] + /** Set when the server failed to start; the notice doubles as the error report. */ + error?: string +} + +/** A blocking question. Only raised for `changed`, or when the user opts in. */ +export interface McpConsentRequest { + /** Correlates the renderer's answer with the awaiting main-process promise. */ + requestId: string + id: string + config: McpServerConfig + provenance: McpProvenance + workspace: string | null + disclosure: McpDisclosure + /** `changed` = a trusted entry was altered; `confirm-first-run` = opted-in check. */ + reason: Extract + /** Human-readable summary of the previously approved config, for `changed`. */ + previousSummary?: string +} + +/** The user's answer. `scope` says how widely to remember it. */ +export interface McpConsentResponse { + requestId: string + decision: 'allow' | 'deny' + /** + * - `once` run now, remember nothing. + * - `server` remember this server + fingerprint. + * - `workspace` trust the workspace: every server it declares, now and later. + */ + scope: 'once' | 'server' | 'workspace' +} From 36dd8d3102cf797c1bbd63055f18a616dfe395c3 Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:17:17 -0400 Subject: [PATCH 02/11] feat(mcp): keep tool results lossless internally A `tools/call` result is structured: ordered content blocks, optional `structuredContent`, and `_meta`. Roxy reduced all of it to `{ ok, output: string, image? }` the moment it arrived. That is right for one consumer, the model, and destructive for every other one. A resource link became the prose "[resource: file://x]" with the URI no longer addressable. A second image was dropped. `_meta` never survived at all, which is exactly where MCP Apps identifies the view a result belongs to. Results are now parsed into a typed model that keeps everything, and flattened only at the boundary that needs a string. Losslessness is bounded rather than unlimited: these connections are warm and long-lived, so every growable field is capped at parse time and the cap is recorded in the value (`truncated`, `omitted`, `droppedBlocks`) - a consumer must be able to tell "empty" from "too big to keep". Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- src/shared/mcp-content.ts | 421 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/shared/mcp-content.ts diff --git a/src/shared/mcp-content.ts b/src/shared/mcp-content.ts new file mode 100644 index 0000000..bbd388f --- /dev/null +++ b/src/shared/mcp-content.ts @@ -0,0 +1,421 @@ +/** + * MCP content blocks and results, kept LOSSLESS. + * + * Pure logic only (no Node/SDK imports) so every rule here is unit tested in + * smoke:shared. + * + * ## Why this module exists + * + * A `tools/call` result is a structured, typed thing: an ordered list of content + * blocks (text, image, audio, resource links, embedded resources), optional + * `structuredContent` matching the tool's `outputSchema`, and `_meta` carrying + * whatever extensions the server speaks. Roxy used to reduce all of that, at the + * moment it arrived, to `{ ok, output: string, image? }`. + * + * That flattening is correct for ONE consumer - the model, which reads text - + * and destructive for every other one. A resource link became the sentence + * "[resource: file://x]" with the URI no longer addressable. A second image was + * dropped entirely. `_meta` never survived at all, which is precisely where MCP + * Apps puts the identity of the view a result belongs to. + * + * So the rule this module enforces is: **parse into a typed model, keep + * everything, and flatten only at the boundary that actually needs a string.** + * `toModelText` is that boundary. Nothing else should be lowering a result. + * + * ## Bounded, not unlimited + * + * "Lossless" cannot mean "hold whatever a server sends". A tool can return a + * 200MB base64 blob, and MCP connections are warm and long-lived, so anything + * retained is retained for the life of the session. Every field that can grow + * without bound is capped HERE, at parse time, and the cap is recorded in the + * value itself (`truncated`, `omitted`) rather than left implicit - a consumer + * must be able to tell "empty" from "too big to keep". + */ + +import type { ToolResult } from './types' + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/** + * Caps applied when parsing a result. Deliberately generous for text (a file + * listing is legitimately large) and strict for binary (base64 in memory is the + * only thing here that can realistically exhaust a session). + */ +export const MCP_LIMITS = { + /** Per text-ish block, in chars. */ + textChars: 200_000, + /** Per inline binary payload (image/audio/blob), in base64 chars (~3MB decoded). */ + binaryChars: 4_000_000, + /** Blocks kept per result; beyond this we count and drop. */ + blocks: 1_000, + /** Serialized size cap for `structuredContent` / `_meta`, in chars. */ + jsonChars: 200_000 +} as const + +// --------------------------------------------------------------------------- +// Content blocks +// --------------------------------------------------------------------------- + +/** Text a server returned. */ +export interface McpTextBlock { + kind: 'text' + text: string + /** True when `text` was cut to `MCP_LIMITS.textChars`. */ + truncated?: boolean + annotations?: Record + _meta?: Record +} + +/** Inline binary (an image or audio clip) as base64, with its media type. */ +export interface McpBinaryBlock { + kind: 'image' | 'audio' + /** base64 payload, or undefined when it exceeded `MCP_LIMITS.binaryChars`. */ + data?: string + mimeType: string + /** True when the payload was dropped for size (so `data` is absent by policy). */ + omitted?: boolean + annotations?: Record + _meta?: Record +} + +/** + * A pointer to a resource the server hosts (`resource_link`). + * + * The URI is the whole point and must stay addressable: this is what a client + * calls `resources/read` with. Flattening it into prose - as the old renderer + * did - is exactly the loss this module exists to prevent. + */ +export interface McpResourceLinkBlock { + kind: 'resource_link' + uri: string + name?: string + title?: string + description?: string + mimeType?: string + annotations?: Record + _meta?: Record +} + +/** A resource embedded directly in the result (text or base64 blob). */ +export interface McpEmbeddedResourceBlock { + kind: 'resource' + uri: string + mimeType?: string + /** Inline text contents, when the resource is textual. */ + text?: string + /** Inline base64 contents, when it is binary. */ + blob?: string + truncated?: boolean + omitted?: boolean + annotations?: Record + _meta?: Record +} + +/** + * A block whose `type` this version of Roxy does not model. + * + * Kept rather than discarded, because the spec is actively gaining content + * types and "we didn't recognise it" is not a reason to make it unrecoverable. + * The raw JSON is retained (bounded) so a newer consumer can interpret it. + */ +export interface McpUnknownBlock { + kind: 'unknown' + /** The server's own `type` discriminator, when it had one. */ + type?: string + /** The block verbatim, serialized. Absent if it exceeded the JSON cap. */ + raw?: string +} + +export type McpContentBlock = + | McpTextBlock + | McpBinaryBlock + | McpResourceLinkBlock + | McpEmbeddedResourceBlock + | McpUnknownBlock + +/** + * One `tools/call` result, with nothing thrown away. + * + * This is what the service returns and what every non-model consumer should + * read. `toModelText` derives the string the model sees; it is a projection of + * this, never a replacement for it. + */ +export interface McpCallResult { + /** `isError: true` from the server (a tool-level failure, not a transport one). */ + isError: boolean + /** Content blocks in the order the server sent them. */ + content: McpContentBlock[] + /** Typed output, when the tool declares an `outputSchema`. */ + structuredContent?: unknown + /** Result-level extension data (MCP Apps and friends live here). */ + _meta?: Record + /** How many blocks were dropped for exceeding `MCP_LIMITS.blocks`. */ + droppedBlocks?: number +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v ? v : undefined +} + +/** Copy a `_meta`/`annotations` bag if it is a plain object AND fits the cap. */ +function bag(v: unknown): Record | undefined { + if (!isRecord(v)) return undefined + const json = safeJson(v) + if (!json || json.length > MCP_LIMITS.jsonChars) return undefined + return v +} + +function clampText(v: string): { text: string; truncated?: boolean } { + if (v.length <= MCP_LIMITS.textChars) return { text: v } + return { text: v.slice(0, MCP_LIMITS.textChars), truncated: true } +} + +/** Parse one content block, preserving everything that fits the caps. */ +function parseBlock(raw: unknown): McpContentBlock { + if (!isRecord(raw)) return { kind: 'unknown', raw: boundedJson(raw) } + const type = str(raw.type) + const annotations = bag(raw.annotations) + const meta = bag(raw._meta) + + if (type === 'text' && typeof raw.text === 'string') { + const { text, truncated } = clampText(raw.text) + return { kind: 'text', text, ...(truncated && { truncated }), annotations, _meta: meta } + } + + if ((type === 'image' || type === 'audio') && typeof raw.data === 'string') { + const mimeType = str(raw.mimeType) ?? (type === 'image' ? 'image/png' : 'audio/mpeg') + const tooBig = raw.data.length > MCP_LIMITS.binaryChars + return { + kind: type, + ...(tooBig ? { omitted: true } : { data: raw.data }), + mimeType, + annotations, + _meta: meta + } + } + + if (type === 'resource_link' && str(raw.uri)) { + return { + kind: 'resource_link', + uri: str(raw.uri)!, + name: str(raw.name), + title: str(raw.title), + description: str(raw.description), + mimeType: str(raw.mimeType), + annotations, + _meta: meta + } + } + + if (type === 'resource' && isRecord(raw.resource)) { + const r = raw.resource + const uri = str(r.uri) ?? '' + const block: McpEmbeddedResourceBlock = { + kind: 'resource', + uri, + mimeType: str(r.mimeType), + annotations, + _meta: meta + } + if (typeof r.text === 'string') { + const { text, truncated } = clampText(r.text) + block.text = text + if (truncated) block.truncated = true + } else if (typeof r.blob === 'string') { + if (r.blob.length > MCP_LIMITS.binaryChars) block.omitted = true + else block.blob = r.blob + } + return block + } + + // Unrecognised. A `text` field is still worth surfacing to the model, so a + // block that is merely NEWER than us doesn't read as empty - but it stays + // typed as unknown, because guessing at its semantics would be worse. + if (typeof raw.text === 'string') { + const { text, truncated } = clampText(raw.text) + return { kind: 'text', text, ...(truncated && { truncated }), annotations, _meta: meta } + } + return { kind: 'unknown', type, raw: boundedJson(raw) } +} + +/** + * Parse a raw `tools/call` result into the lossless model. + * + * Never throws: a malformed result degrades to an empty-but-valid value, since + * the caller is an agent turn that must not die because a server misbehaved. + */ +export function parseCallResult(raw: { + content?: unknown + isError?: boolean + structuredContent?: unknown + _meta?: unknown +}): McpCallResult { + const list = Array.isArray(raw?.content) ? raw.content : [] + const kept = list.slice(0, MCP_LIMITS.blocks) + const result: McpCallResult = { + isError: raw?.isError === true, + content: kept.map(parseBlock) + } + if (list.length > kept.length) result.droppedBlocks = list.length - kept.length + if (raw?.structuredContent !== undefined && raw.structuredContent !== null) { + result.structuredContent = raw.structuredContent + } + const meta = bag(raw?._meta) + if (meta) result._meta = meta + return result +} + +// --------------------------------------------------------------------------- +// Projection to the model's view +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/** One entry from `resources/list`. */ +export interface McpResourceInfo { + uri: string + name?: string + title?: string + description?: string + mimeType?: string +} + +/** + * The contents of one resource, as read. + * + * Text and binary are separate fields rather than one `data: string`, because a + * consumer must not have to guess which it got: an MCP App's HTML is text to be + * rendered, a PNG is bytes to be embedded, and conflating them is how you end up + * rendering base64 into a document. + */ +export interface McpResourceContents { + uri: string + mimeType?: string + /** Decoded text, when the resource is textual. */ + text?: string + /** base64 payload, when it is binary. */ + blob?: string + /** True when `text` was cut to `MCP_LIMITS.textChars`. */ + truncated?: boolean + /** True when a payload was dropped for exceeding `MCP_LIMITS.binaryChars`. */ + omitted?: boolean +} + +/** + * Parse a `resources/read` response. + * + * The wire returns an ARRAY of contents (one URI can expand to several parts). + * Roxy reads one addressable resource at a time, so the first entry is the + * answer; flattening that here keeps every call site from re-deciding it. + * + * Bounded on the same terms as content blocks - see the note on `MCP_LIMITS`. + */ +export function parseResourceContents(uri: string, contents: unknown): McpResourceContents { + const first = Array.isArray(contents) ? contents[0] : undefined + if (!isRecord(first)) return { uri } + const out: McpResourceContents = { + uri: str(first.uri) ?? uri, + mimeType: str(first.mimeType) + } + if (typeof first.text === 'string') { + const { text, truncated } = clampText(first.text) + out.text = text + if (truncated) out.truncated = true + } else if (typeof first.blob === 'string') { + if (first.blob.length > MCP_LIMITS.binaryChars) out.omitted = true + else out.blob = first.blob + } + return out +} + +/** + * Render a result as the text the MODEL sees. + * + * The single place a lossless result is allowed to become a string. Every block + * contributes something legible: a resource link keeps its URI (so the model can + * ask for it by name), an omitted payload says so rather than vanishing, and a + * block we don't understand still reports its type instead of silently emptying. + */ +export function toModelText(result: McpCallResult): string { + const parts: string[] = [] + for (const b of result.content) { + switch (b.kind) { + case 'text': + parts.push(b.truncated ? `${b.text}\n…[truncated]` : b.text) + break + case 'image': + case 'audio': + parts.push(b.omitted ? `[${b.kind} omitted: too large]` : `[${b.kind}: ${b.mimeType}]`) + break + case 'resource_link': + // The URI stays verbatim: it is an address the model can act on. + parts.push(`[resource: ${b.name || b.title || b.uri}](${b.uri})`) + break + case 'resource': + if (b.text) parts.push(b.truncated ? `${b.text}\n…[truncated]` : b.text) + else if (b.omitted) parts.push(`[resource ${b.uri}: contents too large]`) + else parts.push(`[resource: ${b.uri}]`) + break + case 'unknown': + parts.push(`[unsupported content${b.type ? `: ${b.type}` : ''}]`) + break + } + } + if (result.droppedBlocks) { + parts.push(`…[${result.droppedBlocks} further block(s) omitted]`) + } + + // Only fall back to the structured half when the blocks said nothing. A server + // returning both means text for the model and structure for the application; + // appending the JSON too would pay twice in context for one answer. + let joined = parts.join('\n').trim() + if (!joined && result.structuredContent !== undefined) { + joined = boundedJson(result.structuredContent) ?? '' + } + return joined +} + +/** + * Lower a lossless result to the flat `ToolResult` the agent loop and UI use. + * + * Keeps the FIRST inline image as the renderable preview, matching how every + * other Roxy tool reports imagery. + */ +export function toToolResult(result: McpCallResult): ToolResult { + const text = toModelText(result) + const output = + text || (result.isError ? 'The MCP tool reported an error with no message.' : '(no output)') + const out: ToolResult = { ok: !result.isError, output } + const img = result.content.find( + (b): b is McpBinaryBlock => b.kind === 'image' && !!(b as McpBinaryBlock).data + ) + if (img?.data) out.image = `data:${img.mimeType};base64,${img.data}` + return out +} + +/** JSON for display, bounded, never throwing on a cycle or a BigInt. */ +function boundedJson(value: unknown): string | undefined { + const json = safeJson(value) + if (!json) return undefined + return json.length > MCP_LIMITS.jsonChars ? json.slice(0, MCP_LIMITS.jsonChars) : json +} + +function safeJson(value: unknown): string | undefined { + try { + return JSON.stringify(value, null, 2) ?? undefined + } catch { + return undefined + } +} From 2ce9794992ad63755796da1d11d6ce93a81ea542 Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:17:17 -0400 Subject: [PATCH 03/11] feat(mcp): move to the v2 client and speak both protocol eras MCP split into two behavior families: `legacy` (2024-10-07 through 2025-11-25) opens with an `initialize` handshake, `modern` (2026-07-28+) replaces it with a `server/discover` advertisement and a per-request `_meta` envelope. Roxy now connects with `versionNegotiation: { mode: 'auto' }`, so each server is probed once and lands on whichever era it actually speaks. The probe cost is paid once per server per session because the pool is warm, and it is bounded by a short timeout so legacy servers are not slowed down. Alongside the migration, this adds the core features the client was missing: `resources/list`/`resources/read` (also how MCP Apps delivers a UI), OAuth for remote servers with tokens encrypted via the OS keychain, real cancellation through `AbortSignal`, list-change refresh, cache hints, and per-server request timeouts. Three bugs fixed on the way: - The hand-rolled pagination loop was inverted under v2: `listTools()` aggregates pages itself, and passing a cursor asks for a single raw page, so the old loop fetched page one and then re-requested page two unaggregated. - `structuredContent` was dropped, so a tool returning only typed output reported as "(no output)". - Calls arrive by tool name, so `callMcpTool` had no config in hand and always used the global 120s default; a server configured `timeout: 800` still hung a turn for two minutes. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- package-lock.json | 959 +--------------------------------- package.json | 2 +- src/main/services/mcp-auth.ts | 211 ++++++++ src/main/services/mcp.ts | 535 +++++++++++++++++-- src/shared/mcp.ts | 110 ++-- 5 files changed, 791 insertions(+), 1026 deletions(-) create mode 100644 src/main/services/mcp-auth.ts diff --git a/package-lock.json b/package-lock.json index f3417b8..26aae58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@electron-toolkit/utils": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", "@pierre/diffs": "^1.2.11", "ai": "^5.0.210", "better-sqlite3": "^12.11.1", @@ -1313,18 +1313,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -2116,68 +2104,36 @@ "@chevrotain/types": "~11.1.1" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod": "^4.2.0" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "node": ">=20" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "zod": "^4.2.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=20" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@npmcli/fs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", @@ -3648,53 +3604,6 @@ "dev": true, "license": "ISC" }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3767,45 +3676,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -4215,59 +4085,6 @@ "bluebird": "^3.5.5" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -4440,15 +4257,6 @@ "node": ">= 10.0.0" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4581,6 +4389,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4590,22 +4399,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -5007,28 +4800,6 @@ "dev": true, "license": "ISC" }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5049,15 +4820,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -5065,23 +4827,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -5816,15 +5561,6 @@ "dev": true, "license": "MIT" }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6039,6 +5775,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6056,12 +5793,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6398,15 +6129,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -6473,6 +6195,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6482,6 +6205,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6491,6 +6215,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6581,12 +6306,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6610,15 +6329,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6656,101 +6366,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6792,6 +6407,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -6801,22 +6417,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -6889,27 +6489,6 @@ "node": ">=10" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6957,24 +6536,6 @@ "node": ">= 6" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -7034,6 +6595,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7084,6 +6646,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7108,6 +6671,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7243,6 +6807,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7315,6 +6880,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7350,6 +6916,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7530,15 +7097,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -7607,26 +7165,6 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7839,20 +7377,12 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -7949,12 +7479,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -8108,12 +7632,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -8773,6 +8291,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9060,27 +8579,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mermaid": { "version": "11.15.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", @@ -10126,27 +9624,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -10157,18 +9634,6 @@ "node": ">= 0.4" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10326,15 +9791,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -10394,16 +9850,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -10614,19 +10060,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -10656,22 +10089,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -10684,50 +10101,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -11058,15 +10431,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -11225,22 +10589,6 @@ "points-on-path": "^0.2.1" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -11317,57 +10665,6 @@ "license": "MIT", "optional": true }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -11384,25 +10681,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -11416,12 +10694,6 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/sharp": { "version": "0.35.2", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", @@ -11520,78 +10792,6 @@ "node": ">=20" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -11811,15 +11011,6 @@ "node": ">= 6" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/streamdown": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.5.0.tgz", @@ -12188,15 +11379,6 @@ "tmp": "^0.2.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -12269,62 +11451,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -12467,15 +11593,6 @@ "node": ">= 4.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -12552,15 +11669,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -12924,15 +12032,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index b7a7012..5262c36 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@electron-toolkit/utils": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", "@pierre/diffs": "^1.2.11", "ai": "^5.0.210", "better-sqlite3": "^12.11.1", diff --git a/src/main/services/mcp-auth.ts b/src/main/services/mcp-auth.ts new file mode 100644 index 0000000..f76862b --- /dev/null +++ b/src/main/services/mcp-auth.ts @@ -0,0 +1,211 @@ +/** + * OAuth for remote MCP servers. + * + * A remote MCP server is an HTTP API, and the interesting ones are the ones that + * hold something worth protecting - your issues, your calendar, your database. + * The spec's answer is OAuth 2.1 with dynamic client registration and PKCE, and + * the SDK implements the whole dance provided the host supplies two things: + * somewhere to persist credentials, and a way to put a browser in front of the + * user for the one interactive step. + * + * This module is that host side. + * + * ## Why a bespoke store + * + * Tokens are persisted through `secure.ts` (OS keychain via Electron + * `safeStorage`, base64 fallback) rather than dropped in the settings table as + * plain JSON, for the same reason every other credential in Roxy is: a refresh + * token is a long-lived bearer credential for a third-party account, and a + * config file is not a place to keep one. + * + * Everything is keyed by SERVER ID rather than by issuer, because that is the + * identity the rest of the MCP subsystem uses. A server pointed at a new URL is + * a new authorization anyway - the stored client registration no longer matches + * the issuer, and the SDK re-registers. + * + * ## Why the callback is a loopback server + * + * The redirect has to land somewhere. A custom protocol handler (`roxy://`) is + * the other option, but it needs OS-level registration that only exists in a + * packaged build - so it would work in production and silently fail in dev, + * which is the worst possible split for an auth flow. An ephemeral loopback + * listener works identically everywhere and is what the spec recommends for + * native apps (RFC 8252). + */ +import { createServer, type Server } from 'node:http' +import { shell } from 'electron' +import type { OAuthClientProvider } from '@modelcontextprotocol/client' +import * as repo from '../db/repo' + +/** + * How long the loopback listener waits for the browser to come back before it + * gives up and frees the port. + * + * Generous: this window covers a human signing in, possibly creating an account + * and clearing an MFA prompt on a phone. Too short and the flow fails at the + * exact moment the user finally finished. + */ +const CALLBACK_TIMEOUT = 5 * 60_000 + +/** Loopback host for the redirect. Literal IP, not `localhost`. */ +const CALLBACK_HOST = '127.0.0.1' + +/** What the browser tab shows once the redirect has been captured. */ +const DONE_PAGE = `Signed in + +

Signed in

You can close this tab and return to Roxy.

` + +/** + * One pending authorization: the loopback listener and the promise the connect + * path is waiting on. + */ +interface PendingAuth { + server: Server + redirectUrl: string + /** Resolves with the full callback query once the browser hits the listener. */ + code: Promise +} + +const pending = new Map() + +/** + * Start (or reuse) a loopback listener for one server's redirect. + * + * Port 0 lets the OS pick a free port; the chosen one becomes part of the + * redirect URI, so nothing is hardcoded and two servers authorizing at once + * cannot collide. + */ +async function listenForCallback(serverId: string): Promise { + const existing = pending.get(serverId) + if (existing) return existing + + let resolveCode: (params: URLSearchParams) => void + let rejectCode: (err: Error) => void + const code = new Promise((res, rej) => { + resolveCode = res + rejectCode = rej + }) + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${CALLBACK_HOST}`) + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(DONE_PAGE) + resolveCode(url.searchParams) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, CALLBACK_HOST, resolve) + }) + + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + const entry: PendingAuth = { + server, + redirectUrl: `http://${CALLBACK_HOST}:${port}/callback`, + code + } + pending.set(serverId, entry) + + const timer = setTimeout(() => { + rejectCode(new Error('Timed out waiting for the browser to complete sign-in.')) + closeCallback(serverId) + }, CALLBACK_TIMEOUT) + // Never hold the process open on this listener alone. + timer.unref?.() + void code.finally(() => clearTimeout(timer)) + + return entry +} + +/** Tear down a server's loopback listener, if any. */ +export function closeCallback(serverId: string): void { + const entry = pending.get(serverId) + if (!entry) return + pending.delete(serverId) + try { + entry.server.close() + } catch { + /* already closed */ + } +} + +/** + * The `OAuthClientProvider` the SDK drives. + * + * The SDK owns the protocol (discovery, PKCE, registration, refresh); this + * supplies persistence and the one step a library cannot do for itself - putting + * the authorization URL in front of a human. + */ +export function mcpAuthProvider(serverId: string, redirectUrl: string): OAuthClientProvider { + return { + get redirectUrl() { + return redirectUrl + }, + get clientMetadata() { + return { + client_name: 'Roxy', + redirect_uris: [redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + } + }, + clientInformation() { + return repo.getMcpOAuthClient(serverId) ?? undefined + }, + saveClientInformation(info) { + repo.saveMcpOAuthClient(serverId, info) + }, + tokens() { + return repo.getMcpOAuthTokens(serverId) ?? undefined + }, + saveTokens(tokens) { + repo.saveMcpOAuthTokens(serverId, tokens) + }, + redirectToAuthorization(authorizationUrl) { + // The only genuinely interactive step. Opened in the user's real browser, + // not an embedded window: they may already have a session there, and an + // embedded view asking for third-party credentials is a phishing pattern + // even when it is legitimate. + void shell.openExternal(authorizationUrl.toString()) + }, + saveCodeVerifier(verifier) { + repo.saveMcpOAuthVerifier(serverId, verifier) + }, + codeVerifier() { + const v = repo.getMcpOAuthVerifier(serverId) + if (!v) throw new Error('No PKCE code verifier is stored for this server.') + return v + } + } +} + +/** + * Run one interactive authorization for a server. + * + * Returns the callback query so the caller can hand it to `finishAuth`, which + * validates `iss` (RFC 9207) and exchanges the code. + */ +export async function awaitAuthorization(serverId: string): Promise { + const entry = pending.get(serverId) + if (!entry) throw new Error('No authorization is in progress for this server.') + try { + return await entry.code + } finally { + closeCallback(serverId) + } +} + +/** Prepare a listener and return the redirect URI to register with the server. */ +export async function prepareAuthorization(serverId: string): Promise { + const entry = await listenForCallback(serverId) + return entry.redirectUrl +} + +/** Forget every stored credential for a server (used when it is removed). */ +export function clearMcpAuth(serverId: string): void { + closeCallback(serverId) + repo.clearMcpOAuth(serverId) +} diff --git a/src/main/services/mcp.ts b/src/main/services/mcp.ts index 882895c..4664dcd 100644 --- a/src/main/services/mcp.ts +++ b/src/main/services/mcp.ts @@ -1,28 +1,48 @@ /** - * MCP (Model Context Protocol) client service — connects external tool servers - * and exposes their tools to the agent loop. Built on the official - * `@modelcontextprotocol/sdk`, which handles the transports (stdio + Streamable - * HTTP, with an SSE fallback) and protocol negotiation for us. + * MCP (Model Context Protocol) client service - connects external tool servers + * and exposes their tools to the agent loop. Built on the official v2 SDK + * (`@modelcontextprotocol/client`), which owns the transports and the protocol + * handshake for us. + * + * ## Two protocol eras, one client + * + * MCP split into two behavior families. The `legacy` era (`2024-10-07` through + * `2025-11-25`) opens with an `initialize` handshake; the `modern` era + * (`2026-07-28`+) has no handshake at all - it advertises via `server/discover` + * and carries a `_meta` envelope on every request. + * + * Roxy connects with `versionNegotiation: { mode: 'auto' }`, so each server is + * probed once and lands on whichever era it actually speaks. That is what makes + * this a *client* rather than a client for one vintage of the spec: a 2026 + * server gets the modern wire, and a server pinned to 2025 keeps working + * untouched. `conn.era` records where each one landed, for the UI and for + * anything that has to reason about era-specific capabilities later. + * + * The probe costs one round trip per connect. Roxy pools connections warmly, so + * that is once per server per session - not per tool call. On stdio the SDK runs + * the probe on a disposable sibling process (some servers exit on any + * pre-`initialize` request), and a silent server is simply read as legacy. + * + * ## Design (mirrors the LSP service's warm-pool + graceful-degradation shape) * - * Design (mirrors the LSP service's warm-pool + graceful-degradation shape): * - A process-wide pool keyed by server id. Connections are lazy (established on * first `ensureMcpConnected`) and warm (reused across turns). * - Nothing here ever throws into the agent loop: a server that fails to spawn, - * times out, or returns garbage degrades to "no tools" / an error ToolResult — + * times out, or returns garbage degrades to "no tools" / an error ToolResult - * it never breaks a turn. A `ROXY_MCP=0` env var disables the whole subsystem. * - The pure protocol-independent logic (naming, schema conversion, result * rendering, prompt blurb) lives in `src/shared/mcp.ts` and is unit-tested in * smoke:shared; this file is exercised end-to-end against a mock server in * smoke:app. */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/client/stdio' import { - StdioClientTransport, - getDefaultEnvironment -} from '@modelcontextprotocol/sdk/client/stdio.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' + Client, + StreamableHTTPClientTransport, + SSEClientTransport, + type Transport +} from '@modelcontextprotocol/client' +import type { OAuthClientProvider } from '@modelcontextprotocol/client' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import type { ToolResult } from '../../shared/types' @@ -34,19 +54,113 @@ import { mcpToolToSchema, normalizeServerRecords, qualifyToolName, - renderMcpContent, type McpLocalConfig, + type McpProtocolEra, type McpRemoteConfig, type McpServerRecord, type McpServerSummary, + type McpToolDefinition, type RoxyToolSchema } from '../../shared/mcp' +import * as repo from '../db/repo' +import { mcpAuthProvider, prepareAuthorization, awaitAuthorization, clearMcpAuth } from './mcp-auth' +import { + parseCallResult, + parseResourceContents, + toToolResult, + type McpCallResult, + type McpResourceContents, + type McpResourceInfo +} from '../../shared/mcp-content' +import { isAppOnlyTool } from '../../shared/mcp-apps' -const CLIENT_INFO = { name: 'roxy', version: '0.0.13' } +/** + * Identity Roxy presents to servers. + * + * Read from Electron rather than hardcoded: this used to be a hand-maintained + * `0.0.13` that had drifted ~80 releases behind the app, which makes any + * server-side telemetry or version-gating keyed on it actively misleading. + * + * Resolved lazily and defensively because this module is also imported by the + * plain-Node smoke run, where `electron.app` is not available. A version string + * is not worth an import-time crash. + */ +function clientInfo(): { name: string; version: string } { + let version = '0.0.0' + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + version = (require('electron') as typeof import('electron')).app.getVersion() + } catch { + /* not running under Electron (smoke); the name is what identifies us */ + } + return { name: 'roxy', version } +} /** ms to establish + initialize a server before giving up (per-server override wins). */ const DEFAULT_STARTUP_TIMEOUT = 15_000 /** ms budget for a single `tools/call` (tool work can be genuinely slow). */ const DEFAULT_REQUEST_TIMEOUT = 120_000 +/** + * Budget for the `server/discover` probe that decides a connection's era. + * + * Deliberately short and separate from the startup timeout. On stdio a silent + * server IS the legacy answer, so this is the delay every 2025-era server pays + * before falling back to `initialize` - it must stay small enough that adding + * era negotiation never feels like a regression on servers that already worked. + */ +const PROBE_TIMEOUT = 3_000 + +/** + * Client options shared by every connection. + * + * `mode: 'auto'` is the whole point of the v2 migration: probe with + * `server/discover`, fall back to the 2025 `initialize` handshake when the + * server doesn't answer it. One client, both eras, no per-server configuration. + * + * The SDK's warning about `'auto'` (it can stall a spawn-per-invocation CLI for + * the probe timeout) does not bite here: Roxy holds a warm pool, so the probe is + * paid once per server per session, and `PROBE_TIMEOUT` bounds it either way. + */ +function clientOptions(serverId: string): ConstructorParameters[1] { + return { + capabilities: {}, + versionNegotiation: { mode: 'auto', probe: { timeoutMs: PROBE_TIMEOUT } }, + // Cache results from servers that send no freshness hint of their own. + // Servers that DO send one always win: the SDK honours `ttlMs`/`cacheScope` + // per result, and this is only the floor for those that say nothing. + defaultCacheTtlMs: DEFAULT_CACHE_TTL, + // Let the SDK own the `subscriptions/listen` stream where the server + // supports it. Hand-rolling this would mean re-implementing the filter + // negotiation, the era split (2026 `listen` vs 2025 unsolicited + // notifications), and the close/re-listen policy - all of which the SDK + // already does, and all of which it also wires to cache eviction. + // + // A `list_changed` here means the warm pool is holding a stale tool list, so + // the refresh has to write back into `toolIndex`, not just log. + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error || !tools) return + onToolsChanged(serverId, tools as McpToolDefinition[]) + } + } + } + } +} + +/** + * TTL applied to cacheable results from servers that send no hint of their own. + * + * Caching is two halves: a 2026-era server marks a result with `ttlMs` and the + * SDK's per-client cache honours it. A server that sends nothing gets `ttlMs: 0` + * and is never cached - which is every 2025-era server, i.e. most of them today. + * + * A short opt-in default covers the case this actually matters for: `listTools` + * is re-read on every turn to rebuild the model's tool list, and re-listing an + * unchanged server each time is pure latency. Deliberately seconds, not minutes, + * because a legacy server has no way to tell us it changed - and `listChanged` + * eviction (below) only exists where the server supports it. + */ +const DEFAULT_CACHE_TTL = 10_000 /** Kill switch: set ROXY_MCP=0 to disable all MCP connections. */ function mcpDisabled(): boolean { @@ -64,6 +178,17 @@ interface McpToolInfo { toolName: string serverId: string schema: RoxyToolSchema + /** + * The server's own tool definition, verbatim - including `_meta`, + * `outputSchema`, `title`, `icons` and annotations. + * + * Kept because the model-facing `schema` above is a lossy projection, and + * everything the modern spec adds lives in the parts it drops. MCP Apps finds + * its UI resource at `_meta['io.modelcontextprotocol/ui'].resourceUri`, and + * app-only tools are marked in that same `_meta`. Re-listing a server to + * recover fields we already received would be the wrong trade. + */ + definition: McpToolDefinition } interface McpConnection { @@ -72,9 +197,40 @@ interface McpConnection { status: 'connected' | 'error' error?: string tools: McpToolInfo[] + /** + * Which protocol era this connection actually landed on: `'modern'` for + * 2026-07-28+ (negotiated via `server/discover`), `'legacy'` for the 2025 + * `initialize` handshake. Recorded rather than assumed, because era decides + * which capabilities are even expressible on this connection. + */ + era?: McpProtocolEra + /** + * This server's own request budget, resolved once at connect. + * + * Held on the connection because calls arrive by TOOL name, not by record: + * `callMcpTool` has no config in hand, and without this every call fell back + * to the global default - so a server configured with `timeout: 5000` still + * hung a turn for two minutes. + */ + requestTimeout: number + /** Whether the server advertised `resources`, so we don't ask servers that can't. */ + hasResources: boolean } /** Warm pool: server id → connection (connected or errored/cached). */ +/** + * The full, unflattened result of the most recent call to each MCP tool. + * + * Pool state, declared here with the rest of it: `forgetServer` clears entries + * on disconnect, so the two must stay visibly coupled. + * + * Bounded two ways: the parse caps every field (see `MCP_LIMITS`), and only the + * latest result per tool is held - an agent turn can call one tool hundreds of + * times, and keeping a history in a warm, long-lived pool would be a slow leak. + * This is a handoff buffer for the consumer that needs structure right after a + * call, not a transcript. + */ +const lastResults = new Map() const connections = new Map() /** In-flight connects, so concurrent `ensureMcpConnected` calls don't double-spawn. */ const connecting = new Map>() @@ -102,15 +258,30 @@ function makeStdioTransport(cfg: McpLocalConfig, workspaceCwd: string): Transpor }) } -/** Ordered transport attempts for a record (remote tries Streamable HTTP, then SSE). */ -function transportFactories(rec: McpServerRecord, workspaceCwd: string): Array<() => Transport> { +/** + * Ordered transport attempts for a record (remote tries Streamable HTTP, then SSE). + * + * Remote transports carry an `authProvider` when the server has stored OAuth + * credentials, or when the user has explicitly started a sign-in. It is NOT + * attached unconditionally: passing one makes the SDK treat a 401 as "begin an + * authorization flow", which would pop a browser window at any server that + * happens to be down or misconfigured. Auth is something the user opts into. + */ +function transportFactories( + rec: McpServerRecord, + workspaceCwd: string, + auth?: OAuthClientProvider +): Array<() => Transport> { if (rec.config.type === 'local') { const cfg = rec.config return [() => makeStdioTransport(cfg, workspaceCwd)] } const cfg = rec.config as McpRemoteConfig const url = new URL(cfg.url) - const init = cfg.headers ? { requestInit: { headers: cfg.headers } } : undefined + const init = { + ...(cfg.headers ? { requestInit: { headers: cfg.headers } } : {}), + ...(auth ? { authProvider: auth } : {}) + } return [ () => new StreamableHTTPClientTransport(url, init), () => new SSEClientTransport(url, init) @@ -129,11 +300,15 @@ function requestTimeout(rec: McpServerRecord): number { // --------------------------------------------------------------------------- /** Connect a single server and discover its tools. Never throws → errored connection. */ -async function connectOne(rec: McpServerRecord, workspaceCwd: string): Promise { - const attempts = transportFactories(rec, workspaceCwd) +async function connectOne( + rec: McpServerRecord, + workspaceCwd: string, + auth?: OAuthClientProvider +): Promise { + const attempts = transportFactories(rec, workspaceCwd, auth) let lastErr: unknown for (const make of attempts) { - const client = new Client(CLIENT_INFO, { capabilities: {} }) + const client = new Client(clientInfo(), clientOptions(rec.id)) try { const transport = make() await client.connect(transport, { timeout: startupTimeout(rec) }) @@ -141,7 +316,15 @@ async function connectOne(rec: McpServerRecord, workspaceCwd: string): Promise { + const res = await client.listTools(undefined, { timeout: requestTimeout(rec) }) + return buildToolInfos(rec.id, (res.tools ?? []) as McpToolDefinition[]) +} + +/** + * A server told us its tool list changed and the SDK re-fetched it. + * + * The pool is warm and the model's tool list is rebuilt from `toolIndex` each + * turn, so a stale entry means the agent is offered a tool the server no longer + * has - it would fail at call time with a confusing error. Rebuilding here is + * what makes `listChanged` worth subscribing to at all. + */ +function onToolsChanged(serverId: string, tools: McpToolDefinition[]): void { + const conn = connections.get(serverId) + if (!conn || conn.status !== 'connected') return + conn.tools = buildToolInfos(serverId, tools) + indexTools(conn) +} + +/** Namespace + dedupe a server's raw tool definitions into routable entries. */ +function buildToolInfos(serverId: string, tools: McpToolDefinition[]): McpToolInfo[] { const infos: McpToolInfo[] = [] const seen = new Set() - let cursor: string | undefined - do { - const res = await client.listTools(cursor ? { cursor } : undefined, { - timeout: requestTimeout(rec) + for (const t of tools) { + if (!t || typeof t.name !== 'string' || !t.name) continue + const qualified = uniqueName(qualifyToolName(serverId, t.name), seen) + seen.add(qualified) + infos.push({ + qualifiedName: qualified, + toolName: t.name, + serverId, + schema: mcpToolToSchema(qualified, t.description, t.inputSchema), + definition: t }) - for (const t of res.tools ?? []) { - if (!t || typeof t.name !== 'string' || !t.name) continue - const qualified = uniqueName(qualifyToolName(rec.id, t.name), seen) - seen.add(qualified) - infos.push({ - qualifiedName: qualified, - toolName: t.name, - serverId: rec.id, - schema: mcpToolToSchema(qualified, t.description, t.inputSchema) - }) - } - cursor = res.nextCursor - } while (cursor) + } return infos } @@ -189,9 +411,26 @@ function uniqueName(name: string, seen: Set): string { } } +/** + * Drop everything keyed to one server: its tool index entries and any cached + * call results. + * + * Both must go together. A cached result is keyed by qualified tool name, so + * leaving one behind after a disconnect would let a later consumer read stale + * structure for a tool that no longer exists - and, in a warm pool, hold its + * payload for the rest of the session. + */ +function forgetServer(id: string): void { + for (const [key, info] of toolIndex) { + if (info.serverId !== id) continue + toolIndex.delete(key) + lastResults.delete(key) + } +} + function indexTools(conn: McpConnection): void { // Drop any stale tools this server previously registered, then re-index. - for (const [key, info] of toolIndex) if (info.serverId === conn.id) toolIndex.delete(key) + forgetServer(conn.id) for (const t of conn.tools) toolIndex.set(t.qualifiedName, t) } @@ -199,7 +438,7 @@ function indexTools(conn: McpConnection): void { function onTransportClosed(id: string, client: Client): void { const conn = connections.get(id) if (!conn || conn.client !== client) return // superseded by a newer connection - for (const [key, info] of toolIndex) if (info.serverId === id) toolIndex.delete(key) + forgetServer(id) conn.status = 'error' conn.error = conn.error ?? 'The MCP server disconnected.' conn.client = null @@ -212,7 +451,15 @@ function getConnection(rec: McpServerRecord, workspaceCwd: string): Promise = connectOne(rec, workspaceCwd).then((conn) => { + // Attach OAuth only when this server already has credentials. Passing an + // auth provider makes the SDK treat a 401 as "start an authorization flow", + // which would pop a browser at any server that is merely down. Signing in is + // something the user does deliberately (`signInMcpServer`). + const auth = + rec.config.type === 'remote' && repo.hasMcpOAuth(rec.id) + ? mcpAuthProvider(rec.id, REDIRECT_PLACEHOLDER) + : undefined + const p: Promise = connectOne(rec, workspaceCwd, auth).then((conn) => { // If we were disposed or superseded by a newer connect while this one was in // flight, don't resurrect the pool entry — just release this child process. // Whoever superseded us already owns `connections`/`toolIndex`; leave them be. @@ -271,15 +518,39 @@ export function mcpToolSchemas(ids?: Set): RoxyToolSchema[] { for (const conn of connections.values()) { if (conn.status !== 'connected') continue if (ids && !ids.has(conn.id)) continue - for (const t of conn.tools) out.push(t.schema) + for (const t of conn.tools) { + // App-only tools are omitted from the MODEL's list. + // + // `visibility: ['app']` is how a server exposes fine-grained operations to + // its own view (`set_cell`, `select_row`) without filling the model's tool + // list with dozens of them. SEP-1865 makes this a MUST NOT for hosts, and + // it is a correctness issue as much as a spec one: offering the model a + // tool the server said was not for it invites calls the server never + // meant to serve. + // + // They stay in `toolIndex`, so the view can still call them through the + // broker - hidden from the model is not the same as unavailable. + if (isAppOnlyTool(t.definition._meta)) continue + out.push(t.schema) + } } return out } -/** Route + run an MCP tool call, rendering the result. Never throws. */ +/** + * Route + run an MCP tool call, rendering the result. Never throws. + * + * `signal` is the turn's abort signal. Passing it through matters because MCP + * tool calls are the slowest thing an agent does and the most likely to be + * running when a user hits stop: without it the SDK keeps waiting on the wire, + * the server keeps working, and the child process stays busy long after the turn + * that wanted the answer has gone. The SDK also emits `notifications/cancelled`, + * so a well-behaved server can stop its own work rather than finish into a void. + */ export async function callMcpTool( name: string, - args: Record + args: Record, + signal?: AbortSignal ): Promise { const info = toolIndex.get(name) if (!info) return { ok: false, output: `Unknown MCP tool: ${name}` } @@ -288,17 +559,107 @@ export async function callMcpTool( return { ok: false, output: `MCP server "${info.serverId}" is not connected.` } } try { + // v2 dropped the result-schema argument: `callTool(params, options)`. const res = await conn.client.callTool( { name: info.toolName, arguments: args ?? {} }, - undefined, - { timeout: DEFAULT_REQUEST_TIMEOUT } + // The server's own budget, not the global default (see `requestTimeout`). + { timeout: conn.requestTimeout, signal } ) - return renderMcpContent(res.content, res.isError === true) + // Parse ONCE into the lossless model, cache it, and hand the caller the flat + // projection. Everything the flat form cannot express - resource URIs, extra + // images, result `_meta`, unrecognised block types - stays reachable via + // `lastMcpCallResult` instead of being destroyed on arrival. + const parsed = parseCallResult(res) + lastResults.set(name, parsed) + return toToolResult(parsed) } catch (e) { + // A cancelled call is not a failure to report to the model as a tool error - + // the turn it belonged to is already gone. Name it plainly so a retry + // doesn't read it as "the server is broken". + if (signal?.aborted) return { ok: false, output: `MCP tool "${name}" was cancelled.` } return { ok: false, output: `MCP tool "${name}" failed: ${errMsg(e)}` } } } +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +/** + * List a server's resources, or `[]` when it exposes none. + * + * Resources are the half of MCP that isn't tools: files, configs, database rows + * a server is willing to hand over as context. Roxy needs them for MCP Apps in + * particular, where the UI itself arrives as a `ui://` resource read over this + * same path. + * + * Reads are cached by the SDK per the server's own `ttlMs`, so calling this on a + * warm connection is cheap. + */ +export async function listMcpResources(serverId: string): Promise { + const conn = connections.get(serverId) + if (!conn || conn.status !== 'connected' || !conn.client || !conn.hasResources) return [] + try { + const res = await conn.client.listResources(undefined, { timeout: conn.requestTimeout }) + return (res.resources ?? []).map((r) => ({ + uri: r.uri, + name: r.name, + title: r.title, + description: r.description, + mimeType: r.mimeType + })) + } catch { + return [] + } +} + +/** + * Read one resource by URI. Returns its contents, or an error string. + * + * Never throws, mirroring `callMcpTool`: a resource read happens inside an agent + * turn, and a server that 404s a URI must not take the turn with it. + */ +export async function readMcpResource( + serverId: string, + uri: string, + signal?: AbortSignal +): Promise { + const conn = connections.get(serverId) + if (!conn || conn.status !== 'connected' || !conn.client) { + return { error: `MCP server "${serverId}" is not connected.` } + } + try { + const res = await conn.client.readResource({ uri }, { timeout: conn.requestTimeout, signal }) + return parseResourceContents(uri, res.contents) + } catch (e) { + if (signal?.aborted) return { error: `Reading ${uri} was cancelled.` } + return { error: `Could not read ${uri}: ${errMsg(e)}` } + } +} + +/** + * The lossless result of the last call to `qualifiedName`, if any. + * + * The seam MCP Apps reads: a UI-bearing tool's result carries the data its view + * renders, and the flat `ToolResult` is a lossy projection of exactly that. + */ +export function lastMcpCallResult(qualifiedName: string): McpCallResult | undefined { + return lastResults.get(qualifiedName) +} + +/** + * The server's own definition of one namespaced tool, or undefined if unknown. + * + * The seam MCP Apps builds on: a UI-bearing tool declares its view at + * `_meta['io.modelcontextprotocol/ui'].resourceUri`, and app-only tools are + * flagged in the same `_meta`. Exposed as a lookup rather than folded into + * `mcpToolSchemas` because the schema list is the MODEL's view, and these fields + * are deliberately not part of it. + */ +export function mcpToolDefinition(name: string): McpToolDefinition | undefined { + return toolIndex.get(name)?.definition +} + /** Whether a tool name should be dispatched to the MCP pool (re-exported for tools.ts). */ export const isMcpTool = isMcpToolName @@ -320,7 +681,8 @@ export function mcpServerSummaries(ids?: Set): McpServerSummary[] { id: conn.id, status: conn.status, tools: conn.tools.map((t) => t.toolName), - error: conn.error + error: conn.error, + era: conn.era }) } return out @@ -345,16 +707,82 @@ export async function reconnectMcpServer( id: conn.id, status: conn.status, tools: conn.tools.map((t) => t.toolName), - error: conn.error + error: conn.error, + era: conn.era } } +/** + * Redirect URI used when refreshing an already-authorized connection. + * + * A refresh never redirects anywhere - the SDK exchanges the stored refresh + * token directly - but `clientMetadata` still has to name the URI the client was + * registered with, or the authorization server rejects the request. The + * interactive path (`signInMcpServer`) allocates a real port and overrides this. + */ +const REDIRECT_PLACEHOLDER = 'http://127.0.0.1/callback' + +/** + * Sign in to a remote MCP server, then connect it. + * + * The one genuinely interactive MCP flow: allocate a loopback listener, let the + * SDK send the user to the authorization server, capture the redirect, exchange + * the code, and reconnect with the tokens in place. + * + * Never throws - a failed sign-in reports as an errored summary, exactly like a + * server that wouldn't start. + */ +export async function signInMcpServer( + rec: McpServerRecord, + workspaceCwd: string +): Promise { + if (rec.config.type !== 'remote') { + return { id: rec.id, status: 'error', tools: [], error: 'Only remote servers use OAuth.' } + } + await disposeConnection(rec.id) + try { + const redirectUrl = await prepareAuthorization(rec.id) + const auth = mcpAuthProvider(rec.id, redirectUrl) + const client = new Client(clientInfo(), clientOptions(rec.id)) + const [make] = transportFactories(rec, workspaceCwd, auth) + const transport = make() + try { + // Expected to reject with UnauthorizedError after opening the browser: + // that IS the handshake, not a failure. + await client.connect(transport, { timeout: startupTimeout(rec) }) + } catch { + const params = await awaitAuthorization(rec.id) + // `finishAuth` validates `iss` (RFC 9207) and exchanges the code. The + // transport is single-use once it has failed, so reconnect on a fresh one: + // OAuth state lives on the provider, not the transport. + await (transport as { finishAuth?: (p: URLSearchParams) => Promise }).finishAuth?.( + params + ) + } + await client.close().catch(() => {}) + return await reconnectMcpServer(rec, workspaceCwd) + } catch (e) { + return { id: rec.id, status: 'error', tools: [], error: errMsg(e) } + } +} + +/** Forget a remote server's OAuth credentials and drop its connection. */ +export async function signOutMcpServer(id: string): Promise { + clearMcpAuth(id) + await disposeConnection(id) +} + +/** Whether a server has stored OAuth credentials (drives the "signed in" badge). */ +export function isMcpSignedIn(id: string): boolean { + return repo.hasMcpOAuth(id) +} + /** Close + forget one server's connection (e.g. it was deleted or disabled). */ export async function disposeConnection(id: string): Promise { connecting.delete(id) const conn = connections.get(id) connections.delete(id) - for (const [key, info] of toolIndex) if (info.serverId === id) toolIndex.delete(key) + forgetServer(id) if (conn?.client) { conn.client.onclose = undefined try { @@ -415,4 +843,5 @@ export async function _resetMcpForTests(): Promise { connections.clear() connecting.clear() toolIndex.clear() + lastResults.clear() } diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index 7eab3e8..a4c4b0c 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -1,7 +1,7 @@ /** * Pure MCP (Model Context Protocol) primitives — no Node/SDK imports, so this is * fully testable in smoke:shared. The transport + client lifecycle (built on the - * official `@modelcontextprotocol/sdk`) lives in `src/main/services/mcp.ts`. + * official `@modelcontextprotocol/client`) lives in `src/main/services/mcp.ts`. * * What lives here: * - Server config types + a defensive normalizer (parses untrusted JSON from the @@ -14,6 +14,7 @@ */ import type { ToolResult } from './types' +import { parseCallResult, toToolResult } from './mcp-content' // --------------------------------------------------------------------------- // Config types @@ -42,11 +43,55 @@ export interface McpRemoteConfig { export type McpServerConfig = McpLocalConfig | McpRemoteConfig +/** + * Which protocol era a connection negotiated. + * + * `legacy` is the `initialize` handshake (revisions `2024-10-07` through + * `2025-11-25`); `modern` is `2026-07-28`+, which replaced the handshake with a + * `server/discover` advertisement and a per-request `_meta` envelope. Mirrors + * the SDK's own `ProtocolEra`, re-declared here so this isomorphic module stays + * free of SDK imports. + */ +export type McpProtocolEra = 'legacy' | 'modern' + +/** + * A tool exactly as the server described it. + * + * Deliberately loose: Roxy stores the definition verbatim and reads specific + * keys (`_meta`, `outputSchema`) where it understands them, rather than + * modelling a spec that is still gaining fields. Narrow at the point of use. + */ +export interface McpToolDefinition { + name: string + title?: string + description?: string + inputSchema?: unknown + outputSchema?: unknown + annotations?: Record + icons?: unknown + /** Extension data - where MCP Apps puts `io.modelcontextprotocol/ui`. */ + _meta?: Record +} + /** A configured server as persisted (DB row / workspace-file entry). */ export interface McpServerRecord { id: string config: McpServerConfig enabled: boolean + /** + * Who put this row in the database. + * + * Persisted because it is a SECURITY fact, not UI trivia: a row the user typed + * in Settings is self-consenting, while one the `mcp` tool added came from the + * model (which reads web pages, issues and READMEs) and must clear the consent + * gate before it can run. Without this column an agent-added command would be + * indistinguishable from a user-added one the moment it was written, laundering + * itself into "the user configured this". + * + * Absent on rows written before this existed, and on workspace-file entries + * (whose provenance comes from the file they were read out of, not the DB). + */ + origin?: 'user' | 'agent' } // --------------------------------------------------------------------------- @@ -329,49 +374,25 @@ function sanitizeJsonSchema(schema: unknown): Record { // Result rendering (MCP CallTool content blocks → roxy ToolResult) // --------------------------------------------------------------------------- -interface McpContentBlock { - type?: string - text?: string - data?: string - mimeType?: string - resource?: { uri?: string; text?: string; mimeType?: string } -} - /** - * Flatten an MCP `tools/call` result into a roxy `ToolResult`. Text blocks are - * joined; the first image block becomes the inline `image` (data URL); resources - * contribute their inline text or a URI pointer. `isError` maps to `ok:false`. + * Flatten an MCP `tools/call` result into a roxy `ToolResult`. + * + * A thin projection over the lossless model in `./mcp-content`: parse the raw + * result into typed blocks (keeping resource URIs, `_meta`, every image, and + * anything this version doesn't recognise), then lower THAT to a string for the + * model. The parse is the source of truth; this is one of its consumers. + * + * Kept as a function because callers that only want the flat form shouldn't have + * to know about the two-step. Callers that want the structure - MCP Apps needs + * `_meta`, resource links need their URI - should call `parseCallResult` + * directly and read the result, rather than re-deriving anything from this text. */ -export function renderMcpContent(content: unknown, isError: boolean | undefined): ToolResult { - const blocks = Array.isArray(content) ? (content as McpContentBlock[]) : [] - const parts: string[] = [] - let image: string | undefined - - for (const b of blocks) { - if (!b || typeof b !== 'object') continue - if (b.type === 'text' && typeof b.text === 'string') { - parts.push(b.text) - } else if (b.type === 'image' && typeof b.data === 'string') { - const mime = typeof b.mimeType === 'string' && b.mimeType ? b.mimeType : 'image/png' - if (!image) image = `data:${mime};base64,${b.data}` - parts.push(`[image: ${mime}]`) - } else if (b.type === 'audio' && typeof b.data === 'string') { - parts.push(`[audio: ${b.mimeType || 'audio'}]`) - } else if (b.type === 'resource' && b.resource && typeof b.resource === 'object') { - const r = b.resource - if (typeof r.text === 'string' && r.text) parts.push(r.text) - else if (typeof r.uri === 'string' && r.uri) parts.push(`[resource: ${r.uri}]`) - } else if (typeof b.text === 'string') { - parts.push(b.text) - } - } - - const joined = parts.join('\n').trim() - const output = - joined || (isError ? 'The MCP tool reported an error with no message.' : '(no output)') - const result: ToolResult = { ok: !isError, output } - if (image) result.image = image - return result +export function renderMcpContent( + content: unknown, + isError: boolean | undefined, + structuredContent?: unknown +): ToolResult { + return toToolResult(parseCallResult({ content, isError, structuredContent })) } // --------------------------------------------------------------------------- @@ -384,6 +405,11 @@ export interface McpServerSummary { /** Unqualified tool display names exposed by the server. */ tools: string[] error?: string + /** + * Protocol era this server negotiated, once connected. Undefined for servers + * that never connected (and, harmlessly, for any that predate the field). + */ + era?: McpProtocolEra } /** From 008e4e4ab3e0086ac3551672a6df718cbfe6db02 Mon Sep 17 00:00:00 2001 From: Freddy Diaz <45578633+FreddyJD@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:17:18 -0400 Subject: [PATCH 04/11] feat(mcp): render MCP Apps in a sandboxed double iframe Implements SEP-1865 (stable, 2026-01-26). A tool can declare a UI in its metadata; when the model calls it, the host reads the `ui://` resource, gets HTML back, and renders it in the tool card. The view talks JSON-RPC to the host over postMessage. That HTML is arbitrary third-party code, so the whole design is about where it runs: - A dedicated `roxy-mcp-app://` scheme, registered before app-ready because scheme privileges lock in at that moment. - A double iframe. The inner frame gets `allow-scripts` WITHOUT `allow-same-origin`, which is what makes it an opaque origin, so it cannot read the proxy that created it. - The renderer holds no MCP client. A view's request crosses IPC as data and is executed in main against the ONE server that view belongs to, so OAuth tokens and process spawning stay where the view cannot reach them. The broker refuses cross-server calls by construction (the view sends an unqualified tool name that main qualifies against its own session), unapproved tool calls, CSP injection, `file://` links, unknown methods, and unbounded heights. Also filters app-only tools (`visibility: ["app"]`) out of the model's tool list, which the spec makes a MUST NOT: those exist for a server's own view, and offering them to the model invites calls the server never meant to serve. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com> --- src/main/index.ts | 13 + src/main/services/mcp-app-sandbox.ts | 188 ++++++++ src/main/services/mcp-apps.ts | 355 +++++++++++++++ .../src/components/McpAppApprovalDialog.tsx | 106 +++++ src/renderer/src/components/McpAppView.tsx | 266 +++++++++++ src/renderer/src/components/MessageParts.tsx | 13 + src/renderer/src/components/ToolCall.tsx | 32 +- src/shared/mcp-apps.ts | 423 ++++++++++++++++++ src/shared/parts.ts | 4 +- src/shared/types.ts | 8 + 10 files changed, 1406 insertions(+), 2 deletions(-) create mode 100644 src/main/services/mcp-app-sandbox.ts create mode 100644 src/main/services/mcp-apps.ts create mode 100644 src/renderer/src/components/McpAppApprovalDialog.tsx create mode 100644 src/renderer/src/components/McpAppView.tsx create mode 100644 src/shared/mcp-apps.ts diff --git a/src/main/index.ts b/src/main/index.ts index 06d0ee2..8c1895f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,6 +14,9 @@ import { cleanupToolOutputs } from './services/tool-output-store' import { cancelAllBackgroundJobs } from './services/background-tasks' import { shutdownAllLsp } from './services/lsp' import { shutdownAllMcp } from './services/mcp' +import { cancelAllConsent } from './services/mcp-trust' +import { registerSandboxScheme, serveSandbox } from './services/mcp-app-sandbox' +import { closeAllMcpApps } from './services/mcp-apps' import { shutdownRemote } from './services/remote' import { shutdownCliProxy } from './services/cliproxy' import { initAutoUpdater } from './services/updater' @@ -97,6 +100,11 @@ async function warmCatalogThenBackfill(): Promise { backfillUsageFromHistory() } +// Custom scheme for MCP App views. MUST be registered before the app is ready: +// privileges are locked in at that moment, and a view loaded on a non-standard +// origin would not get the same-origin isolation the sandbox depends on. +registerSandboxScheme() + app.whenReady().then(() => { electronApp.setAppUserModelId('com.roxy.app') // Give the agent's browser window the Roxy icon too (no asset import in the @@ -133,6 +141,7 @@ app.whenReady().then(() => { // backfilled rows can be priced (else they'd all cost $0). Best-effort + async. void warmCatalogThenBackfill() + serveSandbox() const mainWindow = createWindow() initAutoUpdater(mainWindow) @@ -162,6 +171,10 @@ app.on('will-quit', () => { cancelAllBackgroundJobs() closeAllBrowsers() shutdownAllLsp() + // Resolve any open consent prompt as a DENY before the window goes away, so + // an awaiting connect unwinds instead of hanging until its timeout. + closeAllMcpApps() + cancelAllConsent() void shutdownAllMcp() shutdownRemote() // The Codex sidecar holds the user's subscription tokens - never leave it diff --git a/src/main/services/mcp-app-sandbox.ts b/src/main/services/mcp-app-sandbox.ts new file mode 100644 index 0000000..3a2b624 --- /dev/null +++ b/src/main/services/mcp-app-sandbox.ts @@ -0,0 +1,188 @@ +/** + * The MCP Apps sandbox origin — where untrusted, server-supplied HTML runs. + * + * ## The problem + * + * An MCP App is arbitrary HTML+JS written by whoever wrote the MCP server. It + * has to execute for the feature to exist at all. The entire job of this module + * is making sure that when it executes, it is somewhere that can hurt nothing. + * + * Roxy's renderer runs with `contextIsolation: true` but `sandbox: false` and a + * preload that exposes `window.roxy` — the full IPC surface. An iframe on the + * app's own origin could reach that, plus `localStorage`, IndexedDB, and the + * session's cookie jar. So the view must not share Roxy's origin, and "must not" + * has to be enforced by the browser, not by our own care. + * + * ## The design + * + * A custom `roxy-mcp-app://` protocol, registered as a standard scheme, serves + * exactly one document: the sandbox proxy. Because it is a distinct scheme with + * its own opaque origin, the same-origin policy does the enforcement for us — + * the proxy cannot touch Roxy's window, storage, or preload even if it tries. + * + * Inside it, the proxy writes the view's HTML into a NESTED iframe with the + * resource's CSP applied. That is the "double iframe" SEP-1865 requires: + * + * Roxy renderer →