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
This commit is contained in:
Lemon
2026-04-05 17:19:42 -07:00
parent 57f1c912e0
commit fcc6d79bb7
9 changed files with 333 additions and 7 deletions
+1
View File
@@ -157,6 +157,7 @@ const hardcodedConfig: AppConfig = {
{ id: 'wikipedia' },
],
aiModel: '',
mcpServers: {},
};
/**
+81 -1
View File
@@ -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.
</p>
</div>
{/* MCP Servers */}
<div className="space-y-3 pt-2 border-t border-border">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">MCP Servers</Label>
</div>
<p className="text-xs text-muted-foreground">
Connect to MCP servers to give Dork additional tools (web fetching, search, etc.).
</p>
{/* Existing servers list */}
{Object.entries(config.mcpServers).length > 0 && (
<div className="space-y-2">
{Object.entries(config.mcpServers).map(([name, server]) => (
<div key={name} className="flex items-center gap-2 rounded-md border border-border p-2">
<Server className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{name}</p>
<p className="text-xs text-muted-foreground truncate">{server.url}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => {
const updated = { ...config.mcpServers };
delete updated[name];
updateConfig(() => ({ mcpServers: updated }));
toast({ title: `Removed "${name}" MCP server` });
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{/* Add new server */}
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={mcpServerName}
onChange={(e) => setMcpServerName(e.target.value)}
placeholder="Name (e.g. web-tools)"
className="flex-1 text-base md:text-sm"
/>
<Input
value={mcpServerUrl}
onChange={(e) => setMcpServerUrl(e.target.value)}
placeholder="https://mcp.example.com/mcp"
className="flex-[2] font-mono text-base md:text-sm"
/>
<Button
variant="outline"
size="icon"
className="shrink-0"
disabled={!mcpServerName.trim() || !mcpServerUrl.trim() || !!config.mcpServers[mcpServerName.trim()]}
onClick={() => {
const name = mcpServerName.trim();
const server: MCPServer = { type: 'streamable-http', url: mcpServerUrl.trim() };
updateConfig(() => ({ mcpServers: { ...config.mcpServers, [name]: server } }));
setMcpServerName('');
setMcpServerUrl('');
toast({ title: `Added "${name}" MCP server` });
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{mcpServerName.trim() && config.mcpServers[mcpServerName.trim()] && (
<p className="text-xs text-destructive">A server with this name already exists</p>
)}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
+9
View File
@@ -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<string, string>;
}
/** 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<string, MCPServer>;
}
/** Configuration for a single widget in the right sidebar. */
+11 -4
View File
@@ -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<DisplayMessage[]>(loadMessages);
const [input, setInput] = useState('');
@@ -173,7 +179,7 @@ export function useAIChatSession() {
streamAccumulator += chunk;
setStreamingText(streamAccumulator);
},
{ tools: TOOLS } as Partial<Record<string, unknown>>,
{ tools: allTools } as Partial<Record<string, unknown>>,
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
+23 -2
View File
@@ -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<string, unknown>): Promise<ToolExecutorResult> => {
// 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 };
}
+50
View File
@@ -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<string, OpenAITool>;
/** Map from prefixed tool name to the MCPClient that owns it (for executing calls). */
clients: Record<string, MCPClient>;
/** 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<string, OpenAITool>, clients: {} as Record<string, MCPClient> };
}
return await discoverMCPTools(mcpServers);
},
staleTime: 5 * 60 * 1000,
retry: 1,
});
return {
tools: data?.tools ?? {},
clients: data?.clients ?? {},
isLoading,
error: error as Error | null,
};
}
+147
View File
@@ -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<string, object>;
required?: string[];
};
}
/** OpenAI-compatible function-calling tool definition. */
export interface OpenAITool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/**
* 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<void> {
if (this.connected) return;
await this.client.connect(this.transport);
this.connected = true;
}
/** List all available tools from the MCP server. */
async listTools(): Promise<MCPToolSchema[]> {
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<string, unknown>): Promise<string> {
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<void> {
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<string, OpenAITool> {
const tools: Record<string, OpenAITool> = {};
for (const tool of mcpTools) {
tools[tool.name] = {
type: 'function',
function: {
name: tool.name,
description: tool.description || '',
parameters: tool.inputSchema as Record<string, unknown>,
},
};
}
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<string, MCPServer>,
): Promise<{
tools: Record<string, OpenAITool>;
clients: Record<string, MCPClient>;
}> {
const allTools: Record<string, OpenAITool> = {};
const clients: Record<string, MCPClient> = {};
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 };
}
+10
View File
@@ -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) ───────────────────────
+1
View File
@@ -114,6 +114,7 @@ export function TestApp({ children }: TestAppProps) {
sandboxDomain: 'iframe.diy',
sidebarWidgets: [],
aiModel: '',
mcpServers: {},
};
return (