diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index abd16c7a7..3eaa767c5 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -404,6 +404,45 @@ class ConnectorsApiService { listDiscordChannelsResponse ); }; + + // ============================================================================= + // MCP Tool Trust (Allow-List) Methods + // ============================================================================= + + /** + * Add a tool to the MCP connector's "Always Allow" list. + * Subsequent calls to this tool will skip HITL approval. + */ + trustMCPTool = async (connectorId: number, toolName: string): Promise => { + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + const token = + typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined; + await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/trust-tool`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ tool_name: toolName }), + }); + }; + + /** + * Remove a tool from the MCP connector's "Always Allow" list. + */ + untrustMCPTool = async (connectorId: number, toolName: string): Promise => { + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + const token = + typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined; + await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/untrust-tool`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ tool_name: toolName }), + }); + }; } export type { SlackChannel, DiscordChannel }; diff --git a/surfsense_web/lib/chat/streaming-state.ts b/surfsense_web/lib/chat/streaming-state.ts index 2f612a552..d54650d40 100644 --- a/surfsense_web/lib/chat/streaming-state.ts +++ b/surfsense_web/lib/chat/streaming-state.ts @@ -148,9 +148,10 @@ export function addToolCall( toolsWithUI: Set, toolCallId: string, toolName: string, - args: Record + args: Record, + force = false, ): void { - if (toolsWithUI.has(toolName)) { + if (force || toolsWithUI.has(toolName)) { state.contentParts.push({ type: "tool-call", toolCallId, @@ -175,13 +176,20 @@ export function updateToolCall( } } +function _hasInterruptResult(part: ContentPart): boolean { + if (part.type !== "tool-call") return false; + const r = (part as { result?: unknown }).result; + return typeof r === "object" && r !== null && "__interrupt__" in r; +} + export function buildContentForUI( state: ContentPartsState, toolsWithUI: Set ): ThreadMessageLike["content"] { const filtered = state.contentParts.filter((part) => { if (part.type === "text") return part.text.length > 0; - if (part.type === "tool-call") return toolsWithUI.has(part.toolName); + if (part.type === "tool-call") + return toolsWithUI.has(part.toolName) || _hasInterruptResult(part); if (part.type === "data-thinking-steps") return true; return false; }); @@ -199,7 +207,10 @@ export function buildContentForPersistence( for (const part of state.contentParts) { if (part.type === "text" && part.text.length > 0) { parts.push(part); - } else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) { + } else if ( + part.type === "tool-call" && + (toolsWithUI.has(part.toolName) || _hasInterruptResult(part)) + ) { parts.push(part); } else if (part.type === "data-thinking-steps") { parts.push(part); diff --git a/surfsense_web/lib/hitl/index.ts b/surfsense_web/lib/hitl/index.ts new file mode 100644 index 000000000..14c791d02 --- /dev/null +++ b/surfsense_web/lib/hitl/index.ts @@ -0,0 +1,8 @@ +export { isInterruptResult } from "./types"; +export type { + HitlDecision, + InterruptActionRequest, + InterruptResult, + InterruptReviewConfig, +} from "./types"; +export { useHitlDecision } from "./use-hitl-decision"; diff --git a/surfsense_web/lib/hitl/types.ts b/surfsense_web/lib/hitl/types.ts new file mode 100644 index 000000000..53df61145 --- /dev/null +++ b/surfsense_web/lib/hitl/types.ts @@ -0,0 +1,45 @@ +/** + * Shared types for Human-in-the-Loop (HITL) approval across all tools. + * + * Every tool-ui component that handles interrupts should import from here + * instead of defining its own `InterruptResult` / `isInterruptResult`. + */ + +export interface InterruptActionRequest { + name: string; + args: Record; +} + +export interface InterruptReviewConfig { + action_name: string; + allowed_decisions: Array<"approve" | "edit" | "reject">; +} + +export interface InterruptResult = Record> { + __interrupt__: true; + __decided__?: "approve" | "reject" | "edit"; + __completed__?: boolean; + action_requests: InterruptActionRequest[]; + review_configs: InterruptReviewConfig[]; + interrupt_type?: string; + context?: C; + message?: string; +} + +export function isInterruptResult(result: unknown): result is InterruptResult { + return ( + typeof result === "object" && + result !== null && + "__interrupt__" in result && + (result as InterruptResult).__interrupt__ === true + ); +} + +export interface HitlDecision { + type: "approve" | "reject" | "edit"; + message?: string; + edited_action?: { + name: string; + args: Record; + }; +} diff --git a/surfsense_web/lib/hitl/use-hitl-decision.ts b/surfsense_web/lib/hitl/use-hitl-decision.ts new file mode 100644 index 000000000..f7f85ab88 --- /dev/null +++ b/surfsense_web/lib/hitl/use-hitl-decision.ts @@ -0,0 +1,19 @@ +/** + * Shared hook for dispatching HITL decisions. + * + * All tool-ui components that handle approve/reject/edit should use this + * instead of manually constructing `CustomEvent("hitl-decision", ...)`. + */ + +import { useCallback } from "react"; +import type { HitlDecision } from "./types"; + +export function useHitlDecision() { + const dispatch = useCallback((decisions: HitlDecision[]) => { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions } }), + ); + }, []); + + return { dispatch }; +}