From 1c9c496e01b74b3f02cee6db3f71fc20cec66d51 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:21:46 +0530 Subject: [PATCH] feat: implement MCP Tool Trust methods for managing trusted tools Added methods to trust and untrust tools in the MCP connector's "Always Allow" list, allowing for streamlined tool usage without HITL approval. This enhancement supports better management of trusted tools within the application. --- .../lib/apis/connectors-api.service.ts | 39 ++++++++++++++++ surfsense_web/lib/chat/streaming-state.ts | 19 ++++++-- surfsense_web/lib/hitl/index.ts | 8 ++++ surfsense_web/lib/hitl/types.ts | 45 +++++++++++++++++++ surfsense_web/lib/hitl/use-hitl-decision.ts | 19 ++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 surfsense_web/lib/hitl/index.ts create mode 100644 surfsense_web/lib/hitl/types.ts create mode 100644 surfsense_web/lib/hitl/use-hitl-decision.ts 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 }; +}