From 20de8a844fb7933a0dcb78eb4d89b8b539effef3 Mon Sep 17 00:00:00 2001 From: paweltargosinski Date: Sat, 21 Mar 2026 22:05:21 +0100 Subject: [PATCH 01/28] add session-level auto-approve for AI file read operations Adds an "Allow reading in this session" button to the AI tool approval UI. When clicked, all subsequent file reads from the same directory are auto-approved for the rest of the session (in-memory only, resets on restart). Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/app/aipanel/aitooluse.tsx | 78 +++++++++++++++++++++++++-- frontend/app/aipanel/waveai-model.tsx | 6 +++ frontend/app/store/wshclientapi.ts | 6 +++ frontend/types/gotypes.d.ts | 5 ++ pkg/aiusechat/sessionapproval.go | 72 +++++++++++++++++++++++++ pkg/aiusechat/tools_readdir.go | 11 ++++ pkg/aiusechat/tools_readfile.go | 11 ++++ pkg/wshrpc/wshclient/wshclient.go | 6 +++ pkg/wshrpc/wshrpctypes.go | 5 ++ pkg/wshrpc/wshserver/wshserver.go | 8 +++ 10 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 pkg/aiusechat/sessionapproval.go diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 7868c188e9..84524d94b2 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -77,6 +77,32 @@ const ToolDesc = memo(({ text, className }: ToolDescProps) => { ToolDesc.displayName = "ToolDesc"; +// Extract directory path from a tool description like: reading "/path/to/file" (...) +function extractDirFromToolDesc(toolDesc: string): string | null { + const match = toolDesc?.match(/(?:reading|reading directory)\s+"([^"]+)"/); + if (!match) return null; + const filePath = match[1]; + // For "reading directory" — the path itself is the directory + if (toolDesc.startsWith("reading directory")) { + return filePath; + } + // For "reading" (file) — get parent directory + const lastSlash = filePath.lastIndexOf("/"); + if (lastSlash < 0) return filePath; + if (lastSlash === 0) return "/"; + return filePath.substring(0, lastSlash); +} + +// Extract all unique directories from a set of tool use parts +function extractDirsFromParts(parts: Array): string[] { + const dirs = new Set(); + for (const part of parts) { + const dir = extractDirFromToolDesc(part.data.tooldesc); + if (dir) dirs.add(dir); + } + return Array.from(dirs); +} + function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; } @@ -85,9 +111,11 @@ interface AIToolApprovalButtonsProps { count: number; onApprove: () => void; onDeny: () => void; + onAllowSession?: () => void; + showSessionButton?: boolean; } -const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApprovalButtonsProps) => { +const AIToolApprovalButtons = memo(({ count, onApprove, onDeny, onAllowSession, showSessionButton }: AIToolApprovalButtonsProps) => { const approveText = count > 1 ? `Approve All (${count})` : "Approve"; const denyText = count > 1 ? "Deny All" : "Deny"; @@ -99,6 +127,15 @@ const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApproval > {approveText} + {showSessionButton && onAllowSession && ( + + )}
{errorMessage} + +
+ ) +); +ToggleSwitch.displayName = "ToggleSwitch"; + export const AIPanelHeader = memo(() => { const model = WaveAIModel.getInstance(); const widgetAccess = useAtomValue(model.widgetAccessAtom); + const mcpContext = useAtomValue(model.mcpContextAtom); const inBuilder = model.inBuilder; const handleKebabClick = (e: React.MouseEvent) => { @@ -29,42 +72,35 @@ export const AIPanelHeader = memo(() => { Wave AI -
+
{!inBuilder && ( -
- Context - Widget Context - -
+ /> + { + model.setMCPContext(!mcpContext); + setTimeout(() => model.focusInput(), 0); + }} + title={`MCP Context ${mcpContext ? "ON" : "OFF"}`} + /> + )} + +
+ ); +}); + +MCPDetectBanner.displayName = "MCPDetectBanner"; + +export const MCPConnectInput = memo(() => { + const model = WaveAIModel.getInstance(); + const showInput = useAtomValue(model.showMCPConnectInput); + const [inputCwd, setInputCwd] = useState(""); + + const handleConnect = useCallback(() => { + if (!inputCwd.trim()) return; + model.setMCPCwd(inputCwd.trim()); + model.setMCPContext(true); + globalStore.set(model.showMCPConnectInput, false); + setInputCwd(""); + }, [inputCwd, model]); + + const handleCancel = useCallback(() => { + globalStore.set(model.showMCPConnectInput, false); + setInputCwd(""); + }, [model]); + + if (!showInput) return null; + + return ( +
+
+ + Connect to MCP server +
+
+ setInputCwd(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConnect(); + if (e.key === "Escape") handleCancel(); + }} + placeholder="/path/to/project (with .mcp.json)" + className="flex-1 bg-gray-900 text-gray-200 text-xs font-mono px-2 py-1.5 rounded border border-gray-600 focus:border-accent-500 focus:outline-none" + spellCheck={false} + autoFocus + /> + + +
+
+ ); +}); + +MCPConnectInput.displayName = "MCPConnectInput"; diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 0ae90e891e..24ffbc9b79 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -56,6 +56,10 @@ export class WaveAIModel { isAIStreaming = jotai.atom(false); widgetAccessAtom!: jotai.Atom; + mcpContextAtom!: jotai.Atom; + mcpCwdAtom!: jotai.Atom; + showMCPConnectInput: jotai.PrimitiveAtom = jotai.atom(false); + showApiKeyInput: jotai.PrimitiveAtom<{ presetKey: string; secretName: string; secretLabel: string } | null> = jotai.atom(null) as any; droppedFiles: jotai.PrimitiveAtom = jotai.atom([]); chatId!: jotai.PrimitiveAtom; currentAIMode!: jotai.PrimitiveAtom; @@ -96,6 +100,21 @@ export class WaveAIModel { return value ?? true; }); + this.mcpContextAtom = jotai.atom((get) => { + if (this.inBuilder) { + return false; + } + const mcpContextMetaAtom = getOrefMetaKeyAtom(this.orefContext, "waveai:mcpcontext"); + const value = get(mcpContextMetaAtom); + return value ?? false; + }); + + this.mcpCwdAtom = jotai.atom((get) => { + const mcpCwdMetaAtom = getOrefMetaKeyAtom(this.orefContext, "waveai:mcpcwd"); + const value = get(mcpCwdMetaAtom); + return (value as string) ?? ""; + }); + this.codeBlockMaxWidth = jotai.atom((get) => { const width = get(this.containerWidth); return width > 0 ? width - 35 : 0; @@ -393,6 +412,20 @@ export class WaveAIModel { }); } + setMCPContext(enabled: boolean) { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: this.orefContext, + meta: { "waveai:mcpcontext": enabled }, + }); + } + + setMCPCwd(cwd: string) { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: this.orefContext, + meta: { "waveai:mcpcwd": cwd }, + }); + } + isValidMode(mode: string): boolean { const telemetryEnabled = globalStore.get(getSettingsKeyAtom("telemetry:enabled")) ?? false; if (mode.startsWith("waveai@") && !telemetryEnabled) { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 76bfc06999..db112be287 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1146,6 +1146,8 @@ declare global { "waveai:model"?: string; "waveai:chatid"?: string; "waveai:widgetcontext"?: boolean; + "waveai:mcpcontext"?: boolean; + "waveai:mcpcwd"?: string; "term:*"?: boolean; "term:fontsize"?: number; "term:fontfamily"?: string; From 69c7e84f1acddb8e06c32f40cd6dbf2538643d0c Mon Sep 17 00:00:00 2001 From: paweltargosinski Date: Sun, 22 Mar 2026 15:22:14 +0100 Subject: [PATCH 08/28] feat: MCP Client widget with tools panel and call log - New 'mcpclient' view in BlockRegistry with CWD input - Tools list with Run button and expandable call log - Registered in launcher sidebar - HTTP endpoints wired in web.go --- frontend/app/block/block.tsx | 2 + frontend/app/block/blockutil.tsx | 6 + frontend/app/view/mcpclient/mcpclient.tsx | 424 ++++++++++++++++++++++ pkg/wconfig/defaultconfig/widgets.json | 11 + pkg/web/web.go | 13 + 5 files changed, 456 insertions(+) create mode 100644 frontend/app/view/mcpclient/mcpclient.tsx diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 126f208813..642e34cf21 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -34,6 +34,7 @@ import clsx from "clsx"; import { atom, useAtomValue } from "jotai"; import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; +import { MCPClientViewModel } from "../view/mcpclient/mcpclient"; import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import "./block.scss"; import { BlockEnv } from "./blockenv"; @@ -54,6 +55,7 @@ BlockRegistry.set("launcher", LauncherViewModel); BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); +BlockRegistry.set("mcpclient", MCPClientViewModel); function makeViewModel( blockId: string, diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 01346183a0..4cc0c95b61 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -31,6 +31,9 @@ export function blockViewToIcon(view: string): string { if (view == "tips") { return "lightbulb"; } + if (view == "mcpclient") { + return "plug"; + } return "square"; } @@ -56,6 +59,9 @@ export function blockViewToName(view: string): string { if (view == "tips") { return "Tips"; } + if (view == "mcpclient") { + return "MCP Client"; + } return view; } diff --git a/frontend/app/view/mcpclient/mcpclient.tsx b/frontend/app/view/mcpclient/mcpclient.tsx new file mode 100644 index 0000000000..b6165ad948 --- /dev/null +++ b/frontend/app/view/mcpclient/mcpclient.tsx @@ -0,0 +1,424 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { RpcApi } from "@/app/store/wshclientapi"; +import type { TabModel } from "@/app/store/tab-model"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS } from "@/store/global"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; +import { Atom, atom, useAtomValue } from "jotai"; +import React, { memo, useCallback, useEffect, useState } from "react"; + +type MCPTool = { + name: string; + title?: string; + description: string; + inputSchema: Record; +}; + +type MCPServerInfo = { + name: string; + version: string; +}; + +type MCPStatusResponse = { + connected: boolean; + serverName?: string; + serverInfo?: MCPServerInfo; + tools?: MCPTool[]; + error?: string; +}; + +type MCPCallLogEntry = { + timestamp: string; + toolName: string; + duration: number; + error?: string; + resultLen: number; + arguments?: Record; + result?: string; +}; + +type MCPCallResponse = { + result?: string; + error?: string; + duration?: number; +}; + +function mcpUrl(path: string): string { + return getWebServerEndpoint() + path; +} + +async function fetchMCPStatus(cwd: string): Promise { + const resp = await fetch(mcpUrl(`/wave/mcp/status?cwd=${encodeURIComponent(cwd)}`)); + return resp.json(); +} + +async function callMCPTool(cwd: string, toolName: string, args?: Record): Promise { + const resp = await fetch(mcpUrl("/wave/mcp/call"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd, toolName, arguments: args || {} }), + }); + return resp.json(); +} + +async function fetchCallLog(): Promise { + const resp = await fetch(mcpUrl("/wave/mcp/calllog")); + return resp.json(); +} + +// ── Tool Run Dialog ────────────────────────────────────────────── + +const ToolRunDialog = memo( + ({ + tool, + cwd, + onClose, + }: { + tool: MCPTool; + cwd: string; + onClose: () => void; + }) => { + const [argsText, setArgsText] = useState("{}"); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + + const handleRun = useCallback(async () => { + setRunning(true); + setResult(null); + try { + const args = JSON.parse(argsText); + const resp = await callMCPTool(cwd, tool.name, args); + setResult(resp); + } catch (e: any) { + setResult({ error: e.message }); + } + setRunning(false); + }, [cwd, tool.name, argsText]); + + return ( +
+
+

{tool.name}

+ +
+

{tool.description}

+ +
+ +