Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 46 additions & 40 deletions tools/ui/src/lib/services/mcp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Comment thread
joydolma marked this conversation as resolved.
case MCPTransportType.SSE:
Comment thread
joydolma marked this conversation as resolved.
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
};
}
}

Expand Down
27 changes: 21 additions & 6 deletions tools/ui/src/lib/utils/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
42 changes: 42 additions & 0 deletions tools/ui/tests/unit/mcp-service.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
});
});