char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
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 { 12StreamableHTTPClientTransport , 13StreamableHTTPError 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 { 18GetPromptResult , 19ListChangedHandlers , 20Prompt , 21Tool 22} from '@modelcontextprotocol/sdk/types.js' ; 23import { 24CORS_PROXY , 25CORS_PROXY_ENDPOINT , 26DEFAULT_CLIENT_VERSION , 27DEFAULT_IMAGE_MIME_TYPE , 28DEFAULT_MCP_CONFIG , 29HEADERS , 30NEWLINE 31} from '$lib/constants' ; 32import { 33MCPConnectionPhase , 34MCPContentType , 35MCPLogLevel , 36MCPRefType , 37MCPTransportType 38} from '$lib/enums' ; 39import type { 40ClientCapabilities , 41Implementation , 42MCPConnection , 43MCPConnectionLog , 44MCPPhaseCallback , 45MCPReadResourceResult , 46MCPResource , 47MCPResourceContent , 48MCPResourceIcon , 49MCPResourceTemplate , 50MCPServerConfig , 51MCPServerInfo , 52ToolCallParams , 53ToolExecutionResult 54} from '$lib/types' ; 55import { 56buildProxiedHeaders , 57buildProxiedUrl , 58createBase64DataUrl , 59extractJsonRpcMethods , 60formatDiagnosticErrorMessage , 61getAuthHeaders , 62getRequestBody , 63getRequestMethod , 64getRequestUrl , 65isAbortError , 66type RequestBodySummary , 67sanitizeHeaders , 68summarizeRequestBody , 69throwIfAborted 70} from '$lib/utils' ; 71 72interface ToolResultContentItem { 73type :string ; 74text ?:string ; 75data ?:string ; 76mimeType ?:string ; 77resource ?:{ text ?:string ; blob ?:string ; uri ?:string }; 78} 79 80interface ToolCallResult { 81content ?:ToolResultContentItem []; 82structuredContent ?:Record < string , unknown >; 83isError ?:boolean ; 84_meta ?:Record < string , unknown >; 85} 86 87interface DiagnosticRequestDetails { 88url :string ; 89method :string ; 90credentials ?:RequestCredentials ; 91mode ?:RequestMode ; 92headers :Record < string , string >; 93body :RequestBodySummary ; 94jsonRpcMethods ?: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*/ 109static async callTool ( 110connection :MCPConnection , 111params :ToolCallParams , 112signal ?:AbortSignal 113) :Promise < ToolExecutionResult > { 114throwIfAborted ( signal ); 115 116try { 117const result = await connection . client . callTool ( 118{ arguments :params . arguments , name :params . name }, 119undefined , 120{ signal, timeout :connection . requestTimeoutMs } 121); 122 123return { 124content :this . formatToolResult ( result as ToolCallResult ), 125isError :( result as ToolCallResult ). isError ?? false 126}; 127} catch ( error ) { 128if ( isAbortError ( error )) { 129throw error ; 130} 131 132// Let session-expired errors propagate unwrapped for reconnection handling 133if ( this . isSessionExpiredError ( error )) { 134throw error ; 135} 136 137const message = error instanceof Error ?error . message :String ( error ); 138 139throw 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*/ 155static async complete ( 156connection :MCPConnection , 157ref :{ type :MCPRefType . PROMPT ; name :string } | { type :MCPRefType . RESOURCE ; uri :string }, 158argument :{ name :string ; value :string } 159) :Promise <{ values :string []; total ?:number ; hasMore ?:boolean } | null > { 160try { 161const result = await connection . client . complete ({ 162 argument, 163 ref 164}); 165 166return result . completion ; 167} catch ( error ) { 168console . error ( `[MCPService] Failed to get completions:` , error ); 169 170return 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*/ 194static async connect ( 195serverName :string , 196serverConfig :MCPServerConfig , 197clientInfo ?:Implementation , 198capabilities ?:ClientCapabilities , 199onPhase ?:MCPPhaseCallback , 200listChangedHandlers ?:ListChangedHandlers 201) :Promise < MCPConnection > { 202const startTime = performance . now (); 203const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG . clientInfo ; 204const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG . capabilities ; 205 206// Phase: Creating transport 207onPhase ?.( 208MCPConnectionPhase . TRANSPORT_CREATING , 209this . createLog ( 210MCPConnectionPhase . TRANSPORT_CREATING , 211`Creating transport for ${ serverConfig . url } ` 212) 213); 214 215if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 216console . log ( `[MCPService][ ${ serverName } ] Creating transport...` ); 217} 218 219const { 220 stopPhaseLogging, 221 transport, 222type :transportType 223} = this . createTransport ( serverName , serverConfig , ( log ) => onPhase ?.( log . phase , log )); 224 225// Setup WebSocket reconnection handler 226if ( transportType === MCPTransportType . WEBSOCKET ) { 227transport . onclose = () => { 228console . log ( `[MCPService][ ${ serverName } ] WebSocket closed, notifying for reconnection` ); 229onPhase ?.( 230MCPConnectionPhase . DISCONNECTED , 231this . createLog ( MCPConnectionPhase . DISCONNECTED , 'WebSocket connection closed' ) 232); 233}; 234} 235 236// Phase: Transport ready 237onPhase ?.( 238MCPConnectionPhase . TRANSPORT_READY , 239this . createLog ( MCPConnectionPhase . TRANSPORT_READY , `Transport ready ( ${ transportType } )` ), 240{ transportType} 241); 242 243const client = new Client ( 244{ 245name :effectiveClientInfo . name , 246version :effectiveClientInfo . version ?? DEFAULT_CLIENT_VERSION 247}, 248{ 249capabilities :effectiveCapabilities , 250listChanged :listChangedHandlers 251} 252); 253const 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 260if ( isAbortError ( error )) { 261return ; 262} 263 264const msg = error ?. message ?? '' ; 265 266if ( 267/ SSE stream disconnected:.*AbortError / i . test ( msg ) || 268/ AbortError: .*aborted / i . test ( msg ) || 269/ stream locked by a reader / i . test ( msg ) 270) { 271return ; 272} 273 274console . error ( `[MCPService][ ${ serverName } ] Protocol error after initialize:` , error ); 275}; 276 277client . onerror = ( error ) => { 278onPhase ?.( 279MCPConnectionPhase . ERROR , 280this . createLog ( 281MCPConnectionPhase . ERROR , 282`Protocol error: ${ error . message } ` , 283MCPLogLevel . ERROR , 284{ 285error :this . summarizeError ( error ) 286} 287) 288); 289}; 290 291// Phase: Initializing 292onPhase ?.( 293MCPConnectionPhase . INITIALIZING , 294this . 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. 301const handshakeTimeoutMs = 302serverConfig . handshakeTimeoutMs ?? DEFAULT_MCP_CONFIG . connectionTimeoutMs ; 303 304try { 305let handshakeTimer :ReturnType < typeof setTimeout > | undefined ; 306 307const handshakeDeadline = new Promise < never >(( _ , reject ) => { 308handshakeTimer = setTimeout (() => { 309void transport . close (). catch (() => {}); 310reject ( new Error ( `Connection timed out after ${ Math . round ( handshakeTimeoutMs / 1000 )} s` )); 311}, handshakeTimeoutMs ); 312}); 313 314try { 315await Promise . race ([ 316client . connect ( transport , { timeout :handshakeTimeoutMs }), 317handshakeDeadline 318]); 319} finally { 320clearTimeout ( handshakeTimer ); 321} 322 323// Transport diagnostics are only for the initial handshake, not long-lived traffic. 324stopPhaseLogging (); 325client . onerror = runtimeErrorHandler ; 326} catch ( error ) { 327client . onerror = runtimeErrorHandler ; 328const url = 329( serverConfig . useProxy ?? false ) 330 ?buildProxiedUrl ( serverConfig . url ) 331 :new URL ( serverConfig . url ); 332 333onPhase ?.( 334MCPConnectionPhase . ERROR , 335this . createLog ( 336MCPConnectionPhase . ERROR , 337`Connection failed during initialize: ${ 338error instanceof Error ? error . message : String ( error ) 339} ` , 340MCPLogLevel . ERROR , 341{ 342browser :this . getBrowserContext ( url , serverConfig . useProxy ?? false ), 343config :{ 344configuredUrl :serverConfig . url , 345credentials :serverConfig . credentials , 346effectiveUrl :url . href , 347headers :sanitizeHeaders ( 348serverConfig . headers , 349Object . keys ( serverConfig . headers ?? {}), 350HEADERS . PARTIAL_REDACT 351), 352 serverName, 353 transportType, 354useProxy :serverConfig . useProxy ?? false 355}, 356error :this . summarizeError ( error ), 357hints :this . getConnectionHints ( url , serverConfig , error ) 358} 359) 360); 361 362throw error ; 363} 364 365const serverVersion = client . getServerVersion (); 366const serverCapabilities = client . getServerCapabilities (); 367const instructions = client . getInstructions (); 368const serverInfo = this . extractServerInfo ( serverVersion ); 369 370// Phase: Capabilities exchanged 371onPhase ?.( 372MCPConnectionPhase . CAPABILITIES_EXCHANGED , 373this . createLog ( 374MCPConnectionPhase . CAPABILITIES_EXCHANGED , 375'Capabilities exchanged successfully' , 376MCPLogLevel . INFO , 377{ 378 serverCapabilities, 379 serverInfo 380} 381), 382{ 383clientCapabilities :effectiveCapabilities , 384 instructions, 385 serverCapabilities, 386 serverInfo 387} 388); 389 390// Phase: Listing tools 391onPhase ?.( 392MCPConnectionPhase . LISTING_TOOLS , 393this . createLog ( MCPConnectionPhase . LISTING_TOOLS , 'Listing available tools...' ) 394); 395 396if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 397console . log ( `[MCPService][ ${ serverName } ] Connected, listing tools...` ); 398} 399 400const tools = await this . listTools ({ 401 client, 402connectionTimeMs :0 , 403requestTimeoutMs : 404serverConfig . requestTimeoutMs ?? DEFAULT_MCP_CONFIG . requestTimeoutSeconds * 1000 , 405 serverName, 406tools :[], 407 transport, 408 transportType 409}); 410const connectionTimeMs = Math . round ( performance . now () - startTime ); 411 412// Phase: Connected 413onPhase ?.( 414MCPConnectionPhase . CONNECTED , 415this . createLog ( 416MCPConnectionPhase . CONNECTED , 417`Connection established with ${ tools . length } tools ( ${ connectionTimeMs } ms)` 418) 419); 420 421if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 422console . log ( 423`[MCPService][ ${ serverName } ] Initialization complete with ${ tools . length } tools in ${ connectionTimeMs } ms` 424); 425} 426 427return { 428 client, 429clientCapabilities :effectiveCapabilities , 430 connectionTimeMs, 431 instructions, 432protocolVersion :DEFAULT_MCP_CONFIG . protocolVersion , 433requestTimeoutMs : 434serverConfig . 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*/ 458static createTransport ( 459serverName :string , 460config :MCPServerConfig , 461onLog ?:( log :MCPConnectionLog ) => void 462) :{ 463transport :Transport ; 464type :MCPTransportType ; 465stopPhaseLogging :() => void ; 466} { 467if ( ! config . url ) { 468throw new Error ( 'MCP server configuration is missing url' ); 469} 470 471const useProxy = config . useProxy ?? false ; 472const requestInit :RequestInit = {}; 473 474if ( config . headers ) { 475requestInit . headers = config . useProxy ?buildProxiedHeaders ( config . headers ) :config . headers ; 476} 477 478if ( useProxy ) { 479requestInit . headers = { 480 ...getAuthHeaders (), 481 ...( requestInit . headers as Record < string , string >) 482}; 483} 484 485if ( config . credentials ) { 486requestInit . credentials = config . credentials ; 487} 488 489if ( config . transport === MCPTransportType . WEBSOCKET ) { 490if ( useProxy ) { 491throw new Error ( 492'WebSocket transport is not supported when using CORS proxy. Use HTTP transport instead.' 493); 494} 495 496const url = new URL ( config . url ); 497 498if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 499console . log ( `[MCPService] Creating WebSocket transport for ${ url . href } ` ); 500} 501 502return { 503stopPhaseLogging :() => {}, 504transport :new WebSocketClientTransport ( url ), 505type :MCPTransportType . WEBSOCKET 506}; 507} 508 509if ( config . transport === MCPTransportType . SSE ) { 510const url = useProxy ?buildProxiedUrl ( config . url ) :new URL ( config . url ); 511const { disable :stopPhaseLogging , fetch :diagnosticFetch } = this . createDiagnosticFetch ( 512serverName , 513config , 514requestInit , 515url , 516useProxy , 517onLog 518); 519 520if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 521console . log ( `[MCPService] Creating SSE transport for ${ url . href } ` ); 522} 523 524return { 525 stopPhaseLogging, 526transport :new SSEClientTransport ( url , { 527eventSourceInit :{ fetch :diagnosticFetch }, 528fetch :diagnosticFetch , 529 requestInit 530}), 531type :MCPTransportType . SSE 532}; 533} 534 535const url = useProxy ?buildProxiedUrl ( config . url ) :new URL ( config . url ); 536const { disable :stopPhaseLogging , fetch :diagnosticFetch } = this . createDiagnosticFetch ( 537serverName , 538config , 539requestInit , 540url , 541useProxy , 542onLog 543); 544 545if ( useProxy && import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 546console . log ( `[MCPService] Using CORS proxy for ${ config . url } -> ${ url . href } ` ); 547} 548 549try { 550if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 551console . log ( `[MCPService] Creating StreamableHTTP transport for ${ url . href } ` ); 552} 553 554return { 555 stopPhaseLogging, 556transport :new StreamableHTTPClientTransport ( url , { 557fetch :diagnosticFetch , 558 requestInit 559}), 560type :MCPTransportType . STREAMABLE_HTTP 561}; 562} catch ( httpError ) { 563console . warn ( `[MCPService] StreamableHTTP failed, trying SSE transport...` , httpError ); 564 565try { 566return { 567 stopPhaseLogging, 568transport :new SSEClientTransport ( url , { 569eventSourceInit :{ fetch :diagnosticFetch }, 570fetch :diagnosticFetch , 571 requestInit 572}), 573type :MCPTransportType . SSE 574}; 575} catch ( sseError ) { 576const httpMsg = httpError instanceof Error ?httpError . message :String ( httpError ); 577const sseMsg = sseError instanceof Error ?sseError . message :String ( sseError ); 578 579throw 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*/ 590static async disconnect ( connection :MCPConnection ) :Promise < void > { 591if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 592console . log ( `[MCPService][ ${ connection . serverName } ] Disconnecting...` ); 593} 594 595try { 596// Terminate the session first for streamable-http transports to cleanly 597// close streams, matching the inspector's disconnect flow. 598if ( connection . transport instanceof StreamableHTTPClientTransport ) { 599await 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. 606connection . client . onerror = undefined ; 607 608if ( connection . transport . onclose ) { 609connection . transport . onclose = undefined ; 610} 611 612await connection . client . close (); 613} catch ( error ) { 614console . 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*/ 629static async getPrompt ( 630connection :MCPConnection , 631name :string , 632args ?:Record < string , string > 633) :Promise < GetPromptResult > { 634try { 635return await connection . client . getPrompt ({ arguments :args , name}); 636} catch ( error ) { 637console . error ( `[MCPService][ ${ connection . serverName } ] Failed to get prompt:` , error ); 638 639throw 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*/ 651static isSessionExpiredError ( error :unknown ) :boolean { 652return 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*/ 660static async listAllResources ( connection :MCPConnection ) :Promise < MCPResource []> { 661return this . paginate ( 662connection , 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*/ 673static async listAllResourceTemplates ( connection :MCPConnection ) :Promise < MCPResourceTemplate []> { 674return this . paginate ( 675connection , 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*/ 688static async listPrompts ( connection :MCPConnection ) :Promise < Prompt []> { 689try { 690const result = await connection . client . listPrompts (); 691 692return result . prompts ?? []; 693} catch ( error ) { 694// Let session-expired errors propagate for reconnection handling 695if ( this . isSessionExpiredError ( error )) { 696throw error ; 697} 698 699console . warn ( `[MCPService][ ${ connection . serverName } ] Failed to list prompts:` , error ); 700 701return []; 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*/ 711static async listResources ( 712connection :MCPConnection , 713cursor ?:string 714) :Promise <{ resources :MCPResource []; nextCursor ?:string }> { 715try { 716const result = await connection . client . listResources ( cursor ?{ cursor} :undefined ); 717 718return { 719nextCursor :result . nextCursor , 720resources :( result . resources ?? []) as MCPResource [] 721}; 722} catch ( error ) { 723if ( this . isSessionExpiredError ( error )) { 724throw error ; 725} 726 727console . warn ( `[MCPService][ ${ connection . serverName } ] Failed to list resources:` , error ); 728 729return { 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*/ 739static async listResourceTemplates ( 740connection :MCPConnection , 741cursor ?:string 742) :Promise <{ resourceTemplates :MCPResourceTemplate []; nextCursor ?:string }> { 743try { 744const result = await connection . client . listResourceTemplates ( cursor ?{ cursor} :undefined ); 745 746return { 747nextCursor :result . nextCursor , 748resourceTemplates :( result . resourceTemplates ?? []) as MCPResourceTemplate [] 749}; 750} catch ( error ) { 751if ( this . isSessionExpiredError ( error )) { 752throw error ; 753} 754 755console . warn ( 756`[MCPService][ ${ connection . serverName } ] Failed to list resource templates:` , 757error 758); 759 760return { 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*/ 771static async listTools ( connection :MCPConnection ) :Promise < Tool []> { 772try { 773const result = await connection . client . listTools (); 774 775return result . tools ?? []; 776} catch ( error ) { 777// Let session-expired errors propagate for reconnection handling 778if ( this . isSessionExpiredError ( error )) { 779throw error ; 780} 781 782console . warn ( `[MCPService][ ${ connection . serverName } ] Failed to list tools:` , error ); 783 784return []; 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*/ 794static async readResource ( 795connection :MCPConnection , 796uri :string 797) :Promise < MCPReadResourceResult > { 798try { 799const result = await connection . client . readResource ({ uri}); 800 801return { 802_meta :result . _meta , 803contents :( result . contents ?? []) as MCPResourceContent [] 804}; 805} catch ( error ) { 806console . error ( `[MCPService][ ${ connection . serverName } ] Failed to read resource:` , error ); 807 808throw 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*/ 818static async subscribeResource ( connection :MCPConnection , uri :string ) :Promise < void > { 819try { 820await connection . client . subscribeResource ({ uri}); 821 822console . log ( `[MCPService][ ${ connection . serverName } ] Subscribed to resource: ${ uri } ` ); 823} catch ( error ) { 824console . error ( 825`[MCPService][ ${ connection . serverName } ] Failed to subscribe to resource:` , 826error 827); 828 829throw 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*/ 841static 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 844return 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*/ 852static supportsResourceSubscriptions ( connection :MCPConnection ) :boolean { 853return !! 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*/ 861static async unsubscribeResource ( connection :MCPConnection , uri :string ) :Promise < void > { 862try { 863await connection . client . unsubscribeResource ({ uri}); 864 865if ( import . meta. env . DEV && import . meta. env . VITE_DEBUG ) { 866console . log ( `[MCPService][ ${ connection . serverName } ] Unsubscribed from resource: ${ uri } ` ); 867} 868} catch ( error ) { 869console . error ( 870`[MCPService][ ${ connection . serverName } ] Failed to unsubscribe from resource:` , 871error 872); 873 874throw error ; 875} 876} 877 878private static addRequestHeaders ( 879requestHeaders :Headers , 880headers :HeadersInit , 881useProxy :boolean 882) { 883for ( const [ key , value ] of new Headers ( headers ). entries ()) { 884const proxiedKey = 885useProxy && ! key . toLowerCase (). startsWith ( CORS_PROXY . HEADER_PREFIX ) 886 ?` ${ CORS_PROXY . HEADER_PREFIX } ${ key } ` 887 :key ; 888 889requestHeaders . set ( proxiedKey , value ); 890} 891} 892 893private static createDiagnosticFetch ( 894serverName :string , 895config :MCPServerConfig , 896baseInit :RequestInit , 897targetUrl :URL , 898useProxy :boolean , 899onLog ?:( log :MCPConnectionLog ) => void 900) :{ 901fetch :typeof fetch ; 902disable :() => void ; 903} { 904let enabled = true ; 905 906const logIfEnabled = ( log :MCPConnectionLog ) => { 907if ( enabled ) { 908onLog ?.( log ); 909} 910}; 911 912return { 913disable :() => { 914enabled = false ; 915}, 916fetch :async ( input , init ) => { 917if ( useProxy && typeof window !== 'undefined' ) { 918let requestUrlStr = '' ; 919 920if ( typeof input === 'string' ) { 921requestUrlStr = input ; 922} else if ( input instanceof URL ) { 923requestUrlStr = input . href ; 924} 925 926if ( requestUrlStr ) { 927const parsedRequestUrl = new URL ( requestUrlStr , window . location . origin ); 928 929if ( 930parsedRequestUrl . origin === window . location . origin && 931! parsedRequestUrl . pathname . includes ( CORS_PROXY_ENDPOINT ) 932) { 933const originalConfigUrl = new URL ( config . url ); 934const realTargetUrl = new URL ( 935parsedRequestUrl . pathname + parsedRequestUrl . search , 936originalConfigUrl . origin 937); 938const proxiedUrl = buildProxiedUrl ( realTargetUrl . href ); 939 940if ( typeof input === 'string' ) { 941input = proxiedUrl . href ; 942} else if ( input instanceof URL ) { 943input = proxiedUrl ; 944} 945} 946} 947} 948 949const startedAt = performance . now (); 950const requestHeaders = new Headers ( baseInit . headers ); 951 952if ( typeof Request !== 'undefined' && input instanceof Request ) { 953this . addRequestHeaders ( requestHeaders , input . headers , useProxy ); 954} 955 956if ( init ?. headers ) { 957this . addRequestHeaders ( requestHeaders , init . headers , useProxy ); 958} 959 960const request = this . createDiagnosticRequestDetails ( 961input , 962init , 963baseInit , 964requestHeaders , 965Object . keys ( config . headers ?? {}) 966); 967const { method, url} = request ; 968 969logIfEnabled ( 970this . createLog ( 971MCPConnectionPhase . INITIALIZING , 972`HTTP ${ method } ${ url } ` , 973MCPLogLevel . INFO , 974{ 975 request, 976 serverName 977} 978) 979); 980 981if ( method === 'DELETE' && url . includes ( CORS_PROXY_ENDPOINT )) { 982const response = new Response ( null , { status :200 , statusText :'OK' }); 983 984logIfEnabled ( 985this . createLog ( 986MCPConnectionPhase . INITIALIZING , 987`HTTP 200 ${ method } ${ url } (fake response)` , 988MCPLogLevel . INFO , 989{ 990response :{ 991durationMs :0 , 992isFake :true , 993status :response . status , 994statusText :response . statusText , 995 url 996} 997} 998) 999); 1000 1001// fake response, bypass real fetch() 1002return response ; 1003} 1004 1005try { 1006const response = await fetch ( input , { 1007 ...baseInit , 1008 ...init , 1009headers :requestHeaders 1010}); 1011const durationMs = Math . round ( performance . now () - startedAt ); 1012 1013logIfEnabled ( 1014this . createLog ( 1015MCPConnectionPhase . INITIALIZING , 1016`HTTP ${ response . status } ${ method } ${ url } ( ${ durationMs } ms)` , 1017response . ok ?MCPLogLevel . INFO :MCPLogLevel . WARN , 1018{ 1019response :{ 1020 durationMs, 1021headers :sanitizeHeaders ( response . headers , undefined , HEADERS . PARTIAL_REDACT ), 1022status :response . status , 1023statusText :response . statusText , 1024 url 1025} 1026} 1027) 1028); 1029 1030return response ; 1031} catch ( error ) { 1032const durationMs = Math . round ( performance . now () - startedAt ); 1033 1034logIfEnabled ( 1035this . createLog ( 1036MCPConnectionPhase . ERROR , 1037`HTTP ${ method } ${ url } failed: ${ formatDiagnosticErrorMessage ( error )} ` , 1038MCPLogLevel . ERROR , 1039{ 1040browser :this . getBrowserContext ( targetUrl , useProxy ), 1041 durationMs, 1042error :this . summarizeError ( error ), 1043hints :this . getConnectionHints ( targetUrl , config , error ), 1044 request, 1045 serverName 1046} 1047) 1048); 1049 1050throw error ; 1051} 1052} 1053}; 1054} 1055 1056private static createDiagnosticRequestDetails ( 1057input :RequestInfo | URL , 1058init :RequestInit | undefined , 1059baseInit :RequestInit , 1060requestHeaders :Headers , 1061extraRedactedHeaders ?:Iterable < string > 1062) :DiagnosticRequestDetails { 1063const body = getRequestBody ( input , init ); 1064const details :DiagnosticRequestDetails = { 1065body :summarizeRequestBody ( body ), 1066credentials :init ?. credentials ?? baseInit . credentials , 1067headers :sanitizeHeaders ( requestHeaders , extraRedactedHeaders , HEADERS . PARTIAL_REDACT ), 1068method :getRequestMethod ( input , init , baseInit ). toUpperCase (), 1069mode :init ?. mode ?? baseInit . mode , 1070url :getRequestUrl ( input ) 1071}; 1072const jsonRpcMethods = extractJsonRpcMethods ( body ); 1073 1074if ( jsonRpcMethods ) { 1075details . jsonRpcMethods = jsonRpcMethods ; 1076} 1077 1078return 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*/ 1090private static createLog ( 1091phase :MCPConnectionPhase , 1092message :string , 1093level :MCPLogLevel = MCPLogLevel . INFO , 1094details ?:unknown 1095) :MCPConnectionLog { 1096return { 1097 details, 1098 level, 1099 message, 1100 phase, 1101timestamp :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*/ 1112private static extractServerInfo ( impl :Implementation | undefined ) :MCPServerInfo | undefined { 1113if ( ! impl ) { 1114return undefined ; 1115} 1116 1117return { 1118description :impl . description , 1119icons :impl . icons ?. map (( icon :MCPResourceIcon ) => ({ 1120mimeType :icon . mimeType , 1121sizes :icon . sizes , 1122src :icon . src , 1123theme :icon . theme 1124})), 1125name :impl . name , 1126title :impl . title , 1127version :impl . version , 1128websiteUrl :impl . websiteUrl 1129}; 1130} 1131 1132private static formatSingleContent ( content :ToolResultContentItem ) :string { 1133if ( content . type === MCPContentType . TEXT && content . text ) { 1134return content . text ; 1135} 1136 1137if ( content . type === MCPContentType . IMAGE && content . data ) { 1138return createBase64DataUrl ( content . mimeType ?? DEFAULT_IMAGE_MIME_TYPE , content . data ); 1139} 1140 1141if ( content . type === MCPContentType . RESOURCE && content . resource ) { 1142const resource = content . resource ; 1143 1144if ( resource . text ) return resource . text ; 1145 1146if ( resource . blob ) return resource . blob ; 1147 1148return JSON . stringify ( resource ); 1149} 1150 1151if ( content . data && content . mimeType ) { 1152return createBase64DataUrl ( content . mimeType , content . data ); 1153} 1154 1155return 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*/ 1165private static formatToolResult ( result :ToolCallResult ) :string { 1166const content = result . content ; 1167 1168if ( ! Array . isArray ( content )) return '' ; 1169 1170const formatted = content 1171. map (( item ) => this . formatSingleContent ( item )) 1172. filter ( Boolean ) 1173. join ( NEWLINE ); 1174 1175if ( formatted !== '' ) { 1176return formatted ; 1177} 1178 1179if ( result . structuredContent && typeof result . structuredContent === 'object' ) { 1180return JSON . stringify ( result . structuredContent ); 1181} 1182 1183return '' ; 1184} 1185 1186private static getBrowserContext ( 1187targetUrl :URL , 1188useProxy :boolean 1189) :Record < string , unknown > | undefined { 1190if ( typeof window === 'undefined' ) { 1191return undefined ; 1192} 1193 1194return { 1195isSecureContext :window . isSecureContext , 1196location :window . location . href , 1197origin :window . location . origin , 1198protocol :window . location . protocol , 1199sameOrigin :window . location . origin === targetUrl . origin , 1200targetOrigin :targetUrl . origin , 1201targetProtocol :targetUrl . protocol , 1202 useProxy 1203}; 1204} 1205 1206private static getConnectionHints ( 1207targetUrl :URL , 1208config :MCPServerConfig , 1209error :unknown 1210) :string [] { 1211const hints :string [] = []; 1212const message = error instanceof Error ?error . message :String ( error ); 1213const headerNames = Object . keys ( config . headers ?? {}); 1214 1215if ( typeof window !== 'undefined' ) { 1216if ( 1217window . location . protocol === 'https:' && 1218targetUrl . protocol === 'http:' && 1219! config . useProxy 1220) { 1221hints . 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 1226if ( window . location . origin !== targetUrl . origin && ! config . useProxy ) { 1227hints . 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 1233if ( headerNames . length > 0 ) { 1234hints . 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 1239if ( config . credentials && config . credentials !== 'omit' ) { 1240hints . 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 1245if ( message . includes ( 'Failed to fetch' )) { 1246hints . 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 1251return hints ; 1252} 1253 1254/** 1255* Walk a cursor-paginated MCP list endpoint, collecting every page. 1256*/ 1257private static async paginate < T , R extends { nextCursor ?:string } > ( 1258connection :MCPConnection , 1259fetchPage :( cursor ?:string ) => Promise < R >, 1260extract :( result :R ) => T [] 1261) :Promise < T []> { 1262const all :T [] = []; 1263 1264let cursor :string | undefined ; 1265 1266do { 1267const result = await fetchPage ( cursor ); 1268 1269all . push ( ...extract ( result )); 1270cursor = result . nextCursor ; 1271} while ( cursor ); 1272 1273return all ; 1274} 1275 1276private static summarizeError ( error :unknown ) :Record < string , unknown > { 1277if ( error instanceof Error ) { 1278return { 1279cause : 1280error . cause instanceof Error 1281 ?{ message :error . cause . message , name :error . cause . name } 1282 :error . cause , 1283message :error . message , 1284name :error . name , 1285stack :error . stack ?. split ( '\n' ). slice ( 0 , 6 ). join ( '\n' ) 1286}; 1287} 1288 1289return { value :String ( error ) }; 1290} 1291}