From fcc6d79bb78c63f97aec677308cd1cfc976fc06d Mon Sep 17 00:00:00 2001 From: Lemon Date: Sun, 5 Apr 2026 17:19:42 -0700 Subject: [PATCH] Add MCP client support for Dork AI tool discovery Dork can now connect to Streamable HTTP MCP servers to discover and use external tools at runtime. This bridges the gap between dev-time MCP configs (.mcp.json) and the production AI chat system. - MCPClient class using @modelcontextprotocol/sdk for full spec support - useMCPTools hook with TanStack Query caching (5-min stale time) - MCP tools merged into Dork's tool array and routed through MCPClient - Settings UI in Advanced Settings to add/remove MCP servers - MCPServer type, Zod schema, and AppConfig integration --- src/App.tsx | 1 + src/components/AdvancedSettings.tsx | 82 +++++++++++++++- src/contexts/AppContext.ts | 9 ++ src/hooks/useAIChatSession.ts | 15 ++- src/hooks/useAIChatTools.ts | 25 ++++- src/hooks/useMCPTools.ts | 50 ++++++++++ src/lib/MCPClient.ts | 147 ++++++++++++++++++++++++++++ src/lib/schemas.ts | 10 ++ src/test/TestApp.tsx | 1 + 9 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useMCPTools.ts create mode 100644 src/lib/MCPClient.ts diff --git a/src/App.tsx b/src/App.tsx index 97960f56..0b581cd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -157,6 +157,7 @@ const hardcodedConfig: AppConfig = { { id: 'wikipedia' }, ], aiModel: '', + mcpServers: {}, }; /** diff --git a/src/components/AdvancedSettings.tsx b/src/components/AdvancedSettings.tsx index 1e5d0c41..67a504c6 100644 --- a/src/components/AdvancedSettings.tsx +++ b/src/components/AdvancedSettings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react'; +import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle, Server, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; @@ -13,6 +13,8 @@ import { useToast } from '@/hooks/useToast'; import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import type { MCPServer } from '@/contexts/AppContext'; + /** The build-time default DSN from the environment variable. */ const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || ''; @@ -29,6 +31,8 @@ export function AdvancedSettings() { const [sentryOpen, setSentryOpen] = useState(false); const [dangerOpen, setDangerOpen] = useState(false); const [vanishDialogOpen, setVanishDialogOpen] = useState(false); + const [mcpServerName, setMcpServerName] = useState(''); + const [mcpServerUrl, setMcpServerUrl] = useState(''); const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey); const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl); const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl); @@ -124,6 +128,82 @@ export function AdvancedSettings() { Choose which AI model Dork uses for chat responses.

+ + {/* MCP Servers */} +
+
+ + +
+

+ Connect to MCP servers to give Dork additional tools (web fetching, search, etc.). +

+ + {/* Existing servers list */} + {Object.entries(config.mcpServers).length > 0 && ( +
+ {Object.entries(config.mcpServers).map(([name, server]) => ( +
+ +
+

{name}

+

{server.url}

+
+ +
+ ))} +
+ )} + + {/* Add new server */} +
+
+ setMcpServerName(e.target.value)} + placeholder="Name (e.g. web-tools)" + className="flex-1 text-base md:text-sm" + /> + setMcpServerUrl(e.target.value)} + placeholder="https://mcp.example.com/mcp" + className="flex-[2] font-mono text-base md:text-sm" + /> + +
+ {mcpServerName.trim() && config.mcpServers[mcpServerName.trim()] && ( +

A server with this name already exists

+ )} +
+
diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 1f2ce3b7..c9e6c4da 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -154,6 +154,13 @@ export interface FeedSettings { followsFeedShowReplies: boolean; } +/** Configuration for a Streamable HTTP MCP server. */ +export interface MCPServer { + type: 'streamable-http'; + url: string; + headers?: Record; +} + /** A named feed tab stored as a kind:777 spell event. */ export interface SavedFeed { id: string; @@ -233,6 +240,8 @@ export interface AppConfig { sidebarWidgets: WidgetConfig[]; /** Selected AI model ID for Dork chat. Empty string means "use cheapest available". */ aiModel: string; + /** Configured MCP (Model Context Protocol) servers for Dork AI tool discovery. */ + mcpServers: Record; } /** Configuration for a single widget in the right sidebar. */ diff --git a/src/hooks/useAIChatSession.ts b/src/hooks/useAIChatSession.ts index ee7b1478..ef6edd32 100644 --- a/src/hooks/useAIChatSession.ts +++ b/src/hooks/useAIChatSession.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; @@ -43,7 +43,13 @@ export function useAIChatSession() { const { user } = useCurrentUser(); const { config } = useAppContext(); const { sendStreamingMessage, getAvailableModels, getCredits, isLoading: apiLoading, error: apiError, clearError } = useShakespeare(); - const { executeToolCall } = useAIChatTools(); + const { executeToolCall, mcpTools, mcpToolsLoading } = useAIChatTools(); + + // Merge built-in tools with discovered MCP tools. + const allTools = useMemo(() => { + if (mcpTools.length === 0) return TOOLS; + return [...TOOLS, ...mcpTools]; + }, [mcpTools]); const [messages, setMessages] = useState(loadMessages); const [input, setInput] = useState(''); @@ -173,7 +179,7 @@ export function useAIChatSession() { streamAccumulator += chunk; setStreamingText(streamAccumulator); }, - { tools: TOOLS } as Partial>, + { tools: allTools } as Partial>, controller.signal, ); @@ -268,7 +274,7 @@ export function useAIChatSession() { setIsStreaming(false); setStreamingText(''); } - }, [input, selectedModel, isStreaming, messages, buildApiMessages, sendStreamingMessage, executeToolCall, clearError]); + }, [input, selectedModel, isStreaming, messages, buildApiMessages, sendStreamingMessage, executeToolCall, clearError, allTools]); // Stop an in-flight generation const handleStop = useCallback(() => { @@ -300,6 +306,7 @@ export function useAIChatSession() { selectedModel, apiLoading, apiError, + mcpToolsLoading, messagesEndRef, // Actions diff --git a/src/hooks/useAIChatTools.ts b/src/hooks/useAIChatTools.ts index bc74f4f5..cf8c83d1 100644 --- a/src/hooks/useAIChatTools.ts +++ b/src/hooks/useAIChatTools.ts @@ -4,6 +4,7 @@ import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTheme } from '@/hooks/useTheme'; +import { useMCPTools } from '@/hooks/useMCPTools'; import { bundledFonts } from '@/lib/fonts'; import { AVAILABLE_FONTS } from '@/lib/aiChatTools'; import { buildSpellTags } from '@/lib/spellEngine'; @@ -11,6 +12,7 @@ import { buildSpellTags } from '@/lib/spellEngine'; import type { NostrEvent } from '@nostrify/nostrify'; import type { ThemeConfig } from '@/themes'; import type { ToolExecutorResult } from '@/lib/aiChatTools'; +import type { OpenAITool } from '@/lib/MCPClient'; // ─── Helpers ─── @@ -26,8 +28,27 @@ export function useAIChatTools() { const { applyCustomTheme } = useTheme(); const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { tools: mcpToolDefs, clients: mcpClients, isLoading: mcpLoading } = useMCPTools(); + + /** MCP tool definitions in OpenAI format, ready to merge with built-in TOOLS. */ + const mcpTools: OpenAITool[] = Object.values(mcpToolDefs); + + /** Whether MCP tool discovery is still in progress. */ + const mcpToolsLoading = mcpLoading; const executeToolCall = useCallback(async (name: string, args: Record): Promise => { + // Route MCP tool calls (prefixed with `serverName__`) to the appropriate MCPClient. + if (name.includes('__') && mcpClients[name]) { + try { + // Strip the server prefix to get the original tool name for the MCP server. + const originalName = name.split('__').slice(1).join('__'); + const result = await mcpClients[name].callTool(originalName, args); + return { result: JSON.stringify({ success: true, content: result }) }; + } catch (err) { + return { result: JSON.stringify({ error: `MCP tool error: ${err instanceof Error ? err.message : 'Unknown error'}` }) }; + } + } + switch (name) { case 'set_theme': { const { background, text, primary, font, background_url, background_mode } = args; @@ -354,7 +375,7 @@ export function useAIChatTools() { default: return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) }; } - }, [applyCustomTheme, nostr, user]); + }, [applyCustomTheme, nostr, user, mcpClients]); - return { executeToolCall }; + return { executeToolCall, mcpTools, mcpToolsLoading }; } diff --git a/src/hooks/useMCPTools.ts b/src/hooks/useMCPTools.ts new file mode 100644 index 00000000..0207675d --- /dev/null +++ b/src/hooks/useMCPTools.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useAppContext } from '@/hooks/useAppContext'; +import { discoverMCPTools, type MCPClient, type OpenAITool } from '@/lib/MCPClient'; + +interface UseMCPToolsResult { + /** OpenAI-formatted tool definitions keyed by prefixed name (`serverName__toolName`). */ + tools: Record; + /** Map from prefixed tool name to the MCPClient that owns it (for executing calls). */ + clients: Record; + /** Whether tool discovery is still in progress. */ + isLoading: boolean; + /** Error from discovery, if any. */ + error: Error | null; +} + +/** + * Discovers and caches MCP tools from all configured servers. + * + * Tools are cached for 5 minutes and auto-refetched when the + * `mcpServers` config changes (e.g. user adds/removes a server). + */ +export function useMCPTools(): UseMCPToolsResult { + const { config } = useAppContext(); + const mcpServers = config.mcpServers; + + // Stable query key: serialize the server URLs so TanStack detects config changes. + const serverKey = JSON.stringify( + Object.entries(mcpServers).map(([name, s]) => [name, s.url]), + ); + + const { data, isLoading, error } = useQuery({ + queryKey: ['mcp-tools', serverKey], + queryFn: async () => { + if (Object.keys(mcpServers).length === 0) { + return { tools: {} as Record, clients: {} as Record }; + } + return await discoverMCPTools(mcpServers); + }, + staleTime: 5 * 60 * 1000, + retry: 1, + }); + + return { + tools: data?.tools ?? {}, + clients: data?.clients ?? {}, + isLoading, + error: error as Error | null, + }; +} diff --git a/src/lib/MCPClient.ts b/src/lib/MCPClient.ts new file mode 100644 index 00000000..fbf006ff --- /dev/null +++ b/src/lib/MCPClient.ts @@ -0,0 +1,147 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +import type { MCPServer } from '@/contexts/AppContext'; + +/** Tool schema as returned by MCP server listTools. */ +export interface MCPToolSchema { + name: string; + description?: string; + inputSchema: { + type: 'object'; + properties?: Record; + required?: string[]; + }; +} + +/** OpenAI-compatible function-calling tool definition. */ +export interface OpenAITool { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +/** + * MCP Client for discovering and executing tools from Streamable HTTP MCP servers. + * Uses the official @modelcontextprotocol/sdk library. + */ +export class MCPClient { + private client: Client; + private transport: StreamableHTTPClientTransport; + private connected = false; + + constructor(server: MCPServer) { + this.client = new Client( + { name: 'ditto', version: '1.0.0' }, + { capabilities: {} }, + ); + + this.transport = new StreamableHTTPClientTransport(new URL(server.url), { + requestInit: server.headers ? { headers: server.headers } : undefined, + }); + } + + /** Connect to the MCP server (no-op if already connected). */ + async connect(): Promise { + if (this.connected) return; + await this.client.connect(this.transport); + this.connected = true; + } + + /** List all available tools from the MCP server. */ + async listTools(): Promise { + await this.connect(); + const result = await this.client.listTools(); + return result.tools; + } + + /** Call a tool on the MCP server and return concatenated text content. */ + async callTool(name: string, args: Record): Promise { + await this.connect(); + + const result = await this.client.callTool({ name, arguments: args }); + const content = Array.isArray(result.content) ? result.content : []; + + if (result.isError) { + const errorText = content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); + throw new Error(`MCP tool error: ${errorText}`); + } + + return content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); + } + + /** Close the connection to the MCP server. */ + async close(): Promise { + if (!this.connected) return; + await this.client.close(); + this.connected = false; + } + + /** Convert MCP tool schemas to OpenAI function-calling format. */ + static toOpenAITools(mcpTools: MCPToolSchema[]): Record { + const tools: Record = {}; + + for (const tool of mcpTools) { + tools[tool.name] = { + type: 'function', + function: { + name: tool.name, + description: tool.description || '', + parameters: tool.inputSchema as Record, + }, + }; + } + + return tools; + } +} + +/** + * Discover tools from all configured MCP servers. + * Tool names are prefixed with `serverName__` to avoid collisions. + * Returns both the OpenAI-formatted tool definitions and a map from + * prefixed tool name to the MCPClient instance that owns it. + */ +export async function discoverMCPTools( + mcpServers: Record, +): Promise<{ + tools: Record; + clients: Record; +}> { + const allTools: Record = {}; + const clients: Record = {}; + + for (const [serverName, serverConfig] of Object.entries(mcpServers)) { + try { + const client = new MCPClient(serverConfig); + const mcpTools = await client.listTools(); + const openAITools = MCPClient.toOpenAITools(mcpTools); + + for (const [toolName, tool] of Object.entries(openAITools)) { + const prefixedName = `${serverName}__${toolName}`; + allTools[prefixedName] = { + type: 'function', + function: { + ...tool.function, + name: prefixedName, + description: `[${serverName}] ${tool.function.description}`, + }, + }; + clients[prefixedName] = client; + } + } catch (error) { + console.error(`Failed to discover tools from MCP server "${serverName}":`, error); + } + } + + return { tools: allTools, clients }; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 4565ca2c..f39f4cd2 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -198,6 +198,15 @@ export const SavedFeedSchema = z.object({ createdAt: z.number(), }); +// ─── MCP Server Schema ─────────────────────────────────────────────── + +/** Zod schema for a single MCP server configuration. */ +export const MCPServerSchema = z.object({ + type: z.literal('streamable-http'), + url: z.string().url(), + headers: z.record(z.string(), z.string()).optional(), +}); + // ─── AppConfigSchema ───────────────────────────────────────────────── /** @@ -251,6 +260,7 @@ export const AppConfigSchema = z.object({ id: z.string(), height: z.number().optional(), })).optional(), + mcpServers: z.record(z.string(), MCPServerSchema).optional(), }); // ─── DittoConfigSchema (build-time ditto.json) ─────────────────────── diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index e9ad189e..940edc80 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -114,6 +114,7 @@ export function TestApp({ children }: TestAppProps) { sandboxDomain: 'iframe.diy', sidebarWidgets: [], aiModel: '', + mcpServers: {}, }; return (