char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
36.4 KiB1291 linesraw
1/**
2 * MCPService - Stateless MCP protocol layer
3 *
4 * Implements the client side of the MCP spec over WebSocket, StreamableHTTP
5 * and SSE transports: connect, tool/prompt/resource operations and result
6 * formatting. No reactive state; consumed by mcpStore and its managers.
7 */
8
9import { Client } from '@modelcontextprotocol/sdk/client';
10import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
11import {
12	StreamableHTTPClientTransport,
13	StreamableHTTPError
14} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
15import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
16import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
17import type {
18	GetPromptResult,
19	ListChangedHandlers,
20	Prompt,
21	Tool
22} from '@modelcontextprotocol/sdk/types.js';
23import {
24	CORS_PROXY,
25	CORS_PROXY_ENDPOINT,
26	DEFAULT_CLIENT_VERSION,
27	DEFAULT_IMAGE_MIME_TYPE,
28	DEFAULT_MCP_CONFIG,
29	HEADERS,
30	NEWLINE
31} from '$lib/constants';
32import {
33	MCPConnectionPhase,
34	MCPContentType,
35	MCPLogLevel,
36	MCPRefType,
37	MCPTransportType
38} from '$lib/enums';
39import type {
40	ClientCapabilities,
41	Implementation,
42	MCPConnection,
43	MCPConnectionLog,
44	MCPPhaseCallback,
45	MCPReadResourceResult,
46	MCPResource,
47	MCPResourceContent,
48	MCPResourceIcon,
49	MCPResourceTemplate,
50	MCPServerConfig,
51	MCPServerInfo,
52	ToolCallParams,
53	ToolExecutionResult
54} from '$lib/types';
55import {
56	buildProxiedHeaders,
57	buildProxiedUrl,
58	createBase64DataUrl,
59	extractJsonRpcMethods,
60	formatDiagnosticErrorMessage,
61	getAuthHeaders,
62	getRequestBody,
63	getRequestMethod,
64	getRequestUrl,
65	isAbortError,
66	type RequestBodySummary,
67	sanitizeHeaders,
68	summarizeRequestBody,
69	throwIfAborted
70} from '$lib/utils';
71
72interface ToolResultContentItem {
73	type: string;
74	text?: string;
75	data?: string;
76	mimeType?: string;
77	resource?: { text?: string; blob?: string; uri?: string };
78}
79
80interface ToolCallResult {
81	content?: ToolResultContentItem[];
82	structuredContent?: Record<string, unknown>;
83	isError?: boolean;
84	_meta?: Record<string, unknown>;
85}
86
87interface DiagnosticRequestDetails {
88	url: string;
89	method: string;
90	credentials?: RequestCredentials;
91	mode?: RequestMode;
92	headers: Record<string, string>;
93	body: RequestBodySummary;
94	jsonRpcMethods?: string[];
95}
96
97export class MCPService {
98	/**
99	 * Execute a tool call on a connection.
100	 * Supports abort signal for cancellable operations (e.g., when user stops generation).
101	 * Formats the raw tool result into a string representation.
102	 *
103	 * @param connection - The MCP connection to execute against
104	 * @param params - Tool name and arguments to execute
105	 * @param signal - Optional AbortSignal for cancellation support
106	 * @returns Formatted tool execution result with content string and error flag
107	 * @throws {Error} If tool execution fails or is aborted
108	 */
109	static async callTool(
110		connection: MCPConnection,
111		params: ToolCallParams,
112		signal?: AbortSignal
113	): Promise<ToolExecutionResult> {
114		throwIfAborted(signal);
115
116		try {
117			const result = await connection.client.callTool(
118				{ arguments: params.arguments, name: params.name },
119				undefined,
120				{ signal, timeout: connection.requestTimeoutMs }
121			);
122
123			return {
124				content: this.formatToolResult(result as ToolCallResult),
125				isError: (result as ToolCallResult).isError ?? false
126			};
127		} catch (error) {
128			if (isAbortError(error)) {
129				throw error;
130			}
131
132			// Let session-expired errors propagate unwrapped for reconnection handling
133			if (this.isSessionExpiredError(error)) {
134				throw error;
135			}
136
137			const message = error instanceof Error ? error.message : String(error);
138
139			throw new Error(
140				`Tool "${params.name}" execution failed on server "${connection.serverName}": ${message}`,
141				{ cause: error instanceof Error ? error : undefined }
142			);
143		}
144	}
145
146	/**
147	 * Request completion suggestions from a server.
148	 * Used for autocompleting prompt arguments or resource URI templates.
149	 *
150	 * @param connection - The MCP connection to use
151	 * @param ref - Reference to the prompt or resource template
152	 * @param argument - The argument being completed (name and current value)
153	 * @returns Completion result with suggested values
154	 */
155	static async complete(
156		connection: MCPConnection,
157		ref: { type: MCPRefType.PROMPT; name: string } | { type: MCPRefType.RESOURCE; uri: string },
158		argument: { name: string; value: string }
159	): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
160		try {
161			const result = await connection.client.complete({
162				argument,
163				ref
164			});
165
166			return result.completion;
167		} catch (error) {
168			console.error(`[MCPService] Failed to get completions:`, error);
169
170			return null;
171		}
172	}
173
174	/**
175	 * Connect to a single MCP server with detailed phase tracking.
176	 *
177	 * Performs the full MCP connection lifecycle:
178	 * 1. Transport creation (with automatic fallback)
179	 * 2. Client initialization and capability exchange
180	 * 3. Tool discovery via `listTools`
181	 *
182	 * Reports progress via `onPhase` callback at each step, enabling
183	 * UI progress indicators during connection.
184	 *
185	 * @param serverName - Display name for the server (used in logging)
186	 * @param serverConfig - Server URL, transport type, proxy, and auth configuration
187	 * @param clientInfo - Optional client identification (defaults to app info)
188	 * @param capabilities - Optional client capability declaration
189	 * @param onPhase - Optional callback for connection phase progress updates
190	 * @param listChangedHandlers - Optional handlers for server-initiated list change notifications
191	 * @returns Full connection object with client, transport, tools, server info, and timing
192	 * @throws {Error} If transport creation or connection fails
193	 */
194	static async connect(
195		serverName: string,
196		serverConfig: MCPServerConfig,
197		clientInfo?: Implementation,
198		capabilities?: ClientCapabilities,
199		onPhase?: MCPPhaseCallback,
200		listChangedHandlers?: ListChangedHandlers
201	): Promise<MCPConnection> {
202		const startTime = performance.now();
203		const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
204		const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
205
206		// Phase: Creating transport
207		onPhase?.(
208			MCPConnectionPhase.TRANSPORT_CREATING,
209			this.createLog(
210				MCPConnectionPhase.TRANSPORT_CREATING,
211				`Creating transport for ${serverConfig.url}`
212			)
213		);
214
215		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
216			console.log(`[MCPService][${serverName}] Creating transport...`);
217		}
218
219		const {
220			stopPhaseLogging,
221			transport,
222			type: transportType
223		} = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log));
224
225		// Setup WebSocket reconnection handler
226		if (transportType === MCPTransportType.WEBSOCKET) {
227			transport.onclose = () => {
228				console.log(`[MCPService][${serverName}] WebSocket closed, notifying for reconnection`);
229				onPhase?.(
230					MCPConnectionPhase.DISCONNECTED,
231					this.createLog(MCPConnectionPhase.DISCONNECTED, 'WebSocket connection closed')
232				);
233			};
234		}
235
236		// Phase: Transport ready
237		onPhase?.(
238			MCPConnectionPhase.TRANSPORT_READY,
239			this.createLog(MCPConnectionPhase.TRANSPORT_READY, `Transport ready (${transportType})`),
240			{ transportType }
241		);
242
243		const client = new Client(
244			{
245				name: effectiveClientInfo.name,
246				version: effectiveClientInfo.version ?? DEFAULT_CLIENT_VERSION
247			},
248			{
249				capabilities: effectiveCapabilities,
250				listChanged: listChangedHandlers
251			}
252		);
253		const runtimeErrorHandler = (error: Error) => {
254			// the SDK reports any post initialize error here, including the abort we trigger
255			// ourselves on the next health check cycle, on tab unload, or on server teardown.
256			// these are lifecycle aborts, not actionable errors, so we keep them out of the red console.
257			// the SDK wraps the original AbortError in a generic Error like
258			//   "SSE stream disconnected: AbortError: The operation was aborted."
259			// which isAbortError cannot recognize by name alone, so we also pattern match on the message
260			if (isAbortError(error)) {
261				return;
262			}
263
264			const msg = error?.message ?? '';
265
266			if (
267				/SSE stream disconnected:.*AbortError/i.test(msg) ||
268				/AbortError: .*aborted/i.test(msg) ||
269				/stream locked by a reader/i.test(msg)
270			) {
271				return;
272			}
273
274			console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
275		};
276
277		client.onerror = (error) => {
278			onPhase?.(
279				MCPConnectionPhase.ERROR,
280				this.createLog(
281					MCPConnectionPhase.ERROR,
282					`Protocol error: ${error.message}`,
283					MCPLogLevel.ERROR,
284					{
285						error: this.summarizeError(error)
286					}
287				)
288			);
289		};
290
291		// Phase: Initializing
292		onPhase?.(
293			MCPConnectionPhase.INITIALIZING,
294			this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
295		);
296
297		// The SDK timeout only covers the initialize request, not transport.start(),
298		// which can hang forever on an unreachable host (SSE endpoint wait, WebSocket
299		// handshake, proxied fetch). This race bounds the whole handshake and closes
300		// the transport on expiry so the underlying fetch or socket is aborted.
301		const handshakeTimeoutMs =
302			serverConfig.handshakeTimeoutMs ?? DEFAULT_MCP_CONFIG.connectionTimeoutMs;
303
304		try {
305			let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
306
307			const handshakeDeadline = new Promise<never>((_, reject) => {
308				handshakeTimer = setTimeout(() => {
309					void transport.close().catch(() => {});
310					reject(new Error(`Connection timed out after ${Math.round(handshakeTimeoutMs / 1000)}s`));
311				}, handshakeTimeoutMs);
312			});
313
314			try {
315				await Promise.race([
316					client.connect(transport, { timeout: handshakeTimeoutMs }),
317					handshakeDeadline
318				]);
319			} finally {
320				clearTimeout(handshakeTimer);
321			}
322
323			// Transport diagnostics are only for the initial handshake, not long-lived traffic.
324			stopPhaseLogging();
325			client.onerror = runtimeErrorHandler;
326		} catch (error) {
327			client.onerror = runtimeErrorHandler;
328			const url =
329				(serverConfig.useProxy ?? false)
330					? buildProxiedUrl(serverConfig.url)
331					: new URL(serverConfig.url);
332
333			onPhase?.(
334				MCPConnectionPhase.ERROR,
335				this.createLog(
336					MCPConnectionPhase.ERROR,
337					`Connection failed during initialize: ${
338						error instanceof Error ? error.message : String(error)
339					}`,
340					MCPLogLevel.ERROR,
341					{
342						browser: this.getBrowserContext(url, serverConfig.useProxy ?? false),
343						config: {
344							configuredUrl: serverConfig.url,
345							credentials: serverConfig.credentials,
346							effectiveUrl: url.href,
347							headers: sanitizeHeaders(
348								serverConfig.headers,
349								Object.keys(serverConfig.headers ?? {}),
350								HEADERS.PARTIAL_REDACT
351							),
352							serverName,
353							transportType,
354							useProxy: serverConfig.useProxy ?? false
355						},
356						error: this.summarizeError(error),
357						hints: this.getConnectionHints(url, serverConfig, error)
358					}
359				)
360			);
361
362			throw error;
363		}
364
365		const serverVersion = client.getServerVersion();
366		const serverCapabilities = client.getServerCapabilities();
367		const instructions = client.getInstructions();
368		const serverInfo = this.extractServerInfo(serverVersion);
369
370		// Phase: Capabilities exchanged
371		onPhase?.(
372			MCPConnectionPhase.CAPABILITIES_EXCHANGED,
373			this.createLog(
374				MCPConnectionPhase.CAPABILITIES_EXCHANGED,
375				'Capabilities exchanged successfully',
376				MCPLogLevel.INFO,
377				{
378					serverCapabilities,
379					serverInfo
380				}
381			),
382			{
383				clientCapabilities: effectiveCapabilities,
384				instructions,
385				serverCapabilities,
386				serverInfo
387			}
388		);
389
390		// Phase: Listing tools
391		onPhase?.(
392			MCPConnectionPhase.LISTING_TOOLS,
393			this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
394		);
395
396		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
397			console.log(`[MCPService][${serverName}] Connected, listing tools...`);
398		}
399
400		const tools = await this.listTools({
401			client,
402			connectionTimeMs: 0,
403			requestTimeoutMs:
404				serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000,
405			serverName,
406			tools: [],
407			transport,
408			transportType
409		});
410		const connectionTimeMs = Math.round(performance.now() - startTime);
411
412		// Phase: Connected
413		onPhase?.(
414			MCPConnectionPhase.CONNECTED,
415			this.createLog(
416				MCPConnectionPhase.CONNECTED,
417				`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
418			)
419		);
420
421		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
422			console.log(
423				`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
424			);
425		}
426
427		return {
428			client,
429			clientCapabilities: effectiveCapabilities,
430			connectionTimeMs,
431			instructions,
432			protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
433			requestTimeoutMs:
434				serverConfig.requestTimeoutMs ?? DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000,
435			serverCapabilities,
436			serverInfo,
437			serverName,
438			tools,
439			transport,
440			transportType
441		};
442	}
443
444	/**
445	 * Create transport based on server configuration.
446	 * Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports.
447	 * When `useProxy` is enabled, routes HTTP requests through llama-server's CORS proxy.
448	 *
449	 * **Fallback Order:**
450	 * 1. WebSocket — if explicitly configured (no CORS proxy support)
451	 * 2. StreamableHTTP — default for HTTP connections
452	 * 3. SSE — automatic fallback if StreamableHTTP fails
453	 *
454	 * @param config - Server configuration with url, transport type, proxy, and auth settings
455	 * @returns Object containing the created transport and the transport type used
456	 * @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail
457	 */
458	static createTransport(
459		serverName: string,
460		config: MCPServerConfig,
461		onLog?: (log: MCPConnectionLog) => void
462	): {
463		transport: Transport;
464		type: MCPTransportType;
465		stopPhaseLogging: () => void;
466	} {
467		if (!config.url) {
468			throw new Error('MCP server configuration is missing url');
469		}
470
471		const useProxy = config.useProxy ?? false;
472		const requestInit: RequestInit = {};
473
474		if (config.headers) {
475			requestInit.headers = config.useProxy ? buildProxiedHeaders(config.headers) : config.headers;
476		}
477
478		if (useProxy) {
479			requestInit.headers = {
480				...getAuthHeaders(),
481				...(requestInit.headers as Record<string, string>)
482			};
483		}
484
485		if (config.credentials) {
486			requestInit.credentials = config.credentials;
487		}
488
489		if (config.transport === MCPTransportType.WEBSOCKET) {
490			if (useProxy) {
491				throw new Error(
492					'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.'
493				);
494			}
495
496			const url = new URL(config.url);
497
498			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
499				console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
500			}
501
502			return {
503				stopPhaseLogging: () => {},
504				transport: new WebSocketClientTransport(url),
505				type: MCPTransportType.WEBSOCKET
506			};
507		}
508
509		if (config.transport === MCPTransportType.SSE) {
510			const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
511			const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch(
512				serverName,
513				config,
514				requestInit,
515				url,
516				useProxy,
517				onLog
518			);
519
520			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
521				console.log(`[MCPService] Creating SSE transport for ${url.href}`);
522			}
523
524			return {
525				stopPhaseLogging,
526				transport: new SSEClientTransport(url, {
527					eventSourceInit: { fetch: diagnosticFetch },
528					fetch: diagnosticFetch,
529					requestInit
530				}),
531				type: MCPTransportType.SSE
532			};
533		}
534
535		const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
536		const { disable: stopPhaseLogging, fetch: diagnosticFetch } = this.createDiagnosticFetch(
537			serverName,
538			config,
539			requestInit,
540			url,
541			useProxy,
542			onLog
543		);
544
545		if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
546			console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
547		}
548
549		try {
550			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
551				console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
552			}
553
554			return {
555				stopPhaseLogging,
556				transport: new StreamableHTTPClientTransport(url, {
557					fetch: diagnosticFetch,
558					requestInit
559				}),
560				type: MCPTransportType.STREAMABLE_HTTP
561			};
562		} catch (httpError) {
563			console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
564
565			try {
566				return {
567					stopPhaseLogging,
568					transport: new SSEClientTransport(url, {
569						eventSourceInit: { fetch: diagnosticFetch },
570						fetch: diagnosticFetch,
571						requestInit
572					}),
573					type: MCPTransportType.SSE
574				};
575			} catch (sseError) {
576				const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
577				const sseMsg = sseError instanceof Error ? sseError.message : String(sseError);
578
579				throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`);
580			}
581		}
582	}
583
584	/**
585	 * Disconnect from a server.
586	 * Clears the `onclose` handler to prevent reconnection attempts on voluntary disconnect.
587	 *
588	 * @param connection - The active MCP connection to close
589	 */
590	static async disconnect(connection: MCPConnection): Promise<void> {
591		if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
592			console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
593		}
594
595		try {
596			// Terminate the session first for streamable-http transports to cleanly
597			// close streams, matching the inspector's disconnect flow.
598			if (connection.transport instanceof StreamableHTTPClientTransport) {
599				await connection.transport.terminateSession();
600			}
601
602			// Clear error handlers before closing to prevent noise from expected
603			// abort errors during shutdown. The inspector avoids this entirely
604			// by not setting onerror, but since we use it for protocol logging,
605			// we must clear it before disconnect.
606			connection.client.onerror = undefined;
607
608			if (connection.transport.onclose) {
609				connection.transport.onclose = undefined;
610			}
611
612			await connection.client.close();
613		} catch (error) {
614			console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error);
615		}
616	}
617
618	/**
619	 * Get a specific prompt with arguments.
620	 * Unlike list operations, this throws on failure since the caller explicitly
621	 * requested a specific prompt and needs to handle the error.
622	 *
623	 * @param connection - The MCP connection to use
624	 * @param name - The prompt name to retrieve
625	 * @param args - Optional key-value arguments to pass to the prompt
626	 * @returns The prompt result with messages and metadata
627	 * @throws {Error} If the prompt retrieval fails
628	 */
629	static async getPrompt(
630		connection: MCPConnection,
631		name: string,
632		args?: Record<string, string>
633	): Promise<GetPromptResult> {
634		try {
635			return await connection.client.getPrompt({ arguments: args, name });
636		} catch (error) {
637			console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
638
639			throw error;
640		}
641	}
642
643	/**
644	 * Detect if an error indicates an expired/invalidated MCP session.
645	 * Per MCP spec 2025-11-25: HTTP 404 means session invalidated, client MUST
646	 * discard its session ID and start a new session with a fresh initialize request.
647	 *
648	 * @param error - The caught error to inspect
649	 * @returns true if the error is a StreamableHTTP 404 (session not found)
650	 */
651	static isSessionExpiredError(error: unknown): boolean {
652		return error instanceof StreamableHTTPError && error.code === 404;
653	}
654
655	/**
656	 * List all resources from a connection (handles pagination automatically).
657	 * @param connection - The MCP connection to use
658	 * @returns Array of all available resources
659	 */
660	static async listAllResources(connection: MCPConnection): Promise<MCPResource[]> {
661		return this.paginate(
662			connection,
663			(cursor) => this.listResources(connection, cursor),
664			(result) => result.resources
665		);
666	}
667
668	/**
669	 * List all resource templates from a connection (handles pagination automatically).
670	 * @param connection - The MCP connection to use
671	 * @returns Array of all available resource templates
672	 */
673	static async listAllResourceTemplates(connection: MCPConnection): Promise<MCPResourceTemplate[]> {
674		return this.paginate(
675			connection,
676			(cursor) => this.listResourceTemplates(connection, cursor),
677			(result) => result.resourceTemplates
678		);
679	}
680
681	/**
682	 * List prompts from a connection.
683	 * Silently returns empty array on failure (logged as warning).
684	 *
685	 * @param connection - The MCP connection to query
686	 * @returns Array of available prompts, or empty array on error
687	 */
688	static async listPrompts(connection: MCPConnection): Promise<Prompt[]> {
689		try {
690			const result = await connection.client.listPrompts();
691
692			return result.prompts ?? [];
693		} catch (error) {
694			// Let session-expired errors propagate for reconnection handling
695			if (this.isSessionExpiredError(error)) {
696				throw error;
697			}
698
699			console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error);
700
701			return [];
702		}
703	}
704
705	/**
706	 * List resources from a connection.
707	 * @param connection - The MCP connection to use
708	 * @param cursor - Optional pagination cursor
709	 * @returns Array of available resources and optional next cursor
710	 */
711	static async listResources(
712		connection: MCPConnection,
713		cursor?: string
714	): Promise<{ resources: MCPResource[]; nextCursor?: string }> {
715		try {
716			const result = await connection.client.listResources(cursor ? { cursor } : undefined);
717
718			return {
719				nextCursor: result.nextCursor,
720				resources: (result.resources ?? []) as MCPResource[]
721			};
722		} catch (error) {
723			if (this.isSessionExpiredError(error)) {
724				throw error;
725			}
726
727			console.warn(`[MCPService][${connection.serverName}] Failed to list resources:`, error);
728
729			return { resources: [] };
730		}
731	}
732
733	/**
734	 * List resource templates from a connection.
735	 * @param connection - The MCP connection to use
736	 * @param cursor - Optional pagination cursor
737	 * @returns Array of available resource templates and optional next cursor
738	 */
739	static async listResourceTemplates(
740		connection: MCPConnection,
741		cursor?: string
742	): Promise<{ resourceTemplates: MCPResourceTemplate[]; nextCursor?: string }> {
743		try {
744			const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined);
745
746			return {
747				nextCursor: result.nextCursor,
748				resourceTemplates: (result.resourceTemplates ?? []) as MCPResourceTemplate[]
749			};
750		} catch (error) {
751			if (this.isSessionExpiredError(error)) {
752				throw error;
753			}
754
755			console.warn(
756				`[MCPService][${connection.serverName}] Failed to list resource templates:`,
757				error
758			);
759
760			return { resourceTemplates: [] };
761		}
762	}
763
764	/**
765	 * List tools from a connection.
766	 * Silently returns empty array on failure (logged as warning).
767	 *
768	 * @param connection - The MCP connection to query
769	 * @returns Array of available tools, or empty array on error
770	 */
771	static async listTools(connection: MCPConnection): Promise<Tool[]> {
772		try {
773			const result = await connection.client.listTools();
774
775			return result.tools ?? [];
776		} catch (error) {
777			// Let session-expired errors propagate for reconnection handling
778			if (this.isSessionExpiredError(error)) {
779				throw error;
780			}
781
782			console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
783
784			return [];
785		}
786	}
787
788	/**
789	 * Read the contents of a resource.
790	 * @param connection - The MCP connection to use
791	 * @param uri - The URI of the resource to read
792	 * @returns The resource contents
793	 */
794	static async readResource(
795		connection: MCPConnection,
796		uri: string
797	): Promise<MCPReadResourceResult> {
798		try {
799			const result = await connection.client.readResource({ uri });
800
801			return {
802				_meta: result._meta,
803				contents: (result.contents ?? []) as MCPResourceContent[]
804			};
805		} catch (error) {
806			console.error(`[MCPService][${connection.serverName}] Failed to read resource:`, error);
807
808			throw error;
809		}
810	}
811
812	/**
813	 * Subscribe to updates for a resource.
814	 * The server will send notifications/resources/updated when the resource changes.
815	 * @param connection - The MCP connection to use
816	 * @param uri - The URI of the resource to subscribe to
817	 */
818	static async subscribeResource(connection: MCPConnection, uri: string): Promise<void> {
819		try {
820			await connection.client.subscribeResource({ uri });
821
822			console.log(`[MCPService][${connection.serverName}] Subscribed to resource: ${uri}`);
823		} catch (error) {
824			console.error(
825				`[MCPService][${connection.serverName}] Failed to subscribe to resource:`,
826				error
827			);
828
829			throw error;
830		}
831	}
832
833	/**
834	 * Check if a connection supports resources.
835	 * Per MCP spec: presence of the `resources` key (even as empty object `{}`) indicates support.
836	 * Empty object means resources are supported but no sub-features (subscribe, listChanged).
837	 *
838	 * @param connection - The MCP connection to check
839	 * @returns Whether the server declares the resources capability
840	 */
841	static supportsResources(connection: MCPConnection): boolean {
842		// Per MCP spec: "Servers that support resources MUST declare the resources capability"
843		// The presence of the key indicates support, even if it's an empty object
844		return connection.serverCapabilities?.resources !== undefined;
845	}
846
847	/**
848	 * Check if a connection supports resource subscriptions.
849	 * @param connection - The MCP connection to check
850	 * @returns Whether the server supports resource subscriptions
851	 */
852	static supportsResourceSubscriptions(connection: MCPConnection): boolean {
853		return !!connection.serverCapabilities?.resources?.subscribe;
854	}
855
856	/**
857	 * Unsubscribe from updates for a resource.
858	 * @param connection - The MCP connection to use
859	 * @param uri - The URI of the resource to unsubscribe from
860	 */
861	static async unsubscribeResource(connection: MCPConnection, uri: string): Promise<void> {
862		try {
863			await connection.client.unsubscribeResource({ uri });
864
865			if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
866				console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
867			}
868		} catch (error) {
869			console.error(
870				`[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,
871				error
872			);
873
874			throw error;
875		}
876	}
877
878	private static addRequestHeaders(
879		requestHeaders: Headers,
880		headers: HeadersInit,
881		useProxy: boolean
882	) {
883		for (const [key, value] of new Headers(headers).entries()) {
884			const proxiedKey =
885				useProxy && !key.toLowerCase().startsWith(CORS_PROXY.HEADER_PREFIX)
886					? `${CORS_PROXY.HEADER_PREFIX}${key}`
887					: key;
888
889			requestHeaders.set(proxiedKey, value);
890		}
891	}
892
893	private static createDiagnosticFetch(
894		serverName: string,
895		config: MCPServerConfig,
896		baseInit: RequestInit,
897		targetUrl: URL,
898		useProxy: boolean,
899		onLog?: (log: MCPConnectionLog) => void
900	): {
901		fetch: typeof fetch;
902		disable: () => void;
903	} {
904		let enabled = true;
905
906		const logIfEnabled = (log: MCPConnectionLog) => {
907			if (enabled) {
908				onLog?.(log);
909			}
910		};
911
912		return {
913			disable: () => {
914				enabled = false;
915			},
916			fetch: async (input, init) => {
917				if (useProxy && typeof window !== 'undefined') {
918					let requestUrlStr = '';
919
920					if (typeof input === 'string') {
921						requestUrlStr = input;
922					} else if (input instanceof URL) {
923						requestUrlStr = input.href;
924					}
925
926					if (requestUrlStr) {
927						const parsedRequestUrl = new URL(requestUrlStr, window.location.origin);
928
929						if (
930							parsedRequestUrl.origin === window.location.origin &&
931							!parsedRequestUrl.pathname.includes(CORS_PROXY_ENDPOINT)
932						) {
933							const originalConfigUrl = new URL(config.url);
934							const realTargetUrl = new URL(
935								parsedRequestUrl.pathname + parsedRequestUrl.search,
936								originalConfigUrl.origin
937							);
938							const proxiedUrl = buildProxiedUrl(realTargetUrl.href);
939
940							if (typeof input === 'string') {
941								input = proxiedUrl.href;
942							} else if (input instanceof URL) {
943								input = proxiedUrl;
944							}
945						}
946					}
947				}
948
949				const startedAt = performance.now();
950				const requestHeaders = new Headers(baseInit.headers);
951
952				if (typeof Request !== 'undefined' && input instanceof Request) {
953					this.addRequestHeaders(requestHeaders, input.headers, useProxy);
954				}
955
956				if (init?.headers) {
957					this.addRequestHeaders(requestHeaders, init.headers, useProxy);
958				}
959
960				const request = this.createDiagnosticRequestDetails(
961					input,
962					init,
963					baseInit,
964					requestHeaders,
965					Object.keys(config.headers ?? {})
966				);
967				const { method, url } = request;
968
969				logIfEnabled(
970					this.createLog(
971						MCPConnectionPhase.INITIALIZING,
972						`HTTP ${method} ${url}`,
973						MCPLogLevel.INFO,
974						{
975							request,
976							serverName
977						}
978					)
979				);
980
981				if (method === 'DELETE' && url.includes(CORS_PROXY_ENDPOINT)) {
982					const response = new Response(null, { status: 200, statusText: 'OK' });
983
984					logIfEnabled(
985						this.createLog(
986							MCPConnectionPhase.INITIALIZING,
987							`HTTP 200 ${method} ${url} (fake response)`,
988							MCPLogLevel.INFO,
989							{
990								response: {
991									durationMs: 0,
992									isFake: true,
993									status: response.status,
994									statusText: response.statusText,
995									url
996								}
997							}
998						)
999					);
1000
1001					// fake response, bypass real fetch()
1002					return response;
1003				}
1004
1005				try {
1006					const response = await fetch(input, {
1007						...baseInit,
1008						...init,
1009						headers: requestHeaders
1010					});
1011					const durationMs = Math.round(performance.now() - startedAt);
1012
1013					logIfEnabled(
1014						this.createLog(
1015							MCPConnectionPhase.INITIALIZING,
1016							`HTTP ${response.status} ${method} ${url} (${durationMs}ms)`,
1017							response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN,
1018							{
1019								response: {
1020									durationMs,
1021									headers: sanitizeHeaders(response.headers, undefined, HEADERS.PARTIAL_REDACT),
1022									status: response.status,
1023									statusText: response.statusText,
1024									url
1025								}
1026							}
1027						)
1028					);
1029
1030					return response;
1031				} catch (error) {
1032					const durationMs = Math.round(performance.now() - startedAt);
1033
1034					logIfEnabled(
1035						this.createLog(
1036							MCPConnectionPhase.ERROR,
1037							`HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`,
1038							MCPLogLevel.ERROR,
1039							{
1040								browser: this.getBrowserContext(targetUrl, useProxy),
1041								durationMs,
1042								error: this.summarizeError(error),
1043								hints: this.getConnectionHints(targetUrl, config, error),
1044								request,
1045								serverName
1046							}
1047						)
1048					);
1049
1050					throw error;
1051				}
1052			}
1053		};
1054	}
1055
1056	private static createDiagnosticRequestDetails(
1057		input: RequestInfo | URL,
1058		init: RequestInit | undefined,
1059		baseInit: RequestInit,
1060		requestHeaders: Headers,
1061		extraRedactedHeaders?: Iterable<string>
1062	): DiagnosticRequestDetails {
1063		const body = getRequestBody(input, init);
1064		const details: DiagnosticRequestDetails = {
1065			body: summarizeRequestBody(body),
1066			credentials: init?.credentials ?? baseInit.credentials,
1067			headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, HEADERS.PARTIAL_REDACT),
1068			method: getRequestMethod(input, init, baseInit).toUpperCase(),
1069			mode: init?.mode ?? baseInit.mode,
1070			url: getRequestUrl(input)
1071		};
1072		const jsonRpcMethods = extractJsonRpcMethods(body);
1073
1074		if (jsonRpcMethods) {
1075			details.jsonRpcMethods = jsonRpcMethods;
1076		}
1077
1078		return details;
1079	}
1080
1081	/**
1082	 * Create a connection log entry for phase tracking.
1083	 *
1084	 * @param phase - The connection phase this log belongs to
1085	 * @param message - Human-readable log message
1086	 * @param level - Log severity level (default: INFO)
1087	 * @param details - Optional structured details for debugging
1088	 * @returns Formatted connection log entry
1089	 */
1090	private static createLog(
1091		phase: MCPConnectionPhase,
1092		message: string,
1093		level: MCPLogLevel = MCPLogLevel.INFO,
1094		details?: unknown
1095	): MCPConnectionLog {
1096		return {
1097			details,
1098			level,
1099			message,
1100			phase,
1101			timestamp: new Date()
1102		};
1103	}
1104
1105	/**
1106	 * Extract server info from SDK Implementation type.
1107	 * Normalizes the SDK's server version response into our MCPServerInfo type.
1108	 *
1109	 * @param impl - Raw Implementation object from MCP SDK
1110	 * @returns Normalized server info or undefined if input is empty
1111	 */
1112	private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined {
1113		if (!impl) {
1114			return undefined;
1115		}
1116
1117		return {
1118			description: impl.description,
1119			icons: impl.icons?.map((icon: MCPResourceIcon) => ({
1120				mimeType: icon.mimeType,
1121				sizes: icon.sizes,
1122				src: icon.src,
1123				theme: icon.theme
1124			})),
1125			name: impl.name,
1126			title: impl.title,
1127			version: impl.version,
1128			websiteUrl: impl.websiteUrl
1129		};
1130	}
1131
1132	private static formatSingleContent(content: ToolResultContentItem): string {
1133		if (content.type === MCPContentType.TEXT && content.text) {
1134			return content.text;
1135		}
1136
1137		if (content.type === MCPContentType.IMAGE && content.data) {
1138			return createBase64DataUrl(content.mimeType ?? DEFAULT_IMAGE_MIME_TYPE, content.data);
1139		}
1140
1141		if (content.type === MCPContentType.RESOURCE && content.resource) {
1142			const resource = content.resource;
1143
1144			if (resource.text) return resource.text;
1145
1146			if (resource.blob) return resource.blob;
1147
1148			return JSON.stringify(resource);
1149		}
1150
1151		if (content.data && content.mimeType) {
1152			return createBase64DataUrl(content.mimeType, content.data);
1153		}
1154
1155		return JSON.stringify(content);
1156	}
1157
1158	/**
1159	 * Format tool result content items to a single string.
1160	 * Handles text, image (base64 data URL), and embedded resource content types.
1161	 *
1162	 * @param result - Raw tool call result from MCP SDK
1163	 * @returns Concatenated string representation of all content items
1164	 */
1165	private static formatToolResult(result: ToolCallResult): string {
1166		const content = result.content;
1167
1168		if (!Array.isArray(content)) return '';
1169
1170		const formatted = content
1171			.map((item) => this.formatSingleContent(item))
1172			.filter(Boolean)
1173			.join(NEWLINE);
1174
1175		if (formatted !== '') {
1176			return formatted;
1177		}
1178
1179		if (result.structuredContent && typeof result.structuredContent === 'object') {
1180			return JSON.stringify(result.structuredContent);
1181		}
1182
1183		return '';
1184	}
1185
1186	private static getBrowserContext(
1187		targetUrl: URL,
1188		useProxy: boolean
1189	): Record<string, unknown> | undefined {
1190		if (typeof window === 'undefined') {
1191			return undefined;
1192		}
1193
1194		return {
1195			isSecureContext: window.isSecureContext,
1196			location: window.location.href,
1197			origin: window.location.origin,
1198			protocol: window.location.protocol,
1199			sameOrigin: window.location.origin === targetUrl.origin,
1200			targetOrigin: targetUrl.origin,
1201			targetProtocol: targetUrl.protocol,
1202			useProxy
1203		};
1204	}
1205
1206	private static getConnectionHints(
1207		targetUrl: URL,
1208		config: MCPServerConfig,
1209		error: unknown
1210	): string[] {
1211		const hints: string[] = [];
1212		const message = error instanceof Error ? error.message : String(error);
1213		const headerNames = Object.keys(config.headers ?? {});
1214
1215		if (typeof window !== 'undefined') {
1216			if (
1217				window.location.protocol === 'https:' &&
1218				targetUrl.protocol === 'http:' &&
1219				!config.useProxy
1220			) {
1221				hints.push(
1222					'The page is running over HTTPS but the MCP server is HTTP. Browsers often block this as mixed content; enable the proxy or use HTTPS/WSS for the MCP server.'
1223				);
1224			}
1225
1226			if (window.location.origin !== targetUrl.origin && !config.useProxy) {
1227				hints.push(
1228					'This is a cross-origin browser request. If the server is reachable from curl or Node but not from the browser, missing CORS headers are the most likely cause.'
1229				);
1230			}
1231		}
1232
1233		if (headerNames.length > 0) {
1234			hints.push(
1235				`Custom request headers are configured (${headerNames.join(', ')}). That triggers a CORS preflight, so the server must allow OPTIONS and include the matching Access-Control-Allow-Headers response.`
1236			);
1237		}
1238
1239		if (config.credentials && config.credentials !== 'omit') {
1240			hints.push(
1241				'Credentials are enabled for this connection. Cross-origin credentialed requests need Access-Control-Allow-Credentials: true and cannot use a wildcard Access-Control-Allow-Origin.'
1242			);
1243		}
1244
1245		if (message.includes('Failed to fetch')) {
1246			hints.push(
1247				'"Failed to fetch" is a browser-level network failure. Common causes are CORS rejection, mixed-content blocking, certificate/TLS errors, DNS failures, or nothing listening on the target port.'
1248			);
1249		}
1250
1251		return hints;
1252	}
1253
1254	/**
1255	 * Walk a cursor-paginated MCP list endpoint, collecting every page.
1256	 */
1257	private static async paginate<T, R extends { nextCursor?: string }>(
1258		connection: MCPConnection,
1259		fetchPage: (cursor?: string) => Promise<R>,
1260		extract: (result: R) => T[]
1261	): Promise<T[]> {
1262		const all: T[] = [];
1263
1264		let cursor: string | undefined;
1265
1266		do {
1267			const result = await fetchPage(cursor);
1268
1269			all.push(...extract(result));
1270			cursor = result.nextCursor;
1271		} while (cursor);
1272
1273		return all;
1274	}
1275
1276	private static summarizeError(error: unknown): Record<string, unknown> {
1277		if (error instanceof Error) {
1278			return {
1279				cause:
1280					error.cause instanceof Error
1281						? { message: error.cause.message, name: error.cause.name }
1282						: error.cause,
1283				message: error.message,
1284				name: error.name,
1285				stack: error.stack?.split('\n').slice(0, 6).join('\n')
1286			};
1287		}
1288
1289		return { value: String(error) };
1290	}
1291}