From 08a88a88ea7f94a0ccbc5aaf7b1d01ef7b554db3 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:47:16 +0200 Subject: [PATCH 1/2] feat(frontend): add mcp connection settings, oauth consent, and mcp inspector ui --- frontend/apps/inspector-ui/mcp-index.html | 57 ++++ frontend/apps/inspector-ui/src/main.tsx | 66 +++- frontend/apps/inspector-ui/src/mcp-main.tsx | 160 ++++++++++ frontend/apps/inspector-ui/src/vite-env.d.ts | 2 + frontend/apps/inspector-ui/vite.config.ts | 1 + frontend/apps/inspector-ui/vite.mcp.config.ts | 97 ++++++ frontend/package.json | 6 +- .../src/app/settings-pages/mcp-connection.tsx | 231 ++++++++++++++ .../src/app/settings-pages/mcp-scope.test.ts | 36 +++ frontend/src/app/settings-pages/mcp-scope.ts | 54 ++++ .../app/settings-pages/namespace-settings.tsx | 2 + .../actors/actor-inspector-context.tsx | 5 +- .../actors/inspector-tab-registry.tsx | 15 +- frontend/src/lib/auth.ts | 3 +- frontend/src/lib/env.ts | 6 + frontend/src/lib/features.ts | 3 + frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/oauth.consent.tsx | 290 ++++++++++++++++++ frontend/src/vite-env.d.ts | 1 + frontend/vite.base.config.ts | 1 + frontend/vite.mcp-inspector-ui.config.ts | 1 + 21 files changed, 1037 insertions(+), 21 deletions(-) create mode 100644 frontend/apps/inspector-ui/mcp-index.html create mode 100644 frontend/apps/inspector-ui/src/mcp-main.tsx create mode 100644 frontend/apps/inspector-ui/vite.mcp.config.ts create mode 100644 frontend/src/app/settings-pages/mcp-connection.tsx create mode 100644 frontend/src/app/settings-pages/mcp-scope.test.ts create mode 100644 frontend/src/app/settings-pages/mcp-scope.ts create mode 100644 frontend/src/routes/oauth.consent.tsx create mode 100644 frontend/vite.mcp-inspector-ui.config.ts diff --git a/frontend/apps/inspector-ui/mcp-index.html b/frontend/apps/inspector-ui/mcp-index.html new file mode 100644 index 0000000000..0aaa4e8a72 --- /dev/null +++ b/frontend/apps/inspector-ui/mcp-index.html @@ -0,0 +1,57 @@ + + + + + + Rivet Actor Inspector + + + +
+ + + diff --git a/frontend/apps/inspector-ui/src/main.tsx b/frontend/apps/inspector-ui/src/main.tsx index ef56887310..171ecd5588 100644 --- a/frontend/apps/inspector-ui/src/main.tsx +++ b/frontend/apps/inspector-ui/src/main.tsx @@ -62,32 +62,65 @@ function InspectorContent({ actorId, activeTab, bridge, + standalone = false, }: { actorId: ActorId; activeTab: string | undefined; - bridge: BridgeClient; + bridge?: BridgeClient; + standalone?: boolean; }) { const availableTabs = useAvailableInspectorTabs(actorId); + const [standaloneTab, setStandaloneTab] = useState(); useEffect(() => { - if (availableTabs) bridge.sendTabsAvailable(availableTabs); + if (availableTabs) bridge?.sendTabsAvailable(availableTabs); }, [bridge, availableTabs]); - return ; + const selectedTab = activeTab ?? standaloneTab ?? availableTabs?.[0]?.id; + return ( +
+ {standalone && availableTabs ? ( + + ) : null} +
+ +
+
+ ); } -function InspectorApp({ +export function InspectorApp({ actorId, credentials, bridge, activeTab, initialVersion, + standalone, }: { actorId: ActorId; credentials: { url: string; inspectorToken: string; token: string }; - bridge: BridgeClient; + bridge?: BridgeClient; activeTab: string | undefined; initialVersion?: string; + standalone?: boolean; }) { const queryClient = useMemo( () => @@ -123,6 +156,7 @@ function InspectorApp({ actorId={actorId} activeTab={activeTab} bridge={bridge} + standalone={standalone} /> @@ -192,13 +226,15 @@ function BootGate({ bridge }: { bridge: BridgeClient }) { ); } -const bridge = new BridgeClient(); -const rootEl = document.getElementById("root"); -if (!rootEl) throw new Error("Inspector UI: #root element missing"); -ReactDOM.createRoot(rootEl).render( - - - - - , -); +if (!__MCP_APP__) { + const bridge = new BridgeClient(); + const rootEl = document.getElementById("root"); + if (!rootEl) throw new Error("Inspector UI: #root element missing"); + ReactDOM.createRoot(rootEl).render( + + + + + , + ); +} diff --git a/frontend/apps/inspector-ui/src/mcp-main.tsx b/frontend/apps/inspector-ui/src/mcp-main.tsx new file mode 100644 index 0000000000..5a33a4bd76 --- /dev/null +++ b/frontend/apps/inspector-ui/src/mcp-main.tsx @@ -0,0 +1,160 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import { useEffect, useState } from "react"; +import ReactDOM from "react-dom/client"; +import type { ActorId } from "@/components/actors/queries"; +import "@/index.css"; +import { InspectorApp } from "./main"; + +type ActorTarget = + | { actorId: string } + | { name: string; key?: string[]; method: "get"; skipReadyWait?: boolean } + | { + name: string; + key?: string[]; + method: "getOrCreate"; + pool: string; + input?: unknown; + region?: string; + crashPolicy?: "restart" | "sleep" | "destroy"; + skipReadyWait?: boolean; + }; + +type InspectorGrant = { + token: string; + proxyUrl: string; + expiresAt: string; + actorId: string; + dashboardUrl?: string; +}; + +const app = new App( + { name: "Rivet Actor Inspector", version: "0.1.0" }, + {}, + { strict: true }, +); + +let currentActor: ActorTarget | undefined; +let currentGrant: InspectorGrant | undefined; + +function structuredGrant(result: Awaited>): InspectorGrant { + if (result.isError || !result.structuredContent) { + throw new Error("Could not create the temporary Inspector session"); + } + const value = result.structuredContent as Record; + for (const key of ["token", "proxyUrl", "expiresAt", "actorId"] as const) { + if (typeof value[key] !== "string") throw new Error("Invalid Inspector session response"); + } + return value as InspectorGrant; +} + +async function createSession(actor: ActorTarget): Promise { + return structuredGrant( + await app.callServerTool({ + name: "rivet.ui.actor.session.create", + arguments: { actor }, + }), + ); +} + +async function renewSession(token: string): Promise { + return structuredGrant( + await app.callServerTool({ + name: "rivet.ui.actor.session.renew", + arguments: { token }, + }), + ); +} + +function McpInspector() { + const [grant, setGrant] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + const receiveInput = (params: { arguments?: Record }) => { + const actor = params.arguments?.actor; + if (actor && typeof actor === "object") currentActor = actor as ActorTarget; + }; + const receiveResult = () => { + if (!currentActor) return; + void createSession(currentActor) + .then((next) => { + currentGrant = next; + setGrant(next); + }) + .catch(() => setError("Could not authenticate the embedded Inspector.")); + }; + app.addEventListener("toolinput", receiveInput); + app.addEventListener("toolresult", receiveResult); + app.onhostcontextchanged = (context) => { + document.documentElement.classList.toggle("dark", context.theme !== "light"); + }; + app.onteardown = async () => { + if (currentGrant) { + await app.callServerTool({ + name: "rivet.ui.actor.session.revoke", + arguments: { token: currentGrant.token }, + }); + } + return {}; + }; + void app.connect().catch(() => setError("This host could not initialize the MCP App.")); + return () => { + app.removeEventListener("toolinput", receiveInput); + app.removeEventListener("toolresult", receiveResult); + }; + }, []); + + useEffect(() => { + if (!grant) return; + const renewAt = Math.max(1_000, new Date(grant.expiresAt).getTime() - Date.now() - 30_000); + const timer = window.setTimeout(() => { + void renewSession(grant.token) + .then((next) => { + currentGrant = next; + setGrant(next); + }) + .catch(() => setError("The Inspector session expired. Reopen the Inspector to continue.")); + }, renewAt); + return () => window.clearTimeout(timer); + }, [grant]); + + if (error) return

{error}

; + if (!grant) return

Connecting to the Rivet Actor Inspector…

; + return ( +
+ {grant.dashboardUrl ? ( +
+ Console and custom tabs are available in the{" "} + + full Rivet Inspector + + . +
+ ) : null} +
+ +
+
+ ); +} + +const root = document.getElementById("root"); +if (!root) throw new Error("Inspector UI: #root element missing"); +ReactDOM.createRoot(root).render( + , +); diff --git a/frontend/apps/inspector-ui/src/vite-env.d.ts b/frontend/apps/inspector-ui/src/vite-env.d.ts index de5cc2235c..08f95bb406 100644 --- a/frontend/apps/inspector-ui/src/vite-env.d.ts +++ b/frontend/apps/inspector-ui/src/vite-env.d.ts @@ -1,4 +1,6 @@ /// +declare const __MCP_APP__: boolean; + // rivetkit's package version, baked in at build time. See vite.config.ts. declare const __RIVETKIT_VERSION__: string; diff --git a/frontend/apps/inspector-ui/vite.config.ts b/frontend/apps/inspector-ui/vite.config.ts index aa35a76ed1..e6eb64636a 100644 --- a/frontend/apps/inspector-ui/vite.config.ts +++ b/frontend/apps/inspector-ui/vite.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ envDir: path.resolve(__dirname, "../.."), plugins: [react(), tsconfigPaths()], define: { + __MCP_APP__: JSON.stringify(false), __APP_TYPE__: JSON.stringify("inspector"), __APP_BUILD_ID__: JSON.stringify( `${new Date().toISOString()}@${crypto.randomUUID()}`, diff --git a/frontend/apps/inspector-ui/vite.mcp.config.ts b/frontend/apps/inspector-ui/vite.mcp.config.ts new file mode 100644 index 0000000000..fd513e5d30 --- /dev/null +++ b/frontend/apps/inspector-ui/vite.mcp.config.ts @@ -0,0 +1,97 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import tsconfigPaths from "vite-tsconfig-paths"; + +const rivetkitVersion = JSON.parse( + readFileSync( + path.resolve(__dirname, "../../../rivetkit-typescript/packages/rivetkit/package.json"), + "utf8", + ), +).version as string; +const require = createRequire(path.resolve(__dirname, "package.json")); + +export default defineConfig({ + root: path.resolve(__dirname), + base: "./", + plugins: [ + { + name: "fallback-unavailable-mcp-icons", + enforce: "pre", + transform(code, id) { + if (!id.endsWith("/packages/icons/src/index.gen.js")) return; + let usedFallback = false; + const transformed = code.replace( + /export \{([^}]+)\} from "([^"]+)";/g, + (statement, names: string, specifier: string) => { + try { + require.resolve(specifier); + return statement; + } catch { + usedFallback = true; + const aliases = names.split(",").map((entry) => { + const parts = entry.trim().split(/\s+as\s+/); + return `__mcpFallbackIcon as ${parts.at(-1)}`; + }); + return `export { ${aliases.join(", ")} };`; + } + }, + ); + if (!usedFallback) return transformed; + return `${transformed}\nconst __mcpFallbackIcon = { prefix: "fas", iconName: "circle", icon: [16, 16, [], "", "M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Z"] };`; + }, + }, + react(), + { + name: "disable-unsupported-mcp-console-worker", + enforce: "pre", + transform(code, id) { + if (!id.endsWith("/actor-worker-container.ts")) return; + return code.replace( + 'import ActorWorker from "./actor-repl.worker?worker";', + `class ActorWorker extends EventTarget { + constructor() { + super(); + throw new Error("The actor console is unavailable in the embedded Inspector."); + } + postMessage() {} + terminate() {} +}`, + ); + }, + }, + tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] }), + viteSingleFile(), + ], + resolve: { + alias: { + "@rivet-gg/icons": path.resolve( + __dirname, + "../../packages/icons/src/index.gen.js", + ), + "@": path.resolve(__dirname, "../../src"), + }, + }, + define: { + __MCP_APP__: JSON.stringify(true), + __APP_TYPE__: JSON.stringify("inspector"), + __APP_BUILD_ID__: JSON.stringify("mcp-actor-inspector"), + __RIVETKIT_VERSION__: JSON.stringify(rivetkitVersion), + }, + optimizeDeps: { include: ["@fortawesome/*", "@rivet-gg/icons", "@rivet-gg/cloud"] }, + worker: { format: "es" }, + build: { + outDir: "../../dist/mcp-inspector-ui", + emptyOutDir: true, + sourcemap: false, + cssCodeSplit: false, + rollupOptions: { + input: path.resolve(__dirname, "mcp-index.html"), + output: { inlineDynamicImports: true }, + }, + commonjsOptions: { include: [/@rivet-gg\/components/, /node_modules/] }, + }, +}); diff --git a/frontend/package.json b/frontend/package.json index cade20f977..bd4a3ac727 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,9 @@ "build:ladle": "ladle build" }, "dependencies": { + "@better-auth/oauth-provider": "1.6.23", + "@modelcontextprotocol/ext-apps": "1.7.4", + "@modelcontextprotocol/sdk": "1.29.0", "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.8.1", "@codemirror/lang-javascript": "^6.2.4", @@ -117,7 +120,7 @@ "actor-core": "^0.6.3", "autoprefixer": "^10.4.21", "bcryptjs": "^2.4.3", - "better-auth": "^1.5.6", + "better-auth": "1.6.23", "canvas-confetti": "^1.9.3", "cbor-x": "^1.6.0", "class-variance-authority": "^0.7.1", @@ -162,6 +165,7 @@ "usehooks-ts": "^3.1.1", "vite": "^5.4.20", "vite-plugin-favicons-inject": "^2.2.0", + "vite-plugin-singlefile": "2.3.3", "vite-tsconfig-paths": "^5.1.4", "zod": "^3.25.76" }, diff --git a/frontend/src/app/settings-pages/mcp-connection.tsx b/frontend/src/app/settings-pages/mcp-connection.tsx new file mode 100644 index 0000000000..3d07eaf49b --- /dev/null +++ b/frontend/src/app/settings-pages/mcp-connection.tsx @@ -0,0 +1,231 @@ +import { + faChevronRight, + faClaude, + faCursor, + faGemini, + faPlug, + faVscode, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { useParams } from "@tanstack/react-router"; +import { useState } from "react"; +import { + CodeFrame, + CodeGroup, + CodePreview, + getConfig, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components"; +import { useEngineCompatDataProvider } from "@/components/actors"; +import { getMcpUrl } from "@/lib/env"; +import { features } from "@/lib/features"; +import { + type HostedTarget, + hostedUrl, + SCOPE_ORDER, + SCOPES, + type Scope, +} from "./mcp-scope"; +import { SettingsCard } from "./settings-card"; + +const DOCS_URL = "https://rivet.dev/mcp"; + +const DESCRIPTION = + "Let AI tools like Claude Code and Cursor read and manage your actors."; + +type Language = "json" | "bash"; + +interface ClientTab { + title: string; + icon: IconProp; + language: Language; + code: string; +} + +function json(value: unknown) { + return JSON.stringify(value, null, 2); +} + +function hostedTabs(url: string): ClientTab[] { + return [ + { + title: "Claude Code", + icon: faClaude, + language: "bash", + code: `claude mcp add --transport http rivet "${url}"`, + }, + { + title: "Cursor", + icon: faCursor, + language: "json", + code: json({ mcpServers: { rivet: { url } } }), + }, + { + title: "VS Code", + icon: faVscode, + language: "bash", + code: `code --add-mcp '${JSON.stringify({ name: "rivet", type: "http", url })}'`, + }, + { + title: "Gemini CLI", + icon: faGemini, + language: "json", + code: json({ mcpServers: { rivet: { httpUrl: url } } }), + }, + { + title: "Other", + icon: faPlug, + language: "json", + code: json({ mcpServers: { rivet: { type: "http", url } } }), + }, + ]; +} + +function localTabs(endpoint: string, namespace: string): ClientTab[] { + const command = "npx"; + const args = ["-y", "@rivet-dev/mcp", "--target", "local"]; + const env = { RIVET_ENDPOINT: endpoint, RIVET_NAMESPACE: namespace }; + const server = { command, args, env }; + + return [ + { + title: "Claude Code", + icon: faClaude, + language: "bash", + code: `claude mcp add rivet \\ + --env RIVET_ENDPOINT=${endpoint} \\ + --env RIVET_NAMESPACE=${namespace} \\ + -- ${command} ${args.join(" ")}`, + }, + { + title: "Cursor", + icon: faCursor, + language: "json", + code: json({ mcpServers: { rivet: server } }), + }, + { + title: "VS Code", + icon: faVscode, + language: "bash", + code: `code --add-mcp '${JSON.stringify({ name: "rivet", ...server })}'`, + }, + { + title: "Gemini CLI", + icon: faGemini, + language: "json", + code: json({ mcpServers: { rivet: server } }), + }, + { + title: "Other", + icon: faPlug, + language: "json", + code: json({ mcpServers: { rivet: server } }), + }, + ]; +} + +function DocsFooter() { + return ( + + + See MCP Documentation{" "} + + + + ); +} + +function ClientTabs({ tabs }: { tabs: ClientTab[] }) { + return ( + + {tabs.map((tab) => ( + tab.code} + footer={} + > + + + ))} + + ); +} + +function ScopeSelect({ + value, + onValueChange, +}: { + value: Scope; + onValueChange: (value: Scope) => void; +}) { + return ( + + ); +} + +function HostedMcp() { + const params = useParams({ strict: false }) as Partial; + const [scope, setScope] = useState("namespace"); + + if (!params.organization || !params.project || !params.namespace) { + return null; + } + + const target: HostedTarget = { + organization: params.organization, + project: params.project, + namespace: params.namespace, + }; + return ( + } + > + + + ); +} + +function LocalMcp() { + const namespace = useEngineCompatDataProvider().engineNamespace; + const endpoint = getConfig().apiUrl; + + return ( + + + + ); +} + +export function McpConnection() { + if (!features.mcp) return null; + return features.platform ? : ; +} diff --git a/frontend/src/app/settings-pages/mcp-scope.test.ts b/frontend/src/app/settings-pages/mcp-scope.test.ts new file mode 100644 index 0000000000..7601e14b8e --- /dev/null +++ b/frontend/src/app/settings-pages/mcp-scope.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { hostedUrl, type Scope } from "./mcp-scope"; + +const BASE = "https://mcp.rivet.dev/mcp"; +const TARGET = { + organization: "acme", + project: "prod", + namespace: "canary", +}; + +function params(scope: Scope) { + return Object.fromEntries( + new URL(hostedUrl(BASE, TARGET, scope)).searchParams, + ); +} + +describe("hostedUrl", () => { + it("pins every level for the namespace scope", () => { + expect(params("namespace")).toEqual(TARGET); + }); + + it("leaves the namespace open for the project scope", () => { + expect(params("project")).toEqual({ + organization: "acme", + project: "prod", + }); + }); + + it("leaves the project open for the organization scope", () => { + expect(params("organization")).toEqual({ organization: "acme" }); + }); + + it("emits no query at all for the account scope", () => { + expect(hostedUrl(BASE, TARGET, "account")).toBe(BASE); + }); +}); diff --git a/frontend/src/app/settings-pages/mcp-scope.ts b/frontend/src/app/settings-pages/mcp-scope.ts new file mode 100644 index 0000000000..edeb46e577 --- /dev/null +++ b/frontend/src/app/settings-pages/mcp-scope.ts @@ -0,0 +1,54 @@ +export type Scope = "namespace" | "project" | "organization" | "account"; + +export interface HostedTarget { + organization: string; + project: string; + namespace: string; +} + +export const SCOPES: Record< + Scope, + { label: string; reach: string; pins: (keyof HostedTarget)[] } +> = { + namespace: { + label: "This namespace", + reach: "only this namespace", + pins: ["organization", "project", "namespace"], + }, + project: { + label: "This project", + reach: "any namespace in this project", + pins: ["organization", "project"], + }, + organization: { + label: "This organization", + reach: "any project in this organization", + pins: ["organization"], + }, + account: { + label: "Entire account", + reach: "any project in your account", + pins: [], + }, +}; + +export const SCOPE_ORDER: Scope[] = [ + "namespace", + "project", + "organization", + "account", +]; + +// Levels left out of the query stay open for the agent to name per call. Every +// level that is present is a hard pin the session cannot move off, so a +// narrower scope is the safer default. +export function hostedUrl( + base: string, + target: HostedTarget, + scope: Scope, +): string { + const query = new URLSearchParams(); + for (const level of SCOPES[scope].pins) query.set(level, target[level]); + const search = query.toString(); + return search ? `${base}?${search}` : base; +} diff --git a/frontend/src/app/settings-pages/namespace-settings.tsx b/frontend/src/app/settings-pages/namespace-settings.tsx index a75967fa7d..a6451a1f80 100644 --- a/frontend/src/app/settings-pages/namespace-settings.tsx +++ b/frontend/src/app/settings-pages/namespace-settings.tsx @@ -24,6 +24,7 @@ import { PublishableToken, SecretToken, } from "@/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens"; +import { McpConnection } from "./mcp-connection"; import { SettingsCard } from "./settings-card"; export function NamespaceSettingsContent() { @@ -52,6 +53,7 @@ export function NamespaceAdvancedContent() { {features.auth ? : null} {features.auth ? : null} + {features.dangerZone ? : null} diff --git a/frontend/src/components/actors/actor-inspector-context.tsx b/frontend/src/components/actors/actor-inspector-context.tsx index 67dceaa67b..ebdcf4ec33 100644 --- a/frontend/src/components/actors/actor-inspector-context.tsx +++ b/frontend/src/components/actors/actor-inspector-context.tsx @@ -658,8 +658,11 @@ export const createDefaultActorInspectorContext = ({ }, }); +// The base may carry a path prefix (the MCP Inspector proxy is mounted under +// /mcp/inspector-proxy), so the segment must stay relative. A leading slash +// would resolve against the origin and drop that prefix. const computeActorUrl = ({ url, actorId }: { url: string; actorId: ActorId }) => - new URL(`/gateway/${actorId}`, url).href; + new URL(`gateway/${actorId}`, url.endsWith("/") ? url : `${url}/`).href; function transformWorkflowHistoryFromJson(raw: number[] | null): { history: WorkflowHistory | null; diff --git a/frontend/src/components/actors/inspector-tab-registry.tsx b/frontend/src/components/actors/inspector-tab-registry.tsx index e314dc34ce..d0511f467e 100644 --- a/frontend/src/components/actors/inspector-tab-registry.tsx +++ b/frontend/src/components/actors/inspector-tab-registry.tsx @@ -124,6 +124,12 @@ export const INSPECTOR_TAB_REGISTRATIONS: readonly TabRegistration[] = [ }, ] as const; +const availableInspectorRegistrations = __MCP_APP__ + ? INSPECTOR_TAB_REGISTRATIONS.filter( + (registration) => registration.descriptor.id !== "console", + ) + : INSPECTOR_TAB_REGISTRATIONS; + /** * Returns the descriptors of all inspector tabs available for this actor, * filtered by the live capability flags from the inspector context. Returns @@ -179,12 +185,15 @@ export function useAvailableInspectorTabs( .filter((t) => t.hidden === true) .map((t) => t.id), ); - const builtIns = INSPECTOR_TAB_REGISTRATIONS.filter((t) => + const builtIns = availableInspectorRegistrations.filter((t) => t.available(caps), ) .map((t) => t.descriptor) .filter((d) => !hideSet.has(d.id)); - const customs: InspectorTabDescriptor[] = (tabConfig?.tabs ?? []) + const customs: InspectorTabDescriptor[] = (__MCP_APP__ + ? [] + : (tabConfig?.tabs ?? []) + ) .filter((t) => t.hidden !== true && typeof t.label === "string") .map((t) => ({ id: t.id, @@ -224,7 +233,7 @@ export function InspectorTabContent({ actorId: ActorId; activeTab: string | undefined; }) { - const registration = INSPECTOR_TAB_REGISTRATIONS.find( + const registration = availableInspectorRegistrations.find( (t) => t.descriptor.id === activeTab, ); if (!registration) return null; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index bad7a6f077..34c7ea5e13 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,4 +1,5 @@ import { notFound, redirect } from "@tanstack/react-router"; +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; import { adminClient, organizationClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { cloudEnv } from "./env"; @@ -8,7 +9,7 @@ const createClient = () => createAuthClient({ baseURL: cloudEnv().VITE_APP_CLOUD_API_URL, fetchOptions: { credentials: "include" }, - plugins: [organizationClient(), adminClient()], + plugins: [organizationClient(), adminClient(), oauthProviderClient()], }); type AuthClient = ReturnType; diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts index 89483f012f..6d4522379d 100644 --- a/frontend/src/lib/env.ts +++ b/frontend/src/lib/env.ts @@ -28,6 +28,9 @@ export const cloudEnvSchema = commonEnvSchema.merge( z.object({ // Cloud API endpoint - direct URL without transformation, used for cloud-specific operations VITE_APP_CLOUD_API_URL: z.string().url(), + // Hosted MCP endpoint. Unset on Rivet Cloud; self-hosted deployments + // point this at their own MCP service. + VITE_APP_MCP_URL: z.string().url().optional(), VITE_APP_SENTRY_TUNNEL: z.string().optional(), VITE_APP_TURNSTILE_SITE_KEY: z.string().optional(), }), @@ -35,6 +38,9 @@ export const cloudEnvSchema = commonEnvSchema.merge( export const cloudEnv = () => cloudEnvSchema.parse(import.meta.env); +export const getMcpUrl = () => + cloudEnv().VITE_APP_MCP_URL ?? "https://mcp.rivet.dev/mcp"; + export const getRivetRunUrl = (engineNsName: string) => { return cloudEnv().VITE_DEPLOYMENT_TYPE === "production" ? `https://${engineNsName}.rivet.run/` diff --git a/frontend/src/lib/features.ts b/frontend/src/lib/features.ts index f111bc9d05..0ba2e5ecf0 100644 --- a/frontend/src/lib/features.ts +++ b/frontend/src/lib/features.ts @@ -37,6 +37,9 @@ export const features = { compute: isEnabled("compute") && platform, // `agentOs` gates the agentOS (coding-agent VM) onboarding template. Beta. agentOs: isEnabled("agent-os"), + // `mcp` gates the MCP connection settings. The snippet differs per flavor: + // platform points at the hosted endpoint, OSS at the local stdio server. + mcp: isEnabled("mcp"), support: isEnabled("support"), branding: isEnabled("branding"), datacenter: isEnabled("datacenter"), diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 301f4ed881..f7754a6c3e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ForgotPasswordRouteImport } from './routes/forgot-password' import { Route as AcceptInvitationRouteImport } from './routes/accept-invitation' import { Route as ContextRouteImport } from './routes/_context' import { Route as ContextIndexRouteImport } from './routes/_context/index' +import { Route as OauthConsentRouteImport } from './routes/oauth.consent' import { Route as ContextNewIndexRouteImport } from './routes/_context/new/index' import { Route as ContextNewOrgIndexRouteImport } from './routes/_context/new-org/index' import { Route as ContextOrgsOrganizationRouteImport } from './routes/_context/orgs.$organization' @@ -84,6 +85,11 @@ const ContextIndexRoute = ContextIndexRouteImport.update({ path: '/', getParentRoute: () => ContextRoute, } as any) +const OauthConsentRoute = OauthConsentRouteImport.update({ + id: '/oauth/consent', + path: '/oauth/consent', + getParentRoute: () => rootRouteImport, +} as any) const ContextNewIndexRoute = ContextNewIndexRouteImport.update({ id: '/new/', path: '/new/', @@ -224,6 +230,7 @@ export interface FileRoutesByFullPath { '/onboarding': typeof OnboardingRoute '/reset-password': typeof ResetPasswordRoute '/verify-email-pending': typeof VerifyEmailPendingRoute + '/oauth/consent': typeof OauthConsentRoute '/ns/$namespace': typeof ContextNsNamespaceRouteWithChildren '/orgs/$organization': typeof ContextOrgsOrganizationRouteWithChildren '/new-org/': typeof ContextNewOrgIndexRoute @@ -254,6 +261,7 @@ export interface FileRoutesByTo { '/onboarding': typeof OnboardingRoute '/reset-password': typeof ResetPasswordRoute '/verify-email-pending': typeof VerifyEmailPendingRoute + '/oauth/consent': typeof OauthConsentRoute '/': typeof ContextIndexRoute '/new-org': typeof ContextNewOrgIndexRoute '/new': typeof ContextNewIndexRoute @@ -283,6 +291,7 @@ export interface FileRoutesById { '/onboarding': typeof OnboardingRoute '/reset-password': typeof ResetPasswordRoute '/verify-email-pending': typeof VerifyEmailPendingRoute + '/oauth/consent': typeof OauthConsentRoute '/_context/': typeof ContextIndexRoute '/_context/ns/$namespace': typeof ContextNsNamespaceRouteWithChildren '/_context/orgs/$organization': typeof ContextOrgsOrganizationRouteWithChildren @@ -317,6 +326,7 @@ export interface FileRouteTypes { | '/onboarding' | '/reset-password' | '/verify-email-pending' + | '/oauth/consent' | '/ns/$namespace' | '/orgs/$organization' | '/new-org/' @@ -347,6 +357,7 @@ export interface FileRouteTypes { | '/onboarding' | '/reset-password' | '/verify-email-pending' + | '/oauth/consent' | '/' | '/new-org' | '/new' @@ -375,6 +386,7 @@ export interface FileRouteTypes { | '/onboarding' | '/reset-password' | '/verify-email-pending' + | '/oauth/consent' | '/_context/' | '/_context/ns/$namespace' | '/_context/orgs/$organization' @@ -408,6 +420,7 @@ export interface RootRouteChildren { OnboardingRoute: typeof OnboardingRoute ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailPendingRoute: typeof VerifyEmailPendingRoute + OauthConsentRoute: typeof OauthConsentRoute } declare module '@tanstack/react-router' { @@ -475,6 +488,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ContextIndexRouteImport parentRoute: typeof ContextRoute } + '/oauth/consent': { + id: '/oauth/consent' + path: '/oauth/consent' + fullPath: '/oauth/consent' + preLoaderRoute: typeof OauthConsentRouteImport + parentRoute: typeof rootRouteImport + } '/_context/new/': { id: '/_context/new/' path: '/new' @@ -746,6 +766,7 @@ const rootRouteChildren: RootRouteChildren = { OnboardingRoute: OnboardingRoute, ResetPasswordRoute: ResetPasswordRoute, VerifyEmailPendingRoute: VerifyEmailPendingRoute, + OauthConsentRoute: OauthConsentRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/frontend/src/routes/oauth.consent.tsx b/frontend/src/routes/oauth.consent.tsx new file mode 100644 index 0000000000..f2bbda3b26 --- /dev/null +++ b/frontend/src/routes/oauth.consent.tsx @@ -0,0 +1,290 @@ +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Logo } from "@/app/logo"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { authClient } from "@/lib/auth"; + +const searchSchema = z.object({ + client_id: z.string(), + scope: z.string(), + oauth_query: z.string().optional(), +}); + +interface ScopeDetail { + title: string; + description: string; +} + +// The MCP entrypoint verifies every request against rivet:cloud:read, so a +// grant without it yields a token that cannot call anything. +const REQUIRED_SCOPES = new Set(["rivet:cloud:read"]); + +const SCOPE_DETAILS: Record = { + openid: { + title: "Confirm your identity", + description: + "Share your Rivet user ID so the client knows who signed in.", + }, + offline_access: { + title: "Stay signed in", + description: + "Reconnect without asking you again. Access tokens expire after 15 minutes.", + }, + "rivet:cloud:read": { + title: "View your organizations and projects", + description: + "List organizations, projects, and namespaces, and read their usage metrics.", + }, + "rivet:cloud:write": { + title: "Manage your organizations and projects", + description: + "Change cloud resources on your behalf. Destructive and credential operations stay unavailable.", + }, + "rivet:actors:read": { + title: "View your actors", + description: + "List actors in the selected namespace and send them read-only requests.", + }, + "rivet:actors:write": { + title: "Modify your actors", + description: "Send requests that change actor state or lifecycle.", + }, + "rivet:inspector:read": { + title: "Inspect actor internals", + description: + "Read actor state, database contents, and logs through the Actor Inspector.", + }, + "rivet:inspector:write": { + title: "Modify actor internals", + description: + "Edit state and run commands against an actor through the Actor Inspector.", + }, +}; + +export const Route = createFileRoute("/oauth/consent")({ + validateSearch: searchSchema, + beforeLoad: async ({ location }) => { + const session = await authClient.getSession(); + if (!session.data) { + throw redirect({ + to: "/login", + search: { from: `${location.pathname}${location.searchStr}` }, + }); + } + }, + component: OAuthConsent, +}); + +function OAuthConsent() { + const search = Route.useSearch(); + const [error, setError] = useState(); + const { handleSubmit, formState } = useForm>(); + const requestedScopes = useMemo( + () => search.scope.split(/\s+/).filter(Boolean), + [search.scope], + ); + const [granted, setGranted] = useState>( + () => new Set(requestedScopes), + ); + + // Dynamically registered clients pick their own opaque client_id, so the + // name they registered under is the only human-readable identifier. + const { data: client, isPending: isClientPending } = useQuery({ + queryKey: ["oauth", "public-client", search.client_id], + queryFn: async () => { + const result = await authClient.oauth2.publicClient({ + query: { client_id: search.client_id }, + }); + if (result.error) { + throw new Error( + result.error.message ?? "Could not load client.", + ); + } + return result.data; + }, + retry: false, + }); + + const toggle = (scope: string, checked: boolean) => { + setGranted((previous) => { + const next = new Set(previous); + if (checked) next.add(scope); + else next.delete(scope); + return next; + }); + }; + + const submit = (accept: boolean) => + handleSubmit(async () => { + setError(undefined); + const result = await authClient.oauth2.consent({ + accept, + // Only ever a subset of the originally requested scopes; the + // provider rejects anything that was not asked for. + scope: requestedScopes + .filter((scope) => granted.has(scope)) + .join(" "), + // The provider verifies a signature over the full authorize + // query. validateSearch drops the params it does not declare, + // so the router's searchStr would fail that check. + oauth_query: + search.oauth_query ?? + window.location.search.replace(/^\?/, ""), + }); + if (result.error || !result.data?.url) { + setError( + result.error?.message ?? + "Could not complete OAuth consent.", + ); + return; + } + window.location.assign(result.data.url); + }); + + return ( +
+
+ + +
+ {client?.logo_uri ? ( + + ) : null} +
+

+ Authorize MCP access +

+ {isClientPending ? ( + + ) : ( +

+ {client?.client_name ? ( + <> + + {client.client_name} + {" "} + is requesting access to your Rivet + account. + + ) : ( + "An application is requesting access to your Rivet account." + )} +

+ )} +
+
+ +

+ Choose what to allow +

+
    + {requestedScopes.map((scope) => { + const detail = SCOPE_DETAILS[scope]; + const required = REQUIRED_SCOPES.has(scope); + const id = `scope-${scope}`; + return ( +
  • + + toggle(scope, checked === true) + } + className="mt-0.5" + /> +
    + +

    + {detail?.description ?? + "Grants the client additional access to your Rivet account."} +

    +
    +
  • + ); + })} +
+ + {granted.has("rivet:cloud:write") || + granted.has("rivet:actors:write") || + granted.has("rivet:inspector:write") ? ( +

+ Write access also requires the MCP service's write + policy to be enabled, so approving it here does not by + itself allow changes. +

+ ) : null} + + {error ? ( +

{error}

+ ) : null} + +
+ + +
+ +
+
+
Client ID
+
+ {search.client_id} +
+
+ {client?.client_uri ? ( +
+
Website
+
+ + {client.client_uri} + +
+
+ ) : null} +
+
+
+ ); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 836993a9ca..c9103c1e03 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// declare const __APP_BUILD_ID__: string; +declare const __MCP_APP__: boolean; declare module "*.module.css" { const classes: { [key: string]: string }; diff --git a/frontend/vite.base.config.ts b/frontend/vite.base.config.ts index 26f2b53c10..954e5eb25c 100644 --- a/frontend/vite.base.config.ts +++ b/frontend/vite.base.config.ts @@ -11,6 +11,7 @@ export function baseViteConfig(): UserConfig { __APP_BUILD_ID__: JSON.stringify( `${new Date().toISOString()}@${crypto.randomUUID()}`, ), + __MCP_APP__: JSON.stringify(false), }, resolve: { alias: { diff --git a/frontend/vite.mcp-inspector-ui.config.ts b/frontend/vite.mcp-inspector-ui.config.ts new file mode 100644 index 0000000000..fe87901813 --- /dev/null +++ b/frontend/vite.mcp-inspector-ui.config.ts @@ -0,0 +1 @@ +export { default } from "./apps/inspector-ui/vite.mcp.config"; From 1908ea6c2965733a17a1f71ec0ed692c2f4478b8 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:48:54 +0200 Subject: [PATCH 2/2] fix(frontend): address review on the mcp connection settings pr --- .claude/reference/feature-flags.md | 8 +- frontend/apps/inspector-ui/src/mcp-main.tsx | 94 ++++-- frontend/apps/inspector-ui/vite.mcp.config.ts | 28 +- frontend/package.json | 4 +- frontend/src/routes/oauth.consent.tsx | 99 +++--- pnpm-lock.yaml | 315 +++++++++++------- 6 files changed, 350 insertions(+), 198 deletions(-) diff --git a/.claude/reference/feature-flags.md b/.claude/reference/feature-flags.md index 0d74bd969b..dbe9dd3f50 100644 --- a/.claude/reference/feature-flags.md +++ b/.claude/reference/feature-flags.md @@ -33,6 +33,7 @@ if (features.platform) { | `branding` | Rivet branding chrome. | | `datacenter` | Datacenter-related UI. | | `danger-zone` | Destructive settings actions (`features.dangerZone`). | +| `mcp` | MCP connection settings card on the namespace settings drawer. Flavor-dependent content: `platform` renders the hosted `mcp.rivet.dev` endpoint pinned to this namespace, everything else renders the local stdio (`npx @rivet-dev/mcp`) config. | Deployment flavors map to flag sets roughly as: **cloud** = all on; **OSS** = `auth`/`platform`/`acl` off; **enterprise** = `acl` on, `auth`/`platform` off (engine enforces auth without a login UI). Do not treat `platform`/`auth` as "engine requires credentials" — that is `acl`. **`compute` is opt-in even on cloud** — each Railway service adds it to `VITE_FEATURE_FLAGS` per-environment (e.g. staging on, prod off) rather than inheriting the cloud default-on set. @@ -48,15 +49,18 @@ Switch flavors in dev without restarting the server by setting the `localStorage // OSS self-host: everything off localStorage.setItem("FEATURE_FLAGS", ""); location.reload(); +// OSS self-host with the local MCP snippet shown +localStorage.setItem("FEATURE_FLAGS", "mcp"); location.reload(); + // Full cloud: all flags on (see the commented canonical list in frontend/.env.local) localStorage.setItem( "FEATURE_FLAGS", - "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy", + "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy,mcp", ); location.reload(); // Enterprise: acl on, no login UI -localStorage.setItem("FEATURE_FLAGS", "acl,branding,support,datacenter,danger-zone"); location.reload(); +localStorage.setItem("FEATURE_FLAGS", "acl,branding,support,datacenter,danger-zone,mcp"); location.reload(); ``` In an agent-browser / DevTools session, paste those into the page console. `localStorage` persists across reloads, so run `localStorage.removeItem("FEATURE_FLAGS")` to return to the env default when finished. Confirm the active flavor with `JSON.stringify(features)` after importing, or just observe whether auth/cloud chrome is present. diff --git a/frontend/apps/inspector-ui/src/mcp-main.tsx b/frontend/apps/inspector-ui/src/mcp-main.tsx index 5a33a4bd76..c84c981474 100644 --- a/frontend/apps/inspector-ui/src/mcp-main.tsx +++ b/frontend/apps/inspector-ui/src/mcp-main.tsx @@ -17,7 +17,7 @@ type ActorTarget = region?: string; crashPolicy?: "restart" | "sleep" | "destroy"; skipReadyWait?: boolean; - }; + }; type InspectorGrant = { token: string; @@ -36,13 +36,16 @@ const app = new App( let currentActor: ActorTarget | undefined; let currentGrant: InspectorGrant | undefined; -function structuredGrant(result: Awaited>): InspectorGrant { +function structuredGrant( + result: Awaited>, +): InspectorGrant { if (result.isError || !result.structuredContent) { throw new Error("Could not create the temporary Inspector session"); } const value = result.structuredContent as Record; for (const key of ["token", "proxyUrl", "expiresAt", "actorId"] as const) { - if (typeof value[key] !== "string") throw new Error("Invalid Inspector session response"); + if (typeof value[key] !== "string") + throw new Error("Invalid Inspector session response"); } return value as InspectorGrant; } @@ -65,39 +68,70 @@ async function renewSession(token: string): Promise { ); } +async function revokeSession(token: string): Promise { + await app.callServerTool({ + name: "rivet.ui.actor.session.revoke", + arguments: { token }, + }); +} + +// `create` mints a new session record rather than rotating the current one, so +// the grant it replaces stays valid until its own TTL and keeps counting +// against the per-principal session limit. `renew` rotates in place and needs +// no revocation. Hosts may fire tool results back to back, so swaps are +// serialized to keep a concurrent pair from both reading the same outgoing +// grant and leaking one of them. +let sessionSwap: Promise = Promise.resolve(); + +function replaceSession(actor: ActorTarget): Promise { + const swap = sessionSwap.then(async () => { + const superseded = currentGrant; + const next = await createSession(actor); + currentGrant = next; + if (superseded) await revokeSession(superseded.token).catch(() => {}); + return next; + }); + sessionSwap = swap.catch(() => {}); + return swap; +} + function McpInspector() { const [grant, setGrant] = useState(); const [error, setError] = useState(); useEffect(() => { - const receiveInput = (params: { arguments?: Record }) => { + const receiveInput = (params: { + arguments?: Record; + }) => { const actor = params.arguments?.actor; - if (actor && typeof actor === "object") currentActor = actor as ActorTarget; + if (actor && typeof actor === "object") + currentActor = actor as ActorTarget; }; const receiveResult = () => { if (!currentActor) return; - void createSession(currentActor) - .then((next) => { - currentGrant = next; - setGrant(next); - }) - .catch(() => setError("Could not authenticate the embedded Inspector.")); + void replaceSession(currentActor) + .then(setGrant) + .catch(() => + setError("Could not authenticate the embedded Inspector."), + ); }; app.addEventListener("toolinput", receiveInput); app.addEventListener("toolresult", receiveResult); app.onhostcontextchanged = (context) => { - document.documentElement.classList.toggle("dark", context.theme !== "light"); + document.documentElement.classList.toggle( + "dark", + context.theme !== "light", + ); }; app.onteardown = async () => { - if (currentGrant) { - await app.callServerTool({ - name: "rivet.ui.actor.session.revoke", - arguments: { token: currentGrant.token }, - }); - } + if (currentGrant) await revokeSession(currentGrant.token); return {}; }; - void app.connect().catch(() => setError("This host could not initialize the MCP App.")); + void app + .connect() + .catch(() => + setError("This host could not initialize the MCP App."), + ); return () => { app.removeEventListener("toolinput", receiveInput); app.removeEventListener("toolresult", receiveResult); @@ -106,20 +140,32 @@ function McpInspector() { useEffect(() => { if (!grant) return; - const renewAt = Math.max(1_000, new Date(grant.expiresAt).getTime() - Date.now() - 30_000); + const renewAt = Math.max( + 1_000, + new Date(grant.expiresAt).getTime() - Date.now() - 30_000, + ); const timer = window.setTimeout(() => { void renewSession(grant.token) .then((next) => { currentGrant = next; setGrant(next); }) - .catch(() => setError("The Inspector session expired. Reopen the Inspector to continue.")); + .catch(() => + setError( + "The Inspector session expired. Reopen the Inspector to continue.", + ), + ); }, renewAt); return () => window.clearTimeout(timer); }, [grant]); if (error) return

{error}

; - if (!grant) return

Connecting to the Rivet Actor Inspector…

; + if (!grant) + return ( +

+ Connecting to the Rivet Actor Inspector… +

+ ); return (
{grant.dashboardUrl ? ( @@ -155,6 +201,4 @@ function McpInspector() { const root = document.getElementById("root"); if (!root) throw new Error("Inspector UI: #root element missing"); -ReactDOM.createRoot(root).render( - , -); +ReactDOM.createRoot(root).render(); diff --git a/frontend/apps/inspector-ui/vite.mcp.config.ts b/frontend/apps/inspector-ui/vite.mcp.config.ts index fd513e5d30..8042047b22 100644 --- a/frontend/apps/inspector-ui/vite.mcp.config.ts +++ b/frontend/apps/inspector-ui/vite.mcp.config.ts @@ -8,11 +8,16 @@ import tsconfigPaths from "vite-tsconfig-paths"; const rivetkitVersion = JSON.parse( readFileSync( - path.resolve(__dirname, "../../../rivetkit-typescript/packages/rivetkit/package.json"), + path.resolve( + __dirname, + "../../../rivetkit-typescript/packages/rivetkit/package.json", + ), "utf8", ), ).version as string; const require = createRequire(path.resolve(__dirname, "package.json")); +const WORKER_IMPORT = 'import ActorWorker from "./actor-repl.worker?worker";'; +let sawConsoleWorker = false; export default defineConfig({ root: path.resolve(__dirname), @@ -50,8 +55,16 @@ export default defineConfig({ enforce: "pre", transform(code, id) { if (!id.endsWith("/actor-worker-container.ts")) return; + // viteSingleFile cannot inline a `?worker` chunk, so a silently + // unmatched import ships a bundle whose console throws at load. + if (!code.includes(WORKER_IMPORT)) { + throw new Error( + `${id} no longer contains ${WORKER_IMPORT}; update the MCP console worker stub`, + ); + } + sawConsoleWorker = true; return code.replace( - 'import ActorWorker from "./actor-repl.worker?worker";', + WORKER_IMPORT, `class ActorWorker extends EventTarget { constructor() { super(); @@ -62,6 +75,13 @@ export default defineConfig({ }`, ); }, + buildEnd() { + if (!sawConsoleWorker) { + throw new Error( + "actor-worker-container.ts was never transformed; the MCP console worker stub did not apply", + ); + } + }, }, tsconfigPaths({ projects: [path.resolve(__dirname, "tsconfig.json")] }), viteSingleFile(), @@ -81,7 +101,9 @@ export default defineConfig({ __APP_BUILD_ID__: JSON.stringify("mcp-actor-inspector"), __RIVETKIT_VERSION__: JSON.stringify(rivetkitVersion), }, - optimizeDeps: { include: ["@fortawesome/*", "@rivet-gg/icons", "@rivet-gg/cloud"] }, + optimizeDeps: { + include: ["@fortawesome/*", "@rivet-gg/icons", "@rivet-gg/cloud"], + }, worker: { format: "es" }, build: { outDir: "../../dist/mcp-inspector-ui", diff --git a/frontend/package.json b/frontend/package.json index bd4a3ac727..f1bb728c7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,8 +20,6 @@ }, "dependencies": { "@better-auth/oauth-provider": "1.6.23", - "@modelcontextprotocol/ext-apps": "1.7.4", - "@modelcontextprotocol/sdk": "1.29.0", "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.8.1", "@codemirror/lang-javascript": "^6.2.4", @@ -40,6 +38,8 @@ "@ladle/react": "^5.1.1", "@marsidev/react-turnstile": "^1.5.0", "@microsoft/fetch-event-source": "^2.0.1", + "@modelcontextprotocol/ext-apps": "1.7.4", + "@modelcontextprotocol/sdk": "1.29.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/frontend/src/routes/oauth.consent.tsx b/frontend/src/routes/oauth.consent.tsx index f2bbda3b26..16d7f19cbf 100644 --- a/frontend/src/routes/oauth.consent.tsx +++ b/frontend/src/routes/oauth.consent.tsx @@ -1,7 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { Logo } from "@/app/logo"; import { Button } from "@/components/ui/button"; @@ -80,17 +80,27 @@ export const Route = createFileRoute("/oauth/consent")({ component: OAuthConsent, }); +interface ConsentFormValues { + scopes: Record; +} + function OAuthConsent() { const search = Route.useSearch(); - const [error, setError] = useState(); - const { handleSubmit, formState } = useForm>(); const requestedScopes = useMemo( () => search.scope.split(/\s+/).filter(Boolean), [search.scope], ); - const [granted, setGranted] = useState>( - () => new Set(requestedScopes), - ); + const { control, handleSubmit, watch } = useForm({ + defaultValues: { + scopes: Object.fromEntries( + requestedScopes.map((scope) => [scope, true]), + ), + }, + }); + const granted = watch("scopes"); + const grantedCount = requestedScopes.filter( + (scope) => granted[scope], + ).length; // Dynamically registered clients pick their own opaque client_id, so the // name they registered under is the only human-readable identifier. @@ -110,24 +120,20 @@ function OAuthConsent() { retry: false, }); - const toggle = (scope: string, checked: boolean) => { - setGranted((previous) => { - const next = new Set(previous); - if (checked) next.add(scope); - else next.delete(scope); - return next; - }); - }; - - const submit = (accept: boolean) => - handleSubmit(async () => { - setError(undefined); + const consent = useMutation({ + mutationFn: async ({ + accept, + values, + }: { + accept: boolean; + values: ConsentFormValues; + }) => { const result = await authClient.oauth2.consent({ accept, // Only ever a subset of the originally requested scopes; the // provider rejects anything that was not asked for. scope: requestedScopes - .filter((scope) => granted.has(scope)) + .filter((scope) => values.scopes[scope]) .join(" "), // The provider verifies a signature over the full authorize // query. validateSearch drops the params it does not declare, @@ -137,14 +143,18 @@ function OAuthConsent() { window.location.search.replace(/^\?/, ""), }); if (result.error || !result.data?.url) { - setError( + throw new Error( result.error?.message ?? "Could not complete OAuth consent.", ); - return; } - window.location.assign(result.data.url); - }); + return result.data.url; + }, + onSuccess: (url) => window.location.assign(url), + }); + + const submit = (accept: boolean) => + handleSubmit((values) => consent.mutate({ accept, values })); return (
@@ -193,14 +203,21 @@ function OAuthConsent() { const id = `scope-${scope}`; return (
  • - - toggle(scope, checked === true) - } - className="mt-0.5" + ( + + field.onChange(checked === true) + } + className="mt-0.5" + /> + )} />