// Conversation MCP TypeScript Client Library v2.1.1 // Export JSON schemas for all MCP tools export * from './schemas'; // JSON-RPC types type JsonRpcId = number | string; interface JsonRpcRequest { jsonrpc: '2.0'; id: JsonRpcId; method: string; params: T; } interface JsonRpcResponse { jsonrpc: '2.0'; id: JsonRpcId; result?: R; error?: { code: number; message: string; data?: any; }; } // Unified API interfaces export interface SendMessageParams { userId: string; conversationId?: string; // Optional - omit for auto-creation content: string; role: 'user' | 'assistant'; title?: string; // Optional - used for auto-created conversations metadata?: Record; } export interface SendMessageResponse { conversationId: string; messageId: string; sequence: number; created: boolean; // true if conversation was auto-created title: string; timestamp: string; // ISO-8601 timestamp of message creation metadata?: Record; // Optional message metadata (e.g., model, secondOpinion) } export interface GetConversationParams { userId: string; conversationId: string; } export interface ConversationMetadata { conversationId: string; userId: string; title: string; messageCount: number; createdAt: string; updatedAt: string; lastMessage: string | null; metadata?: Record; } export interface GetHistoryParams { userId: string; conversationId: string; limit?: number; cursor?: string; // messageId or sequence number for pagination } export interface Message { messageId: string; conversationId: string; content: string; role: 'user' | 'assistant'; sequence: number; timestamp: string; metadata?: Record; } export interface GetHistoryResponse { conversationId: string; messages: Message[]; hasMore: boolean; nextCursor?: string; } export interface ListConversationsParams { userId: string; pageSize?: number; cursor?: string; // conversationId for pagination } export interface ConversationSummary { conversationId: string; title: string; messageCount: number; createdAt: string; updatedAt: string; metadata?: Record; // Optional conversation-level metadata } export interface ListConversationsResponse { conversations: ConversationSummary[]; totalCount: number; hasMore: boolean; nextCursor?: string; } // Validation and normalization utilities /** * Maps snake_case to camelCase field names and returns both original and normalized keys. * Handles all characters after underscores (lowercase, uppercase, digits, etc.) */ function normalizeKey(key: string): { normalized: string; wasNormalized: boolean } { const camelCase = key.replace(/_(.)/g, (_, letter) => letter.toUpperCase()); return { normalized: camelCase, wasNormalized: camelCase !== key }; } /** * Normalizes an object from snake_case to camelCase, logging warnings for any conversions. * Detects field collisions and unexpected fields. */ function normalizeParams>( params: Record, methodName: string, allowedFields: Set ): T { const normalized: Record = {}; const normalizedWarnings: string[] = []; const unexpectedAllowedFields: string[] = []; const unexpectedNormalizedFields: string[] = []; const collisions: string[] = []; // First pass: detect collisions const collisionMap = new Map(); for (const key of Object.keys(params)) { const { normalized: normalizedKey, wasNormalized } = normalizeKey(key); if (wasNormalized && normalizedKey in params) { collisionMap.set(normalizedKey, { snake: key, camel: normalizedKey }); } } // Second pass: normalize and apply values, ensuring camelCase wins for collisions for (const [key, value] of Object.entries(params)) { const { normalized: normalizedKey, wasNormalized } = normalizeKey(key); // Skip snake_case version if collision detected (camelCase will be used) if (wasNormalized && collisionMap.has(normalizedKey)) { const collision = collisionMap.get(normalizedKey)!; if (key === collision.snake) { // This is the snake_case version, skip it (camelCase wins) if (!collisions.includes(`${collision.snake} and ${collision.camel}`)) { collisions.push(`${collision.snake} and ${collision.camel}`); } continue; } } // Track normalization (only for non-collision snake_case) if (wasNormalized && !collisionMap.has(normalizedKey)) { normalizedWarnings.push(`${key} → ${normalizedKey}`); } // Check for unexpected fields - separate tracking for normalized vs original if (!allowedFields.has(normalizedKey)) { if (wasNormalized) { unexpectedNormalizedFields.push(key); // Show original snake_case name } else { unexpectedAllowedFields.push(normalizedKey); // Show camelCase name } } normalized[normalizedKey] = value; } // Warn about field collisions first (most critical) if (collisions.length > 0) { console.warn( `[ConversationMCPClient] Field collision detected in ${methodName}:\n` + ` Both snake_case and camelCase versions provided: ${collisions.join(', ')}\n` + ` The camelCase version will be used. Please provide only one version.` ); } // Warn about snake_case normalization (only for valid fields) if (normalizedWarnings.length > 0) { const validNormalizedWarnings = normalizedWarnings.filter(w => { const camelField = w.split(' → ')[1]; return allowedFields.has(camelField); }); if (validNormalizedWarnings.length > 0) { const normalizedFields = validNormalizedWarnings.map(w => w.split(' → ')[1]); console.warn( `[ConversationMCPClient] Normalized snake_case parameters in ${methodName}:\n ${validNormalizedWarnings.join('\n ')}\n` + `Please update your code to use camelCase: ${normalizedFields.join(', ')}` ); } } // Warn about unexpected fields (already in camelCase) if (unexpectedAllowedFields.length > 0) { console.warn( `[ConversationMCPClient] Unexpected parameters in ${methodName}: ${unexpectedAllowedFields.join(', ')}\n` + `These fields are not recognized and will be sent to the server but may be ignored.` ); } // Warn about unexpected snake_case fields if (unexpectedNormalizedFields.length > 0) { console.warn( `[ConversationMCPClient] Unrecognized snake_case parameters in ${methodName}: ${unexpectedNormalizedFields.join(', ')}\n` + `These fields are not valid parameters for this method and will be sent to the server but likely ignored.` ); } return normalized as T; } /** * Validates a string field is non-empty after trimming */ function validateRequiredString(value: unknown, fieldName: string): string | null { if (typeof value !== 'string' || value.trim().length === 0) { return fieldName; } return null; } /** * Validates a number field is positive */ function validatePositiveNumber(value: unknown, fieldName: string): string | null { if (typeof value !== 'number' || value <= 0 || !Number.isFinite(value)) { return `${fieldName} (must be a positive number)`; } return null; } /** * Validates metadata is an object if provided */ function validateMetadata(value: unknown): string | null { if (value !== undefined && (typeof value !== 'object' || value === null || Array.isArray(value))) { return 'metadata (must be an object)'; } return null; } /** * Validates and normalizes SendMessageParams */ function validateSendMessageParams(params: any): SendMessageParams { const allowedFields = new Set(['userId', 'conversationId', 'content', 'role', 'title', 'metadata']); const normalized = normalizeParams(params, 'sendMessage', allowedFields); const missing: string[] = []; // Required fields const userIdError = validateRequiredString(normalized.userId, 'userId'); if (userIdError) missing.push(userIdError); const contentError = validateRequiredString(normalized.content, 'content'); if (contentError) missing.push(contentError); if (!normalized.role || !['user', 'assistant'].includes(normalized.role)) { missing.push('role (must be "user" or "assistant")'); } // Optional fields validation if (normalized.conversationId !== undefined) { const convIdError = validateRequiredString(normalized.conversationId, 'conversationId'); if (convIdError) missing.push(convIdError); } if (normalized.title !== undefined) { const titleError = validateRequiredString(normalized.title, 'title'); if (titleError) missing.push(titleError); } const metadataError = validateMetadata(normalized.metadata); if (metadataError) missing.push(metadataError); if (missing.length > 0) { throw new Error( `[ConversationMCPClient] sendMessage validation failed. Missing or invalid required parameters: ${missing.join(', ')}` ); } return normalized; } /** * Validates and normalizes GetConversationParams */ function validateGetConversationParams(params: any): GetConversationParams { const allowedFields = new Set(['userId', 'conversationId']); const normalized = normalizeParams(params, 'getConversation', allowedFields); const missing: string[] = []; const userIdError = validateRequiredString(normalized.userId, 'userId'); if (userIdError) missing.push(userIdError); const convIdError = validateRequiredString(normalized.conversationId, 'conversationId'); if (convIdError) missing.push(convIdError); if (missing.length > 0) { throw new Error( `[ConversationMCPClient] getConversation validation failed. Missing or invalid required parameters: ${missing.join(', ')}` ); } return normalized; } /** * Validates and normalizes GetHistoryParams */ function validateGetHistoryParams(params: any): GetHistoryParams { const allowedFields = new Set(['userId', 'conversationId', 'limit', 'cursor']); const normalized = normalizeParams(params, 'getHistory', allowedFields); const missing: string[] = []; const userIdError = validateRequiredString(normalized.userId, 'userId'); if (userIdError) missing.push(userIdError); const convIdError = validateRequiredString(normalized.conversationId, 'conversationId'); if (convIdError) missing.push(convIdError); // Optional fields validation if (normalized.limit !== undefined) { const limitError = validatePositiveNumber(normalized.limit, 'limit'); if (limitError) missing.push(limitError); } if (normalized.cursor !== undefined) { const cursorError = validateRequiredString(normalized.cursor, 'cursor'); if (cursorError) missing.push(cursorError); } if (missing.length > 0) { throw new Error( `[ConversationMCPClient] getHistory validation failed. Missing or invalid required parameters: ${missing.join(', ')}` ); } return normalized; } /** * Validates and normalizes ListConversationsParams */ function validateListConversationsParams(params: any): ListConversationsParams { const allowedFields = new Set(['userId', 'pageSize', 'cursor']); const normalized = normalizeParams(params, 'listConversations', allowedFields); const missing: string[] = []; const userIdError = validateRequiredString(normalized.userId, 'userId'); if (userIdError) missing.push(userIdError); // Optional fields validation if (normalized.pageSize !== undefined) { const pageSizeError = validatePositiveNumber(normalized.pageSize, 'pageSize'); if (pageSizeError) missing.push(pageSizeError); } if (normalized.cursor !== undefined) { const cursorError = validateRequiredString(normalized.cursor, 'cursor'); if (cursorError) missing.push(cursorError); } if (missing.length > 0) { throw new Error( `[ConversationMCPClient] listConversations validation failed. Missing or invalid required parameters: ${missing.join(', ')}` ); } return normalized; } /** * Conversation MCP Client v2.1.1 * * Unified API with auto-creation pattern: * - No sessionId required * - sendMessage() auto-creates conversation on first message * - Client stores conversationId returned from first sendMessage() * * Usage Pattern: * ```typescript * const client = new ConversationMCPClient('http://localhost:8080'); * * // First message - auto-creates conversation * const result1 = await client.sendMessage({ * userId: 'user123', * content: 'Hello!', * role: 'user', * title: 'My Chat' // optional * }); * const conversationId = result1.conversationId; * console.log('Created:', result1.created); // true * * // Subsequent messages - use conversationId * const result2 = await client.sendMessage({ * userId: 'user123', * conversationId, * content: 'How are you?', * role: 'assistant' * }); * console.log('Created:', result2.created); // false * ``` */ export class ConversationMCPClient { private readonly serverUrl: string; private requestId: number = 1; constructor(serverUrl: string) { this.serverUrl = serverUrl.replace(/\/+$/, ''); } /** * Send a message with auto-creation. * * If conversationId is omitted, automatically creates a new conversation. * If conversationId is provided, adds message to existing conversation. * * ⚠️ CRITICAL: Messages must be sent SEQUENTIALLY (await each call before the next). * * ✅ CORRECT: * const r1 = await client.sendMessage(msg1); * const r2 = await client.sendMessage(msg2); * * ❌ WRONG: (race condition!) * await Promise.all([ * client.sendMessage(msg1), * client.sendMessage(msg2) * ]); */ async sendMessage(params: SendMessageParams): Promise { const validated = validateSendMessageParams(params); return this.callTool('convo.send-message', validated); } /** * Get conversation metadata (does not include messages). * * Use getHistory() to retrieve messages. */ async getConversation(params: GetConversationParams): Promise { const validated = validateGetConversationParams(params); return this.callTool('convo.get-conversation', validated); } /** * Get conversation message history with pagination. * * Returns messages in chronological order (oldest first). * Use cursor from response to fetch next page. */ async getHistory(params: GetHistoryParams): Promise { const validated = validateGetHistoryParams(params); return this.callTool('convo.get-history', validated); } /** * List all conversations for a user with pagination. * * Returns conversations sorted by updatedAt DESC (most recent first). * Use cursor from response to fetch next page. */ async listConversations(params: ListConversationsParams): Promise { const validated = validateListConversationsParams(params); return this.callTool('convo.list-conversations', validated); } /** * Low-level JSON-RPC call. * * @throws {Error} Network errors, non-200 status, or JSON-RPC errors */ private async callTool(toolName: string, params: any): Promise { const requestId = this.requestId++; const payload: JsonRpcRequest = { jsonrpc: '2.0', id: requestId, method: 'tools/call', params: { name: toolName, arguments: params } }; const endpoint = `${this.serverUrl}/mcp`; try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (!response.ok) { const text = await response.text(); throw new Error( `MCP request failed with status ${response.status}: ${text}`, ); } const json: JsonRpcResponse = await response.json(); if (json.error) { const { code, message, data } = json.error; const extra = data ? ` | Data: ${JSON.stringify(data)}` : ''; throw new Error(`MCP JSON-RPC error ${code}: ${message}${extra}`); } // Parse content from MCP response format const rawResult = json.result?.content ?? json.result; return this.processToolResult(toolName, rawResult); } catch (err) { if (err instanceof Error) { throw err; } throw new Error(`MCP client error: ${String(err)}`); } } private processToolResult(toolName: string, rawResult: unknown): T { let payload = rawResult; // Handle MCP content blocks format (array of content blocks) if (Array.isArray(payload)) { if (payload.length === 0) { throw new Error('Empty MCP content blocks array'); } const firstBlock = payload[0]; if (firstBlock && typeof firstBlock === 'object' && 'type' in firstBlock && 'text' in firstBlock) { if (firstBlock.type === 'text' && typeof firstBlock.text === 'string') { try { payload = JSON.parse(firstBlock.text); } catch (parseError) { throw new Error(`Failed to parse MCP content block: ${parseError instanceof Error ? parseError.message : String(parseError)}`); } } else { throw new Error(`Unsupported MCP content block type: ${firstBlock.type}`); } } else { throw new Error('Invalid MCP content block format'); } } else if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (parseError) { throw new Error(`Failed to parse MCP response: ${parseError instanceof Error ? parseError.message : String(parseError)}`); } } if (payload && typeof payload === 'object') { const wrapper = payload as { success?: boolean; data?: unknown; error?: unknown }; if (typeof wrapper.success === 'boolean') { if (wrapper.success === false) { throw new Error(`MCP tool error: ${this.formatToolError(wrapper.error)}`); } if ('data' in wrapper) { if (wrapper.data === undefined) { throw new Error('MCP tool error: missing data in success response'); } this.validateToolResponse(toolName, wrapper.data); return wrapper.data as T; } } } this.validateToolResponse(toolName, payload); return payload as T; } private validateToolResponse(toolName: string, payload: unknown): void { if (!payload || typeof payload !== 'object') { throw new Error(`[ConversationMCPClient] Server returned invalid ${toolName} response: expected object, got ${typeof payload}`); } const data = payload as Record; switch (toolName) { case 'convo.send-message': this.validateSendMessageResponse(data); break; case 'convo.get-conversation': this.validateConversationMetadataResponse(data); break; case 'convo.get-history': this.validateGetHistoryResponse(data); break; case 'convo.list-conversations': this.validateListConversationsResponse(data); break; } } private validateSendMessageResponse(data: Record): void { const errors: string[] = []; if (typeof data.messageId !== 'string' || data.messageId.trim().length === 0) { errors.push('messageId (required string)'); } if (typeof data.conversationId !== 'string' || data.conversationId.trim().length === 0) { errors.push('conversationId (required string)'); } if (typeof data.sequence !== 'number' || !Number.isFinite(data.sequence) || data.sequence < 0) { errors.push('sequence (required non-negative finite number)'); } if (typeof data.created !== 'boolean') { errors.push('created (required boolean)'); } if (typeof data.title !== 'string' || data.title.trim().length === 0) { errors.push('title (required non-empty string)'); } if (typeof data.timestamp !== 'string' || data.timestamp.trim().length === 0) { errors.push('timestamp (required ISO-8601 string)'); } if (errors.length > 0) { throw new Error( `[ConversationMCPClient] Invalid sendMessage response. Missing or invalid fields: ${errors.join(', ')}` ); } } private validateConversationMetadataResponse(data: Record): void { const errors: string[] = []; if (typeof data.conversationId !== 'string' || data.conversationId.trim().length === 0) { errors.push('conversationId (required string)'); } if (typeof data.userId !== 'string' || data.userId.trim().length === 0) { errors.push('userId (required string)'); } if (typeof data.title !== 'string' || data.title.trim().length === 0) { errors.push('title (required non-empty string)'); } if (typeof data.messageCount !== 'number' || !Number.isFinite(data.messageCount) || data.messageCount < 0) { errors.push('messageCount (required non-negative finite number)'); } if (typeof data.createdAt !== 'string' || data.createdAt.trim().length === 0) { errors.push('createdAt (required ISO-8601 string)'); } if (typeof data.updatedAt !== 'string' || data.updatedAt.trim().length === 0) { errors.push('updatedAt (required ISO-8601 string)'); } if (errors.length > 0) { throw new Error( `[ConversationMCPClient] Invalid getConversation response. Missing or invalid fields: ${errors.join(', ')}` ); } } private validateGetHistoryResponse(data: Record): void { const errors: string[] = []; if (typeof data.conversationId !== 'string' || data.conversationId.trim().length === 0) { errors.push('conversationId (required string)'); } if (!Array.isArray(data.messages)) { errors.push('messages (required array)'); } else { // Validate each message in the array - all fields per Message interface data.messages.forEach((msg, idx) => { if (!msg || typeof msg !== 'object') { errors.push(`messages[${idx}] (must be an object)`); return; } const message = msg as Record; if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) { errors.push(`messages[${idx}].messageId (required string)`); } if (typeof message.conversationId !== 'string' || message.conversationId.trim().length === 0) { errors.push(`messages[${idx}].conversationId (required string)`); } if (typeof message.content !== 'string' || message.content.trim().length === 0) { errors.push(`messages[${idx}].content (required non-empty string)`); } if (typeof message.role !== 'string' || !['user', 'assistant'].includes(message.role)) { errors.push(`messages[${idx}].role (must be "user" or "assistant")`); } if (typeof message.sequence !== 'number' || !Number.isFinite(message.sequence) || message.sequence < 0) { errors.push(`messages[${idx}].sequence (required non-negative finite number)`); } if (typeof message.timestamp !== 'string' || message.timestamp.trim().length === 0) { errors.push(`messages[${idx}].timestamp (required ISO-8601 string)`); } }); } if (typeof data.hasMore !== 'boolean') { errors.push('hasMore (required boolean)'); } if (errors.length > 0) { throw new Error( `[ConversationMCPClient] Invalid getHistory response. Missing or invalid fields: ${errors.join(', ')}` ); } } private validateListConversationsResponse(data: Record): void { const errors: string[] = []; if (!Array.isArray(data.conversations)) { errors.push('conversations (required array)'); } else { // Validate each conversation in the array - all fields per ConversationSummary interface data.conversations.forEach((conv, idx) => { if (!conv || typeof conv !== 'object') { errors.push(`conversations[${idx}] (must be an object)`); return; } const conversation = conv as Record; if (typeof conversation.conversationId !== 'string' || conversation.conversationId.trim().length === 0) { errors.push(`conversations[${idx}].conversationId (required string)`); } if (typeof conversation.title !== 'string' || conversation.title.trim().length === 0) { errors.push(`conversations[${idx}].title (required non-empty string)`); } if (typeof conversation.messageCount !== 'number' || !Number.isFinite(conversation.messageCount) || conversation.messageCount < 0) { errors.push(`conversations[${idx}].messageCount (required non-negative finite number)`); } if (typeof conversation.createdAt !== 'string' || conversation.createdAt.trim().length === 0) { errors.push(`conversations[${idx}].createdAt (required ISO-8601 string)`); } if (typeof conversation.updatedAt !== 'string' || conversation.updatedAt.trim().length === 0) { errors.push(`conversations[${idx}].updatedAt (required ISO-8601 string)`); } }); } if (typeof data.totalCount !== 'number' || !Number.isFinite(data.totalCount) || data.totalCount < 0) { errors.push('totalCount (required non-negative finite number)'); } if (typeof data.hasMore !== 'boolean') { errors.push('hasMore (required boolean)'); } if (errors.length > 0) { throw new Error( `[ConversationMCPClient] Invalid listConversations response. Missing or invalid fields: ${errors.join(', ')}` ); } } private formatToolError(errorValue: unknown): string { if (typeof errorValue === 'string' && errorValue.trim().length > 0) { return errorValue; } if (errorValue && typeof errorValue === 'object') { const message = (errorValue as { message?: unknown }).message; if (typeof message === 'string' && message.trim().length > 0) { return message; } try { return JSON.stringify(errorValue); } catch { // fall through } } if (errorValue === undefined || errorValue === null) { return 'Unknown error'; } return String(errorValue); } }