From 29f387b7670f4677f69cf9d5b116653632919ddb Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Tue, 30 Dec 2025 15:44:07 -0300 Subject: [PATCH 1/4] feat: add support for STDIO connections and environment variable management - Introduced a new migration to make `connection_url` nullable for STDIO connections. - Updated connection handling to support STDIO, including command execution and environment variable management. - Added a new `EnvVarsEditor` component for managing environment variables in the UI. - Enhanced connection forms to accommodate NPX and STDIO types, with appropriate validation and field rendering. - Updated various schemas and types to reflect the new connection parameters and ensure proper handling of STDIO connections. - Implemented logic to parse and build connection parameters for both NPX and custom commands. This update improves the flexibility of connection types and enhances the user experience when configuring connections. --- AGENTS.md | 2 + .../migrations/014-nullable-connection-url.ts | 38 ++ apps/mesh/src/api/routes/proxy.ts | 121 ++++- apps/mesh/src/auth/index.ts | 8 + apps/mesh/src/auth/org.ts | 1 + apps/mesh/src/core/context-factory.ts | 11 +- apps/mesh/src/stdio/stable-transport.ts | 265 +++++++++ apps/mesh/src/storage/connection.test.ts | 12 +- apps/mesh/src/storage/connection.ts | 79 ++- apps/mesh/src/storage/types.ts | 6 +- apps/mesh/src/tools/connection/create.ts | 3 +- apps/mesh/src/tools/connection/fetch-tools.ts | 115 +++- apps/mesh/src/tools/connection/schema.ts | 58 +- apps/mesh/src/tools/connection/update.ts | 1 + .../connection-settings-form-ui.tsx | 318 ++++++++--- .../details/connection/settings-tab/index.tsx | 218 +++++++- .../details/connection/settings-tab/schema.ts | 74 ++- .../src/web/components/env-vars-editor.tsx | 117 ++++ apps/mesh/src/web/routes/orgs/connections.tsx | 509 +++++++++++++++--- lefthook.yml | 14 + 20 files changed, 1747 insertions(+), 223 deletions(-) create mode 100644 apps/mesh/migrations/014-nullable-connection-url.ts create mode 100644 apps/mesh/src/stdio/stable-transport.ts create mode 100644 apps/mesh/src/web/components/env-vars-editor.tsx create mode 100644 lefthook.yml diff --git a/AGENTS.md b/AGENTS.md index 96e85f8a5a..a7585f0837 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ Database migrations live in `apps/mesh/migrations/`, code quality plugins in `pl - `bun run build:runtime`: build the runtime package - `bun run docs:dev`: run documentation site locally +**IMPORTANT**: Always run `bun run fmt` after making code changes to ensure consistent formatting. A lefthook pre-commit hook is configured to run this automatically. Install with `npx lefthook install`. + ### Mesh-specific commands (from `apps/mesh/`) - `bun run dev:client`: Vite dev server (port 4000) - `bun run dev:server`: Hono server with hot reload diff --git a/apps/mesh/migrations/014-nullable-connection-url.ts b/apps/mesh/migrations/014-nullable-connection-url.ts new file mode 100644 index 0000000000..e8f351e9a6 --- /dev/null +++ b/apps/mesh/migrations/014-nullable-connection-url.ts @@ -0,0 +1,38 @@ +/** + * Make connection_url nullable for STDIO connections + * + * STDIO connections don't need a URL - they use connection_headers + * to store { command, args, cwd, envVars } instead. + */ + +import { Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // SQLite doesn't support ALTER COLUMN, so we need to recreate the table + // For PostgreSQL, we can use ALTER COLUMN directly + // Detect dialect by attempting PostgreSQL-style ALTER first + + try { + // Try PostgreSQL syntax + await sql`ALTER TABLE connections ALTER COLUMN connection_url DROP NOT NULL`.execute( + db, + ); + } catch { + // SQLite: Need to recreate table (complex, skip for now - SQLite already allows nulls in practice) + // SQLite's NOT NULL is only enforced on INSERT, and we can use empty string as fallback + console.log( + "[Migration 014] SQLite detected - connection_url will use empty string for STDIO", + ); + } +} + +export async function down(db: Kysely): Promise { + try { + // PostgreSQL: Re-add NOT NULL constraint + await sql`ALTER TABLE connections ALTER COLUMN connection_url SET NOT NULL`.execute( + db, + ); + } catch { + // SQLite: No-op + } +} diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 6b58fa203a..9d4bf3bb74 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -8,13 +8,18 @@ * - Creates MCP Server to handle incoming requests * - Creates MCP Client to connect to downstream connections * - Uses middleware pipeline for authorization - * - Supports StreamableHTTP transport + * - Supports StreamableHTTP and STDIO transports */ import { extractConnectionPermissions } from "@/auth/configuration-scopes"; import { once } from "@/common"; import { getMonitoringConfig } from "@/core/config"; -import { ConnectionEntity } from "@/tools/connection/schema"; +import { getStableStdioClient } from "@/stdio/stable-transport"; +import { + ConnectionEntity, + isStdioParameters, + type HttpConnectionParameters, +} from "@/tools/connection/schema"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -263,33 +268,76 @@ async function createMCPProxyDoNotUseDirectly( headers["x-mesh-token"] = configurationToken; } - // Add custom headers from connection - if (connection.connection_headers) { - Object.assign(headers, connection.connection_headers); - } - return headers; }; - // Create client factory for downstream MCP + // Determine connection type and extract parameters + const isStdio = connection.connection_type === "STDIO"; + const stdioParams = isStdioParameters(connection.connection_headers) + ? connection.connection_headers + : null; + const httpParams = !isStdio + ? (connection.connection_headers as HttpConnectionParameters | null) + : null; + + // Create client factory for downstream MCP based on connection_type const createClient = async () => { - const headers = await buildRequestHeaders(); + switch (connection.connection_type) { + case "STDIO": { + if (!stdioParams) { + throw new Error("STDIO connection missing parameters"); + } - // Create transport to downstream MCP using StreamableHTTP - const transport = new StreamableHTTPClientTransport( - new URL(connection.connection_url), - { requestInit: { headers } }, - ); + // Get or create stable connection - respawns automatically if closed + // We want stable local MCP connection - don't spawn new process per request + return getStableStdioClient({ + id: connectionId, + command: stdioParams.command, + args: stdioParams.args, + env: stdioParams.envVars, + cwd: stdioParams.cwd, + }); + } - // Create MCP client - const client = new Client({ - name: "mcp-mesh-proxy", - version: "1.0.0", - }); + case "HTTP": + case "SSE": + case "Websocket": { + if (!connection.connection_url) { + throw new Error( + `${connection.connection_type} connection missing URL`, + ); + } - await client.connect(transport); + // HTTP/SSE/WebSocket - create fresh client per request + const client = new Client({ + name: "mcp-mesh-proxy", + version: "1.0.0", + }); + + const headers = await buildRequestHeaders(); - return client; + // Add custom headers from connection_headers + if (httpParams?.headers) { + Object.assign(headers, httpParams.headers); + } + + // Create transport to downstream MCP using StreamableHTTP + // TODO: Add SSE transport support when needed + const transport = new StreamableHTTPClientTransport( + new URL(connection.connection_url), + { requestInit: { headers } }, + ); + + await client.connect(transport); + + return client; + } + + default: + throw new Error( + `Unknown connection type: ${connection.connection_type}`, + ); + } }; // Create authorization middlewares @@ -385,6 +433,7 @@ async function createMCPProxyDoNotUseDirectly( throw error; } finally { + // Close client - stdio connections ignore close() via stable-transport await client.close(); } }, @@ -449,10 +498,16 @@ async function createMCPProxyDoNotUseDirectly( // Call tool using fetch directly for streaming support // Inspired by @deco/api proxy callStreamableTool + // Note: Only works for HTTP connections - STDIO doesn't support streaming fetch const callStreamableTool = async ( name: string, args: Record, ): Promise => { + if (!connection.connection_url) { + throw new Error("Streamable tools require HTTP connection with URL"); + } + const connectionUrl = connection.connection_url; + const request: CallToolRequest = { method: "tools/call", params: { name, arguments: args }, @@ -460,9 +515,14 @@ async function createMCPProxyDoNotUseDirectly( return callStreamableToolPipeline(request, async (): Promise => { const headers = await buildRequestHeaders(); + // Add custom headers from connection_headers + if (httpParams?.headers) { + Object.assign(headers, httpParams.headers); + } + // Use fetch directly to support streaming responses // Build URL with tool name appended for call-tool endpoint pattern - const url = new URL(connection.connection_url); + const url = new URL(connectionUrl); url.pathname = url.pathname.replace(/\/$/, "") + `/call-tool/${request.params.name}`; @@ -546,13 +606,16 @@ async function createMCPProxyDoNotUseDirectly( client = await createClient(); } catch (error) { // Check if this is an auth error - if so, return appropriate 401 - const authResponse = await handleAuthError({ - error: error as Error & { status?: number }, - reqUrl, - connectionId, - connectionUrl: connection.connection_url, - headers: await buildRequestHeaders(), - }); + // Note: This only applies to HTTP connections + const authResponse = connection.connection_url + ? await handleAuthError({ + error: error as Error & { status?: number }, + reqUrl, + connectionId, + connectionUrl: connection.connection_url, + headers: await buildRequestHeaders(), + }) + : null; if (authResponse) { return authResponse; } diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index bab7a0201a..f46df887cf 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -289,6 +289,14 @@ export const auth = betterAuth({ // Load optional configuration from file ...authConfig, + // Disable rate limiting in development (set DISABLE_RATE_LIMIT=true) + // Must be AFTER authConfig spread to ensure it takes precedence + rateLimit: { + enabled: process.env.DISABLE_RATE_LIMIT !== "true", + window: 60, + max: 10000, // Very high limit as fallback + }, + plugins, // Database hooks for automatic organization creation on signup diff --git a/apps/mesh/src/auth/org.ts b/apps/mesh/src/auth/org.ts index 69cba1bb5e..f0262b1a88 100644 --- a/apps/mesh/src/auth/org.ts +++ b/apps/mesh/src/auth/org.ts @@ -112,6 +112,7 @@ export async function seedOrgDb(organizationId: string, createdBy: string) { (await fetchToolsFromMCP({ id: "pending", title: mcpConfig.data.title, + connection_type: mcpConfig.data.connection_type, connection_url: mcpConfig.data.connection_url, connection_token: mcpConfig.data.connection_token, connection_headers: mcpConfig.data.connection_headers, diff --git a/apps/mesh/src/core/context-factory.ts b/apps/mesh/src/core/context-factory.ts index 1632620566..70dd052f97 100644 --- a/apps/mesh/src/core/context-factory.ts +++ b/apps/mesh/src/core/context-factory.ts @@ -572,8 +572,15 @@ async function authenticateRequest( }; } } catch (error) { - const err = error as Error; - console.error("[Auth] Session check failed:", err); + const err = error as Error & { body?: unknown }; + console.error( + "[Auth] Session check failed:", + JSON.stringify( + { message: err.message, body: err.body, stack: err.stack }, + null, + 2, + ), + ); } // No valid authentication found - return empty auth data diff --git a/apps/mesh/src/stdio/stable-transport.ts b/apps/mesh/src/stdio/stable-transport.ts new file mode 100644 index 0000000000..ee40ccebb0 --- /dev/null +++ b/apps/mesh/src/stdio/stable-transport.ts @@ -0,0 +1,265 @@ +/** + * Stable Stdio Client Transport + * + * Wraps StdioClientTransport to provide a stable local MCP connection: + * - Does NOT close the connection when close() is called (keeps process alive) + * - Automatically respawns the process if it dies unexpectedly + * + * This is important for local MCP servers (npx packages) where we want to + * avoid the overhead of spawning a new process for every request. + * + * @see https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/stdio.ts + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + StdioClientTransport, + type StdioServerParameters, +} from "@modelcontextprotocol/sdk/client/stdio.js"; + +export interface StableStdioConfig extends StdioServerParameters { + /** Unique ID for this connection (for logging) */ + id: string; +} + +/** + * Stable client wrapper that ignores close() calls. + * This ensures the underlying connection stays alive across requests. + */ +interface StableClient extends Client { + /** The actual client (for internal use) */ + __actualClient: Client; +} + +interface StableConnection { + transport: StdioClientTransport; + client: Client; + stableClient: StableClient; + config: StableStdioConfig; + status: "connecting" | "connected" | "reconnecting" | "failed"; + connectPromise: Promise | null; +} + +/** + * Create a stable client wrapper that ignores close() calls + */ +function createStableClientWrapper(client: Client): StableClient { + // Create a proxy that intercepts close() and does nothing + const stableClient = new Proxy(client, { + get(target, prop, receiver) { + if (prop === "close") { + // Return a no-op function that resolves immediately + return async () => { + // Do nothing - stable connections should NOT be closed + }; + } + if (prop === "__actualClient") { + return target; + } + return Reflect.get(target, prop, receiver); + }, + }) as StableClient; + + return stableClient; +} + +/** + * Pool of stable stdio connections + * Uses globalThis to survive HMR reloads + */ +const GLOBAL_KEY = "__mesh_stable_stdio_pool__"; + +declare global { + var __mesh_stable_stdio_pool__: Map | undefined; + var __mesh_stable_stdio_shutdown_registered__: boolean | undefined; +} + +const connectionPool: Map = + globalThis[GLOBAL_KEY] ?? (globalThis[GLOBAL_KEY] = new Map()); + +/** + * Get or create a stable stdio connection + * + * - If connection exists and is connected, returns existing client + * - If connection is reconnecting, waits for reconnection + * - If connection doesn't exist, creates new one + * - If connection died, respawns it + * + * The returned client has close() disabled - call forceCloseStdioConnection() for explicit shutdown. + */ +export async function getStableStdioClient( + config: StableStdioConfig, +): Promise { + const existing = connectionPool.get(config.id); + + // If we have an existing connection that's connected, return the stable wrapper + if (existing?.status === "connected" && existing.stableClient) { + return existing.stableClient; + } + + // If we're already connecting/reconnecting, wait for that + if ( + existing?.connectPromise && + (existing.status === "connecting" || existing.status === "reconnecting") + ) { + return existing.connectPromise; + } + + // Create new connection or respawn + const isRespawn = existing?.status === "failed"; + const connection: StableConnection = existing ?? { + transport: null as unknown as StdioClientTransport, + client: null as unknown as Client, + stableClient: null as unknown as StableClient, + config, + status: "connecting", + connectPromise: null, + }; + + if (!existing) { + connectionPool.set(config.id, connection); + } + + connection.status = isRespawn ? "reconnecting" : "connecting"; + + // Create the connection promise + connection.connectPromise = (async () => { + try { + console.log( + `[StableStdio] ${isRespawn ? "Respawning" : "Spawning"}: ${config.id} (${config.command} ${config.args?.join(" ") ?? ""})`, + ); + + // Create transport - SDK handles spawning and merges env with getDefaultEnvironment() + // We only pass the additional env vars we need (like API tokens) + const transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + cwd: config.cwd, + stderr: "pipe", // Capture stderr for debugging + }); + + connection.transport = transport; + + // Create client + const client = new Client({ + name: `mesh-stdio-${config.id}`, + version: "1.0.0", + }); + + connection.client = client; + + // Create stable wrapper that ignores close() calls + connection.stableClient = createStableClientWrapper(client); + + // Handle unexpected close - mark for respawn + // We want stable local MCP connection - respawn on close + client.onclose = () => { + console.log( + `[StableStdio] Connection closed unexpectedly: ${config.id}`, + ); + connection.status = "failed"; + connection.connectPromise = null; + // Don't remove from pool - next request will respawn + }; + + // Handle stderr for debugging + transport.stderr?.on("data", (data: Buffer) => { + console.error(`[stdio:${config.id}] stderr:`, data.toString()); + }); + + // Connect with timeout - use AbortController to clean up on success + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30_000); + + try { + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => { + reject(new Error("Stdio connection timeout after 30s")); + }); + }), + ]); + } finally { + clearTimeout(timeoutId); + } + + connection.status = "connected"; + console.log(`[StableStdio] Connected: ${config.id}`); + + // Return the stable wrapper (close() is disabled) + return connection.stableClient; + } catch (error) { + console.error(`[StableStdio] Failed to connect ${config.id}:`, error); + connection.status = "failed"; + connection.connectPromise = null; + + // Clean up the spawned transport process to avoid orphaned processes + try { + await connection.transport?.close(); + } catch { + // Ignore close errors during cleanup + } + + throw error; + } + })(); + + return connection.connectPromise; +} + +/** + * Force close a stable stdio connection + * Used for explicit shutdown (e.g., server shutdown) + */ +async function forceCloseStdioConnection(id: string): Promise { + const connection = connectionPool.get(id); + if (!connection) return; + + console.log(`[StableStdio] Force closing: ${id}`); + + try { + // Remove onclose handler to prevent respawn + if (connection.client) { + connection.client.onclose = undefined; + } + await connection.client?.close(); + } catch { + // Ignore close errors + } + + connectionPool.delete(id); +} + +/** + * Force close all stable stdio connections + * Called during server shutdown via SIGINT/SIGTERM handlers + */ +async function forceCloseAllStdioConnections(): Promise { + console.log(`[StableStdio] Closing all connections (${connectionPool.size})`); + + const closePromises = Array.from(connectionPool.keys()).map((id) => + forceCloseStdioConnection(id), + ); + + await Promise.allSettled(closePromises); + connectionPool.clear(); +} + +// Register shutdown handlers - clean up connections before exit +// Use globalThis to survive HMR reloads (same pattern as connectionPool) +const SHUTDOWN_KEY = "__mesh_stable_stdio_shutdown_registered__"; + +if (!globalThis[SHUTDOWN_KEY]) { + globalThis[SHUTDOWN_KEY] = true; + + const cleanup = async (signal: string) => { + await forceCloseAllStdioConnections(); + // Re-raise signal after cleanup so process exits properly + process.exit(signal === "SIGINT" ? 130 : 143); // 128 + signal number + }; + + process.on("SIGINT", () => cleanup("SIGINT")); + process.on("SIGTERM", () => cleanup("SIGTERM")); +} diff --git a/apps/mesh/src/storage/connection.test.ts b/apps/mesh/src/storage/connection.test.ts index c763955970..b81f4e20fe 100644 --- a/apps/mesh/src/storage/connection.test.ts +++ b/apps/mesh/src/storage/connection.test.ts @@ -48,10 +48,12 @@ describe("ConnectionStorage", () => { title: "With Headers", connection_type: "SSE", connection_url: "https://sse.com", - connection_headers: { "X-Custom": "value" }, + connection_headers: { headers: { "X-Custom": "value" } }, }); - expect(connection.connection_headers).toEqual({ "X-Custom": "value" }); + expect(connection.connection_headers).toEqual({ + headers: { "X-Custom": "value" }, + }); }); it("should serialize OAuth config as JSON", async () => { @@ -259,7 +261,7 @@ describe("ConnectionStorage", () => { title: "JSON Test", connection_type: "SSE", connection_url: "https://test.com", - connection_headers: { "X-Test": "value" }, + connection_headers: { headers: { "X-Test": "value" } }, metadata: { key: "value" }, }); @@ -269,7 +271,9 @@ describe("ConnectionStorage", () => { bindings: ["CHAT"], }); - expect(updated.connection_headers).toEqual({ "X-Test": "value" }); + expect(updated.connection_headers).toEqual({ + headers: { "X-Test": "value" }, + }); expect(updated.metadata).toEqual({ key: "value" }); expect(updated.tools).toEqual([{ name: "TEST_TOOL", inputSchema: {} }]); expect(updated.bindings).toEqual(["CHAT"]); diff --git a/apps/mesh/src/storage/connection.ts b/apps/mesh/src/storage/connection.ts index f56594b09a..7e320727dd 100644 --- a/apps/mesh/src/storage/connection.ts +++ b/apps/mesh/src/storage/connection.ts @@ -9,9 +9,12 @@ import type { Insertable, Kysely, Updateable } from "kysely"; import type { CredentialVault } from "../encryption/credential-vault"; import type { ConnectionEntity, + ConnectionParameters, OAuthConfig, + StdioConnectionParameters, ToolDefinition, } from "../tools/connection/schema"; +import { isStdioParameters } from "../tools/connection/schema"; import { generatePrefixedId } from "@/shared/utils/generate-id"; import type { ConnectionStoragePort } from "./ports"; import type { Database } from "./types"; @@ -36,10 +39,10 @@ type RawConnectionRow = { icon: string | null; app_name: string | null; app_id: string | null; - connection_type: "HTTP" | "SSE" | "Websocket"; - connection_url: string; + connection_type: "HTTP" | "SSE" | "Websocket" | "STDIO"; + connection_url: string | null; connection_token: string | null; - connection_headers: string | Record | null; + connection_headers: string | null; // JSON, envVars encrypted for STDIO oauth_config: string | OAuthConfig | null; configuration_state: string | null; // Encrypted configuration_scopes: string | string[] | null; @@ -163,7 +166,27 @@ export class ConnectionStorage implements ConnectionStoragePort { const startTime = Date.now(); + // STDIO connections can't be tested via HTTP + if (connection.connection_type === "STDIO") { + // For STDIO, we'd need to spawn the process - skip for now + return { + healthy: true, // Assume healthy, actual health checked on first use + latencyMs: Date.now() - startTime, + }; + } + + if (!connection.connection_url) { + return { + healthy: false, + latencyMs: Date.now() - startTime, + }; + } + try { + const httpParams = connection.connection_headers as { + headers?: Record; + } | null; + const response = await fetch(connection.connection_url, { method: "POST", headers: { @@ -171,6 +194,7 @@ export class ConnectionStorage implements ConnectionStoragePort { ...(connection.connection_token && { Authorization: `Bearer ${connection.connection_token}`, }), + ...httpParams?.headers, ...headers, }, body: JSON.stringify({ @@ -209,6 +233,21 @@ export class ConnectionStorage implements ConnectionStoragePort { // Encrypt configuration state const stateJson = JSON.stringify(value); result[key] = await this.vault.encrypt(stateJson); + } else if (key === "connection_headers" && value) { + // For STDIO, encrypt envVars before storing + const params = value as ConnectionParameters; + if (isStdioParameters(params) && params.envVars) { + const encryptedEnvVars: Record = {}; + for (const [envKey, envValue] of Object.entries(params.envVars)) { + encryptedEnvVars[envKey] = await this.vault.encrypt(envValue); + } + result[key] = JSON.stringify({ + ...params, + envVars: encryptedEnvVars, + }); + } else { + result[key] = JSON.stringify(params); + } } else if (JSON_FIELDS.includes(key as (typeof JSON_FIELDS)[number])) { result[key] = value ? JSON.stringify(value) : null; } else { @@ -245,6 +284,36 @@ export class ConnectionStorage implements ConnectionStoragePort { } } + // Parse and decrypt connection_headers + let connectionParameters: ConnectionParameters | null = null; + if (row.connection_headers) { + try { + const parsed = JSON.parse(row.connection_headers); + // For STDIO, decrypt envVars + if (isStdioParameters(parsed) && parsed.envVars) { + const decryptedEnvVars: Record = {}; + for (const [envKey, envValue] of Object.entries(parsed.envVars)) { + try { + decryptedEnvVars[envKey] = await this.vault.decrypt( + envValue as string, + ); + } catch { + // If decryption fails, keep encrypted value (migration case) + decryptedEnvVars[envKey] = envValue as string; + } + } + connectionParameters = { + ...parsed, + envVars: decryptedEnvVars, + } as StdioConnectionParameters; + } else { + connectionParameters = parsed; + } + } catch (error) { + console.error("Failed to parse connection_headers:", error); + } + } + const parseJson = (value: string | T | null): T | null => { if (value === null) return null; if (typeof value === "string") { @@ -269,9 +338,7 @@ export class ConnectionStorage implements ConnectionStoragePort { connection_type: row.connection_type, connection_url: row.connection_url, connection_token: decryptedToken, - connection_headers: parseJson>( - row.connection_headers, - ), + connection_headers: connectionParameters, oauth_config: parseJson(row.oauth_config), configuration_state: decryptedConfigState, configuration_scopes: parseJson(row.configuration_scopes), diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 67a1c1a765..3452256d12 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -130,10 +130,10 @@ export interface MCPConnectionTable { app_id: string | null; // Connection details - connection_type: "HTTP" | "SSE" | "Websocket"; - connection_url: string; + connection_type: "HTTP" | "SSE" | "Websocket" | "STDIO"; + connection_url: string | null; // Null for STDIO connections connection_token: string | null; // Encrypted - connection_headers: JsonObject> | null; + connection_headers: string | null; // JSON - encrypted envVars for STDIO // OAuth config for downstream MCP (if MCP supports OAuth) oauth_config: JsonObject | null; diff --git a/apps/mesh/src/tools/connection/create.ts b/apps/mesh/src/tools/connection/create.ts index 54fdd26245..e7ca994bd8 100644 --- a/apps/mesh/src/tools/connection/create.ts +++ b/apps/mesh/src/tools/connection/create.ts @@ -59,8 +59,9 @@ export const COLLECTION_CONNECTIONS_CREATE = defineTool({ // Fetch tools from the MCP server before creating the connection const fetchedTools = await fetchToolsFromMCP({ - id: "pending", + id: `pending-${Date.now()}`, title: connectionData.title, + connection_type: connectionData.connection_type, connection_url: connectionData.connection_url, connection_token: connectionData.connection_token, connection_headers: connectionData.connection_headers, diff --git a/apps/mesh/src/tools/connection/fetch-tools.ts b/apps/mesh/src/tools/connection/fetch-tools.ts index bc55c47988..e92f95e84f 100644 --- a/apps/mesh/src/tools/connection/fetch-tools.ts +++ b/apps/mesh/src/tools/connection/fetch-tools.ts @@ -2,11 +2,18 @@ * Shared utility to fetch tools from an MCP connection * * Used by create/update to populate tools at save time. + * Supports HTTP, SSE, and STDIO transports based on connection_type. */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { ToolDefinition } from "./schema"; +import type { + ConnectionParameters, + HttpConnectionParameters, + ToolDefinition, +} from "./schema"; +import { isStdioParameters } from "./schema"; /** * Minimal connection data needed for tool fetching @@ -14,13 +21,15 @@ import type { ToolDefinition } from "./schema"; export interface ConnectionForToolFetch { id: string; title: string; - connection_url: string; + connection_type: "HTTP" | "SSE" | "Websocket" | "STDIO"; + connection_url?: string | null; connection_token?: string | null; - connection_headers?: Record | null; + connection_headers?: ConnectionParameters | null; } /** * Fetches tools from an MCP connection server. + * Supports HTTP, SSE, and STDIO transports based on connection_type. * * @param connection - Connection details for connecting to MCP * @returns Array of tool definitions, or null if fetch failed @@ -28,6 +37,32 @@ export interface ConnectionForToolFetch { export async function fetchToolsFromMCP( connection: ConnectionForToolFetch, ): Promise { + switch (connection.connection_type) { + case "STDIO": + return fetchToolsFromStdioMCP(connection); + + case "HTTP": + case "SSE": + case "Websocket": + return fetchToolsFromHttpMCP(connection); + + default: + console.error(`Unknown connection type: ${connection.connection_type}`); + return null; + } +} + +/** + * Fetch tools from an HTTP-based MCP connection + */ +async function fetchToolsFromHttpMCP( + connection: ConnectionForToolFetch, +): Promise { + if (!connection.connection_url) { + console.error(`HTTP connection ${connection.id} missing URL`); + return null; + } + let client: Client | null = null; try { @@ -39,8 +74,11 @@ export async function fetchToolsFromMCP( headers.Authorization = `Bearer ${connection.connection_token}`; } - if (connection.connection_headers) { - Object.assign(headers, connection.connection_headers); + // Add custom headers from connection_headers + const httpParams = + connection.connection_headers as HttpConnectionParameters | null; + if (httpParams?.headers) { + Object.assign(headers, httpParams.headers); } const transport = new StreamableHTTPClientTransport( @@ -76,7 +114,72 @@ export async function fetchToolsFromMCP( })); } catch (error) { console.error( - `Failed to fetch tools from connection ${connection.id}:`, + `Failed to fetch tools from HTTP connection ${connection.id}:`, + error, + ); + return null; + } finally { + try { + if (client && typeof client.close === "function") { + await client.close(); + } + } catch { + // Ignore close errors + } + } +} + +/** + * Fetch tools from a STDIO-based MCP connection + */ +async function fetchToolsFromStdioMCP( + connection: ConnectionForToolFetch, +): Promise { + const stdioParams = isStdioParameters(connection.connection_headers) + ? connection.connection_headers + : null; + + if (!stdioParams) { + console.error(`STDIO connection ${connection.id} missing parameters`); + return null; + } + + let client: Client | null = null; + + try { + const transport = new StdioClientTransport({ + command: stdioParams.command, + args: stdioParams.args, + env: stdioParams.envVars, + cwd: stdioParams.cwd, + }); + + client = new Client({ + name: "mcp-mesh-tool-fetcher", + version: "1.0.0", + }); + + // Add timeout to prevent hanging + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Tool fetch timeout")), 10_000); + }); + + await Promise.race([client.connect(transport), timeoutPromise]); + const result = await Promise.race([client.listTools(), timeoutPromise]); + + if (!result.tools || result.tools.length === 0) { + return null; + } + + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? undefined, + inputSchema: tool.inputSchema ?? {}, + outputSchema: tool.outputSchema ?? undefined, + })); + } catch (error) { + console.error( + `Failed to fetch tools from STDIO connection ${connection.id}:`, error, ); return null; diff --git a/apps/mesh/src/tools/connection/schema.ts b/apps/mesh/src/tools/connection/schema.ts index af6f72cc77..a2fec8eeb9 100644 --- a/apps/mesh/src/tools/connection/schema.ts +++ b/apps/mesh/src/tools/connection/schema.ts @@ -34,6 +34,36 @@ const ToolDefinitionSchema = z.object({ export type ToolDefinition = z.infer; +/** + * Connection parameters - discriminated by connection_type + * + * HTTP/SSE/WebSocket: HTTP headers for requests + * STDIO: Environment variables + command config + */ +const HttpConnectionParametersSchema = z.object({ + headers: z.record(z.string(), z.string()).optional(), +}); + +const StdioConnectionParametersSchema = z.object({ + command: z.string().describe("Command to run (e.g., 'npx', 'node')"), + args: z.array(z.string()).optional().describe("Command arguments"), + cwd: z.string().optional().describe("Working directory"), + envVars: z + .record(z.string(), z.string()) + .optional() + .describe("Environment variables (encrypted in storage)"), +}); + +export type HttpConnectionParameters = z.infer< + typeof HttpConnectionParametersSchema +>; +export type StdioConnectionParameters = z.infer< + typeof StdioConnectionParametersSchema +>; +export type ConnectionParameters = + | HttpConnectionParameters + | StdioConnectionParameters; + /** * Connection entity schema - single source of truth. * Compliant with collections binding pattern. @@ -60,14 +90,22 @@ export const ConnectionEntitySchema = z.object({ app_id: z.string().nullable().describe("Associated app ID"), connection_type: z - .enum(["HTTP", "SSE", "Websocket"]) + .enum(["HTTP", "SSE", "Websocket", "STDIO"]) .describe("Type of connection"), - connection_url: z.string().describe("URL for the connection"), - connection_token: z.string().nullable().describe("Authentication token"), + connection_url: z + .string() + .nullable() + .describe("URL for HTTP/SSE/WebSocket connections. Null for STDIO."), + connection_token: z + .string() + .nullable() + .describe("Authentication token (for HTTP connections)"), connection_headers: z - .record(z.string(), z.string()) + .union([StdioConnectionParametersSchema, HttpConnectionParametersSchema]) .nullable() - .describe("Custom headers"), + .describe( + "Connection parameters. HTTP: { headers }. STDIO: { command, args, cwd, envVars }", + ), oauth_config: OAuthConfigSchema.nullable().describe("OAuth configuration"), @@ -118,6 +156,7 @@ export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({ icon: true, app_name: true, app_id: true, + connection_url: true, connection_token: true, connection_headers: true, oauth_config: true, @@ -134,3 +173,12 @@ export type ConnectionCreateData = z.infer; export const ConnectionUpdateDataSchema = ConnectionEntitySchema.partial(); export type ConnectionUpdateData = z.infer; + +/** + * Type guard to check if parameters are STDIO type + */ +export function isStdioParameters( + params: ConnectionParameters | null | undefined, +): params is StdioConnectionParameters { + return params !== null && params !== undefined && "command" in params; +} diff --git a/apps/mesh/src/tools/connection/update.ts b/apps/mesh/src/tools/connection/update.ts index ab658c83bc..d8d275f783 100644 --- a/apps/mesh/src/tools/connection/update.ts +++ b/apps/mesh/src/tools/connection/update.ts @@ -158,6 +158,7 @@ export const COLLECTION_CONNECTIONS_UPDATE = defineTool({ const fetchedTools = await fetchToolsFromMCP({ id: existing.id, title: data.title ?? existing.title, + connection_type: data.connection_type ?? existing.connection_type, connection_url: data.connection_url ?? existing.connection_url, connection_token: data.connection_token ?? existing.connection_token, connection_headers: diff --git a/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx b/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx index 7027a66895..19109d419d 100644 --- a/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx +++ b/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx @@ -1,4 +1,5 @@ import type { ConnectionEntity } from "@/tools/connection/schema"; +import { EnvVarsEditor } from "@/web/components/env-vars-editor"; import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { useProjectContext } from "@/web/providers/project-context-provider"; import { @@ -17,11 +18,249 @@ import { SelectTrigger, SelectValue, } from "@deco/ui/components/select.tsx"; +import { Container, Globe02, Terminal } from "@untitledui/icons"; import { formatDistanceToNow } from "date-fns"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { ConnectionGatewaysSection } from "./connection-gateways-section"; import type { ConnectionFormData } from "./schema"; +/** + * Connection fields component with conditional rendering based on ui_type + */ +function ConnectionFields({ + form, + connection, +}: { + form: ReturnType>; + connection: ConnectionEntity; +}) { + const uiType = useWatch({ control: form.control, name: "ui_type" }); + + return ( +
+ {/* Connection Type Selector */} + ( + + Type + + + + )} + /> + + {/* NPX-specific fields */} + {uiType === "NPX" && ( + ( + + NPM Package + + + +

+ The npm package to run with npx +

+ +
+ )} + /> + )} + + {/* STDIO/Custom Command fields */} + {uiType === "STDIO" && ( + <> +
+ ( + + Command + + + + + + )} + /> + + ( + + + Arguments + + + + +

+ Space-separated arguments (no quotes needed) +

+ +
+ )} + /> +
+ + ( + + + Working Directory + + + + +

+ Directory where the command will be executed +

+ +
+ )} + /> + + )} + + {/* Shared: Environment Variables for NPX and STDIO */} + {(uiType === "NPX" || uiType === "STDIO") && ( + ( + + + Environment Variables + + + + + + + )} + /> + )} + + {/* HTTP/SSE/Websocket fields */} + {uiType !== "NPX" && uiType !== "STDIO" && ( + <> + ( + + URL + + + + + + )} + /> + + ( + + Token + + + + + + )} + /> + + )} +
+ ); +} + export function ConnectionSettingsFormUI({ form, connection, @@ -82,82 +321,7 @@ export function ConnectionSettingsFormUI({ {/* Connection section */} -
-
- Connection -
- ( - - - - )} - /> - ( - - - - - - )} - /> -
- } - /> - } - /> -
- - ( - - Token - - - - - - )} - /> -
+ {/* Last Updated section */}
diff --git a/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx b/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx index 4535af63e6..27344eb015 100644 --- a/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx +++ b/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx @@ -1,5 +1,15 @@ import { createToolCaller } from "@/tools/client"; -import type { ConnectionEntity } from "@/tools/connection/schema"; +import type { + ConnectionEntity, + StdioConnectionParameters, + HttpConnectionParameters, +} from "@/tools/connection/schema"; +import { isStdioParameters } from "@/tools/connection/schema"; +import { + envVarsToRecord, + recordToEnvVars, + type EnvVar, +} from "@/web/components/env-vars-editor"; import { EmptyState } from "@/web/components/empty-state.tsx"; import { ErrorBoundary } from "@/web/components/error-boundary.tsx"; import { useConnectionActions } from "@/web/hooks/collections/use-connection"; @@ -19,6 +29,199 @@ import { ConnectionSettingsFormUI } from "./connection-settings-form-ui"; import { McpConfigurationForm } from "./mcp-configuration-form"; import { connectionFormSchema, type ConnectionFormData } from "./schema"; +/** + * Check if STDIO params look like an NPX command + */ +function isNpxCommand(params: StdioConnectionParameters): boolean { + return params.command === "npx"; +} + +/** + * Parse STDIO connection_headers back to NPX form fields + */ +function parseStdioToNpx(params: StdioConnectionParameters): string { + // Find the package (skip -y flag) + return params.args?.find((a) => !a.startsWith("-")) ?? ""; +} + +/** + * Parse STDIO connection_headers to custom command form fields + */ +function parseStdioToCustom(params: StdioConnectionParameters): { + command: string; + args: string; + cwd: string; +} { + return { + command: params.command, + args: params.args?.join(" ") ?? "", + cwd: params.cwd ?? "", + }; +} + +/** + * Build STDIO connection_headers from NPX form fields + */ +function buildNpxParameters( + packageName: string, + envVars: EnvVar[], +): StdioConnectionParameters { + const params: StdioConnectionParameters = { + command: "npx", + args: ["-y", packageName], + }; + const envRecord = envVarsToRecord(envVars); + if (Object.keys(envRecord).length > 0) { + params.envVars = envRecord; + } + return params; +} + +/** + * Build STDIO connection_headers from custom command form fields + */ +function buildCustomStdioParameters( + command: string, + argsString: string, + cwd: string | undefined, + envVars: EnvVar[], +): StdioConnectionParameters { + const params: StdioConnectionParameters = { + command: command, + }; + + // Parse args from space-separated string (basic parsing) + if (argsString.trim()) { + params.args = argsString.trim().split(/\s+/); + } + + if (cwd?.trim()) { + params.cwd = cwd.trim(); + } + + const envRecord = envVarsToRecord(envVars); + if (Object.keys(envRecord).length > 0) { + params.envVars = envRecord; + } + + return params; +} + +/** + * Convert connection entity to form values + */ +function connectionToFormValues( + connection: ConnectionEntity, + scopes?: string[], +): ConnectionFormData { + const baseFields = { + title: connection.title, + description: connection.description ?? "", + configuration_state: connection.configuration_state ?? {}, + configuration_scopes: scopes || connection.configuration_scopes || [], + }; + + // Check if it's a STDIO connection + if ( + connection.connection_type === "STDIO" && + isStdioParameters(connection.connection_headers) + ) { + const stdioParams = connection.connection_headers; + const envVars = recordToEnvVars(stdioParams.envVars); + + // Check if it's an NPX command + if (isNpxCommand(stdioParams)) { + const npxPackage = parseStdioToNpx(stdioParams); + return { + ...baseFields, + ui_type: "NPX", + connection_url: "", + connection_token: null, + npx_package: npxPackage, + stdio_command: "", + stdio_args: "", + stdio_cwd: "", + env_vars: envVars, + }; + } + + // Custom STDIO command + const customData = parseStdioToCustom(stdioParams); + return { + ...baseFields, + ui_type: "STDIO", + connection_url: "", + connection_token: null, + npx_package: "", + stdio_command: customData.command, + stdio_args: customData.args, + stdio_cwd: customData.cwd, + env_vars: envVars, + }; + } + + // HTTP/SSE/Websocket connection + return { + ...baseFields, + ui_type: connection.connection_type as "HTTP" | "SSE" | "Websocket", + connection_url: connection.connection_url ?? "", + connection_token: null, // Don't pre-fill token for security + npx_package: "", + stdio_command: "", + stdio_args: "", + stdio_cwd: "", + env_vars: [], + }; +} + +/** + * Convert form values back to connection entity update + */ +function formValuesToConnectionUpdate( + data: ConnectionFormData, +): Partial { + let connectionType: "HTTP" | "SSE" | "Websocket" | "STDIO"; + let connectionUrl: string | null = null; + let connectionToken: string | null = null; + let connectionParameters: + | StdioConnectionParameters + | HttpConnectionParameters + | null = null; + + if (data.ui_type === "NPX") { + connectionType = "STDIO"; + connectionUrl = ""; // STDIO doesn't use URL + connectionParameters = buildNpxParameters( + data.npx_package || "", + data.env_vars || [], + ); + } else if (data.ui_type === "STDIO") { + connectionType = "STDIO"; + connectionUrl = ""; // STDIO doesn't use URL + connectionParameters = buildCustomStdioParameters( + data.stdio_command || "", + data.stdio_args || "", + data.stdio_cwd, + data.env_vars || [], + ); + } else { + connectionType = data.ui_type; + connectionUrl = data.connection_url || ""; + connectionToken = data.connection_token || null; + } + + return { + title: data.title, + description: data.description || null, + connection_type: connectionType, + connection_url: connectionUrl, + ...(connectionToken && { connection_token: connectionToken }), + ...(connectionParameters && { connection_headers: connectionParameters }), + configuration_state: data.configuration_state ?? null, + configuration_scopes: data.configuration_scopes ?? null, + }; +} + interface SettingsTabProps { connection: ConnectionEntity; onUpdate: (connection: Partial) => Promise; @@ -235,15 +438,7 @@ function SettingsTabContentImpl(props: SettingsTabContentImplProps) { const form = useForm({ resolver: zodResolver(connectionFormSchema), - values: { - title: connection.title, - description: connection.description ?? "", - connection_type: connection.connection_type, - connection_url: connection.connection_url, - connection_token: connection.connection_token, - configuration_state: connection.configuration_state ?? {}, - configuration_scopes: scopes || connection.configuration_scopes || [], - }, + values: connectionToFormValues(connection, scopes), }); const formState = form.watch("configuration_state"); @@ -258,7 +453,8 @@ function SettingsTabContentImpl(props: SettingsTabContentImplProps) { if (!isValid) return; const data = form.getValues(); - await onUpdate(data); + const updateData = formValuesToConnectionUpdate(data); + await onUpdate(updateData); form.reset(data); }; diff --git a/apps/mesh/src/web/components/details/connection/settings-tab/schema.ts b/apps/mesh/src/web/components/details/connection/settings-tab/schema.ts index 86757b9c66..a0d53dc470 100644 --- a/apps/mesh/src/web/components/details/connection/settings-tab/schema.ts +++ b/apps/mesh/src/web/components/details/connection/settings-tab/schema.ts @@ -1,17 +1,67 @@ -import { ConnectionEntitySchema } from "@/tools/connection/schema"; import { z } from "zod"; -export const connectionFormSchema = ConnectionEntitySchema.pick({ - title: true, - description: true, - connection_type: true, - connection_url: true, - connection_token: true, - configuration_scopes: true, - configuration_state: true, -}).partial({ - description: true, - connection_token: true, +// Environment variable schema +const envVarSchema = z.object({ + key: z.string().min(1, "Key is required"), + value: z.string(), }); +// UI type - includes "NPX" and "STDIO" which both map to STDIO internally +export const connectionFormSchema = z + .object({ + title: z.string().min(1, "Name is required"), + description: z.string().nullable().optional(), + // UI type for display + // - NPX: convenience wrapper for npm packages + // - STDIO: custom command for local servers + // - HTTP/SSE/Websocket: remote servers + ui_type: z.enum(["HTTP", "SSE", "Websocket", "NPX", "STDIO"]), + // For HTTP/SSE/Websocket + connection_url: z.string().optional(), + connection_token: z.string().nullable().optional(), + // For NPX (convenience wrapper) + npx_package: z.string().optional(), + // For STDIO (custom command) + stdio_command: z.string().optional(), + stdio_args: z.string().optional(), // Space-separated args + stdio_cwd: z.string().optional(), + // Shared: Environment variables for both NPX and STDIO + env_vars: z.array(envVarSchema).optional(), + // Preserved fields + configuration_scopes: z.array(z.string()).nullable().optional(), + configuration_state: z.record(z.unknown()).nullable().optional(), + }) + .refine( + (data) => { + if (data.ui_type === "NPX") { + return !!data.npx_package?.trim(); + } + return true; + }, + { message: "NPM package is required", path: ["npx_package"] }, + ) + .refine( + (data) => { + if (data.ui_type === "STDIO") { + return !!data.stdio_command?.trim(); + } + return true; + }, + { message: "Command is required", path: ["stdio_command"] }, + ) + .refine( + (data) => { + if ( + data.ui_type === "HTTP" || + data.ui_type === "SSE" || + data.ui_type === "Websocket" + ) { + return !!data.connection_url?.trim(); + } + return true; + }, + { message: "URL is required", path: ["connection_url"] }, + ); + export type ConnectionFormData = z.infer; +export type EnvVar = z.infer; diff --git a/apps/mesh/src/web/components/env-vars-editor.tsx b/apps/mesh/src/web/components/env-vars-editor.tsx new file mode 100644 index 0000000000..f99d97e2bd --- /dev/null +++ b/apps/mesh/src/web/components/env-vars-editor.tsx @@ -0,0 +1,117 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Plus, Trash01 } from "@untitledui/icons"; + +export interface EnvVar { + key: string; + value: string; +} + +interface EnvVarsEditorProps { + value: EnvVar[]; + onChange: (envVars: EnvVar[]) => void; + keyPlaceholder?: string; + valuePlaceholder?: string; + className?: string; +} + +export function EnvVarsEditor({ + value, + onChange, + keyPlaceholder = "VARIABLE_NAME", + valuePlaceholder = "value...", + className, +}: EnvVarsEditorProps) { + const handleAdd = () => { + onChange([...value, { key: "", value: "" }]); + }; + + const handleRemove = (index: number) => { + onChange(value.filter((_, i) => i !== index)); + }; + + const handleKeyChange = (index: number, key: string) => { + const newEnvVars = [...value]; + const current = newEnvVars[index]; + if (current) { + newEnvVars[index] = { key, value: current.value }; + onChange(newEnvVars); + } + }; + + const handleValueChange = (index: number, newValue: string) => { + const newEnvVars = [...value]; + const current = newEnvVars[index]; + if (current) { + newEnvVars[index] = { key: current.key, value: newValue }; + onChange(newEnvVars); + } + }; + + return ( +
+
+ {value.map((envVar, index) => ( +
+ handleKeyChange(index, e.target.value)} + className="h-10 rounded-lg flex-1 font-mono text-sm" + /> + handleValueChange(index, e.target.value)} + className="h-10 rounded-lg flex-1" + /> + +
+ ))} + + +
+
+ ); +} + +/** + * Convert EnvVar array to Record for API + */ +export function envVarsToRecord(envVars: EnvVar[]): Record { + const record: Record = {}; + for (const { key, value } of envVars) { + if (key.trim()) { + record[key.trim()] = value; + } + } + return record; +} + +/** + * Convert Record to EnvVar array for form + */ +export function recordToEnvVars( + record: Record | undefined | null, +): EnvVar[] { + if (!record) return []; + return Object.entries(record).map(([key, value]) => ({ key, value })); +} diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx index 0e523a3e52..7ae8c10f13 100644 --- a/apps/mesh/src/web/routes/orgs/connections.tsx +++ b/apps/mesh/src/web/routes/orgs/connections.tsx @@ -1,5 +1,4 @@ import type { ConnectionEntity } from "@/tools/connection/schema"; -import { ConnectionEntitySchema } from "@/tools/connection/schema"; import { CollectionHeader } from "@/web/components/collections/collection-header.tsx"; import { CollectionPage } from "@/web/components/collections/collection-page.tsx"; import { CollectionSearch } from "@/web/components/collections/collection-search.tsx"; @@ -55,6 +54,8 @@ import { Trash01, Loading01, Container, + Terminal, + Globe02, } from "@untitledui/icons"; import { Input } from "@deco/ui/components/input.tsx"; import { @@ -73,22 +74,154 @@ import { z } from "zod"; import { authClient } from "@/web/lib/auth-client"; import { generatePrefixedId } from "@/shared/utils/generate-id"; +import type { + StdioConnectionParameters, + HttpConnectionParameters, +} from "@/tools/connection/schema"; +import { isStdioParameters } from "@/tools/connection/schema"; +import { + EnvVarsEditor, + envVarsToRecord, + recordToEnvVars, + type EnvVar, +} from "@/web/components/env-vars-editor"; + +// Environment variable schema +const envVarSchema = z.object({ + key: z.string(), + value: z.string(), +}); + // Form validation schema derived from ConnectionEntitySchema // Pick the relevant fields and adapt for form use -const connectionFormSchema = ConnectionEntitySchema.pick({ - title: true, - description: true, - connection_type: true, - connection_url: true, - connection_token: true, -}).partial({ - // These are optional for form input - description: true, - connection_token: true, -}); +const connectionFormSchema = z + .object({ + title: z.string().min(1, "Name is required"), + description: z.string().nullable().optional(), + // UI type - includes "NPX" and "STDIO" which both map to STDIO internally + ui_type: z.enum(["HTTP", "SSE", "Websocket", "NPX", "STDIO"]), + // For HTTP/SSE/Websocket + connection_url: z.string().optional(), + connection_token: z.string().nullable().optional(), + // For NPX + npx_package: z.string().optional(), + // For STDIO (custom command) + stdio_command: z.string().optional(), + stdio_args: z.string().optional(), + stdio_cwd: z.string().optional(), + // Shared: Environment variables for both NPX and STDIO + env_vars: z.array(envVarSchema).optional(), + }) + .refine( + (data) => { + if (data.ui_type === "NPX") { + return !!data.npx_package?.trim(); + } + return true; + }, + { message: "NPM package is required", path: ["npx_package"] }, + ) + .refine( + (data) => { + if (data.ui_type === "STDIO") { + return !!data.stdio_command?.trim(); + } + return true; + }, + { message: "Command is required", path: ["stdio_command"] }, + ) + .refine( + (data) => { + if ( + data.ui_type === "HTTP" || + data.ui_type === "SSE" || + data.ui_type === "Websocket" + ) { + return !!data.connection_url?.trim(); + } + return true; + }, + { message: "URL is required", path: ["connection_url"] }, + ); type ConnectionFormData = z.infer; +/** + * Build STDIO connection_headers from NPX form fields + */ +function buildNpxParameters( + packageName: string, + envVars: EnvVar[], +): StdioConnectionParameters { + const params: StdioConnectionParameters = { + command: "npx", + args: ["-y", packageName], + }; + const envRecord = envVarsToRecord(envVars); + if (Object.keys(envRecord).length > 0) { + params.envVars = envRecord; + } + return params; +} + +/** + * Build STDIO connection_headers from custom command form fields + */ +function buildCustomStdioParameters( + command: string, + argsString: string, + cwd: string | undefined, + envVars: EnvVar[], +): StdioConnectionParameters { + const params: StdioConnectionParameters = { + command: command, + }; + + if (argsString.trim()) { + params.args = argsString.trim().split(/\s+/); + } + + if (cwd?.trim()) { + params.cwd = cwd.trim(); + } + + const envRecord = envVarsToRecord(envVars); + if (Object.keys(envRecord).length > 0) { + params.envVars = envRecord; + } + + return params; +} + +/** + * Check if STDIO params look like an NPX command + */ +function isNpxCommand(params: StdioConnectionParameters): boolean { + return params.command === "npx"; +} + +/** + * Parse STDIO connection_headers back to NPX form fields + */ +function parseStdioToNpx(params: StdioConnectionParameters): string { + return params.args?.find((a) => !a.startsWith("-")) ?? ""; +} + +/** + * Parse STDIO connection_headers to custom command form fields + */ +function parseStdioToCustom(params: StdioConnectionParameters): { + command: string; + args: string; + cwd: string; +} { + return { + command: params.command, + args: params.args?.join(" ") ?? "", + cwd: params.cwd ?? "", + }; +} + type DialogState = | { mode: "idle" } | { mode: "editing"; connection: ConnectionEntity } @@ -148,12 +281,20 @@ function OrgMcpsContent() { defaultValues: { title: "", description: null, - connection_type: "HTTP", + ui_type: "HTTP", connection_url: "", connection_token: null, + npx_package: "", + stdio_command: "", + stdio_args: "", + stdio_cwd: "", + env_vars: [], }, }); + // Watch the ui_type to conditionally render fields + const uiType = form.watch("ui_type"); + // Reset form when editing connection changes const editingConnection = dialogState.mode === "editing" ? dialogState.connection : null; @@ -161,20 +302,77 @@ function OrgMcpsContent() { // oxlint-disable-next-line ban-use-effect/ban-use-effect useEffect(() => { if (editingConnection) { - form.reset({ - title: editingConnection.title, - description: editingConnection.description, - connection_type: editingConnection.connection_type, - connection_url: editingConnection.connection_url, - connection_token: null, // Don't pre-fill token for security - }); + // Check if it's an STDIO connection + const stdioParams = isStdioParameters( + editingConnection.connection_headers, + ) + ? editingConnection.connection_headers + : null; + + if (stdioParams && editingConnection.connection_type === "STDIO") { + const envVars = recordToEnvVars(stdioParams.envVars); + + if (isNpxCommand(stdioParams)) { + // NPX connection + const npxPackage = parseStdioToNpx(stdioParams); + form.reset({ + title: editingConnection.title, + description: editingConnection.description, + ui_type: "NPX", + connection_url: "", + connection_token: null, + npx_package: npxPackage, + stdio_command: "", + stdio_args: "", + stdio_cwd: "", + env_vars: envVars, + }); + } else { + // Custom STDIO connection + const customData = parseStdioToCustom(stdioParams); + form.reset({ + title: editingConnection.title, + description: editingConnection.description, + ui_type: "STDIO", + connection_url: "", + connection_token: null, + npx_package: "", + stdio_command: customData.command, + stdio_args: customData.args, + stdio_cwd: customData.cwd, + env_vars: envVars, + }); + } + } else { + // HTTP/SSE/Websocket connection + form.reset({ + title: editingConnection.title, + description: editingConnection.description, + ui_type: editingConnection.connection_type as + | "HTTP" + | "SSE" + | "Websocket", + connection_url: editingConnection.connection_url ?? "", + connection_token: null, + npx_package: "", + stdio_command: "", + stdio_args: "", + stdio_cwd: "", + env_vars: [], + }); + } } else { form.reset({ title: "", description: null, - connection_type: "HTTP", + ui_type: "HTTP", connection_url: "", connection_token: null, + npx_package: "", + stdio_command: "", + stdio_args: "", + stdio_cwd: "", + env_vars: [], }); } }, [editingConnection, form]); @@ -193,6 +391,39 @@ function OrgMcpsContent() { }; const onSubmit = async (data: ConnectionFormData) => { + // Determine actual connection_type, connection_url, and connection_headers based on ui_type + let connectionType: "HTTP" | "SSE" | "Websocket" | "STDIO"; + let connectionUrl: string | null = null; + let connectionToken: string | null = null; + let connectionParameters: + | StdioConnectionParameters + | HttpConnectionParameters + | null = null; + + if (data.ui_type === "NPX") { + // NPX maps to STDIO with parameters (no URL needed) + connectionType = "STDIO"; + connectionUrl = ""; + connectionParameters = buildNpxParameters( + data.npx_package || "", + data.env_vars || [], + ); + } else if (data.ui_type === "STDIO") { + // Custom STDIO command + connectionType = "STDIO"; + connectionUrl = ""; + connectionParameters = buildCustomStdioParameters( + data.stdio_command || "", + data.stdio_args || "", + data.stdio_cwd, + data.env_vars || [], + ); + } else { + connectionType = data.ui_type; + connectionUrl = data.connection_url || ""; + connectionToken = data.connection_token || null; + } + if (editingConnection) { // Update existing connection await actions.update.mutateAsync({ @@ -200,10 +431,11 @@ function OrgMcpsContent() { data: { title: data.title, description: data.description || null, - connection_type: data.connection_type, - connection_url: data.connection_url, - ...(data.connection_token && { - connection_token: data.connection_token, + connection_type: connectionType, + connection_url: connectionUrl, + ...(connectionToken && { connection_token: connectionToken }), + ...(connectionParameters && { + connection_headers: connectionParameters, }), }, }); @@ -219,9 +451,9 @@ function OrgMcpsContent() { id: newId, title: data.title, description: data.description || null, - connection_type: data.connection_type, - connection_url: data.connection_url, - connection_token: data.connection_token || null, + connection_type: connectionType, + connection_url: connectionUrl, + connection_token: connectionToken, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), created_by: session?.user?.id || "system", @@ -229,7 +461,7 @@ function OrgMcpsContent() { icon: null, app_name: null, app_id: null, - connection_headers: null, + connection_headers: connectionParameters, oauth_config: null, configuration_state: null, metadata: null, @@ -452,7 +684,7 @@ function OrgMcpsContent() { ( Type * @@ -466,9 +698,36 @@ function OrgMcpsContent() { - HTTP - SSE - Websocket + + + + HTTP + + + + + + SSE + + + + + + Websocket + + + + + + NPX Package + + + + + + Custom Command + + @@ -476,41 +735,157 @@ function OrgMcpsContent() { )} /> - ( - - URL * - - - - - - )} - /> + {/* NPX-specific fields */} + {uiType === "NPX" && ( + <> + ( + + NPM Package * + + + +

+ The npm package to run with npx +

+ +
+ )} + /> + + )} - ( - - Token (optional) - - - - - - )} - /> + {/* STDIO/Custom Command fields */} + {uiType === "STDIO" && ( + <> +
+ ( + + Command * + + + + + + )} + /> + + ( + + Arguments + + + + + + )} + /> +
+ + ( + + Working Directory + + + +

+ Directory where the command will be executed +

+ +
+ )} + /> + + )} + + {/* Shared: Environment Variables for NPX and STDIO */} + {(uiType === "NPX" || uiType === "STDIO") && ( + ( + + Environment Variables + + + + + + )} + /> + )} + + {/* HTTP/SSE/Websocket fields */} + {uiType !== "NPX" && uiType !== "STDIO" && ( + <> + ( + + URL * + + + + + + )} + /> + + ( + + Token (optional) + + + + + + )} + /> + + )}
diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000000..47a2af9a1d --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,14 @@ +# Lefthook configuration for mesh monorepo +# Install: npx lefthook install + +pre-commit: + parallel: true + commands: + fmt: + glob: "*.{ts,tsx,js,jsx,json,md}" + run: bunx biome format --write {staged_files} + stage_fixed: true + lint: + glob: "*.{ts,tsx,js,jsx}" + run: bunx oxlint {staged_files} + From 4063bfe61c92ac9b1a1cdb7ab6f4ea2fc629f1bd Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Tue, 30 Dec 2025 16:00:12 -0300 Subject: [PATCH 2/4] Remove nullable migration --- .../migrations/014-nullable-connection-url.ts | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 apps/mesh/migrations/014-nullable-connection-url.ts diff --git a/apps/mesh/migrations/014-nullable-connection-url.ts b/apps/mesh/migrations/014-nullable-connection-url.ts deleted file mode 100644 index e8f351e9a6..0000000000 --- a/apps/mesh/migrations/014-nullable-connection-url.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Make connection_url nullable for STDIO connections - * - * STDIO connections don't need a URL - they use connection_headers - * to store { command, args, cwd, envVars } instead. - */ - -import { Kysely, sql } from "kysely"; - -export async function up(db: Kysely): Promise { - // SQLite doesn't support ALTER COLUMN, so we need to recreate the table - // For PostgreSQL, we can use ALTER COLUMN directly - // Detect dialect by attempting PostgreSQL-style ALTER first - - try { - // Try PostgreSQL syntax - await sql`ALTER TABLE connections ALTER COLUMN connection_url DROP NOT NULL`.execute( - db, - ); - } catch { - // SQLite: Need to recreate table (complex, skip for now - SQLite already allows nulls in practice) - // SQLite's NOT NULL is only enforced on INSERT, and we can use empty string as fallback - console.log( - "[Migration 014] SQLite detected - connection_url will use empty string for STDIO", - ); - } -} - -export async function down(db: Kysely): Promise { - try { - // PostgreSQL: Re-add NOT NULL constraint - await sql`ALTER TABLE connections ALTER COLUMN connection_url SET NOT NULL`.execute( - db, - ); - } catch { - // SQLite: No-op - } -} From 1e35a5c1ea7c05171bab83a3e39330b6f20fca72 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Tue, 30 Dec 2025 16:22:07 -0300 Subject: [PATCH 3/4] feat(auth): enable STDIO connections with environment variable control - Added `stdioEnabled` configuration to manage STDIO connections based on the environment. - Updated authentication configuration to include STDIO settings. - Implemented logic to block STDIO connections in production unless explicitly allowed via the `UNSAFE_ALLOW_STDIO_TRANSPORT` environment variable. - Enhanced UI components to conditionally display STDIO options based on the new configuration. This update improves security and flexibility in managing connection types. --- apps/mesh/src/api/routes/auth.ts | 11 ++++++ apps/mesh/src/api/routes/proxy.ts | 10 ++++++ .../connection-settings-form-ui.tsx | 36 ++++++++++++------- apps/mesh/src/web/routes/orgs/connections.tsx | 32 ++++++++++------- 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 6d7c95a481..55ea3e1542 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -33,6 +33,11 @@ export type AuthConfig = { | { enabled: false; }; + /** + * Whether STDIO connections are allowed. + * Disabled by default in production unless UNSAFE_ALLOW_STDIO_TRANSPORT=true + */ + stdioEnabled: boolean; }; /** @@ -51,6 +56,11 @@ app.get("/config", async (c) => { icon: KNOWN_OAUTH_PROVIDERS[name as OAuthProvider].icon, })); + // STDIO is disabled in production unless explicitly allowed + const stdioEnabled = + process.env.NODE_ENV !== "production" || + process.env.UNSAFE_ALLOW_STDIO_TRANSPORT === "true"; + const config: AuthConfig = { emailAndPassword: { enabled: authConfig.emailAndPassword?.enabled ?? false, @@ -70,6 +80,7 @@ app.get("/config", async (c) => { : { enabled: false, }, + stdioEnabled, }; return c.json({ success: true, config }); diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 9d4bf3bb74..00834dd01d 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -284,6 +284,16 @@ async function createMCPProxyDoNotUseDirectly( const createClient = async () => { switch (connection.connection_type) { case "STDIO": { + // Block STDIO connections in production unless explicitly allowed + if ( + process.env.NODE_ENV === "production" && + process.env.UNSAFE_ALLOW_STDIO_TRANSPORT !== "true" + ) { + throw new Error( + "STDIO connections are disabled in production. Set UNSAFE_ALLOW_STDIO_TRANSPORT=true to enable.", + ); + } + if (!stdioParams) { throw new Error("STDIO connection missing parameters"); } diff --git a/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx b/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx index 19109d419d..6e3e3c3a1b 100644 --- a/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx +++ b/apps/mesh/src/web/components/details/connection/settings-tab/connection-settings-form-ui.tsx @@ -1,6 +1,7 @@ import type { ConnectionEntity } from "@/tools/connection/schema"; import { EnvVarsEditor } from "@/web/components/env-vars-editor"; import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { useAuthConfig } from "@/web/providers/auth-config-provider"; import { useProjectContext } from "@/web/providers/project-context-provider"; import { Form, @@ -35,6 +36,13 @@ function ConnectionFields({ connection: ConnectionEntity; }) { const uiType = useWatch({ control: form.control, name: "ui_type" }); + const { stdioEnabled } = useAuthConfig(); + + // Show STDIO options if: + // 1. STDIO is enabled globally, OR + // 2. The connection is already an STDIO type (allow viewing/editing existing connections) + const showStdioOptions = + stdioEnabled || connection.connection_type === "STDIO"; return (
@@ -70,18 +78,22 @@ function ConnectionFields({ Websocket - - - - NPX Package - - - - - - Custom Command - - + {showStdioOptions && ( + <> + + + + NPX Package + + + + + + Custom Command + + + + )} diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx index 7ae8c10f13..a96ee08fd1 100644 --- a/apps/mesh/src/web/routes/orgs/connections.tsx +++ b/apps/mesh/src/web/routes/orgs/connections.tsx @@ -12,6 +12,7 @@ import { useConnectionActions, } from "@/web/hooks/collections/use-connection"; import { useListState } from "@/web/hooks/use-list-state"; +import { useAuthConfig } from "@/web/providers/auth-config-provider"; import { useProjectContext } from "@/web/providers/project-context-provider"; import { AlertDialog, @@ -248,6 +249,7 @@ function OrgMcpsContent() { const navigate = useNavigate(); const search = useSearch({ strict: false }) as { action?: "create" }; const { data: session } = authClient.useSession(); + const { stdioEnabled } = useAuthConfig(); // Consolidated list UI state (search, filters, sorting, view mode) const listState = useListState({ @@ -543,7 +545,7 @@ function OrgMcpsContent() { id: "connection_url", header: "URL", render: (connection) => { - const url = connection.connection_url; + const url = connection.connection_url ?? ""; const truncated = url.length > 40 ? `${url.slice(0, 40)}...` : url; return ( {truncated} @@ -716,18 +718,22 @@ function OrgMcpsContent() { Websocket - - - - NPX Package - - - - - - Custom Command - - + {stdioEnabled && ( + <> + + + + NPX Package + + + + + + Custom Command + + + + )} From bf63fa56918d1b839ec5d16c3f3d2cb0457c2f12 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Tue, 30 Dec 2025 17:47:34 -0300 Subject: [PATCH 4/4] Improve logs from stdio mcps so they dont look like errors --- apps/mesh/src/api/routes/proxy.ts | 1 + apps/mesh/src/stdio/stable-transport.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 00834dd01d..e614801fdf 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -302,6 +302,7 @@ async function createMCPProxyDoNotUseDirectly( // We want stable local MCP connection - don't spawn new process per request return getStableStdioClient({ id: connectionId, + name: connection.title, command: stdioParams.command, args: stdioParams.args, env: stdioParams.envVars, diff --git a/apps/mesh/src/stdio/stable-transport.ts b/apps/mesh/src/stdio/stable-transport.ts index ee40ccebb0..66cddbeb49 100644 --- a/apps/mesh/src/stdio/stable-transport.ts +++ b/apps/mesh/src/stdio/stable-transport.ts @@ -20,6 +20,8 @@ import { export interface StableStdioConfig extends StdioServerParameters { /** Unique ID for this connection (for logging) */ id: string; + /** Human-readable name for the MCP (for logging) */ + name?: string; } /** @@ -163,9 +165,16 @@ export async function getStableStdioClient( // Don't remove from pool - next request will respawn }; - // Handle stderr for debugging + // Handle stderr for debugging - pass through MCP logs with subtle connection reference + const label = config.name || config.id; + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; transport.stderr?.on("data", (data: Buffer) => { - console.error(`[stdio:${config.id}] stderr:`, data.toString()); + const output = data.toString().trimEnd(); + if (output) { + // Print MCP output first, then subtle connection reference + console.error(`${output} ${dim}[${label}]${reset}`); + } }); // Connect with timeout - use AbortController to clean up on success