From 83bebab8aa60edb2cdbdb7d5efb188b5738d69ff Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Fri, 30 Jan 2026 10:18:08 -0300 Subject: [PATCH] feat(cli): add mesh login command with beautiful capybara success page - Add CLI mesh login command with browser-based OAuth flow - Add beautiful cli-callback page with capybara coding animation - Add /api/auth/custom/cli-token endpoint for creating CLI API keys - Add mesh session and client utilities for CLI authentication - Update runtime OAuth to show beautiful success page for standalone MCPs The CLI login flow opens a browser for authentication, then displays a beautiful success page featuring the capybara coding animation while completing the callback via hidden iframe. Co-Authored-By: Claude Opus 4.5 --- apps/mesh/src/api/routes/auth.ts | 68 +- apps/mesh/src/web/index.tsx | 7 + apps/mesh/src/web/routes/cli-callback.tsx | 236 ++++ packages/cli/src/commands/auth/mesh-login.ts | 515 ++++++++ packages/cli/src/lib/mesh-client.ts | 1222 ++++++++++++++++++ packages/cli/src/lib/mesh-session.ts | 138 ++ packages/runtime/src/oauth.ts | 259 +++- 7 files changed, 2443 insertions(+), 2 deletions(-) create mode 100644 apps/mesh/src/web/routes/cli-callback.tsx create mode 100644 packages/cli/src/commands/auth/mesh-login.ts create mode 100644 packages/cli/src/lib/mesh-client.ts create mode 100644 packages/cli/src/lib/mesh-session.ts diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 55ea3e1542..676d8eb1f7 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -6,7 +6,7 @@ */ import { Hono } from "hono"; -import { authConfig } from "../../auth"; +import { auth, authConfig } from "../../auth"; import { KNOWN_OAUTH_PROVIDERS, OAuthProvider } from "@/auth/oauth-providers"; const app = new Hono(); @@ -98,4 +98,70 @@ app.get("/config", async (c) => { } }); +/** + * CLI Token Endpoint + * + * Creates an API key for CLI use. + * This endpoint is called after the user logs in via browser. + * It validates the session cookie and creates a long-lived API key. + * + * Route: GET /api/auth/custom/cli-token + */ +app.get("/cli-token", async (c) => { + try { + // Get session from cookie using the global auth instance + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session?.session || !session?.user) { + return c.json({ success: false, error: "Not authenticated" }, 401); + } + + // Create an API key for CLI use + // Use unique name with timestamp to avoid race conditions + const cliKeyName = `deco-cli-${Date.now()}`; + + // Create a new API key for CLI (90 day expiration) + // expiresIn is in SECONDS (90 days = 90 * 24 * 60 * 60) + const ninetyDaysInSeconds = 90 * 24 * 60 * 60; + + const newKey = await auth.api.createApiKey({ + headers: c.req.raw.headers, + body: { + name: cliKeyName, + expiresIn: ninetyDaysInSeconds, + metadata: { + source: "cli-login", + createdAt: new Date().toISOString(), + }, + }, + }); + + const apiKey = newKey?.key || ""; + + if (!apiKey) { + return c.json({ success: false, error: "Failed to create API key" }, 500); + } + + // Return the API key for CLI use + return c.json({ + success: true, + token: apiKey, + user: { + id: session.user.id, + email: session.user.email, + name: session.user.name, + }, + expiresAt: new Date( + Date.now() + ninetyDaysInSeconds * 1000, + ).toISOString(), + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to get CLI token"; + return c.json({ success: false, error: errorMessage }, 500); + } +}); + export default app; diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx index ab0d6ede08..54c32febbf 100644 --- a/apps/mesh/src/web/index.tsx +++ b/apps/mesh/src/web/index.tsx @@ -284,6 +284,12 @@ const oauthCallbackRoute = createRoute({ component: lazyRouteComponent(() => import("./routes/oauth-callback.tsx")), }); +const cliCallbackRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/cli-callback", + component: lazyRouteComponent(() => import("./routes/cli-callback.tsx")), +}); + const orgStoreRouteWithChildren = orgStoreRoute.addChildren([ storeServerDetailRoute, ]); @@ -310,6 +316,7 @@ const routeTree = rootRoute.addChildren([ loginRoute, betterAuthRoutes, oauthCallbackRoute, + cliCallbackRoute, connectRoute, storeInviteRoute, ]); diff --git a/apps/mesh/src/web/routes/cli-callback.tsx b/apps/mesh/src/web/routes/cli-callback.tsx new file mode 100644 index 0000000000..4d76f9f626 --- /dev/null +++ b/apps/mesh/src/web/routes/cli-callback.tsx @@ -0,0 +1,236 @@ +/** + * CLI Callback Route + * + * This page is shown after login when the user is authenticating via CLI. + * It fetches the session token and shows a beautiful success page with + * the capybara coding animation, then completes the callback silently. + */ + +import { useEffect, useState, useRef } from "react"; +import { useSearch } from "@tanstack/react-router"; + +// Declare UnicornStudio on window +declare global { + interface Window { + UnicornStudio?: { + init: () => Promise; + }; + } +} + +export default function CliCallbackRoute() { + const searchParams = useSearch({ from: "/cli-callback" }); + const { callback } = searchParams; + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const scriptLoadedRef = useRef(false); + + // Load UnicornStudio script + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + if (scriptLoadedRef.current) return; + scriptLoadedRef.current = true; + + const script = document.createElement("script"); + script.src = + "https://cdn.jsdelivr.net/gh/nicholashamilton/unicorn-studio-embed-player@v1.5.2/dist/player.umd.js"; + script.async = true; + script.onload = () => { + if (window.UnicornStudio) { + window.UnicornStudio.init().catch(console.error); + } + }; + document.body.appendChild(script); + }, []); + + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + async function handleCliCallback() { + if (!callback) { + setError("No callback URL provided"); + return; + } + + try { + // Fetch the CLI token from the API + const response = await fetch("/api/auth/custom/cli-token", { + credentials: "include", // Include session cookie + }); + + const data = await response.json(); + + if (!data.success || !data.token) { + setError(data.error || "Failed to get session token"); + return; + } + + // Build callback URL with token and user data + const callbackUrl = new URL(callback); + callbackUrl.searchParams.set("token", data.token); + + // Include user info so CLI doesn't need to fetch again + if (data.user) { + callbackUrl.searchParams.set("user", btoa(JSON.stringify(data.user))); + } + if (data.expiresAt) { + callbackUrl.searchParams.set("expiresAt", data.expiresAt); + } + + // Also include state if it was in our callback + const currentParams = new URLSearchParams(window.location.search); + const state = currentParams.get("state"); + if (state) { + callbackUrl.searchParams.set("state", state); + } + + setSuccess(true); + + // Complete the callback silently via hidden iframe + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.src = callbackUrl.toString(); + document.body.appendChild(iframe); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } + } + + handleCliCallback(); + }, [callback]); + + const badgeColor = error + ? "rgba(239, 68, 68, 0.12)" + : "rgba(34, 197, 94, 0.12)"; + const badgeBorder = error + ? "rgba(239, 68, 68, 0.25)" + : "rgba(34, 197, 94, 0.25)"; + const badgeTextColor = error ? "#f87171" : "#4ade80"; + + return ( +
+ {/* Animation Panel */} +
+
+
+
+
+ + {/* Content Panel */} +
+
+ {/* Logo */} + MCP Mesh + + {/* Status Badge */} +
+ {error ? ( + + + + ) : success ? ( + + + + ) : ( + + + + + )} + {error ? "Failed" : success ? "Authenticated" : "Authenticating"} +
+ + {/* Title */} +

+ {error + ? "Authentication Failed" + : success + ? "Welcome to the Mesh" + : "Connecting..."} +

+ + {/* Description */} +

+ {error ? ( + <> + {error} +
+ + You can close this window and try again. + + + ) : success ? ( + "You can close this window and return to your terminal." + ) : ( + "Please wait while we complete the authentication..." + )} +

+
+
+ + {/* Custom animation keyframes */} + +
+ ); +} diff --git a/packages/cli/src/commands/auth/mesh-login.ts b/packages/cli/src/commands/auth/mesh-login.ts new file mode 100644 index 0000000000..b0011ae9cf --- /dev/null +++ b/packages/cli/src/commands/auth/mesh-login.ts @@ -0,0 +1,515 @@ +/** + * Mesh Login Command + * + * Authenticates with the Mesh using Better Auth. + * Supports email/password login or browser-based OAuth. + */ + +import { createServer, IncomingMessage, ServerResponse } from "http"; +import { spawn } from "child_process"; +import { randomBytes } from "crypto"; +import * as readline from "readline"; +import process from "node:process"; +import { saveMeshSession, type MeshSession } from "../../lib/mesh-session.js"; + +const AUTH_PORT = 3458; // Different port from old auth + +interface MeshLoginOptions { + meshUrl?: string; + email?: string; + password?: string; +} + +/** + * Open browser with OS-appropriate command + */ +function openBrowser(url: string): void { + const browserCommands: Record = { + linux: "xdg-open", + darwin: "open", + win32: "start", + }; + + const browser = + process.env.BROWSER ?? browserCommands[process.platform] ?? "open"; + + const command = + process.platform === "win32" && browser === "start" + ? spawn("cmd", ["/c", "start", url], { detached: true }) + : spawn(browser, [url], { detached: true }); + + command.unref(); + command.on("error", () => { + console.log("āš ļø Could not automatically open browser"); + }); +} + +/** + * Prompt for email and password + */ +async function promptCredentials(): Promise<{ + email: string; + password: string; +}> { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question("šŸ“§ Email: ", (email) => { + // Hide password input + process.stdout.write("šŸ”‘ Password: "); + let password = ""; + + const stdin = process.stdin; + const originalRawMode = stdin.isRaw; + + if (stdin.isTTY) { + stdin.setRawMode(true); + } + stdin.resume(); + stdin.setEncoding("utf8"); + + const onData = (char: string) => { + if (char === "\n" || char === "\r") { + stdin.removeListener("data", onData); + if (stdin.isTTY) { + stdin.setRawMode(originalRawMode ?? false); + } + console.log(""); // New line after password + rl.close(); + resolve({ email: email.trim(), password }); + } else if (char === "\u0003") { + // Ctrl+C + process.exit(0); + } else if (char === "\u007F" || char === "\b") { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1); + } + } else { + password += char; + } + }; + + stdin.on("data", onData); + }); + }); +} + +/** + * Login with email and password directly + */ +async function loginWithEmailPassword( + meshUrl: string, + email: string, + password: string, +): Promise { + const response = await fetch(`${meshUrl}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Login failed: ${error}`); + } + + const data = await response.json(); + + // Better Auth returns user and session in the response + if (!data.user || !data.token) { + throw new Error("Invalid response from auth server"); + } + + return { + meshUrl, + token: data.token, + user: { + id: data.user.id, + email: data.user.email, + name: data.user.name, + }, + expiresAt: data.expiresAt, + }; +} + +/** + * Login with browser-based OAuth flow + * Uses Better Auth's session token via callback + */ +async function loginWithBrowser(meshUrl: string): Promise { + return new Promise((resolve, reject) => { + const state = randomBytes(16).toString("hex"); + let timeout: NodeJS.Timeout; + + const server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url!, `http://localhost:${AUTH_PORT}`); + + if (url.pathname === "/callback") { + const token = url.searchParams.get("token"); + const error = url.searchParams.get("error"); + const receivedState = url.searchParams.get("state"); + const userBase64 = url.searchParams.get("user"); + const expiresAt = url.searchParams.get("expiresAt"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + `

Login Failed

${error}

`, + ); + clearTimeout(timeout); + server.close(() => reject(new Error(error))); + return; + } + + if (receivedState !== state) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(`

Invalid State

`); + return; + } + + if (!token) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(`

No Token Received

`); + return; + } + + // Parse user data from callback (already included by cli-callback page) + try { + let userData: { + user?: { id?: string; email?: string; name?: string }; + } = {}; + + if (userBase64) { + try { + userData = { + user: JSON.parse( + Buffer.from(userBase64, "base64").toString("utf-8"), + ), + }; + } catch { + // Fallback to empty user + userData = { user: {} }; + } + } + + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + }); + res.end(` + + + + + Login Successful - MCP Mesh + + + +
+ +
+
+
+
+
+ + +
+
+ +
+ + + + Authenticated +
+

Welcome to the Mesh

+

You can close this window and return to the terminal.

+
+
+
+ + + + +`); + + clearTimeout(timeout); + server.close(() => + resolve({ + meshUrl, + token, + user: { + id: userData.user?.id || "unknown", + email: userData.user?.email, + name: userData.user?.name, + }, + expiresAt: expiresAt || undefined, + }), + ); + } catch (err) { + res.writeHead(500, { "Content-Type": "text/html" }); + res.end(`

Error

${err}

`); + clearTimeout(timeout); + server.close(() => reject(err)); + } + return; + } + + res.writeHead(404); + res.end("Not found"); + }, + ); + + server.listen(AUTH_PORT, () => { + const callbackUrl = `http://localhost:${AUTH_PORT}/callback?state=${state}`; + const loginUrl = `${meshUrl}/login?cli=true&callback=${encodeURIComponent(callbackUrl)}`; + + console.log("šŸ” Opening browser for login...\n"); + console.log(` Login URL: ${loginUrl}\n`); + console.log( + ` Callback listening on: http://localhost:${AUTH_PORT}/callback\n`, + ); + openBrowser(loginUrl); + + timeout = setTimeout(() => { + console.log("šŸ“‹ If your browser didn't open, visit:"); + console.log(`\n ${loginUrl}\n`); + console.log("Waiting for authentication...\n"); + }, 1000); + }); + + server.on("error", (err) => { + reject(err); + }); + }); +} + +/** + * Main Mesh login command + */ +export async function meshLoginCommand( + options: MeshLoginOptions = {}, +): Promise { + const meshUrl = + options.meshUrl || process.env.MESH_URL || "http://localhost:3000"; + + console.log(`\nšŸ”— Connecting to Mesh at ${meshUrl}\n`); + + try { + let session: MeshSession; + + if (options.email && options.password) { + // Direct email/password login + console.log("šŸ”‘ Logging in with email/password..."); + session = await loginWithEmailPassword( + meshUrl, + options.email, + options.password, + ); + } else if (process.stdin.isTTY) { + // Interactive: ask user how they want to login + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const method = await new Promise((resolve) => { + rl.question( + "Login method: (1) Email/Password, (2) Browser [2]: ", + (answer) => { + rl.close(); + resolve(answer.trim() || "2"); + }, + ); + }); + + if (method === "1") { + const { email, password } = await promptCredentials(); + session = await loginWithEmailPassword(meshUrl, email, password); + } else { + session = await loginWithBrowser(meshUrl); + } + } else { + // Non-interactive: use browser + session = await loginWithBrowser(meshUrl); + } + + // Save session + await saveMeshSession(session); + + console.log(`\nāœ… Successfully logged in to Mesh!`); + console.log( + ` User: ${session.user.email || session.user.name || session.user.id}`, + ); + console.log(` Mesh: ${meshUrl}\n`); + } catch (error) { + throw new Error( + `Login failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/cli/src/lib/mesh-client.ts b/packages/cli/src/lib/mesh-client.ts new file mode 100644 index 0000000000..61c8cec137 --- /dev/null +++ b/packages/cli/src/lib/mesh-client.ts @@ -0,0 +1,1222 @@ +/** + * Mesh Client for CLI + * + * Connects to the Mesh and calls tools, including discovering LLM connections. + */ + +import { + readMeshSession, + setMeshOrganization, + type MeshSession, +} from "./mesh-session.js"; + +interface Organization { + id: string; + name: string; + slug: string; + logo?: string | null; +} + +interface ToolCallResult { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; +} + +interface ConnectionTool { + name: string; + description?: string; + inputSchema?: Record; +} + +export interface Connection { + id: string; + title: string; + type?: string; + bindings?: string[]; + tools?: ConnectionTool[]; +} + +export interface AgentConnection extends Connection { + /** Full tool definitions fetched from the connection */ + fullTools: ConnectionTool[]; + /** System prompt if any */ + systemPrompt?: string; +} + +/** + * Gateway entity (called "Agent" in UI) + */ +export interface Gateway { + id: string; + title: string; + description?: string | null; + status: string; + tool_selection_mode?: string; + connections?: Array<{ connection_id: string }>; +} + +/** + * Build headers with authentication and organization context + */ +function buildHeaders(session: MeshSession): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${session.token}`, + }; + if (session.organizationId) { + headers["x-organization-id"] = session.organizationId; + } + return headers; +} + +/** + * Call a tool on the Mesh's management endpoint (/mcp) + * If an organization is selected, uses the org-scoped endpoint + */ +export async function callMeshTool( + session: MeshSession, + toolName: string, + args: Record, +): Promise { + // Always use /mcp for management tools - organization is passed via header + const mcpPath = "/mcp"; + + // Build headers with organization context if available + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${session.token}`, + }; + if (session.organizationId) { + headers["x-organization-id"] = session.organizationId; + } + + const response = await fetch(`${session.meshUrl}${mcpPath}`, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: Date.now(), + method: "tools/call", + params: { + name: toolName, + arguments: args, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Mesh API error (${response.status}): ${text}`); + } + + const contentType = response.headers.get("Content-Type") || ""; + let json: { + result?: ToolCallResult; + error?: { message: string; code?: number }; + }; + + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + const lines = text.split("\n"); + const dataLines = lines.filter((line) => line.startsWith("data: ")); + const lastData = dataLines[dataLines.length - 1]; + if (!lastData) { + throw new Error("Empty SSE response"); + } + json = JSON.parse(lastData.slice(6)); + } else { + json = await response.json(); + } + + if (json.error) { + throw new Error(`Tool error: ${json.error.message}`); + } + + if (json.result?.structuredContent) { + return json.result.structuredContent as T; + } + + const content = json.result?.content; + if (content && content.length > 0) { + const textItem = content.find((c) => c.type === "text" || c.text); + if (textItem?.text) { + try { + return JSON.parse(textItem.text) as T; + } catch { + return { text: textItem.text } as T; + } + } + } + + return null as T; +} + +/** + * Call a tool on a specific connection + */ +export async function callConnectionTool( + session: MeshSession, + connectionId: string, + toolName: string, + args: Record, +): Promise { + const response = await fetch(`${session.meshUrl}/mcp/${connectionId}`, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify({ + jsonrpc: "2.0", + id: Date.now(), + method: "tools/call", + params: { + name: toolName, + arguments: args, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Connection tool error (${response.status}): ${text}`); + } + + const contentType = response.headers.get("Content-Type") || ""; + let json: { + result?: ToolCallResult; + error?: { message: string; code?: number }; + }; + + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + const lines = text.split("\n"); + const dataLines = lines.filter((line) => line.startsWith("data: ")); + const lastData = dataLines[dataLines.length - 1]; + if (!lastData) { + throw new Error("Empty SSE response"); + } + json = JSON.parse(lastData.slice(6)); + } else { + json = await response.json(); + } + + if (json.error) { + throw new Error(`Tool error: ${json.error.message}`); + } + + if (json.result?.structuredContent) { + return json.result.structuredContent as T; + } + + const content = json.result?.content; + if (content && content.length > 0) { + const textItem = content.find((c) => c.type === "text" || c.text); + if (textItem?.text) { + try { + return JSON.parse(textItem.text) as T; + } catch { + return { text: textItem.text } as T; + } + } + } + + return null as T; +} + +/** + * Call a tool on a gateway (agent) + */ +export async function callGatewayTool( + session: MeshSession, + gatewayId: string, + toolName: string, + args: Record, +): Promise { + const requestBody = { + jsonrpc: "2.0", + id: Date.now(), + method: "tools/call", + params: { + name: toolName, + arguments: args, + }, + }; + + const response = await fetch(`${session.meshUrl}/mcp/gateway/${gatewayId}`, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Gateway tool error (${response.status}): ${text}`); + } + + const contentType = response.headers.get("Content-Type") || ""; + let json: { + result?: ToolCallResult; + error?: { message: string; code?: number }; + }; + + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + const lines = text.split("\n"); + const dataLines = lines.filter((line) => line.startsWith("data: ")); + const lastData = dataLines[dataLines.length - 1]; + if (!lastData) { + throw new Error("Empty SSE response"); + } + json = JSON.parse(lastData.slice(6)); + } else { + json = await response.json(); + } + + if (json.error) { + throw new Error(`Tool error: ${json.error.message}`); + } + + if (json.result?.structuredContent) { + return json.result.structuredContent as T; + } + + const content = json.result?.content; + if (content && content.length > 0) { + const textItem = content.find((c) => c.type === "text" || c.text); + if (textItem?.text) { + try { + return JSON.parse(textItem.text) as T; + } catch { + return { text: textItem.text } as T; + } + } + } + + return null as T; +} + +/** + * List all connections in the Mesh + */ +export async function listConnections( + session: MeshSession, +): Promise { + try { + const result = await callMeshTool<{ items?: Connection[] }>( + session, + "COLLECTION_CONNECTIONS_LIST", + {}, + ); + return result?.items || []; + } catch { + return []; + } +} + +/** + * Find a connection that has LLM tools (LLM_DO_GENERATE) + */ +export async function findLLMConnection( + session: MeshSession, +): Promise { + const connections = await listConnections(session); + + for (const conn of connections) { + // Check if connection has LLM tools + if (conn.tools?.some((t) => t.name === "LLM_DO_GENERATE")) { + return conn; + } + + // Check bindings + if ( + conn.bindings?.includes("LLMS") || + conn.bindings?.includes("LANGUAGE_MODEL_BINDING") + ) { + return conn; + } + } + + // Fallback: try each connection + for (const conn of connections) { + try { + // Try to list tools on this connection + const toolsResponse = await fetch(`${session.meshUrl}/mcp/${conn.id}`, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }), + }); + + if (toolsResponse.ok) { + const data = await toolsResponse.json(); + const tools = data.result?.tools || []; + if (tools.some((t: { name: string }) => t.name === "LLM_DO_GENERATE")) { + return conn; + } + } + } catch { + // Continue to next connection + } + } + + return null; +} + +/** + * Get full tool list for a connection + */ +export async function getConnectionTools( + session: MeshSession, + connectionId: string, +): Promise { + try { + const response = await fetch(`${session.meshUrl}/mcp/${connectionId}`, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }), + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.result?.tools || []; + } catch { + return []; + } +} + +/** + * Get system prompt for a connection (if available) + */ +export async function getConnectionPrompts( + session: MeshSession, + connectionId: string, +): Promise { + try { + const response = await fetch(`${session.meshUrl}/mcp/${connectionId}`, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "prompts/list", + params: {}, + }), + }); + + if (!response.ok) { + return undefined; + } + + const data = await response.json(); + const prompts = data.result?.prompts || []; + + // Look for a system prompt + const systemPrompt = prompts.find( + (p: { name: string }) => + p.name === "system" || + p.name === "SYSTEM" || + p.name.toLowerCase().includes("system"), + ); + + if (systemPrompt) { + // Fetch the prompt content + const promptResponse = await fetch( + `${session.meshUrl}/mcp/${connectionId}`, + { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "prompts/get", + params: { name: systemPrompt.name }, + }), + }, + ); + + if (promptResponse.ok) { + const promptData = await promptResponse.json(); + const messages = promptData.result?.messages || []; + if (messages.length > 0) { + return messages + .map((m: { content: { text: string } }) => m.content?.text || "") + .join("\n"); + } + } + } + + return undefined; + } catch { + return undefined; + } +} + +/** + * Agent info combining gateway metadata with tools + */ +export interface Agent { + id: string; + name: string; // derived from gateway.title + description?: string | null; + status: string; + tools: ConnectionTool[]; +} + +/** + * List all gateways (agents) with their tools + */ +export async function listAgents(session: MeshSession): Promise { + const gateways = await listGateways(session); + const agents: Agent[] = []; + + for (const gateway of gateways) { + if (gateway.status !== "active") { + continue; + } + + // Fetch tools from the gateway endpoint + try { + const url = `${session.meshUrl}/mcp/gateway/${gateway.id}`; + const response = await fetch(url, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }), + }); + + if (response.ok) { + const data = await response.json(); + const tools = data.result?.tools || []; + agents.push({ + id: gateway.id, + name: gateway.title, + description: gateway.description, + status: gateway.status, + tools, + }); + } + } catch { + // Skip gateways that fail to respond + } + } + + return agents; +} + +/** + * List gateways (agents) in the organization + */ +export async function listGateways(session: MeshSession): Promise { + try { + const result = await callMeshTool<{ items?: Gateway[] }>( + session, + "COLLECTION_GATEWAY_LIST", + {}, + ); + return result?.items || []; + } catch { + return []; + } +} + +/** + * List organizations the user has access to + */ +export async function listOrganizations( + session: MeshSession, +): Promise { + try { + const result = await callMeshTool<{ organizations?: Organization[] }>( + session, + "ORGANIZATION_LIST", + {}, + ); + return result?.organizations || []; + } catch { + return []; + } +} + +/** + * Find an LLM-capable gateway (agent) that can be used for reasoning + * Returns the agent with LLM_DO_GENERATE capability + */ +export async function findLLMGateway( + session: MeshSession, +): Promise { + const agents = await listAgents(session); + + for (const agent of agents) { + const hasLLM = agent.tools.some((t) => t.name === "LLM_DO_GENERATE"); + if (hasLLM) { + return agent; + } + } + + return null; +} + +/** + * Convert simple messages to the LanguageModelPrompt format + */ +function toLanguageModelPrompt( + messages: Array<{ role: string; content: string }>, +): Array<{ + role: string; + content: string | Array<{ type: "text"; text: string }>; +}> { + return messages.map((msg) => { + if (msg.role === "system") { + // System messages have string content + return { role: "system", content: msg.content }; + } + // User and assistant messages have array content + return { + role: msg.role, + content: [{ type: "text" as const, text: msg.content }], + }; + }); +} + +/** + * Extract text from LLM response content + */ +function extractTextFromResponse(result: unknown): string { + if (!result || typeof result !== "object") { + return JSON.stringify(result); + } + + const r = result as Record; + + // Check for content array (LanguageModelGenerateOutputSchema format) + if (Array.isArray(r.content)) { + const textParts = r.content + .filter( + (part: unknown) => + typeof part === "object" && + part !== null && + (part as Record).type === "text", + ) + .map((part: unknown) => (part as { text: string }).text); + if (textParts.length > 0) { + return textParts.join(""); + } + } + + // Fallback to legacy formats + if (typeof r.text === "string") return r.text; + if (typeof r.content === "string") return r.content; + if ( + r.response && + typeof r.response === "object" && + typeof (r.response as Record).text === "string" + ) { + return (r.response as { text: string }).text; + } + + return JSON.stringify(result); +} + +/** + * Generate text using an LLM connection + */ +export async function generateText( + session: MeshSession, + connectionId: string, + messages: Array<{ role: string; content: string }>, + model?: string, +): Promise { + const result = await callConnectionTool( + session, + connectionId, + "LLM_DO_GENERATE", + { + modelId: model || "anthropic/claude-sonnet-4", + callOptions: { + prompt: toLanguageModelPrompt(messages), + }, + }, + ); + + return extractTextFromResponse(result); +} + +/** + * Generate text using an LLM via a gateway (agent) + */ +export async function generateTextViaGateway( + session: MeshSession, + gatewayId: string, + messages: Array<{ role: string; content: string }>, + model?: string, +): Promise { + const result = await callGatewayTool( + session, + gatewayId, + "LLM_DO_GENERATE", + { + modelId: model || "anthropic/claude-sonnet-4", + callOptions: { + prompt: toLanguageModelPrompt(messages), + }, + }, + ); + + return extractTextFromResponse(result); +} + +/** + * Tool definition format for LLM + */ +export interface LLMTool { + name: string; + description?: string; + inputSchema?: unknown; +} + +/** + * Tool call from LLM response + */ +export interface ToolCall { + toolCallId: string; + toolName: string; + args: Record; +} + +/** + * Callback for tool execution events + */ +export type ToolExecutionCallback = (event: ToolExecutionEvent) => void; + +export type ToolExecutionEvent = + | { type: "text"; text: string } + | { type: "tool-call-start"; toolName: string; toolCallId: string } + | { type: "tool-call-args"; toolName: string; args: Record } + | { + type: "tool-result"; + toolName: string; + result: unknown; + isError?: boolean; + } + | { type: "done" }; + +/** + * Parse tool calls from LLM response content + */ +function parseToolCalls(content: unknown[]): ToolCall[] { + const toolCalls: ToolCall[] = []; + + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + (part as Record).type === "tool-call" + ) { + const tc = part as { + toolCallId?: string; + toolName?: string; + input?: string; + }; + if (tc.toolName) { + let args: Record = {}; + if (tc.input) { + try { + args = JSON.parse(tc.input); + } catch { + args = { input: tc.input }; + } + } + toolCalls.push({ + toolCallId: tc.toolCallId || `tc_${Date.now()}`, + toolName: tc.toolName, + args, + }); + } + } + } + + return toolCalls; +} + +/** + * Local tool executor type - used for tools that run locally instead of via gateway + */ +export type LocalToolExecutor = ( + toolName: string, + args: Record, +) => Promise<{ success: boolean; result?: unknown; error?: string }>; + +/** + * Generate text with tool execution loop via gateway + * Handles multiple rounds of tool calls until the model is done + * @param localToolExecutor - Optional executor for local tools + * @param localToolNames - Set of tool names that should be executed locally + */ +export async function generateWithToolsViaGateway( + session: MeshSession, + gatewayId: string, + initialMessages: Array<{ role: string; content: string }>, + tools: LLMTool[], + model: string | undefined, + onEvent: ToolExecutionCallback, + maxIterations = 10, + localToolExecutor?: LocalToolExecutor, + localToolNames?: Set, +): Promise { + // Convert tools to LLM format + const llmTools = tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description || "", + parameters: tool.inputSchema || { type: "object", properties: {} }, + })); + + // Build message history with proper format + const messageHistory = toLanguageModelPrompt(initialMessages); + let fullText = ""; + let iterations = 0; + + while (iterations < maxIterations) { + iterations++; + + // Call LLM with tools + const result = await callGatewayTool<{ + content?: unknown[]; + finishReason?: string; + }>(session, gatewayId, "LLM_DO_GENERATE", { + modelId: model || "anthropic/claude-sonnet-4", + callOptions: { + prompt: messageHistory, + tools: llmTools, + toolChoice: { type: "auto" }, + }, + }); + + const content = result?.content || []; + + // Extract text parts + for (const part of content) { + if ( + typeof part === "object" && + part !== null && + (part as Record).type === "text" + ) { + const text = (part as { text: string }).text; + fullText += text; + onEvent({ type: "text", text }); + } + } + + // Check for tool calls + const toolCalls = parseToolCalls(content); + + if (toolCalls.length === 0) { + // No tool calls, we're done + break; + } + + // Execute tool calls + const toolResults: Array<{ + type: "tool-result"; + toolCallId: string; + toolName: string; + result: unknown; + isError?: boolean; + }> = []; + + for (const tc of toolCalls) { + onEvent({ + type: "tool-call-start", + toolName: tc.toolName, + toolCallId: tc.toolCallId, + }); + onEvent({ + type: "tool-call-args", + toolName: tc.toolName, + args: tc.args, + }); + + try { + let toolResult: unknown; + let isError = false; + + // Check if this is a local tool + if (localToolExecutor && localToolNames?.has(tc.toolName)) { + const localResult = await localToolExecutor(tc.toolName, tc.args); + if (localResult.success) { + toolResult = localResult.result; + } else { + toolResult = { error: localResult.error }; + isError = true; + } + } else { + // Execute the tool via the gateway + toolResult = await callGatewayTool( + session, + gatewayId, + tc.toolName, + tc.args, + ); + } + + onEvent({ + type: "tool-result", + toolName: tc.toolName, + result: toolResult, + isError, + }); + + toolResults.push({ + type: "tool-result", + toolCallId: tc.toolCallId, + toolName: tc.toolName, + result: toolResult, + isError, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + + onEvent({ + type: "tool-result", + toolName: tc.toolName, + result: { error: errorMessage }, + isError: true, + }); + + toolResults.push({ + type: "tool-result", + toolCallId: tc.toolCallId, + toolName: tc.toolName, + result: { error: errorMessage }, + isError: true, + }); + } + } + + // Add assistant message with tool calls to history + messageHistory.push({ + role: "assistant", + content: content.map((part) => { + if ( + typeof part === "object" && + part !== null && + (part as Record).type === "tool-call" + ) { + const tc = part as { + toolCallId?: string; + toolName?: string; + input?: string; + }; + return { + type: "tool-call" as const, + toolCallId: tc.toolCallId || "", + toolName: tc.toolName || "", + input: tc.input || "{}", + }; + } + return part; + }), + }); + + // Add tool results to history + messageHistory.push({ + role: "tool", + content: toolResults.map((tr) => ({ + type: "tool-result" as const, + toolCallId: tr.toolCallId, + toolName: tr.toolName, + output: { + type: "json" as const, + value: tr.result, + }, + result: tr.result, + })), + }); + + // Check if we should stop + if (result?.finishReason === "stop") { + break; + } + } + + onEvent({ type: "done" }); + return fullText; +} + +/** + * Stream event types for real-time LLM output + */ +export type StreamEvent = + | { type: "text-delta"; text: string } + | { type: "tool-call-start"; toolCallId: string; toolName: string } + | { + type: "tool-call-delta"; + toolCallId: string; + toolName: string; + argsText: string; + } + | { + type: "tool-call-end"; + toolCallId: string; + toolName: string; + args: unknown; + } + | { + type: "tool-result"; + toolCallId: string; + toolName: string; + result: unknown; + } + | { type: "finish"; reason: string } + | { type: "error"; error: string }; + +/** + * Stream text generation via gateway with real-time callbacks + */ +export async function streamTextViaGateway( + session: MeshSession, + gatewayId: string, + messages: Array<{ role: string; content: string }>, + model: string | undefined, + onEvent: (event: StreamEvent) => void, +): Promise { + const requestBody = { + jsonrpc: "2.0", + id: Date.now(), + method: "tools/call", + params: { + name: "LLM_DO_STREAM", + arguments: { + modelId: model || "anthropic/claude-sonnet-4", + callOptions: { + prompt: toLanguageModelPrompt(messages), + }, + }, + }, + }; + + const response = await fetch(`${session.meshUrl}/mcp/gateway/${gatewayId}`, { + method: "POST", + headers: buildHeaders(session), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Stream error (${response.status}): ${text}`); + } + + const contentType = response.headers.get("Content-Type") || ""; + let fullText = ""; + + if (contentType.includes("text/event-stream") && response.body) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + + // Handle different event types from the stream + if (data.result?.content) { + for (const part of data.result.content) { + if (part.type === "text" && part.text) { + fullText += part.text; + onEvent({ type: "text-delta", text: part.text }); + } else if (part.type === "tool-call") { + onEvent({ + type: "tool-call-end", + toolCallId: part.toolCallId || "", + toolName: part.toolName || "", + args: part.input ? JSON.parse(part.input) : {}, + }); + } + } + } + + // Check for finish reason + if (data.result?.finishReason) { + onEvent({ type: "finish", reason: data.result.finishReason }); + } + + // Check for errors + if (data.error) { + onEvent({ + type: "error", + error: data.error.message || "Unknown error", + }); + } + } catch { + // Ignore parse errors for partial chunks + } + } + } + } + } else { + // Fallback to non-streaming response + const json = await response.json(); + if (json.error) { + throw new Error(`Tool error: ${json.error.message}`); + } + fullText = extractTextFromResponse( + json.result?.structuredContent || json.result, + ); + onEvent({ type: "text-delta", text: fullText }); + onEvent({ type: "finish", reason: "stop" }); + } + + return fullText; +} + +export interface MeshClient { + session: MeshSession; + callTool: (name: string, args: Record) => Promise; + callConnectionTool: ( + connectionId: string, + name: string, + args: Record, + ) => Promise; + callGatewayTool: ( + gatewayId: string, + name: string, + args: Record, + ) => Promise; + listOrganizations: () => Promise; + setOrganization: (orgId: string, orgSlug: string) => Promise; + listConnections: () => Promise; + listGateways: () => Promise; + listAgents: () => Promise; + findLLMConnection: () => Promise; + findLLMGateway: () => Promise; + getConnectionTools: (connectionId: string) => Promise; + generateText: ( + connectionId: string, + messages: Array<{ role: string; content: string }>, + model?: string, + ) => Promise; + generateTextViaGateway: ( + gatewayId: string, + messages: Array<{ role: string; content: string }>, + model?: string, + ) => Promise; + streamTextViaGateway: ( + gatewayId: string, + messages: Array<{ role: string; content: string }>, + model: string | undefined, + onEvent: (event: StreamEvent) => void, + ) => Promise; + generateWithToolsViaGateway: ( + gatewayId: string, + messages: Array<{ role: string; content: string }>, + tools: LLMTool[], + model: string | undefined, + onEvent: ToolExecutionCallback, + maxIterations?: number, + localToolExecutor?: LocalToolExecutor, + localToolNames?: Set, + ) => Promise; +} + +/** + * Create a Mesh client from the stored session + */ +export async function createMeshClient(): Promise { + const session = await readMeshSession(); + if (!session) { + return null; + } + + return { + session, + callTool: (name: string, args: Record) => + callMeshTool(session, name, args), + callConnectionTool: ( + connectionId: string, + name: string, + args: Record, + ) => callConnectionTool(session, connectionId, name, args), + callGatewayTool: ( + gatewayId: string, + name: string, + args: Record, + ) => callGatewayTool(session, gatewayId, name, args), + listOrganizations: () => listOrganizations(session), + setOrganization: async (orgId: string, orgSlug: string) => { + await setMeshOrganization(orgId, orgSlug); + // Update the in-memory session too + session.organizationId = orgId; + session.organizationSlug = orgSlug; + }, + listConnections: () => listConnections(session), + listGateways: () => listGateways(session), + listAgents: () => listAgents(session), + findLLMConnection: () => findLLMConnection(session), + findLLMGateway: () => findLLMGateway(session), + getConnectionTools: (connectionId: string) => + getConnectionTools(session, connectionId), + generateText: ( + connectionId: string, + messages: Array<{ role: string; content: string }>, + model?: string, + ) => generateText(session, connectionId, messages, model), + generateTextViaGateway: ( + gatewayId: string, + messages: Array<{ role: string; content: string }>, + model?: string, + ) => generateTextViaGateway(session, gatewayId, messages, model), + streamTextViaGateway: ( + gatewayId: string, + messages: Array<{ role: string; content: string }>, + model: string | undefined, + onEvent: (event: StreamEvent) => void, + ) => streamTextViaGateway(session, gatewayId, messages, model, onEvent), + generateWithToolsViaGateway: ( + gatewayId: string, + messages: Array<{ role: string; content: string }>, + tools: LLMTool[], + model: string | undefined, + onEvent: ToolExecutionCallback, + maxIterations?: number, + localToolExecutor?: LocalToolExecutor, + localToolNames?: Set, + ) => + generateWithToolsViaGateway( + session, + gatewayId, + messages, + tools, + model, + onEvent, + maxIterations, + localToolExecutor, + localToolNames, + ), + }; +} diff --git a/packages/cli/src/lib/mesh-session.ts b/packages/cli/src/lib/mesh-session.ts new file mode 100644 index 0000000000..236bf729b2 --- /dev/null +++ b/packages/cli/src/lib/mesh-session.ts @@ -0,0 +1,138 @@ +/** + * Mesh Session Management + * + * Stores and retrieves Mesh authentication sessions. + * Separate from the legacy Supabase session. + */ + +import { join } from "path"; +import { homedir } from "os"; +import { promises as fs } from "fs"; +import { z } from "zod"; +import process from "node:process"; + +const MeshSessionSchema = z.object({ + meshUrl: z.string(), + token: z.string(), + user: z.object({ + id: z.string(), + email: z.string().optional(), + name: z.string().optional(), + }), + expiresAt: z.string().optional(), + organizationId: z.string().optional(), + organizationSlug: z.string().optional(), +}); + +export type MeshSession = z.infer; + +/** + * Path to the Mesh session file + */ +function getMeshSessionPath(): string { + return join(homedir(), ".deco_mesh_session.json"); +} + +/** + * Save Mesh session to disk + */ +export async function saveMeshSession(session: MeshSession): Promise { + const sessionPath = getMeshSessionPath(); + await fs.writeFile(sessionPath, JSON.stringify(session, null, 2)); + + // Set file permissions to 600 (read/write for user only) + if (process.platform !== "win32") { + try { + await fs.chmod(sessionPath, 0o600); + } catch (error) { + console.warn( + "Warning: Could not set file permissions on session file:", + error instanceof Error ? error.message : String(error), + ); + } + } +} + +/** + * Read Mesh session from disk + */ +export async function readMeshSession(): Promise { + // Check for token in environment first + const envToken = process.env.MESH_TOKEN; + const envUrl = process.env.MESH_URL; + if (envToken && envUrl) { + return { + meshUrl: envUrl, + token: envToken, + user: { id: "env" }, + }; + } + + try { + const sessionPath = getMeshSessionPath(); + const content = await fs.readFile(sessionPath, "utf-8"); + const parsed = MeshSessionSchema.safeParse(JSON.parse(content)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +/** + * Delete Mesh session + */ +export async function deleteMeshSession(): Promise { + try { + const sessionPath = getMeshSessionPath(); + await fs.unlink(sessionPath); + } catch { + // Session file doesn't exist, that's fine + } +} + +/** + * Get auth headers for Mesh API requests + */ +export async function getMeshAuthHeaders(): Promise> { + const session = await readMeshSession(); + + if (!session) { + throw new Error("Not logged in to Mesh. Run 'deco mesh login' first."); + } + + return { + Authorization: `Bearer ${session.token}`, + }; +} + +/** + * Get Mesh URL from session or environment + */ +export async function getMeshUrl(): Promise { + const session = await readMeshSession(); + return session?.meshUrl || process.env.MESH_URL || "http://localhost:3000"; +} + +/** + * Check if logged in to Mesh + */ +export async function isMeshLoggedIn(): Promise { + const session = await readMeshSession(); + return session !== null; +} + +/** + * Update the organization in an existing session + */ +export async function setMeshOrganization( + organizationId: string, + organizationSlug: string, +): Promise { + const session = await readMeshSession(); + if (!session) { + throw new Error("Not logged in to Mesh"); + } + session.organizationId = organizationId; + session.organizationSlug = organizationSlug; + await saveMeshSession(session); +} diff --git a/packages/runtime/src/oauth.ts b/packages/runtime/src/oauth.ts index 19611b1ecf..d26e0858d7 100644 --- a/packages/runtime/src/oauth.ts +++ b/packages/runtime/src/oauth.ts @@ -60,6 +60,256 @@ interface PendingAuthState { oauthCallbackUri?: string; } +/** + * Generate a beautiful success page with capybara animation + * Auto-redirects to the client callback after a brief delay + */ +function generateSuccessPage(redirectUrl: string): string { + return ` + + + + + Authentication Successful + + + +
+
+
+
+
+
+
+ +
+
+ +
+ + + + Authenticated +
+

Connection Successful

+

Your MCP connection has been authenticated successfully.

+

Completing authentication...

+
+
+
+ + + + +`; +} + interface CodePayload { accessToken: string; tokenType: string; @@ -259,7 +509,14 @@ export function createOAuthHandlers(oauth: OAuthConfig) { redirectUrl.searchParams.set("state", pending.clientState); } - return Response.redirect(redirectUrl.toString(), 302); + // Return a beautiful success page that auto-redirects + const finalRedirectUrl = redirectUrl.toString(); + return new Response(generateSuccessPage(finalRedirectUrl), { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }); } catch (err) { console.error("OAuth callback error:", err);