441 lines
11 KiB
TypeScript
441 lines
11 KiB
TypeScript
/**
|
|
* MCPService - Stateless MCP Protocol Communication Layer
|
|
*
|
|
* Low-level MCP operations:
|
|
* - Transport creation (WebSocket, StreamableHTTP, SSE)
|
|
* - Server connection/disconnection
|
|
* - Tool discovery (listTools)
|
|
* - Tool execution (callTool)
|
|
*
|
|
* NO business logic, NO state management, NO orchestration.
|
|
* This is the protocol layer - pure MCP SDK operations.
|
|
*
|
|
* @see MCPClient in clients/mcp/ for business logic facade
|
|
*/
|
|
|
|
import { Client } from '@modelcontextprotocol/sdk/client';
|
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
|
|
import type {
|
|
Tool,
|
|
Prompt,
|
|
GetPromptResult,
|
|
ListChangedHandlers
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
import type {
|
|
MCPServerConfig,
|
|
ToolCallParams,
|
|
ToolExecutionResult,
|
|
Implementation,
|
|
ClientCapabilities,
|
|
MCPConnection,
|
|
MCPPhaseCallback,
|
|
MCPConnectionLog,
|
|
MCPServerInfo
|
|
} from '$lib/types';
|
|
import { MCPConnectionPhase, MCPLogLevel, MCPTransportType } from '$lib/enums';
|
|
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
|
|
|
interface ToolResultContentItem {
|
|
type: string;
|
|
text?: string;
|
|
data?: string;
|
|
mimeType?: string;
|
|
resource?: { text?: string; blob?: string; uri?: string };
|
|
}
|
|
|
|
interface ToolCallResult {
|
|
content?: ToolResultContentItem[];
|
|
isError?: boolean;
|
|
_meta?: Record<string, unknown>;
|
|
}
|
|
|
|
export class MCPService {
|
|
/**
|
|
* Create a connection log entry
|
|
*/
|
|
private static createLog(
|
|
phase: MCPConnectionPhase,
|
|
message: string,
|
|
level: MCPLogLevel = MCPLogLevel.INFO,
|
|
details?: unknown
|
|
): MCPConnectionLog {
|
|
return {
|
|
timestamp: new Date(),
|
|
phase,
|
|
message,
|
|
level,
|
|
details
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create transport based on server configuration.
|
|
* Supports WebSocket, StreamableHTTP (modern), and SSE (legacy) transports.
|
|
* Returns both transport and the type used.
|
|
*/
|
|
static createTransport(config: MCPServerConfig): {
|
|
transport: Transport;
|
|
type: MCPTransportType;
|
|
} {
|
|
if (!config.url) {
|
|
throw new Error('MCP server configuration is missing url');
|
|
}
|
|
|
|
const url = new URL(config.url);
|
|
const requestInit: RequestInit = {};
|
|
|
|
if (config.headers) {
|
|
requestInit.headers = config.headers;
|
|
}
|
|
|
|
if (config.credentials) {
|
|
requestInit.credentials = config.credentials;
|
|
}
|
|
|
|
if (config.transport === 'websocket') {
|
|
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
|
|
|
|
return {
|
|
transport: new WebSocketClientTransport(url),
|
|
type: MCPTransportType.WEBSOCKET
|
|
};
|
|
}
|
|
|
|
try {
|
|
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
|
|
|
|
return {
|
|
transport: new StreamableHTTPClientTransport(url, {
|
|
requestInit,
|
|
sessionId: config.sessionId
|
|
}),
|
|
type: MCPTransportType.STREAMABLE_HTTP
|
|
};
|
|
} catch (httpError) {
|
|
console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
|
|
|
|
try {
|
|
return {
|
|
transport: new SSEClientTransport(url, { requestInit }),
|
|
type: MCPTransportType.SSE
|
|
};
|
|
} 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract server info from SDK Implementation type
|
|
*/
|
|
private static extractServerInfo(impl: Implementation | undefined): MCPServerInfo | undefined {
|
|
if (!impl) return undefined;
|
|
return {
|
|
name: impl.name,
|
|
version: impl.version,
|
|
title: impl.title,
|
|
description: impl.description,
|
|
websiteUrl: impl.websiteUrl,
|
|
icons: impl.icons?.map((icon: { src: string; mimeType?: string; sizes?: string }) => ({
|
|
src: icon.src,
|
|
mimeType: icon.mimeType,
|
|
sizes: icon.sizes
|
|
}))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Connect to a single MCP server with detailed phase tracking.
|
|
* Returns connection object with client, transport, discovered tools, and connection details.
|
|
* @param onPhase - Optional callback for connection phase changes
|
|
* @param listChangedHandlers - Optional handlers for list changed notifications
|
|
*/
|
|
static async connect(
|
|
serverName: string,
|
|
serverConfig: MCPServerConfig,
|
|
clientInfo?: Implementation,
|
|
capabilities?: ClientCapabilities,
|
|
onPhase?: MCPPhaseCallback,
|
|
listChangedHandlers?: ListChangedHandlers
|
|
): Promise<MCPConnection> {
|
|
const startTime = performance.now();
|
|
const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
|
|
const effectiveCapabilities = capabilities ?? DEFAULT_MCP_CONFIG.capabilities;
|
|
|
|
// Phase: Creating transport
|
|
onPhase?.(
|
|
MCPConnectionPhase.TRANSPORT_CREATING,
|
|
this.createLog(
|
|
MCPConnectionPhase.TRANSPORT_CREATING,
|
|
`Creating transport for ${serverConfig.url}`
|
|
)
|
|
);
|
|
|
|
console.log(`[MCPService][${serverName}] Creating transport...`);
|
|
const { transport, type: transportType } = this.createTransport(serverConfig);
|
|
|
|
// Phase: Transport ready
|
|
onPhase?.(
|
|
MCPConnectionPhase.TRANSPORT_READY,
|
|
this.createLog(MCPConnectionPhase.TRANSPORT_READY, `Transport ready (${transportType})`),
|
|
{ transportType }
|
|
);
|
|
|
|
const client = new Client(
|
|
{
|
|
name: effectiveClientInfo.name,
|
|
version: effectiveClientInfo.version ?? '1.0.0'
|
|
},
|
|
{
|
|
capabilities: effectiveCapabilities,
|
|
listChanged: listChangedHandlers
|
|
}
|
|
);
|
|
|
|
// Phase: Initializing
|
|
onPhase?.(
|
|
MCPConnectionPhase.INITIALIZING,
|
|
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
|
|
);
|
|
|
|
console.log(`[MCPService][${serverName}] Connecting to server...`);
|
|
await client.connect(transport);
|
|
|
|
const serverVersion = client.getServerVersion();
|
|
const serverCapabilities = client.getServerCapabilities();
|
|
const instructions = client.getInstructions();
|
|
const serverInfo = this.extractServerInfo(serverVersion);
|
|
|
|
// Phase: Capabilities exchanged
|
|
onPhase?.(
|
|
MCPConnectionPhase.CAPABILITIES_EXCHANGED,
|
|
this.createLog(
|
|
MCPConnectionPhase.CAPABILITIES_EXCHANGED,
|
|
'Capabilities exchanged successfully',
|
|
MCPLogLevel.INFO,
|
|
{
|
|
serverCapabilities,
|
|
serverInfo
|
|
}
|
|
),
|
|
{
|
|
serverInfo,
|
|
serverCapabilities,
|
|
clientCapabilities: effectiveCapabilities,
|
|
instructions
|
|
}
|
|
);
|
|
|
|
// Phase: Listing tools
|
|
onPhase?.(
|
|
MCPConnectionPhase.LISTING_TOOLS,
|
|
this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
|
|
);
|
|
|
|
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
|
|
const tools = await this.listTools({
|
|
client,
|
|
transport,
|
|
tools: [],
|
|
serverName,
|
|
transportType,
|
|
connectionTimeMs: 0
|
|
});
|
|
|
|
const connectionTimeMs = Math.round(performance.now() - startTime);
|
|
|
|
// Phase: Connected
|
|
onPhase?.(
|
|
MCPConnectionPhase.CONNECTED,
|
|
this.createLog(
|
|
MCPConnectionPhase.CONNECTED,
|
|
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
|
|
)
|
|
);
|
|
|
|
console.log(
|
|
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
|
|
);
|
|
|
|
return {
|
|
client,
|
|
transport,
|
|
tools,
|
|
serverName,
|
|
transportType,
|
|
serverInfo,
|
|
serverCapabilities,
|
|
clientCapabilities: effectiveCapabilities,
|
|
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
|
instructions,
|
|
connectionTimeMs
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Disconnect from a server.
|
|
*/
|
|
static async disconnect(connection: MCPConnection): Promise<void> {
|
|
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
|
|
try {
|
|
await connection.client.close();
|
|
} catch (error) {
|
|
console.warn(`[MCPService][${connection.serverName}] Error during disconnect:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List tools from a connection.
|
|
*/
|
|
static async listTools(connection: MCPConnection): Promise<Tool[]> {
|
|
try {
|
|
const result = await connection.client.listTools();
|
|
return result.tools ?? [];
|
|
} catch (error) {
|
|
console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List prompts from a connection.
|
|
*/
|
|
static async listPrompts(connection: MCPConnection): Promise<Prompt[]> {
|
|
try {
|
|
const result = await connection.client.listPrompts();
|
|
return result.prompts ?? [];
|
|
} catch (error) {
|
|
console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a specific prompt with arguments.
|
|
*/
|
|
static async getPrompt(
|
|
connection: MCPConnection,
|
|
name: string,
|
|
args?: Record<string, string>
|
|
): Promise<GetPromptResult> {
|
|
try {
|
|
return await connection.client.getPrompt({ name, arguments: args });
|
|
} catch (error) {
|
|
console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a tool call on a connection.
|
|
*/
|
|
static async callTool(
|
|
connection: MCPConnection,
|
|
params: ToolCallParams,
|
|
signal?: AbortSignal
|
|
): Promise<ToolExecutionResult> {
|
|
if (signal?.aborted) {
|
|
throw new DOMException('Aborted', 'AbortError');
|
|
}
|
|
|
|
try {
|
|
const result = await connection.client.callTool(
|
|
{ name: params.name, arguments: params.arguments },
|
|
undefined,
|
|
{ signal }
|
|
);
|
|
|
|
return {
|
|
content: this.formatToolResult(result as ToolCallResult),
|
|
isError: (result as ToolCallResult).isError ?? false
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
throw error;
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
throw new Error(`Tool execution failed: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format tool result content to string.
|
|
*/
|
|
private static formatToolResult(result: ToolCallResult): string {
|
|
const content = result.content;
|
|
if (!Array.isArray(content)) return '';
|
|
|
|
return content
|
|
.map((item) => this.formatSingleContent(item))
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
private static formatSingleContent(content: ToolResultContentItem): string {
|
|
if (content.type === 'text' && content.text) {
|
|
return content.text;
|
|
}
|
|
|
|
if (content.type === 'image' && content.data) {
|
|
return `data:${content.mimeType ?? 'image/png'};base64,${content.data}`;
|
|
}
|
|
|
|
if (content.type === 'resource' && content.resource) {
|
|
const resource = content.resource;
|
|
if (resource.text) return resource.text;
|
|
if (resource.blob) return resource.blob;
|
|
return JSON.stringify(resource);
|
|
}
|
|
|
|
if (content.data && content.mimeType) {
|
|
return `data:${content.mimeType};base64,${content.data}`;
|
|
}
|
|
|
|
return JSON.stringify(content);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Completions Operations
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Request completion suggestions from a server.
|
|
* Used for autocompleting prompt arguments or resource URI templates.
|
|
*
|
|
* @param connection - The MCP connection to use
|
|
* @param ref - Reference to the prompt or resource template
|
|
* @param argument - The argument being completed (name and current value)
|
|
* @returns Completion result with suggested values
|
|
*/
|
|
static async complete(
|
|
connection: MCPConnection,
|
|
ref: { type: 'ref/prompt'; name: string } | { type: 'ref/resource'; uri: string },
|
|
argument: { name: string; value: string }
|
|
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
|
|
try {
|
|
const result = await connection.client.complete({
|
|
ref,
|
|
argument
|
|
});
|
|
return result.completion;
|
|
} catch (error) {
|
|
console.error(`[MCPService] Failed to get completions:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|