/** * Lightweight MCP client for the browser. * * Connects via SSE, performs the MCP initialize handshake, * then allows tools/list and tools/call operations. */ let sseSessionId: string | null = null; let sseSource: EventSource | null = null; let initialized = false; const pendingResolvers = new Map void; reject: (reason: unknown) => void; timer: ReturnType; }>(); let jsonRpcId = 1; const getBaseUrl = (): string => localStorage.getItem('smcp_server_url') || ''; export type McpConnectionState = 'disconnected' | 'connecting' | 'connected'; let connectionState: McpConnectionState = 'disconnected'; let onStateChange: ((state: McpConnectionState) => void) | null = null; export function onConnectionStateChange(cb: (state: McpConnectionState) => void) { onStateChange = cb; } export function getMcpConnectionState(): McpConnectionState { return connectionState; } function setState(s: McpConnectionState) { connectionState = s; onStateChange?.(s); } /** * Connect to the MCP server via SSE and perform the initialization handshake. */ export async function connect(): Promise { if (sseSource && sseSessionId && initialized) { return; } setState('connecting'); // Step 1: Establish SSE connection and get sessionId await new Promise((resolve, reject) => { const baseUrl = getBaseUrl(); const es = new EventSource(`${baseUrl}/sse`); sseSource = es; es.addEventListener('endpoint', (ev: MessageEvent) => { const url = new URL(ev.data as string, window.location.origin); sseSessionId = url.searchParams.get('sessionId'); resolve(); }); es.addEventListener('message', (ev: MessageEvent) => { try { const data = JSON.parse(ev.data as string) as { id?: number; result?: unknown; error?: unknown }; if (data.id != null) { const pending = pendingResolvers.get(data.id); if (pending) { pendingResolvers.delete(pending.timer as unknown as number); clearTimeout(pending.timer); pendingResolvers.delete(data.id); if (data.error) { pending.reject(data.error); } else { pending.resolve(data.result); } } } } catch { // ignore } }); es.onerror = () => { if (connectionState === 'connecting' && !sseSessionId) { reject(new Error('Failed to connect to MCP SSE endpoint')); } }; }); // Step 2: Send initialize request const initResult = await sendJsonRpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'social-mcp-dashboard', version: '0.1.0' }, }); if (!initResult) { throw new Error('Initialize returned empty result'); } // Step 3: Send initialized notification (no id = notification) await sendNotification('notifications/initialized', {}); initialized = true; setState('connected'); } /** * Disconnect from the MCP server. */ export function disconnect() { if (sseSource) { sseSource.close(); sseSource = null; } sseSessionId = null; initialized = false; for (const [, p] of pendingResolvers) { clearTimeout(p.timer); p.reject(new Error('Disconnected')); } pendingResolvers.clear(); setState('disconnected'); } export interface McpToolInfo { name: string; description?: string; inputSchema?: { type: string; properties?: Record; required?: string[]; }; } /** * List available MCP tools. */ export async function listTools(): Promise<{ tools: McpToolInfo[] }> { const result = await sendJsonRpc('tools/list', {}); return result as { tools: McpToolInfo[] }; } /** * Call an MCP tool by name with arguments. */ export async function callTool(name: string, args: Record): Promise { return sendJsonRpc('tools/call', { name, arguments: args }); } /** * Send a JSON-RPC request (with id, expects response). */ function sendJsonRpc(method: string, params: unknown): Promise { const id = jsonRpcId++; const baseUrl = getBaseUrl(); const body = JSON.stringify({ jsonrpc: '2.0', id, method, params, }); return new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingResolvers.delete(id); reject(new Error(`MCP request "${method}" timed out (5m)`)); }, 300_000); pendingResolvers.set(id, { resolve, reject, timer }); fetch(`${baseUrl}/messages?sessionId=${sseSessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }).then((res) => { if (!res.ok) { clearTimeout(timer); pendingResolvers.delete(id); return res.text().then((text) => { reject(new Error(`POST /messages failed: ${res.status} - ${text}`)); }); } }).catch((err) => { clearTimeout(timer); pendingResolvers.delete(id); reject(err); }); }); } /** * Send a JSON-RPC notification (no id, no response expected). */ async function sendNotification(method: string, params: unknown): Promise { const baseUrl = getBaseUrl(); const body = JSON.stringify({ jsonrpc: '2.0', method, params, }); const res = await fetch(`${baseUrl}/messages?sessionId=${sseSessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); if (!res.ok) { const text = await res.text(); throw new Error(`Notification "${method}" failed: ${res.status} - ${text}`); } }