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 (