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 */}
+
+
+ {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 (