From 7e466f70720087ce8f124dfc5e13a44e934ff8af Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Tue, 10 Mar 2026 22:42:51 -0300 Subject: [PATCH 01/87] feat(chat): add Claude Code as local chat provider When running locally with Claude Code installed, offer it as a model option in the chat UI. Routes messages through the Claude Agent SDK subprocess instead of OpenRouter, using the user's local Claude auth. - Add `claudeCodeAvailable` to AuthConfig (detected via Bun.which) - New claude-code-provider adapter wrapping @anthropic-ai/claude-agent-sdk - Fork decopilot stream endpoint for claude-code connectionId sentinel - Add Claude Code entry in model selector with "Local" badge - Handle empty connections gracefully when only Claude Code is available Co-Authored-By: Claude Opus 4.6 --- apps/mesh/package.json | 4 +- apps/mesh/src/api/routes/auth.ts | 6 + .../routes/decopilot/claude-code-provider.ts | 277 ++++++++++++++++++ apps/mesh/src/api/routes/decopilot/routes.ts | 40 +-- apps/mesh/src/api/routes/decopilot/schemas.ts | 1 + .../src/web/components/chat/select-model.tsx | 51 +++- 6 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 apps/mesh/src/api/routes/decopilot/claude-code-provider.ts diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 649bfcd1e1..fea4d72229 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -36,7 +36,9 @@ "prepublishOnly": "bun run build:client && bun run build:server" }, "optionalDependencies": { - "@duckdb/node-api": "^1.5.0-r.1" + "@anthropic-ai/claude-agent-sdk": "^0.2.72", + "@duckdb/node-api": "^1.5.0-r.1", + "chdb": "^1.6.0" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.58", diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 66b4255d03..4f1a69dce1 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -56,6 +56,11 @@ export type AuthConfig = { * When true, the frontend should auto-login and skip org selection. */ localMode: boolean; + /** + * Whether Claude Code is available locally. + * When true, the frontend can offer Claude Code as a chat provider. + */ + claudeCodeAvailable: boolean; }; /** @@ -105,6 +110,7 @@ app.get("/config", async (c) => { }, stdioEnabled, localMode: isLocalMode(), + claudeCodeAvailable: isLocalMode() && !!Bun.which("claude"), }; return c.json({ success: true, config }); diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts new file mode 100644 index 0000000000..4e0716df80 --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -0,0 +1,277 @@ +/** + * Claude Code Provider + * + * Adapter for the Claude Agent SDK that streams Claude Code responses + * into AI SDK's UIMessageStreamWriter format. + */ + +import type { UIMessageStreamWriter } from "ai"; +import type { ChatMessage } from "./types"; +import { generateMessageId } from "./constants"; + +// Lazily loaded SDK query function +let _query: typeof import("@anthropic-ai/claude-agent-sdk").query | null = null; + +// Clear CLAUDECODE to prevent recursive invocation +delete process.env.CLAUDECODE; + +async function getQuery() { + if (!_query) { + const sdk = await import("@anthropic-ai/claude-agent-sdk"); + _query = sdk.query; + } + return _query; +} + +export function isClaudeCodeAvailable(): boolean { + return !!Bun.which("claude"); +} + +/** + * Convert chat messages to a prompt string for the Claude Agent SDK. + */ +function messagesToPrompt(messages: ChatMessage[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "system") continue; + for (const part of msg.parts ?? []) { + if ("text" in part && typeof part.text === "string") { + parts.push(part.text); + } + } + } + + return parts.join("\n\n"); +} + +/** + * Extract system prompt text from system messages. + */ +function extractSystemPrompt(messages: ChatMessage[]): string { + const systemParts: string[] = []; + for (const msg of messages) { + if (msg.role !== "system") continue; + for (const part of msg.parts ?? []) { + if ("text" in part && typeof part.text === "string") { + systemParts.push(part.text); + } + } + } + return systemParts.join("\n\n"); +} + +export interface ClaudeCodeStreamOptions { + messages: ChatMessage[]; + abortController?: AbortController; + mcpEndpoint?: string; + agentId?: string; + agentMode?: string; + threadId: string; + connectionId: string; +} + +/** + * Stream Claude Code responses into a UIMessageStreamWriter. + * + * Uses the Claude Agent SDK's query() function to spawn a Claude Code + * subprocess and converts the streaming SDKMessages into AI SDK format. + */ +export async function streamClaudeCode( + writer: UIMessageStreamWriter, + opts: ClaudeCodeStreamOptions, +): Promise<{ + costUsd: number; + usage: { inputTokens: number; outputTokens: number; totalTokens: number }; +}> { + const queryFn = await getQuery(); + + const prompt = messagesToPrompt(opts.messages); + const systemPrompt = extractSystemPrompt(opts.messages); + + console.log("[claude-code] Starting stream", { + promptLength: prompt.length, + promptPreview: prompt.slice(0, 200), + hasSystemPrompt: !!systemPrompt, + }); + + const abortController = opts.abortController ?? new AbortController(); + + const queryOpts: Parameters[0]["options"] = { + maxTurns: 1, + abortController, + systemPrompt: systemPrompt || undefined, + permissionMode: "bypassPermissions" as const, + allowDangerouslySkipPermissions: true, + tools: [], + }; + + // If an MCP endpoint is provided, pass it so Claude Code can use mesh tools + if (opts.mcpEndpoint) { + queryOpts.mcpServers = { + mesh: { + type: "sse" as const, + url: opts.mcpEndpoint, + }, + }; + // Allow more turns when tools are available + queryOpts.maxTurns = 30; + // Let Claude Code use its default tools + MCP tools + queryOpts.tools = undefined; + } + + let conversation: ReturnType; + try { + conversation = queryFn({ prompt, options: queryOpts }); + } catch (err) { + console.error("[claude-code] Failed to start query:", err); + throw err; + } + + // Emit a start message + const messageId = generateMessageId(); + const textPartId = generateMessageId(); + writer.write({ + type: "start", + messageId, + messageMetadata: { + agent: { + id: opts.agentId ?? null, + mode: opts.agentMode ?? "passthrough", + }, + models: { + connectionId: opts.connectionId, + thinking: { id: "claude-code", provider: "claude-code" }, + }, + created_at: new Date(), + thread_id: opts.threadId, + }, + }); + + // Start a text part + writer.write({ type: "text-start", id: textPartId }); + + let totalCostUsd = 0; + let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + + try { + for await (const message of conversation) { + if (abortController.signal.aborted) break; + + console.log( + "[claude-code] Message:", + message.type, + "subtype" in message ? (message as { subtype?: string }).subtype : "", + ); + + switch (message.type) { + case "stream_event": { + // Only handle main thread events (no subagent) + if (message.parent_tool_use_id) break; + + const event = message.event; + + if ( + event.type === "content_block_delta" && + "delta" in event && + event.delta + ) { + const delta = event.delta as { type: string; text?: string }; + if (delta.type === "text_delta" && delta.text) { + writer.write({ + type: "text-delta", + delta: delta.text, + id: textPartId, + }); + } + } + break; + } + + case "result": { + if (message.subtype === "success") { + totalCostUsd = message.total_cost_usd ?? 0; + const u = message.usage; + if (u) { + usage = { + inputTokens: u.input_tokens ?? 0, + outputTokens: u.output_tokens ?? 0, + totalTokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0), + }; + } + } else { + // Error result + const errors = (message as { errors?: string[] }).errors ?? []; + if (errors.length > 0) { + writer.write({ + type: "error", + errorText: errors.join("; "), + }); + } + } + break; + } + + case "assistant": { + // Only handle main thread messages (no subagent) + if (message.parent_tool_use_id) break; + + // Handle errors + if (message.error) { + const errorMessages: Record = { + authentication_failed: + "Claude Code is not authenticated. Run `claude login` in your terminal.", + billing_error: + "Claude Code billing error. Check your subscription.", + rate_limit: "Claude Code rate limited. Please try again shortly.", + }; + writer.write({ + type: "error", + errorText: + errorMessages[message.error] ?? + `Claude Code error: ${message.error}`, + }); + break; + } + + // Extract text content from the full assistant message + const content = ( + message.message as { content?: { type: string; text?: string }[] } + )?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) { + writer.write({ + type: "text-delta", + delta: block.text, + id: textPartId, + }); + } + } + } + break; + } + } + } + } catch (err) { + console.error("[claude-code] Stream error:", err); + writer.write({ + type: "error", + errorText: + err instanceof Error ? err.message : "Claude Code stream failed", + }); + } + + // End the text part + writer.write({ type: "text-end", id: textPartId }); + + writer.write({ + type: "finish", + finishReason: "stop", + messageMetadata: { + usage, + }, + }); + + return { costUsd: totalCostUsd, usage }; +} diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index d9b7178aa7..0fcb377464 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -125,24 +125,28 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { throw new HTTPException(401, { message: "User ID is required" }); } - // 2. Check model permissions - const allowedModels = await fetchModelPermissions( - ctx.db, - organization.id, - ctx.auth.user?.role, - ); - - if ( - allowedModels !== undefined && - !checkModelPermission( - allowedModels, - models.credentialId, - models.thinking.id, - ) - ) { - throw new HTTPException(403, { - message: "Model not allowed for your role", - }); + const isClaudeCode = models.connectionId === "claude-code"; + + // 2. Check model permissions (skip for Claude Code — uses local auth) + if (!isClaudeCode) { + const allowedModels = await fetchModelPermissions( + ctx.db, + organization.id, + ctx.auth.user?.role, + ); + + if ( + allowedModels !== undefined && + !checkModelPermission( + allowedModels, + models.credentialId, + models.thinking.id, + ) + ) { + throw new HTTPException(403, { + message: "Model not allowed for your role", + }); + } } const windowSize = memoryConfig?.windowSize ?? DEFAULT_WINDOW_SIZE; diff --git a/apps/mesh/src/api/routes/decopilot/schemas.ts b/apps/mesh/src/api/routes/decopilot/schemas.ts index 2350f51f28..6bfc401c57 100644 --- a/apps/mesh/src/api/routes/decopilot/schemas.ts +++ b/apps/mesh/src/api/routes/decopilot/schemas.ts @@ -28,6 +28,7 @@ const ProviderEnum = z.enum([ "openrouter", "openai-compatible", "deco", + "claude-code", ]); const ProviderSchema = ProviderEnum.optional().nullable(); diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 797ea85cc3..e592108c2c 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -28,6 +28,7 @@ import { SearchMd, Settings01, Stars01, + TerminalSquare, Tool01, } from "@untitledui/icons"; import { @@ -45,6 +46,8 @@ import { useAiProviderModels, useAiProviders, } from "../../hooks/collections/use-llm"; +import { useAllowedModels } from "../../hooks/use-allowed-models"; +import { useAuthConfig } from "../../providers/auth-config-provider"; import { ErrorBoundary } from "../error-boundary"; import { useChat } from "./context"; import { getProviderLogo } from "@/web/utils/ai-providers-logos"; @@ -66,6 +69,18 @@ function parseModelTitle(model: { title: string; modelId: string }): { }; } +// ============================================================================ +// Claude Code Constants +// ============================================================================ + +export const CLAUDE_CODE_CONNECTION_ID = "claude-code"; + +export function isClaudeCodeModel( + model: { connectionId?: string } | null | undefined, +): boolean { + return model?.connectionId === CLAUDE_CODE_CONNECTION_ID; +} + // ============================================================================ // Tier Classification // ============================================================================ @@ -1020,11 +1035,27 @@ function ModelSelectorInner({ ); const { open: openSettings } = useSettingsModal(); + const authConfig = useAuthConfig(); + const showClaudeCode = authConfig.claudeCodeAvailable; + const handleKeyChange = (keyId: string) => { onCredentialChange(keyId); setHoveredModel(null); }; + const handleClaudeCodeSelect = () => { + setSelectedModel({ + modelId: "claude-code", + title: "Claude Code", + providerId: "claude-code", + capabilities: ["text", "tools"], + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + keyId: "claude-code", + } as AiProviderModel); + setSearchTerm(""); + onClose(); + }; + const handleModelSelect = (model: AiProviderModel) => { if (!credentialId) return; onModelChange(model); @@ -1124,6 +1155,24 @@ function ModelSelectorInner({ + {showClaudeCode && ( +
+ +
+ )} ( @@ -1165,7 +1214,7 @@ function ModelSelectorInner({ Manage API keys - )} + )
From 7bbfa6cba83ea150f30543da54db906ae418b02e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Wed, 11 Mar 2026 07:47:34 -0300 Subject: [PATCH 02/87] fix(chat): fix Claude Code streaming and add model variant selector - Extract text from assistant message content blocks (SDK emits full messages, not stream_event deltas) - Add start-step/finish-step markers for proper AI SDK status tracking - Add Opus/Sonnet/Haiku model variants in the selector - Pass selected model to SDK query options - Fix selectedConnectionId initialization to skip claude-code sentinel Co-Authored-By: Claude Opus 4.6 --- .../routes/decopilot/claude-code-provider.ts | 42 +++++++++- .../src/web/components/chat/select-model.tsx | 79 +++++++++++++++---- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 4e0716df80..2749afd479 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -61,6 +61,28 @@ function extractSystemPrompt(messages: ChatMessage[]): string { return systemParts.join("\n\n"); } +/** Claude Code model variants that can be selected in the UI */ +export const CLAUDE_CODE_MODELS = [ + { + id: "claude-code:opus", + sdkModel: "claude-opus-4-6", + title: "Claude Code Opus", + tier: "smarter" as const, + }, + { + id: "claude-code:sonnet", + sdkModel: "claude-sonnet-4-6", + title: "Claude Code Sonnet", + tier: "faster" as const, + }, + { + id: "claude-code:haiku", + sdkModel: "claude-haiku-4-5", + title: "Claude Code Haiku", + tier: "cheaper" as const, + }, +] as const; + export interface ClaudeCodeStreamOptions { messages: ChatMessage[]; abortController?: AbortController; @@ -69,6 +91,8 @@ export interface ClaudeCodeStreamOptions { agentMode?: string; threadId: string; connectionId: string; + /** SDK model identifier, e.g. "claude-sonnet-4-6" */ + model?: string; } /** @@ -97,9 +121,16 @@ export async function streamClaudeCode( const abortController = opts.abortController ?? new AbortController(); + // Resolve SDK model name from the model id (e.g. "claude-code:sonnet" → "claude-sonnet-4-6") + const sdkModel = opts.model + ? (CLAUDE_CODE_MODELS.find((m) => m.id === opts.model)?.sdkModel ?? + opts.model) + : undefined; + const queryOpts: Parameters[0]["options"] = { maxTurns: 1, abortController, + model: sdkModel, systemPrompt: systemPrompt || undefined, permissionMode: "bypassPermissions" as const, allowDangerouslySkipPermissions: true, @@ -141,14 +172,18 @@ export async function streamClaudeCode( }, models: { connectionId: opts.connectionId, - thinking: { id: "claude-code", provider: "claude-code" }, + thinking: { + id: opts.model ?? "claude-code", + provider: "claude-code", + }, }, created_at: new Date(), thread_id: opts.threadId, }, }); - // Start a text part + // Start a step + text part (AI SDK expects step markers for status tracking) + writer.write({ type: "start-step" }); writer.write({ type: "text-start", id: textPartId }); let totalCostUsd = 0; @@ -262,8 +297,9 @@ export async function streamClaudeCode( }); } - // End the text part + // End the text part + step writer.write({ type: "text-end", id: textPartId }); + writer.write({ type: "finish-step" }); writer.write({ type: "finish", diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index e592108c2c..9db37a15c4 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -75,12 +75,43 @@ function parseModelTitle(model: { title: string; modelId: string }): { export const CLAUDE_CODE_CONNECTION_ID = "claude-code"; +/** Claude Code model variants available in the selector */ +const CLAUDE_CODE_MODELS = [ + { + id: "claude-code:opus", + title: "Opus", + description: "Most capable", + tier: "smarter" as const, + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + }, + { + id: "claude-code:sonnet", + title: "Sonnet", + description: "Fast & capable", + tier: "faster" as const, + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + }, + { + id: "claude-code:haiku", + title: "Haiku", + description: "Fastest", + tier: "cheaper" as const, + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + }, +]; + export function isClaudeCodeModel( model: { connectionId?: string } | null | undefined, ): boolean { return model?.connectionId === CLAUDE_CODE_CONNECTION_ID; } +/** Get display name for a Claude Code model id */ +function claudeCodeDisplayName(modelId: string): string { + const m = CLAUDE_CODE_MODELS.find((m) => m.id === modelId); + return m ? `Claude Code ${m.title}` : "Claude Code"; +} + // ============================================================================ // Tier Classification // ============================================================================ @@ -1043,13 +1074,17 @@ function ModelSelectorInner({ setHoveredModel(null); }; - const handleClaudeCodeSelect = () => { + const handleClaudeCodeSelect = (modelId: string) => { + const variant = CLAUDE_CODE_MODELS.find((m) => m.id === modelId); setSelectedModel({ - modelId: "claude-code", - title: "Claude Code", + modelId, + title: variant ? `Claude Code ${variant.title}` : "Claude Code", providerId: "claude-code", capabilities: ["text", "tools"], - limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + limits: variant?.limits ?? { + contextWindow: 200_000, + maxOutputTokens: 32_768, + }, keyId: "claude-code", } as AiProviderModel); setSearchTerm(""); @@ -1157,20 +1192,34 @@ function ModelSelectorInner({ {showClaudeCode && (
- +
+ {CLAUDE_CODE_MODELS.map((variant) => ( + + ))}
)} Date: Thu, 12 Mar 2026 16:34:40 -0300 Subject: [PATCH 03/87] feat(chat): add Connect Studio and auto-wire MCP for Claude Code - Add Connect Studio modal with one-click Claude Code / Cursor setup - Server endpoint generates API key and runs `claude mcp add-json` or writes Cursor config - Status endpoint checks if IDE is already connected - Auto-wire MCP endpoint when using Claude Code as local chat provider - Support mcpHeaders in Claude Agent SDK http transport Co-Authored-By: Claude Opus 4.6 --- .../routes/decopilot/claude-code-provider.ts | 16 +- apps/mesh/src/api/routes/decopilot/routes.ts | 129 ++++++++++++ .../src/web/components/chat/select-model.tsx | 16 ++ .../web/components/connect-studio-modal.tsx | 188 ++++++++++++++++++ .../web/components/sidebar/footer/inbox.tsx | 143 +++++++++---- conductor.json | 2 +- 6 files changed, 440 insertions(+), 54 deletions(-) create mode 100644 apps/mesh/src/web/components/connect-studio-modal.tsx diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 2749afd479..1d7a802a81 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -87,6 +87,7 @@ export interface ClaudeCodeStreamOptions { messages: ChatMessage[]; abortController?: AbortController; mcpEndpoint?: string; + mcpHeaders?: Record; agentId?: string; agentMode?: string; threadId: string; @@ -113,12 +114,6 @@ export async function streamClaudeCode( const prompt = messagesToPrompt(opts.messages); const systemPrompt = extractSystemPrompt(opts.messages); - console.log("[claude-code] Starting stream", { - promptLength: prompt.length, - promptPreview: prompt.slice(0, 200), - hasSystemPrompt: !!systemPrompt, - }); - const abortController = opts.abortController ?? new AbortController(); // Resolve SDK model name from the model id (e.g. "claude-code:sonnet" → "claude-sonnet-4-6") @@ -141,8 +136,9 @@ export async function streamClaudeCode( if (opts.mcpEndpoint) { queryOpts.mcpServers = { mesh: { - type: "sse" as const, + type: "http" as const, url: opts.mcpEndpoint, + headers: opts.mcpHeaders, }, }; // Allow more turns when tools are available @@ -193,12 +189,6 @@ export async function streamClaudeCode( for await (const message of conversation) { if (abortController.signal.aborted) break; - console.log( - "[claude-code] Message:", - message.type, - "subtype" in message ? (message as { subtype?: string }).subtype : "", - ); - switch (message.type) { case "stream_event": { // Only handle main thread events (no subagent) diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 0fcb377464..0779892e2d 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -196,6 +196,135 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { } }); + // ============================================================================ + // Connect Studio — check + register MCP server in Claude Code / Cursor + // ============================================================================ + + app.get("/:org/decopilot/connect-studio/status", async (c) => { + const ctx = c.get("meshContext"); + if (!ctx.auth?.user?.id) { + throw new HTTPException(401, { message: "Authentication required" }); + } + + const { spawn } = await import("node:child_process"); + + // Check Claude Code: `claude mcp get mesh-studio` exits 0 if configured + const claudeCode = await new Promise((resolve) => { + const proc = spawn("claude", ["mcp", "get", "mesh-studio"], { + stdio: "ignore", + }); + proc.on("close", (code) => resolve(code === 0)); + proc.on("error", () => resolve(false)); + }); + + // Check Cursor: read ~/.cursor/mcp.json + let cursor = false; + try { + const os = await import("node:os"); + const path = await import("node:path"); + const fs = await import("node:fs"); + const configPath = path.join(os.homedir(), ".cursor", "mcp.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + cursor = !!config?.mcpServers?.["mesh-studio"]; + } catch { + // File doesn't exist or parse error + } + + return c.json({ "claude-code": claudeCode, cursor }); + }); + + app.post("/:org/decopilot/connect-studio", async (c) => { + const ctx = c.get("meshContext"); + const organization = ensureOrganization(c); + const userId = ctx.auth?.user?.id; + if (!userId) { + throw new HTTPException(401, { message: "Authentication required" }); + } + + const body = await c.req.json<{ target: "claude-code" | "cursor" }>(); + const target = body.target; + if (target !== "claude-code" && target !== "cursor") { + throw new HTTPException(400, { + message: "target must be 'claude-code' or 'cursor'", + }); + } + + // Create API key for the MCP endpoint + const apiKey = await ctx.boundAuth.apiKey.create({ + name: `studio-connect-${target}`, + permissions: { "*": ["*"] }, + metadata: { + internal: true, + target, + organization: organization.id, + }, + }); + + const serverPort = process.env.PORT || "3000"; + const origin = `http://localhost:${serverPort}`; + const mcpConfig = JSON.stringify({ + type: "http", + url: `${origin}/mcp`, + headers: { + Authorization: `Bearer ${apiKey.key}`, + "x-org-id": organization.id, + "x-mesh-client": target === "claude-code" ? "Claude Code" : "Cursor", + }, + }); + + const { spawn } = await import("node:child_process"); + + if (target === "claude-code") { + // Use `claude mcp add-json` CLI to register globally + const result = await new Promise<{ ok: boolean; stderr: string }>( + (resolve) => { + const proc = spawn( + "claude", + ["mcp", "add-json", "mesh-studio", mcpConfig, "--scope", "user"], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + let stderr = ""; + proc.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + proc.on("close", (code) => resolve({ ok: code === 0, stderr })); + proc.on("error", (err) => + resolve({ ok: false, stderr: err.message }), + ); + }, + ); + if (!result.ok) { + throw new HTTPException(500, { + message: `claude mcp add-json failed: ${result.stderr}`, + }); + } + } else { + // Cursor has no CLI — write directly to ~/.cursor/mcp.json + const os = await import("node:os"); + const fs = await import("node:fs"); + const path = await import("node:path"); + const cursorDir = path.join(os.homedir(), ".cursor"); + if (!fs.existsSync(cursorDir)) { + fs.mkdirSync(cursorDir, { recursive: true }); + } + const configPath = path.join(cursorDir, "mcp.json"); + let config: Record = {}; + try { + config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + } catch { + // File doesn't exist yet + } + if (!config.mcpServers || typeof config.mcpServers !== "object") { + config.mcpServers = {}; + } + (config.mcpServers as Record)["mesh-studio"] = + JSON.parse(mcpConfig); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); + } + + return c.json({ success: true, target }); + }); + // ============================================================================ // Cancel Endpoint — cancel ongoing run (local or via NATS to owning pod) // ============================================================================ diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 9db37a15c4..8af197a104 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -1206,6 +1206,22 @@ function ModelSelectorInner({ key={variant.id} type="button" onClick={() => handleClaudeCodeSelect(variant.id)} + onMouseEnter={() => + setHoveredModel({ + id: variant.id, + title: `Claude Code: ${variant.title}`, + logo: null, + description: variant.description, + capabilities: ["text", "tools"], + limits: variant.limits, + costs: null, + provider: null, + created_at: "", + updated_at: "", + created_by: "", + updated_by: "", + }) + } className={cn( "flex items-center gap-2 w-full min-h-8 py-2 px-4 text-left cursor-pointer", "hover:bg-accent", diff --git a/apps/mesh/src/web/components/connect-studio-modal.tsx b/apps/mesh/src/web/components/connect-studio-modal.tsx new file mode 100644 index 0000000000..dad5ca5616 --- /dev/null +++ b/apps/mesh/src/web/components/connect-studio-modal.tsx @@ -0,0 +1,188 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { Check, Loading01, RefreshCw01 } from "@untitledui/icons"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { cn } from "@deco/ui/lib/utils.ts"; + +type Target = "claude-code" | "cursor"; +type Status = Record; + +function useConnectStatus(org: { slug: string }) { + return useQuery({ + queryKey: ["connect-studio-status", org.slug], + queryFn: async () => { + const res = await fetch( + `/api/${org.slug}/decopilot/connect-studio/status`, + ); + if (!res.ok) throw new Error("Failed to fetch status"); + return res.json(); + }, + }); +} + +function ConnectButton({ + target, + label, + logo, + logoStyle, + connected, + statusLoading, +}: { + target: Target; + label: string; + logo: string; + logoStyle?: React.CSSProperties; + connected: boolean; + statusLoading: boolean; +}) { + const { org } = useProjectContext(); + const queryClient = useQueryClient(); + const [loading, setLoading] = useState(false); + + const handleConnect = async () => { + setLoading(true); + try { + const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed" })); + throw new Error(err.error ?? "Failed to connect"); + } + toast.success(`Connected to ${label}!`); + queryClient.invalidateQueries({ + queryKey: ["connect-studio-status", org.slug], + }); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to connect studio", + ); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} + +export function ConnectStudioModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { org } = useProjectContext(); + const { + data: status, + isLoading, + refetch, + isRefetching, + } = useConnectStatus(org); + const queryClient = useQueryClient(); + + return ( + { + onOpenChange(v); + if (v) { + queryClient.invalidateQueries({ + queryKey: ["connect-studio-status", org.slug], + }); + } + }} + > + + +
+ Connect Studio + +
+ + One-click install all your studio tools into your IDE. + +
+
+ + +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx index ab25caa776..ec774b6c20 100644 --- a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx +++ b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx @@ -12,7 +12,7 @@ import { SidebarMenu, SidebarMenuItem, } from "@deco/ui/components/sidebar.tsx"; -import { Check, Coins01, Inbox01, XClose } from "@untitledui/icons"; +import { Check, Coins01, Inbox01, Link01, XClose } from "@untitledui/icons"; import { AuthUIContext } from "@daveyplate/better-auth-ui"; import { cn } from "@deco/ui/lib/utils.ts"; import { Component, Suspense, useContext, useState } from "react"; @@ -20,12 +20,14 @@ import type { ErrorInfo, ReactNode } from "react"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { - SELF_MCP_ALIAS_ID, + useConnections, useMCPClient, useMCPToolCallQuery, useProjectContext, } from "@decocms/mesh-sdk"; -import { useAiProviderKeyList } from "@/web/hooks/collections/use-llm"; +import { ConnectStudioModal } from "@/web/components/connect-studio-modal"; +import { connectionImplementsBinding } from "@/web/hooks/use-binding"; +import { AI_GATEWAY_BILLING_BINDING } from "@decocms/bindings/ai-gateway"; interface Invitation { id: string; @@ -152,35 +154,89 @@ class SilentErrorBoundary extends Component< } } -function creditColor(balanceDollars: number): string { - if (balanceDollars <= 1) return "text-destructive"; - if (balanceDollars <= 5) return "text-amber-500 dark:text-amber-400"; +type LimitPeriod = "daily" | "weekly" | "monthly"; + +interface GatewayUsageResult { + billing: { mode: "prepaid" | "postpaid"; limitPeriod: LimitPeriod | null }; + limit: { remaining: number | null; total: number | null }; + usage: { total: number; daily: number; weekly: number; monthly: number }; +} + +const CHIP_PERIOD_KEY = "gateway-chip-period"; + +function getChipPeriod(): LimitPeriod { + try { + const stored = localStorage.getItem(CHIP_PERIOD_KEY); + if (stored === "daily" || stored === "weekly" || stored === "monthly") + return stored; + } catch { + // ignore + } + return "daily"; +} + +function prepaidColor(remaining: number, total: number | null): string { + if (!total || total <= 0) return "text-foreground/70"; + const pct = remaining / total; + if (pct <= 0.05) return "text-destructive"; + if (pct <= 0.2) return "text-amber-500 dark:text-amber-400"; + return "text-foreground/70"; +} + +function postpaidUsedColor(percentUsed: number): string { + if (percentUsed >= 90) return "text-destructive"; + if (percentUsed >= 70) return "text-amber-500 dark:text-amber-400"; return "text-foreground/70"; } -function CreditChip() { +function CreditChip({ connectionId }: { connectionId: string }) { const { open } = useSettingsModal(); const { org } = useProjectContext(); - const client = useMCPClient({ - connectionId: SELF_MCP_ALIAS_ID, - orgId: org.id, - }); + const client = useMCPClient({ connectionId, orgId: org.id }); - const { data, isPending, isError } = useMCPToolCallQuery< - { balanceCents: number } | undefined - >({ + const { data } = useMCPToolCallQuery({ client, - toolName: "AI_PROVIDER_CREDITS", - toolArguments: { providerId: "deco" }, + toolName: "GATEWAY_USAGE", + toolArguments: {}, staleTime: 60_000, select: (result) => - (result as { structuredContent?: { balanceCents: number } }) - .structuredContent, + (result as { structuredContent?: GatewayUsageResult }).structuredContent, }); - const balanceDollars = - data?.balanceCents != null ? data.balanceCents / 100 : null; + const billingMode = data?.billing.mode ?? "prepaid"; + const limitTotal = data?.limit.total ?? null; + const limitRemaining = data?.limit.remaining ?? 0; + const usage = data?.usage ?? { total: 0, daily: 0, weekly: 0, monthly: 0 }; + + let label: string; + let value: string; + let valueColor: string; + + if (billingMode === "prepaid") { + label = "Credits"; + value = `$${limitRemaining.toFixed(2)}`; + valueColor = prepaidColor(limitRemaining, limitTotal); + } else if (limitTotal != null && limitTotal > 0) { + const used = limitTotal - limitRemaining; + const pct = Math.min(100, Math.round((used / limitTotal) * 100)); + label = "Usage"; + value = `${pct}%`; + valueColor = postpaidUsedColor(pct); + } else { + const chipPeriod = getChipPeriod(); + const periodUsage = + chipPeriod === "daily" + ? usage.daily + : chipPeriod === "weekly" + ? usage.weekly + : usage.monthly; + const periodSuffix = + chipPeriod === "daily" ? "/day" : chipPeriod === "weekly" ? "/wk" : "/mo"; + label = "Usage"; + value = `$${periodUsage.toFixed(2)}${periodSuffix}`; + valueColor = "text-foreground/70"; + } return ( ); } function CreditChipConditional() { - const keys = useAiProviderKeyList(); - const hasDecoKey = keys.some((k) => k.providerId === "deco"); + const connections = useConnections(); - if (!hasDecoKey) return null; + const gatewayConnection = connections.find((c) => + connectionImplementsBinding(c, AI_GATEWAY_BILLING_BINDING), + ); + + if (!gatewayConnection?.id) { + return null; + } - return ; + return ; } export function SidebarInboxFooter() { const pendingInvitations = usePendingInvitations(); + const [connectOpen, setConnectOpen] = useState(false); return ( @@ -229,13 +280,25 @@ export function SidebarInboxFooter() { +
+ + +
-
+
diff --git a/conductor.json b/conductor.json index 1772c40a72..d46ea1f752 100644 --- a/conductor.json +++ b/conductor.json @@ -1,7 +1,7 @@ { "scripts": { "setup": "bun install", - "run": "bun run dev:conductor", + "run": "bun run dev:local:conductor", "archive": "rm -rf node_modules" } } From 2915e7f13ac0c304f4a7f52ae4327a0ba1c2d1fb Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 16:40:03 -0300 Subject: [PATCH 04/87] fix(chat): fix missing closing brace in model selector JSX Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/web/components/chat/select-model.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 8af197a104..fe7db2b819 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -1279,7 +1279,7 @@ function ModelSelectorInner({ Manage API keys
- ) + )}
From cd320370bcf9ff2d86a5096043a3157d2914d024 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 16:52:25 -0300 Subject: [PATCH 05/87] fix(chat): fix type errors from rebase and add Claude Code card to onboarding - Fix missing closing brace in model selector JSX - Fix AiProviderModel type mismatches for Claude Code synthetic models - Remove stale useAllowedModels import - Add Claude Code card with "Local" badge to the AI provider onboarding screen - Card shows connection status and one-click connect via CLI Co-Authored-By: Claude Opus 4.6 --- .../chat/no-llm-binding-empty-state.tsx | 115 +++++++++++++++++- .../src/web/components/chat/select-model.tsx | 25 ++-- 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx b/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx index 1a417d8c96..41caa245e1 100644 --- a/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx +++ b/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx @@ -1,6 +1,8 @@ -import { Suspense } from "react"; +import { Suspense, useState } from "react"; import { CpuChip01 } from "@untitledui/icons"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { Card } from "@deco/ui/components/card.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; import { ProviderCard, type AiProvider, @@ -9,7 +11,12 @@ import { useAiProviders, useAiProviderKeyList, } from "@/web/hooks/collections/use-llm"; +import { useAuthConfig } from "@/web/providers/auth-config-provider"; +import { useProjectContext } from "@decocms/mesh-sdk"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Loading01 } from "@untitledui/icons"; import { cn } from "@deco/ui/lib/utils.ts"; +import { toast } from "sonner"; function ProviderList() { const aiProviders = useAiProviders(); @@ -36,6 +43,102 @@ function ProviderList() { ); } +function ClaudeCodeCard() { + const { org } = useProjectContext(); + const [connecting, setConnecting] = useState(false); + + const { data: status, refetch } = useQuery({ + queryKey: ["connect-studio-status", org.slug], + queryFn: async () => { + const res = await fetch( + `/api/${org.slug}/decopilot/connect-studio/status`, + ); + if (!res.ok) return { "claude-code": false }; + return res.json() as Promise<{ "claude-code": boolean }>; + }, + }); + + const connected = status?.["claude-code"] ?? false; + + const handleConnect = async () => { + if (connected) return; + setConnecting(true); + try { + const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: "claude-code" }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed" })); + throw new Error(err.error ?? "Failed to connect"); + } + toast.success("Claude Code connected!"); + refetch(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to connect"); + } finally { + setConnecting(false); + } + }; + + return ( + + {connected && ( +
+ )} +
+
+ Claude Code +
+
+

Claude Code

+ + Local + +
+

+ {connecting + ? "Connecting..." + : connected + ? "Connected — available in chat" + : "Uses your local Claude Code CLI installation"} +

+
+
+ {connecting && ( + + )} + {connected && ( + + )} +
+ + ); +} + interface NoLlmBindingEmptyStateProps { title?: string; description?: string; @@ -45,6 +148,9 @@ export function NoLlmBindingEmptyState({ title = "Connect an AI provider", description = "Keys are stored encrypted in the vault.", }: NoLlmBindingEmptyStateProps = {}) { + const authConfig = useAuthConfig(); + const showClaudeCode = authConfig.claudeCodeAvailable; + return (
@@ -66,6 +172,13 @@ export function NoLlmBindingEmptyState({ > + {showClaudeCode && ( +
+ + + +
+ )}
); } diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index fe7db2b819..2ab8a45294 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -46,7 +46,6 @@ import { useAiProviderModels, useAiProviders, } from "../../hooks/collections/use-llm"; -import { useAllowedModels } from "../../hooks/use-allowed-models"; import { useAuthConfig } from "../../providers/auth-config-provider"; import { ErrorBoundary } from "../error-boundary"; import { useChat } from "./context"; @@ -106,12 +105,6 @@ export function isClaudeCodeModel( return model?.connectionId === CLAUDE_CODE_CONNECTION_ID; } -/** Get display name for a Claude Code model id */ -function claudeCodeDisplayName(modelId: string): string { - const m = CLAUDE_CODE_MODELS.find((m) => m.id === modelId); - return m ? `Claude Code ${m.title}` : "Claude Code"; -} - // ============================================================================ // Tier Classification // ============================================================================ @@ -1079,8 +1072,8 @@ function ModelSelectorInner({ setSelectedModel({ modelId, title: variant ? `Claude Code ${variant.title}` : "Claude Code", - providerId: "claude-code", - capabilities: ["text", "tools"], + providerId: "claude-code" as AiProviderModel["providerId"], + capabilities: ["text"], limits: variant?.limits ?? { contextWindow: 200_000, maxOutputTokens: 32_768, @@ -1208,25 +1201,21 @@ function ModelSelectorInner({ onClick={() => handleClaudeCodeSelect(variant.id)} onMouseEnter={() => setHoveredModel({ - id: variant.id, + modelId: variant.id, title: `Claude Code: ${variant.title}`, logo: null, description: variant.description, - capabilities: ["text", "tools"], + capabilities: ["text"], limits: variant.limits, costs: null, - provider: null, - created_at: "", - updated_at: "", - created_by: "", - updated_by: "", + providerId: "claude-code" as AiProviderModel["providerId"], }) } className={cn( "flex items-center gap-2 w-full min-h-8 py-2 px-4 text-left cursor-pointer", "hover:bg-accent", - selectedModel?.thinking?.id === variant.id && - isClaudeCodeModel(selectedModel) && + selectedModel?.modelId === variant.id && + (selectedModel?.providerId as string) === "claude-code" && "bg-accent/50", )} > From 2bd9341d14007487dcd8dbe1fe8b34a2f185bf4c Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 19:19:48 -0300 Subject: [PATCH 06/87] fix(chat): unify Claude Code into standard model provider flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove custom Claude Code UI from model selector and onboarding. Claude Code now uses the same ConnectionModelList → groupByTier → ModelTierSection pipeline as every other provider. Models are returned from factory.ts via a static list, classified into tiers via prefixes, and server detection uses thinking.provider instead of broken connectionId field. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/ai-providers/factory.ts | 39 ++++++ apps/mesh/src/ai-providers/registry.ts | 19 +++ apps/mesh/src/api/routes/decopilot/routes.ts | 2 +- .../chat/no-llm-binding-empty-state.tsx | 116 +----------------- .../src/web/components/chat/select-model.tsx | 106 +--------------- .../settings-modal/pages/org-ai-providers.tsx | 44 ++++++- apps/mesh/src/web/utils/ai-providers-logos.ts | 1 + packages/mesh-sdk/src/types/ai-providers.ts | 1 + 8 files changed, 105 insertions(+), 223 deletions(-) diff --git a/apps/mesh/src/ai-providers/factory.ts b/apps/mesh/src/ai-providers/factory.ts index 915f8c5059..3f9856c5fd 100644 --- a/apps/mesh/src/ai-providers/factory.ts +++ b/apps/mesh/src/ai-providers/factory.ts @@ -4,6 +4,40 @@ import type { ModelListCache } from "./model-list-cache"; import type { MeshProvider, ModelInfo, OpenRouterAPIModel } from "./types"; import { PROVIDERS } from "./registry"; +/** Static model list for the Claude Code local provider. */ +const CLAUDE_CODE_MODEL_LIST: ModelInfo[] = [ + { + providerId: "claude-code", + modelId: "claude-code:sonnet", + title: "Claude Code: Sonnet", + description: "Fast, capable model via local Claude Code CLI", + logo: null, + capabilities: ["text"] as ModelCapability[], + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + costs: null, + }, + { + providerId: "claude-code", + modelId: "claude-code:opus", + title: "Claude Code: Opus", + description: "Most capable model via local Claude Code CLI", + logo: null, + capabilities: ["text"] as ModelCapability[], + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + costs: null, + }, + { + providerId: "claude-code", + modelId: "claude-code:haiku", + title: "Claude Code: Haiku", + description: "Fastest, most affordable model via local Claude Code CLI", + logo: null, + capabilities: ["text"] as ModelCapability[], + limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + costs: null, + }, +]; + // Sentinel org ID for the shared OpenRouter metadata cache (not org-specific) const OR_INDEX_ORG_ID = "_global"; @@ -134,6 +168,11 @@ export class AIProviderFactory { ); const providerId = keyInfo.providerId; + // Claude Code uses the local CLI — return static model list + if (providerId === "claude-code") { + return CLAUDE_CODE_MODEL_LIST; + } + if (this.cache) { const cached = await this.cache.get(organizationId, providerId); if (cached) return cached; diff --git a/apps/mesh/src/ai-providers/registry.ts b/apps/mesh/src/ai-providers/registry.ts index 19958ca506..2b3f13eece 100644 --- a/apps/mesh/src/ai-providers/registry.ts +++ b/apps/mesh/src/ai-providers/registry.ts @@ -7,9 +7,28 @@ import { decoAiGatewayAdapter } from "./adapters/deco-ai-gateway"; const isDecoAiGatewayEnabled = !!process.env.DECO_AI_GATEWAY_ENABLED; +/** + * Claude Code uses the local CLI — no API key or SDK adapter needed. + * This placeholder satisfies the registry type; the actual chat path + * bypasses the adapter entirely via the isClaudeCode branch. + */ +const claudeCodeAdapter: ProviderAdapter = { + info: { + id: "claude-code", + name: "Claude Code", + description: "Local Claude Code CLI", + logo: "/logos/Claude Code.svg", + }, + supportedMethods: [], + create() { + throw new Error("Claude Code uses the local CLI, not an API adapter"); + }, +}; + export const PROVIDERS: Partial> = { ...(isDecoAiGatewayEnabled && { deco: decoAiGatewayAdapter }), anthropic: anthropicAdapter, google: googleAdapter, openrouter: openrouterAdapter, + "claude-code": claudeCodeAdapter, }; diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 0779892e2d..f5cad3cb5d 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -125,7 +125,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { throw new HTTPException(401, { message: "User ID is required" }); } - const isClaudeCode = models.connectionId === "claude-code"; + const isClaudeCode = models.thinking.provider === "claude-code"; // 2. Check model permissions (skip for Claude Code — uses local auth) if (!isClaudeCode) { diff --git a/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx b/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx index 41caa245e1..02c1ba5fd9 100644 --- a/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx +++ b/apps/mesh/src/web/components/chat/no-llm-binding-empty-state.tsx @@ -1,8 +1,6 @@ -import { Suspense, useState } from "react"; +import { Suspense } from "react"; import { CpuChip01 } from "@untitledui/icons"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; -import { Card } from "@deco/ui/components/card.tsx"; -import { Badge } from "@deco/ui/components/badge.tsx"; import { ProviderCard, type AiProvider, @@ -11,12 +9,6 @@ import { useAiProviders, useAiProviderKeyList, } from "@/web/hooks/collections/use-llm"; -import { useAuthConfig } from "@/web/providers/auth-config-provider"; -import { useProjectContext } from "@decocms/mesh-sdk"; -import { useQuery } from "@tanstack/react-query"; -import { Check, Loading01 } from "@untitledui/icons"; -import { cn } from "@deco/ui/lib/utils.ts"; -import { toast } from "sonner"; function ProviderList() { const aiProviders = useAiProviders(); @@ -43,102 +35,6 @@ function ProviderList() { ); } -function ClaudeCodeCard() { - const { org } = useProjectContext(); - const [connecting, setConnecting] = useState(false); - - const { data: status, refetch } = useQuery({ - queryKey: ["connect-studio-status", org.slug], - queryFn: async () => { - const res = await fetch( - `/api/${org.slug}/decopilot/connect-studio/status`, - ); - if (!res.ok) return { "claude-code": false }; - return res.json() as Promise<{ "claude-code": boolean }>; - }, - }); - - const connected = status?.["claude-code"] ?? false; - - const handleConnect = async () => { - if (connected) return; - setConnecting(true); - try { - const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: "claude-code" }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Failed" })); - throw new Error(err.error ?? "Failed to connect"); - } - toast.success("Claude Code connected!"); - refetch(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to connect"); - } finally { - setConnecting(false); - } - }; - - return ( - - {connected && ( -
- )} -
-
- Claude Code -
-
-

Claude Code

- - Local - -
-

- {connecting - ? "Connecting..." - : connected - ? "Connected — available in chat" - : "Uses your local Claude Code CLI installation"} -

-
-
- {connecting && ( - - )} - {connected && ( - - )} -
- - ); -} - interface NoLlmBindingEmptyStateProps { title?: string; description?: string; @@ -148,9 +44,6 @@ export function NoLlmBindingEmptyState({ title = "Connect an AI provider", description = "Keys are stored encrypted in the vault.", }: NoLlmBindingEmptyStateProps = {}) { - const authConfig = useAuthConfig(); - const showClaudeCode = authConfig.claudeCodeAvailable; - return (
@@ -172,13 +65,6 @@ export function NoLlmBindingEmptyState({ > - {showClaudeCode && ( -
- - - -
- )}
); } diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 2ab8a45294..7d839366db 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -28,7 +28,6 @@ import { SearchMd, Settings01, Stars01, - TerminalSquare, Tool01, } from "@untitledui/icons"; import { @@ -46,7 +45,6 @@ import { useAiProviderModels, useAiProviders, } from "../../hooks/collections/use-llm"; -import { useAuthConfig } from "../../providers/auth-config-provider"; import { ErrorBoundary } from "../error-boundary"; import { useChat } from "./context"; import { getProviderLogo } from "@/web/utils/ai-providers-logos"; @@ -68,43 +66,6 @@ function parseModelTitle(model: { title: string; modelId: string }): { }; } -// ============================================================================ -// Claude Code Constants -// ============================================================================ - -export const CLAUDE_CODE_CONNECTION_ID = "claude-code"; - -/** Claude Code model variants available in the selector */ -const CLAUDE_CODE_MODELS = [ - { - id: "claude-code:opus", - title: "Opus", - description: "Most capable", - tier: "smarter" as const, - limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, - }, - { - id: "claude-code:sonnet", - title: "Sonnet", - description: "Fast & capable", - tier: "faster" as const, - limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, - }, - { - id: "claude-code:haiku", - title: "Haiku", - description: "Fastest", - tier: "cheaper" as const, - limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, - }, -]; - -export function isClaudeCodeModel( - model: { connectionId?: string } | null | undefined, -): boolean { - return model?.connectionId === CLAUDE_CODE_CONNECTION_ID; -} - // ============================================================================ // Tier Classification // ============================================================================ @@ -122,6 +83,7 @@ const TIER_PATTERNS: Array<{ tier: TierId; prefixes: string[] }> = [ { tier: "smarter", prefixes: [ + "claude-code:opus", "anthropic/claude-4.6-opus", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", @@ -136,6 +98,7 @@ const TIER_PATTERNS: Array<{ tier: TierId; prefixes: string[] }> = [ { tier: "faster", prefixes: [ + "claude-code:sonnet", "anthropic/claude-haiku-4.5", "anthropic/claude-4.5-haiku", "google/gemini-3-flash", @@ -151,6 +114,7 @@ const TIER_PATTERNS: Array<{ tier: TierId; prefixes: string[] }> = [ { tier: "cheaper", prefixes: [ + "claude-code:haiku", "google/gemini-2.5-flash-lite", "google/gemini-2.5-flash", "google/gemini-2.0-flash", @@ -1059,31 +1023,11 @@ function ModelSelectorInner({ ); const { open: openSettings } = useSettingsModal(); - const authConfig = useAuthConfig(); - const showClaudeCode = authConfig.claudeCodeAvailable; - const handleKeyChange = (keyId: string) => { onCredentialChange(keyId); setHoveredModel(null); }; - const handleClaudeCodeSelect = (modelId: string) => { - const variant = CLAUDE_CODE_MODELS.find((m) => m.id === modelId); - setSelectedModel({ - modelId, - title: variant ? `Claude Code ${variant.title}` : "Claude Code", - providerId: "claude-code" as AiProviderModel["providerId"], - capabilities: ["text"], - limits: variant?.limits ?? { - contextWindow: 200_000, - maxOutputTokens: 32_768, - }, - keyId: "claude-code", - } as AiProviderModel); - setSearchTerm(""); - onClose(); - }; - const handleModelSelect = (model: AiProviderModel) => { if (!credentialId) return; onModelChange(model); @@ -1183,50 +1127,6 @@ function ModelSelectorInner({
- {showClaudeCode && ( -
-
- - - Claude Code - - - Local - -
- {CLAUDE_CODE_MODELS.map((variant) => ( - - ))} -
- )} ( diff --git a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx index afc09cdd79..0746375167 100644 --- a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx +++ b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx @@ -495,12 +495,42 @@ export function ProviderCard({ }; }, [isOAuthPending, oauthStateToken, exchangeOAuth]); + const isClaudeCode = provider.id === "claude-code"; const supportsOAuth = provider.supportedMethods.includes("oauth-pkce"); const supportsApiKey = provider.supportedMethods.includes("api-key"); + const [isClaudeCodePending, setIsClaudeCodePending] = useState(false); + + const handleConnectClaudeCode = async () => { + if (isActive || isClaudeCodePending) return; + setIsClaudeCodePending(true); + try { + await client.callTool({ + name: "AI_PROVIDER_KEY_CREATE", + arguments: { + providerId: "claude-code", + label: "Local CLI", + apiKey: "local", + }, + }); + queryClient.invalidateQueries({ + queryKey: KEYS.aiProviderKeys(locator), + }); + queryClient.invalidateQueries({ queryKey: KEYS.aiProviders(locator) }); + toast.success("Claude Code connected!"); + } catch (err) { + toast.error( + `Failed to connect: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + setIsClaudeCodePending(false); + } + }; const handleCardClick = () => { if (isConnectFormOpen || isOAuthPending) return; - if (supportsOAuth) { + if (isClaudeCode) { + handleConnectClaudeCode(); + } else if (supportsOAuth) { handleConnectOAuth(); } else if (supportsApiKey) { setIsConnectFormOpen(true); @@ -548,8 +578,10 @@ export function ProviderCard({ className={cn( "p-4 flex flex-col gap-3 transition-colors relative", isActive && "border-primary/20", - !isOAuthPending && "cursor-pointer hover:bg-muted/30", - isOAuthPending && "cursor-wait", + !isOAuthPending && + !isClaudeCodePending && + "cursor-pointer hover:bg-muted/30", + (isOAuthPending || isClaudeCodePending) && "cursor-wait", )} onClick={handleCardClick} > @@ -574,7 +606,11 @@ export function ProviderCard({

{provider.name}

- {isOAuthPending ? "Authorizing..." : provider.description} + {isClaudeCodePending + ? "Connecting..." + : isOAuthPending + ? "Authorizing..." + : provider.description}

diff --git a/apps/mesh/src/web/utils/ai-providers-logos.ts b/apps/mesh/src/web/utils/ai-providers-logos.ts index fe8beb4e9f..5c71a017f2 100644 --- a/apps/mesh/src/web/utils/ai-providers-logos.ts +++ b/apps/mesh/src/web/utils/ai-providers-logos.ts @@ -38,6 +38,7 @@ export const PROVIDER_LOGOS: Record = { amazon: "https://assets.decocache.com/decocms/31e7b260-6cf0-4753-bb32-bd062b15c5f1/Amazon_icon.png", anthropic: ANTHROPIC_ICON_URL, + "claude-code": ANTHROPIC_ICON_URL, "anthracite-org": DEFAULT_LOGO, "arcee-ai": "https://assets.decocache.com/decocms/ee325839-6acc-48dc-8cf7-8bab74698015/126496414.png", diff --git a/packages/mesh-sdk/src/types/ai-providers.ts b/packages/mesh-sdk/src/types/ai-providers.ts index 583cd0ce91..53a3e688f2 100644 --- a/packages/mesh-sdk/src/types/ai-providers.ts +++ b/packages/mesh-sdk/src/types/ai-providers.ts @@ -7,6 +7,7 @@ export const PROVIDER_IDS = [ "anthropic", "openrouter", "google", + "claude-code", ] as const; export type ProviderId = (typeof PROVIDER_IDS)[number]; From 6fe7feefd04c7fa3a64b2292c6295bff36213f9f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 19:28:31 -0300 Subject: [PATCH 07/87] fix(chat): rewrite Connect Studio modal with auth info and disconnect Remove Cursor support (not working), show Claude Code auth info (email, org, subscription) when connected, add disconnect button, and fix refresh icon positioning. Add timeouts to all CLI spawns. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/decopilot/routes.ts | 196 +++++++++----- .../web/components/connect-studio-modal.tsx | 250 ++++++++++-------- 2 files changed, 259 insertions(+), 187 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index f5cad3cb5d..640d83b022 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -208,29 +208,68 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { const { spawn } = await import("node:child_process"); - // Check Claude Code: `claude mcp get mesh-studio` exits 0 if configured - const claudeCode = await new Promise((resolve) => { + // Check if mesh-studio MCP server is configured in Claude Code + const connected = await new Promise((resolve) => { const proc = spawn("claude", ["mcp", "get", "mesh-studio"], { stdio: "ignore", }); - proc.on("close", (code) => resolve(code === 0)); - proc.on("error", () => resolve(false)); + const timeout = setTimeout(() => { + proc.kill(); + resolve(false); + }, 5000); + proc.on("close", (code) => { + clearTimeout(timeout); + resolve(code === 0); + }); + proc.on("error", () => { + clearTimeout(timeout); + resolve(false); + }); }); - // Check Cursor: read ~/.cursor/mcp.json - let cursor = false; - try { - const os = await import("node:os"); - const path = await import("node:path"); - const fs = await import("node:fs"); - const configPath = path.join(os.homedir(), ".cursor", "mcp.json"); - const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); - cursor = !!config?.mcpServers?.["mesh-studio"]; - } catch { - // File doesn't exist or parse error + // Get Claude Code auth info (email, org, subscription) + let auth: { + email?: string; + orgName?: string; + subscriptionType?: string; + } | null = null; + if (connected) { + try { + const authInfo = await new Promise((resolve, reject) => { + const proc = spawn("claude", ["auth", "status"], { + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error("timeout")); + }, 5000); + proc.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + proc.on("close", () => { + clearTimeout(timeout); + resolve(stdout); + }); + proc.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + const parsed = JSON.parse(authInfo); + if (parsed.loggedIn) { + auth = { + email: parsed.email, + orgName: parsed.orgName, + subscriptionType: parsed.subscriptionType, + }; + } + } catch { + // Auth info not available + } } - return c.json({ "claude-code": claudeCode, cursor }); + return c.json({ connected, auth }); }); app.post("/:org/decopilot/connect-studio", async (c) => { @@ -241,21 +280,13 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { throw new HTTPException(401, { message: "Authentication required" }); } - const body = await c.req.json<{ target: "claude-code" | "cursor" }>(); - const target = body.target; - if (target !== "claude-code" && target !== "cursor") { - throw new HTTPException(400, { - message: "target must be 'claude-code' or 'cursor'", - }); - } - // Create API key for the MCP endpoint const apiKey = await ctx.boundAuth.apiKey.create({ - name: `studio-connect-${target}`, + name: "studio-connect-claude-code", permissions: { "*": ["*"] }, metadata: { internal: true, - target, + target: "claude-code", organization: organization.id, }, }); @@ -268,61 +299,80 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { headers: { Authorization: `Bearer ${apiKey.key}`, "x-org-id": organization.id, - "x-mesh-client": target === "claude-code" ? "Claude Code" : "Cursor", + "x-mesh-client": "Claude Code", }, }); const { spawn } = await import("node:child_process"); - if (target === "claude-code") { - // Use `claude mcp add-json` CLI to register globally - const result = await new Promise<{ ok: boolean; stderr: string }>( - (resolve) => { - const proc = spawn( - "claude", - ["mcp", "add-json", "mesh-studio", mcpConfig, "--scope", "user"], - { stdio: ["ignore", "ignore", "pipe"] }, - ); - let stderr = ""; - proc.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - proc.on("close", (code) => resolve({ ok: code === 0, stderr })); - proc.on("error", (err) => - resolve({ ok: false, stderr: err.message }), - ); - }, - ); - if (!result.ok) { - throw new HTTPException(500, { - message: `claude mcp add-json failed: ${result.stderr}`, + const result = await new Promise<{ ok: boolean; stderr: string }>( + (resolve) => { + const proc = spawn( + "claude", + ["mcp", "add-json", "mesh-studio", mcpConfig, "--scope", "user"], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + let stderr = ""; + const timeout = setTimeout(() => { + proc.kill(); + resolve({ ok: false, stderr: "timeout" }); + }, 10000); + proc.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); }); - } - } else { - // Cursor has no CLI — write directly to ~/.cursor/mcp.json - const os = await import("node:os"); - const fs = await import("node:fs"); - const path = await import("node:path"); - const cursorDir = path.join(os.homedir(), ".cursor"); - if (!fs.existsSync(cursorDir)) { - fs.mkdirSync(cursorDir, { recursive: true }); - } - const configPath = path.join(cursorDir, "mcp.json"); - let config: Record = {}; - try { - config = JSON.parse(fs.readFileSync(configPath, "utf-8")); - } catch { - // File doesn't exist yet - } - if (!config.mcpServers || typeof config.mcpServers !== "object") { - config.mcpServers = {}; - } - (config.mcpServers as Record)["mesh-studio"] = - JSON.parse(mcpConfig); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); + proc.on("close", (code) => { + clearTimeout(timeout); + resolve({ ok: code === 0, stderr }); + }); + proc.on("error", (err) => { + clearTimeout(timeout); + resolve({ ok: false, stderr: err.message }); + }); + }, + ); + if (!result.ok) { + throw new HTTPException(500, { + message: `Failed to register MCP server`, + }); + } + + return c.json({ success: true }); + }); + + app.delete("/:org/decopilot/connect-studio", async (c) => { + const ctx = c.get("meshContext"); + if (!ctx.auth?.user?.id) { + throw new HTTPException(401, { message: "Authentication required" }); + } + + const { spawn } = await import("node:child_process"); + + const result = await new Promise<{ ok: boolean }>((resolve) => { + const proc = spawn( + "claude", + ["mcp", "remove", "mesh-studio", "--scope", "user"], + { stdio: "ignore" }, + ); + const timeout = setTimeout(() => { + proc.kill(); + resolve({ ok: false }); + }, 5000); + proc.on("close", (code) => { + clearTimeout(timeout); + resolve({ ok: code === 0 }); + }); + proc.on("error", () => { + clearTimeout(timeout); + resolve({ ok: false }); + }); + }); + if (!result.ok) { + throw new HTTPException(500, { + message: "Failed to remove MCP server", + }); } - return c.json({ success: true, target }); + return c.json({ success: true }); }); // ============================================================================ diff --git a/apps/mesh/src/web/components/connect-studio-modal.tsx b/apps/mesh/src/web/components/connect-studio-modal.tsx index dad5ca5616..fe8aeab1b8 100644 --- a/apps/mesh/src/web/components/connect-studio-modal.tsx +++ b/apps/mesh/src/web/components/connect-studio-modal.tsx @@ -7,17 +7,23 @@ import { DialogTitle, } from "@deco/ui/components/dialog.tsx"; import { useProjectContext } from "@decocms/mesh-sdk"; -import { Check, Loading01, RefreshCw01 } from "@untitledui/icons"; +import { Check, LinkBroken02, Loading01, RefreshCw01 } from "@untitledui/icons"; import { useState } from "react"; import { toast } from "sonner"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { cn } from "@deco/ui/lib/utils.ts"; -type Target = "claude-code" | "cursor"; -type Status = Record; +interface ConnectStatus { + connected: boolean; + auth: { + email?: string; + orgName?: string; + subscriptionType?: string; + } | null; +} function useConnectStatus(org: { slug: string }) { - return useQuery({ + return useQuery({ queryKey: ["connect-studio-status", org.slug], queryFn: async () => { const res = await fetch( @@ -29,101 +35,70 @@ function useConnectStatus(org: { slug: string }) { }); } -function ConnectButton({ - target, - label, - logo, - logoStyle, - connected, - statusLoading, +export function ConnectStudioModal({ + open, + onOpenChange, }: { - target: Target; - label: string; - logo: string; - logoStyle?: React.CSSProperties; - connected: boolean; - statusLoading: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; }) { const { org } = useProjectContext(); + const { + data: status, + isLoading, + refetch, + isRefetching, + } = useConnectStatus(org); const queryClient = useQueryClient(); - const [loading, setLoading] = useState(false); + const [connecting, setConnecting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); const handleConnect = async () => { - setLoading(true); + setConnecting(true); try { const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target }), + body: JSON.stringify({ target: "claude-code" }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Failed" })); throw new Error(err.error ?? "Failed to connect"); } - toast.success(`Connected to ${label}!`); + toast.success("Connected to Claude Code!"); queryClient.invalidateQueries({ queryKey: ["connect-studio-status", org.slug], }); } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to connect studio", - ); + toast.error(err instanceof Error ? err.message : "Failed to connect"); } finally { - setLoading(false); + setConnecting(false); } }; - return ( - - ); -} + const handleDisconnect = async () => { + setDisconnecting(true); + try { + const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { + method: "DELETE", + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed" })); + throw new Error(err.error ?? "Failed to disconnect"); + } + toast.success("Disconnected from Claude Code"); + queryClient.invalidateQueries({ + queryKey: ["connect-studio-status", org.slug], + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to disconnect"); + } finally { + setDisconnecting(false); + } + }; -export function ConnectStudioModal({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const { org } = useProjectContext(); - const { - data: status, - isLoading, - refetch, - isRefetching, - } = useConnectStatus(org); - const queryClient = useQueryClient(); + const connected = status?.connected ?? false; + const auth = status?.auth; return ( -
- Connect Studio - -
+ Connect Studio - One-click install all your studio tools into your IDE. + Install all your studio tools into Claude Code.
-
- - +
+
+ Claude Code +
+
+ Claude Code + {connected && ( + + + Connected + + )} +
+ {auth && ( +

+ {auth.email} + {auth.orgName ? ` — ${auth.orgName}` : ""} + {auth.subscriptionType ? ` (${auth.subscriptionType})` : ""} +

+ )} +
+ {isLoading && ( + + )} +
+ +
+ {!connected ? ( + + ) : ( + <> + + + + )} +
From 84e38f39ecebbf4afce121bb3c3aaa7dda114822 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 19:30:12 -0300 Subject: [PATCH 08/87] fix: remove unused export on CLAUDE_CODE_MODELS to fix knip Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/decopilot/claude-code-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 1d7a802a81..62db614960 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -62,7 +62,7 @@ function extractSystemPrompt(messages: ChatMessage[]): string { } /** Claude Code model variants that can be selected in the UI */ -export const CLAUDE_CODE_MODELS = [ +const CLAUDE_CODE_MODELS = [ { id: "claude-code:opus", sdkModel: "claude-opus-4-6", From fea0d46d58fd5f9eb4b736e9e13f43489e0e8c7f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 19:38:09 -0300 Subject: [PATCH 09/87] fix(chat): stream thinking and reasoning from Claude Code Handle thinking_delta events from the Claude Agent SDK to stream reasoning content in real-time. Track content block types to route thinking vs text deltas correctly. Prevent duplicate text from assistant message fallback when stream events are available. Co-Authored-By: Claude Opus 4.6 --- .../routes/decopilot/claude-code-provider.ts | 118 ++++++++++++++---- 1 file changed, 97 insertions(+), 21 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 62db614960..5f70178e82 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -178,13 +178,26 @@ export async function streamClaudeCode( }, }); - // Start a step + text part (AI SDK expects step markers for status tracking) writer.write({ type: "start-step" }); - writer.write({ type: "text-start", id: textPartId }); + + // Track content block types by index so we route deltas correctly + const blockTypes = new Map(); + let reasoningPartId: string | null = null; + let textStarted = false; + // Track which content we've already streamed via stream_event so we + // don't duplicate it when the assistant message arrives. + let streamedText = false; let totalCostUsd = 0; let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + const ensureTextStarted = () => { + if (!textStarted) { + writer.write({ type: "text-start", id: textPartId }); + textStarted = true; + } + }; + try { for await (const message of conversation) { if (abortController.signal.aborted) break; @@ -194,15 +207,48 @@ export async function streamClaudeCode( // Only handle main thread events (no subagent) if (message.parent_tool_use_id) break; - const event = message.event; + const event = message.event as { + type: string; + index?: number; + content_block?: { type: string; name?: string; id?: string }; + delta?: { + type: string; + text?: string; + thinking?: string; + partial_json?: string; + }; + }; + + // Track block types so we know how to route deltas + if (event.type === "content_block_start" && event.content_block) { + const idx = event.index ?? 0; + blockTypes.set(idx, event.content_block.type); - if ( - event.type === "content_block_delta" && - "delta" in event && - event.delta - ) { - const delta = event.delta as { type: string; text?: string }; - if (delta.type === "text_delta" && delta.text) { + if (event.content_block.type === "thinking") { + reasoningPartId = generateMessageId(); + writer.write({ + type: "reasoning-start", + id: reasoningPartId, + }); + } + } + + if (event.type === "content_block_delta" && event.delta) { + const delta = event.delta; + + if ( + delta.type === "thinking_delta" && + delta.thinking && + reasoningPartId + ) { + writer.write({ + type: "reasoning-delta", + delta: delta.thinking, + id: reasoningPartId, + }); + } else if (delta.type === "text_delta" && delta.text) { + ensureTextStarted(); + streamedText = true; writer.write({ type: "text-delta", delta: delta.text, @@ -210,13 +256,26 @@ export async function streamClaudeCode( }); } } + + if (event.type === "content_block_stop") { + const idx = event.index ?? 0; + if (blockTypes.get(idx) === "thinking" && reasoningPartId) { + writer.write({ type: "reasoning-end", id: reasoningPartId }); + reasoningPartId = null; + } + } break; } case "result": { if (message.subtype === "success") { - totalCostUsd = message.total_cost_usd ?? 0; - const u = message.usage; + totalCostUsd = + (message as { total_cost_usd?: number }).total_cost_usd ?? 0; + const u = ( + message as { + usage?: { input_tokens?: number; output_tokens?: number }; + } + ).usage; if (u) { usage = { inputTokens: u.input_tokens ?? 0, @@ -225,9 +284,9 @@ export async function streamClaudeCode( }; } } else { - // Error result const errors = (message as { errors?: string[] }).errors ?? []; if (errors.length > 0) { + ensureTextStarted(); writer.write({ type: "error", errorText: errors.join("; "), @@ -239,10 +298,13 @@ export async function streamClaudeCode( case "assistant": { // Only handle main thread messages (no subagent) - if (message.parent_tool_use_id) break; + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } // Handle errors - if (message.error) { + if ((message as { error?: string }).error) { + const errorCode = (message as { error: string }).error; const errorMessages: Record = { authentication_failed: "Claude Code is not authenticated. Run `claude login` in your terminal.", @@ -250,22 +312,29 @@ export async function streamClaudeCode( "Claude Code billing error. Check your subscription.", rate_limit: "Claude Code rate limited. Please try again shortly.", }; + ensureTextStarted(); writer.write({ type: "error", errorText: - errorMessages[message.error] ?? - `Claude Code error: ${message.error}`, + errorMessages[errorCode] ?? `Claude Code error: ${errorCode}`, }); break; } - // Extract text content from the full assistant message + // If we already streamed via stream_event deltas, skip the full + // assistant message to avoid duplicate text. + if (streamedText) break; + + // Fallback: extract text content from the full assistant message const content = ( - message.message as { content?: { type: string; text?: string }[] } - )?.content; + message as { + message?: { content?: { type: string; text?: string }[] }; + } + )?.message?.content; if (Array.isArray(content)) { for (const block of content) { if (block.type === "text" && block.text) { + ensureTextStarted(); writer.write({ type: "text-delta", delta: block.text, @@ -280,6 +349,7 @@ export async function streamClaudeCode( } } catch (err) { console.error("[claude-code] Stream error:", err); + ensureTextStarted(); writer.write({ type: "error", errorText: @@ -287,7 +357,13 @@ export async function streamClaudeCode( }); } - // End the text part + step + // Close any open reasoning block + if (reasoningPartId) { + writer.write({ type: "reasoning-end", id: reasoningPartId }); + } + + // Ensure text part is opened before closing it + ensureTextStarted(); writer.write({ type: "text-end", id: textPartId }); writer.write({ type: "finish-step" }); From a903f496011bef7749d9222d84e616cb7e14be26 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 19:38:44 -0300 Subject: [PATCH 10/87] fix(chat): rename MCP server from mesh-studio to deco-studio Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/decopilot/routes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 640d83b022..2a5855a160 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -208,9 +208,9 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { const { spawn } = await import("node:child_process"); - // Check if mesh-studio MCP server is configured in Claude Code + // Check if deco-studio MCP server is configured in Claude Code const connected = await new Promise((resolve) => { - const proc = spawn("claude", ["mcp", "get", "mesh-studio"], { + const proc = spawn("claude", ["mcp", "get", "deco-studio"], { stdio: "ignore", }); const timeout = setTimeout(() => { @@ -309,7 +309,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { (resolve) => { const proc = spawn( "claude", - ["mcp", "add-json", "mesh-studio", mcpConfig, "--scope", "user"], + ["mcp", "add-json", "deco-studio", mcpConfig, "--scope", "user"], { stdio: ["ignore", "ignore", "pipe"] }, ); let stderr = ""; @@ -350,7 +350,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { const result = await new Promise<{ ok: boolean }>((resolve) => { const proc = spawn( "claude", - ["mcp", "remove", "mesh-studio", "--scope", "user"], + ["mcp", "remove", "deco-studio", "--scope", "user"], { stdio: "ignore" }, ); const timeout = setTimeout(() => { From 322c7ed34cc840c9a7cf83eedd1eef97d9f721d1 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 19:48:12 -0300 Subject: [PATCH 11/87] feat(chat): per-connection cards in Connect Studio + GitHub support Refactor Connect Studio modal to show each connection as a self-contained card with its own connect/disconnect buttons. Add GitHub connection that uses the local `gh` CLI token to register GitHub's MCP in Claude Code. Also enable `includePartialMessages` in the Claude Agent SDK to get real-time thinking/text streaming events. Co-Authored-By: Claude Opus 4.6 --- .../routes/decopilot/claude-code-provider.ts | 78 ++++- apps/mesh/src/api/routes/decopilot/routes.ts | 278 +++++++++-------- .../web/components/connect-studio-modal.tsx | 283 ++++++++++-------- 3 files changed, 385 insertions(+), 254 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 5f70178e82..f38c15c061 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -129,6 +129,8 @@ export async function streamClaudeCode( systemPrompt: systemPrompt || undefined, permissionMode: "bypassPermissions" as const, allowDangerouslySkipPermissions: true, + // Enable streaming events so we get thinking_delta + text_delta in real-time + includePartialMessages: true, tools: [], }; @@ -202,6 +204,16 @@ export async function streamClaudeCode( for await (const message of conversation) { if (abortController.signal.aborted) break; + const msg = message as Record; + console.log( + "[claude-code] SDK message:", + msg.type, + msg.subtype ?? "", + msg.type === "stream_event" + ? (msg.event as { type?: string })?.type + : "", + ); + switch (message.type) { case "stream_event": { // Only handle main thread events (no subagent) @@ -267,6 +279,26 @@ export async function streamClaudeCode( break; } + // Tool use summary — emit as reasoning so user sees tool activity + case "tool_use_summary": { + if ((msg as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } + const toolName = (msg as { tool_name?: string }).tool_name ?? "tool"; + + // Show tool activity as reasoning + if (!reasoningPartId) { + reasoningPartId = generateMessageId(); + writer.write({ type: "reasoning-start", id: reasoningPartId }); + } + writer.write({ + type: "reasoning-delta", + delta: `\nUsing tool: ${toolName}\n`, + id: reasoningPartId, + }); + break; + } + case "result": { if (message.subtype === "success") { totalCostUsd = @@ -321,26 +353,42 @@ export async function streamClaudeCode( break; } - // If we already streamed via stream_event deltas, skip the full - // assistant message to avoid duplicate text. - if (streamedText) break; - - // Fallback: extract text content from the full assistant message + // Extract content from the full assistant message const content = ( message as { - message?: { content?: { type: string; text?: string }[] }; + message?: { + content?: { + type: string; + text?: string; + thinking?: string; + }[]; + }; } )?.message?.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - ensureTextStarted(); - writer.write({ - type: "text-delta", - delta: block.text, - id: textPartId, - }); + if (!Array.isArray(content)) break; + + for (const block of content) { + // Stream thinking content as reasoning + if (block.type === "thinking" && block.thinking) { + if (!reasoningPartId) { + reasoningPartId = generateMessageId(); + writer.write({ type: "reasoning-start", id: reasoningPartId }); } + writer.write({ + type: "reasoning-delta", + delta: block.thinking, + id: reasoningPartId, + }); + } + + // Stream text content (skip if already streamed via stream_event) + if (block.type === "text" && block.text && !streamedText) { + ensureTextStarted(); + writer.write({ + type: "text-delta", + delta: block.text, + id: textPartId, + }); } } break; diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 2a5855a160..04b7d818bc 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -197,66 +197,54 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { }); // ============================================================================ - // Connect Studio — check + register MCP server in Claude Code / Cursor + // Connect Studio — check + register MCP servers in Claude Code // ============================================================================ - app.get("/:org/decopilot/connect-studio/status", async (c) => { - const ctx = c.get("meshContext"); - if (!ctx.auth?.user?.id) { - throw new HTTPException(401, { message: "Authentication required" }); - } - + // Helper: run a CLI command and return { ok, stdout, stderr } + async function runCli( + cmd: string, + args: string[], + timeoutMs = 5000, + ): Promise<{ ok: boolean; stdout: string; stderr: string }> { const { spawn } = await import("node:child_process"); - - // Check if deco-studio MCP server is configured in Claude Code - const connected = await new Promise((resolve) => { - const proc = spawn("claude", ["mcp", "get", "deco-studio"], { - stdio: "ignore", + return new Promise((resolve) => { + const proc = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], }); - const timeout = setTimeout(() => { + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { proc.kill(); - resolve(false); - }, 5000); + resolve({ ok: false, stdout, stderr: "timeout" }); + }, timeoutMs); + proc.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + proc.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); proc.on("close", (code) => { - clearTimeout(timeout); - resolve(code === 0); + clearTimeout(timer); + resolve({ ok: code === 0, stdout, stderr }); }); - proc.on("error", () => { - clearTimeout(timeout); - resolve(false); + proc.on("error", (err) => { + clearTimeout(timer); + resolve({ ok: false, stdout, stderr: err.message }); }); }); + } - // Get Claude Code auth info (email, org, subscription) - let auth: { - email?: string; - orgName?: string; - subscriptionType?: string; - } | null = null; + async function getClaudeStatus() { + const { ok: connected } = await runCli("claude", [ + "mcp", + "get", + "deco-studio", + ]); + let auth: Record | null = null; if (connected) { try { - const authInfo = await new Promise((resolve, reject) => { - const proc = spawn("claude", ["auth", "status"], { - stdio: ["ignore", "pipe", "ignore"], - }); - let stdout = ""; - const timeout = setTimeout(() => { - proc.kill(); - reject(new Error("timeout")); - }, 5000); - proc.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - proc.on("close", () => { - clearTimeout(timeout); - resolve(stdout); - }); - proc.on("error", (err) => { - clearTimeout(timeout); - reject(err); - }); - }); - const parsed = JSON.parse(authInfo); + const { stdout } = await runCli("claude", ["auth", "status"]); + const parsed = JSON.parse(stdout); if (parsed.loggedIn) { auth = { email: parsed.email, @@ -268,8 +256,44 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { // Auth info not available } } + return { connected, auth }; + } - return c.json({ connected, auth }); + async function getGithubStatus() { + const { ok, stdout } = await runCli("gh", ["auth", "status", "--json"]); + if (!ok) return { connected: false, auth: null }; + try { + const parsed = JSON.parse(stdout); + // Also check if the MCP is registered in Claude Code + const { ok: mcpRegistered } = await runCli("claude", [ + "mcp", + "get", + "github", + ]); + return { + connected: mcpRegistered, + auth: { + user: parsed.user ?? parsed.login, + host: parsed.host ?? "github.com", + }, + }; + } catch { + return { connected: false, auth: null }; + } + } + + app.get("/:org/decopilot/connect-studio/status", async (c) => { + const ctx = c.get("meshContext"); + if (!ctx.auth?.user?.id) { + throw new HTTPException(401, { message: "Authentication required" }); + } + + const [claude, github] = await Promise.all([ + getClaudeStatus(), + getGithubStatus(), + ]); + + return c.json({ claude, github }); }); app.post("/:org/decopilot/connect-studio", async (c) => { @@ -280,63 +304,78 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { throw new HTTPException(401, { message: "Authentication required" }); } - // Create API key for the MCP endpoint - const apiKey = await ctx.boundAuth.apiKey.create({ - name: "studio-connect-claude-code", - permissions: { "*": ["*"] }, - metadata: { - internal: true, - target: "claude-code", - organization: organization.id, - }, - }); - - const serverPort = process.env.PORT || "3000"; - const origin = `http://localhost:${serverPort}`; - const mcpConfig = JSON.stringify({ - type: "http", - url: `${origin}/mcp`, - headers: { - Authorization: `Bearer ${apiKey.key}`, - "x-org-id": organization.id, - "x-mesh-client": "Claude Code", - }, - }); + const body = await c.req.json().catch(() => ({})); + const target = (body as { target?: string }).target; + + if (target === "claude-code") { + // Create API key for the MCP endpoint + const apiKey = await ctx.boundAuth.apiKey.create({ + name: "studio-connect-claude-code", + permissions: { "*": ["*"] }, + metadata: { + internal: true, + target: "claude-code", + organization: organization.id, + }, + }); - const { spawn } = await import("node:child_process"); + const serverPort = process.env.PORT || "3000"; + const origin = `http://localhost:${serverPort}`; + const mcpConfig = JSON.stringify({ + type: "http", + url: `${origin}/mcp`, + headers: { + Authorization: `Bearer ${apiKey.key}`, + "x-org-id": organization.id, + "x-mesh-client": "Claude Code", + }, + }); - const result = await new Promise<{ ok: boolean; stderr: string }>( - (resolve) => { - const proc = spawn( - "claude", - ["mcp", "add-json", "deco-studio", mcpConfig, "--scope", "user"], - { stdio: ["ignore", "ignore", "pipe"] }, - ); - let stderr = ""; - const timeout = setTimeout(() => { - proc.kill(); - resolve({ ok: false, stderr: "timeout" }); - }, 10000); - proc.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - proc.on("close", (code) => { - clearTimeout(timeout); - resolve({ ok: code === 0, stderr }); + const result = await runCli( + "claude", + ["mcp", "add-json", "deco-studio", mcpConfig, "--scope", "user"], + 10000, + ); + if (!result.ok) { + throw new HTTPException(500, { + message: "Failed to register deco-studio MCP", }); - proc.on("error", (err) => { - clearTimeout(timeout); - resolve({ ok: false, stderr: err.message }); + } + return c.json({ success: true }); + } + + if (target === "github") { + // Get token from local gh CLI + const { ok, stdout: token } = await runCli("gh", ["auth", "token"]); + if (!ok || !token.trim()) { + throw new HTTPException(400, { + message: + "GitHub CLI not authenticated. Run `gh auth login` in your terminal.", }); - }, - ); - if (!result.ok) { - throw new HTTPException(500, { - message: `Failed to register MCP server`, + } + + const mcpConfig = JSON.stringify({ + type: "http", + url: "https://api.githubcopilot.com/mcp/", + headers: { + Authorization: `Bearer ${token.trim()}`, + }, }); + + const result = await runCli( + "claude", + ["mcp", "add-json", "github", mcpConfig, "--scope", "user"], + 10000, + ); + if (!result.ok) { + throw new HTTPException(500, { + message: "Failed to register GitHub MCP", + }); + } + return c.json({ success: true }); } - return c.json({ success: true }); + throw new HTTPException(400, { message: `Unknown target: ${target}` }); }); app.delete("/:org/decopilot/connect-studio", async (c) => { @@ -345,33 +384,30 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { throw new HTTPException(401, { message: "Authentication required" }); } - const { spawn } = await import("node:child_process"); + const body = await c.req.json().catch(() => ({})); + const target = (body as { target?: string }).target; - const result = await new Promise<{ ok: boolean }>((resolve) => { - const proc = spawn( - "claude", - ["mcp", "remove", "deco-studio", "--scope", "user"], - { stdio: "ignore" }, - ); - const timeout = setTimeout(() => { - proc.kill(); - resolve({ ok: false }); - }, 5000); - proc.on("close", (code) => { - clearTimeout(timeout); - resolve({ ok: code === 0 }); - }); - proc.on("error", () => { - clearTimeout(timeout); - resolve({ ok: false }); - }); - }); + let mcpName: string; + if (target === "claude-code") { + mcpName = "deco-studio"; + } else if (target === "github") { + mcpName = "github"; + } else { + throw new HTTPException(400, { message: `Unknown target: ${target}` }); + } + + const result = await runCli("claude", [ + "mcp", + "remove", + mcpName, + "--scope", + "user", + ]); if (!result.ok) { throw new HTTPException(500, { - message: "Failed to remove MCP server", + message: `Failed to remove ${mcpName} MCP`, }); } - return c.json({ success: true }); }); diff --git a/apps/mesh/src/web/components/connect-studio-modal.tsx b/apps/mesh/src/web/components/connect-studio-modal.tsx index fe8aeab1b8..8193081eba 100644 --- a/apps/mesh/src/web/components/connect-studio-modal.tsx +++ b/apps/mesh/src/web/components/connect-studio-modal.tsx @@ -13,18 +13,21 @@ import { toast } from "sonner"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { cn } from "@deco/ui/lib/utils.ts"; -interface ConnectStatus { +interface ConnectionStatus { connected: boolean; - auth: { - email?: string; - orgName?: string; - subscriptionType?: string; - } | null; + auth: Record | null; } -function useConnectStatus(org: { slug: string }) { - return useQuery({ - queryKey: ["connect-studio-status", org.slug], +interface ConnectStudioStatus { + claude: ConnectionStatus; + github: ConnectionStatus; +} + +const CONNECT_STUDIO_QK = "connect-studio-status"; + +function useConnectStudioStatus(org: { slug: string }) { + return useQuery({ + queryKey: [CONNECT_STUDIO_QK, org.slug], queryFn: async () => { const res = await fetch( `/api/${org.slug}/decopilot/connect-studio/status`, @@ -35,40 +38,42 @@ function useConnectStatus(org: { slug: string }) { }); } -export function ConnectStudioModal({ - open, - onOpenChange, +function ConnectionCard({ + target, + logo, + name, + status, + isLoading, + orgSlug, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + target: string; + logo: React.ReactNode; + name: string; + status: ConnectionStatus | undefined; + isLoading: boolean; + orgSlug: string; }) { - const { org } = useProjectContext(); - const { - data: status, - isLoading, - refetch, - isRefetching, - } = useConnectStatus(org); const queryClient = useQueryClient(); const [connecting, setConnecting] = useState(false); const [disconnecting, setDisconnecting] = useState(false); + const connected = status?.connected ?? false; + const auth = status?.auth; + const handleConnect = async () => { setConnecting(true); try { - const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { + const res = await fetch(`/api/${orgSlug}/decopilot/connect-studio`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: "claude-code" }), + body: JSON.stringify({ target }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Failed" })); throw new Error(err.error ?? "Failed to connect"); } - toast.success("Connected to Claude Code!"); - queryClient.invalidateQueries({ - queryKey: ["connect-studio-status", org.slug], - }); + toast.success(`Connected ${name}!`); + queryClient.invalidateQueries({ queryKey: [CONNECT_STUDIO_QK] }); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to connect"); } finally { @@ -79,17 +84,17 @@ export function ConnectStudioModal({ const handleDisconnect = async () => { setDisconnecting(true); try { - const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { + const res = await fetch(`/api/${orgSlug}/decopilot/connect-studio`, { method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Failed" })); throw new Error(err.error ?? "Failed to disconnect"); } - toast.success("Disconnected from Claude Code"); - queryClient.invalidateQueries({ - queryKey: ["connect-studio-status", org.slug], - }); + toast.success(`Disconnected ${name}`); + queryClient.invalidateQueries({ queryKey: [CONNECT_STUDIO_QK] }); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to disconnect"); } finally { @@ -97,8 +102,107 @@ export function ConnectStudioModal({ } }; - const connected = status?.connected ?? false; - const auth = status?.auth; + const authLine = auth + ? Object.values(auth).filter(Boolean).join(" — ") + : null; + + return ( +
+
+
+ {logo} +
+
+
+ {name} + {connected && ( + + + Connected + + )} +
+ {authLine && ( +

{authLine}

+ )} +
+ {isLoading && ( + + )} +
+ +
+ {!connected ? ( + + ) : ( + <> + + + + )} +
+
+ ); +} + +const GITHUB_SVG = ( + + + +); + +export function ConnectStudioModal({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { org } = useProjectContext(); + const { data: status, isLoading } = useConnectStudioStatus(org); + const queryClient = useQueryClient(); return ( Connect Studio - Install all your studio tools into Claude Code. + Install studio tools into your local dev environment.
-
- Claude Code -
-
- Claude Code - {connected && ( - - - Connected - - )} -
- {auth && ( -

- {auth.email} - {auth.orgName ? ` — ${auth.orgName}` : ""} - {auth.subscriptionType ? ` (${auth.subscriptionType})` : ""} -

- )} -
- {isLoading && ( - - )} -
- -
- {!connected ? ( - - ) : ( - <> - - - - )} -
+ } + status={status?.claude} + isLoading={isLoading} + orgSlug={org.slug} + /> +
From 6dfcfa15a2eabf80072ced61b651b1a01e4d2eeb Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Thu, 12 Mar 2026 23:02:26 -0300 Subject: [PATCH 12/87] fix(chat): fix GitHub status detection and compact Connect Studio cards GitHub status now uses `gh auth status` (plain) + `gh api user --jq .login` instead of `gh auth status --json` which requires explicit field names. Simplify ConnectionCard to single-row layout with inline toggle button. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/decopilot/routes.ts | 41 +++-- .../web/components/connect-studio-modal.tsx | 146 ++++++------------ 2 files changed, 71 insertions(+), 116 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 04b7d818bc..317a631d9a 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -260,26 +260,35 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { } async function getGithubStatus() { - const { ok, stdout } = await runCli("gh", ["auth", "status", "--json"]); - if (!ok) return { connected: false, auth: null }; + // Check if gh CLI is authenticated + const { ok: ghOk } = await runCli("gh", ["auth", "status"]); + if (!ghOk) return { connected: false, auth: null }; + + // Get username via API + let user: string | undefined; try { - const parsed = JSON.parse(stdout); - // Also check if the MCP is registered in Claude Code - const { ok: mcpRegistered } = await runCli("claude", [ - "mcp", - "get", - "github", + const { ok, stdout } = await runCli("gh", [ + "api", + "user", + "--jq", + ".login", ]); - return { - connected: mcpRegistered, - auth: { - user: parsed.user ?? parsed.login, - host: parsed.host ?? "github.com", - }, - }; + if (ok) user = stdout.trim(); } catch { - return { connected: false, auth: null }; + // Username not available } + + // Check if the MCP is registered in Claude Code + const { ok: mcpRegistered } = await runCli("claude", [ + "mcp", + "get", + "github", + ]); + + return { + connected: mcpRegistered, + auth: user ? { user } : null, + }; } app.get("/:org/decopilot/connect-studio/status", async (c) => { diff --git a/apps/mesh/src/web/components/connect-studio-modal.tsx b/apps/mesh/src/web/components/connect-studio-modal.tsx index 8193081eba..c74fd4e275 100644 --- a/apps/mesh/src/web/components/connect-studio-modal.tsx +++ b/apps/mesh/src/web/components/connect-studio-modal.tsx @@ -7,7 +7,7 @@ import { DialogTitle, } from "@deco/ui/components/dialog.tsx"; import { useProjectContext } from "@decocms/mesh-sdk"; -import { Check, LinkBroken02, Loading01, RefreshCw01 } from "@untitledui/icons"; +import { Check, Loading01 } from "@untitledui/icons"; import { useState } from "react"; import { toast } from "sonner"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -54,51 +54,30 @@ function ConnectionCard({ orgSlug: string; }) { const queryClient = useQueryClient(); - const [connecting, setConnecting] = useState(false); - const [disconnecting, setDisconnecting] = useState(false); + const [busy, setBusy] = useState(false); const connected = status?.connected ?? false; const auth = status?.auth; - const handleConnect = async () => { - setConnecting(true); + const handleToggle = async () => { + setBusy(true); + const method = connected ? "DELETE" : "POST"; try { const res = await fetch(`/api/${orgSlug}/decopilot/connect-studio`, { - method: "POST", + method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Failed" })); - throw new Error(err.error ?? "Failed to connect"); + throw new Error(err.error ?? "Failed"); } - toast.success(`Connected ${name}!`); + toast.success(connected ? `Disconnected ${name}` : `Connected ${name}!`); queryClient.invalidateQueries({ queryKey: [CONNECT_STUDIO_QK] }); } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to connect"); + toast.error(err instanceof Error ? err.message : "Failed"); } finally { - setConnecting(false); - } - }; - - const handleDisconnect = async () => { - setDisconnecting(true); - try { - const res = await fetch(`/api/${orgSlug}/decopilot/connect-studio`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Failed" })); - throw new Error(err.error ?? "Failed to disconnect"); - } - toast.success(`Disconnected ${name}`); - queryClient.invalidateQueries({ queryKey: [CONNECT_STUDIO_QK] }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to disconnect"); - } finally { - setDisconnecting(false); + setBusy(false); } }; @@ -109,80 +88,47 @@ function ConnectionCard({ return (
-
-
- {logo} -
-
-
- {name} - {connected && ( - - - Connected - - )} -
- {authLine && ( -

{authLine}

- )} -
- {isLoading && ( - - )} +
+ {logo}
- -
- {!connected ? ( - - ) : ( - <> - - - +
+
+ {name} + {connected && } +
+ {authLine && ( +

{authLine}

)}
+ {isLoading ? ( + + ) : ( + + )}
); } @@ -223,7 +169,7 @@ export function ConnectStudioModal({ Install studio tools into your local dev environment. -
+
Date: Fri, 13 Mar 2026 15:27:08 -0300 Subject: [PATCH 13/87] fix: rewrite self-connection URL to localhost in outbound client The self MCP connection URL is persisted in the DB using BASE_URL (e.g. las-vegas.localhost) which is unresolvable from Node.js/Bun runtime. Parse the stored URL and replace hostname/port/protocol with getInternalUrl() (localhost:PORT) so loopback calls always succeed, regardless of what origin is stored in the database. Also fix dev-worktree.ts to not set BASE_URL in local mode, allowing the server to default to localhost:PORT. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/mcp-clients/outbound/index.ts | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/mesh/src/mcp-clients/outbound/index.ts b/apps/mesh/src/mcp-clients/outbound/index.ts index 196adf2016..a3c6c8d18f 100644 --- a/apps/mesh/src/mcp-clients/outbound/index.ts +++ b/apps/mesh/src/mcp-clients/outbound/index.ts @@ -7,6 +7,7 @@ import { env } from "../../env"; import type { MeshContext } from "@/core/mesh-context"; +import { getInternalUrl } from "@/core/server-constants"; import { type ConnectionEntity, isStdioParameters, @@ -43,6 +44,32 @@ export async function createOutboundClient( ): Promise { const connectionId = connection.id; + // Self connections (e.g. "orgId_self") use a loopback URL to reach this server. + // The DB may store a proxy hostname (e.g. las-vegas.localhost) which is + // unresolvable from the server's own runtime. Always rewrite the origin + // to localhost:PORT so loopback calls succeed. + if (connectionId.endsWith("_self") && connection.connection_url) { + const internalUrl = getInternalUrl(); + try { + const stored = new URL(connection.connection_url); + const internal = new URL(internalUrl); + if ( + stored.hostname !== internal.hostname || + stored.port !== internal.port + ) { + stored.hostname = internal.hostname; + stored.port = internal.port; + stored.protocol = internal.protocol; + connection = { + ...connection, + connection_url: stored.toString().replace(/\/$/, ""), + }; + } + } catch { + // Malformed URL — leave as-is + } + } + // Extract virtualMcpId if request is routed through a Virtual MCP (agent) const virtualMcpId = ctx.connectionId && ctx.connectionId !== connectionId From 761b6acb563548fb45e4adfefce7c060acd1cbaf Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 10:55:26 -0300 Subject: [PATCH 14/87] feat(chat): fix Claude Code MCP integration and add deco mcp-serve CLI - Fix API key org metadata: pass full organization object instead of bare ID string so auth resolver can determine org context - Route Claude Code MCP to /mcp/self (instant, no connection timeouts) instead of /mcp (aggregates all connections, slow) - Add stream-core.ts Claude Code branch: skip model permissions and aiProviders.activate(), delegate to streamClaudeCode() - Add MCP instructions to management MCP and Decopilot virtual MCP so Claude Code understands how to use Studio tools - Add deco mcp-serve CLI command: stdio MCP proxy to mesh instance - Remove debug console.log from claude-code-provider.ts - Gitignore .mcp.json (may contain API keys) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + .../routes/decopilot/claude-code-provider.ts | 15 +-- apps/mesh/src/api/routes/decopilot/routes.ts | 4 +- .../src/api/routes/decopilot/stream-core.ts | 106 +++++++++++++--- apps/mesh/src/tools/index.ts | 27 ++++- packages/cli/src/commands.ts | 28 +++++ packages/cli/src/commands/tools/mcp-serve.ts | 114 ++++++++++++++++++ packages/mesh-sdk/src/lib/constants.ts | 34 +++++- 8 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/commands/tools/mcp-serve.ts diff --git a/.gitignore b/.gitignore index e231fc43c5..f97c47213b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # GSD workflow state .planning/ +# MCP config (may contain API keys) +.mcp.json + # Logs logs *.log diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index f38c15c061..7e225e28c1 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -204,16 +204,6 @@ export async function streamClaudeCode( for await (const message of conversation) { if (abortController.signal.aborted) break; - const msg = message as Record; - console.log( - "[claude-code] SDK message:", - msg.type, - msg.subtype ?? "", - msg.type === "stream_event" - ? (msg.event as { type?: string })?.type - : "", - ); - switch (message.type) { case "stream_event": { // Only handle main thread events (no subagent) @@ -281,10 +271,11 @@ export async function streamClaudeCode( // Tool use summary — emit as reasoning so user sees tool activity case "tool_use_summary": { - if ((msg as { parent_tool_use_id?: string }).parent_tool_use_id) { + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { break; } - const toolName = (msg as { tool_name?: string }).tool_name ?? "tool"; + const toolName = + (message as { tool_name?: string }).tool_name ?? "tool"; // Show tool activity as reasoning if (!reasoningPartId) { diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 317a631d9a..bae876b73b 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -324,7 +324,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { metadata: { internal: true, target: "claude-code", - organization: organization.id, + organization, }, }); @@ -332,7 +332,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { const origin = `http://localhost:${serverPort}`; const mcpConfig = JSON.stringify({ type: "http", - url: `${origin}/mcp`, + url: `${origin}/mcp/self`, headers: { Authorization: `Bearer ${apiKey.key}`, "x-org-id": organization.id, diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 9168799048..dd56706465 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -36,6 +36,7 @@ import type { StreamBuffer } from "./stream-buffer"; import { genTitle } from "./title-generator"; import type { ChatMessage, ModelsConfig } from "./types"; import type { CancelBroadcast } from "./cancel-broadcast"; +import { streamClaudeCode } from "./claude-code-provider"; import { ThreadMessage } from "@/storage/types"; // ============================================================================ @@ -90,21 +91,25 @@ export async function streamCore( let llmCallLogged = false; try { - // 1. Check model permissions - const allowedModels = await fetchModelPermissions( - ctx.db, - input.organizationId, - ctx.auth.user?.role, - ); + const isClaudeCode = input.models.thinking.provider === "claude-code"; + + // 1. Check model permissions (skip for Claude Code — uses local auth) + if (!isClaudeCode) { + const allowedModels = await fetchModelPermissions( + ctx.db, + input.organizationId, + ctx.auth.user?.role, + ); - if ( - !checkModelPermission( - allowedModels, - input.models.credentialId, - input.models.thinking.id, - ) - ) { - throw new Error("Model not allowed for your role"); + if ( + !checkModelPermission( + allowedModels, + input.models.credentialId, + input.models.thinking.id, + ) + ) { + throw new Error("Model not allowed for your role"); + } } const windowSize = input.windowSize ?? DEFAULT_WINDOW_SIZE; @@ -112,7 +117,12 @@ export async function streamCore( // 2. Load entities and create/load memory in parallel const [virtualMcp, provider, mem] = await Promise.all([ ctx.storage.virtualMcps.findById(input.agent.id, input.organizationId), - ctx.aiProviders.activate(input.models.credentialId, input.organizationId), + isClaudeCode + ? Promise.resolve(null) + : ctx.aiProviders.activate( + input.models.credentialId, + input.organizationId, + ), createMemory(ctx.storage.threads, { organization_id: input.organizationId, thread_id: input.threadId, @@ -235,6 +245,66 @@ export async function streamCore( const uiStream = createUIMessageStream({ originalMessages: allMessages, execute: async ({ writer }) => { + // ── Claude Code path ────────────────────────────────────────── + if (isClaudeCode) { + const { getInternalUrl } = await import("@/core/server-constants"); + const internalUrl = getInternalUrl(); + + // Build MCP endpoint so Claude Code can reach Mesh tools + const mcpEndpoint = `${internalUrl}/mcp/self`; + const apiKeyRecord = await ctx.boundAuth.apiKey.create({ + name: "claude-code-session", + permissions: { "*": ["*"] }, + metadata: { + internal: true, + target: "claude-code", + organization: ctx.organization, + }, + }); + const mcpHeaders: Record = { + Authorization: `Bearer ${apiKeyRecord.key}`, + "x-org-id": input.organizationId, + "x-mesh-client": "Claude Code", + }; + + const abortController = new AbortController(); + registrySignal.addEventListener("abort", () => { + abortController.abort(); + }); + + llmCallStartTime = Date.now(); + const ccResult = await streamClaudeCode(writer, { + messages: allMessages, + abortController, + mcpEndpoint, + mcpHeaders, + agentId: input.agent.id, + agentMode: input.agent.mode, + threadId: mem.thread.id, + connectionId: input.models.credentialId, + model: input.models.thinking.id, + }); + + // Record usage metrics + if (ccResult.usage) { + recordLlmCallMetrics({ + ctx, + organizationId: input.organizationId, + modelId: input.models.thinking.id, + durationMs: Date.now() - (llmCallStartTime ?? Date.now()), + isError: false, + inputTokens: ccResult.usage.inputTokens, + outputTokens: ccResult.usage.outputTokens, + }); + } + + return; + } + + // ── Standard AI provider path ───────────────────────────────── + // provider is guaranteed non-null here (Claude Code returns early above) + const activeProvider = provider!; + const [passthroughClient, strategyClient] = await Promise.all([ createVirtualClientFrom(virtualMcp, ctx, "passthrough"), isGatewayMode @@ -276,7 +346,7 @@ export async function streamCore( const builtInTools = await getBuiltInTools( writer, { - provider, + provider: activeProvider, organization, models: input.models, toolApprovalLevel: input.toolApprovalLevel, @@ -314,7 +384,7 @@ export async function streamCore( if (shouldGenerateTitle) { genTitle({ abortSignal: registrySignal, - model: provider.aiSdk.languageModel( + model: activeProvider.aiSdk.languageModel( input.models.fast?.id ?? input.models.thinking.id, ), userMessage: JSON.stringify(processedMessages[0]?.content), @@ -352,7 +422,7 @@ export async function streamCore( llmCallStartTime = Date.now(); const result = streamText({ - model: provider.aiSdk.languageModel(input.models.thinking.id), + model: activeProvider.aiSdk.languageModel(input.models.thinking.id), system: processedSystemMessages, messages: processedMessages, tools, diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 8b456d81e1..943ad08bb0 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -184,6 +184,26 @@ export type MCPMeshTools = typeof ALL_TOOLS; // Derive tool name type from ALL_TOOLS export type ToolNameFromTools = (typeof ALL_TOOLS)[number]["name"]; +const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP control plane that manages connections, credentials, and tools for AI agents. + +## Available tool categories + +- **CODE_EXECUTION**: Search, describe, and run code in sandboxed environments. Use these to execute operations against connected services programmatically. +- **COLLECTION_CONNECTIONS**: List, create, update, and delete connections to external services (APIs, databases, SaaS tools). +- **COLLECTION_VIRTUAL_MCP**: Manage virtual MCPs (agents) that aggregate tools from multiple connections. +- **API_KEY**: Create and manage API keys for programmatic access. +- **ORGANIZATION / PROJECT**: Manage workspaces and projects. +- **MONITORING**: View logs, stats, and dashboards. +- **EVENT_***: Publish/subscribe events between connections. +- **AUTOMATION_***: Create and manage automated workflows. + +## How to use effectively + +1. **Start by listing**: Use LIST tools to discover what's available before creating or modifying. +2. **IDs, not names**: Tools reference resources by ID. Always resolve IDs first via list/search. +3. **Code execution flow**: Use CODE_EXECUTION_SEARCH to find available operations, CODE_EXECUTION_DESCRIBE to understand inputs/outputs, then CODE_EXECUTION_RUN to execute. +4. **Connections are credentials**: Each connection holds auth tokens for an external service. Tools from connections are accessed via the virtual MCP / gateway.`; + export const managementMCP = async (ctx: MeshContext) => { // Get enabled plugins for this organization to filter plugin tools // Check both org settings (legacy) and all projects (current UI saves to projects table) @@ -211,8 +231,11 @@ export const managementMCP = async (ctx: MeshContext) => { // Create MCP server directly const server = new McpServer( - { name: "mcp-mesh-management", version: "1.0.0" }, - { capabilities: { tools: {} } }, + { name: "deco-studio", version: "1.0.0" }, + { + capabilities: { tools: {} }, + instructions: MANAGEMENT_MCP_INSTRUCTIONS, + }, ); // Register each tool with the server diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index abe870d6e6..45f2abe1b2 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -522,6 +522,33 @@ const callTool = new Command("call-tool") } }); +// MCP serve command - stdio bridge to mesh +const mcpServe = new Command("mcp-serve") + .description( + "Start an MCP stdio server that proxies to a running Mesh instance.", + ) + .option("-w, --workspace ", "Workspace name") + .option( + "-i, --integration ", + "Integration ID to expose tools from", + ) + .option( + "--url ", + "Direct MCP endpoint URL (e.g. http://localhost:3000/mcp)", + ) + .option("--token ", "Bearer token / API key for authentication") + .action(async (options) => { + const { mcpServeCommand } = await import("./commands/tools/mcp-serve.js"); + const config = await getConfig(); + await mcpServeCommand({ + workspace: options.workspace ?? config.workspace, + integration: options.integration, + local: config.local, + url: options.url, + token: options.token, + }); + }); + // Completion command implementation (internal command) const completion = new Command("completion") .description("Generate shell completions (internal command)") @@ -1230,6 +1257,7 @@ export const program = new Command() .addCommand(configure) .addCommand(add) .addCommand(callTool) + .addCommand(mcpServe) .addCommand(upgrade) .addCommand(update) .addCommand(linkCmd) diff --git a/packages/cli/src/commands/tools/mcp-serve.ts b/packages/cli/src/commands/tools/mcp-serve.ts new file mode 100644 index 0000000000..9565f4629a --- /dev/null +++ b/packages/cli/src/commands/tools/mcp-serve.ts @@ -0,0 +1,114 @@ +/** + * MCP Serve Command + * + * Starts an MCP stdio server that proxies to a running Mesh instance. + * This allows AI agents (Claude Code, Cursor, etc.) to use Mesh tools + * via the standard stdio MCP transport. + * + * Usage: + * deco mcp-serve -w + * deco mcp-serve --url http://localhost:3000/mcp --token + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +export interface McpServeOptions { + workspace?: string; + integration?: string; + local?: boolean; + url?: string; + token?: string; +} + +export async function mcpServeCommand(options: McpServeOptions) { + const { workspace, integration, local, url: directUrl } = options; + const token = options.token || process.env.DECO_API_KEY; + + let client: Client; + + if (directUrl) { + // Direct URL mode — connect to arbitrary MCP endpoint + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else { + // Fall back to session auth headers + const { getRequestAuthHeaders } = await import("../../lib/session.js"); + Object.assign(headers, await getRequestAuthHeaders().catch(() => ({}))); + } + + client = new Client({ name: "deco-mcp-serve", version: "1.0.0" }); + await client.connect( + new StreamableHTTPClientTransport(new URL(directUrl), { + requestInit: { headers }, + }), + ); + } else { + // Workspace mode — use standard workspace client + const { createWorkspaceClient } = await import("../../lib/mcp.js"); + client = await createWorkspaceClient({ + workspace, + local, + integrationId: integration, + }); + } + + // Bridge client → server + const capabilities = client.getServerCapabilities(); + const instructions = client.getInstructions(); + + const server = new McpServer( + { name: "deco-mesh", version: "1.0.0" }, + { capabilities, instructions }, + ); + + server.server.setRequestHandler(ListToolsRequestSchema, () => + client.listTools(), + ); + server.server.setRequestHandler(CallToolRequestSchema, (request) => + client.callTool(request.params), + ); + + if (capabilities?.resources) { + server.server.setRequestHandler(ListResourcesRequestSchema, () => + client.listResources(), + ); + server.server.setRequestHandler(ReadResourceRequestSchema, (request) => + client.readResource(request.params), + ); + server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => + client.listResourceTemplates(), + ); + } + + if (capabilities?.prompts) { + server.server.setRequestHandler(ListPromptsRequestSchema, () => + client.listPrompts(), + ); + server.server.setRequestHandler(GetPromptRequestSchema, (request) => + client.getPrompt(request.params), + ); + } + + // Serve over stdio + const transport = new StdioServerTransport(); + await server.connect(transport); + + // Keep alive until stdin closes + process.stdin.on("end", () => { + client.close().catch(() => {}); + process.exit(0); + }); +} diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 0a66cfb0fb..00f8537fcc 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -226,6 +226,38 @@ export function getWellKnownMcpStudioConnection(): ConnectionCreateData { }; } +/** + * Master prompt for the Decopilot MCP server. + * Sent as `instructions` during MCP initialize — Claude Code and other + * clients use this to understand the server's purpose and capabilities. + */ +const DECOPILOT_MCP_INSTRUCTIONS = `You are connected to Deco Studio via MCP (Model Context Protocol). + +## What is Deco Studio? + +Deco Studio is an MCP control plane — a unified layer that manages connections to external services (APIs, databases, SaaS tools) and exposes them as MCP tools. Your tools come from the user's configured connections. + +## How tools work + +Each tool you see comes from a connection the user has configured in their Studio workspace. Tools follow naming patterns based on their source: +- Connection tools are prefixed or grouped by the connection they come from +- Tools accept structured JSON input and return structured JSON output +- Some tools may be slow (external API calls) — inform the user when waiting + +## Key capabilities + +1. **Data access**: Query databases, fetch API data, read files from connected services +2. **Actions**: Create/update/delete records, send messages, trigger workflows +3. **Multi-service orchestration**: Chain tools across different connections to accomplish complex tasks + +## Best practices + +- **List tools first**: Call the appropriate list/search tools before attempting to create or modify resources +- **Be precise with IDs**: Tools use IDs (not names) to reference resources — always resolve IDs first +- **Handle errors gracefully**: If a tool call fails, read the error message carefully and adjust your approach +- **Explain what you're doing**: Tell the user which tools you're calling and why before executing multi-step workflows +- **Batch when possible**: If you need to perform many similar operations, look for batch/bulk tools first`; + /** * Get well-known Decopilot Virtual MCP entity. * This is the default agent that aggregates ALL org connections. @@ -247,7 +279,7 @@ export function getWellKnownDecopilotVirtualMCP( updated_at: new Date().toISOString(), created_by: "system", updated_by: undefined, - metadata: { instructions: null }, + metadata: { instructions: DECOPILOT_MCP_INSTRUCTIONS }, connections: [], // Empty connections array - gateway.ts will populate with all org connections }; } From 9ad625c6bbfc1250dec75ab594e005023d3dd0b6 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 12:40:39 -0300 Subject: [PATCH 15/87] feat(chat): Claude Code provider improvements + connection discovery tools Claude Code as chat provider: - Fix proxy lifecycle bug (proxies closed before callTool used) - Fix token usage mapping (exclude cache tokens from context count) - Update to 1M context models (claude-opus-4-6-max, claude-sonnet-4-6-max) - Add cost tracking via providerMetadata - Use Claude orange icon for Claude Code provider Connection discovery and inline auth: - Add CONNECTION_SEARCH_STORE tool (search Deco Store + Community Registry) - Add CONNECTION_INSTALL tool (install MCP from store results) - Add CONNECTION_AUTH_STATUS tool (check if connection needs auth) - Add CONNECTION_AUTHENTICATE tool (returns data for inline auth UI) - Add ConnectionAuthPart component for inline OAuth card in chat - Add include_tools param to COLLECTION_CONNECTIONS_LIST Decopilot identity and instructions: - Rewrite DECOPILOT_BASE_PROMPT with full identity, capabilities, behavior - Update MANAGEMENT_MCP_INSTRUCTIONS with store search workflow - Update DECOPILOT_MCP_INSTRUCTIONS with discovery guidance Database resilience: - Add PGlite process-level lock (.mesh.lock) to prevent concurrent corruption - Auto-detect stale locks from crashed processes Other fixes: - Fix ice-breakers: use non-suspense query, no skeleton - Code execution proxy cleanup via ToolContext.close() Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/public/logos/Claude Code.svg | 2 +- apps/mesh/src/ai-providers/factory.ts | 16 +- .../routes/decopilot/claude-code-provider.ts | 30 ++- .../src/api/routes/decopilot/constants.ts | 43 ++- .../mcp-clients/virtual-mcp/code-execution.ts | 1 + .../mesh/src/tools/code-execution/describe.ts | 10 +- apps/mesh/src/tools/code-execution/run.ts | 14 +- apps/mesh/src/tools/code-execution/search.ts | 26 +- apps/mesh/src/tools/code-execution/utils.ts | 16 +- apps/mesh/src/tools/connection/auth-status.ts | 70 +++++ .../mesh/src/tools/connection/authenticate.ts | 88 +++++++ apps/mesh/src/tools/connection/index.ts | 6 + apps/mesh/src/tools/connection/install.ts | 137 ++++++++++ apps/mesh/src/tools/connection/list.ts | 17 +- .../mesh/src/tools/connection/search-store.ts | 246 ++++++++++++++++++ apps/mesh/src/tools/index.ts | 101 ++++++- apps/mesh/src/tools/registry.ts | 28 ++ .../src/web/components/chat/ice-breakers.tsx | 53 ++-- .../web/components/chat/message/assistant.tsx | 10 + .../parts/tool-call-part/connection-auth.tsx | 152 +++++++++++ .../message/parts/tool-call-part/index.ts | 1 + apps/mesh/src/web/utils/ai-providers-logos.ts | 2 +- packages/mesh-sdk/src/lib/constants.ts | 65 ++++- packages/mesh-sdk/src/lib/usage.ts | 15 ++ 24 files changed, 1042 insertions(+), 107 deletions(-) create mode 100644 apps/mesh/src/tools/connection/auth-status.ts create mode 100644 apps/mesh/src/tools/connection/authenticate.ts create mode 100644 apps/mesh/src/tools/connection/install.ts create mode 100644 apps/mesh/src/tools/connection/search-store.ts create mode 100644 apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx diff --git a/apps/mesh/public/logos/Claude Code.svg b/apps/mesh/public/logos/Claude Code.svg index 74c283f2eb..fa5b4ab6ac 100644 --- a/apps/mesh/public/logos/Claude Code.svg +++ b/apps/mesh/public/logos/Claude Code.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/mesh/src/ai-providers/factory.ts b/apps/mesh/src/ai-providers/factory.ts index 3f9856c5fd..552e4a156a 100644 --- a/apps/mesh/src/ai-providers/factory.ts +++ b/apps/mesh/src/ai-providers/factory.ts @@ -8,22 +8,22 @@ import { PROVIDERS } from "./registry"; const CLAUDE_CODE_MODEL_LIST: ModelInfo[] = [ { providerId: "claude-code", - modelId: "claude-code:sonnet", - title: "Claude Code: Sonnet", - description: "Fast, capable model via local Claude Code CLI", + modelId: "claude-code:opus", + title: "Claude Code: Opus", + description: "Most capable model via local Claude Code CLI (1M context)", logo: null, capabilities: ["text"] as ModelCapability[], - limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + limits: { contextWindow: 1_000_000, maxOutputTokens: 32_768 }, costs: null, }, { providerId: "claude-code", - modelId: "claude-code:opus", - title: "Claude Code: Opus", - description: "Most capable model via local Claude Code CLI", + modelId: "claude-code:sonnet", + title: "Claude Code: Sonnet", + description: "Fast, capable model via local Claude Code CLI (1M context)", logo: null, capabilities: ["text"] as ModelCapability[], - limits: { contextWindow: 200_000, maxOutputTokens: 32_768 }, + limits: { contextWindow: 1_000_000, maxOutputTokens: 32_768 }, costs: null, }, { diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 7e225e28c1..0f12ffa029 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -65,13 +65,13 @@ function extractSystemPrompt(messages: ChatMessage[]): string { const CLAUDE_CODE_MODELS = [ { id: "claude-code:opus", - sdkModel: "claude-opus-4-6", + sdkModel: "claude-opus-4-6-max", title: "Claude Code Opus", tier: "smarter" as const, }, { id: "claude-code:sonnet", - sdkModel: "claude-sonnet-4-6", + sdkModel: "claude-sonnet-4-6-max", title: "Claude Code Sonnet", tier: "faster" as const, }, @@ -296,14 +296,21 @@ export async function streamClaudeCode( (message as { total_cost_usd?: number }).total_cost_usd ?? 0; const u = ( message as { - usage?: { input_tokens?: number; output_tokens?: number }; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; } ).usage; if (u) { + const inputTokens = u.input_tokens ?? 0; + const outputTokens = u.output_tokens ?? 0; usage = { - inputTokens: u.input_tokens ?? 0, - outputTokens: u.output_tokens ?? 0, - totalTokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0), + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, }; } } else { @@ -410,7 +417,16 @@ export async function streamClaudeCode( type: "finish", finishReason: "stop", messageMetadata: { - usage, + usage: { + ...usage, + providerMetadata: totalCostUsd + ? { + "claude-code": { + usage: { cost: totalCostUsd }, + }, + } + : undefined, + }, }, }); diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 2a3c8cbe95..73dc169a9e 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -19,7 +19,46 @@ export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"]; * @returns ChatMessage with the base system prompt */ export function DECOPILOT_BASE_PROMPT(agentInstructions?: string): ChatMessage { - const platformPrompt = `You are Decopilot, an AI assistant running inside decocms (deco context management system).`; + const platformPrompt = `You are **Decopilot**, the AI assistant built into **Deco Studio** — an MCP (Model Context Protocol) control plane that connects AI agents to external services. + +## Your identity + +You are the user's hands inside Deco Studio. You can manage their MCP connections, search for new integrations, run code against connected APIs, create agents, and more. When the user asks you something, act — don't just explain. + +## What you can do + +You have MCP tools available from the Deco Studio management server ("mesh"). Key capabilities: + +### Use connected services (the main workflow) +Search for tools, get their schemas, then run code against them: +1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools by keyword (e.g. "gmail", "slack") +2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get full input/output schemas +3. **CODE_EXECUTION_RUN_CODE** — execute code that calls tools (must use \`export default async function(tools) { ... }\` format) + +### Find and install new integrations +When the user asks about capabilities they don't have (e.g. "can you send emails?"): +1. **CONNECTION_SEARCH_STORE** — search the Deco Store and Community Registry for MCPs +2. **CONNECTION_INSTALL** — install an MCP as a new connection +3. **CONNECTION_AUTHENTICATE** — show an inline auth card so the user can click to authenticate + +### Manage connections and agents +- **COLLECTION_CONNECTIONS_LIST** — see all connected services (pass \`include_tools: false\` for lighter responses) +- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — manage connections +- **CONNECTION_AUTH_STATUS** — check if a connection needs authentication +- **COLLECTION_VIRTUAL_MCP_*CREATE/LIST/GET** — create and manage agents (virtual MCPs) + +### Other +- **MONITORING_LOGS_LIST / MONITORING_STATS** — view logs and metrics +- **EVENT_PUBLISH / EVENT_SUBSCRIBE** — pub/sub between connections +- **AUTOMATION_*CREATE/RUN** — automated workflows + +## How to behave + +- **Be proactive**: If the user says "can you send emails?", don't just say no — search the store for email MCPs and offer to install one. +- **Act, don't explain**: Use your tools immediately. Don't describe what you would do — do it. +- **Keep it concise**: Short answers, clear actions. The user can see tool calls inline. +- **Follow Search → Describe → Run**: Always search for tools first, describe them to get schemas, then run code. Never guess tool names or parameters. +- **Code format**: All code execution MUST use \`export default async function(tools) { return await tools.tool_name(args); }\``; let text = platformPrompt; if (agentInstructions?.trim()) { @@ -29,7 +68,7 @@ export function DECOPILOT_BASE_PROMPT(agentInstructions?: string): ChatMessage { ## Agent-Specific Instructions -The following instructions are specific to the agent (virtual MCP) the user has selected. These instructions supplement the platform guidelines above: +The following instructions come from the selected agent and supplement the platform capabilities above: ${agentInstructions}`; } diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/code-execution.ts b/apps/mesh/src/mcp-clients/virtual-mcp/code-execution.ts index 58733e76b9..abdb72d979 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/code-execution.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/code-execution.ts @@ -89,6 +89,7 @@ export class CodeExecutionClient extends BaseSelection { callTool: async (name: string, innerArgs: Record) => { return this.routeToolCall({ name, arguments: innerArgs }); }, + close: async () => {}, // Proxies managed by BaseSelection }; // Use shared run code logic diff --git a/apps/mesh/src/tools/code-execution/describe.ts b/apps/mesh/src/tools/code-execution/describe.ts index 28c0cd304f..e03c80616a 100644 --- a/apps/mesh/src/tools/code-execution/describe.ts +++ b/apps/mesh/src/tools/code-execution/describe.ts @@ -36,9 +36,11 @@ export const CODE_EXECUTION_DESCRIBE_TOOLS = defineTool({ // Get tools from connections (agent-specific or all org connections) const toolContext = await getToolsWithConnections(ctx); - // Describe requested tools - const result = describeTools(input.tools, toolContext.tools); - - return result; + try { + // Describe requested tools + return describeTools(input.tools, toolContext.tools); + } finally { + await toolContext.close(); + } }, }); diff --git a/apps/mesh/src/tools/code-execution/run.ts b/apps/mesh/src/tools/code-execution/run.ts index e6e2d876e9..5307cb350d 100644 --- a/apps/mesh/src/tools/code-execution/run.ts +++ b/apps/mesh/src/tools/code-execution/run.ts @@ -36,13 +36,11 @@ export const CODE_EXECUTION_RUN_CODE = defineTool({ // Get tools from connections (agent-specific or all org connections) const toolContext = await getToolsWithConnections(ctx); - // Run code with tools - const result = await runCodeWithTools( - input.code, - toolContext, - input.timeoutMs, - ); - - return result; + try { + // Run code with tools + return await runCodeWithTools(input.code, toolContext, input.timeoutMs); + } finally { + await toolContext.close(); + } }, }); diff --git a/apps/mesh/src/tools/code-execution/search.ts b/apps/mesh/src/tools/code-execution/search.ts index d1f4c67d4c..3175f0bfae 100644 --- a/apps/mesh/src/tools/code-execution/search.ts +++ b/apps/mesh/src/tools/code-execution/search.ts @@ -36,17 +36,21 @@ export const CODE_EXECUTION_SEARCH_TOOLS = defineTool({ // Get tools from connections (agent-specific or all org connections) const toolContext = await getToolsWithConnections(ctx); - // Search tools by query - const results = searchTools(input.query, toolContext.tools, input.limit); + try { + // Search tools by query + const results = searchTools(input.query, toolContext.tools, input.limit); - return { - query: input.query, - results: results.map((t) => ({ - name: t.name, - description: t.description, - connection: t._meta?.connectionTitle ?? "", - })), - totalAvailable: toolContext.tools.length, - }; + return { + query: input.query, + results: results.map((t) => ({ + name: t.name, + description: t.description, + connection: t._meta?.connectionTitle ?? "", + })), + totalAvailable: toolContext.tools.length, + }; + } finally { + await toolContext.close(); + } }, }); diff --git a/apps/mesh/src/tools/code-execution/utils.ts b/apps/mesh/src/tools/code-execution/utils.ts index a1a3b80c4d..9abb3ee15f 100644 --- a/apps/mesh/src/tools/code-execution/utils.ts +++ b/apps/mesh/src/tools/code-execution/utils.ts @@ -44,6 +44,8 @@ export interface ToolContext { name: string, args: Record, ) => Promise; + /** Close all underlying proxies — call after code execution completes */ + close: () => Promise; } /** Tool description for describe tools output */ @@ -238,16 +240,16 @@ async function loadToolsFromConnections( return result as CallToolResult; }; - // Dispose of proxies when done - const closePromises: Promise[] = []; - for (const [, entry] of proxyMap) { - closePromises.push(entry.proxy.close().catch(() => {})); - } - await Promise.all(closePromises); - return { tools: allTools, callTool, + close: async () => { + await Promise.all( + Array.from(proxyMap.values()).map((entry) => + entry.proxy.close().catch(() => {}), + ), + ); + }, }; } diff --git a/apps/mesh/src/tools/connection/auth-status.ts b/apps/mesh/src/tools/connection/auth-status.ts new file mode 100644 index 0000000000..7cd5b17a8a --- /dev/null +++ b/apps/mesh/src/tools/connection/auth-status.ts @@ -0,0 +1,70 @@ +/** + * CONNECTION_AUTH_STATUS Tool + * + * Check if a connection needs authentication. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireOrganization } from "../../core/mesh-context"; + +export const CONNECTION_AUTH_STATUS = defineTool({ + name: "CONNECTION_AUTH_STATUS", + description: + "Check if a connection needs authentication and its current health status", + annotations: { + title: "Connection Auth Status", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema: z.object({ + id: z.string().describe("Connection ID to check"), + }), + outputSchema: z.object({ + connection_id: z.string(), + title: z.string(), + icon: z.string().nullable(), + status: z.enum(["active", "inactive", "error"]), + needs_auth: z.boolean(), + is_healthy: z.boolean(), + }), + + handler: async (input, ctx) => { + const organization = requireOrganization(ctx); + await ctx.access.check(); + + const connection = await ctx.storage.connections.findById(input.id); + if (!connection || connection.organization_id !== organization.id) { + throw new Error("Connection not found"); + } + + // Test health + let isHealthy = false; + try { + const result = await ctx.storage.connections.testConnection(input.id); + isHealthy = result.healthy; + } catch { + // Connection unreachable + } + + // Determine if auth is needed: + // - Connection is unhealthy AND has OAuth config or scopes + // - Connection has configuration_scopes but no configuration_state values + const hasOAuth = !!connection.oauth_config; + const hasScopes = + connection.configuration_scopes && + connection.configuration_scopes.length > 0; + const needsAuth = !isHealthy && (hasOAuth || !!hasScopes); + + return { + connection_id: connection.id, + title: connection.title, + icon: connection.icon ?? null, + status: connection.status ?? "inactive", + needs_auth: needsAuth, + is_healthy: isHealthy, + }; + }, +}); diff --git a/apps/mesh/src/tools/connection/authenticate.ts b/apps/mesh/src/tools/connection/authenticate.ts new file mode 100644 index 0000000000..e092010069 --- /dev/null +++ b/apps/mesh/src/tools/connection/authenticate.ts @@ -0,0 +1,88 @@ +/** + * CONNECTION_AUTHENTICATE Tool + * + * Returns structured data for the frontend to render an inline auth card. + * The tool itself is read-only — the UI handles the OAuth mutation. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireOrganization } from "../../core/mesh-context"; + +export const CONNECTION_AUTHENTICATE = defineTool({ + name: "CONNECTION_AUTHENTICATE", + description: + "Show an inline authentication card for a connection. The user can click to authenticate via OAuth popup.", + annotations: { + title: "Authenticate Connection", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + connection_id: z.string().describe("Connection ID to authenticate"), + }), + outputSchema: z.object({ + connection_id: z.string(), + title: z.string(), + icon: z.string().nullable(), + description: z.string().nullable(), + connection_url: z.string().nullable(), + status: z.enum(["active", "inactive", "error"]), + needs_auth: z.boolean(), + auth_type: z + .enum(["oauth", "token", "configuration", "none"]) + .describe("Type of authentication required"), + }), + + handler: async (input, ctx) => { + const organization = requireOrganization(ctx); + await ctx.access.check(); + + const connection = await ctx.storage.connections.findById( + input.connection_id, + ); + if (!connection || connection.organization_id !== organization.id) { + throw new Error("Connection not found"); + } + + // Test health + let isHealthy = false; + try { + const result = await ctx.storage.connections.testConnection( + input.connection_id, + ); + isHealthy = result.healthy; + } catch { + // Connection unreachable + } + + // Determine auth type + const hasOAuth = !!connection.oauth_config; + const hasScopes = + connection.configuration_scopes && + connection.configuration_scopes.length > 0; + let authType: "oauth" | "token" | "configuration" | "none" = "none"; + if (hasOAuth) { + authType = "oauth"; + } else if (hasScopes) { + authType = "configuration"; + } else if (!isHealthy && connection.connection_token) { + authType = "token"; + } + + const needsAuth = !isHealthy && authType !== "none"; + + return { + connection_id: connection.id, + title: connection.title, + icon: connection.icon ?? null, + description: connection.description ?? null, + connection_url: connection.connection_url ?? null, + status: connection.status ?? "inactive", + needs_auth: needsAuth, + auth_type: authType, + }; + }, +}); diff --git a/apps/mesh/src/tools/connection/index.ts b/apps/mesh/src/tools/connection/index.ts index 1263d3685f..a7103ac8c5 100644 --- a/apps/mesh/src/tools/connection/index.ts +++ b/apps/mesh/src/tools/connection/index.ts @@ -14,4 +14,10 @@ export { COLLECTION_CONNECTIONS_DELETE } from "./delete"; // Connection test tool export { CONNECTION_TEST } from "./test"; +// Connection management tools (store search, install, auth) +export { CONNECTION_SEARCH_STORE } from "./search-store"; +export { CONNECTION_INSTALL } from "./install"; +export { CONNECTION_AUTH_STATUS } from "./auth-status"; +export { CONNECTION_AUTHENTICATE } from "./authenticate"; + // Utility exports diff --git a/apps/mesh/src/tools/connection/install.ts b/apps/mesh/src/tools/connection/install.ts new file mode 100644 index 0000000000..a19b370fd4 --- /dev/null +++ b/apps/mesh/src/tools/connection/install.ts @@ -0,0 +1,137 @@ +/** + * CONNECTION_INSTALL Tool + * + * Install an MCP from the store as a new connection. + * Simplified version of COLLECTION_CONNECTIONS_CREATE for AI-driven installs. + */ + +import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { + getUserId, + requireAuth, + requireOrganization, +} from "../../core/mesh-context"; +import { fetchToolsFromMCP } from "./fetch-tools"; + +export const CONNECTION_INSTALL = defineTool({ + name: "CONNECTION_INSTALL", + description: + "Install an MCP from the store as a new connection. Use after CONNECTION_SEARCH_STORE to add a discovered MCP.", + annotations: { + title: "Install Connection", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + inputSchema: z.object({ + title: z.string().describe("Display name for the connection"), + connection_url: z.string().url().describe("MCP server URL"), + description: z.string().optional(), + icon: z.string().optional().describe("Icon URL"), + app_name: z.string().optional(), + app_id: z.string().optional(), + connection_type: z + .enum(["HTTP", "SSE"]) + .optional() + .describe("Transport type. Defaults to HTTP."), + }), + outputSchema: z.object({ + connection: z.object({ + id: z.string(), + title: z.string(), + icon: z.string().nullable(), + status: z.enum(["active", "inactive", "error"]), + }), + needs_auth: z.boolean(), + message: z.string(), + }), + + handler: async (input, ctx) => { + requireAuth(ctx); + const organization = requireOrganization(ctx); + await ctx.access.check(); + + const userId = getUserId(ctx); + if (!userId) { + throw new Error("User ID required to install connection"); + } + + // Check if a connection with same URL already exists + const existing = await ctx.storage.connections.list(organization.id); + const duplicate = existing.find( + (c) => c.connection_url === input.connection_url, + ); + if (duplicate) { + return { + connection: { + id: duplicate.id, + title: duplicate.title, + icon: duplicate.icon ?? null, + status: duplicate.status ?? "active", + }, + needs_auth: false, + message: `Connection "${duplicate.title}" already exists.`, + }; + } + + // Fetch tools to validate endpoint + const fetchResult = await fetchToolsFromMCP({ + id: `pending-${Date.now()}`, + title: input.title, + connection_type: input.connection_type ?? "HTTP", + connection_url: input.connection_url, + connection_token: null, + connection_headers: null, + }).catch(() => null); + + const tools = fetchResult?.tools?.length ? fetchResult.tools : null; + const scopes = fetchResult?.scopes?.length ? fetchResult.scopes : null; + + // Create the connection + const connection = await ctx.storage.connections.create({ + title: input.title, + connection_type: input.connection_type ?? "HTTP", + connection_url: input.connection_url, + description: input.description ?? null, + icon: input.icon ?? null, + app_name: input.app_name ?? null, + app_id: input.app_id ?? null, + organization_id: organization.id, + created_by: userId, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: scopes, + tools, + }); + + await ctx.eventBus.publish( + organization.id, + WellKnownOrgMCPId.SELF(organization.id), + { + type: "connection.created", + data: connection, + }, + ); + + // If tools couldn't be fetched, the connection likely needs auth + const needsAuth = !fetchResult; + + return { + connection: { + id: connection.id, + title: connection.title, + icon: connection.icon ?? null, + status: connection.status ?? "active", + }, + needs_auth: needsAuth, + message: needsAuth + ? `Installed "${connection.title}". Authentication is required — use CONNECTION_AUTHENTICATE to show the auth UI.` + : `Installed "${connection.title}" successfully with ${tools?.length ?? 0} tools.`, + }; + }, +}); diff --git a/apps/mesh/src/tools/connection/list.ts b/apps/mesh/src/tools/connection/list.ts index 986c48773f..05de2df67b 100644 --- a/apps/mesh/src/tools/connection/list.ts +++ b/apps/mesh/src/tools/connection/list.ts @@ -199,6 +199,12 @@ const ConnectionListInputSchema = CollectionListInputSchema.extend({ .describe( "Whether to include VIRTUAL connections in the results. Defaults to false.", ), + include_tools: z + .boolean() + .optional() + .describe( + "Whether to include full tool schemas per connection. Defaults to true. Set to false for lighter responses.", + ), }); /** @@ -314,8 +320,17 @@ export const COLLECTION_CONNECTIONS_LIST = defineTool({ ); const hasMore = offset + limit < totalCount; + // Strip tool schemas when explicitly excluded (they bloat AI responses) + const items = + input.include_tools === false + ? paginatedConnections.map(({ tools: _, ...rest }) => ({ + ...rest, + tools: [], + })) + : paginatedConnections; + return { - items: paginatedConnections, + items, totalCount, hasMore, }; diff --git a/apps/mesh/src/tools/connection/search-store.ts b/apps/mesh/src/tools/connection/search-store.ts new file mode 100644 index 0000000000..5a9d0e63a8 --- /dev/null +++ b/apps/mesh/src/tools/connection/search-store.ts @@ -0,0 +1,246 @@ +/** + * CONNECTION_SEARCH_STORE Tool + * + * Search the Deco Store and Community Registry for MCPs by query. + * Uses direct HTTP JSON-RPC calls to well-known registry URLs to avoid + * proxy setup issues and schema validation errors. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireOrganization } from "../../core/mesh-context"; + +const StoreResultSchema = z.object({ + title: z.string(), + description: z.string().nullable(), + icon: z.string().nullable(), + connection_url: z.string(), + app_name: z.string().nullable(), + app_id: z.string().nullable(), + source: z.string().describe("Which registry this result came from"), +}); + +type StoreResult = z.infer; + +// Well-known registry URLs (hardcoded — these never change) +const REGISTRIES = [ + { + name: "Deco Store", + url: "https://studio.decocms.com/org/deco/registry/mcp", + }, + { + name: "Community Registry", + url: "https://sites-registry.decocache.com/mcp", + }, +]; + +let nextRpcId = 1; + +/** + * Make a raw JSON-RPC call to an MCP server. + * Bypasses proxy setup, schema validation, and connection DB lookups. + */ +async function mcpRpc( + url: string, + method: string, + params?: Record, +): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: nextRpcId++, + method, + params: params ?? {}, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const body = await response.json(); + if (body.error) { + throw new Error(body.error.message || "RPC error"); + } + return body.result; +} + +/** + * Search a registry by first discovering its tools, then calling the search/list tool. + */ +async function searchRegistry( + url: string, + query: string, + limit: number, + source: string, +): Promise { + // Step 1: Initialize (required by MCP protocol) + await mcpRpc(url, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mesh-store-search", version: "1.0.0" }, + }); + + // Step 2: Discover tools + const listResult = (await mcpRpc(url, "tools/list")) as { + tools?: Array<{ name: string }>; + }; + const tools = listResult?.tools ?? []; + + // Find the best search/list tool + const searchTool = + tools.find((t) => t.name.toLowerCase().includes("search")) ?? + tools.find((t) => t.name.toLowerCase().includes("list")); + + if (!searchTool) { + return []; + } + + // Step 3: Call the search tool + // Use `where` filter for collection-style tools, `query` for search-style + const isCollectionTool = searchTool.name.startsWith("COLLECTION_"); + const args = isCollectionTool + ? { + where: { + field: ["title"], + operator: "contains", + value: query, + }, + limit, + } + : { query, limit }; + + const callResult = (await mcpRpc(url, "tools/call", { + name: searchTool.name, + arguments: args, + })) as { + content?: Array<{ type: string; text?: string }>; + structuredContent?: Record; + }; + + // Try structured content first (bypasses schema validation on our side), + // then fall back to text content + let items: Record[] = []; + + if (callResult?.structuredContent) { + const sc = callResult.structuredContent; + items = Array.isArray(sc) + ? sc + : Array.isArray(sc.items) + ? (sc.items as Record[]) + : Array.isArray(sc.data) + ? (sc.data as Record[]) + : []; + } else if (callResult?.content?.length) { + const textContent = callResult.content.find((c) => c.type === "text"); + if (textContent?.text) { + try { + const parsed = JSON.parse(textContent.text); + items = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed.items) + ? parsed.items + : Array.isArray(parsed.data) + ? parsed.data + : []; + } catch { + // Invalid JSON + } + } + } + + return items + .slice(0, limit) + .map( + (item): StoreResult => ({ + title: String( + item.title || item.name || item.app_name || "Unknown MCP", + ), + description: item.description ? String(item.description) : null, + icon: item.icon ? String(item.icon) : null, + connection_url: String( + item.connection_url || item.url || item.mcp_url || "", + ), + app_name: item.app_name ? String(item.app_name) : null, + app_id: item.app_id ? String(item.app_id) : null, + source, + }), + ) + .filter((r) => r.connection_url); +} + +export const CONNECTION_SEARCH_STORE = defineTool({ + name: "CONNECTION_SEARCH_STORE", + description: + "Search the Deco Store and Community Registry for available MCPs to install", + annotations: { + title: "Search MCP Store", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema: z.object({ + query: z.string().describe("Search query (e.g. 'gmail', 'slack', 'email')"), + limit: z + .number() + .optional() + .default(10) + .describe("Max results per registry. Defaults to 10."), + }), + outputSchema: z.object({ + results: z.array(StoreResultSchema), + query: z.string(), + }), + + handler: async (input, ctx) => { + requireOrganization(ctx); + await ctx.access.check(); + + const results: StoreResult[] = []; + + // Search all registries in parallel via direct HTTP + const searchPromises = REGISTRIES.map(async (registry) => { + try { + const url = registry.url; + return await searchRegistry( + url, + input.query, + input.limit, + registry.name, + ); + } catch (error) { + console.warn( + `[search-store] Failed to search ${registry.name}:`, + error instanceof Error ? error.message : error, + ); + return []; + } + }); + + const searchResults = await Promise.allSettled(searchPromises); + for (const result of searchResults) { + if (result.status === "fulfilled") { + results.push(...result.value); + } + } + + // Deduplicate by connection_url + const seen = new Set(); + const deduplicated = results.filter((r) => { + if (seen.has(r.connection_url)) return false; + seen.add(r.connection_url); + return true; + }); + + return { + results: deduplicated, + query: input.query, + }; + }, +}); diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 943ad08bb0..177685f6db 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -53,6 +53,10 @@ const CORE_TOOLS = [ ConnectionTools.COLLECTION_CONNECTIONS_UPDATE, ConnectionTools.COLLECTION_CONNECTIONS_DELETE, ConnectionTools.CONNECTION_TEST, + ConnectionTools.CONNECTION_SEARCH_STORE, + ConnectionTools.CONNECTION_INSTALL, + ConnectionTools.CONNECTION_AUTH_STATUS, + ConnectionTools.CONNECTION_AUTHENTICATE, // Virtual MCP collection tools VirtualMCPTools.COLLECTION_VIRTUAL_MCP_CREATE, @@ -188,7 +192,7 @@ const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP ## Available tool categories -- **CODE_EXECUTION**: Search, describe, and run code in sandboxed environments. Use these to execute operations against connected services programmatically. +- **CODE_EXECUTION_***: Search, describe, and run code against connected services. **This is the primary way to interact with external services.** - **COLLECTION_CONNECTIONS**: List, create, update, and delete connections to external services (APIs, databases, SaaS tools). - **COLLECTION_VIRTUAL_MCP**: Manage virtual MCPs (agents) that aggregate tools from multiple connections. - **API_KEY**: Create and manage API keys for programmatic access. @@ -197,12 +201,95 @@ const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP - **EVENT_***: Publish/subscribe events between connections. - **AUTOMATION_***: Create and manage automated workflows. -## How to use effectively - -1. **Start by listing**: Use LIST tools to discover what's available before creating or modifying. -2. **IDs, not names**: Tools reference resources by ID. Always resolve IDs first via list/search. -3. **Code execution flow**: Use CODE_EXECUTION_SEARCH to find available operations, CODE_EXECUTION_DESCRIBE to understand inputs/outputs, then CODE_EXECUTION_RUN to execute. -4. **Connections are credentials**: Each connection holds auth tokens for an external service. Tools from connections are accessed via the virtual MCP / gateway.`; +## Code execution — the main workflow + +To interact with external services (Gmail, Slack, databases, etc.), use the three CODE_EXECUTION tools in order: + +### Step 1: Search for tools +\`\`\` +CODE_EXECUTION_SEARCH_TOOLS({ query: "gmail" }) +\`\`\` +Returns tool names and descriptions. Always do this first — don't guess tool names. + +### Step 2: Get schemas +\`\`\` +CODE_EXECUTION_DESCRIBE_TOOLS({ tools: ["gmail_send_email"] }) +\`\`\` +Returns full input/output schemas. Check exact parameter names and types before writing code. + +### Step 3: Run code +**CRITICAL**: The \`code\` parameter must be an ES module that \`export default\`s an async function receiving \`tools\` as its argument. + +✅ Correct: +\`\`\` +CODE_EXECUTION_RUN_CODE({ + code: "export default async function(tools) {\\n const result = await tools.gmail_send_email({ to: 'user@example.com', subject: 'Hello', body: 'Hi there' });\\n return result;\\n}" +}) +\`\`\` + +❌ Wrong (bare return/await — will fail with syntax error): +\`\`\` +CODE_EXECUTION_RUN_CODE({ + code: "return await tools.gmail_send_email({ ... })" +}) +\`\`\` + +### Code execution rules + +1. **Always \`export default async function(tools)\`** — this is the only accepted format +2. **Always \`return\`** the result so you can see the output +3. **Use \`await\`** for all tool calls — they are async +4. **Use bracket notation** for tool names with hyphens: \`tools["my-tool"](args)\` +5. **Wrap in try/catch** for better error messages: + \`\`\` + export default async function(tools) { + try { + return await tools.gmail_list_emails({ maxResults: 5 }); + } catch (e) { + return { error: e.message }; + } + } + \`\`\` +6. **Chain multiple tools** in a single run for complex workflows: + \`\`\` + export default async function(tools) { + const emails = await tools.gmail_list_emails({ maxResults: 3 }); + const summaries = emails.map(e => e.subject); + return { count: emails.length, subjects: summaries }; + } + \`\`\` + +## Finding and installing MCPs + +When the user asks about capabilities you don't have (e.g., "can you send emails?", "install gmail", "connect to slack"): + +### Step 1: Search the store +\`\`\` +CONNECTION_SEARCH_STORE({ query: "gmail" }) +\`\`\` +Returns available MCPs from the Deco Store and community registry. + +### Step 2: Install +\`\`\` +CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://...", icon: "..." }) +\`\`\` +Creates a new connection. Returns whether authentication is needed. + +### Step 3: Authenticate (if needed) +\`\`\` +CONNECTION_AUTHENTICATE({ connection_id: "conn_..." }) +\`\`\` +Shows an inline authentication card in the chat. The user can click to authenticate via OAuth popup. **Wait for the user to complete authentication before proceeding.** + +### Step 4: Use the tools +After authentication, the connection's tools are available via CODE_EXECUTION_SEARCH_TOOLS. + +## General guidelines + +- **IDs, not names**: Tools reference resources by ID. Always resolve IDs first via list/search. +- **Connections are credentials**: Each connection holds auth tokens for an external service. Tools from connections are accessed via code execution. +- **COLLECTION_CONNECTIONS_LIST** includes full tool schemas by default. Pass \`include_tools: false\` for lighter responses when you only need connection metadata. +- Use **CONNECTION_AUTH_STATUS** to check if a connection needs auth before trying to use its tools.`; export const managementMCP = async (ctx: MeshContext) => { // Get enabled plugins for this organization to filter plugin tools diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index 4248f9bbd4..b0b2f18b19 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -57,6 +57,10 @@ const ALL_TOOL_NAMES = [ "COLLECTION_CONNECTIONS_UPDATE", "COLLECTION_CONNECTIONS_DELETE", "CONNECTION_TEST", + "CONNECTION_SEARCH_STORE", + "CONNECTION_INSTALL", + "CONNECTION_AUTH_STATUS", + "CONNECTION_AUTHENTICATE", // Virtual MCP tools "COLLECTION_VIRTUAL_MCP_CREATE", "COLLECTION_VIRTUAL_MCP_LIST", @@ -278,6 +282,26 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Test connections", category: "Connections", }, + { + name: "CONNECTION_SEARCH_STORE", + description: "Search MCP store", + category: "Connections", + }, + { + name: "CONNECTION_INSTALL", + description: "Install MCP connections", + category: "Connections", + }, + { + name: "CONNECTION_AUTH_STATUS", + description: "Check connection auth status", + category: "Connections", + }, + { + name: "CONNECTION_AUTHENTICATE", + description: "Authenticate connections", + category: "Connections", + }, { name: "DATABASES_RUN_SQL", description: "Run SQL queries", @@ -698,6 +722,10 @@ const TOOL_LABELS: Record = { COLLECTION_CONNECTIONS_UPDATE: "Update connections", COLLECTION_CONNECTIONS_DELETE: "Delete connections", CONNECTION_TEST: "Test connections", + CONNECTION_SEARCH_STORE: "Search MCP store", + CONNECTION_INSTALL: "Install MCP connections", + CONNECTION_AUTH_STATUS: "Check connection auth", + CONNECTION_AUTHENTICATE: "Authenticate connections", DATABASES_RUN_SQL: "Run SQL queries", COLLECTION_VIRTUAL_MCP_CREATE: "Create virtual MCPs", COLLECTION_VIRTUAL_MCP_LIST: "List virtual MCPs", diff --git a/apps/mesh/src/web/components/chat/ice-breakers.tsx b/apps/mesh/src/web/components/chat/ice-breakers.tsx index bde1ac7e89..c5992319e5 100644 --- a/apps/mesh/src/web/components/chat/ice-breakers.tsx +++ b/apps/mesh/src/web/components/chat/ice-breakers.tsx @@ -3,7 +3,6 @@ import { PopoverContent, PopoverTrigger, } from "@deco/ui/components/popover.tsx"; -import { Skeleton } from "@deco/ui/components/skeleton.tsx"; import { Spinner } from "@deco/ui/components/spinner.tsx"; import { Tooltip, @@ -16,7 +15,7 @@ import { getPrompt, getWellKnownDecopilotVirtualMCP, useMCPClient, - useMCPPromptsList, + useMCPPromptsListQuery, useProjectContext, } from "@decocms/mesh-sdk"; import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; @@ -172,19 +171,6 @@ interface IceBreakersProps { className?: string; } -/** - * Fallback component for Suspense that maintains min-height to prevent layout shift - * Shows skeleton pills matching the actual IceBreakers appearance - */ -function IceBreakersFallback() { - return ( - <> - - - - ); -} - /** * State machine for ice breakers */ @@ -237,14 +223,20 @@ function iceBreakerReducer( * Inner component that fetches and displays prompts for a specific MCP connection * @param connectionId - The connection ID, or null for the management MCP */ -function IceBreakersContent({ connectionId }: { connectionId: string | null }) { +function IceBreakersContent({ + connectionId, + className, +}: { + connectionId: string | null; + className?: string; +}) { const { tiptapDocRef, sendMessage } = useChatStable(); const { org } = useProjectContext(); const client = useMCPClient({ connectionId, orgId: org.id, }); - const { data } = useMCPPromptsList({ client, staleTime: 60000 }); + const { data } = useMCPPromptsListQuery({ client, staleTime: 60000 }); const prompts = data?.prompts ?? []; const [state, dispatch] = useReducer(iceBreakerReducer, { stage: "idle" }); const [dialogPrompt, setDialogPrompt] = useState(null); @@ -318,6 +310,7 @@ function IceBreakersContent({ connectionId }: { connectionId: string | null }) { prompts={prompts} onSelect={handlePromptSelection} loadingPrompt={state.stage === "loading" ? state.prompt : null} + className={className} /> - - } - > - - - -
+ + + + + ); } diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index c1bbcb01f8..afe05f3622 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -7,6 +7,7 @@ import type { ChatMessage } from "../types.ts"; import { MessageStatsBar } from "../usage-stats.tsx"; import { MessageTextPart } from "./parts/text-part.tsx"; import { + ConnectionAuthPart, GenericToolCallPart, SubtaskPart, UserAskPart, @@ -236,6 +237,15 @@ function MessagePart({ return null; default: { const fallback = part as ToolUIPart; + // Inline auth card for CONNECTION_AUTHENTICATE tool + if (fallback.type === "tool-CONNECTION_AUTHENTICATE") { + return ( + + ); + } if (fallback.type.startsWith("tool-")) { const toolCallId = (fallback as ToolUIPart).toolCallId; const meta = dataParts.toolMetadata.get(toolCallId); diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx new file mode 100644 index 0000000000..3601615639 --- /dev/null +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { Button } from "@deco/ui/components/button.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { authenticateMcp } from "@decocms/mesh-sdk"; +import { Check, Loading01, Lock01 } from "@untitledui/icons"; +import type { ToolUIPart } from "ai"; +import { useState } from "react"; +import { ToolCallShell } from "./common.tsx"; +import { getEffectiveState } from "./utils.tsx"; + +interface AuthData { + connection_id: string; + title: string; + icon: string | null; + description: string | null; + connection_url: string | null; + status: string; + needs_auth: boolean; + auth_type: string; +} + +function parseAuthData(output: unknown): AuthData | null { + if (!output || typeof output !== "object") return null; + const data = output as Record; + if (!data.connection_id || typeof data.connection_id !== "string") { + return null; + } + return { + connection_id: data.connection_id as string, + title: (data.title as string) ?? "Connection", + icon: (data.icon as string) ?? null, + description: (data.description as string) ?? null, + connection_url: (data.connection_url as string) ?? null, + status: (data.status as string) ?? "inactive", + needs_auth: (data.needs_auth as boolean) ?? true, + auth_type: (data.auth_type as string) ?? "oauth", + }; +} + +type AuthState = "idle" | "authenticating" | "success" | "error"; + +function AuthCard({ data }: { data: AuthData }) { + const [authState, setAuthState] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(null); + + const connected = authState === "success" || !data.needs_auth; + + const handleAuthenticate = async () => { + setAuthState("authenticating"); + setErrorMsg(null); + try { + const result = await authenticateMcp({ + connectionId: data.connection_id, + }); + if (result.token) { + setAuthState("success"); + } else { + setAuthState("error"); + setErrorMsg(result.error ?? "Authentication failed"); + } + } catch (err) { + setAuthState("error"); + setErrorMsg(err instanceof Error ? err.message : "Authentication failed"); + } + }; + + return ( +
+
+ {data.icon ? ( + {data.title} + ) : ( + + )} +
+
+
+ {data.title} + {connected && } +
+ {data.description && ( +

+ {data.description} +

+ )} + {authState === "error" && errorMsg && ( +

{errorMsg}

+ )} +
+ {!connected && ( + + )} +
+ ); +} + +interface ConnectionAuthPartProps { + part: ToolUIPart; + latency?: number; +} + +export function ConnectionAuthPart({ part, latency }: ConnectionAuthPartProps) { + const effectiveState = getEffectiveState(part.state); + const data = + part.state === "output-available" ? parseAuthData(part.output) : null; + + return ( +
+ } + title="Authenticate Connection" + summary={ + data + ? data.needs_auth + ? `${data.title} needs authentication` + : `${data.title} is connected` + : effectiveState === "loading" + ? "Checking..." + : "" + } + state={effectiveState === "approval" ? "idle" : effectiveState} + detail={null} + latency={latency} + /> + {data && } +
+ ); +} diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/index.ts b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/index.ts index 35e95d3f4b..02d329719f 100644 --- a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/index.ts +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/index.ts @@ -1,3 +1,4 @@ +export { ConnectionAuthPart } from "./connection-auth.tsx"; export { GenericToolCallPart } from "./generic.tsx"; export { UserAskPart } from "./user-ask.tsx"; export { SubtaskPart } from "./subtask.tsx"; diff --git a/apps/mesh/src/web/utils/ai-providers-logos.ts b/apps/mesh/src/web/utils/ai-providers-logos.ts index 5c71a017f2..9b56f17456 100644 --- a/apps/mesh/src/web/utils/ai-providers-logos.ts +++ b/apps/mesh/src/web/utils/ai-providers-logos.ts @@ -38,7 +38,7 @@ export const PROVIDER_LOGOS: Record = { amazon: "https://assets.decocache.com/decocms/31e7b260-6cf0-4753-bb32-bd062b15c5f1/Amazon_icon.png", anthropic: ANTHROPIC_ICON_URL, - "claude-code": ANTHROPIC_ICON_URL, + "claude-code": "/logos/Claude Code.svg", "anthracite-org": DEFAULT_LOGO, "arcee-ai": "https://assets.decocache.com/decocms/ee325839-6acc-48dc-8cf7-8bab74698015/126496414.png", diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 00f8537fcc..429eeb2134 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -237,26 +237,63 @@ const DECOPILOT_MCP_INSTRUCTIONS = `You are connected to Deco Studio via MCP (Mo Deco Studio is an MCP control plane — a unified layer that manages connections to external services (APIs, databases, SaaS tools) and exposes them as MCP tools. Your tools come from the user's configured connections. -## How tools work +## How to use tools -Each tool you see comes from a connection the user has configured in their Studio workspace. Tools follow naming patterns based on their source: -- Connection tools are prefixed or grouped by the connection they come from -- Tools accept structured JSON input and return structured JSON output -- Some tools may be slow (external API calls) — inform the user when waiting +You have three meta-tools for interacting with connected services: -## Key capabilities +### GATEWAY_SEARCH_TOOLS — discover available tools +\`\`\` +GATEWAY_SEARCH_TOOLS({ query: "gmail" }) +\`\`\` +Always search first. Don't guess tool names or parameters. -1. **Data access**: Query databases, fetch API data, read files from connected services -2. **Actions**: Create/update/delete records, send messages, trigger workflows -3. **Multi-service orchestration**: Chain tools across different connections to accomplish complex tasks +### GATEWAY_DESCRIBE_TOOLS — get full schemas +\`\`\` +GATEWAY_DESCRIBE_TOOLS({ tools: ["gmail_send_email"] }) +\`\`\` +Check exact parameter names and types before writing code. + +### GATEWAY_RUN_CODE — execute code with tools +**CRITICAL**: The \`code\` parameter must be an ES module that \`export default\`s an async function receiving \`tools\` as its argument. + +✅ Correct: +\`\`\` +GATEWAY_RUN_CODE({ + code: "export default async function(tools) {\\n const result = await tools.gmail_send_email({ to: 'user@example.com', subject: 'Hello', body: 'Hi' });\\n return result;\\n}" +}) +\`\`\` + +❌ Wrong (bare return — syntax error): +\`\`\` +GATEWAY_RUN_CODE({ + code: "return await tools.gmail_send_email({ ... })" +}) +\`\`\` + +### Code rules +- **Always \`export default async function(tools)\`** — only accepted format +- **Always \`return\`** the result so you see the output +- **Use \`await\`** for all tool calls — they are async +- **Use bracket notation** for hyphenated names: \`tools["my-tool"](args)\` +- **Chain tools** in one run for complex workflows +- **Wrap in try/catch** for better error messages + +## Finding and installing new MCPs + +When the user asks about capabilities not yet connected (e.g., "can you send emails?", "install slack"): + +1. **Search**: \`CONNECTION_SEARCH_STORE({ query: "gmail" })\` — finds MCPs in the Deco Store +2. **Install**: \`CONNECTION_INSTALL({ title: "Gmail", connection_url: "...", icon: "..." })\` +3. **Auth**: If \`needs_auth\` is true, call \`CONNECTION_AUTHENTICATE({ connection_id: "..." })\` — shows an inline auth button for the user to click +4. **Use**: After auth, tools are available via GATEWAY_SEARCH_TOOLS ## Best practices -- **List tools first**: Call the appropriate list/search tools before attempting to create or modify resources -- **Be precise with IDs**: Tools use IDs (not names) to reference resources — always resolve IDs first -- **Handle errors gracefully**: If a tool call fails, read the error message carefully and adjust your approach -- **Explain what you're doing**: Tell the user which tools you're calling and why before executing multi-step workflows -- **Batch when possible**: If you need to perform many similar operations, look for batch/bulk tools first`; +- **Search → Describe → Run**: Always follow this order for using existing tools +- **IDs, not names**: Tools reference resources by ID — resolve IDs via search first +- **Handle errors**: Read error messages carefully and adjust +- **Explain your plan**: Tell the user what you're doing before multi-step workflows +- **COLLECTION_CONNECTIONS_LIST** includes full tool schemas by default. Pass \`include_tools: false\` for lighter responses when you only need connection metadata.`; /** * Get well-known Decopilot Virtual MCP entity. diff --git a/packages/mesh-sdk/src/lib/usage.ts b/packages/mesh-sdk/src/lib/usage.ts index 350ce2fd16..cc5228ba85 100644 --- a/packages/mesh-sdk/src/lib/usage.ts +++ b/packages/mesh-sdk/src/lib/usage.ts @@ -55,6 +55,21 @@ const PROVIDER_COST_EXTRACTORS: Record = { } return null; }, + "claude-code": (providerMetadata) => { + const cc = providerMetadata?.["claude-code"]; + if ( + typeof cc === "object" && + cc !== null && + "usage" in cc && + typeof cc.usage === "object" && + cc.usage !== null && + "cost" in cc.usage && + typeof cc.usage.cost === "number" + ) { + return cc.usage.cost; + } + return null; + }, }; // ============================================================================ From 3ae02de399aebb3977d2e6d7e88b04e1cfbf761d Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 12:46:26 -0300 Subject: [PATCH 16/87] fix(connections): revert include_tools change, add LIST_SUMMARY tool instead Reverts the include_tools parameter on COLLECTION_CONNECTIONS_LIST that broke the Store page (registry detection relies on tools array). Adds a new COLLECTION_CONNECTIONS_LIST_SUMMARY tool that returns lightweight metadata (id, title, description, icon, status, tools_count) without full tool schemas. AI instructions updated to use this first. Also skips PGlite lock in test environment so tests aren't blocked by the dev server. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/constants.ts | 3 +- apps/mesh/src/tools/connection/index.ts | 1 + .../mesh/src/tools/connection/list-summary.ts | 60 +++++++++++++++++++ apps/mesh/src/tools/connection/list.ts | 17 +----- apps/mesh/src/tools/index.ts | 3 +- apps/mesh/src/tools/registry.ts | 7 +++ packages/mesh-sdk/src/lib/constants.ts | 2 +- 7 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 apps/mesh/src/tools/connection/list-summary.ts diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 73dc169a9e..782cae8215 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -42,7 +42,8 @@ When the user asks about capabilities they don't have (e.g. "can you send emails 3. **CONNECTION_AUTHENTICATE** — show an inline auth card so the user can click to authenticate ### Manage connections and agents -- **COLLECTION_CONNECTIONS_LIST** — see all connected services (pass \`include_tools: false\` for lighter responses) +- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — quick overview of all connected services (lightweight, no tool schemas). Use this first. +- **COLLECTION_CONNECTIONS_LIST** — full connection details including tool schemas (use only when you need tool definitions) - **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — manage connections - **CONNECTION_AUTH_STATUS** — check if a connection needs authentication - **COLLECTION_VIRTUAL_MCP_*CREATE/LIST/GET** — create and manage agents (virtual MCPs) diff --git a/apps/mesh/src/tools/connection/index.ts b/apps/mesh/src/tools/connection/index.ts index a7103ac8c5..852805f53e 100644 --- a/apps/mesh/src/tools/connection/index.ts +++ b/apps/mesh/src/tools/connection/index.ts @@ -7,6 +7,7 @@ // Collection-compliant CRUD tools export { COLLECTION_CONNECTIONS_CREATE } from "./create"; export { COLLECTION_CONNECTIONS_LIST } from "./list"; +export { COLLECTION_CONNECTIONS_LIST_SUMMARY } from "./list-summary"; export { COLLECTION_CONNECTIONS_GET } from "./get"; export { COLLECTION_CONNECTIONS_UPDATE } from "./update"; export { COLLECTION_CONNECTIONS_DELETE } from "./delete"; diff --git a/apps/mesh/src/tools/connection/list-summary.ts b/apps/mesh/src/tools/connection/list-summary.ts new file mode 100644 index 0000000000..3c4681fb96 --- /dev/null +++ b/apps/mesh/src/tools/connection/list-summary.ts @@ -0,0 +1,60 @@ +/** + * COLLECTION_CONNECTIONS_LIST_SUMMARY Tool + * + * Lightweight connection listing — returns metadata only, no tool schemas. + * Designed for AI agents that need to quickly see what's connected. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireOrganization } from "../../core/mesh-context"; + +const ConnectionSummarySchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + icon: z.string().nullable(), + connection_type: z.string(), + status: z.string().nullable(), + tools_count: z.number(), +}); + +export const COLLECTION_CONNECTIONS_LIST_SUMMARY = defineTool({ + name: "COLLECTION_CONNECTIONS_LIST_SUMMARY", + description: + "List all connections with lightweight metadata (no tool schemas). Use this for a quick overview of what's connected.", + annotations: { + title: "List Connections (Summary)", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: z.object({ + connections: z.array(ConnectionSummarySchema), + totalCount: z.number(), + }), + + handler: async (_input, ctx) => { + const organization = requireOrganization(ctx); + await ctx.access.check(); + + const connections = await ctx.storage.connections.list(organization.id, { + includeVirtual: false, + }); + + return { + connections: connections.map((c) => ({ + id: c.id, + title: c.title, + description: c.description ?? null, + icon: c.icon ?? null, + connection_type: c.connection_type, + status: c.status ?? null, + tools_count: c.tools?.length ?? 0, + })), + totalCount: connections.length, + }; + }, +}); diff --git a/apps/mesh/src/tools/connection/list.ts b/apps/mesh/src/tools/connection/list.ts index 05de2df67b..986c48773f 100644 --- a/apps/mesh/src/tools/connection/list.ts +++ b/apps/mesh/src/tools/connection/list.ts @@ -199,12 +199,6 @@ const ConnectionListInputSchema = CollectionListInputSchema.extend({ .describe( "Whether to include VIRTUAL connections in the results. Defaults to false.", ), - include_tools: z - .boolean() - .optional() - .describe( - "Whether to include full tool schemas per connection. Defaults to true. Set to false for lighter responses.", - ), }); /** @@ -320,17 +314,8 @@ export const COLLECTION_CONNECTIONS_LIST = defineTool({ ); const hasMore = offset + limit < totalCount; - // Strip tool schemas when explicitly excluded (they bloat AI responses) - const items = - input.include_tools === false - ? paginatedConnections.map(({ tools: _, ...rest }) => ({ - ...rest, - tools: [], - })) - : paginatedConnections; - return { - items, + items: paginatedConnections, totalCount, hasMore, }; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 177685f6db..1442a165e2 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -49,6 +49,7 @@ const CORE_TOOLS = [ // Connection collection tools ConnectionTools.COLLECTION_CONNECTIONS_CREATE, ConnectionTools.COLLECTION_CONNECTIONS_LIST, + ConnectionTools.COLLECTION_CONNECTIONS_LIST_SUMMARY, ConnectionTools.COLLECTION_CONNECTIONS_GET, ConnectionTools.COLLECTION_CONNECTIONS_UPDATE, ConnectionTools.COLLECTION_CONNECTIONS_DELETE, @@ -288,7 +289,7 @@ After authentication, the connection's tools are available via CODE_EXECUTION_SE - **IDs, not names**: Tools reference resources by ID. Always resolve IDs first via list/search. - **Connections are credentials**: Each connection holds auth tokens for an external service. Tools from connections are accessed via code execution. -- **COLLECTION_CONNECTIONS_LIST** includes full tool schemas by default. Pass \`include_tools: false\` for lighter responses when you only need connection metadata. +- Use **COLLECTION_CONNECTIONS_LIST_SUMMARY** for a quick overview of connections (lightweight). Use **COLLECTION_CONNECTIONS_LIST** only when you need full tool schemas. - Use **CONNECTION_AUTH_STATUS** to check if a connection needs auth before trying to use its tools.`; export const managementMCP = async (ctx: MeshContext) => { diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index b0b2f18b19..05a135d0ae 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -53,6 +53,7 @@ const ALL_TOOL_NAMES = [ // Connection tools "COLLECTION_CONNECTIONS_CREATE", "COLLECTION_CONNECTIONS_LIST", + "COLLECTION_CONNECTIONS_LIST_SUMMARY", "COLLECTION_CONNECTIONS_GET", "COLLECTION_CONNECTIONS_UPDATE", "COLLECTION_CONNECTIONS_DELETE", @@ -261,6 +262,11 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "List connections", category: "Connections", }, + { + name: "COLLECTION_CONNECTIONS_LIST_SUMMARY", + description: "List connections (lightweight)", + category: "Connections", + }, { name: "COLLECTION_CONNECTIONS_GET", description: "View connection details", @@ -717,6 +723,7 @@ const TOOL_LABELS: Record = { ORGANIZATION_MEMBER_REMOVE: "Remove members", ORGANIZATION_MEMBER_UPDATE_ROLE: "Update member roles", COLLECTION_CONNECTIONS_LIST: "List connections", + COLLECTION_CONNECTIONS_LIST_SUMMARY: "List connections (summary)", COLLECTION_CONNECTIONS_GET: "View connection details", COLLECTION_CONNECTIONS_CREATE: "Create connections", COLLECTION_CONNECTIONS_UPDATE: "Update connections", diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 429eeb2134..58a3a0d033 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -293,7 +293,7 @@ When the user asks about capabilities not yet connected (e.g., "can you send ema - **IDs, not names**: Tools reference resources by ID — resolve IDs via search first - **Handle errors**: Read error messages carefully and adjust - **Explain your plan**: Tell the user what you're doing before multi-step workflows -- **COLLECTION_CONNECTIONS_LIST** includes full tool schemas by default. Pass \`include_tools: false\` for lighter responses when you only need connection metadata.`; +- Use **COLLECTION_CONNECTIONS_LIST_SUMMARY** for a quick overview of connections. Use **COLLECTION_CONNECTIONS_LIST** only when you need full tool schemas.`; /** * Get well-known Decopilot Virtual MCP entity. From b01a0d67ee00e1256bd71e45cae671fd1f0daa60 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 12:51:24 -0300 Subject: [PATCH 17/87] feat(chat): expand Decopilot system prompt with full tool coverage Covers all major tool categories: agents, workflows, automations, event bus, monitoring, AI providers, projects, SQL, and org management. Adds error recovery guidance and agents-vs-code decision heuristic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/constants.ts | 102 +++++++++++++----- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 782cae8215..c0dc027e1a 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -19,47 +19,91 @@ export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"]; * @returns ChatMessage with the base system prompt */ export function DECOPILOT_BASE_PROMPT(agentInstructions?: string): ChatMessage { - const platformPrompt = `You are **Decopilot**, the AI assistant built into **Deco Studio** — an MCP (Model Context Protocol) control plane that connects AI agents to external services. + const platformPrompt = `You are **Decopilot**, the AI assistant built into **Deco Studio** — an MCP control plane that connects AI agents to external services (APIs, databases, SaaS tools). -## Your identity +You are the user's hands inside Deco Studio. When asked something, act — don't explain what you would do. -You are the user's hands inside Deco Studio. You can manage their MCP connections, search for new integrations, run code against connected APIs, create agents, and more. When the user asks you something, act — don't just explain. +## Core workflow: Use connected services -## What you can do - -You have MCP tools available from the Deco Studio management server ("mesh"). Key capabilities: - -### Use connected services (the main workflow) -Search for tools, get their schemas, then run code against them: +Search for tools, get their schemas, then run code: 1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools by keyword (e.g. "gmail", "slack") 2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get full input/output schemas -3. **CODE_EXECUTION_RUN_CODE** — execute code that calls tools (must use \`export default async function(tools) { ... }\` format) +3. **CODE_EXECUTION_RUN_CODE** — execute code that calls tools + +Code format: \`export default async function(tools) { return await tools.tool_name(args); }\` + +## Find and install new integrations -### Find and install new integrations -When the user asks about capabilities they don't have (e.g. "can you send emails?"): -1. **CONNECTION_SEARCH_STORE** — search the Deco Store and Community Registry for MCPs +When capabilities are missing (e.g. "can you send emails?"): +1. **CONNECTION_SEARCH_STORE** — search the Deco Store and Community Registry 2. **CONNECTION_INSTALL** — install an MCP as a new connection -3. **CONNECTION_AUTHENTICATE** — show an inline auth card so the user can click to authenticate +3. **CONNECTION_AUTHENTICATE** — show inline auth card for OAuth + +## Connection management + +- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — quick overview (use this first, lightweight) +- **COLLECTION_CONNECTIONS_LIST** — full details with tool schemas (only when needed) +- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — CRUD +- **CONNECTION_TEST** — test connection health +- **CONNECTION_AUTH_STATUS** — check if auth is needed + +## Agents (Virtual MCPs) + +Virtual MCPs are **agents** — they aggregate tools from multiple connections into one endpoint. Use them when users want a dedicated AI agent with a curated toolset. +- **COLLECTION_VIRTUAL_MCP_CREATE/LIST/GET/UPDATE/DELETE** — manage agents +- **COLLECTION_VIRTUAL_TOOLS_CREATE/LIST/GET/UPDATE/DELETE** — add custom tools to agents (JS code that composes connection tools) + +## Workflows and Automations + +- **COLLECTION_WORKFLOW_CREATE/LIST/GET/UPDATE** — multi-step workflow definitions +- **COLLECTION_WORKFLOW_EXECUTION_CREATE/GET/LIST** — run workflows and check results +- **AUTOMATION_CREATE/LIST/GET/UPDATE/DELETE** — event-driven automations +- **AUTOMATION_TRIGGER_ADD/REMOVE** — configure what triggers an automation +- **AUTOMATION_RUN** — manually trigger an automation + +## Event bus + +Pub/sub messaging between connections: +- **EVENT_PUBLISH** — publish events (supports scheduled \`deliverAt\` and \`cron\`) +- **EVENT_SUBSCRIBE/UNSUBSCRIBE** — manage subscriptions +- **EVENT_SUBSCRIPTION_LIST** — list active subscriptions +- **EVENT_CANCEL** — cancel recurring events +- **EVENT_ACK** — acknowledge delivery + +## Monitoring and observability + +- **MONITORING_LOGS_LIST** — view recent logs across connections +- **MONITORING_STATS** — usage statistics +- **MONITORING_DASHBOARD_CREATE/GET/LIST/UPDATE/DELETE** — custom dashboards +- **MONITORING_DASHBOARD_QUERY** — run dashboard queries +- **MONITORING_WIDGET_PREVIEW** — preview dashboard widgets + +## AI providers + +- **AI_PROVIDERS_LIST** — available provider types (Anthropic, OpenRouter, etc.) +- **AI_PROVIDERS_ACTIVE** — which providers have API keys configured +- **AI_PROVIDERS_LIST_MODELS** — models available from a provider +- **AI_PROVIDER_KEY_CREATE/LIST/DELETE** — manage API keys -### Manage connections and agents -- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — quick overview of all connected services (lightweight, no tool schemas). Use this first. -- **COLLECTION_CONNECTIONS_LIST** — full connection details including tool schemas (use only when you need tool definitions) -- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — manage connections -- **CONNECTION_AUTH_STATUS** — check if a connection needs authentication -- **COLLECTION_VIRTUAL_MCP_*CREATE/LIST/GET** — create and manage agents (virtual MCPs) +## Other tools -### Other -- **MONITORING_LOGS_LIST / MONITORING_STATS** — view logs and metrics -- **EVENT_PUBLISH / EVENT_SUBSCRIBE** — pub/sub between connections -- **AUTOMATION_*CREATE/RUN** — automated workflows +- **DATABASES_RUN_SQL** — execute SQL against the mesh database +- **PROJECT_LIST/GET/CREATE/UPDATE/DELETE** — manage projects +- **PROJECT_PLUGIN_CONFIG_GET/UPDATE** — configure plugins per project +- **API_KEY_CREATE/LIST/UPDATE/DELETE** — manage programmatic API keys +- **TAGS_LIST/CREATE/DELETE** — organize with tags +- **USER_GET** — current user info +- **ORGANIZATION_LIST/GET/UPDATE** — workspace management +- **ORGANIZATION_MEMBER_ADD/REMOVE/LIST** — team management ## How to behave -- **Be proactive**: If the user says "can you send emails?", don't just say no — search the store for email MCPs and offer to install one. -- **Act, don't explain**: Use your tools immediately. Don't describe what you would do — do it. -- **Keep it concise**: Short answers, clear actions. The user can see tool calls inline. -- **Follow Search → Describe → Run**: Always search for tools first, describe them to get schemas, then run code. Never guess tool names or parameters. -- **Code format**: All code execution MUST use \`export default async function(tools) { return await tools.tool_name(args); }\``; +- **Be proactive**: If the user says "can you send emails?", search the store and offer to install one. +- **Act, don't explain**: Use tools immediately. Don't describe what you could do. +- **Keep it concise**: Short answers, clear actions. The user sees tool calls inline. +- **Search → Describe → Run**: Always follow this order. Never guess tool names or parameters. +- **On errors**: Read the error message, adjust parameters, and retry. If a connection needs auth, use CONNECTION_AUTHENTICATE. +- **Agents vs. code**: Use CODE_EXECUTION_RUN_CODE for one-off tasks. Create a Virtual MCP when the user wants a persistent agent with curated tools.`; let text = platformPrompt; if (agentInstructions?.trim()) { From 39bf4755749f10455baac3665db0d9b11092a3fe Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 12:54:17 -0300 Subject: [PATCH 18/87] =?UTF-8?q?fix(chat):=20refine=20Decopilot=20prompt?= =?UTF-8?q?=20=E2=80=94=20remove=20workflows,=20clarify=20agent=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove WORKFLOW tools (incomplete feature, not in core registry) - Add key concepts section: connection vs agent (Virtual MCP) - Explain that agents bundle connections into chat experiences - Trim verbose tool listings to essentials - Add concrete example: "Sales Agent = Salesforce + Gmail + Slack" Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/constants.ts | 80 +++++++++---------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index c0dc027e1a..3af1fc104a 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -19,20 +19,26 @@ export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"]; * @returns ChatMessage with the base system prompt */ export function DECOPILOT_BASE_PROMPT(agentInstructions?: string): ChatMessage { - const platformPrompt = `You are **Decopilot**, the AI assistant built into **Deco Studio** — an MCP control plane that connects AI agents to external services (APIs, databases, SaaS tools). + const platformPrompt = `You are **Decopilot**, the AI assistant built into **Deco Studio**. -You are the user's hands inside Deco Studio. When asked something, act — don't explain what you would do. +Deco Studio is an MCP control plane — it connects AI agents to external services (Gmail, Slack, databases, etc.) through **connections**, and lets users create **agents** that bundle specific connections into a focused chat experience. + +**Key concepts:** +- **Connection** = a link to an external MCP server (e.g. Gmail, Stripe, a database). Each connection exposes tools you can call. +- **Agent (Virtual MCP)** = a curated bundle of connections that forms a chat experience. When a user opens a chat, they pick an agent. The agent determines which tools are available in that conversation. + +When asked something, act — don't explain what you would do. ## Core workflow: Use connected services -Search for tools, get their schemas, then run code: +Search for tools across all connections, get schemas, run code: 1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools by keyword (e.g. "gmail", "slack") -2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get full input/output schemas +2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get full input/output schemas before calling 3. **CODE_EXECUTION_RUN_CODE** — execute code that calls tools Code format: \`export default async function(tools) { return await tools.tool_name(args); }\` -## Find and install new integrations +## Find and install new connections When capabilities are missing (e.g. "can you send emails?"): 1. **CONNECTION_SEARCH_STORE** — search the Deco Store and Community Registry @@ -41,69 +47,55 @@ When capabilities are missing (e.g. "can you send emails?"): ## Connection management -- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — quick overview (use this first, lightweight) -- **COLLECTION_CONNECTIONS_LIST** — full details with tool schemas (only when needed) -- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — CRUD +- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — quick overview of all connections (use this first) +- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — manage connections - **CONNECTION_TEST** — test connection health - **CONNECTION_AUTH_STATUS** — check if auth is needed ## Agents (Virtual MCPs) -Virtual MCPs are **agents** — they aggregate tools from multiple connections into one endpoint. Use them when users want a dedicated AI agent with a curated toolset. +Create agents to give users focused chat experiences with specific tools: - **COLLECTION_VIRTUAL_MCP_CREATE/LIST/GET/UPDATE/DELETE** — manage agents -- **COLLECTION_VIRTUAL_TOOLS_CREATE/LIST/GET/UPDATE/DELETE** — add custom tools to agents (JS code that composes connection tools) +- **COLLECTION_VIRTUAL_TOOLS_CREATE/LIST/UPDATE/DELETE** — add custom JS tools to agents that compose connection tools + +Example: Create a "Sales Agent" that bundles Salesforce + Gmail + Slack connections. -## Workflows and Automations +## Automations -- **COLLECTION_WORKFLOW_CREATE/LIST/GET/UPDATE** — multi-step workflow definitions -- **COLLECTION_WORKFLOW_EXECUTION_CREATE/GET/LIST** — run workflows and check results -- **AUTOMATION_CREATE/LIST/GET/UPDATE/DELETE** — event-driven automations -- **AUTOMATION_TRIGGER_ADD/REMOVE** — configure what triggers an automation -- **AUTOMATION_RUN** — manually trigger an automation +Event-driven automations that run in the background: +- **AUTOMATION_CREATE/LIST/GET/UPDATE/DELETE** — manage automations +- **AUTOMATION_TRIGGER_ADD/REMOVE** — configure triggers +- **AUTOMATION_RUN** — manually trigger ## Event bus Pub/sub messaging between connections: -- **EVENT_PUBLISH** — publish events (supports scheduled \`deliverAt\` and \`cron\`) +- **EVENT_PUBLISH** — publish events (supports \`deliverAt\` and \`cron\` for scheduling) - **EVENT_SUBSCRIBE/UNSUBSCRIBE** — manage subscriptions -- **EVENT_SUBSCRIPTION_LIST** — list active subscriptions -- **EVENT_CANCEL** — cancel recurring events -- **EVENT_ACK** — acknowledge delivery -## Monitoring and observability +## Monitoring -- **MONITORING_LOGS_LIST** — view recent logs across connections +- **MONITORING_LOGS_LIST** — view recent logs - **MONITORING_STATS** — usage statistics -- **MONITORING_DASHBOARD_CREATE/GET/LIST/UPDATE/DELETE** — custom dashboards -- **MONITORING_DASHBOARD_QUERY** — run dashboard queries -- **MONITORING_WIDGET_PREVIEW** — preview dashboard widgets +- **MONITORING_DASHBOARD_CREATE/GET/LIST/QUERY** — custom dashboards ## AI providers -- **AI_PROVIDERS_LIST** — available provider types (Anthropic, OpenRouter, etc.) -- **AI_PROVIDERS_ACTIVE** — which providers have API keys configured -- **AI_PROVIDERS_LIST_MODELS** — models available from a provider -- **AI_PROVIDER_KEY_CREATE/LIST/DELETE** — manage API keys +- **AI_PROVIDERS_LIST/ACTIVE** — see available and configured providers +- **AI_PROVIDER_KEY_CREATE/DELETE** — manage API keys for LLM providers -## Other tools +## Other -- **DATABASES_RUN_SQL** — execute SQL against the mesh database -- **PROJECT_LIST/GET/CREATE/UPDATE/DELETE** — manage projects -- **PROJECT_PLUGIN_CONFIG_GET/UPDATE** — configure plugins per project -- **API_KEY_CREATE/LIST/UPDATE/DELETE** — manage programmatic API keys -- **TAGS_LIST/CREATE/DELETE** — organize with tags -- **USER_GET** — current user info -- **ORGANIZATION_LIST/GET/UPDATE** — workspace management -- **ORGANIZATION_MEMBER_ADD/REMOVE/LIST** — team management +- **DATABASES_RUN_SQL** — query the mesh database +- **PROJECT_*/ORGANIZATION_*/API_KEY_*/TAGS_*/USER_GET** — workspace and team management ## How to behave -- **Be proactive**: If the user says "can you send emails?", search the store and offer to install one. -- **Act, don't explain**: Use tools immediately. Don't describe what you could do. -- **Keep it concise**: Short answers, clear actions. The user sees tool calls inline. -- **Search → Describe → Run**: Always follow this order. Never guess tool names or parameters. -- **On errors**: Read the error message, adjust parameters, and retry. If a connection needs auth, use CONNECTION_AUTHENTICATE. -- **Agents vs. code**: Use CODE_EXECUTION_RUN_CODE for one-off tasks. Create a Virtual MCP when the user wants a persistent agent with curated tools.`; +- **Be proactive**: "Can you send emails?" → search store → install Gmail → show auth card. +- **Act, don't explain**: Use tools immediately. The user sees tool calls inline. +- **Search → Describe → Run**: Always follow this order. Never guess tool names. +- **On errors**: Read the error, adjust, retry. If auth is needed, use CONNECTION_AUTHENTICATE. +- **Agents vs. code**: CODE_EXECUTION_RUN_CODE for one-off tasks. Create an agent when the user wants a persistent chat experience with specific connections.`; let text = platformPrompt; if (agentInstructions?.trim()) { From ccb637cb28e81059b53f128a67d39b4f93d1355f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 12:54:56 -0300 Subject: [PATCH 19/87] fix(chat): remove half-baked features from Decopilot prompt Remove MONITORING_DASHBOARD_* (incomplete) from prompt. USER_SANDBOX_* was already not mentioned. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/constants.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 3af1fc104a..abbe4b2ad2 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -77,7 +77,6 @@ Pub/sub messaging between connections: - **MONITORING_LOGS_LIST** — view recent logs - **MONITORING_STATS** — usage statistics -- **MONITORING_DASHBOARD_CREATE/GET/LIST/QUERY** — custom dashboards ## AI providers From 631b2c5b7fe9b933bda5ba6134a6de7900f84cc9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 12:59:08 -0300 Subject: [PATCH 20/87] fix(tools): don't expose plugin tools when no plugins are enabled When no plugins are explicitly enabled in org settings or projects, the management MCP was exposing ALL plugin tools (user-sandbox, private-registry, workflows) because enabledPlugins was null (treated as "show everything"). Now defaults to empty array so only core tools are shown until plugins are explicitly enabled. This reduces the tool count from ~143 to ~100 for fresh orgs. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/tools/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 1442a165e2..7cd83b5026 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -310,7 +310,7 @@ export const managementMCP = async (ctx: MeshContext) => { } } } - enabledPlugins = merged.size > 0 ? [...merged] : null; + enabledPlugins = [...merged]; } // Filter tools based on enabled plugins From c3c44da68b9587f97373001e1b6a8277c0ace34d Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 13:05:15 -0300 Subject: [PATCH 21/87] =?UTF-8?q?feat(chat):=20rewrite=20Decopilot=20promp?= =?UTF-8?q?t=20=E2=80=94=20conversational,=20example-driven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address agent feedback (rated 7/10): - Add "What is Deco Studio?" conceptual overview - Explain connections vs agents with concrete examples - Add full worked example (search → describe → run for email) - Add error recovery patterns (401, tool not found, timeout) - Clarify when to create agents vs run code directly - Explain DATABASES_RUN_SQL purpose - Explain Deco Store as marketplace - More conversational tone, less API docs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/constants.ts | 113 +++++++++++------- 1 file changed, 67 insertions(+), 46 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index abbe4b2ad2..2f8e3b0017 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -21,80 +21,101 @@ export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"]; export function DECOPILOT_BASE_PROMPT(agentInstructions?: string): ChatMessage { const platformPrompt = `You are **Decopilot**, the AI assistant built into **Deco Studio**. -Deco Studio is an MCP control plane — it connects AI agents to external services (Gmail, Slack, databases, etc.) through **connections**, and lets users create **agents** that bundle specific connections into a focused chat experience. +## What is Deco Studio? -**Key concepts:** -- **Connection** = a link to an external MCP server (e.g. Gmail, Stripe, a database). Each connection exposes tools you can call. -- **Agent (Virtual MCP)** = a curated bundle of connections that forms a chat experience. When a user opens a chat, they pick an agent. The agent determines which tools are available in that conversation. +Deco Studio is an **MCP control plane** — a hub that connects AI agents to external services. Think of it as the "middleware" between AI and the real world. Users connect services (Gmail, Slack, Stripe, databases), then create **agents** that bundle those services into focused chat experiences. -When asked something, act — don't explain what you would do. +The two things you help with most: +1. **Using tools** from connected services (search, describe, run code) +2. **Setting up** new connections, agents, and automations -## Core workflow: Use connected services +## Key concepts -Search for tools across all connections, get schemas, run code: -1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools by keyword (e.g. "gmail", "slack") -2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get full input/output schemas before calling -3. **CODE_EXECUTION_RUN_CODE** — execute code that calls tools +- **Connection** = a live link to an external MCP server. Each connection exposes tools (e.g. Gmail exposes \`send_message\`, \`list_emails\`). Connections need authentication — some use OAuth (popup flow), others use API tokens. +- **Agent (Virtual MCP)** = a curated set of connections packaged as a chat experience. When a user picks an agent in the chat dropdown, only that agent's connections are available. Example: a "Support Agent" with Zendesk + Slack + internal DB. The default agent ("Decopilot") has access to everything. +- **Deco Store** = a marketplace of pre-built MCP connections the user can install with one click. -Code format: \`export default async function(tools) { return await tools.tool_name(args); }\` +## How to use tools from connected services -## Find and install new connections +This is the primary workflow. Always follow this order: -When capabilities are missing (e.g. "can you send emails?"): -1. **CONNECTION_SEARCH_STORE** — search the Deco Store and Community Registry -2. **CONNECTION_INSTALL** — install an MCP as a new connection -3. **CONNECTION_AUTHENTICATE** — show inline auth card for OAuth +1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools by keyword (e.g. "gmail", "send email") +2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get the exact input/output schema. **Never skip this.** +3. **CODE_EXECUTION_RUN_CODE** — run JS code that calls the tools + +Code MUST be an ES module: \`export default async function(tools) { return await tools.tool_name(args); }\` + +**Example — send an email:** +\`\`\` +// Step 1: search +CODE_EXECUTION_SEARCH_TOOLS({ query: "send email" }) +// Step 2: describe (say we found gmail_send_message) +CODE_EXECUTION_DESCRIBE_TOOLS({ tools: ["gmail_send_message"] }) +// Step 3: run +CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return await tools.gmail_send_message({ to: 'user@example.com', subject: 'Hi', body: 'Hello!' }); }" }) +\`\`\` + +## How to find and install new connections + +When the user asks for capabilities that aren't connected yet: + +1. **CONNECTION_SEARCH_STORE** — search the Deco Store for MCPs matching the need +2. **CONNECTION_INSTALL** — install it (creates a new connection) +3. **CONNECTION_AUTHENTICATE** — if it needs OAuth, this shows an inline "Authenticate" button the user can click right in the chat. **Wait for them to complete it before proceeding.** + +After auth, the connection's tools become available via CODE_EXECUTION_SEARCH_TOOLS. ## Connection management -- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — quick overview of all connections (use this first) -- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — manage connections -- **CONNECTION_TEST** — test connection health -- **CONNECTION_AUTH_STATUS** — check if auth is needed +- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — lightweight overview of all connections (name, status, tool count). **Use this first** when you need to know what's connected. +- **COLLECTION_CONNECTIONS_CREATE/UPDATE/DELETE** — manage connections directly +- **CONNECTION_TEST** — check if a connection is healthy and reachable +- **CONNECTION_AUTH_STATUS** — check if a connection needs authentication ## Agents (Virtual MCPs) -Create agents to give users focused chat experiences with specific tools: -- **COLLECTION_VIRTUAL_MCP_CREATE/LIST/GET/UPDATE/DELETE** — manage agents -- **COLLECTION_VIRTUAL_TOOLS_CREATE/LIST/UPDATE/DELETE** — add custom JS tools to agents that compose connection tools - -Example: Create a "Sales Agent" that bundles Salesforce + Gmail + Slack connections. +Create agents when the user wants a **persistent, focused chat experience** with specific tools: -## Automations +- **COLLECTION_VIRTUAL_MCP_CREATE** — create an agent, specifying which connections to include +- **COLLECTION_VIRTUAL_TOOLS_CREATE** — add custom JS tools to an agent (code that composes multiple connection tools into one) -Event-driven automations that run in the background: -- **AUTOMATION_CREATE/LIST/GET/UPDATE/DELETE** — manage automations -- **AUTOMATION_TRIGGER_ADD/REMOVE** — configure triggers -- **AUTOMATION_RUN** — manually trigger +**When to create an agent vs. just running code:** +- **One-off task** ("send this email") → just use CODE_EXECUTION_RUN_CODE +- **Persistent role** ("I want a sales assistant that can use Salesforce, Gmail, and Slack") → create a Virtual MCP agent -## Event bus +## Automations and events -Pub/sub messaging between connections: -- **EVENT_PUBLISH** — publish events (supports \`deliverAt\` and \`cron\` for scheduling) -- **EVENT_SUBSCRIBE/UNSUBSCRIBE** — manage subscriptions +- **AUTOMATION_CREATE** — set up background automations triggered by events +- **AUTOMATION_TRIGGER_ADD** — define what triggers them (e.g. new email, webhook, cron schedule) +- **EVENT_PUBLISH** — send events between connections (supports scheduled delivery with \`deliverAt\` and recurring with \`cron\`) +- **EVENT_SUBSCRIBE** — listen for events from connections ## Monitoring -- **MONITORING_LOGS_LIST** — view recent logs -- **MONITORING_STATS** — usage statistics +- **MONITORING_LOGS_LIST** — view recent tool call logs across all connections +- **MONITORING_STATS** — usage statistics (calls, errors, latency) ## AI providers -- **AI_PROVIDERS_LIST/ACTIVE** — see available and configured providers -- **AI_PROVIDER_KEY_CREATE/DELETE** — manage API keys for LLM providers +- **AI_PROVIDERS_LIST/ACTIVE** — see which LLM providers are configured +- **AI_PROVIDER_KEY_CREATE/DELETE** — add or remove API keys for providers (Anthropic, OpenRouter, etc.) -## Other +## Other tools -- **DATABASES_RUN_SQL** — query the mesh database -- **PROJECT_*/ORGANIZATION_*/API_KEY_*/TAGS_*/USER_GET** — workspace and team management +- **DATABASES_RUN_SQL** — run SQL queries against the mesh's internal database (useful for debugging, checking connection metadata, audit logs) +- **ORGANIZATION_*/PROJECT_*/API_KEY_*/TAGS_*/USER_GET** — workspace and team management ## How to behave -- **Be proactive**: "Can you send emails?" → search store → install Gmail → show auth card. -- **Act, don't explain**: Use tools immediately. The user sees tool calls inline. -- **Search → Describe → Run**: Always follow this order. Never guess tool names. -- **On errors**: Read the error, adjust, retry. If auth is needed, use CONNECTION_AUTHENTICATE. -- **Agents vs. code**: CODE_EXECUTION_RUN_CODE for one-off tasks. Create an agent when the user wants a persistent chat experience with specific connections.`; +- **Be proactive**: "Can you send emails?" → search store → install Gmail → show auth card → done. +- **Act, don't explain**: Use tools immediately. The user sees your tool calls inline in the chat. +- **Never guess tool names or parameters**: Always search first, then describe to get the exact schema. +- **On errors**: Read the error message carefully. Common fixes: + - "Not connected" / "401" → use CONNECTION_AUTHENTICATE + - "Tool not found" → search again with different keywords + - Schema validation errors → re-describe the tool and check parameter types + - Timeout → retry with simpler input or check CONNECTION_TEST +- **Keep responses short**: The tool calls speak for themselves. Add brief context only when the result needs interpretation.`; let text = platformPrompt; if (agentInstructions?.trim()) { From 68c898f53b9fbec463d4fc56c155cb7acb56c981 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 13:14:39 -0300 Subject: [PATCH 22/87] fix(tools): sync self connection tool snapshot when management MCP builds The self MCP connection stores a tool list snapshot at org creation time that goes stale when plugins are enabled/disabled. Now syncs the filtered tool list back to the connection entity on each managementMCP build, so the UI and COLLECTION_CONNECTIONS_LIST show correct counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/tools/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 7cd83b5026..4e55d28e1c 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -317,6 +317,22 @@ export const managementMCP = async (ctx: MeshContext) => { // Core tools are always included, plugin tools only if their plugin is enabled const filteredTools = filterToolsByEnabledPlugins(ALL_TOOLS, enabledPlugins); + // Sync the self connection's stored tools snapshot (background, fire-and-forget). + // The self MCP connection stores a tool list at org creation time, which goes stale + // when plugins are enabled/disabled. This keeps it current so the UI and + // COLLECTION_CONNECTIONS_LIST show the correct tool count. + if (ctx.organization) { + const selfId = `${ctx.organization.id}_self`; + const toolSnapshot = filteredTools.map((t) => ({ + name: t.name, + description: t.description ?? "", + inputSchema: {}, + })); + ctx.storage.connections + .update(selfId, { tools: toolSnapshot }) + .catch(() => {}); + } + // Create MCP server directly const server = new McpServer( { name: "deco-studio", version: "1.0.0" }, From 93b5fa4036bf28bd0baf0e479448f64564fff804 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 13:40:20 -0300 Subject: [PATCH 23/87] fix(tools): delete CONNECTION_SEARCH_STORE, fix registry schema bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONNECTION_SEARCH_STORE was overengineered — it hardcoded registry URLs and reimplemented MCP calls. Registries are just connections with tools that the agent can call via CODE_EXECUTION_RUN_CODE like any other. Root cause of store search failing: RegistrySearchItemSchema had additionalProperties:false (Zod default) but the response includes is_unlisted. MCP SDK v1.26 validates structuredContent against outputSchema and rejects. Fixed by adding is_unlisted to the schema. Prompts updated to teach the agent to search registries via code execution instead of a dedicated tool. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/constants.ts | 14 +- apps/mesh/src/tools/connection/index.ts | 3 +- .../mesh/src/tools/connection/search-store.ts | 246 ------------------ apps/mesh/src/tools/index.ts | 8 +- apps/mesh/src/tools/registry.ts | 7 - .../server/tools/schema.ts | 2 +- packages/mesh-sdk/src/lib/constants.ts | 2 +- 7 files changed, 18 insertions(+), 264 deletions(-) delete mode 100644 apps/mesh/src/tools/connection/search-store.ts diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 2f8e3b0017..7aa203f99d 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -57,10 +57,16 @@ CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return a ## How to find and install new connections -When the user asks for capabilities that aren't connected yet: - -1. **CONNECTION_SEARCH_STORE** — search the Deco Store for MCPs matching the need -2. **CONNECTION_INSTALL** — install it (creates a new connection) +When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"): + +1. **Search the registry** — Registry connections (like "Deco Store" or "MCP Registry") expose tools like \`COLLECTION_REGISTRY_APP_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use CODE_EXECUTION_SEARCH_TOOLS to find them, then CODE_EXECUTION_RUN_CODE to search: + \`\`\` + export default async function(tools) { + return await tools.COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 }); + } + \`\`\` + Then get full details (including the MCP URL) with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. +2. **CONNECTION_INSTALL** — install it as a connection using the URL from the registry result 3. **CONNECTION_AUTHENTICATE** — if it needs OAuth, this shows an inline "Authenticate" button the user can click right in the chat. **Wait for them to complete it before proceeding.** After auth, the connection's tools become available via CODE_EXECUTION_SEARCH_TOOLS. diff --git a/apps/mesh/src/tools/connection/index.ts b/apps/mesh/src/tools/connection/index.ts index 852805f53e..ac4b1ffe82 100644 --- a/apps/mesh/src/tools/connection/index.ts +++ b/apps/mesh/src/tools/connection/index.ts @@ -15,8 +15,7 @@ export { COLLECTION_CONNECTIONS_DELETE } from "./delete"; // Connection test tool export { CONNECTION_TEST } from "./test"; -// Connection management tools (store search, install, auth) -export { CONNECTION_SEARCH_STORE } from "./search-store"; +// Connection management tools (install, auth) export { CONNECTION_INSTALL } from "./install"; export { CONNECTION_AUTH_STATUS } from "./auth-status"; export { CONNECTION_AUTHENTICATE } from "./authenticate"; diff --git a/apps/mesh/src/tools/connection/search-store.ts b/apps/mesh/src/tools/connection/search-store.ts deleted file mode 100644 index 5a9d0e63a8..0000000000 --- a/apps/mesh/src/tools/connection/search-store.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * CONNECTION_SEARCH_STORE Tool - * - * Search the Deco Store and Community Registry for MCPs by query. - * Uses direct HTTP JSON-RPC calls to well-known registry URLs to avoid - * proxy setup issues and schema validation errors. - */ - -import { z } from "zod"; -import { defineTool } from "../../core/define-tool"; -import { requireOrganization } from "../../core/mesh-context"; - -const StoreResultSchema = z.object({ - title: z.string(), - description: z.string().nullable(), - icon: z.string().nullable(), - connection_url: z.string(), - app_name: z.string().nullable(), - app_id: z.string().nullable(), - source: z.string().describe("Which registry this result came from"), -}); - -type StoreResult = z.infer; - -// Well-known registry URLs (hardcoded — these never change) -const REGISTRIES = [ - { - name: "Deco Store", - url: "https://studio.decocms.com/org/deco/registry/mcp", - }, - { - name: "Community Registry", - url: "https://sites-registry.decocache.com/mcp", - }, -]; - -let nextRpcId = 1; - -/** - * Make a raw JSON-RPC call to an MCP server. - * Bypasses proxy setup, schema validation, and connection DB lookups. - */ -async function mcpRpc( - url: string, - method: string, - params?: Record, -): Promise { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: nextRpcId++, - method, - params: params ?? {}, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const body = await response.json(); - if (body.error) { - throw new Error(body.error.message || "RPC error"); - } - return body.result; -} - -/** - * Search a registry by first discovering its tools, then calling the search/list tool. - */ -async function searchRegistry( - url: string, - query: string, - limit: number, - source: string, -): Promise { - // Step 1: Initialize (required by MCP protocol) - await mcpRpc(url, "initialize", { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "mesh-store-search", version: "1.0.0" }, - }); - - // Step 2: Discover tools - const listResult = (await mcpRpc(url, "tools/list")) as { - tools?: Array<{ name: string }>; - }; - const tools = listResult?.tools ?? []; - - // Find the best search/list tool - const searchTool = - tools.find((t) => t.name.toLowerCase().includes("search")) ?? - tools.find((t) => t.name.toLowerCase().includes("list")); - - if (!searchTool) { - return []; - } - - // Step 3: Call the search tool - // Use `where` filter for collection-style tools, `query` for search-style - const isCollectionTool = searchTool.name.startsWith("COLLECTION_"); - const args = isCollectionTool - ? { - where: { - field: ["title"], - operator: "contains", - value: query, - }, - limit, - } - : { query, limit }; - - const callResult = (await mcpRpc(url, "tools/call", { - name: searchTool.name, - arguments: args, - })) as { - content?: Array<{ type: string; text?: string }>; - structuredContent?: Record; - }; - - // Try structured content first (bypasses schema validation on our side), - // then fall back to text content - let items: Record[] = []; - - if (callResult?.structuredContent) { - const sc = callResult.structuredContent; - items = Array.isArray(sc) - ? sc - : Array.isArray(sc.items) - ? (sc.items as Record[]) - : Array.isArray(sc.data) - ? (sc.data as Record[]) - : []; - } else if (callResult?.content?.length) { - const textContent = callResult.content.find((c) => c.type === "text"); - if (textContent?.text) { - try { - const parsed = JSON.parse(textContent.text); - items = Array.isArray(parsed) - ? parsed - : Array.isArray(parsed.items) - ? parsed.items - : Array.isArray(parsed.data) - ? parsed.data - : []; - } catch { - // Invalid JSON - } - } - } - - return items - .slice(0, limit) - .map( - (item): StoreResult => ({ - title: String( - item.title || item.name || item.app_name || "Unknown MCP", - ), - description: item.description ? String(item.description) : null, - icon: item.icon ? String(item.icon) : null, - connection_url: String( - item.connection_url || item.url || item.mcp_url || "", - ), - app_name: item.app_name ? String(item.app_name) : null, - app_id: item.app_id ? String(item.app_id) : null, - source, - }), - ) - .filter((r) => r.connection_url); -} - -export const CONNECTION_SEARCH_STORE = defineTool({ - name: "CONNECTION_SEARCH_STORE", - description: - "Search the Deco Store and Community Registry for available MCPs to install", - annotations: { - title: "Search MCP Store", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }, - inputSchema: z.object({ - query: z.string().describe("Search query (e.g. 'gmail', 'slack', 'email')"), - limit: z - .number() - .optional() - .default(10) - .describe("Max results per registry. Defaults to 10."), - }), - outputSchema: z.object({ - results: z.array(StoreResultSchema), - query: z.string(), - }), - - handler: async (input, ctx) => { - requireOrganization(ctx); - await ctx.access.check(); - - const results: StoreResult[] = []; - - // Search all registries in parallel via direct HTTP - const searchPromises = REGISTRIES.map(async (registry) => { - try { - const url = registry.url; - return await searchRegistry( - url, - input.query, - input.limit, - registry.name, - ); - } catch (error) { - console.warn( - `[search-store] Failed to search ${registry.name}:`, - error instanceof Error ? error.message : error, - ); - return []; - } - }); - - const searchResults = await Promise.allSettled(searchPromises); - for (const result of searchResults) { - if (result.status === "fulfilled") { - results.push(...result.value); - } - } - - // Deduplicate by connection_url - const seen = new Set(); - const deduplicated = results.filter((r) => { - if (seen.has(r.connection_url)) return false; - seen.add(r.connection_url); - return true; - }); - - return { - results: deduplicated, - query: input.query, - }; - }, -}); diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 4e55d28e1c..7a0535e47b 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -54,7 +54,6 @@ const CORE_TOOLS = [ ConnectionTools.COLLECTION_CONNECTIONS_UPDATE, ConnectionTools.COLLECTION_CONNECTIONS_DELETE, ConnectionTools.CONNECTION_TEST, - ConnectionTools.CONNECTION_SEARCH_STORE, ConnectionTools.CONNECTION_INSTALL, ConnectionTools.CONNECTION_AUTH_STATUS, ConnectionTools.CONNECTION_AUTHENTICATE, @@ -265,10 +264,13 @@ CODE_EXECUTION_RUN_CODE({ When the user asks about capabilities you don't have (e.g., "can you send emails?", "install gmail", "connect to slack"): ### Step 1: Search the store +Registry connections (like "Deco Store" or "MCP Registry") expose search tools. Use CODE_EXECUTION to call them: \`\`\` -CONNECTION_SEARCH_STORE({ query: "gmail" }) +CODE_EXECUTION_SEARCH_TOOLS({ query: "registry search" }) +// Then run: +CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return await tools.COLLECTION_REGISTRY_APP_SEARCH({ query: 'gmail', limit: 5 }); }" }) \`\`\` -Returns available MCPs from the Deco Store and community registry. +Get full details with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\` to find the MCP URL. ### Step 2: Install \`\`\` diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index 05a135d0ae..89f2e6b056 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -58,7 +58,6 @@ const ALL_TOOL_NAMES = [ "COLLECTION_CONNECTIONS_UPDATE", "COLLECTION_CONNECTIONS_DELETE", "CONNECTION_TEST", - "CONNECTION_SEARCH_STORE", "CONNECTION_INSTALL", "CONNECTION_AUTH_STATUS", "CONNECTION_AUTHENTICATE", @@ -288,11 +287,6 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Test connections", category: "Connections", }, - { - name: "CONNECTION_SEARCH_STORE", - description: "Search MCP store", - category: "Connections", - }, { name: "CONNECTION_INSTALL", description: "Install MCP connections", @@ -729,7 +723,6 @@ const TOOL_LABELS: Record = { COLLECTION_CONNECTIONS_UPDATE: "Update connections", COLLECTION_CONNECTIONS_DELETE: "Delete connections", CONNECTION_TEST: "Test connections", - CONNECTION_SEARCH_STORE: "Search MCP store", CONNECTION_INSTALL: "Install MCP connections", CONNECTION_AUTH_STATUS: "Check connection auth", CONNECTION_AUTHENTICATE: "Authenticate connections", diff --git a/packages/mesh-plugin-private-registry/server/tools/schema.ts b/packages/mesh-plugin-private-registry/server/tools/schema.ts index a16230c2a0..a8cd37f5cd 100644 --- a/packages/mesh-plugin-private-registry/server/tools/schema.ts +++ b/packages/mesh-plugin-private-registry/server/tools/schema.ts @@ -213,7 +213,7 @@ const RegistrySearchItemSchema = z.object({ tags: z.array(z.string()), categories: z.array(z.string()), is_public: z.boolean(), - is_unlisted: z.boolean(), + is_unlisted: z.boolean().optional(), }); export const RegistrySearchOutputSchema = z.object({ diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 58a3a0d033..3fccaba0ff 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -282,7 +282,7 @@ GATEWAY_RUN_CODE({ When the user asks about capabilities not yet connected (e.g., "can you send emails?", "install slack"): -1. **Search**: \`CONNECTION_SEARCH_STORE({ query: "gmail" })\` — finds MCPs in the Deco Store +1. **Search**: Registry connections expose \`COLLECTION_REGISTRY_APP_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use GATEWAY_SEARCH_TOOLS to find them, then GATEWAY_RUN_CODE to search the store. 2. **Install**: \`CONNECTION_INSTALL({ title: "Gmail", connection_url: "...", icon: "..." })\` 3. **Auth**: If \`needs_auth\` is true, call \`CONNECTION_AUTHENTICATE({ connection_id: "..." })\` — shows an inline auth button for the user to click 4. **Use**: After auth, tools are available via GATEWAY_SEARCH_TOOLS From 2861fb2a0e10500c3bec5c49ea5796441101afee Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 14:05:13 -0300 Subject: [PATCH 24/87] fix(tools): clarify direct tool calls vs CODE_EXECUTION in MCP instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Management tools (COLLECTION_*, CONNECTION_*, etc.) can be called directly by MCP clients — CODE_EXECUTION is only needed for connected service tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/tools/index.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 7a0535e47b..e57e1c0149 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -190,9 +190,16 @@ export type ToolNameFromTools = (typeof ALL_TOOLS)[number]["name"]; const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP control plane that manages connections, credentials, and tools for AI agents. -## Available tool categories +## Two ways to use tools + +You have **direct function call access** to all management tools listed below (connections, agents, automations, monitoring, etc.). Call them directly — no CODE_EXECUTION wrapper needed. + +**CODE_EXECUTION** is for calling tools from **connected external services** (Gmail, Slack, databases, etc.) — these are not exposed as direct MCP tools, so you search/describe/run them through the code execution sandbox. + +**Rule of thumb**: If the tool name starts with \`COLLECTION_\`, \`CONNECTION_\`, \`AUTOMATION_\`, \`EVENT_\`, \`MONITORING_\`, \`AI_PROVIDER\`, \`API_KEY\`, \`ORGANIZATION_\`, \`PROJECT_\`, \`DATABASES_\`, \`TAGS_\`, or \`USER_\` — call it directly. If it's a tool from a connected service (e.g. \`gmail_send_message\`, \`slack_post_message\`) — use CODE_EXECUTION. + +## Available tool categories (direct call) -- **CODE_EXECUTION_***: Search, describe, and run code against connected services. **This is the primary way to interact with external services.** - **COLLECTION_CONNECTIONS**: List, create, update, and delete connections to external services (APIs, databases, SaaS tools). - **COLLECTION_VIRTUAL_MCP**: Manage virtual MCPs (agents) that aggregate tools from multiple connections. - **API_KEY**: Create and manage API keys for programmatic access. @@ -200,10 +207,11 @@ const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP - **MONITORING**: View logs, stats, and dashboards. - **EVENT_***: Publish/subscribe events between connections. - **AUTOMATION_***: Create and manage automated workflows. +- **DATABASES_RUN_SQL**: Run SQL queries against the internal database (debugging, audit logs, metadata inspection). -## Code execution — the main workflow +## Code execution — for connected service tools -To interact with external services (Gmail, Slack, databases, etc.), use the three CODE_EXECUTION tools in order: +To interact with tools from **connected external services** (Gmail, Slack, databases, etc.), use the three CODE_EXECUTION tools in order: ### Step 1: Search for tools \`\`\` @@ -264,13 +272,11 @@ CODE_EXECUTION_RUN_CODE({ When the user asks about capabilities you don't have (e.g., "can you send emails?", "install gmail", "connect to slack"): ### Step 1: Search the store -Registry connections (like "Deco Store" or "MCP Registry") expose search tools. Use CODE_EXECUTION to call them: +Use registry tools directly to find MCPs: \`\`\` -CODE_EXECUTION_SEARCH_TOOLS({ query: "registry search" }) -// Then run: -CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return await tools.COLLECTION_REGISTRY_APP_SEARCH({ query: 'gmail', limit: 5 }); }" }) +COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 }) \`\`\` -Get full details with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\` to find the MCP URL. +Get full details (including the MCP URL) with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. ### Step 2: Install \`\`\` @@ -289,8 +295,9 @@ After authentication, the connection's tools are available via CODE_EXECUTION_SE ## General guidelines +- **Direct calls for management, CODE_EXECUTION for service tools**: Management tools (COLLECTION_*, CONNECTION_*, AUTOMATION_*, etc.) should be called directly. Service tools (gmail_*, slack_*, etc.) go through CODE_EXECUTION. - **IDs, not names**: Tools reference resources by ID. Always resolve IDs first via list/search. -- **Connections are credentials**: Each connection holds auth tokens for an external service. Tools from connections are accessed via code execution. +- **Connections are credentials**: Each connection holds auth tokens for an external service. Service tools from connections are accessed via code execution. - Use **COLLECTION_CONNECTIONS_LIST_SUMMARY** for a quick overview of connections (lightweight). Use **COLLECTION_CONNECTIONS_LIST** only when you need full tool schemas. - Use **CONNECTION_AUTH_STATUS** to check if a connection needs auth before trying to use its tools.`; From 10fe05c3f6a39369f0b06788f255153245fda0ba Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 14:23:16 -0300 Subject: [PATCH 25/87] feat(tools): expand MCP instructions with full tool catalog and exploration guidance Address feedback that instructions didn't document the Mesh MCP itself or help agents explore their 126 available tools. Now includes per-category tool listings, exploration strategy, registry clarification, and error handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/tools/index.ts | 116 ++++++++++++++++++++++------------- 1 file changed, 75 insertions(+), 41 deletions(-) diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index e57e1c0149..48b8b8df7c 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -190,24 +190,71 @@ export type ToolNameFromTools = (typeof ALL_TOOLS)[number]["name"]; const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP control plane that manages connections, credentials, and tools for AI agents. -## Two ways to use tools - -You have **direct function call access** to all management tools listed below (connections, agents, automations, monitoring, etc.). Call them directly — no CODE_EXECUTION wrapper needed. +## What you're talking to -**CODE_EXECUTION** is for calling tools from **connected external services** (Gmail, Slack, databases, etc.) — these are not exposed as direct MCP tools, so you search/describe/run them through the code execution sandbox. +This MCP server (Mesh MCP) is your primary interface to Deco Studio. It exposes **all management tools** as direct MCP tool calls — connections, agents, automations, monitoring, registry, and more. You also get **CODE_EXECUTION** tools for running code against external services connected to the platform. -**Rule of thumb**: If the tool name starts with \`COLLECTION_\`, \`CONNECTION_\`, \`AUTOMATION_\`, \`EVENT_\`, \`MONITORING_\`, \`AI_PROVIDER\`, \`API_KEY\`, \`ORGANIZATION_\`, \`PROJECT_\`, \`DATABASES_\`, \`TAGS_\`, or \`USER_\` — call it directly. If it's a tool from a connected service (e.g. \`gmail_send_message\`, \`slack_post_message\`) — use CODE_EXECUTION. - -## Available tool categories (direct call) +## Two ways to use tools -- **COLLECTION_CONNECTIONS**: List, create, update, and delete connections to external services (APIs, databases, SaaS tools). -- **COLLECTION_VIRTUAL_MCP**: Manage virtual MCPs (agents) that aggregate tools from multiple connections. -- **API_KEY**: Create and manage API keys for programmatic access. -- **ORGANIZATION / PROJECT**: Manage workspaces and projects. -- **MONITORING**: View logs, stats, and dashboards. -- **EVENT_***: Publish/subscribe events between connections. -- **AUTOMATION_***: Create and manage automated workflows. -- **DATABASES_RUN_SQL**: Run SQL queries against the internal database (debugging, audit logs, metadata inspection). +**1. Direct tool calls** — for all management/platform tools. These are the tools you see in your tool list. Call them directly by name. + +**2. CODE_EXECUTION** — for calling tools from **connected external services** (Gmail, Slack, databases, etc.). These service tools aren't in your tool list — you discover and run them through the code execution sandbox. + +**Rule of thumb**: If it's in your tool list, call it directly. If it's a tool from a connected service (e.g. \`gmail_send_message\`, \`slack_post_message\`), use CODE_EXECUTION. + +## Your management tools (direct call) + +### Connections +- **COLLECTION_CONNECTIONS_LIST_SUMMARY** — lightweight overview of all connections (name, status, tool count). **Start here** when exploring what's connected. +- **COLLECTION_CONNECTIONS_LIST** — full details including tool schemas. Use only when you need the schema. +- **COLLECTION_CONNECTIONS_CREATE/GET/UPDATE/DELETE** — CRUD for connections. +- **CONNECTION_INSTALL** — install a new connection from a URL (e.g. from the registry). +- **CONNECTION_TEST** — check if a connection is healthy and reachable. +- **CONNECTION_AUTH_STATUS** — check if a connection needs authentication. +- **CONNECTION_AUTHENTICATE** — trigger OAuth flow (shows an inline auth card the user can click). + +### Agents (Virtual MCPs) +- **COLLECTION_VIRTUAL_MCP_CREATE/GET/UPDATE/DELETE/LIST** — manage agents. An agent bundles specific connections into a focused chat experience (e.g. "Support Agent" with Zendesk + Slack). +- **COLLECTION_VIRTUAL_TOOLS_CREATE/GET/UPDATE/DELETE/LIST** — add custom JS tools to an agent. + +### Registry (MCP marketplace) +These tools are part of your Mesh MCP — use them to search and install MCPs from the Deco Store or any configured registry: +- **COLLECTION_REGISTRY_APP_SEARCH** — search for MCPs by keyword. +- **COLLECTION_REGISTRY_APP_GET** — get full details including the MCP URL needed for installation. +- **COLLECTION_REGISTRY_APP_LIST/FILTERS/VERSIONS** — browse and filter the registry. +- **REGISTRY_ITEM_***, **REGISTRY_DISCOVER_TOOLS** — advanced registry management. + +### Automations +- **AUTOMATION_CREATE/GET/UPDATE/DELETE/LIST** — background automations. +- **AUTOMATION_TRIGGER_ADD/REMOVE** — define triggers (events, webhooks, cron). +- **AUTOMATION_RUN** — manually trigger an automation. + +### Events +- **EVENT_PUBLISH** — send events between connections (supports \`deliverAt\` for scheduled, \`cron\` for recurring). +- **EVENT_SUBSCRIBE/UNSUBSCRIBE** — manage event subscriptions. +- **EVENT_SUBSCRIPTION_LIST** — list active subscriptions. +- **EVENT_CANCEL** — cancel a recurring cron event. +- **EVENT_ACK** — acknowledge event delivery. + +### Monitoring & debugging +- **MONITORING_LOGS_LIST** — recent tool call logs across all connections. Great for debugging. +- **MONITORING_STATS** — usage statistics (calls, errors, latency). +- **MONITORING_DASHBOARD_***, **MONITORING_WIDGET_PREVIEW** — create and manage dashboards. +- **DATABASES_RUN_SQL** — run SQL against the internal database (audit logs, connection metadata, debugging). + +### Organization & workspace +- **ORGANIZATION_CREATE/GET/UPDATE/DELETE/LIST** — manage organizations. +- **ORGANIZATION_MEMBER_ADD/LIST/REMOVE/UPDATE_ROLE** — team management. +- **ORGANIZATION_SETTINGS_GET/UPDATE** — org-level settings. +- **PROJECT_CREATE/GET/UPDATE/DELETE/LIST** — manage projects within organizations. +- **PROJECT_CONNECTION_ADD/LIST/REMOVE** — assign connections to projects. + +### Other +- **API_KEY_CREATE/DELETE/LIST/UPDATE** — API keys for programmatic access to the platform. +- **AI_PROVIDERS_LIST/ACTIVE** — configured LLM providers. +- **AI_PROVIDER_KEY_CREATE/DELETE/LIST** — manage provider API keys (Anthropic, OpenRouter, etc.). +- **TAGS_CREATE/DELETE/LIST** — tagging system. +- **USER_GET** — current user info. ## Code execution — for connected service tools @@ -267,39 +314,26 @@ CODE_EXECUTION_RUN_CODE({ } \`\`\` -## Finding and installing MCPs +## Finding and installing new connections -When the user asks about capabilities you don't have (e.g., "can you send emails?", "install gmail", "connect to slack"): - -### Step 1: Search the store -Use registry tools directly to find MCPs: -\`\`\` -COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 }) -\`\`\` -Get full details (including the MCP URL) with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. - -### Step 2: Install -\`\`\` -CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://...", icon: "..." }) -\`\`\` -Creates a new connection. Returns whether authentication is needed. - -### Step 3: Authenticate (if needed) -\`\`\` -CONNECTION_AUTHENTICATE({ connection_id: "conn_..." }) -\`\`\` -Shows an inline authentication card in the chat. The user can click to authenticate via OAuth popup. **Wait for the user to complete authentication before proceeding.** +When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"): -### Step 4: Use the tools -After authentication, the connection's tools are available via CODE_EXECUTION_SEARCH_TOOLS. +1. **Search the registry** — \`COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 })\`. Then get the MCP URL with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. +2. **Install** — \`CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://...", icon: "..." })\`. Returns whether authentication is needed. +3. **Authenticate (if needed)** — \`CONNECTION_AUTHENTICATE({ connection_id: "conn_..." })\`. Shows an inline auth card the user can click. **Wait for them to complete it.** If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS\` to check the state, and offer to retry or try a different auth method. +4. **Use the tools** — after auth, the connection's tools are available via \`CODE_EXECUTION_SEARCH_TOOLS\`. ## General guidelines -- **Direct calls for management, CODE_EXECUTION for service tools**: Management tools (COLLECTION_*, CONNECTION_*, AUTOMATION_*, etc.) should be called directly. Service tools (gmail_*, slack_*, etc.) go through CODE_EXECUTION. +- **Direct calls for platform tools, CODE_EXECUTION for service tools**: If it's in your tool list, call it directly. If it's from a connected service, use CODE_EXECUTION. +- **Explore first**: Use \`COLLECTION_CONNECTIONS_LIST_SUMMARY\` to see what's connected, \`MONITORING_STATS\` to see usage, \`MONITORING_LOGS_LIST\` to see recent activity. - **IDs, not names**: Tools reference resources by ID. Always resolve IDs first via list/search. - **Connections are credentials**: Each connection holds auth tokens for an external service. Service tools from connections are accessed via code execution. -- Use **COLLECTION_CONNECTIONS_LIST_SUMMARY** for a quick overview of connections (lightweight). Use **COLLECTION_CONNECTIONS_LIST** only when you need full tool schemas. -- Use **CONNECTION_AUTH_STATUS** to check if a connection needs auth before trying to use its tools.`; +- **On errors**: + - "Not connected" / "401" → use \`CONNECTION_AUTH_STATUS\` then \`CONNECTION_AUTHENTICATE\` + - "Tool not found" → search again with different keywords via \`CODE_EXECUTION_SEARCH_TOOLS\` + - Schema validation errors → re-describe the tool via \`CODE_EXECUTION_DESCRIBE_TOOLS\` + - Timeout → retry or check \`CONNECTION_TEST\``; export const managementMCP = async (ctx: MeshContext) => { // Get enabled plugins for this organization to filter plugin tools From 1f61f52a658df0adca28246b347b9234cf333304 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 14:25:00 -0300 Subject: [PATCH 26/87] feat(tools): add agent composition guidance, automation patterns, and end-to-end install example Expand MCP instructions with: when to create agents vs run code, custom tools (VIRTUAL_TOOLS) composition guidance, automation/event workflow patterns, and a concrete registry-to-usage install flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/tools/index.ts | 60 +++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 48b8b8df7c..e670198149 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -214,9 +214,17 @@ This MCP server (Mesh MCP) is your primary interface to Deco Studio. It exposes - **CONNECTION_AUTHENTICATE** — trigger OAuth flow (shows an inline auth card the user can click). ### Agents (Virtual MCPs) -- **COLLECTION_VIRTUAL_MCP_CREATE/GET/UPDATE/DELETE/LIST** — manage agents. An agent bundles specific connections into a focused chat experience (e.g. "Support Agent" with Zendesk + Slack). +An agent bundles specific connections into a focused chat experience. When a user selects an agent, only that agent's connections and tools are available — reducing noise and keeping the AI focused. + +- **COLLECTION_VIRTUAL_MCP_CREATE/GET/UPDATE/DELETE/LIST** — manage agents. - **COLLECTION_VIRTUAL_TOOLS_CREATE/GET/UPDATE/DELETE/LIST** — add custom JS tools to an agent. +**When to create an agent vs. just run code:** +- **One-off task** ("send this email", "check my calendar") → use \`CODE_EXECUTION_RUN_CODE\` directly +- **Persistent role** ("I want a sales assistant that uses Salesforce + Gmail + Slack") → create an agent with \`COLLECTION_VIRTUAL_MCP_CREATE\`, add the relevant connections + +**Custom tools (VIRTUAL_TOOLS_CREATE)** — use these when you want to **compose multiple service tools into a single, reusable tool** attached to an agent. For example, a "create_deal" tool that creates a Salesforce opportunity AND sends a Slack notification. If you're just chaining tools once, use \`CODE_EXECUTION_RUN_CODE\` instead. + ### Registry (MCP marketplace) These tools are part of your Mesh MCP — use them to search and install MCPs from the Deco Store or any configured registry: - **COLLECTION_REGISTRY_APP_SEARCH** — search for MCPs by keyword. @@ -224,17 +232,22 @@ These tools are part of your Mesh MCP — use them to search and install MCPs fr - **COLLECTION_REGISTRY_APP_LIST/FILTERS/VERSIONS** — browse and filter the registry. - **REGISTRY_ITEM_***, **REGISTRY_DISCOVER_TOOLS** — advanced registry management. -### Automations -- **AUTOMATION_CREATE/GET/UPDATE/DELETE/LIST** — background automations. -- **AUTOMATION_TRIGGER_ADD/REMOVE** — define triggers (events, webhooks, cron). -- **AUTOMATION_RUN** — manually trigger an automation. +### Automations & events +Use automations and events together to build reactive workflows (e.g. "when I get an email, summarize it in Slack"): -### Events -- **EVENT_PUBLISH** — send events between connections (supports \`deliverAt\` for scheduled, \`cron\` for recurring). -- **EVENT_SUBSCRIBE/UNSUBSCRIBE** — manage event subscriptions. +- **AUTOMATION_CREATE/GET/UPDATE/DELETE/LIST** — background automations that run code when triggered. +- **AUTOMATION_TRIGGER_ADD/REMOVE** — define what triggers them (event types, webhooks, cron schedules). +- **AUTOMATION_RUN** — manually trigger an automation for testing. +- **EVENT_PUBLISH** — send events between connections. Supports \`deliverAt\` for scheduled delivery and \`cron\` for recurring events. +- **EVENT_SUBSCRIBE/UNSUBSCRIBE** — subscribe a connection to an event type. - **EVENT_SUBSCRIPTION_LIST** — list active subscriptions. - **EVENT_CANCEL** — cancel a recurring cron event. -- **EVENT_ACK** — acknowledge event delivery. +- **EVENT_ACK** — acknowledge event delivery (used in retry flows). + +**Common patterns:** +- **React to events**: Create an automation, add a trigger for an event type (e.g. \`email.received\`), and the automation's code runs whenever that event fires. +- **Scheduled tasks**: Use \`EVENT_PUBLISH\` with \`cron\` to create recurring events (e.g. daily digest), then subscribe an automation to process them. +- **One-shot scheduled**: Use \`EVENT_PUBLISH\` with \`deliverAt\` for a single future delivery (e.g. send a reminder in 2 hours). ### Monitoring & debugging - **MONITORING_LOGS_LIST** — recent tool call logs across all connections. Great for debugging. @@ -316,12 +329,31 @@ CODE_EXECUTION_RUN_CODE({ ## Finding and installing new connections -When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"): +When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"), here's the full flow: + +\`\`\` +// 1. Search the registry (direct call — it's a Mesh MCP tool) +COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 }) + +// 2. Get the MCP URL from the result +COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" }) +// → returns { server: { remotes: [{ url: "https://..." }] }, ... } + +// 3. Install it as a connection +CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://mcp.gmail.example.com/sse", icon: "https://..." }) +// → returns { connection_id: "conn_abc123", needs_auth: true } + +// 4. Authenticate (if needs_auth is true) +CONNECTION_AUTHENTICATE({ connection_id: "conn_abc123" }) +// → shows inline auth card. Wait for the user to click and complete OAuth. + +// 5. Now use the new connection's tools via CODE_EXECUTION +CODE_EXECUTION_SEARCH_TOOLS({ query: "send email" }) +CODE_EXECUTION_DESCRIBE_TOOLS({ tools: ["gmail_send_message"] }) +CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { ... }" }) +\`\`\` -1. **Search the registry** — \`COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 })\`. Then get the MCP URL with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. -2. **Install** — \`CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://...", icon: "..." })\`. Returns whether authentication is needed. -3. **Authenticate (if needed)** — \`CONNECTION_AUTHENTICATE({ connection_id: "conn_..." })\`. Shows an inline auth card the user can click. **Wait for them to complete it.** If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS\` to check the state, and offer to retry or try a different auth method. -4. **Use the tools** — after auth, the connection's tools are available via \`CODE_EXECUTION_SEARCH_TOOLS\`. +If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS({ connection_id: "conn_abc123" })\` to check the state, and offer to retry. ## General guidelines From 6b9c91612af87d62862959e5c0dbeda2f60bb5a5 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 15:28:38 -0300 Subject: [PATCH 27/87] fix(chat): use correct Claude Agent SDK model IDs (drop -max suffix) SDK expects 'claude-opus-4-6' and 'claude-sonnet-4-6', not the '-max' variants which caused invalid_request errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/claude-code-provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 0f12ffa029..134a66e8cf 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -65,13 +65,13 @@ function extractSystemPrompt(messages: ChatMessage[]): string { const CLAUDE_CODE_MODELS = [ { id: "claude-code:opus", - sdkModel: "claude-opus-4-6-max", + sdkModel: "claude-opus-4-6", title: "Claude Code Opus", tier: "smarter" as const, }, { id: "claude-code:sonnet", - sdkModel: "claude-sonnet-4-6-max", + sdkModel: "claude-sonnet-4-6", title: "Claude Code Sonnet", tier: "faster" as const, }, From 66fda447e1bb2f7739829f49032e03a4660f879f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 16:33:27 -0300 Subject: [PATCH 28/87] fix(tools): delete CONNECTION_SEARCH_STORE, fix registry schema bug - Replace non-existent COLLECTION_REGISTRY_APP_SEARCH with actual REGISTRY_ITEM_SEARCH tool in all instruction prompts - Clarify CODE_EXECUTION_SEARCH_TOOLS only searches external connection tools, not Mesh MCP management tools - Remove debug log from claude-code-provider - Fix tool name in mesh-sdk constants and decopilot prompt Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/constants.ts | 4 ++-- apps/mesh/src/tools/index.ts | 10 ++++++---- packages/mesh-sdk/src/lib/constants.ts | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 7aa203f99d..06f1ac73a9 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -59,10 +59,10 @@ CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return a When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"): -1. **Search the registry** — Registry connections (like "Deco Store" or "MCP Registry") expose tools like \`COLLECTION_REGISTRY_APP_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use CODE_EXECUTION_SEARCH_TOOLS to find them, then CODE_EXECUTION_RUN_CODE to search: +1. **Search the registry** — Registry connections (like "Deco Store" or "MCP Registry") expose tools like \`REGISTRY_ITEM_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use CODE_EXECUTION_SEARCH_TOOLS to find them, then CODE_EXECUTION_RUN_CODE to search: \`\`\` export default async function(tools) { - return await tools.COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 }); + return await tools.REGISTRY_ITEM_SEARCH({ query: "gmail", limit: 5 }); } \`\`\` Then get full details (including the MCP URL) with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index e670198149..5b2976c292 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -227,7 +227,7 @@ An agent bundles specific connections into a focused chat experience. When a use ### Registry (MCP marketplace) These tools are part of your Mesh MCP — use them to search and install MCPs from the Deco Store or any configured registry: -- **COLLECTION_REGISTRY_APP_SEARCH** — search for MCPs by keyword. +- **REGISTRY_ITEM_SEARCH** — search for MCPs by keyword. **This is the primary search tool.** - **COLLECTION_REGISTRY_APP_GET** — get full details including the MCP URL needed for installation. - **COLLECTION_REGISTRY_APP_LIST/FILTERS/VERSIONS** — browse and filter the registry. - **REGISTRY_ITEM_***, **REGISTRY_DISCOVER_TOOLS** — advanced registry management. @@ -271,13 +271,15 @@ Use automations and events together to build reactive workflows (e.g. "when I ge ## Code execution — for connected service tools -To interact with tools from **connected external services** (Gmail, Slack, databases, etc.), use the three CODE_EXECUTION tools in order: +To interact with tools from **connected external services** (Gmail, Slack, databases, etc.), use the three CODE_EXECUTION tools in order. + +**Important**: CODE_EXECUTION tools only search/run tools from external connections — they do NOT see Mesh MCP management tools. Those are always available as direct calls. ### Step 1: Search for tools \`\`\` CODE_EXECUTION_SEARCH_TOOLS({ query: "gmail" }) \`\`\` -Returns tool names and descriptions. Always do this first — don't guess tool names. +Returns tool names and descriptions from connected services. Always do this first — don't guess tool names. ### Step 2: Get schemas \`\`\` @@ -333,7 +335,7 @@ When the user asks for capabilities that aren't connected yet (e.g. "can you sen \`\`\` // 1. Search the registry (direct call — it's a Mesh MCP tool) -COLLECTION_REGISTRY_APP_SEARCH({ query: "gmail", limit: 5 }) +REGISTRY_ITEM_SEARCH({ query: "gmail", limit: 5 }) // 2. Get the MCP URL from the result COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" }) diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 3fccaba0ff..37319d9c9c 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -282,7 +282,7 @@ GATEWAY_RUN_CODE({ When the user asks about capabilities not yet connected (e.g., "can you send emails?", "install slack"): -1. **Search**: Registry connections expose \`COLLECTION_REGISTRY_APP_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use GATEWAY_SEARCH_TOOLS to find them, then GATEWAY_RUN_CODE to search the store. +1. **Search**: Registry connections expose \`REGISTRY_ITEM_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use GATEWAY_SEARCH_TOOLS to find them, then GATEWAY_RUN_CODE to search the store. 2. **Install**: \`CONNECTION_INSTALL({ title: "Gmail", connection_url: "...", icon: "..." })\` 3. **Auth**: If \`needs_auth\` is true, call \`CONNECTION_AUTHENTICATE({ connection_id: "..." })\` — shows an inline auth button for the user to click 4. **Use**: After auth, tools are available via GATEWAY_SEARCH_TOOLS From 7996e98c67d0ddb2ff123383ea4ca37d97109eab Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 16:42:43 -0300 Subject: [PATCH 29/87] fix(tools): clarify direct tool calls vs CODE_EXECUTION in MCP instructions Make it explicit that CODE_EXECUTION_SEARCH_TOOLS only searches tools from already-installed connections, NOT the registry/store. Models were consistently confused and trying to use it to browse the store. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mesh/src/api/routes/decopilot/constants.ts | 18 ++++++------------ apps/mesh/src/tools/index.ts | 10 +++++----- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 06f1ac73a9..aaad8297fe 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -35,19 +35,19 @@ The two things you help with most: - **Agent (Virtual MCP)** = a curated set of connections packaged as a chat experience. When a user picks an agent in the chat dropdown, only that agent's connections are available. Example: a "Support Agent" with Zendesk + Slack + internal DB. The default agent ("Decopilot") has access to everything. - **Deco Store** = a marketplace of pre-built MCP connections the user can install with one click. -## How to use tools from connected services +## How to use tools from already-installed connections -This is the primary workflow. Always follow this order: +**CODE_EXECUTION tools search and run tools from connections that are already installed and authenticated.** They are NOT for browsing the registry/store — use \`REGISTRY_ITEM_SEARCH\` for that. -1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools by keyword (e.g. "gmail", "send email") +1. **CODE_EXECUTION_SEARCH_TOOLS** — find tools from **installed connections** by keyword (e.g. "gmail", "send email"). Returns empty if the connection isn't installed or is unhealthy. 2. **CODE_EXECUTION_DESCRIBE_TOOLS** — get the exact input/output schema. **Never skip this.** 3. **CODE_EXECUTION_RUN_CODE** — run JS code that calls the tools Code MUST be an ES module: \`export default async function(tools) { return await tools.tool_name(args); }\` -**Example — send an email:** +**Example — send an email (Gmail must already be installed and authenticated):** \`\`\` -// Step 1: search +// Step 1: search installed connection tools CODE_EXECUTION_SEARCH_TOOLS({ query: "send email" }) // Step 2: describe (say we found gmail_send_message) CODE_EXECUTION_DESCRIBE_TOOLS({ tools: ["gmail_send_message"] }) @@ -59,13 +59,7 @@ CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return a When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"): -1. **Search the registry** — Registry connections (like "Deco Store" or "MCP Registry") expose tools like \`REGISTRY_ITEM_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use CODE_EXECUTION_SEARCH_TOOLS to find them, then CODE_EXECUTION_RUN_CODE to search: - \`\`\` - export default async function(tools) { - return await tools.REGISTRY_ITEM_SEARCH({ query: "gmail", limit: 5 }); - } - \`\`\` - Then get full details (including the MCP URL) with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. +1. **Search the registry** — use \`REGISTRY_ITEM_SEARCH({ query: "gmail", limit: 5 })\` to find MCPs in the store. Then get full details with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. 2. **CONNECTION_INSTALL** — install it as a connection using the URL from the registry result 3. **CONNECTION_AUTHENTICATE** — if it needs OAuth, this shows an inline "Authenticate" button the user can click right in the chat. **Wait for them to complete it before proceeding.** diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 5b2976c292..9f3dad9b93 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -269,17 +269,17 @@ Use automations and events together to build reactive workflows (e.g. "when I ge - **TAGS_CREATE/DELETE/LIST** — tagging system. - **USER_GET** — current user info. -## Code execution — for connected service tools +## Code execution — for already-installed connection tools -To interact with tools from **connected external services** (Gmail, Slack, databases, etc.), use the three CODE_EXECUTION tools in order. +**CODE_EXECUTION tools are for calling tools from connections that are already installed and authenticated.** They are NOT for searching the registry/store — use \`REGISTRY_ITEM_SEARCH\` for that. -**Important**: CODE_EXECUTION tools only search/run tools from external connections — they do NOT see Mesh MCP management tools. Those are always available as direct calls. +Use these three tools in order to interact with installed external services (Gmail, Slack, databases, etc.): -### Step 1: Search for tools +### Step 1: Search for tools from installed connections \`\`\` CODE_EXECUTION_SEARCH_TOOLS({ query: "gmail" }) \`\`\` -Returns tool names and descriptions from connected services. Always do this first — don't guess tool names. +Searches tools **only from installed, authenticated connections** — not from the registry or store. If this returns empty, the connection may not be installed yet (use \`REGISTRY_ITEM_SEARCH\` + \`CONNECTION_INSTALL\`) or may be unhealthy (use \`CONNECTION_TEST\`). ### Step 2: Get schemas \`\`\` From 0e1abea2b36bf2b5e9c914f83374e4fed77d2a05 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 16:49:47 -0300 Subject: [PATCH 30/87] fix(tools): always show auth card after install, fix auth detection for new connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the install→authenticate flow: 1. Prompt: Make CONNECTION_AUTHENTICATE mandatory after install (agents were skipping it and saying "ready to use" without auth) 2. Backend: CONNECTION_AUTHENTICATE now defaults to auth_type "oauth" for unhealthy connections with no stored auth config, so the auth card renders and triggers MCP OAuth discovery Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/constants.ts | 6 +++--- apps/mesh/src/tools/connection/authenticate.ts | 5 +++++ apps/mesh/src/tools/index.ts | 9 +++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index aaad8297fe..3833e1a9e9 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -60,10 +60,10 @@ CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { return a When the user asks for capabilities that aren't connected yet (e.g. "can you send emails?"): 1. **Search the registry** — use \`REGISTRY_ITEM_SEARCH({ query: "gmail", limit: 5 })\` to find MCPs in the store. Then get full details with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. -2. **CONNECTION_INSTALL** — install it as a connection using the URL from the registry result -3. **CONNECTION_AUTHENTICATE** — if it needs OAuth, this shows an inline "Authenticate" button the user can click right in the chat. **Wait for them to complete it before proceeding.** +2. **CONNECTION_INSTALL** — install it as a connection using the URL from the registry result. +3. **CONNECTION_AUTHENTICATE** — **always call this after install**. Most services need OAuth. This renders an inline "Authenticate" button the user can click. **Stop and wait** — do NOT say "ready to use" until the user completes authentication. -After auth, the connection's tools become available via CODE_EXECUTION_SEARCH_TOOLS. +After the user authenticates via the card, the connection's tools become available via CODE_EXECUTION_SEARCH_TOOLS. ## Connection management diff --git a/apps/mesh/src/tools/connection/authenticate.ts b/apps/mesh/src/tools/connection/authenticate.ts index e092010069..b8de926657 100644 --- a/apps/mesh/src/tools/connection/authenticate.ts +++ b/apps/mesh/src/tools/connection/authenticate.ts @@ -70,6 +70,11 @@ export const CONNECTION_AUTHENTICATE = defineTool({ authType = "configuration"; } else if (!isHealthy && connection.connection_token) { authType = "token"; + } else if (!isHealthy && connection.connection_url) { + // Connection is unhealthy with no stored auth config — likely needs OAuth. + // Default to "oauth" so the frontend renders the auth card and triggers + // MCP OAuth discovery flow against the connection URL. + authType = "oauth"; } const needsAuth = !isHealthy && authType !== "none"; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 9f3dad9b93..4b509a32ec 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -345,17 +345,18 @@ COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" }) CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://mcp.gmail.example.com/sse", icon: "https://..." }) // → returns { connection_id: "conn_abc123", needs_auth: true } -// 4. Authenticate (if needs_auth is true) +// 4. ALWAYS call CONNECTION_AUTHENTICATE after install CONNECTION_AUTHENTICATE({ connection_id: "conn_abc123" }) -// → shows inline auth card. Wait for the user to click and complete OAuth. +// → renders an inline auth card the user can click. STOP here and wait for the user. +// Do NOT proceed or say "ready to use" until the user completes authentication. -// 5. Now use the new connection's tools via CODE_EXECUTION +// 5. After user authenticates, use the new connection's tools via CODE_EXECUTION CODE_EXECUTION_SEARCH_TOOLS({ query: "send email" }) CODE_EXECUTION_DESCRIBE_TOOLS({ tools: ["gmail_send_message"] }) CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { ... }" }) \`\`\` -If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS({ connection_id: "conn_abc123" })\` to check the state, and offer to retry. +**Important**: Always call \`CONNECTION_AUTHENTICATE\` after installing a new connection, even if the install response is ambiguous. Most external services require OAuth. Do NOT tell the user "ready to use" until they've clicked the auth card and authenticated. If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS\` to check state and offer to retry. ## General guidelines From 63882c0bfea8b467ec1520c2db897efdae54e6a8 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 16:57:50 -0300 Subject: [PATCH 31/87] feat(chat): emit auth cards in Claude Code path after CONNECTION_AUTHENTICATE The Claude Code SDK doesn't expose structured tool results, so the frontend couldn't render inline auth cards. Now stream-core detects when CONNECTION_AUTHENTICATE was called during a Claude Code session and emits tool-call/tool-result pairs directly so the auth card renders. Also strengthens the install flow prompts to always call CONNECTION_AUTHENTICATE after install. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/decopilot/claude-code-provider.ts | 10 +++- .../src/api/routes/decopilot/stream-core.ts | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 134a66e8cf..c83873c9e8 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -108,6 +108,7 @@ export async function streamClaudeCode( ): Promise<{ costUsd: number; usage: { inputTokens: number; outputTokens: number; totalTokens: number }; + calledAuthTool: boolean; }> { const queryFn = await getQuery(); @@ -192,6 +193,8 @@ export async function streamClaudeCode( let totalCostUsd = 0; let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + // Track whether CONNECTION_AUTHENTICATE was called so the caller can emit auth cards + let calledAuthTool = false; const ensureTextStarted = () => { if (!textStarted) { @@ -287,6 +290,11 @@ export async function streamClaudeCode( delta: `\nUsing tool: ${toolName}\n`, id: reasoningPartId, }); + + // Track CONNECTION_AUTHENTICATE calls so caller can emit auth cards + if (toolName === "CONNECTION_AUTHENTICATE") { + calledAuthTool = true; + } break; } @@ -430,5 +438,5 @@ export async function streamClaudeCode( }, }); - return { costUsd: totalCostUsd, usage }; + return { costUsd: totalCostUsd, usage, calledAuthTool }; } diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index dd56706465..953024b0d4 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -298,6 +298,61 @@ export async function streamCore( }); } + // Emit auth cards for connections that need authentication. + // The Claude Code path can't render inline tool UI parts directly, + // so we call CONNECTION_AUTHENTICATE ourselves and emit the card. + if (ccResult.calledAuthTool) { + try { + const connections = await ctx.storage.connections.list( + organization.id, + ); + for (const conn of connections) { + const health = await ctx.storage.connections.testConnection( + conn.id, + ); + if (!health.healthy) { + const hasOAuth = !!conn.oauth_config; + const hasScopes = + conn.configuration_scopes && + conn.configuration_scopes.length > 0; + let authType: string = "none"; + if (hasOAuth) authType = "oauth"; + else if (hasScopes) authType = "configuration"; + else if (conn.connection_url) authType = "oauth"; + + if (authType !== "none") { + const toolCallId = `cc-auth-${conn.id}`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = writer.write.bind(writer) as (msg: any) => void; + w({ + type: "tool-call", + toolCallId, + toolName: "CONNECTION_AUTHENTICATE", + input: { connection_id: conn.id }, + }); + w({ + type: "tool-result", + toolCallId, + toolName: "CONNECTION_AUTHENTICATE", + output: { + connection_id: conn.id, + title: conn.title, + icon: conn.icon ?? null, + description: conn.description ?? null, + connection_url: conn.connection_url ?? null, + status: conn.status ?? "inactive", + needs_auth: true, + auth_type: authType, + }, + }); + } + } + } + } catch { + // Auth card emission is best-effort + } + } + return; } From c5c5058355101b519c961ce761c6dbfbcf3e2690 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:01:23 -0300 Subject: [PATCH 32/87] fix(chat): prevent duplicate reasoning/thinking in Claude Code stream Thinking content was streamed both via stream_event (real-time) and then again from the assistant message (final), causing duplicated reasoning blocks. Added streamedReasoning guard matching the existing streamedText pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/claude-code-provider.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index c83873c9e8..7266f10a89 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -190,6 +190,7 @@ export async function streamClaudeCode( // Track which content we've already streamed via stream_event so we // don't duplicate it when the assistant message arrives. let streamedText = false; + let streamedReasoning = false; let totalCostUsd = 0; let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; @@ -246,6 +247,7 @@ export async function streamClaudeCode( delta.thinking && reasoningPartId ) { + streamedReasoning = true; writer.write({ type: "reasoning-delta", delta: delta.thinking, @@ -374,8 +376,12 @@ export async function streamClaudeCode( if (!Array.isArray(content)) break; for (const block of content) { - // Stream thinking content as reasoning - if (block.type === "thinking" && block.thinking) { + // Stream thinking content as reasoning (skip if already streamed via stream_event) + if ( + block.type === "thinking" && + block.thinking && + !streamedReasoning + ) { if (!reasoningPartId) { reasoningPartId = generateMessageId(); writer.write({ type: "reasoning-start", id: reasoningPartId }); From f1f5ea6c8dcc4a735d25adcb08c8f27c506313d0 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:03:22 -0300 Subject: [PATCH 33/87] fix(chat): detect CONNECTION_AUTHENTICATE via tool_progress, not tool_use_summary tool_use_summary doesn't have tool_name (SDK type only has summary text). tool_progress fires during execution and has the actual tool_name. Also uses .includes() to match the MCP-prefixed name (mcp__mesh__CONNECTION_AUTHENTICATE). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/decopilot/claude-code-provider.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 7266f10a89..2d6ee9ffbe 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -274,13 +274,29 @@ export async function streamClaudeCode( break; } + // Tool progress — fires during tool execution with tool_name + case "tool_progress": { + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } + const progressToolName = + (message as { tool_name?: string }).tool_name ?? ""; + + // Track CONNECTION_AUTHENTICATE calls so caller can emit auth cards. + // Claude Code prefixes MCP tools as mcp____. + if (progressToolName.includes("CONNECTION_AUTHENTICATE")) { + calledAuthTool = true; + } + break; + } + // Tool use summary — emit as reasoning so user sees tool activity case "tool_use_summary": { if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { break; } - const toolName = - (message as { tool_name?: string }).tool_name ?? "tool"; + const summaryText = + (message as { summary?: string }).summary ?? "Using tool..."; // Show tool activity as reasoning if (!reasoningPartId) { @@ -289,14 +305,9 @@ export async function streamClaudeCode( } writer.write({ type: "reasoning-delta", - delta: `\nUsing tool: ${toolName}\n`, + delta: `\n${summaryText}\n`, id: reasoningPartId, }); - - // Track CONNECTION_AUTHENTICATE calls so caller can emit auth cards - if (toolName === "CONNECTION_AUTHENTICATE") { - calledAuthTool = true; - } break; } From d28a805788138e05802eb470340cefd366e601c1 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:07:23 -0300 Subject: [PATCH 34/87] fix(chat): always emit auth cards for unhealthy connections, fix text cramping Two fixes: 1. Auth cards: Remove unreliable tool_progress detection. Instead, always check for unhealthy connections after Claude Code stream finishes and emit auth cards for any that need authentication. 2. Text cramping: Add needsTextSeparator flag that inserts \n\n between text from different tool-use turns, preventing "for you.Found it!" concatenation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/claude-code-provider.ts | 12 ++++++++++++ apps/mesh/src/api/routes/decopilot/stream-core.ts | 9 +++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 2d6ee9ffbe..383bc8e761 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -196,6 +196,8 @@ export async function streamClaudeCode( let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; // Track whether CONNECTION_AUTHENTICATE was called so the caller can emit auth cards let calledAuthTool = false; + // Insert separator between text from different turns (after tool use) + let needsTextSeparator = false; const ensureTextStarted = () => { if (!textStarted) { @@ -255,6 +257,14 @@ export async function streamClaudeCode( }); } else if (delta.type === "text_delta" && delta.text) { ensureTextStarted(); + if (needsTextSeparator) { + writer.write({ + type: "text-delta", + delta: "\n\n", + id: textPartId, + }); + needsTextSeparator = false; + } streamedText = true; writer.write({ type: "text-delta", @@ -308,6 +318,8 @@ export async function streamClaudeCode( delta: `\n${summaryText}\n`, id: reasoningPartId, }); + // Next text output should start on a new line + needsTextSeparator = true; break; } diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 953024b0d4..ff550bf761 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -298,10 +298,11 @@ export async function streamCore( }); } - // Emit auth cards for connections that need authentication. - // The Claude Code path can't render inline tool UI parts directly, - // so we call CONNECTION_AUTHENTICATE ourselves and emit the card. - if (ccResult.calledAuthTool) { + // Emit auth cards for any unhealthy connections. + // The Claude Code path can't render inline tool UI parts directly + // (SDK doesn't expose structured tool results), so we check for + // unhealthy connections after every Claude Code turn and emit cards. + { try { const connections = await ctx.storage.connections.list( organization.id, From d93c0fccf5a8234f35f4018ffb39102ebc617a7c Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:11:36 -0300 Subject: [PATCH 35/87] fix(chat): revert broken auth card hack, leave TODO for MCP elicitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool-call/tool-result emission approach fails writer validation. The proper fix is MCP elicitation (URL mode) — CONNECTION_AUTHENTICATE should trigger an elicitation request that the Agent SDK's onElicitation handler renders as an auth card. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/routes/decopilot/stream-core.ts | 59 ++----------------- 1 file changed, 4 insertions(+), 55 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index ff550bf761..34a30c80fb 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -298,61 +298,10 @@ export async function streamCore( }); } - // Emit auth cards for any unhealthy connections. - // The Claude Code path can't render inline tool UI parts directly - // (SDK doesn't expose structured tool results), so we check for - // unhealthy connections after every Claude Code turn and emit cards. - { - try { - const connections = await ctx.storage.connections.list( - organization.id, - ); - for (const conn of connections) { - const health = await ctx.storage.connections.testConnection( - conn.id, - ); - if (!health.healthy) { - const hasOAuth = !!conn.oauth_config; - const hasScopes = - conn.configuration_scopes && - conn.configuration_scopes.length > 0; - let authType: string = "none"; - if (hasOAuth) authType = "oauth"; - else if (hasScopes) authType = "configuration"; - else if (conn.connection_url) authType = "oauth"; - - if (authType !== "none") { - const toolCallId = `cc-auth-${conn.id}`; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const w = writer.write.bind(writer) as (msg: any) => void; - w({ - type: "tool-call", - toolCallId, - toolName: "CONNECTION_AUTHENTICATE", - input: { connection_id: conn.id }, - }); - w({ - type: "tool-result", - toolCallId, - toolName: "CONNECTION_AUTHENTICATE", - output: { - connection_id: conn.id, - title: conn.title, - icon: conn.icon ?? null, - description: conn.description ?? null, - connection_url: conn.connection_url ?? null, - status: conn.status ?? "inactive", - needs_auth: true, - auth_type: authType, - }, - }); - } - } - } - } catch { - // Auth card emission is best-effort - } - } + // TODO: Implement auth cards for Claude Code path using MCP elicitation + // (URL mode). CONNECTION_AUTHENTICATE should trigger an elicitation + // request that the onElicitation handler renders as an auth card. + // See: MCP protocol ElicitRequestURLParams, Agent SDK onElicitation. return; } From 4effad3e099f1f989e28133275c989810b8f1493 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:29:02 -0300 Subject: [PATCH 36/87] feat(chat): implement auth cards via MCP elicitation protocol Proper implementation using MCP elicitation (URL mode) instead of hacking tool-call/tool-result emissions: 1. MCP server (tools/index.ts): After CONNECTION_AUTHENTICATE returns needs_auth: true, calls server.elicitInput() with URL mode pointing to the connection's MCP endpoint 2. Claude Code provider: onElicitation handler receives the request and emits a data-connection-auth part to the UI stream 3. Frontend (assistant.tsx): Renders data-connection-auth parts as ConnectionAuthPart cards with the Authenticate button 4. Types: Added connection-auth to ChatMessage data types Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/decopilot/claude-code-provider.ts | 29 ++++++++++++++++++ .../src/api/routes/decopilot/stream-core.ts | 5 ---- apps/mesh/src/api/routes/decopilot/types.ts | 7 +++++ apps/mesh/src/tools/index.ts | 30 ++++++++++++++++++- .../web/components/chat/message/assistant.tsx | 29 ++++++++++++++++++ 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 383bc8e761..6478a00ac7 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -148,6 +148,35 @@ export async function streamClaudeCode( queryOpts.maxTurns = 30; // Let Claude Code use its default tools + MCP tools queryOpts.tools = undefined; + + // Handle MCP elicitation requests (e.g. CONNECTION_AUTHENTICATE OAuth) + // by emitting a data part that the frontend renders as an auth card. + queryOpts.onElicitation = async (request) => { + if (request.mode === "url" && request.elicitationId) { + // Extract connection details from elicitationId (format: auth-{connId}-{timestamp}) + const parts = (request.elicitationId ?? "").split("-"); + const connectionId = + parts.length >= 2 ? parts.slice(1, -1).join("-") : ""; + + // Emit a data part for the frontend to render an inline auth card + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (writer.write as (msg: any) => void)({ + type: "data-connection-auth", + data: { + connectionId, + title: request.message, + icon: null, + connectionUrl: request.url, + elicitationId: request.elicitationId, + }, + }); + // Return accept — the tool handler on the MCP server side will + // continue after this. The actual OAuth completion happens async + // when the user clicks the auth card in the frontend. + return { action: "accept" as const }; + } + return { action: "decline" as const }; + }; } let conversation: ReturnType; diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 34a30c80fb..dd56706465 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -298,11 +298,6 @@ export async function streamCore( }); } - // TODO: Implement auth cards for Claude Code path using MCP elicitation - // (URL mode). CONNECTION_AUTHENTICATE should trigger an elicitation - // request that the onElicitation handler renders as an auth card. - // See: MCP protocol ElicitRequestURLParams, Agent SDK onElicitation. - return; } diff --git a/apps/mesh/src/api/routes/decopilot/types.ts b/apps/mesh/src/api/routes/decopilot/types.ts index ef68e5c6b6..cd161714d1 100644 --- a/apps/mesh/src/api/routes/decopilot/types.ts +++ b/apps/mesh/src/api/routes/decopilot/types.ts @@ -38,6 +38,13 @@ export type ChatMessage = UIMessage< "thread-title": { title: string; }; + "connection-auth": { + connectionId: string; + title: string; + icon: string | null; + connectionUrl: string | null; + elicitationId: string; + }; }, { [K in keyof ReturnType]: InferUITool< diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 4b509a32ec..91b1bc9bf6 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -447,10 +447,38 @@ export const managementMCP = async (ctx: MeshContext) => { annotations: tool.annotations, _meta: tool._meta, }, - async (args) => { + async (args, extra) => { ctx.access.setToolName(tool.name); try { const result = await tool.execute(args, ctx); + + // CONNECTION_AUTHENTICATE: trigger MCP elicitation so the client + // can render an inline auth card (works for Claude Code and any + // MCP client that supports elicitation URL mode). + const authResult = result as Record | null; + if ( + tool.name === "CONNECTION_AUTHENTICATE" && + authResult && + authResult.needs_auth === true && + typeof authResult.connection_url === "string" + ) { + const connId = authResult.connection_id as string; + const connTitle = authResult.title as string; + try { + await server.server.elicitInput( + { + mode: "url" as const, + message: `Authenticate ${connTitle}`, + url: `/mcp/${connId}`, + elicitationId: `auth-${connId}-${Date.now()}`, + }, + { signal: extra.signal }, + ); + } catch { + // Client may not support elicitation — that's OK, fall through + } + } + return { content: [{ type: "text" as const, text: JSON.stringify(result) }], structuredContent: result as { [x: string]: unknown }, diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index afe05f3622..a6ee661971 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -235,6 +235,35 @@ function MessagePart({ case "data-tool-metadata": case "data-tool-subtask-metadata": return null; + case "data-connection-auth": { + // Auth card emitted via MCP elicitation (Claude Code path) + const authData = (part as { data?: Record }).data; + if (authData) { + const connectionId = (authData.connectionId as string) ?? ""; + return ( + + ); + } + return null; + } default: { const fallback = part as ToolUIPart; // Inline auth card for CONNECTION_AUTHENTICATE tool From fa576fd657a40dba420e25050a2d5f3fd2df0105 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:36:02 -0300 Subject: [PATCH 37/87] fix(chat): use data-connection-auth part instead of MCP elicitation Claude Code's MCP client doesn't support elicitation. Instead, after the stream finishes, stream-core checks for unhealthy connections and emits typed data-connection-auth parts that the frontend renders as auth cards. Removes failed elicitation approach and debug logging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../routes/decopilot/claude-code-provider.ts | 29 ------------------ .../src/api/routes/decopilot/stream-core.ts | 30 +++++++++++++++++++ apps/mesh/src/tools/index.ts | 29 +----------------- 3 files changed, 31 insertions(+), 57 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 6478a00ac7..383bc8e761 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -148,35 +148,6 @@ export async function streamClaudeCode( queryOpts.maxTurns = 30; // Let Claude Code use its default tools + MCP tools queryOpts.tools = undefined; - - // Handle MCP elicitation requests (e.g. CONNECTION_AUTHENTICATE OAuth) - // by emitting a data part that the frontend renders as an auth card. - queryOpts.onElicitation = async (request) => { - if (request.mode === "url" && request.elicitationId) { - // Extract connection details from elicitationId (format: auth-{connId}-{timestamp}) - const parts = (request.elicitationId ?? "").split("-"); - const connectionId = - parts.length >= 2 ? parts.slice(1, -1).join("-") : ""; - - // Emit a data part for the frontend to render an inline auth card - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (writer.write as (msg: any) => void)({ - type: "data-connection-auth", - data: { - connectionId, - title: request.message, - icon: null, - connectionUrl: request.url, - elicitationId: request.elicitationId, - }, - }); - // Return accept — the tool handler on the MCP server side will - // continue after this. The actual OAuth completion happens async - // when the user clicks the auth card in the frontend. - return { action: "accept" as const }; - } - return { action: "decline" as const }; - }; } let conversation: ReturnType; diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index dd56706465..a8e027fb40 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -298,6 +298,36 @@ export async function streamCore( }); } + // Emit auth cards for unhealthy connections. + // MCP elicitation isn't available (Claude Code's MCP client + // doesn't support it), so we check directly after the stream. + if (ccResult.calledAuthTool) { + try { + const connections = await ctx.storage.connections.list( + organization.id, + ); + for (const conn of connections) { + const health = await ctx.storage.connections.testConnection( + conn.id, + ); + if (!health.healthy && conn.connection_url) { + writer.write({ + type: "data-connection-auth", + data: { + connectionId: conn.id, + title: conn.title, + icon: conn.icon ?? null, + connectionUrl: conn.connection_url, + elicitationId: `auth-${conn.id}`, + }, + }); + } + } + } catch { + // Best-effort + } + } + return; } diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 91b1bc9bf6..c291e013f0 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -447,38 +447,11 @@ export const managementMCP = async (ctx: MeshContext) => { annotations: tool.annotations, _meta: tool._meta, }, - async (args, extra) => { + async (args) => { ctx.access.setToolName(tool.name); try { const result = await tool.execute(args, ctx); - // CONNECTION_AUTHENTICATE: trigger MCP elicitation so the client - // can render an inline auth card (works for Claude Code and any - // MCP client that supports elicitation URL mode). - const authResult = result as Record | null; - if ( - tool.name === "CONNECTION_AUTHENTICATE" && - authResult && - authResult.needs_auth === true && - typeof authResult.connection_url === "string" - ) { - const connId = authResult.connection_id as string; - const connTitle = authResult.title as string; - try { - await server.server.elicitInput( - { - mode: "url" as const, - message: `Authenticate ${connTitle}`, - url: `/mcp/${connId}`, - elicitationId: `auth-${connId}-${Date.now()}`, - }, - { signal: extra.signal }, - ); - } catch { - // Client may not support elicitation — that's OK, fall through - } - } - return { content: [{ type: "text" as const, text: JSON.stringify(result) }], structuredContent: result as { [x: string]: unknown }, From 733e37c0ac7803b75764996a1b24ced1a5352024 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:39:11 -0300 Subject: [PATCH 38/87] fix(chat): always check for unhealthy connections after Claude Code stream tool_progress events don't fire for MCP tool calls, so calledAuthTool was always false. Remove the condition and always check. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/stream-core.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index a8e027fb40..9aa67f793a 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -299,9 +299,10 @@ export async function streamCore( } // Emit auth cards for unhealthy connections. - // MCP elicitation isn't available (Claude Code's MCP client - // doesn't support it), so we check directly after the stream. - if (ccResult.calledAuthTool) { + // Claude Code's MCP client doesn't support elicitation and + // tool_progress events don't fire for MCP tools, so we always + // check for unhealthy connections after the stream. + { try { const connections = await ctx.storage.connections.list( organization.id, From c97a6e29c21b67aa2d8f69732a28684ab7d73920 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:42:36 -0300 Subject: [PATCH 39/87] fix(chat): skip self connection when emitting auth cards The Mesh MCP (self) connection always fails health checks but doesn't need user authentication. Skip connections ending in _self. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/stream-core.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 9aa67f793a..64ab93dd41 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -308,6 +308,8 @@ export async function streamCore( organization.id, ); for (const conn of connections) { + // Skip the self connection (Mesh MCP) + if (conn.id.endsWith("_self")) continue; const health = await ctx.storage.connections.testConnection( conn.id, ); From 6bc17920f63009d62cb89271eed0189af99d9155 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:45:56 -0300 Subject: [PATCH 40/87] fix(chat): detect turn boundaries from stream_event content_block_start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_use_summary doesn't fire for MCP tools, so needsTextSeparator was never set. Now detect new turns from content_block_start with type "text" — when text was already streamed, a new text block means a new turn after tool use. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/claude-code-provider.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 383bc8e761..c947c90848 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -239,6 +239,12 @@ export async function streamClaudeCode( id: reasoningPartId, }); } + + // New text block after we already streamed text = new turn. + // Insert a separator so text doesn't run together. + if (event.content_block.type === "text" && streamedText) { + needsTextSeparator = true; + } } if (event.type === "content_block_delta" && event.delta) { From 82d3454b80e139abe34093c489ba854b84945ecc Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:49:11 -0300 Subject: [PATCH 41/87] fix(chat): persist OAuth token after inline auth card authentication The chat auth card completed OAuth but never saved the token to the connection, so refreshing showed "Not connected". Now saves via POST /api/connections/{id}/oauth-token (same as the connection detail page) with fallback to PATCH with raw token. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../parts/tool-call-part/connection-auth.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx index 3601615639..1996656541 100644 --- a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx @@ -54,6 +54,53 @@ function AuthCard({ data }: { data: AuthData }) { connectionId: data.connection_id, }); if (result.token) { + // Save the OAuth token to the connection so it persists + if (result.tokenInfo) { + try { + const res = await fetch( + `/api/connections/${data.connection_id}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: result.tokenInfo.accessToken, + refreshToken: result.tokenInfo.refreshToken, + expiresIn: result.tokenInfo.expiresIn, + scope: result.tokenInfo.scope, + clientId: result.tokenInfo.clientId, + clientSecret: result.tokenInfo.clientSecret, + tokenEndpoint: result.tokenInfo.tokenEndpoint, + }), + }, + ); + if (!res.ok) { + // Fallback: save raw token + await fetch(`/api/connections/${data.connection_id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ connection_token: result.token }), + }); + } + } catch { + // Fallback: save raw token + await fetch(`/api/connections/${data.connection_id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ connection_token: result.token }), + }); + } + } else { + // No tokenInfo, save raw token + await fetch(`/api/connections/${data.connection_id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ connection_token: result.token }), + }); + } setAuthState("success"); } else { setAuthState("error"); From 7fedfc206dcf4504c805670b7e1dae2e62a119b0 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:55:35 -0300 Subject: [PATCH 42/87] fix(chat): simplify auth card token persistence, add error logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified the token-saving flow — always POST to oauth-token endpoint with either full tokenInfo or just accessToken. Added console.error logging for failed saves. Also fixed prompt saying "button above" when auth card renders below. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/tools/index.ts | 2 +- .../parts/tool-call-part/connection-auth.tsx | 86 ++++++++----------- 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index c291e013f0..7aec113673 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -347,7 +347,7 @@ CONNECTION_INSTALL({ title: "Gmail", connection_url: "https://mcp.gmail.example. // 4. ALWAYS call CONNECTION_AUTHENTICATE after install CONNECTION_AUTHENTICATE({ connection_id: "conn_abc123" }) -// → renders an inline auth card the user can click. STOP here and wait for the user. +// → an auth card will appear BELOW your message. STOP here and wait for the user. // Do NOT proceed or say "ready to use" until the user completes authentication. // 5. After user authenticates, use the new connection's tools via CODE_EXECUTION diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx index 1996656541..e822f1a3aa 100644 --- a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx @@ -53,59 +53,45 @@ function AuthCard({ data }: { data: AuthData }) { const result = await authenticateMcp({ connectionId: data.connection_id, }); - if (result.token) { - // Save the OAuth token to the connection so it persists - if (result.tokenInfo) { - try { - const res = await fetch( - `/api/connections/${data.connection_id}/oauth-token`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - accessToken: result.tokenInfo.accessToken, - refreshToken: result.tokenInfo.refreshToken, - expiresIn: result.tokenInfo.expiresIn, - scope: result.tokenInfo.scope, - clientId: result.tokenInfo.clientId, - clientSecret: result.tokenInfo.clientSecret, - tokenEndpoint: result.tokenInfo.tokenEndpoint, - }), - }, - ); - if (!res.ok) { - // Fallback: save raw token - await fetch(`/api/connections/${data.connection_id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ connection_token: result.token }), - }); - } - } catch { - // Fallback: save raw token - await fetch(`/api/connections/${data.connection_id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ connection_token: result.token }), - }); - } - } else { - // No tokenInfo, save raw token - await fetch(`/api/connections/${data.connection_id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ connection_token: result.token }), - }); - } - setAuthState("success"); - } else { + if (!result.token) { setAuthState("error"); setErrorMsg(result.error ?? "Authentication failed"); + return; + } + + // Save the OAuth token to the connection so it persists + const tokenPayload = result.tokenInfo + ? { + accessToken: result.tokenInfo.accessToken, + refreshToken: result.tokenInfo.refreshToken, + expiresIn: result.tokenInfo.expiresIn, + scope: result.tokenInfo.scope, + clientId: result.tokenInfo.clientId, + clientSecret: result.tokenInfo.clientSecret, + tokenEndpoint: result.tokenInfo.tokenEndpoint, + } + : { accessToken: result.token }; + + const saveRes = await fetch( + `/api/connections/${data.connection_id}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(tokenPayload), + }, + ); + + if (!saveRes.ok) { + const errText = await saveRes.text().catch(() => "unknown error"); + console.error( + "[auth-card] Failed to save token:", + saveRes.status, + errText, + ); } + + setAuthState("success"); } catch (err) { setAuthState("error"); setErrorMsg(err instanceof Error ? err.message : "Authentication failed"); From b8d002158b7c19b4cf1291fa475e90dc110965ae Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 17:59:52 -0300 Subject: [PATCH 43/87] fix(connections): re-fetch tools after OAuth token save After saving OAuth tokens via the downstream-token endpoint, trigger a background tool re-fetch from the MCP server. This ensures tools appear immediately after authentication instead of requiring a manual connection update. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/downstream-token.ts | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/mesh/src/api/routes/downstream-token.ts b/apps/mesh/src/api/routes/downstream-token.ts index 6058ef24d8..5c6c6bd273 100644 --- a/apps/mesh/src/api/routes/downstream-token.ts +++ b/apps/mesh/src/api/routes/downstream-token.ts @@ -97,6 +97,30 @@ app.post("/connections/:connectionId/oauth-token", async (c) => { const token = await tokenStorage.upsert(tokenData); + // Re-fetch tools now that the connection is authenticated. + // This runs in the background so it doesn't block the response. + if (connection.connection_url) { + import("../../tools/connection/fetch-tools") + .then(({ fetchToolsFromMCP }) => + fetchToolsFromMCP({ + id: connectionId, + title: connection.title, + connection_type: connection.connection_type ?? "HTTP", + connection_url: connection.connection_url!, + connection_token: null, + connection_headers: null, + }), + ) + .then((result) => { + if (result?.tools?.length) { + ctx.storage.connections + .update(connectionId, { tools: result.tools }) + .catch(() => {}); + } + }) + .catch(() => {}); + } + return c.json({ success: true, expiresAt: token.expiresAt, From 8174d41d360c3054b3cef9e97d69b9cabda4e514 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 18:11:09 -0300 Subject: [PATCH 44/87] fix(connections): pass access token when re-fetching tools after OAuth The background tool re-fetch was passing connection_token: null, so it couldn't authenticate with the MCP server. Now passes the just-saved accessToken so tool discovery succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/downstream-token.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/api/routes/downstream-token.ts b/apps/mesh/src/api/routes/downstream-token.ts index 5c6c6bd273..3084d4f382 100644 --- a/apps/mesh/src/api/routes/downstream-token.ts +++ b/apps/mesh/src/api/routes/downstream-token.ts @@ -107,7 +107,7 @@ app.post("/connections/:connectionId/oauth-token", async (c) => { title: connection.title, connection_type: connection.connection_type ?? "HTTP", connection_url: connection.connection_url!, - connection_token: null, + connection_token: body.accessToken, connection_headers: null, }), ) From 8694e177297fafc3571d3ef619cb4aad1badb217 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 18:15:56 -0300 Subject: [PATCH 45/87] fix(chat): check live auth status on mount, show connected state on refresh Auth card now checks /api/connections/{id}/oauth-token/status on mount to determine if the connection is already authenticated. On refresh, shows green checkmark instead of stale "Authenticate" button. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../parts/tool-call-part/connection-auth.tsx | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx index e822f1a3aa..39f57dbfd5 100644 --- a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx @@ -38,13 +38,38 @@ function parseAuthData(output: unknown): AuthData | null { }; } -type AuthState = "idle" | "authenticating" | "success" | "error"; +type AuthState = "idle" | "checking" | "authenticating" | "success" | "error"; function AuthCard({ data }: { data: AuthData }) { - const [authState, setAuthState] = useState("idle"); + // Check live token status on mount to handle page refreshes + const [authState, setAuthState] = useState(() => { + if (!data.needs_auth) return "success"; + // Kick off a live check — will update state async + return "checking"; + }); const [errorMsg, setErrorMsg] = useState(null); - const connected = authState === "success" || !data.needs_auth; + // Check live auth status (runs once via useState initializer + async update) + const [checked, setChecked] = useState(false); + if (!checked && authState === "checking" && data.connection_id) { + setChecked(true); + fetch(`/api/connections/${data.connection_id}/oauth-token/status`, { + credentials: "include", + }) + .then((res) => res.json()) + .then((status: { hasToken?: boolean; isExpired?: boolean }) => { + if (status.hasToken && !status.isExpired) { + setAuthState("success"); + } else { + setAuthState("idle"); + } + }) + .catch(() => { + setAuthState("idle"); + }); + } + + const connected = authState === "success"; const handleAuthenticate = async () => { setAuthState("authenticating"); @@ -130,7 +155,7 @@ function AuthCard({ data }: { data: AuthData }) {

{errorMsg}

)}
- {!connected && ( + {!connected && authState !== "checking" && (
); } @@ -168,9 +196,7 @@ export function ConnectionAuthPart({ part, latency }: ConnectionAuthPartProps) { title="Authenticate Connection" summary={ data - ? data.needs_auth - ? `${data.title} needs authentication` - : `${data.title} is connected` + ? `${data.title}` : effectiveState === "loading" ? "Checking..." : "" From b38bd97756d9b7b66d86c16438ece658d71dd9c9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 18:22:49 -0300 Subject: [PATCH 46/87] fix(chat): tell model auth card appears below, never say above Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/constants.ts | 2 +- apps/mesh/src/tools/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 3833e1a9e9..7943be6f7d 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -61,7 +61,7 @@ When the user asks for capabilities that aren't connected yet (e.g. "can you sen 1. **Search the registry** — use \`REGISTRY_ITEM_SEARCH({ query: "gmail", limit: 5 })\` to find MCPs in the store. Then get full details with \`COLLECTION_REGISTRY_APP_GET({ id: "deco/google-gmail" })\`. 2. **CONNECTION_INSTALL** — install it as a connection using the URL from the registry result. -3. **CONNECTION_AUTHENTICATE** — **always call this after install**. Most services need OAuth. This renders an inline "Authenticate" button the user can click. **Stop and wait** — do NOT say "ready to use" until the user completes authentication. +3. **CONNECTION_AUTHENTICATE** — **always call this after install**. Most services need OAuth. An auth card with an "Authenticate" button will appear **below your message**. Tell the user to click it below (never say "above"). **Stop and wait** — do NOT say "ready to use" until the user completes authentication. After the user authenticates via the card, the connection's tools become available via CODE_EXECUTION_SEARCH_TOOLS. diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 7aec113673..a8dabb02f0 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -356,7 +356,7 @@ CODE_EXECUTION_DESCRIBE_TOOLS({ tools: ["gmail_send_message"] }) CODE_EXECUTION_RUN_CODE({ code: "export default async function(tools) { ... }" }) \`\`\` -**Important**: Always call \`CONNECTION_AUTHENTICATE\` after installing a new connection, even if the install response is ambiguous. Most external services require OAuth. Do NOT tell the user "ready to use" until they've clicked the auth card and authenticated. If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS\` to check state and offer to retry. +**Important**: Always call \`CONNECTION_AUTHENTICATE\` after installing a new connection, even if the install response is ambiguous. Most external services require OAuth. The auth card appears **below your message** (never say "above"). Do NOT tell the user "ready to use" until they've clicked the auth card and authenticated. If auth fails or the user cancels, use \`CONNECTION_AUTH_STATUS\` to check state and offer to retry. ## General guidelines From aa0f36eaaf1958181a4f8cba00816b27549324d1 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 18:32:11 -0300 Subject: [PATCH 47/87] fix(chat): only show auth cards for connections without tokens Skip connections that already have an OAuth token (in downstream_tokens) or a connection_token. Only show auth cards for genuinely unauthenticated connections, not ones that fail health checks for other reasons. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/stream-core.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 64ab93dd41..783f66e611 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -307,9 +307,24 @@ export async function streamCore( const connections = await ctx.storage.connections.list( organization.id, ); + const { DownstreamTokenStorage } = await import( + "@/storage/downstream-token" + ); + const tokenStorage = new DownstreamTokenStorage( + ctx.db, + ctx.vault, + ); for (const conn of connections) { // Skip the self connection (Mesh MCP) if (conn.id.endsWith("_self")) continue; + // Skip connections that already have an OAuth token + const existingToken = await tokenStorage + .get(conn.id) + .catch(() => null); + if (existingToken?.accessToken) continue; + // Skip connections with a stored connection_token + if (conn.connection_token) continue; + const health = await ctx.storage.connections.testConnection( conn.id, ); From ba8aa084c55ed5120c878339a8c08d72c28ca9a6 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 18:58:02 -0300 Subject: [PATCH 48/87] chore: remove GitHub CLI connection from Connect Studio GitHub CLI connection will be added later when we add support for CLI-based connections. For now, only Claude Code is available. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/routes.ts | 72 +------------------ .../web/components/connect-studio-modal.tsx | 15 ---- 2 files changed, 2 insertions(+), 85 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index bae876b73b..9d72d7687a 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -259,50 +259,15 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { return { connected, auth }; } - async function getGithubStatus() { - // Check if gh CLI is authenticated - const { ok: ghOk } = await runCli("gh", ["auth", "status"]); - if (!ghOk) return { connected: false, auth: null }; - - // Get username via API - let user: string | undefined; - try { - const { ok, stdout } = await runCli("gh", [ - "api", - "user", - "--jq", - ".login", - ]); - if (ok) user = stdout.trim(); - } catch { - // Username not available - } - - // Check if the MCP is registered in Claude Code - const { ok: mcpRegistered } = await runCli("claude", [ - "mcp", - "get", - "github", - ]); - - return { - connected: mcpRegistered, - auth: user ? { user } : null, - }; - } - app.get("/:org/decopilot/connect-studio/status", async (c) => { const ctx = c.get("meshContext"); if (!ctx.auth?.user?.id) { throw new HTTPException(401, { message: "Authentication required" }); } - const [claude, github] = await Promise.all([ - getClaudeStatus(), - getGithubStatus(), - ]); + const claude = await getClaudeStatus(); - return c.json({ claude, github }); + return c.json({ claude }); }); app.post("/:org/decopilot/connect-studio", async (c) => { @@ -353,37 +318,6 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { return c.json({ success: true }); } - if (target === "github") { - // Get token from local gh CLI - const { ok, stdout: token } = await runCli("gh", ["auth", "token"]); - if (!ok || !token.trim()) { - throw new HTTPException(400, { - message: - "GitHub CLI not authenticated. Run `gh auth login` in your terminal.", - }); - } - - const mcpConfig = JSON.stringify({ - type: "http", - url: "https://api.githubcopilot.com/mcp/", - headers: { - Authorization: `Bearer ${token.trim()}`, - }, - }); - - const result = await runCli( - "claude", - ["mcp", "add-json", "github", mcpConfig, "--scope", "user"], - 10000, - ); - if (!result.ok) { - throw new HTTPException(500, { - message: "Failed to register GitHub MCP", - }); - } - return c.json({ success: true }); - } - throw new HTTPException(400, { message: `Unknown target: ${target}` }); }); @@ -399,8 +333,6 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { let mcpName: string; if (target === "claude-code") { mcpName = "deco-studio"; - } else if (target === "github") { - mcpName = "github"; } else { throw new HTTPException(400, { message: `Unknown target: ${target}` }); } diff --git a/apps/mesh/src/web/components/connect-studio-modal.tsx b/apps/mesh/src/web/components/connect-studio-modal.tsx index c74fd4e275..11430cfc12 100644 --- a/apps/mesh/src/web/components/connect-studio-modal.tsx +++ b/apps/mesh/src/web/components/connect-studio-modal.tsx @@ -20,7 +20,6 @@ interface ConnectionStatus { interface ConnectStudioStatus { claude: ConnectionStatus; - github: ConnectionStatus; } const CONNECT_STUDIO_QK = "connect-studio-status"; @@ -133,12 +132,6 @@ function ConnectionCard({ ); } -const GITHUB_SVG = ( - - - -); - export function ConnectStudioModal({ open, onOpenChange, @@ -188,14 +181,6 @@ export function ConnectStudioModal({ isLoading={isLoading} orgSlug={org.slug} /> -
From 51f877d764c6126e7152ab13b6548d9add952b3e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 19:07:06 -0300 Subject: [PATCH 49/87] chore: remove unused isClaudeCodeAvailable export (knip) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/routes/decopilot/claude-code-provider.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index c947c90848..2a055a25e3 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -23,10 +23,6 @@ async function getQuery() { return _query; } -export function isClaudeCodeAvailable(): boolean { - return !!Bun.which("claude"); -} - /** * Convert chat messages to a prompt string for the Claude Agent SDK. */ From 055d39cd6f1fc7f12234b501a98f38e4b8cbc1c7 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 19:35:10 -0300 Subject: [PATCH 50/87] fix(chat): unify Claude Code connection into AI provider card Merge the separate "Connect Studio" modal into the AI Provider card so connecting Claude Code is a single operation that registers both the provider key and the MCP in Claude Code CLI. Disconnect also removes the MCP. Auth status (email, org, subscription) now shows on the provider card. Co-Authored-By: Claude Opus 4.6 --- .../web/components/connect-studio-modal.tsx | 188 ------------------ .../settings-modal/pages/org-ai-providers.tsx | 58 +++++- .../web/components/sidebar/footer/inbox.tsx | 16 +- apps/mesh/src/web/lib/query-keys.ts | 4 + 4 files changed, 62 insertions(+), 204 deletions(-) delete mode 100644 apps/mesh/src/web/components/connect-studio-modal.tsx diff --git a/apps/mesh/src/web/components/connect-studio-modal.tsx b/apps/mesh/src/web/components/connect-studio-modal.tsx deleted file mode 100644 index 11430cfc12..0000000000 --- a/apps/mesh/src/web/components/connect-studio-modal.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { Button } from "@deco/ui/components/button.tsx"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@deco/ui/components/dialog.tsx"; -import { useProjectContext } from "@decocms/mesh-sdk"; -import { Check, Loading01 } from "@untitledui/icons"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { cn } from "@deco/ui/lib/utils.ts"; - -interface ConnectionStatus { - connected: boolean; - auth: Record | null; -} - -interface ConnectStudioStatus { - claude: ConnectionStatus; -} - -const CONNECT_STUDIO_QK = "connect-studio-status"; - -function useConnectStudioStatus(org: { slug: string }) { - return useQuery({ - queryKey: [CONNECT_STUDIO_QK, org.slug], - queryFn: async () => { - const res = await fetch( - `/api/${org.slug}/decopilot/connect-studio/status`, - ); - if (!res.ok) throw new Error("Failed to fetch status"); - return res.json(); - }, - }); -} - -function ConnectionCard({ - target, - logo, - name, - status, - isLoading, - orgSlug, -}: { - target: string; - logo: React.ReactNode; - name: string; - status: ConnectionStatus | undefined; - isLoading: boolean; - orgSlug: string; -}) { - const queryClient = useQueryClient(); - const [busy, setBusy] = useState(false); - - const connected = status?.connected ?? false; - const auth = status?.auth; - - const handleToggle = async () => { - setBusy(true); - const method = connected ? "DELETE" : "POST"; - try { - const res = await fetch(`/api/${orgSlug}/decopilot/connect-studio`, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Failed" })); - throw new Error(err.error ?? "Failed"); - } - toast.success(connected ? `Disconnected ${name}` : `Connected ${name}!`); - queryClient.invalidateQueries({ queryKey: [CONNECT_STUDIO_QK] }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed"); - } finally { - setBusy(false); - } - }; - - const authLine = auth - ? Object.values(auth).filter(Boolean).join(" — ") - : null; - - return ( -
-
- {logo} -
-
-
- {name} - {connected && } -
- {authLine && ( -

{authLine}

- )} -
- {isLoading ? ( - - ) : ( - - )} -
- ); -} - -export function ConnectStudioModal({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const { org } = useProjectContext(); - const { data: status, isLoading } = useConnectStudioStatus(org); - const queryClient = useQueryClient(); - - return ( - { - onOpenChange(v); - if (v) { - queryClient.invalidateQueries({ - queryKey: [CONNECT_STUDIO_QK, org.slug], - }); - } - }} - > - - - Connect Studio - - Install studio tools into your local dev environment. - - -
- - } - status={status?.claude} - isLoading={isLoading} - orgSlug={org.slug} - /> -
-
-
- ); -} diff --git a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx index 0746375167..10e29355b8 100644 --- a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx +++ b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx @@ -400,6 +400,7 @@ export function ProviderCard({ const [topUpKeyId, setTopUpKeyId] = useState(null); const isActive = keys.length > 0; + const isClaudeCode = provider.id === "claude-code"; const { mutate: deleteKey, isPending: isDeleting } = useMutation({ mutationFn: async (keyId: string) => { @@ -416,6 +417,16 @@ export function ProviderCard({ queryClient.invalidateQueries({ queryKey: KEYS.aiProviderModels(locator, deletedKeyId), }); + if (isClaudeCode) { + fetch(`/api/${org.slug}/decopilot/connect-studio`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: "claude-code" }), + }).catch(() => {}); + queryClient.invalidateQueries({ + queryKey: KEYS.connectStudioStatus(org.slug), + }); + } toast.success("Key deleted"); }, onError: (err) => { @@ -495,11 +506,27 @@ export function ProviderCard({ }; }, [isOAuthPending, oauthStateToken, exchangeOAuth]); - const isClaudeCode = provider.id === "claude-code"; const supportsOAuth = provider.supportedMethods.includes("oauth-pkce"); const supportsApiKey = provider.supportedMethods.includes("api-key"); const [isClaudeCodePending, setIsClaudeCodePending] = useState(false); + const connectStudioStatus = useQuery({ + queryKey: KEYS.connectStudioStatus(org.slug), + queryFn: async () => { + const res = await fetch( + `/api/${org.slug}/decopilot/connect-studio/status`, + ); + if (!res.ok) throw new Error("Failed to fetch status"); + return res.json() as Promise<{ + claude: { + connected: boolean; + auth: Record | null; + }; + }>; + }, + enabled: isClaudeCode && isActive, + }); + const handleConnectClaudeCode = async () => { if (isActive || isClaudeCodePending) return; setIsClaudeCodePending(true); @@ -516,6 +543,28 @@ export function ProviderCard({ queryKey: KEYS.aiProviderKeys(locator), }); queryClient.invalidateQueries({ queryKey: KEYS.aiProviders(locator) }); + + // Also register MCP in Claude Code CLI + try { + const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: "claude-code" }), + }); + if (!res.ok) { + toast.error( + "Connected as provider but failed to register MCP in Claude Code", + ); + } + } catch { + toast.error( + "Connected as provider but failed to register MCP in Claude Code", + ); + } + queryClient.invalidateQueries({ + queryKey: KEYS.connectStudioStatus(org.slug), + }); + toast.success("Claude Code connected!"); } catch (err) { toast.error( @@ -655,6 +704,13 @@ export function ProviderCard({ />
)} + {isClaudeCode && connectStudioStatus.data?.claude?.auth && ( +

+ {Object.values(connectStudioStatus.data.claude.auth) + .filter(Boolean) + .join(" — ")} +

+ )} @@ -280,18 +278,6 @@ export function SidebarInboxFooter() { -
- - -
diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 240a25f53c..e3335d5bd7 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -270,4 +270,8 @@ export const KEYS = { // AI provider credits balance (scoped by locator + keyId) aiProviderCredits: (locator: string, keyId: string) => ["ai-provider-credits", locator, keyId] as const, + + // Connect Studio status (Claude Code MCP registration + auth) + connectStudioStatus: (orgSlug: string) => + ["connect-studio-status", orgSlug] as const, } as const; From 67ba90367f0cfd2ee340bf2daf335b7ff662207f Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 20:30:52 -0300 Subject: [PATCH 51/87] fix: address PR review feedback (Cubic + internal review) - P0: Don't auto-forward session creds to arbitrary --url in mcp-serve - P1: Add role prefixes in messagesToPrompt for multi-turn context - P1: Persist Claude Code responses to thread storage - P1: Revoke ephemeral API key after Claude Code stream completes - P1: Atomic lock file creation with O_EXCL to prevent TOCTOU race - P2: Add ensureOrganization + API key auth to DELETE connect-studio - P2: Skip workspace config when --url is passed to mcp-serve - P2: Check configuration_state before marking connection as needs_auth - P2: Unregister exit listener when releasing PGlite lock - P2: Tell agent to wait for OAuth completion in system prompt - Fix: Guard MCP removal to last-key-only on disconnect - Fix: Prevent double toast on Claude Code connect failure - Fix: Show warning toast on MCP removal failure instead of swallowing Co-Authored-By: Claude Opus 4.6 --- .../routes/decopilot/claude-code-provider.ts | 14 +++++- apps/mesh/src/api/routes/decopilot/routes.ts | 4 +- .../src/api/routes/decopilot/stream-core.ts | 45 ++++++++++++++----- apps/mesh/src/tools/connection/auth-status.ts | 6 ++- .../settings-modal/pages/org-ai-providers.tsx | 12 +++-- packages/cli/src/commands.ts | 23 ++++++---- packages/cli/src/commands/tools/mcp-serve.ts | 5 +-- packages/mesh-sdk/src/lib/constants.ts | 4 +- 8 files changed, 81 insertions(+), 32 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts index 2a055a25e3..d4a4baf34e 100644 --- a/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -31,11 +31,16 @@ function messagesToPrompt(messages: ChatMessage[]): string { for (const msg of messages) { if (msg.role === "system") continue; + const textParts: string[] = []; for (const part of msg.parts ?? []) { if ("text" in part && typeof part.text === "string") { - parts.push(part.text); + textParts.push(part.text); } } + if (textParts.length > 0) { + const prefix = msg.role === "assistant" ? "Assistant" : "User"; + parts.push(`${prefix}: ${textParts.join("\n")}`); + } } return parts.join("\n\n"); @@ -105,6 +110,8 @@ export async function streamClaudeCode( costUsd: number; usage: { inputTokens: number; outputTokens: number; totalTokens: number }; calledAuthTool: boolean; + /** Accumulated text from the response, for persistence */ + responseText: string; }> { const queryFn = await getQuery(); @@ -190,6 +197,7 @@ export async function streamClaudeCode( let totalCostUsd = 0; let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + let responseText = ""; // Track whether CONNECTION_AUTHENTICATE was called so the caller can emit auth cards let calledAuthTool = false; // Insert separator between text from different turns (after tool use) @@ -265,9 +273,11 @@ export async function streamClaudeCode( delta: "\n\n", id: textPartId, }); + responseText += "\n\n"; needsTextSeparator = false; } streamedText = true; + responseText += delta.text; writer.write({ type: "text-delta", delta: delta.text, @@ -469,5 +479,5 @@ export async function streamClaudeCode( }, }); - return { costUsd: totalCostUsd, usage, calledAuthTool }; + return { costUsd: totalCostUsd, usage, calledAuthTool, responseText }; } diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 9d72d7687a..89b5f91578 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -323,7 +323,9 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { app.delete("/:org/decopilot/connect-studio", async (c) => { const ctx = c.get("meshContext"); - if (!ctx.auth?.user?.id) { + ensureOrganization(c); + const userId = ctx.auth?.user?.id ?? ctx.auth?.apiKey?.userId; + if (!userId) { throw new HTTPException(401, { message: "Authentication required" }); } diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 783f66e611..431f8d246a 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -273,17 +273,30 @@ export async function streamCore( }); llmCallStartTime = Date.now(); - const ccResult = await streamClaudeCode(writer, { - messages: allMessages, - abortController, - mcpEndpoint, - mcpHeaders, - agentId: input.agent.id, - agentMode: input.agent.mode, - threadId: mem.thread.id, - connectionId: input.models.credentialId, - model: input.models.thinking.id, - }); + let ccResult: Awaited>; + try { + ccResult = await streamClaudeCode(writer, { + messages: allMessages, + abortController, + mcpEndpoint, + mcpHeaders, + agentId: input.agent.id, + agentMode: input.agent.mode, + threadId: mem.thread.id, + connectionId: input.models.credentialId, + model: input.models.thinking.id, + }); + } finally { + // Revoke the ephemeral API key after the stream completes + ctx.boundAuth.apiKey + .delete(apiKeyRecord.id) + .catch((err: unknown) => { + console.error( + "[decopilot:stream] Failed to revoke Claude Code session key", + err, + ); + }); + } // Record usage metrics if (ccResult.usage) { @@ -298,6 +311,16 @@ export async function streamCore( }); } + // Persist the assistant response so it survives page reload + if (ccResult.responseText) { + const responseMessage: ChatMessage = { + id: generateMessageId(), + role: "assistant", + parts: [{ type: "text", text: ccResult.responseText }], + }; + await saveMessagesToThread(responseMessage); + } + // Emit auth cards for unhealthy connections. // Claude Code's MCP client doesn't support elicitation and // tool_progress events don't fire for MCP tools, so we always diff --git a/apps/mesh/src/tools/connection/auth-status.ts b/apps/mesh/src/tools/connection/auth-status.ts index 7cd5b17a8a..89ecfd0e23 100644 --- a/apps/mesh/src/tools/connection/auth-status.ts +++ b/apps/mesh/src/tools/connection/auth-status.ts @@ -56,7 +56,11 @@ export const CONNECTION_AUTH_STATUS = defineTool({ const hasScopes = connection.configuration_scopes && connection.configuration_scopes.length > 0; - const needsAuth = !isHealthy && (hasOAuth || !!hasScopes); + const hasConfigState = + connection.configuration_state && + Object.keys(connection.configuration_state).length > 0; + const needsAuth = + !isHealthy && (hasOAuth || (!!hasScopes && !hasConfigState)); return { connection_id: connection.id, diff --git a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx index 10e29355b8..78503be41b 100644 --- a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx +++ b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx @@ -417,12 +417,14 @@ export function ProviderCard({ queryClient.invalidateQueries({ queryKey: KEYS.aiProviderModels(locator, deletedKeyId), }); - if (isClaudeCode) { + if (isClaudeCode && keys.length === 1) { fetch(`/api/${org.slug}/decopilot/connect-studio`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target: "claude-code" }), - }).catch(() => {}); + }).catch(() => { + toast.error("Failed to remove MCP from Claude Code"); + }); queryClient.invalidateQueries({ queryKey: KEYS.connectStudioStatus(org.slug), }); @@ -545,12 +547,14 @@ export function ProviderCard({ queryClient.invalidateQueries({ queryKey: KEYS.aiProviders(locator) }); // Also register MCP in Claude Code CLI + let mcpRegistered = false; try { const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target: "claude-code" }), }); + mcpRegistered = res.ok; if (!res.ok) { toast.error( "Connected as provider but failed to register MCP in Claude Code", @@ -565,7 +569,9 @@ export function ProviderCard({ queryKey: KEYS.connectStudioStatus(org.slug), }); - toast.success("Claude Code connected!"); + if (mcpRegistered) { + toast.success("Claude Code connected!"); + } } catch (err) { toast.error( `Failed to connect: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 45f2abe1b2..5c74c7949a 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -539,14 +539,21 @@ const mcpServe = new Command("mcp-serve") .option("--token ", "Bearer token / API key for authentication") .action(async (options) => { const { mcpServeCommand } = await import("./commands/tools/mcp-serve.js"); - const config = await getConfig(); - await mcpServeCommand({ - workspace: options.workspace ?? config.workspace, - integration: options.integration, - local: config.local, - url: options.url, - token: options.token, - }); + if (options.url) { + // Direct URL mode — skip workspace config + await mcpServeCommand({ + url: options.url, + token: options.token, + }); + } else { + const config = await getConfig(); + await mcpServeCommand({ + workspace: options.workspace ?? config.workspace, + integration: options.integration, + local: config.local, + token: options.token, + }); + } }); // Completion command implementation (internal command) diff --git a/packages/cli/src/commands/tools/mcp-serve.ts b/packages/cli/src/commands/tools/mcp-serve.ts index 9565f4629a..ef8e5cd751 100644 --- a/packages/cli/src/commands/tools/mcp-serve.ts +++ b/packages/cli/src/commands/tools/mcp-serve.ts @@ -43,11 +43,8 @@ export async function mcpServeCommand(options: McpServeOptions) { const headers: Record = {}; if (token) { headers["Authorization"] = `Bearer ${token}`; - } else { - // Fall back to session auth headers - const { getRequestAuthHeaders } = await import("../../lib/session.js"); - Object.assign(headers, await getRequestAuthHeaders().catch(() => ({}))); } + // Do NOT forward session auth to arbitrary URLs — require explicit --token client = new Client({ name: "deco-mcp-serve", version: "1.0.0" }); await client.connect( diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 37319d9c9c..3d23b6e345 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -284,8 +284,8 @@ When the user asks about capabilities not yet connected (e.g., "can you send ema 1. **Search**: Registry connections expose \`REGISTRY_ITEM_SEARCH\` and \`COLLECTION_REGISTRY_APP_GET\`. Use GATEWAY_SEARCH_TOOLS to find them, then GATEWAY_RUN_CODE to search the store. 2. **Install**: \`CONNECTION_INSTALL({ title: "Gmail", connection_url: "...", icon: "..." })\` -3. **Auth**: If \`needs_auth\` is true, call \`CONNECTION_AUTHENTICATE({ connection_id: "..." })\` — shows an inline auth button for the user to click -4. **Use**: After auth, tools are available via GATEWAY_SEARCH_TOOLS +3. **Auth**: If \`needs_auth\` is true, call \`CONNECTION_AUTHENTICATE({ connection_id: "..." })\` — shows an inline auth button for the user to click. **Wait for the user to complete OAuth** before proceeding — they need to click the button and authorize in their browser. +4. **Use**: After the user confirms auth is complete, tools are available via GATEWAY_SEARCH_TOOLS ## Best practices From 6b76646761569f90bc9c3bb81d80cadb458f517e Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 20:37:39 -0300 Subject: [PATCH 52/87] refactor: simplify review fixes after /simplify pass - Use existing getUserId(ctx) helper instead of inline fallback chain - Simplify mcpRegistered boolean to try/catch with early throw - Fix invalidateQueries race: chain after fetch completes, not before - Await API key deletion (wildcard-permission key warrants stronger guarantee) Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/decopilot/routes.ts | 5 ++- .../src/api/routes/decopilot/stream-core.ts | 18 +++++----- .../settings-modal/pages/org-ai-providers.tsx | 34 ++++++++----------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 89b5f91578..dfbf515250 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -5,7 +5,7 @@ * Uses Memory and ModelProvider abstractions. */ -import type { MeshContext } from "@/core/mesh-context"; +import { getUserId, type MeshContext } from "@/core/mesh-context"; import { consumeStream, createUIMessageStream, @@ -324,8 +324,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { app.delete("/:org/decopilot/connect-studio", async (c) => { const ctx = c.get("meshContext"); ensureOrganization(c); - const userId = ctx.auth?.user?.id ?? ctx.auth?.apiKey?.userId; - if (!userId) { + if (!getUserId(ctx)) { throw new HTTPException(401, { message: "Authentication required" }); } diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 431f8d246a..db5db0696c 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -287,15 +287,15 @@ export async function streamCore( model: input.models.thinking.id, }); } finally { - // Revoke the ephemeral API key after the stream completes - ctx.boundAuth.apiKey - .delete(apiKeyRecord.id) - .catch((err: unknown) => { - console.error( - "[decopilot:stream] Failed to revoke Claude Code session key", - err, - ); - }); + // Revoke the ephemeral wildcard API key after the stream completes + try { + await ctx.boundAuth.apiKey.delete(apiKeyRecord.id); + } catch (err) { + console.error( + "[decopilot:stream] Failed to revoke Claude Code session key", + err, + ); + } } // Record usage metrics diff --git a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx index 78503be41b..91e1320288 100644 --- a/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx +++ b/apps/mesh/src/web/components/settings-modal/pages/org-ai-providers.tsx @@ -422,12 +422,15 @@ export function ProviderCard({ method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target: "claude-code" }), - }).catch(() => { - toast.error("Failed to remove MCP from Claude Code"); - }); - queryClient.invalidateQueries({ - queryKey: KEYS.connectStudioStatus(org.slug), - }); + }) + .then(() => { + queryClient.invalidateQueries({ + queryKey: KEYS.connectStudioStatus(org.slug), + }); + }) + .catch(() => { + toast.error("Failed to remove MCP from Claude Code"); + }); } toast.success("Key deleted"); }, @@ -547,31 +550,22 @@ export function ProviderCard({ queryClient.invalidateQueries({ queryKey: KEYS.aiProviders(locator) }); // Also register MCP in Claude Code CLI - let mcpRegistered = false; try { const res = await fetch(`/api/${org.slug}/decopilot/connect-studio`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target: "claude-code" }), }); - mcpRegistered = res.ok; - if (!res.ok) { - toast.error( - "Connected as provider but failed to register MCP in Claude Code", - ); - } + if (!res.ok) throw new Error("MCP registration failed"); + queryClient.invalidateQueries({ + queryKey: KEYS.connectStudioStatus(org.slug), + }); + toast.success("Claude Code connected!"); } catch { toast.error( "Connected as provider but failed to register MCP in Claude Code", ); } - queryClient.invalidateQueries({ - queryKey: KEYS.connectStudioStatus(org.slug), - }); - - if (mcpRegistered) { - toast.success("Claude Code connected!"); - } } catch (err) { toast.error( `Failed to connect: ${err instanceof Error ? err.message : String(err)}`, From 9fa50082dcf013e47cddccc85deb5899aa820515 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Sat, 14 Mar 2026 20:48:11 -0300 Subject: [PATCH 53/87] fix(connections): inline API key auth for MCPs that need tokens (e.g. Perplexity) MCPs like Perplexity list tools without auth but fail on calls. Now CONNECTION_INSTALL and CONNECTION_AUTHENTICATE correctly detect when configuration_scopes exist and mark needs_auth=true. The auth card renders an inline API key input for token/configuration auth types instead of always attempting OAuth discovery. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/downstream-token.ts | 86 ++++++++++++++ .../mesh/src/tools/connection/authenticate.ts | 6 +- apps/mesh/src/tools/connection/install.ts | 6 +- .../parts/tool-call-part/connection-auth.tsx | 112 +++++++++++++++--- 4 files changed, 188 insertions(+), 22 deletions(-) diff --git a/apps/mesh/src/api/routes/downstream-token.ts b/apps/mesh/src/api/routes/downstream-token.ts index 3084d4f382..51dcdf2c18 100644 --- a/apps/mesh/src/api/routes/downstream-token.ts +++ b/apps/mesh/src/api/routes/downstream-token.ts @@ -183,4 +183,90 @@ app.get("/connections/:connectionId/oauth-token/status", async (c) => { }); }); +/** + * POST /api/connections/:connectionId/token + * + * Save an API key as the connection_token (for MCPs that require a bearer token + * but don't support OAuth). Re-fetches tools in the background so the connection + * becomes healthy immediately. + */ +app.post("/connections/:connectionId/token", async (c) => { + const ctx = c.get("meshContext"); + const connectionId = c.req.param("connectionId"); + + const userId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null; + if (!userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const connection = await ctx.storage.connections.findById( + connectionId, + ctx.organization?.id, + ); + if (!connection) { + return c.json({ error: "Connection not found" }, 404); + } + + const body = await c.req.json<{ token: string }>(); + if (!body.token) { + return c.json({ error: "token is required" }, 400); + } + + await ctx.storage.connections.update(connectionId, { + connection_token: body.token, + }); + + // Re-fetch tools in the background now that we have a token + if (connection.connection_url) { + import("../../tools/connection/fetch-tools") + .then(({ fetchToolsFromMCP }) => + fetchToolsFromMCP({ + id: connectionId, + title: connection.title, + connection_type: connection.connection_type ?? "HTTP", + connection_url: connection.connection_url!, + connection_token: body.token, + connection_headers: null, + }), + ) + .then((result) => { + if (result?.tools?.length) { + ctx.storage.connections + .update(connectionId, { tools: result.tools }) + .catch(() => {}); + } + }) + .catch(() => {}); + } + + return c.json({ success: true }); +}); + +/** + * GET /api/connections/:connectionId/token/status + * + * Check if a connection has a stored connection_token (API key). + */ +app.get("/connections/:connectionId/token/status", async (c) => { + const ctx = c.get("meshContext"); + const connectionId = c.req.param("connectionId"); + + const userId = ctx.auth.user?.id ?? ctx.auth.apiKey?.userId ?? null; + if (!userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const connection = await ctx.storage.connections.findById( + connectionId, + ctx.organization?.id, + ); + if (!connection) { + return c.json({ error: "Connection not found" }, 404); + } + + return c.json({ + hasToken: !!connection.connection_token, + }); +}); + export default app; diff --git a/apps/mesh/src/tools/connection/authenticate.ts b/apps/mesh/src/tools/connection/authenticate.ts index b8de926657..a7969e7b2a 100644 --- a/apps/mesh/src/tools/connection/authenticate.ts +++ b/apps/mesh/src/tools/connection/authenticate.ts @@ -77,7 +77,11 @@ export const CONNECTION_AUTHENTICATE = defineTool({ authType = "oauth"; } - const needsAuth = !isHealthy && authType !== "none"; + // Auth is needed if unhealthy, OR if scopes exist but no token is stored + // (some MCPs like Perplexity list tools without auth but fail on calls). + const needsAuth = + (!isHealthy && authType !== "none") || + (!!hasScopes && !connection.connection_token); return { connection_id: connection.id, diff --git a/apps/mesh/src/tools/connection/install.ts b/apps/mesh/src/tools/connection/install.ts index a19b370fd4..d6522ca870 100644 --- a/apps/mesh/src/tools/connection/install.ts +++ b/apps/mesh/src/tools/connection/install.ts @@ -118,8 +118,10 @@ export const CONNECTION_INSTALL = defineTool({ }, ); - // If tools couldn't be fetched, the connection likely needs auth - const needsAuth = !fetchResult; + // Auth is needed if tools couldn't be fetched OR if the server declared + // configuration scopes (e.g. API key required — tools may list without auth + // but fail when called). + const needsAuth = !fetchResult || !!scopes; return { connection: { diff --git a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx index 39f57dbfd5..f5d75412f3 100644 --- a/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; import { authenticateMcp } from "@decocms/mesh-sdk"; import { Check, Loading01, Lock01 } from "@untitledui/icons"; @@ -41,37 +42,53 @@ function parseAuthData(output: unknown): AuthData | null { type AuthState = "idle" | "checking" | "authenticating" | "success" | "error"; function AuthCard({ data }: { data: AuthData }) { - // Check live token status on mount to handle page refreshes + const isTokenAuth = + data.auth_type === "configuration" || data.auth_type === "token"; + const [authState, setAuthState] = useState(() => { if (!data.needs_auth) return "success"; - // Kick off a live check — will update state async return "checking"; }); const [errorMsg, setErrorMsg] = useState(null); + const [apiKey, setApiKey] = useState(""); // Check live auth status (runs once via useState initializer + async update) const [checked, setChecked] = useState(false); if (!checked && authState === "checking" && data.connection_id) { setChecked(true); - fetch(`/api/connections/${data.connection_id}/oauth-token/status`, { - credentials: "include", - }) + + // Check both OAuth token and connection_token status + const oauthCheck = fetch( + `/api/connections/${data.connection_id}/oauth-token/status`, + { credentials: "include" }, + ) .then((res) => res.json()) - .then((status: { hasToken?: boolean; isExpired?: boolean }) => { - if (status.hasToken && !status.isExpired) { - setAuthState("success"); - } else { - setAuthState("idle"); - } - }) - .catch(() => { + .then( + (s: { hasToken?: boolean; isExpired?: boolean }) => + s.hasToken && !s.isExpired, + ) + .catch(() => false); + + const tokenCheck = fetch( + `/api/connections/${data.connection_id}/token/status`, + { credentials: "include" }, + ) + .then((res) => res.json()) + .then((s: { hasToken?: boolean }) => !!s.hasToken) + .catch(() => false); + + Promise.all([oauthCheck, tokenCheck]).then(([hasOAuth, hasToken]) => { + if (hasOAuth || hasToken) { + setAuthState("success"); + } else { setAuthState("idle"); - }); + } + }); } const connected = authState === "success"; - const handleAuthenticate = async () => { + const handleOAuthAuthenticate = async () => { setAuthState("authenticating"); setErrorMsg(null); try { @@ -84,7 +101,6 @@ function AuthCard({ data }: { data: AuthData }) { return; } - // Save the OAuth token to the connection so it persists const tokenPayload = result.tokenInfo ? { accessToken: result.tokenInfo.accessToken, @@ -123,6 +139,30 @@ function AuthCard({ data }: { data: AuthData }) { } }; + const handleTokenSave = async () => { + if (!apiKey.trim()) return; + setAuthState("authenticating"); + setErrorMsg(null); + try { + const res = await fetch(`/api/connections/${data.connection_id}/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ token: apiKey.trim() }), + }); + if (!res.ok) { + const errText = await res.text().catch(() => "unknown error"); + throw new Error(errText); + } + setAuthState("success"); + } catch (err) { + setAuthState("error"); + setErrorMsg( + err instanceof Error ? err.message : "Failed to save API key", + ); + } + }; + return (
{data.title} {connected && }
- {data.description && ( + {connected && data.description && (

{data.description}

)} + {/* Inline API key input for token/configuration auth */} + {!connected && isTokenAuth && authState !== "checking" && ( +
{ + e.preventDefault(); + handleTokenSave(); + }} + > + setApiKey(e.target.value)} + className="h-7 text-xs flex-1" + disabled={authState === "authenticating"} + autoFocus + /> + +
+ )} {authState === "error" && errorMsg && (

{errorMsg}

)}
- {!connected && authState !== "checking" && ( + {/* OAuth authenticate button */} + {!connected && !isTokenAuth && authState !== "checking" && ( + + e.stopPropagation()} + > + { + setDialogOpen(false); + onNavigate(c.id); + }} + > + + Open + + onDelete(c)} + > + + Delete + + + +
+ ))} +
+ + ); } @@ -776,6 +970,303 @@ function BulkDeleteDialog({ ); } +// --------------------------------------------------------------------------- +// Grouped table: renders CollectionTable-style rows with collapsible groups +// --------------------------------------------------------------------------- + +function GroupedConnectionTable({ + columns, + grouped, + sortKey, + sortDirection, + onSort, + onRowClick, + selectionMode, + selectedIds, + onToggleSelect, + emptyState, +}: { + columns: TableColumn[]; + grouped: GroupedItem[]; + sortKey?: string; + sortDirection?: "asc" | "desc" | null; + onSort?: (key: string) => void; + onRowClick: (connection: ConnectionEntity) => void; + selectionMode: boolean; + selectedIds: Set; + onToggleSelect: (id: string) => void; + emptyState?: React.ReactNode; +}) { + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const toggleGroup = (key: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + if (grouped.length === 0 && emptyState) { + return ( +
+ {emptyState} +
+ ); + } + + const colCount = columns.length; + + return ( +
+ + + + {columns.map((col, idx) => { + const isActiveSort = sortKey === col.id; + const headerBase = + "px-4 py-2 text-left font-mono font-normal text-muted-foreground text-[11px] h-9 uppercase tracking-wider"; + const isLast = idx === colCount - 1; + return ( + onSort(col.id) : undefined + } + > + + {col.header} + {col.sortable && ( + + {isActiveSort && + sortDirection && + (sortDirection === "asc" ? ( + + ) : ( + + ))} + + )} + + + ); + })} + + + + {grouped.map((item) => { + if (item.type === "single") { + const c = item.connection; + return ( + onRowClick(c)} + > + {columns.map((col) => ( + +
+ {col.render ? col.render(c) : null} +
+
+ ))} +
+ ); + } + + const group = item; + const isExpanded = expandedGroups.has(group.key); + const allSelected = group.connections.every((c) => + selectedIds.has(c.id), + ); + const someSelected = group.connections.some((c) => + selectedIds.has(c.id), + ); + const creators = getUniqueCreators(group.connections); + + const mostRecent = group.connections.reduce((latest, c) => { + const t = c.updated_at ?? c.created_at; + const l = latest.updated_at ?? latest.created_at; + if (!t) return latest; + if (!l) return c; + return new Date(t) > new Date(l) ? c : latest; + }, group.connections[0]!); + + return ( + + {/* Group header row — cells align with columns */} + toggleGroup(group.key)} + > + {columns.map((col) => { + const base = cn( + "px-5 py-3 h-14 align-middle text-sm", + col.cellClassName, + ); + + if (col.id === "select") { + return ( + + { + for (const c of group.connections) { + if (allSelected) { + if (selectedIds.has(c.id)) + onToggleSelect(c.id); + } else { + if (!selectedIds.has(c.id)) + onToggleSelect(c.id); + } + } + }} + onClick={(e: React.MouseEvent) => + e.stopPropagation() + } + /> + + ); + } + + if (col.id === "title") { + return ( + +
+ } + /> + + {group.title} + + + x{group.connections.length} + +
+
+ ); + } + + if (col.id === "updated_by") { + return ( + +
+ {creators.map((id) => ( + 1} + /> + ))} +
+
+ ); + } + + if (col.id === "updated_at") { + const ts = mostRecent.updated_at ?? mostRecent.created_at; + return ( + + + {ts ? formatTimeAgo(new Date(ts)) : "—"} + + + ); + } + + if (col.id === "actions") { + return ( + + + + ); + } + + return ; + })} +
+ + {/* Expanded child rows */} + {isExpanded && + group.connections.map((c) => ( + onRowClick(c)} + > + {columns.map((col) => ( + +
+ {col.render ? col.render(c) : null} +
+
+ ))} +
+ ))} +
+ ); + })} +
+
+
+ ); +} + // =========================================================================== function OrgMcpsContent() { @@ -794,30 +1285,15 @@ function OrgMcpsContent() { const actions = useConnectionActions(); const connections = useConnections(listState); - // Unfiltered connections for catalog metadata (connectedAppNames, appInstances) - // so the "Connected" badge and modal aren't affected by the search term - const allConnections = useConnections(); const [dialogState, dispatch] = useReducer(dialogReducer, { mode: "idle" }); - // Selection / bulk-action state — no explicit mode; selection is implicit + // Selection / bulk-action state + const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const selectionMode = selectedIds.size > 0; const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [addToAgentOpen, setAddToAgentOpen] = useState(false); - // Tab state - type ConnectionTab = "connected" | "all"; - const [activeTab, setActiveTab] = useState("all"); - - // App modal state (instances + tools) - const [appModal, setAppModal] = useState<{ - appName: string; - appIcon?: string | null; - appDescription?: string | null; - instances: ConnectionEntity[]; - } | null>(null); - // Type & status filters const [typeFilter, setTypeFilter] = useState("ALL"); const [statusFilter, setStatusFilter] = @@ -834,9 +1310,8 @@ function OrgMcpsContent() { return true; }); - const tabFilteredConnections = nonVirtualConnections; - - const grouped = groupConnections(tabFilteredConnections); + const stats = countByStatus(connections); + const grouped = groupConnections(nonVirtualConnections); const toggleSelect = (id: string) => { setSelectedIds((prev) => { @@ -848,122 +1323,31 @@ function OrgMcpsContent() { }; const exitSelectionMode = () => { + setSelectionMode(false); setSelectedIds(new Set()); }; - // Optional registry lookup: support multiple registries, let user pick on "All" tab - // Sort so the self/management MCP (Mesh MCP) appears last — external registries like - // Deco Store / MCP Registry should be the default catalog source. - const registryConnections = useRegistryConnections(allConnections).sort( - (a, b) => { - const isSelfA = a.app_name === "@deco/management-mcp"; - const isSelfB = b.app_name === "@deco/management-mcp"; - if (isSelfA && !isSelfB) return 1; - if (!isSelfA && isSelfB) return -1; - return 0; - }, - ); - const [selectedRegistryId, setSelectedRegistryId] = useLocalStorage( - LOCALSTORAGE_KEYS.selectedRegistry(org.slug), - (existing) => existing ?? "", - ); - const registryConnection = - (selectedRegistryId - ? registryConnections.find((r) => r.id === selectedRegistryId) - : undefined) ?? registryConnections[0]; + // Optional registry lookup: use first available registry connection as a name/description source + const registryConnection = useRegistryConnections(connections)[0]; const registryId = registryConnection?.id ?? ""; const registryListToolName = findListToolName(registryConnection?.tools); - const registryDiscovery = useStoreDiscovery({ - registryId, - listToolName: registryListToolName, + const registryClient = useMCPClient({ + connectionId: registryId || null, + orgId: org.id, }); - const registryItems = registryDiscovery.items; - - const catalogSentinelRef = useInfiniteScroll( - registryDiscovery.loadMore, - registryDiscovery.hasMore, - registryDiscovery.isLoadingMore, - ); - - // "All" tab: catalog items from registry (includes already-connected ones) - // Use allConnections (unfiltered) so the "Connected" badge isn't lost when searching - const connectedAppNames = new Set( - allConnections - .filter((c) => c.connection_type !== "VIRTUAL" && c.app_name) - .map((c) => c.app_name as string), - ); - - const searchLower = listState.search.toLowerCase(); - const catalogItems = - activeTab === "all" - ? registryItems.filter((item) => { - if (!searchLower) return true; - const meshMeta = item._meta?.["mcp.mesh"] as - | Record - | undefined; - const title = [ - meshMeta?.friendly_name, - item.server?.name, - item.server?.title, - item.name, - item.title, - item.id, - ] - .filter(Boolean) - .join(" "); - const desc = [ - meshMeta?.short_description, - meshMeta?.mesh_description, - item.server?.description, - item.description, - ] - .filter(Boolean) - .join(" "); - return ( - title.toLowerCase().includes(searchLower) || - desc.toLowerCase().includes(searchLower) - ); - }) - : []; - - const verifiedCatalogItems = catalogItems.filter( - (item) => - item.verified || - item._meta?.["mcp.mesh"]?.verified || - item.meta?.verified, - ); - const otherCatalogItems = catalogItems.filter( - (item) => - !item.verified && - !item._meta?.["mcp.mesh"]?.verified && - !item.meta?.verified, + const { data: registryListResults } = useMCPToolCallQuery({ + client: registryClient, + toolName: registryListToolName, + toolArguments: { limit: 200 }, + enabled: Boolean(registryId && registryListToolName), + staleTime: 60 * 60 * 1000, + select: (result) => + (result as { structuredContent?: unknown }).structuredContent ?? result, + }); + const registryItems = extractItemsFromResponse( + registryListResults ?? [], ); - // In "All" tab, don't show connected items at top — they belong in the Connected tab - const groupedForDisplay = activeTab === "all" ? [] : grouped; - - const navigateToCatalogItem = (item: RegistryItem) => { - const serverSlug = slugify( - item.name || item.title || item.server?.title || "", - ); - const idIsScoped = typeof item.id === "string" && item.id.includes("/"); - const serverNameIsScoped = - typeof item.server?.name === "string" && item.server.name.includes("/"); - const serverName = - idIsScoped && !serverNameIsScoped - ? item.id - : item.server?.name || item.id || ""; - navigate({ - to: "/$org/$project/store/$appName", - params: { - org: org.slug, - project: ORG_ADMIN_PROJECT_SLUG, - appName: serverSlug, - }, - search: { registryId, serverName }, - }); - }; - // Create dialog state is derived from search params const isCreating = search.action === "create"; @@ -1494,250 +1878,331 @@ function OrgMcpsContent() { } }; + const columns: TableColumn[] = [ + ...(selectionMode + ? [ + { + id: "select", + header: "", + render: (connection: ConnectionEntity) => ( + toggleSelect(connection.id)} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> + ), + cellClassName: "w-10 shrink-0", + } satisfies TableColumn, + ] + : []), + { + id: "title", + header: "Name", + render: (connection) => ( +
+ } + /> + + {connection.title} + +
+ ), + cellClassName: "w-32 min-w-0 shrink-0", + sortable: true, + }, + { + id: "description", + header: "Description", + render: (connection) => ( + + {connection.description || "—"} + + ), + cellClassName: "flex-1 min-w-0 max-w-0", + wrap: false, + sortable: true, + }, + { + id: "connection_type", + header: "Type", + accessor: (connection) => ( + + {connection.connection_type} + + ), + cellClassName: "w-16 shrink-0", + sortable: true, + }, + { + id: "status", + header: "Status", + render: (connection) => ( + + ), + cellClassName: "w-28 shrink-0", + sortable: false, + }, + { + id: "updated_by", + header: "Updated by", + render: (connection) => ( + + ), + cellClassName: "w-32 shrink-0", + sortable: true, + }, + { + id: "updated_at", + header: "Updated", + render: (connection) => ( + + {connection.updated_at + ? formatTimeAgo(new Date(connection.updated_at)) + : "—"} + + ), + cellClassName: "max-w-24 w-24 shrink-0", + sortable: true, + }, + { + id: "actions", + header: "", + render: (connection) => ( + + + + + e.stopPropagation()}> + { + e.stopPropagation(); + navigate({ + to: "/$org/$project/mcps/$connectionId", + params: { + org: org.slug, + project: ORG_ADMIN_PROJECT_SLUG, + connectionId: connection.id, + }, + }); + }} + > + + Open + + { + e.stopPropagation(); + dispatch({ type: "delete", connection }); + }} + > + + Delete + + + + ), + cellClassName: "w-12 shrink-0", + }, + ]; + const ctaButton = (
- + {selectionMode ? ( + + ) : ( + <> + + + + + )}
); return ( - <> - - - - - - {editingConnection ? "Edit Connection" : "Create Connection"} - - - {editingConnection - ? "Update the connection details below." - : "Create a custom connection in your organization. Fill in the details below."} - - -
- -
- ( - - Type * - - - - )} - /> - - {/* NPX-specific fields */} - {uiType === "NPX" && ( - <> - ( - - NPM Package * - - { - const pasted = - e.clipboardData.getData("text"); - if (!pasted) return; - e.preventDefault(); - form.setValue("npx_package", pasted.trim(), { - shouldDirty: true, - }); - applyInferenceFromInput(pasted); - }} - onBlur={(e) => { - applyInferenceFromInput(e.target.value); - field.onBlur(); - }} - /> - - - - )} - /> - - )} - - {/* STDIO/Custom Command fields */} - {uiType === "STDIO" && ( - <> -
- ( - - Command * - - - - - - )} - /> - - ( - - Arguments - - - - - + + + + + + {editingConnection ? "Edit Connection" : "Create Connection"} + + + {editingConnection + ? "Update the connection details below." + : "Create a custom connection in your organization. Fill in the details below."} + + + + +
+ ( + + Type * + - -

- Directory where the command will be executed -

- -
- )} - /> - + + + + )} + /> - {/* Shared: Environment Variables for NPX and STDIO */} - {(uiType === "NPX" || uiType === "STDIO") && ( + {/* NPX-specific fields */} + {uiType === "NPX" && ( + <> ( - Environment Variables + NPM Package * - { + const pasted = e.clipboardData.getData("text"); + if (!pasted) return; + e.preventDefault(); + form.setValue("npx_package", pasted.trim(), { + shouldDirty: true, + }); + applyInferenceFromInput(pasted); + }} + onBlur={(e) => { + applyInferenceFromInput(e.target.value); + field.onBlur(); + }} /> )} /> - )} + + )} - {/* HTTP/SSE/Websocket fields */} - {uiType !== "NPX" && uiType !== "STDIO" && ( - <> + {/* STDIO/Custom Command fields */} + {uiType === "STDIO" && ( + <> +
( - URL * + Command * { - const pasted = - e.clipboardData.getData("text"); - if (!pasted) return; - e.preventDefault(); - form.setValue( - "connection_url", - pasted.trim(), - { - shouldDirty: true, - }, - ); - applyInferenceFromInput(pasted); - }} - onBlur={(e) => { - applyInferenceFromInput(e.target.value); - field.onBlur(); - }} /> @@ -1747,350 +2212,376 @@ function OrgMcpsContent() { ( - - {providerHint?.token?.label ?? "Token (optional)"} - + Arguments - {providerHint?.token?.helperText && ( -

- {providerHint.token.helperText} - {providerHint.id === "github" && ( - <> - {" "} - ·{" "} - - Open GitHub PAT settings - - - )} -

- )}
)} /> - - )} +
- {/* Name/description come after connection mode/inputs so we can infer them */} - ( - - Name * - - - - - - )} - /> + ( + + Working Directory + + + +

+ Directory where the command will be executed +

+ +
+ )} + /> + + )} + {/* Shared: Environment Variables for NPX and STDIO */} + {(uiType === "NPX" || uiType === "STDIO") && ( ( - Description + Environment Variables -