diff --git a/tools/ui/src/lib/services/mcp.service.ts b/tools/ui/src/lib/services/mcp.service.ts index d596381aa055..93cf2c172990 100644 --- a/tools/ui/src/lib/services/mcp.service.ts +++ b/tools/ui/src/lib/services/mcp.service.ts @@ -339,18 +339,21 @@ export class MCPService { } /** - * Create transport based on server configuration. - * Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports. - * When `useProxy` is enabled, routes HTTP requests through llama-server's CORS proxy. + * Creates an MCP transport based on the configured transport type. + * Supports WebSocket, Streamable HTTP (modern), and legacy SSE transports. * - * **Fallback Order:** - * 1. WebSocket — if explicitly configured (no CORS proxy support) - * 2. StreamableHTTP — default for HTTP connections - * 3. SSE — automatic fallback if StreamableHTTP fails + * When `useProxy` is enabled, HTTP-based transports are routed through + * llama-server's CORS proxy. WebSocket connections are established directly. * - * @param config - Server configuration with url, transport type, proxy, and auth settings - * @returns Object containing the created transport and the transport type used - * @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail + * The configured transport is always respected. Streamable HTTP remains the + * default when no transport is explicitly configured. + * + * @param config - Server configuration containing the URL, transport type, + * proxy, and authentication settings. + * @returns The created transport, the selected transport type, and a cleanup + * function for connection logging. + * @throws {Error} If the server URL is missing, an unsupported transport + * configuration is requested, or the selected transport cannot be created. */ static createTransport( serverName: string, @@ -404,51 +407,54 @@ export class MCPService { } const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url); - const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch( - serverName, - config, - requestInit, - url, - useProxy, - onLog - ); + + const { fetch: diagnosticFetch, disable: stopPhaseLogging } = + this.createDiagnosticFetch( + serverName, + config, + requestInit, + url, + useProxy, + onLog + ); if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`); } - try { - if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { - console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`); - } - - return { - transport: new StreamableHTTPClientTransport(url, { - requestInit, - fetch: diagnosticFetch - }), - type: MCPTransportType.STREAMABLE_HTTP, - stopPhaseLogging - }; - } catch (httpError) { - console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError); + // Respect the configured transport instead of always defaulting to Streamable HTTP. + switch (config.transport) { + case MCPTransportType.SSE: + if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { + console.log(`[MCPService] Creating SSE transport for ${url.href}`); + } - try { return { transport: new SSEClientTransport(url, { requestInit, fetch: diagnosticFetch, - eventSourceInit: { fetch: diagnosticFetch } + eventSourceInit: { + fetch: diagnosticFetch + } }), type: MCPTransportType.SSE, stopPhaseLogging }; - } catch (sseError) { - const httpMsg = httpError instanceof Error ? httpError.message : String(httpError); - const sseMsg = sseError instanceof Error ? sseError.message : String(sseError); - throw new Error(`Failed to create transport. StreamableHTTP: ${httpMsg}; SSE: ${sseMsg}`); - } + case MCPTransportType.STREAMABLE_HTTP: + default: + if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { + console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`); + } + + return { + transport: new StreamableHTTPClientTransport(url, { + requestInit, + fetch: diagnosticFetch + }), + type: MCPTransportType.STREAMABLE_HTTP, + stopPhaseLogging + }; } } diff --git a/tools/ui/src/lib/utils/mcp.ts b/tools/ui/src/lib/utils/mcp.ts index 05fe90048f0b..336ba28a7128 100644 --- a/tools/ui/src/lib/utils/mcp.ts +++ b/tools/ui/src/lib/utils/mcp.ts @@ -36,15 +36,30 @@ import type { MimeTypeUnion } from '$lib/types/common'; /** * Detects the MCP transport type from a URL. - * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'. */ export function detectMcpTransportFromUrl(url: string): MCPTransportType { - const normalized = url.trim().toLowerCase(); + const normalized = url.trim().toLowerCase(); - return normalized.startsWith(UrlProtocol.WEBSOCKET) || - normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE) - ? MCPTransportType.WEBSOCKET - : MCPTransportType.STREAMABLE_HTTP; + if ( + normalized.startsWith(UrlProtocol.WEBSOCKET) || + normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE) + ) { + return MCPTransportType.WEBSOCKET; + } + + // Legacy MCP SSE transport + try { + const parsed = new URL(url); + const path = parsed.pathname.replace(/\/+$/, "").toLowerCase(); + + if (path.endsWith("/sse")) { + return MCPTransportType.SSE; + } + } catch { + // Ignore invalid URLs and fall through to the default transport. + } + + return MCPTransportType.STREAMABLE_HTTP; } /** diff --git a/tools/ui/tests/unit/mcp-service.test.ts b/tools/ui/tests/unit/mcp-service.test.ts index afd3bdd5cfe3..33e7b1b437c2 100644 --- a/tools/ui/tests/unit/mcp-service.test.ts +++ b/tools/ui/tests/unit/mcp-service.test.ts @@ -1,5 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; + import { Client } from '@modelcontextprotocol/sdk/client'; +import { + StreamableHTTPClientTransport, + StreamableHTTPError +} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; + import { MCPService } from '$lib/services/mcp.service'; import { MCPConnectionPhase, MCPTransportType } from '$lib/enums'; import type { MCPConnectionLog, MCPServerConfig } from '$lib/types'; @@ -250,3 +258,37 @@ describe('MCPService', () => { ).toHaveLength(0); }); }); +describe('createTransport', () => { + it('creates an SSE transport when SSE is configured', () => { + const result = MCPService.createTransport('test-server', { + url: 'http://localhost:3000/sse', + transport: MCPTransportType.SSE + }); + + expect(result.type).toBe(MCPTransportType.SSE); + expect(result.transport).toBeInstanceOf(SSEClientTransport); + expect(result.stopPhaseLogging).toEqual(expect.any(Function)); + }); + + it('creates a Streamable HTTP transport when Streamable HTTP is configured', () => { + const result = MCPService.createTransport('test-server', { + url: 'http://localhost:3000/mcp', + transport: MCPTransportType.STREAMABLE_HTTP + }); + + expect(result.type).toBe(MCPTransportType.STREAMABLE_HTTP); + expect(result.transport).toBeInstanceOf(StreamableHTTPClientTransport); + expect(result.stopPhaseLogging).toEqual(expect.any(Function)); + }); + + it('creates a WebSocket transport when WebSocket is configured', () => { + const result = MCPService.createTransport('test-server', { + url: 'ws://localhost:3000/mcp', + transport: MCPTransportType.WEBSOCKET + }); + + expect(result.type).toBe(MCPTransportType.WEBSOCKET); + expect(result.transport).toBeInstanceOf(WebSocketClientTransport); + expect(result.stopPhaseLogging).toEqual(expect.any(Function)); + }); +});