diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 649bfcd1e1..541eab3b02 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -36,6 +36,7 @@ "prepublishOnly": "bun run build:client && bun run build:server" }, "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@duckdb/node-api": "^1.5.0-r.1" }, "dependencies": { 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/adapters/deco-ai-gateway.ts b/apps/mesh/src/ai-providers/adapters/deco-ai-gateway.ts index 2f3e7ebb18..d81afad2db 100644 --- a/apps/mesh/src/ai-providers/adapters/deco-ai-gateway.ts +++ b/apps/mesh/src/ai-providers/adapters/deco-ai-gateway.ts @@ -7,7 +7,7 @@ export const decoAiGatewayAdapter: ProviderAdapter = { info: { id: "deco", name: "Deco AI Gateway", - description: "Deco-managed keys with access to 100+ models", + description: "Access to 100+ models", logo: "/logos/deco logo.svg", }, diff --git a/apps/mesh/src/ai-providers/factory.ts b/apps/mesh/src/ai-providers/factory.ts index 915f8c5059..a6ac5960f0 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:opus", + title: "Claude Code: Opus", + description: "Most capable model via local Claude Code CLI (1M context)", + logo: null, + capabilities: ["text", "vision"] as ModelCapability[], + limits: { contextWindow: 1_000_000, maxOutputTokens: 32_768 }, + costs: null, + }, + { + providerId: "claude-code", + modelId: "claude-code:sonnet", + title: "Claude Code: Sonnet", + description: "Fast, capable model via local Claude Code CLI (1M context)", + logo: null, + capabilities: ["text", "vision"] as ModelCapability[], + limits: { contextWindow: 1_000_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", "vision"] 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/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..f1641e8641 --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/claude-code-provider.ts @@ -0,0 +1,1274 @@ +/** + * Claude Code Provider + * + * Adapter for the Claude Agent SDK that streams Claude Code responses + * into AI SDK's UIMessageStreamWriter format. + * + * Converts SDK messages into rich UI parts: + * - stream_event → text, reasoning, tool-call-start/delta + * - tool_progress → latency tracking per tool call + * - tool_use_summary → tool-result fallback when user messages are not emitted + * - user messages → tool-result with actual MCP tool outputs + * - assistant messages → fallback for content not streamed in real-time + * - task_started/task_progress/task_notification → subagent task cards + */ + +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; +} + +/** + * 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; + const textParts: string[] = []; + for (const part of msg.parts ?? []) { + if ("text" in part && typeof part.text === "string") { + 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"); +} + +/** + * Check if the last user message contains image file parts. + */ +function hasImageParts(messages: ChatMessage[]): boolean { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg || msg.role !== "user") continue; + for (const part of msg.parts ?? []) { + if ( + part.type === "file" && + "mediaType" in part && + typeof part.mediaType === "string" && + part.mediaType.startsWith("image/") + ) { + return true; + } + } + break; // only check the last user message + } + return false; +} + +/** + * Build an Anthropic MessageParam content array from the last user message, + * including both text and image blocks. + */ +function buildUserContent(messages: ChatMessage[]): Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { type: "base64"; media_type: string; data: string }; + } +> { + const content: Array< + | { type: "text"; text: string } + | { + type: "image"; + source: { type: "base64"; media_type: string; data: string }; + } + > = []; + + // Add context from prior messages as text + const priorParts: string[] = []; + for (let i = 0; i < messages.length - 1; i++) { + const msg = messages[i]; + if (!msg || msg.role === "system") continue; + const textParts: string[] = []; + for (const part of msg.parts ?? []) { + if ("text" in part && typeof part.text === "string") { + textParts.push(part.text); + } + } + if (textParts.length > 0) { + const prefix = msg.role === "assistant" ? "Assistant" : "User"; + priorParts.push(`${prefix}: ${textParts.join("\n")}`); + } + } + if (priorParts.length > 0) { + content.push({ + type: "text", + text: `Previous conversation:\n\n${priorParts.join("\n\n")}`, + }); + } + + // Process the last user message with both text and images + const lastMsg = messages[messages.length - 1]; + if (lastMsg) { + for (const part of lastMsg.parts ?? []) { + if ("text" in part && typeof part.text === "string" && part.text.trim()) { + content.push({ type: "text", text: part.text }); + } + if ( + part.type === "file" && + "url" in part && + typeof part.url === "string" && + "mediaType" in part && + typeof part.mediaType === "string" && + part.mediaType.startsWith("image/") + ) { + // Extract base64 data from data URL + const dataUrl = part.url as string; + const base64Match = dataUrl.match(/^data:[^;]+;base64,(.+)$/); + if (base64Match?.[1]) { + content.push({ + type: "image", + source: { + type: "base64", + media_type: part.mediaType as string, + data: base64Match[1], + }, + }); + } + } + } + } + + return content; +} + +/** + * 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"); +} + +/** Claude Code model variants that can be selected in the UI */ +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; + mcpEndpoint?: string; + mcpHeaders?: Record; + agentId?: string; + agentMode?: string; + threadId: string; + connectionId: string; + /** SDK model identifier, e.g. "claude-sonnet-4-6" */ + model?: string; + /** When true, use plan mode — Claude Code produces a plan without executing tools */ + planMode?: boolean; +} + +// ============================================================================ +// Internal types for SDK message parsing +// ============================================================================ + +interface StreamEvent { + type: string; + index?: number; + content_block?: { + type: string; + name?: string; + id?: string; + text?: string; + }; + delta?: { + type: string; + text?: string; + thinking?: string; + partial_json?: string; + }; +} + +interface ToolCallInfo { + id: string; + name: string; + startTime: number; + args: string; +} + +/** + * SDK internal control tools that should not be rendered as tool call cards. + * These are handled internally by the Claude Agent SDK and their results + * are not meaningful to show to the user. + */ +const SDK_CONTROL_TOOLS = new Set(["ExitPlanMode", "ExitPlanModeAndWritePlan"]); + +// ============================================================================ +// Stream state manager +// ============================================================================ + +/** + * Manages the complex state machine for converting Claude Code SDK messages + * into AI SDK UIMessageStream parts with proper step boundaries. + */ +class StreamState { + private writer: UIMessageStreamWriter; + + // Part lifecycle + textPartId: string; + textStarted = false; + reasoningPartId: string | null = null; + + // Deduplication: track what we streamed via stream_event + streamedText = false; + streamedReasoning = false; + private streamedToolCalls = new Set(); + + // Tool call tracking + private blockTypes = new Map(); + private toolCallBlocks = new Map(); + private toolProgressTimes = new Map(); + private pendingToolCalls = new Map(); + private resolvedToolCalls = new Set(); + /** SDK control tool calls that are suppressed from the UI */ + private suppressedToolCalls = new Set(); + hasActiveToolCalls = false; + + // Task/subagent tracking + private activeTasks = new Map< + string, + { toolCallId: string; toolUseId?: string } + >(); + + // Text separators between turns + needsTextSeparator = false; + + // Accumulated response text for persistence + responseText = ""; + + /** + * Ordered list of completed parts for message persistence. + * Text segments and tool calls are pushed as they finalize so the + * persisted message faithfully reproduces what was streamed. + */ + completedParts: Array< + | { type: "text"; text: string } + | { type: "reasoning"; text: string } + | { + type: "dynamic-tool"; + toolCallId: string; + toolName: string; + input: unknown; + output: unknown; + state: "output-available" | "output-error"; + } + > = []; + + /** Tracks how much of responseText has been flushed into completedParts */ + private textFlushedLength = 0; + + /** Accumulated reasoning text for the current thinking block */ + private reasoningText = ""; + + /** Total tool calls completed (for monitoring) */ + toolCallCount = 0; + toolCallErrors = 0; + + constructor(writer: UIMessageStreamWriter) { + this.writer = writer; + this.textPartId = generateMessageId(); + } + + // ── Text part helpers ────────────────────────────────────────────── + + ensureTextStarted() { + if (!this.textStarted) { + this.writer.write({ type: "text-start", id: this.textPartId }); + this.textStarted = true; + } + } + + closeText() { + if (this.textStarted) { + this.writer.write({ type: "text-end", id: this.textPartId }); + this.textStarted = false; + } + } + + closeReasoning() { + if (this.reasoningPartId) { + this.writer.write({ type: "reasoning-end", id: this.reasoningPartId }); + this.reasoningPartId = null; + } + } + + /** Flush any new responseText since the last flush into completedParts */ + flushTextPart() { + if (this.responseText.length > this.textFlushedLength) { + const text = this.responseText.slice(this.textFlushedLength); + this.completedParts.push({ type: "text", text }); + this.textFlushedLength = this.responseText.length; + } + } + + /** Close all open parts (text + reasoning) before tool calls or step end */ + closeOpenParts() { + this.closeReasoning(); + this.closeText(); + } + + /** Reset text tracking for a new turn (after tool results) */ + resetForNewTurn() { + this.textPartId = generateMessageId(); + this.textStarted = false; + this.streamedText = false; + this.streamedReasoning = false; + this.needsTextSeparator = false; + this.hasActiveToolCalls = false; + } + + // ── Stream event handlers ────────────────────────────────────────── + + handleContentBlockStart(event: StreamEvent) { + const block = event.content_block; + if (!block) return; + + const idx = event.index ?? 0; + this.blockTypes.set(idx, block.type); + + if (block.type === "thinking") { + this.reasoningPartId = generateMessageId(); + this.writer.write({ + type: "reasoning-start", + id: this.reasoningPartId, + }); + } + + if (block.type === "tool_use") { + const toolCallId = block.id ?? generateMessageId(); + const toolName = block.name ?? "unknown"; + + // Track this tool call + const info: ToolCallInfo = { + id: toolCallId, + name: toolName, + startTime: performance.now(), + args: "", + }; + this.toolCallBlocks.set(idx, info); + this.streamedToolCalls.add(toolCallId); + + // Suppress SDK control tools from the UI + if (SDK_CONTROL_TOOLS.has(toolName)) { + this.suppressedToolCalls.add(toolCallId); + return; + } + + // Close open text/reasoning before emitting tool calls + this.closeOpenParts(); + + this.pendingToolCalls.set(toolCallId, info); + this.hasActiveToolCalls = true; + + // Emit tool input start (dynamic = true since these aren't registered tools) + this.writer.write({ + type: "tool-input-start", + toolCallId, + toolName, + dynamic: true, + }); + } + + // New text block after we already streamed text = new turn. + if (block.type === "text" && this.streamedText) { + this.needsTextSeparator = true; + } + } + + handleContentBlockDelta(event: StreamEvent) { + const delta = event.delta; + if (!delta) return; + + const idx = event.index ?? 0; + + if ( + delta.type === "thinking_delta" && + delta.thinking && + this.reasoningPartId + ) { + this.streamedReasoning = true; + this.reasoningText += delta.thinking; + this.writer.write({ + type: "reasoning-delta", + delta: delta.thinking, + id: this.reasoningPartId, + }); + return; + } + + if (delta.type === "text_delta" && delta.text) { + this.ensureTextStarted(); + if (this.needsTextSeparator) { + this.writer.write({ + type: "text-delta", + delta: "\n\n", + id: this.textPartId, + }); + this.responseText += "\n\n"; + this.needsTextSeparator = false; + } + this.streamedText = true; + this.responseText += delta.text; + this.writer.write({ + type: "text-delta", + delta: delta.text, + id: this.textPartId, + }); + return; + } + + // Tool use input JSON streaming + if (delta.type === "input_json_delta" && delta.partial_json) { + const toolBlock = this.toolCallBlocks.get(idx); + if (toolBlock) { + toolBlock.args += delta.partial_json; + // Skip streaming input for suppressed SDK control tools + if (!this.suppressedToolCalls.has(toolBlock.id)) { + this.writer.write({ + type: "tool-input-delta", + toolCallId: toolBlock.id, + inputTextDelta: delta.partial_json, + }); + } + } + } + } + + handleContentBlockStop(event: StreamEvent) { + const idx = event.index ?? 0; + const blockType = this.blockTypes.get(idx); + + if (blockType === "thinking" && this.reasoningPartId) { + this.writer.write({ type: "reasoning-end", id: this.reasoningPartId }); + // Persist reasoning text as a part so it survives page reload + if (this.reasoningText) { + this.flushTextPart(); + this.completedParts.push({ + type: "reasoning", + text: this.reasoningText, + }); + this.reasoningText = ""; + } + this.reasoningPartId = null; + } + + // Clean up tool call block tracking (tool call input complete) + if (blockType === "tool_use") { + this.toolCallBlocks.delete(idx); + } + } + + // ── Tool progress tracking ───────────────────────────────────────── + + handleToolProgress(message: { + tool_use_id?: string; + tool_name?: string; + elapsed_time_seconds?: number; + }) { + if (message.tool_use_id && message.elapsed_time_seconds != null) { + this.toolProgressTimes.set( + message.tool_use_id, + message.elapsed_time_seconds, + ); + } + } + + // ── Tool results ─────────────────────────────────────────────────── + + /** + * Emit tool-result for a specific tool call. + * Also emits latency metadata if available from tool_progress. + */ + emitToolResult(toolCallId: string, result: string, isError?: boolean) { + if (this.resolvedToolCalls.has(toolCallId)) return; + this.resolvedToolCalls.add(toolCallId); + + // Grab tool info before deleting from pending + const toolBlock = this.pendingToolCalls.get(toolCallId); + this.pendingToolCalls.delete(toolCallId); + + // Track tool call metrics (including suppressed ones for accurate counting) + this.toolCallCount++; + if (isError) this.toolCallErrors++; + + // Skip emitting results for suppressed SDK control tools + if (this.suppressedToolCalls.has(toolCallId)) return; + + if (isError) { + this.writer.write({ + type: "tool-output-error", + toolCallId, + errorText: result, + dynamic: true, + }); + } else { + this.writer.write({ + type: "tool-output-available", + toolCallId, + output: result, + dynamic: true, + }); + } + + // Accumulate tool call part for persistence + if (toolBlock) { + // Flush any preceding text before the tool call + this.flushTextPart(); + + let parsedInput: unknown = toolBlock.args; + try { + parsedInput = JSON.parse(toolBlock.args); + } catch { + // keep as string + } + + this.completedParts.push({ + type: "dynamic-tool", + toolCallId, + toolName: toolBlock.name, + input: parsedInput, + output: isError ? { error: result } : result, + state: isError ? "output-error" : "output-available", + }); + } + + // Emit latency metadata + const elapsed = this.toolProgressTimes.get(toolCallId); + const latencyMs = elapsed + ? elapsed * 1000 + : toolBlock + ? performance.now() - toolBlock.startTime + : undefined; + + if (latencyMs != null) { + this.writer.write({ + type: "data-tool-metadata", + id: toolCallId, + data: { latencyMs }, + }); + } + } + + /** + * Handle user messages which contain tool_result blocks. + * These are synthesized by the SDK after MCP tool execution. + */ + handleUserMessage(message: { + message?: { + content?: { + type: string; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; + }[]; + }; + }) { + const content = message.message?.content; + if (!Array.isArray(content)) return; + + for (const block of content) { + if (block.type === "tool_result" && block.tool_use_id) { + const resultText = this.extractToolResultText(block.content); + this.emitToolResult( + block.tool_use_id, + resultText, + block.is_error === true, + ); + } + } + + this.finishToolCallStep(); + } + + /** + * Handle tool_use_summary as a fallback for resolving pending tool calls. + * If we haven't received explicit tool_result messages, the summary + * provides at least a text description of what happened. + */ + handleToolUseSummary(message: { + summary?: string; + preceding_tool_use_ids?: string[]; + }) { + const summary = message.summary ?? "Tool completed"; + const precedingIds = message.preceding_tool_use_ids ?? []; + + // Resolve any pending tool calls that haven't received results yet + for (const toolCallId of precedingIds) { + if (!this.resolvedToolCalls.has(toolCallId)) { + this.emitToolResult( + toolCallId, + JSON.stringify({ + content: [{ type: "text", text: summary }], + }), + ); + } + } + + // If no preceding IDs, resolve ALL pending tool calls with the summary + if (precedingIds.length === 0 && this.pendingToolCalls.size > 0) { + for (const [toolCallId] of this.pendingToolCalls) { + this.emitToolResult( + toolCallId, + JSON.stringify({ + content: [{ type: "text", text: summary }], + }), + ); + } + } + + // If there were active tool calls, finish the step + if (this.hasActiveToolCalls) { + this.finishToolCallStep(); + } + } + + /** Finish a tool call step and start a new step for the next turn */ + private finishToolCallStep() { + if (!this.hasActiveToolCalls) return; + + this.writer.write({ type: "finish-step" }); + this.writer.write({ type: "start-step" }); + this.resetForNewTurn(); + } + + // ── Task/Subagent handling ───────────────────────────────────────── + + handleTaskStarted(message: { + task_id?: string; + tool_use_id?: string; + description?: string; + prompt?: string; + }) { + const taskId = message.task_id; + if (!taskId) return; + + const toolCallId = generateMessageId(); + this.activeTasks.set(taskId, { + toolCallId, + toolUseId: message.tool_use_id, + }); + + // Close open parts + this.closeOpenParts(); + + // Emit as a tool input (will be rendered similar to subtask) + this.writer.write({ + type: "tool-input-start", + toolCallId, + toolName: "subtask", + dynamic: true, + }); + this.hasActiveToolCalls = true; + } + + handleTaskProgress(message: { + task_id?: string; + description?: string; + usage?: { total_tokens: number; tool_uses: number; duration_ms: number }; + summary?: string; + }) { + const task = message.task_id + ? this.activeTasks.get(message.task_id) + : undefined; + if (!task) return; + + // Emit subtask metadata with usage stats + if (message.usage) { + this.writer.write({ + type: "data-tool-subtask-metadata", + id: task.toolCallId, + data: { + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: message.usage.total_tokens, + }, + }, + }); + } + } + + handleTaskNotification(message: { + task_id?: string; + status?: string; + summary?: string; + usage?: { total_tokens: number; tool_uses: number; duration_ms: number }; + }) { + const task = message.task_id + ? this.activeTasks.get(message.task_id) + : undefined; + if (!task) return; + + const summary = message.summary ?? "Task completed"; + const isError = message.status === "failed"; + + this.emitToolResult(task.toolCallId, summary, isError); + + // Emit final usage metadata + if (message.usage) { + this.writer.write({ + type: "data-tool-subtask-metadata", + id: task.toolCallId, + data: { + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: message.usage.total_tokens, + }, + }, + }); + } + + this.activeTasks.delete(message.task_id!); + + // Finish step if no more active tasks/tools + if (this.pendingToolCalls.size === 0 && this.activeTasks.size === 0) { + this.finishToolCallStep(); + } + } + + // ── Assistant message fallback ───────────────────────────────────── + + /** + * Handle the full assistant message. + * - Emits tool_use blocks not already streamed via stream_event + * - Emits text/thinking not already streamed + */ + handleAssistantMessage( + content: { + type: string; + text?: string; + thinking?: string; + id?: string; + name?: string; + input?: Record; + }[], + ) { + for (const block of content) { + // Thinking fallback + if ( + block.type === "thinking" && + block.thinking && + !this.streamedReasoning + ) { + if (!this.reasoningPartId) { + this.reasoningPartId = generateMessageId(); + this.writer.write({ + type: "reasoning-start", + id: this.reasoningPartId, + }); + } + this.writer.write({ + type: "reasoning-delta", + delta: block.thinking, + id: this.reasoningPartId, + }); + } + + // Text fallback + if (block.type === "text" && block.text && !this.streamedText) { + this.ensureTextStarted(); + this.responseText += block.text; + this.writer.write({ + type: "text-delta", + delta: block.text, + id: this.textPartId, + }); + } + + // Tool use fallback — emit tool calls not already streamed + if (block.type === "tool_use" && block.id) { + if (!this.streamedToolCalls.has(block.id)) { + const toolCallId = block.id; + const toolName = block.name ?? "unknown"; + const input = block.input ?? {}; + + // Suppress SDK control tools + if (SDK_CONTROL_TOOLS.has(toolName)) { + this.streamedToolCalls.add(toolCallId); + this.suppressedToolCalls.add(toolCallId); + } else { + this.closeOpenParts(); + this.streamedToolCalls.add(toolCallId); + this.pendingToolCalls.set(toolCallId, { + id: toolCallId, + name: toolName, + startTime: performance.now(), + args: JSON.stringify(input), + }); + this.hasActiveToolCalls = true; + + // Emit complete tool input (not streaming since we have it all) + this.writer.write({ + type: "tool-input-available", + toolCallId, + toolName, + input, + dynamic: true, + }); + } + } + } + } + } + + // ── Utilities ────────────────────────────────────────────────────── + + private extractToolResultText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((c) => { + if (typeof c === "object" && c !== null) { + if ("text" in c && typeof c.text === "string") return c.text; + return JSON.stringify(c); + } + return String(c); + }) + .join("\n"); + } + if (content != null) return JSON.stringify(content); + return ""; + } + + isToolCallStreamed(id: string): boolean { + return this.streamedToolCalls.has(id); + } +} + +// ============================================================================ +// Main export +// ============================================================================ + +/** + * 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 }; + /** Accumulated text from the response, for persistence */ + responseText: string; + /** Ordered parts (text + reasoning + tool calls) for faithful message persistence */ + parts: Array< + | { type: "text"; text: string } + | { type: "reasoning"; text: string } + | { + type: "dynamic-tool"; + toolCallId: string; + toolName: string; + input: unknown; + output: unknown; + state: "output-available" | "output-error"; + } + >; + /** Tool call metrics for monitoring */ + toolCallCount: number; + toolCallErrors: number; +}> { + const queryFn = await getQuery(); + + // When images are present, build an SDKUserMessage with content blocks. + // Otherwise use plain text prompt. + const containsImages = hasImageParts(opts.messages); + const prompt = containsImages + ? ((async function* () { + yield { + type: "user" as const, + message: { + role: "user" as const, + content: buildUserContent(opts.messages), + }, + parent_tool_use_id: null, + session_id: "chat", + }; + })() as AsyncIterable< + import("@anthropic-ai/claude-agent-sdk").SDKUserMessage + >) + : messagesToPrompt(opts.messages); + const systemPrompt = extractSystemPrompt(opts.messages); + + 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, + // Plan mode: Claude Code produces a plan without executing tools + permissionMode: opts.planMode + ? ("plan" as const) + : ("bypassPermissions" as const), + allowDangerouslySkipPermissions: !opts.planMode, + // Enable streaming events so we get thinking_delta + text_delta in real-time + includePartialMessages: true, + tools: [], + }; + + // If an MCP endpoint is provided, pass it so Claude Code can use mesh tools + if (opts.mcpEndpoint) { + queryOpts.mcpServers = { + mesh: { + type: "http" as const, + url: opts.mcpEndpoint, + headers: opts.mcpHeaders, + }, + }; + // 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(); + writer.write({ + type: "start", + messageId, + messageMetadata: { + agent: { + id: opts.agentId ?? null, + mode: opts.agentMode ?? "passthrough", + }, + models: { + connectionId: opts.connectionId, + thinking: { + id: opts.model ?? "claude-code", + provider: "claude-code", + }, + }, + created_at: new Date(), + thread_id: opts.threadId, + }, + }); + + writer.write({ type: "start-step" }); + + const state = new StreamState(writer); + + let totalCostUsd = 0; + let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + + try { + for await (const message of conversation) { + if (abortController.signal.aborted) break; + + switch (message.type) { + case "stream_event": { + // Only handle main thread events (no subagent) + if (message.parent_tool_use_id) break; + + const event = message.event as StreamEvent; + + if (event.type === "content_block_start") { + state.handleContentBlockStart(event); + } else if (event.type === "content_block_delta") { + state.handleContentBlockDelta(event); + } else if (event.type === "content_block_stop") { + state.handleContentBlockStop(event); + } + break; + } + + case "tool_progress": { + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } + state.handleToolProgress( + message as { + tool_use_id?: string; + tool_name?: string; + elapsed_time_seconds?: number; + }, + ); + break; + } + + case "tool_use_summary": { + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } + state.handleToolUseSummary( + message as { + summary?: string; + preceding_tool_use_ids?: string[]; + }, + ); + break; + } + + case "result": { + if (message.subtype === "success") { + totalCostUsd = + (message as { total_cost_usd?: number }).total_cost_usd ?? 0; + const u = ( + message as { + 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) + + (u.cache_read_input_tokens ?? 0) + + (u.cache_creation_input_tokens ?? 0); + const outputTokens = u.output_tokens ?? 0; + usage = { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + }; + } + } else { + const errors = (message as { errors?: string[] }).errors ?? []; + if (errors.length > 0) { + state.ensureTextStarted(); + writer.write({ + type: "error", + errorText: errors.join("; "), + }); + } + } + break; + } + + case "assistant": { + // Only handle main thread messages (no subagent) + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } + + // Handle errors + 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.", + billing_error: + "Claude Code billing error. Check your subscription.", + rate_limit: "Claude Code rate limited. Please try again shortly.", + }; + state.ensureTextStarted(); + writer.write({ + type: "error", + errorText: + errorMessages[errorCode] ?? `Claude Code error: ${errorCode}`, + }); + break; + } + + // Extract content from the full assistant message + const content = ( + message as { + message?: { + content?: { + type: string; + text?: string; + thinking?: string; + id?: string; + name?: string; + input?: Record; + }[]; + }; + } + )?.message?.content; + if (Array.isArray(content)) { + state.handleAssistantMessage(content); + } + break; + } + + case "user": { + // Handle user messages with tool_result blocks + if ((message as { parent_tool_use_id?: string }).parent_tool_use_id) { + break; + } + state.handleUserMessage( + message as { + message?: { + content?: { + type: string; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; + }[]; + }; + }, + ); + break; + } + + // ── Task/subagent events ────────────────────────────────────── + case "system": { + const subtype = (message as { subtype?: string }).subtype; + + if (subtype === "task_started") { + state.handleTaskStarted( + message as { + task_id?: string; + tool_use_id?: string; + description?: string; + prompt?: string; + }, + ); + } else if (subtype === "task_progress") { + state.handleTaskProgress( + message as { + task_id?: string; + description?: string; + usage?: { + total_tokens: number; + tool_uses: number; + duration_ms: number; + }; + summary?: string; + }, + ); + } else if (subtype === "task_notification") { + state.handleTaskNotification( + message as { + task_id?: string; + status?: string; + summary?: string; + usage?: { + total_tokens: number; + tool_uses: number; + duration_ms: number; + }; + }, + ); + } + break; + } + + // ── Prompt suggestions ──────────────────────────────────────── + case "prompt_suggestion": { + const suggestion = (message as { suggestion?: string }).suggestion; + if (suggestion) { + writer.write({ + type: "data-prompt-suggestion", + data: { suggestion }, + }); + } + break; + } + } + } + } catch (err) { + console.error("[claude-code] Stream error:", err); + state.ensureTextStarted(); + writer.write({ + type: "error", + errorText: + err instanceof Error ? err.message : "Claude Code stream failed", + }); + } + + // Close any open parts + state.closeOpenParts(); + + // Ensure text part is opened before closing it (AI SDK requirement) + state.ensureTextStarted(); + writer.write({ type: "text-end", id: state.textPartId }); + writer.write({ type: "finish-step" }); + + writer.write({ + type: "finish", + finishReason: "stop", + messageMetadata: { + usage: { + ...usage, + providerMetadata: totalCostUsd + ? { + "claude-code": { + usage: { cost: totalCostUsd }, + }, + } + : undefined, + }, + }, + }); + + // Flush any trailing text into completedParts + state.flushTextPart(); + + return { + costUsd: totalCostUsd, + usage, + responseText: state.responseText, + parts: state.completedParts, + toolCallCount: state.toolCallCount, + toolCallErrors: state.toolCallErrors, + }; +} diff --git a/apps/mesh/src/api/routes/decopilot/constants.ts b/apps/mesh/src/api/routes/decopilot/constants.ts index 2a3c8cbe95..6fd3fcf7ef 100644 --- a/apps/mesh/src/api/routes/decopilot/constants.ts +++ b/apps/mesh/src/api/routes/decopilot/constants.ts @@ -1,4 +1,5 @@ import { generatePrefixedId } from "@/shared/utils/generate-id"; +import { MANAGEMENT_MCP_INSTRUCTIONS } from "@/tools/shared-prompts"; import type { ChatMessage } from "./types"; /** Message ID generator. Use as closure where a () => string is expected (e.g. toUIMessageStreamResponse). */ @@ -19,9 +20,9 @@ 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 decopilotHeader = `You are **Decopilot**, the AI assistant built into **Deco Studio**.`; - let text = platformPrompt; + let text = `${decopilotHeader}\n\n${MANAGEMENT_MCP_INSTRUCTIONS}`; if (agentInstructions?.trim()) { text += ` @@ -29,7 +30,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/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index d9b7178aa7..5ef7393ee6 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, @@ -118,6 +118,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { memory: memoryConfig, thread_id, toolApprovalLevel, + planMode, } = await validateRequest(c); const userId = ctx.auth?.user?.id; @@ -125,24 +126,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.thinking.provider === "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; @@ -160,6 +165,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { userId, threadId: resolvedThreadId, windowSize, + planMode, }, ctx, { runRegistry, streamBuffer, cancelBroadcast }, @@ -192,6 +198,176 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { } }); + // ============================================================================ + // Connect Studio — check + register MCP servers in Claude Code + // ============================================================================ + + // 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"); + return new Promise((resolve) => { + const proc = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + proc.kill(); + 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(timer); + resolve({ ok: code === 0, stdout, stderr }); + }); + proc.on("error", (err) => { + clearTimeout(timer); + resolve({ ok: false, stdout, stderr: err.message }); + }); + }); + } + + async function getClaudeStatus() { + const { ok: connected } = await runCli("claude", [ + "mcp", + "get", + "deco-studio", + ]); + let auth: Record | null = null; + if (connected) { + try { + const { stdout } = await runCli("claude", ["auth", "status"]); + const parsed = JSON.parse(stdout); + if (parsed.loggedIn) { + auth = { + email: parsed.email, + orgName: parsed.orgName, + subscriptionType: parsed.subscriptionType, + }; + } + } catch { + // Auth info not available + } + } + return { connected, auth }; + } + + 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 = await getClaudeStatus(); + + return c.json({ claude }); + }); + + 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().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, + }, + }); + + const serverPort = process.env.PORT || "3000"; + const origin = `http://localhost:${serverPort}`; + const mcpConfig = JSON.stringify({ + type: "http", + url: `${origin}/mcp/self`, + headers: { + Authorization: `Bearer ${apiKey.key}`, + "x-org-id": organization.id, + "x-mesh-client": "Claude Code", + }, + }); + + // Remove existing MCP first (ignore failure — may not exist yet) + await runCli("claude", [ + "mcp", + "remove", + "deco-studio", + "--scope", + "user", + ]); + + const result = await runCli( + "claude", + ["mcp", "add-json", "deco-studio", mcpConfig, "--scope", "user"], + 10000, + ); + if (!result.ok) { + console.error("[connect-studio] claude mcp add-json failed", { + stdout: result.stdout, + stderr: result.stderr, + }); + throw new HTTPException(500, { + message: "Failed to register deco-studio MCP", + }); + } + return c.json({ success: true }); + } + + throw new HTTPException(400, { message: `Unknown target: ${target}` }); + }); + + app.delete("/:org/decopilot/connect-studio", async (c) => { + const ctx = c.get("meshContext"); + ensureOrganization(c); + if (!getUserId(ctx)) { + throw new HTTPException(401, { message: "Authentication required" }); + } + + const body = await c.req.json().catch(() => ({})); + const target = (body as { target?: string }).target; + + let mcpName: string; + if (target === "claude-code") { + mcpName = "deco-studio"; + } 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 ${mcpName} MCP`, + }); + } + return c.json({ success: true }); + }); + // ============================================================================ // Cancel Endpoint — cancel ongoing run (local or via NATS to owning pod) // ============================================================================ diff --git a/apps/mesh/src/api/routes/decopilot/schemas.ts b/apps/mesh/src/api/routes/decopilot/schemas.ts index 2350f51f28..341eeb5263 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(); @@ -86,6 +87,7 @@ export const StreamRequestSchema = z.object({ temperature: z.number().default(0.5), thread_id: z.string().optional(), toolApprovalLevel: z.enum(["none", "readonly", "yolo"]).default("none"), + planMode: z.boolean().optional(), }); export type StreamRequest = z.infer; diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 9168799048..0daf39a56b 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -10,6 +10,8 @@ import type { MeshContext } from "@/core/mesh-context"; import { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; import { monitorLlmCall } from "@/monitoring/emit-llm-call"; import { recordLlmCallMetrics } from "@/monitoring/record-llm-call-metrics"; +import { recordToolExecutionMetrics } from "@/monitoring/record-tool-execution-metrics"; +import { DECOPILOT_CONNECTION_ID } from "@/monitoring/schema"; import { sanitizeProviderMetadata } from "@decocms/mesh-sdk"; import { createUIMessageStream, stepCountIs, streamText } from "ai"; import { getBuiltInTools } from "./built-in-tools"; @@ -36,6 +38,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"; // ============================================================================ @@ -59,6 +62,8 @@ export interface StreamCoreInput { triggerId?: string; windowSize?: number; abortSignal?: AbortSignal; + /** Claude Code plan mode — produces a plan without executing tools */ + planMode?: boolean; } export interface StreamCoreDeps { @@ -90,21 +95,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 +121,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, @@ -219,6 +233,7 @@ export async function streamCore( input.models.thinking.limits?.maxOutputTokens ?? DEFAULT_MAX_TOKENS; let streamFinished = false; + const streamStartTime = Date.now(); const pendingOps: Promise[] = []; // Pre-load conversation @@ -235,6 +250,222 @@ 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(); + + // Enrich messages with agent-specific instructions (same as standard path). + // The virtual MCP client holds the agent's custom prompt/instructions. + let enrichedMessages = allMessages; + let agentClient: Awaited< + ReturnType + > | null = null; + try { + agentClient = await createVirtualClientFrom( + virtualMcp, + ctx, + "passthrough", + ); + const serverInstructions = agentClient.getInstructions(); + if (serverInstructions?.trim()) { + enrichedMessages = allMessages.map((msg) => + msg.id === "decopilot-system" + ? DECOPILOT_BASE_PROMPT(serverInstructions) + : msg, + ); + } + } catch (err) { + console.warn( + "[decopilot:stream] Failed to load agent instructions for Claude Code", + err, + ); + } finally { + agentClient?.close().catch(() => {}); + } + + // 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(); + let ccResult: Awaited>; + try { + ccResult = await streamClaudeCode(writer, { + messages: enrichedMessages, + abortController, + mcpEndpoint, + mcpHeaders, + agentId: input.agent.id, + agentMode: input.agent.mode, + threadId: mem.thread.id, + connectionId: input.models.credentialId, + model: input.models.thinking.id, + planMode: input.planMode, + }); + } finally { + // 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 + 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, + }); + } + + // Record tool call metrics for monitoring visibility + if (ccResult.toolCallCount > 0) { + // Emit aggregate tool execution metric so monitoring dashboards + // can see Claude Code tool activity. + for (const part of ccResult.parts) { + if (part.type === "dynamic-tool") { + recordToolExecutionMetrics({ + ctx, + organizationId: input.organizationId, + connectionId: DECOPILOT_CONNECTION_ID, + toolName: part.toolName, + durationMs: 0, + isError: part.state === "output-error", + }); + } + } + } + + // Persist the assistant response so it survives page reload. + // Use the ordered parts array (text + tool calls interleaved) when + // available so tool call cards are preserved after refresh. + if (ccResult.parts.length > 0 || ccResult.responseText) { + const responseParts = + ccResult.parts.length > 0 + ? ccResult.parts + : [{ type: "text" as const, text: ccResult.responseText }]; + const responseMessage: ChatMessage = { + id: generateMessageId(), + role: "assistant", + parts: responseParts as unknown as ChatMessage["parts"], + }; + await saveMessagesToThread(responseMessage); + } + + // Generate title for Claude Code threads (no AI SDK model available, + // so extract from the first user message text). + if (mem.thread.title === DEFAULT_THREAD_TITLE) { + const userText = + requestMessage?.parts + ?.filter( + (p): p is { type: "text"; text: string } => + "text" in p && + typeof (p as { text?: unknown }).text === "string", + ) + .map((p) => p.text) + .join(" ") + .trim() ?? ""; + if (userText) { + const title = userText + .replace(/\s+/g, " ") + .slice(0, 60) + .replace(/\s\S*$/, userText.length > 60 ? "…" : ""); + ctx.storage.threads + .update(mem.thread.id, { title }) + .then(() => { + if (!streamFinished) { + writer.write({ + type: "data-thread-title", + data: { title }, + transient: true, + }); + } + }) + .catch(() => {}); + } + } + + // Emit auth cards for connections created during this stream. + // We compare created_at against the stream start time so we only + // show cards for freshly installed connections, not pre-existing ones. + try { + 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; + + // Only show auth cards for connections created during this + // stream — not for pre-existing unauthenticated connections. + const createdAt = new Date(conn.created_at).getTime(); + if (createdAt < streamStartTime) 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; + + // Connection was just created with no auth — show auth card + if (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; + } + + // ── 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 +507,7 @@ export async function streamCore( const builtInTools = await getBuiltInTools( writer, { - provider, + provider: activeProvider, organization, models: input.models, toolApprovalLevel: input.toolApprovalLevel, @@ -314,7 +545,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 +583,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/api/routes/decopilot/types.ts b/apps/mesh/src/api/routes/decopilot/types.ts index ef68e5c6b6..2c0120cba2 100644 --- a/apps/mesh/src/api/routes/decopilot/types.ts +++ b/apps/mesh/src/api/routes/decopilot/types.ts @@ -38,6 +38,16 @@ export type ChatMessage = UIMessage< "thread-title": { title: string; }; + "connection-auth": { + connectionId: string; + title: string; + icon: string | null; + connectionUrl: string | null; + elicitationId: string; + }; + "prompt-suggestion": { + suggestion: string; + }; }, { [K in keyof ReturnType]: InferUITool< diff --git a/apps/mesh/src/api/routes/downstream-token.ts b/apps/mesh/src/api/routes/downstream-token.ts index 6058ef24d8..a75d923579 100644 --- a/apps/mesh/src/api/routes/downstream-token.ts +++ b/apps/mesh/src/api/routes/downstream-token.ts @@ -97,6 +97,38 @@ app.post("/connections/:connectionId/oauth-token", async (c) => { const token = await tokenStorage.upsert(tokenData); + // Clear needs_auth flag from metadata + const existingMeta = + (connection.metadata as Record | null) ?? {}; + if (existingMeta.needs_auth) { + const { needs_auth: _, ...restMeta } = existingMeta; + await ctx.storage.connections.update(connectionId, { metadata: restMeta }); + } + + // 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: body.accessToken, + 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, @@ -159,4 +191,96 @@ 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); + } + + // Clear needs_auth flag from metadata + const existingMeta = + (connection.metadata as Record | null) ?? {}; + const { needs_auth: _, ...restMeta } = existingMeta; + + await ctx.storage.connections.update(connectionId, { + connection_token: body.token, + metadata: restMeta, + }); + + // 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/mcp-clients/virtual-mcp/code-execution.ts b/apps/mesh/src/mcp-clients/virtual-mcp/code-execution.ts index 58733e76b9..ab03933bf6 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 () => {}, }; // 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/authenticate.ts b/apps/mesh/src/tools/connection/authenticate.ts new file mode 100644 index 0000000000..26d930613b --- /dev/null +++ b/apps/mesh/src/tools/connection/authenticate.ts @@ -0,0 +1,185 @@ +/** + * Connection Authentication Tools + * + * CONNECTION_AUTHENTICATE — Returns structured data for the frontend to render + * an inline auth card. The tool itself is read-only — the UI handles the OAuth + * mutation. + * + * CONNECTION_AUTH_STATUS — Check if a connection needs authentication and its + * current health status. + */ + +import { z } from "zod"; +import { defineTool } from "../../core/define-tool"; +import { requireOrganization } from "../../core/mesh-context"; +import type { MeshContext } from "../../core/mesh-context"; + +// --------------------------------------------------------------------------- +// Shared helper +// --------------------------------------------------------------------------- + +interface ConnectionRow { + id: string; + organization_id: string; + title: string; + icon?: string | null; + description?: string | null; + connection_url?: string | null; + status?: string | null; + oauth_config?: unknown; + connection_token?: string | null; + configuration_scopes?: unknown[] | null; + configuration_state?: Record | null; +} + +async function checkAuth( + connection: ConnectionRow, + ctx: MeshContext, +): Promise<{ + isHealthy: boolean; + hasOAuth: boolean; + hasToken: boolean; + hasScopes: boolean; + hasConfigState: boolean; +}> { + let isHealthy = false; + try { + const result = await ctx.storage.connections.testConnection(connection.id); + isHealthy = result.healthy; + } catch { + // Connection unreachable + } + + const hasOAuth = !!connection.oauth_config; + const hasToken = !!connection.connection_token; + const hasScopes = + !!connection.configuration_scopes && + connection.configuration_scopes.length > 0; + const hasConfigState = + !!connection.configuration_state && + Object.keys(connection.configuration_state).length > 0; + + return { isHealthy, hasOAuth, hasToken, hasScopes, hasConfigState }; +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +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"); + } + + const { isHealthy, hasOAuth, hasToken } = await checkAuth(connection, ctx); + + // Simple: oauth if oauth_config exists, token otherwise. + // needs_auth = true when token is missing (and no oauth_config). + let authType: "oauth" | "token" | "configuration" | "none" = "none"; + if (hasOAuth) { + authType = "oauth"; + } else if (!hasToken) { + authType = "token"; + } + + const needsAuth = hasOAuth ? !isHealthy : !hasToken; + + 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, + }; + }, +}); + +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"); + } + + const { isHealthy, hasOAuth, hasScopes, hasConfigState } = await checkAuth( + connection, + ctx, + ); + + // Determine if auth is needed: + // - Connection is unhealthy AND has OAuth config or scopes + // - Connection has configuration_scopes but no configuration_state values + const needsAuth = + !isHealthy && (hasOAuth || (hasScopes && !hasConfigState)); + + 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/create.ts b/apps/mesh/src/tools/connection/create.ts index 2dc1ca221a..874d1826f3 100644 --- a/apps/mesh/src/tools/connection/create.ts +++ b/apps/mesh/src/tools/connection/create.ts @@ -108,11 +108,21 @@ export const COLLECTION_CONNECTIONS_CREATE = defineTool({ ? fetchResult.scopes : null; + // Flag needs_auth if the MCP declared scopes or has MCP_CONFIGURATION. + // Don't use !fetchResult — VIRTUAL connections return null by design. + const hasMcpConfig = tools?.some((t) => t.name === "MCP_CONFIGURATION"); + const needsAuth = !!configuration_scopes || !!hasMcpConfig; + const metadata = { + ...(connectionData.metadata as Record | null), + ...(needsAuth ? { needs_auth: true } : {}), + }; + // Create the connection with the fetched tools and scopes const connection = await ctx.storage.connections.create({ ...connectionData, tools, configuration_scopes, + metadata, }); await ctx.eventBus.publish( diff --git a/apps/mesh/src/tools/connection/index.ts b/apps/mesh/src/tools/connection/index.ts index 1263d3685f..85c4bc4796 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"; @@ -14,4 +15,11 @@ export { COLLECTION_CONNECTIONS_DELETE } from "./delete"; // Connection test tool export { CONNECTION_TEST } from "./test"; +// Connection management tools (install, auth) +export { CONNECTION_INSTALL } from "./install"; +export { + CONNECTION_AUTH_STATUS, + 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..ee5877be6d --- /dev/null +++ b/apps/mesh/src/tools/connection/install.ts @@ -0,0 +1,140 @@ +/** + * 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; + + // Auth is needed if server declared scopes or has MCP_CONFIGURATION. + // Don't use !fetchResult — probe failures shouldn't persist as "Needs API Key". + const hasMcpConfig = tools?.some((t) => t.name === "MCP_CONFIGURATION"); + const needsAuth = !!scopes || !!hasMcpConfig; + + // Create the connection with needs_auth flag baked into metadata + 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, + metadata: needsAuth ? { needs_auth: true } : null, + }); + + await ctx.eventBus.publish( + organization.id, + WellKnownOrgMCPId.SELF(organization.id), + { + type: "connection.created", + data: connection, + }, + ); + + 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-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/index.ts b/apps/mesh/src/tools/index.ts index 8b456d81e1..0f7a37e9c2 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -32,6 +32,7 @@ import * as AutomationTools from "./automations"; import * as UserTools from "./user"; import * as AiProvidersTools from "./ai-providers"; import { ToolName } from "./registry"; +import { MANAGEMENT_MCP_INSTRUCTIONS } from "./shared-prompts"; // Core tools - always available const CORE_TOOLS = [ OrganizationTools.ORGANIZATION_CREATE, @@ -49,10 +50,14 @@ 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, ConnectionTools.CONNECTION_TEST, + ConnectionTools.CONNECTION_INSTALL, + ConnectionTools.CONNECTION_AUTH_STATUS, + ConnectionTools.CONNECTION_AUTHENTICATE, // Virtual MCP collection tools VirtualMCPTools.COLLECTION_VIRTUAL_MCP_CREATE, @@ -202,17 +207,36 @@ export const managementMCP = async (ctx: MeshContext) => { } } } - enabledPlugins = merged.size > 0 ? [...merged] : null; + enabledPlugins = [...merged]; } // Filter tools based on enabled plugins // 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: "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 @@ -246,6 +270,7 @@ export const managementMCP = async (ctx: MeshContext) => { ctx.access.setToolName(tool.name); try { const result = await tool.execute(args, ctx); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }], structuredContent: result as { [x: string]: unknown }, diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index 4248f9bbd4..89f2e6b056 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -53,10 +53,14 @@ 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", "CONNECTION_TEST", + "CONNECTION_INSTALL", + "CONNECTION_AUTH_STATUS", + "CONNECTION_AUTHENTICATE", // Virtual MCP tools "COLLECTION_VIRTUAL_MCP_CREATE", "COLLECTION_VIRTUAL_MCP_LIST", @@ -257,6 +261,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", @@ -278,6 +287,21 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Test connections", 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", @@ -693,11 +717,15 @@ 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", COLLECTION_CONNECTIONS_DELETE: "Delete connections", CONNECTION_TEST: "Test connections", + 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/tools/shared-prompts.ts b/apps/mesh/src/tools/shared-prompts.ts new file mode 100644 index 0000000000..739f926079 --- /dev/null +++ b/apps/mesh/src/tools/shared-prompts.ts @@ -0,0 +1,190 @@ +/** + * Shared prompt constants used by both the MCP server (tools/index.ts) + * and the Decopilot web chat (api/routes/decopilot/constants.ts). + * + * MANAGEMENT_MCP_INSTRUCTIONS is the canonical, complete reference for the + * Deco Studio platform capabilities. It is used verbatim as MCP server + * instructions and embedded (with a persona header) into the web chat system prompt. + */ + +export const MANAGEMENT_MCP_INSTRUCTIONS = `You are connected to Deco Studio — an MCP control plane that manages connections, credentials, and tools for AI agents. + +## What you're talking to + +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. + +## Two ways to use tools + +**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) +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: +- **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. + +### Automations & events +Use automations and events together to build reactive workflows (e.g. "when I get an email, summarize it in Slack"): + +- **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 (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. +- **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 already-installed connection tools + +**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. + +Use these three tools in order to interact with installed external services (Gmail, Slack, databases, etc.): + +### Step 1: Search for tools from installed connections +\`\`\` +CODE_EXECUTION_SEARCH_TOOLS({ query: "gmail" }) +\`\`\` +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 +\`\`\` +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 new connections + +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) +REGISTRY_ITEM_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. ALWAYS call CONNECTION_AUTHENTICATE after install +CONNECTION_AUTHENTICATE({ connection_id: "conn_abc123" }) +// → 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 +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) { ... }" }) +\`\`\` + +**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 + +- **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. +- **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\``; diff --git a/apps/mesh/src/web/components/chat/context.tsx b/apps/mesh/src/web/components/chat/context.tsx index 9f42f93bef..ceac3662fa 100644 --- a/apps/mesh/src/web/components/chat/context.tsx +++ b/apps/mesh/src/web/components/chat/context.tsx @@ -51,6 +51,8 @@ interface ChatStableValue { setSelectedModel: (model: AiProviderModel) => void; selectedMode: ToolSelectionStrategy; setSelectedMode: (mode: ToolSelectionStrategy) => void; + planMode: boolean; + setPlanMode: (enabled: boolean) => void; sendMessage: ( tiptapDoc: Metadata["tiptapDoc"], @@ -115,6 +117,7 @@ export function useChatStable(): ChatStableValue { model: state.selectedModel, isModelsLoading: state.isModelsLoading, selectedMode: state.selectedMode, + planMode: state.planMode, allModelsConnections: state.allModelsConnections, credentialId: state.credentialId, tiptapDoc: state.tiptapDoc, @@ -139,6 +142,7 @@ export function useChatStable(): ChatStableValue { }, setSelectedModel: (model: AiProviderModel) => chatStore.setModel(model), setSelectedMode: (mode: ToolSelectionStrategy) => chatStore.setMode(mode), + setPlanMode: (enabled: boolean) => chatStore.setPlanMode(enabled), setOwnerFilter: (filter: TaskOwnerFilter) => chatStore.setOwnerFilter(filter), sendMessage: ( 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/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index dccba21611..df512fbf7f 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -26,6 +26,7 @@ import { Edit01, Lock01, Stop, + Target04, Users03, XCircle, } from "@untitledui/icons"; @@ -297,6 +298,46 @@ function VirtualMCPBadge({ ); } +// ============================================================================ +// PlanModeToggle - Toggle button for Claude Code plan mode +// ============================================================================ + +function PlanModeToggle({ + enabled, + onToggle, + disabled = false, +}: { + enabled: boolean; + onToggle: (enabled: boolean) => void; + disabled?: boolean; +}) { + return ( + + + + + + {enabled ? "Plan mode on — click to disable" : "Plan mode"} + + + ); +} + // ============================================================================ // ChatInput - Merged component with virtual MCP wrapper, banners, and selectors // ============================================================================ @@ -316,6 +357,8 @@ export function ChatInput({ isModelsLoading, selectedMode, setSelectedMode, + planMode, + setPlanMode, messages, isStreaming, isRunInProgress, @@ -499,7 +542,10 @@ export function ChatInput({
@@ -550,6 +596,13 @@ export function ChatInput({ onModeChange={setSelectedMode} disabled={isStreaming} /> + {model?.modelId?.startsWith("claude-code:") && ( + + )} {contextWindow && lastTotalTokens > 0 && ( ; +/** + * Returns true for parts that should be rendered in the message. + * Data parts (metadata, suggestions) and invisible parts are filtered out + * so they don't cause index-based key shifts during streaming. + */ +function isVisiblePart(part: MessagePart): boolean { + switch (part.type) { + case "reasoning": + return true; + case "step-start": + case "file": + case "source-url": + case "source-document": + case "data-tool-metadata": + case "data-tool-subtask-metadata": + case "data-prompt-suggestion": + return false; + default: + if (part.type.startsWith("data-") && part.type !== "data-connection-auth") + return false; + return true; + } +} + +function getPartKey(part: MessagePart, messageId: string, index: number) { + if ("toolCallId" in part && part.toolCallId) return part.toolCallId; + return `${messageId}-${part.type}-${index}`; +} + +function PlanApprovalActions() { + const { sendMessage, setPlanMode } = useChatStable(); + + const handleApprove = () => { + setPlanMode(false); + const doc: TiptapDoc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "The plan looks good. Please implement it.", + }, + ], + }, + ], + }; + void sendMessage(doc); + }; + + return ( +
+ + + or send a message with feedback + +
+ ); +} + interface MessageAssistantProps { message: ChatMessage | null; status?: "streaming" | "submitted" | "ready" | "error"; className?: string; isLast: boolean; + isPlanMode?: boolean; } interface MessagePartProps { @@ -233,9 +304,48 @@ function MessagePart({ return null; case "data-tool-metadata": case "data-tool-subtask-metadata": + case "data-prompt-suggestion": 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 + if (fallback.type === "tool-CONNECTION_AUTHENTICATE") { + return ( + + ); + } if (fallback.type.startsWith("tool-")) { const toolCallId = (fallback as ToolUIPart).toolCallId; const meta = dataParts.toolMetadata.get(toolCallId); @@ -288,11 +398,43 @@ function Container({ ); } +function PromptSuggestions({ suggestions }: { suggestions: string[] }) { + const { sendMessage } = useChatStable(); + + if (suggestions.length === 0) return null; + + const handleClick = (suggestion: string) => { + const doc: TiptapDoc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: suggestion }] }, + ], + }; + void sendMessage(doc); + }; + + return ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ); +} + export function MessageAssistant({ message, status, className, isLast = false, + isPlanMode = false, }: MessageAssistantProps) { const isStreaming = status === "streaming"; const isSubmitted = status === "submitted"; @@ -315,9 +457,8 @@ export function MessageAssistant({ // Handle null message or empty parts const hasContent = message !== null && message.parts.length > 0; - // Use hook to extract reasoning and data parts in a single pass - const { reasoningParts, dataParts } = useFilterParts(message); - const hasReasoning = reasoningParts.length > 0; + // Use hook to extract data parts in a single pass + const { dataParts } = useFilterParts(message); const reasoningStartAt = message?.metadata?.reasoning_start_at ? new Date(message.metadata.reasoning_start_at) @@ -331,42 +472,107 @@ export function MessageAssistant({ ? reasoningEndAt.getTime() - reasoningStartAt.getTime() : null; + // Filter to only visible parts to avoid index-based key shifts from data/metadata parts + const visibleParts = hasContent ? message.parts.filter(isVisiblePart) : []; + + // Group consecutive reasoning parts into inline ThoughtSummary blocks. + // Each group renders where it naturally occurs in the message flow. + type RenderItem = + | { kind: "part"; part: MessagePart; index: number } + | { kind: "reasoning"; parts: ReasoningPart[]; key: string }; + + const renderItems: RenderItem[] = []; + let pendingReasoning: ReasoningPart[] = []; + + const flushReasoning = () => { + if (pendingReasoning.length > 0) { + renderItems.push({ + kind: "reasoning", + parts: [...pendingReasoning], + key: `reasoning-${renderItems.length}`, + }); + pendingReasoning = []; + } + }; + + for (let i = 0; i < visibleParts.length; i++) { + const part = visibleParts[i]!; + if (part.type === "reasoning") { + pendingReasoning.push(part as ReasoningPart); + } else { + flushReasoning(); + renderItems.push({ kind: "part", part, index: i }); + } + } + flushReasoning(); + + // Check if the last render item is an active reasoning group (still streaming) + const lastItem = renderItems[renderItems.length - 1]; + const isLastItemStreamingReasoning = + isStreaming && lastItem?.kind === "reasoning"; + return ( {hasContent ? (
- {hasReasoning && ( - - )} - {message.parts.map((part, index) => { - const isLastPart = index === message.parts.length - 1; - const usage = isLastPart - ? addUsage(emptyUsageStats(), message.metadata?.usage) - : null; - - return ( - - ) - } - dataParts={dataParts} - isLoading={isLoading} - isLastMessage={isLast} - /> - ); - })} - {isLast && isLoading && startedAt !== null && ( - + {isPlanMode && ( +
+ + Plan +
)} +
+ {renderItems.map((item) => { + if (item.kind === "reasoning") { + // Inline reasoning block — streaming if it's the last item and we're still loading + const isThisStreaming = isStreaming && item === lastItem; + return ( + + ); + } + + const { part, index } = item; + const isLastVisible = + index === visibleParts.length - 1 && + !isLastItemStreamingReasoning; + const usage = isLastVisible + ? addUsage(emptyUsageStats(), message.metadata?.usage) + : null; + + return ( + + ) + } + dataParts={dataParts} + isLoading={isLoading} + isLastMessage={isLast} + /> + ); + })} + {isLast && !isLoading && dataParts.promptSuggestions.length > 0 && ( + + )} + {isLast && isLoading && startedAt !== null && ( + + )} +
+ {isLast && !isLoading && isPlanMode && }
) : isLoading ? ( diff --git a/apps/mesh/src/web/components/chat/message/pair.tsx b/apps/mesh/src/web/components/chat/message/pair.tsx index c265a11b32..32e2165475 100644 --- a/apps/mesh/src/web/components/chat/message/pair.tsx +++ b/apps/mesh/src/web/components/chat/message/pair.tsx @@ -1,5 +1,5 @@ import { useRef } from "react"; -import type { ChatMessage, ChatStatus } from "../types.ts"; +import type { ChatMessage, ChatStatus, Metadata } from "../types.ts"; import { MessageAssistant } from "./assistant.tsx"; import { MessageUser } from "./user.tsx"; @@ -76,6 +76,8 @@ export function MessagePair({ pair, isLastPair, status }: MessagePairProps) { } }; + const isPlanMode = !!(pair.user.metadata as Metadata | undefined)?.planMode; + return (
{/* Sticky overlay to prevent scrolling content from appearing above the user message */} @@ -88,6 +90,7 @@ export function MessagePair({ pair, isLastPair, status }: MessagePairProps) { message={pair.assistant} status={status} isLast={isLastPair} + isPlanMode={isPlanMode} />
); 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..1bb519b588 --- /dev/null +++ b/apps/mesh/src/web/components/chat/message/parts/tool-call-part/connection-auth.tsx @@ -0,0 +1,313 @@ +"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"; +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" | "checking" | "authenticating" | "success" | "error"; + +function AuthCard({ data }: { data: AuthData }) { + // If OAuth fails (e.g. server doesn't support it), fall back to token input + const [oauthFailed, setOauthFailed] = useState(false); + const isTokenAuth = + oauthFailed || + data.auth_type === "configuration" || + data.auth_type === "token"; + + const [authState, setAuthState] = useState(() => { + if (!data.needs_auth) return "success"; + 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); + + // 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( + (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 handleOAuthAuthenticate = async () => { + setAuthState("authenticating"); + setErrorMsg(null); + try { + const result = await authenticateMcp({ + connectionId: data.connection_id, + }); + if (!result.token) { + const errMsg = result.error ?? "Authentication failed"; + // If OAuth discovery failed, fall back to token input + if ( + errMsg.includes("Protected Resource Metadata") || + errMsg.includes("OAuth") || + errMsg.includes("authorization server") + ) { + setOauthFailed(true); + setAuthState("idle"); + setErrorMsg(null); + return; + } + setAuthState("error"); + setErrorMsg(errMsg); + return; + } + + 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) { + const msg = err instanceof Error ? err.message : "Authentication failed"; + // If OAuth discovery failed, fall back to token input + if ( + msg.includes("Protected Resource Metadata") || + msg.includes("OAuth") || + msg.includes("authorization server") + ) { + setOauthFailed(true); + setAuthState("idle"); + setErrorMsg(null); + return; + } + setAuthState("error"); + setErrorMsg(msg); + } + }; + + 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.icon ? ( + {data.title} + ) : ( + + )} +
+
+
+ {data.title} + {connected && } +
+ {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}

+ )} +
+ {/* OAuth authenticate button */} + {!connected && !isTokenAuth && authState !== "checking" && ( + + )} + {authState === "checking" && ( + + )} +
+ ); +} + +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.title}` + : 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/components/chat/message/use-filter-parts.ts b/apps/mesh/src/web/components/chat/message/use-filter-parts.ts index 0625f2e70b..1f49f65114 100644 --- a/apps/mesh/src/web/components/chat/message/use-filter-parts.ts +++ b/apps/mesh/src/web/components/chat/message/use-filter-parts.ts @@ -25,12 +25,14 @@ export interface ToolSubtaskMetadata { export interface DataParts { toolMetadata: Map; toolSubtaskMetadata: Map; + promptSuggestions: string[]; } export function useFilterParts(message: ChatMessage | null) { const reasoningParts: ReasoningPart[] = []; const toolMetadata = new Map(); const toolSubtaskMetadata = new Map(); + const promptSuggestions: string[] = []; if (message) { for (const p of message.parts) { @@ -73,12 +75,20 @@ export function useFilterParts(message: ChatMessage | null) { (p as { id: string }).id, (p as { data: ToolSubtaskMetadata }).data, ); + continue; + } + + if (p.type === "data-prompt-suggestion" && "data" in p) { + const data = (p as { data: { suggestion?: string } }).data; + if (data.suggestion) { + promptSuggestions.push(data.suggestion); + } } } } return { reasoningParts, - dataParts: { toolMetadata, toolSubtaskMetadata }, + dataParts: { toolMetadata, toolSubtaskMetadata, promptSuggestions }, }; } 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..7ec7425301 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,5 +1,6 @@ import { Suspense } from "react"; import { CpuChip01 } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; import { ProviderCard, @@ -9,7 +10,6 @@ import { useAiProviders, useAiProviderKeyList, } from "@/web/hooks/collections/use-llm"; -import { cn } from "@deco/ui/lib/utils.ts"; function ProviderList() { const aiProviders = useAiProviders(); @@ -58,7 +58,7 @@ export function NoLlmBindingEmptyState({
+
diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 797ea85cc3..7d839366db 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -83,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", @@ -97,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", @@ -112,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", diff --git a/apps/mesh/src/web/components/chat/store/chat-store.ts b/apps/mesh/src/web/components/chat/store/chat-store.ts index ae7a3f912b..13dfdb3d4b 100644 --- a/apps/mesh/src/web/components/chat/store/chat-store.ts +++ b/apps/mesh/src/web/components/chat/store/chat-store.ts @@ -131,6 +131,7 @@ class ChatStore { status: "ready", error: null, finishReason: null, + planMode: false, appContexts: {}, tiptapDoc: undefined, }; @@ -414,6 +415,11 @@ class ChatStore { this.notify(); } + setPlanMode(enabled: boolean): void { + this.state = { ...this.state, planMode: enabled }; + this.notify(); + } + setCredentialId(id: string | null): void { this.state = { ...this.state, credentialId: id }; writeSelectedKeyId(this.state.locator, id); @@ -530,6 +536,7 @@ class ChatStore { tiptapDoc: params.tiptapDoc, created_at: new Date().toISOString(), thread_id: this.state.activeThreadId, + planMode: this.state.planMode || undefined, agent: { id: selectedAgent?.id ?? decopilotId, mode: selectedMode, @@ -555,6 +562,7 @@ class ChatStore { const metadata: Metadata = { ...messageMetadata, system, + planMode: this.state.planMode || undefined, models: { credentialId: model.keyId ?? effectiveKeyId ?? "", thinking: toMetadataModelInfo(model), diff --git a/apps/mesh/src/web/components/chat/store/types.ts b/apps/mesh/src/web/components/chat/store/types.ts index 87ecb2ef88..58d5c73b7e 100644 --- a/apps/mesh/src/web/components/chat/store/types.ts +++ b/apps/mesh/src/web/components/chat/store/types.ts @@ -51,6 +51,9 @@ export interface ChatStoreState { error: Error | null; finishReason: string | null; + // Claude Code plan mode + planMode: boolean; + // App contexts from ext-apps appContexts: Record; diff --git a/apps/mesh/src/web/components/chat/types.ts b/apps/mesh/src/web/components/chat/types.ts index fe562c8d6c..4cd1daaf5e 100644 --- a/apps/mesh/src/web/components/chat/types.ts +++ b/apps/mesh/src/web/components/chat/types.ts @@ -81,6 +81,8 @@ export interface Metadata { system?: string; /** Tiptap document for rich user input (includes prompt tags with resources) */ tiptapDoc?: TiptapDoc; + /** Claude Code plan mode — produces a plan without executing tools */ + planMode?: boolean; usage?: { inputTokens?: number; outputTokens?: number; diff --git a/apps/mesh/src/web/components/connections/connection-status.tsx b/apps/mesh/src/web/components/connections/connection-status.tsx index fcc143dd86..6cca664248 100644 --- a/apps/mesh/src/web/components/connections/connection-status.tsx +++ b/apps/mesh/src/web/components/connections/connection-status.tsx @@ -1,13 +1,29 @@ import { Badge } from "@deco/ui/components/badge.tsx"; -import { AlertCircle, CheckCircle } from "@untitledui/icons"; +import { AlertCircle, CheckCircle, Key01 } from "@untitledui/icons"; type ConnectionStatusValue = "active" | "inactive" | "error"; export function ConnectionStatus({ status, + needsAuth, }: { status: ConnectionStatusValue; + needsAuth?: boolean; }) { + // needsAuth takes priority — even if status is "active", the connection + // can't actually work without an API key + if (needsAuth) { + return ( + + + Needs API Key + + ); + } + if (status === "active") { return ( (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,21 @@ export function ProviderCard({ queryClient.invalidateQueries({ queryKey: KEYS.aiProviderModels(locator, deletedKeyId), }); + 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" }), + }) + .then(() => { + queryClient.invalidateQueries({ + queryKey: KEYS.connectStudioStatus(org.slug), + }); + }) + .catch(() => { + toast.error("Failed to remove MCP from Claude Code"); + }); + } toast.success("Key deleted"); }, onError: (err) => { @@ -497,10 +513,73 @@ export function ProviderCard({ 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); + 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) }); + + // 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) 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", + ); + } + } 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 +627,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} > @@ -572,9 +653,30 @@ export function ProviderCard({ /> )}
-

{provider.name}

+

+ {provider.name} + {provider.id === "openrouter" && ( + + OAuth + + )} + {provider.id === "anthropic" && ( + + API + + )} + {provider.id === "claude-code" && ( + + Local + + )} +

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

@@ -619,6 +721,13 @@ export function ProviderCard({ /> )} + {isClaudeCode && connectStudioStatus.data?.claude?.auth && ( +

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

+ )} = 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(); + + const gatewayConnection = connections.find((c) => + connectionImplementsBinding(c, AI_GATEWAY_BILLING_BINDING), + ); - if (!hasDecoKey) return null; + if (!gatewayConnection?.id) { + return null; + } - return ; + return ; } 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; diff --git a/apps/mesh/src/web/utils/ai-providers-logos.ts b/apps/mesh/src/web/utils/ai-providers-logos.ts index fe8beb4e9f..9b56f17456 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": "/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/bun.lock b/bun.lock index 9fe291379c..64e9f353a4 100644 --- a/bun.lock +++ b/bun.lock @@ -55,7 +55,7 @@ }, "apps/mesh": { "name": "decocms", - "version": "2.175.6", + "version": "2.175.8", "bin": { "deco": "./dist/server/cli.js", }, @@ -173,6 +173,7 @@ "zustand": "^5.0.9", }, "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@duckdb/node-api": "^1.5.0-r.1", }, }, @@ -474,6 +475,8 @@ "@ai-sdk/react": ["@ai-sdk/react@3.0.118", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.19", "ai": "6.0.116", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-fBAix8Jftxse6/2YJnOFkwW1/O6EQK4DK68M9DlFmZGAzBmsaHXEPVS77sVIlkaOWCy11bE7434NAVXRY+3OsQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="], "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], diff --git a/conductor.json b/conductor.json index 1772c40a72..a75b043e20 100644 --- a/conductor.json +++ b/conductor.json @@ -1,7 +1,7 @@ { "scripts": { "setup": "bun install", - "run": "bun run dev:conductor", + "run": "MESH_LOCAL_MODE=true bun run dev:conductor", "archive": "rm -rf node_modules" } } diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index 0a66cfb0fb..3d23b6e345 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -226,6 +226,75 @@ 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 to use tools + +You have three meta-tools for interacting with connected services: + +### GATEWAY_SEARCH_TOOLS — discover available tools +\`\`\` +GATEWAY_SEARCH_TOOLS({ query: "gmail" }) +\`\`\` +Always search first. Don't guess tool names or parameters. + +### 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**: 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. **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 + +- **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 +- 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. * This is the default agent that aggregates ALL org connections. @@ -247,7 +316,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 }; } 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];