diff --git a/.gitignore b/.gitignore index da3bf418..800cd4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ bun-debug.log* .npmrc -logs/ \ No newline at end of file +logs/ + +CLAUDE.md +start.txt \ No newline at end of file diff --git a/bun.lock b/bun.lock index 1b6da107..1e7ebfca 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "lint-staged": "^15.2.0", "msw": "^2.10.4", "tsdown": "^0.14.0", + "tsx": "^4.20.5", "turbo": "latest", "typescript": "^5.9.0", "unplugin-unused": "^0.5.1", diff --git a/package.json b/package.json index 6be26722..3f2c6dcd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lint-staged": "^15.2.0", "msw": "^2.10.4", "tsdown": "^0.14.0", + "tsx": "^4.20.5", "turbo": "latest", "typescript": "^5.9.0", "unplugin-unused": "^0.5.1", diff --git a/packages/mcp-connectors/src/connectors/UNIPILE_README.md b/packages/mcp-connectors/src/connectors/UNIPILE_README.md new file mode 100644 index 00000000..ec3e4339 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/UNIPILE_README.md @@ -0,0 +1,573 @@ +# Unipile MCP Connector + +A comprehensive Model Context Protocol (MCP) connector for the Unipile API, enabling multi-platform messaging integration with intelligent contact management, smart search capabilities, and persistent contact frequency tracking. + +## Overview + +The Unipile MCP Connector provides seamless access to messaging across multiple platforms including WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, and Facebook Messenger through a unified API. It features intelligent contact management with smart search capabilities, persistent memory for contact frequency tracking, and automated contact saving workflows. + +## Features + +### 🚀 Core Messaging Capabilities +- **Multi-platform support**: WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger +- **Account management**: Retrieve connected messaging accounts +- **Chat operations**: List chats, get messages, send messages +- **Email integration**: Access and manage emails +- **Real-time messaging**: Send messages with immediate delivery + +### 🧠 Intelligent Contact Management +- **Smart contact search**: Find contacts by name using advanced lexical search with BM25-like scoring +- **Progressive chat search**: Automatically searches recent chats when contacts aren't found locally +- **Contact CRUD operations**: Save, update, and manage contact information with rich fields +- **Persistent frequency tracking**: Automatically tracks message frequency using MCP's built-in persistence +- **Smart ranking**: Sorts contacts by message count and recent activity +- **Memory management**: Clear or view stored contact data with complete field information + +### 🔍 Advanced Search Capabilities +- **Lexical search engine**: Powered by Orama with intelligent relevance scoring +- **Multi-stage search**: Searches stored contacts first, then progressively searches chat history +- **Time-based search**: Configurable time ranges (last week → last month) for chat history search +- **High-confidence matching**: Automatic confidence scoring for search results +- **Smart recommendations**: Provides next steps when no matches are found + +### 🔧 Technical Features +- **LLM-optimized responses**: API responses are cleaned and filtered to include only relevant fields +- **Client-side batch limiting**: Implements custom batch_size filtering for efficient data retrieval +- **Correct API integration**: Uses `X-API-Key` authentication (not Bearer token) +- **Proper response handling**: Updated interfaces match real Unipile API responses +- **Error handling**: Comprehensive error reporting and recovery +- **Type safety**: Full TypeScript support with proper interfaces +- **Schema consistency**: Unified contact data structure across all tools + +## Installation + +```bash +npm install @stackone/mcp-connectors +``` + +## Configuration + +### Credentials Required + +1. **Unipile DSN**: Your Unipile endpoint (e.g., `your-endpoint.unipile.com:port/api/v1`) +2. **API Key**: Your Unipile API key from the dashboard + +### Example Setup + +```json +{ + "dsn": "your-endpoint.unipile.com:port/api/v1", + "apiKey": "your-api-key-here" +} +``` + +## Available Tools + +### 📋 Account & Chat Management + +#### `unipile_get_accounts` +Get all connected messaging accounts from supported platforms. + +```typescript +// No parameters required +// Returns: List of connected accounts with sources and connection details +``` + +#### `unipile_get_chats` +Get all available chats for a specific account. + +```typescript +{ + account_id: string, // Account ID to get chats from + limit?: number // Max chats to return (default: 10) +} +``` + +#### `unipile_get_chat_messages` +Get messages from a specific chat with LLM-optimized filtering. + +```typescript +{ + chat_id: string, // Chat ID to get messages from + batch_size?: number // Number of messages (default: 100, applied client-side) +} +``` + +**Filtered Response**: Returns only essential fields (`id`, `text`, `timestamp`, `is_sender`, `has_attachments`, `quoted_text`) for cleaner LLM consumption. + +#### `unipile_get_recent_messages` +Get recent messages from all chats in an account. + +```typescript +{ + account_id: string, // Account ID for messages + batch_size?: number // Messages per chat (default: 20) +} +``` + +#### `unipile_get_emails` +Get recent emails from a specific account. + +```typescript +{ + account_id: string, // Account ID for emails + limit?: number // Max emails to return (default: 10) +} +``` + +### 💬 Messaging + +#### `unipile_send_message` +Send a text message with intelligent contact tracking. **IMPORTANT**: LLMs should use `unipile_search_contacts` first when user says "message [person name]". + +```typescript +{ + chat_id: string, // Chat ID to send message to + text: string, // Message text to send + contact_name?: string, // Contact name (automatically tracked) + platform?: string, // Platform type (stored in custom_fields if provided) + account_id?: string // Account ID (stored in custom_fields if provided) +} +``` + +**Automatic Features**: +- Increments `message_count` for existing contacts +- Creates minimal contact entries for new chats +- Preserves all existing contact data during updates + +**Example:** +```typescript +{ + "chat_id": "hxkfCnylUGmwBJy2nRvkSw", + "text": "Hello! How are you?", + "contact_name": "Marco Hack Hack", + "platform": "WHATSAPP", + "account_id": "your-account-id" +} +``` + +### 🧠 Contact Management Tools + +#### `unipile_search_contacts` +Intelligent contact search using lexical search with progressive fallback to chat history. + +```typescript +{ + query: string, // Search query (name, email, etc.) + account_type?: string, // Filter by platform (WHATSAPP, LINKEDIN, etc.) + account_id?: string // Filter by specific account +} +``` + +**Search Strategy**: +1. **Stage 1**: Search stored contacts using lexical search on name, phone, notes +2. **Stage 2**: Search recent chats (last 7 days, limit 20) if no high-confidence match +3. **Stage 3**: Search more chats (last 7 days, limit 100) if still no match +4. **Stage 4**: Search older chats (last 30 days, limit 100) as final attempt + +**Returns**: +```json +{ + "found_contacts": true, + "best_match": { + "id": "contact_id", + "name": "Felix Enslin", + "confidence": "high", + "source": "stored_contacts" + }, + "recommendation": "Use chat_id: contact_id to send messages", + "search_summary": "Found 1 high-confidence match in stored contacts" +} +``` + +#### `unipile_save_contact` +Save or update contact information with rich field support. + +```typescript +{ + name: string, // Contact name (required) + phone_number?: string, // Phone number + whatsapp_chat_id?: string, // WhatsApp chat ID (used as contact ID) + linkedin_chat_id?: string, // LinkedIn chat ID + email?: string, // Email address + notes?: string, // Personal notes + custom_fields?: object // Any additional fields +} +``` + +**Features**: +- Auto-generates contact ID from `whatsapp_chat_id`, `linkedin_chat_id`, `email`, or name +- Preserves existing `message_count` and `created_at` when updating +- Clean data storage (removes undefined fields) + +#### `unipile_update_contact` +Update specific fields of an existing contact by ID. + +```typescript +{ + contact_id: string, // Contact ID to update (required) + name?: string, // Updated name + phone_number?: string, // Updated phone + email?: string, // Updated email + notes?: string, // Updated notes + custom_fields?: object // Updated custom fields +} +``` + +**Features**: +- Updates only provided fields, preserves others +- Maintains `message_count` and contact history +- Returns error with available contact IDs if contact not found + +#### `unipile_get_all_stored_contacts` +Get all stored contacts with complete field information. + +```typescript +// No parameters required +``` + +**Returns**: +```json +{ + "contacts": [ + { + "id": "contact_id", + "name": "Felix Enslin", + "phone_number": "+1234567890", + "whatsapp_chat_id": "chat_id", + "email": "felix@example.com", + "notes": "Great contact", + "message_count": 15, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-02T15:30:00Z", + "custom_fields": {} + } + ], + "count": 1, + "fields_available": ["id", "name", "phone_number", "whatsapp_chat_id", "linkedin_chat_id", "email", "notes", "message_count", "created_at", "updated_at", "custom_fields"] +} +``` + +#### `unipile_clear_contact_memory` +Clear all stored contact data from persistent memory. + +```typescript +// No parameters required +// Returns: Success confirmation +``` + +## Usage Examples + +### Smart Contact Search & Messaging + +```typescript +// Step 1: Search for a contact by name +const searchResult = await unipile_search_contacts({ + query: "Felix", + account_type: "WHATSAPP" +}); + +// Step 2: Use the found contact to send message +if (searchResult.found_contacts) { + await unipile_send_message({ + chat_id: searchResult.best_match.id, + text: "Hello Felix! How are you?", + contact_name: searchResult.best_match.name + }); +} +``` + +### Contact Management Workflow + +```typescript +// Save a new contact with rich information +await unipile_save_contact({ + name: "Felix Enslin", + phone_number: "+1234567890", + whatsapp_chat_id: "chat_felix_123", + email: "felix@example.com", + notes: "Met at conference, interested in AI", + custom_fields: { + company: "TechCorp", + role: "CTO" + } +}); + +// Update contact information +await unipile_update_contact({ + contact_id: "chat_felix_123", + notes: "Now working on MCP connectors", + custom_fields: { + company: "NewCorp", + role: "CEO", + last_project: "MCP Integration" + } +}); + +// Search and find the contact +const result = await unipile_search_contacts({ + query: "Felix TechCorp" +}); +``` + +### Batch Operations + +```typescript +// Get all contacts to review +const allContacts = await unipile_get_all_stored_contacts(); +console.log(`Managing ${allContacts.count} contacts`); + +// Clear memory when needed (for privacy) +await unipile_clear_contact_memory(); +``` + +### Account Discovery + +```typescript +// Get all connected accounts +const accounts = await unipile_get_accounts(); + +// Get chats for specific account +const chats = await unipile_get_chats({ + account_id: "your-account-id", + limit: 20 +}); +``` + +## Data Persistence + +The connector uses MCP's built-in persistence system (`context.setData` and `context.getData`) to store contact frequency data. This ensures: + +- **Persistence across sessions**: Data survives application restarts +- **Automatic synchronization**: Changes are immediately persisted +- **Memory efficiency**: Only active contact data is stored +- **Privacy focused**: Data stays local to your MCP instance + +### Storage Structure + +```json +{ + "unipile_contacts": { + "contact_id_1": { + "id": "contact_id_1", + "name": "Felix Enslin", + "phone_number": "+1234567890", + "whatsapp_chat_id": "chat_felix_123", + "linkedin_chat_id": null, + "email": "felix@example.com", + "notes": "Met at AI conference, works on MCP", + "message_count": 15, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-02T15:30:00Z", + "custom_fields": { + "company": "TechCorp", + "role": "CTO", + "platform": "WHATSAPP", + "account_id": "account_123" + ... + } + } + } +} +``` + +**Schema Features**: +- **Unified structure**: All contact tools use the same schema +- **Rich fields**: Support for multiple contact methods and custom data +- **Automatic tracking**: `message_count`, `created_at`, `updated_at` managed automatically +- **Flexible storage**: `custom_fields` allows any additional data +- **Clean data**: Undefined fields are automatically removed + +## API Response Formats + +All responses are filtered and optimized for LLM consumption, removing unnecessary fields and providing clean, relevant data. + +### Cleaned Accounts Response +```json +{ + "accounts": [ + { + "id": "account_id", + "name": "Phone Number", + "type": "WHATSAPP", + "status": "OK", + "source_id": "source_id_MESSAGING", + "created_at": "2025-08-28T17:21:14.163Z" + } + ], + "count": 1 +} +``` + +### Cleaned Chats Response +```json +{ + "chats": [ + { + "id": "chat_id", + "name": "Felix Enslin", + "unread": 6, + "timestamp": "2025-08-28T17:49:58.000Z" + } + ], + "count": 1 +} +``` + +### Cleaned Messages Response +```json +{ + "messages": [ + { + "id": "msg_id", + "text": "Hello world", + "timestamp": "2025-08-28T17:49:58.000Z", + "is_sender": false, + "has_attachments": false, + "quoted_text": null + } + ], + "count": 1, + "total_available": 5 +} +``` + +**Filtering Benefits**: +- **Reduced noise**: Only essential fields for LLM processing +- **Consistent naming**: Standardized field names across tools +- **Better performance**: Smaller response sizes +- **Cleaner data**: Boolean conversions, fallback values for missing data + +## Authentication + +The connector uses `X-API-Key` header authentication (not Bearer tokens): + +```javascript +headers: { + 'X-API-Key': 'your-api-key', + 'Content-Type': 'application/json' +} +``` + +## Error Handling + +All tools return structured error responses: + +```json +{ + "error": "Failed to send message: Unauthorized" +} +``` + +Common error scenarios: +- Invalid API credentials +- Chat/account not found +- Network connectivity issues +- API rate limits +- Invalid message format + +## Platform Support + +| Platform | Send Messages | Read Messages | Account Info | +|----------|---------------|---------------|---------------| +| WhatsApp | ✅ | ✅ | ✅ | +| LinkedIn | ✅ | ✅ | ✅ | +| Slack | ✅ | ✅ | ✅ | +| Twitter/X | ✅ | ✅ | ✅ | +| Telegram | ✅ | ✅ | ✅ | +| Instagram | ✅ | ✅ | ✅ | +| Messenger | ✅ | ✅ | ✅ | +| Email | ❌ | ✅ | ✅ | + +## Best Practices + +### Smart Messaging Workflow +1. **Always search first**: Use `unipile_search_contacts` before sending messages to find the right contact +2. **Use progressive search**: The search automatically escalates from stored contacts to recent chats +3. **Save important contacts**: Use `unipile_save_contact` for frequently messaged people with rich information +4. **Handle search results**: Check `found_contacts` boolean and use the `recommendation` field + +### Contact Management +1. **Rich contact data**: Include phone numbers, emails, and notes when saving contacts +2. **Use custom fields**: Store additional context like company, role, or relationship +3. **Update incrementally**: Use `unipile_update_contact` to modify specific fields without losing data +4. **Regular maintenance**: Review all contacts periodically with `unipile_get_all_stored_contacts` + +### Search Optimization +1. **Specific queries**: Use names, companies, or unique identifiers for better search results +2. **Account filtering**: Use `account_type` to narrow search scope when needed +3. **Trust confidence scores**: High-confidence matches are usually accurate +4. **Follow recommendations**: The search provides next steps when no matches are found + +### Error Handling +1. **Parse JSON responses** to check for error fields +2. **Implement retry logic** for temporary failures +3. **Validate chat IDs** before attempting operations +4. **Log errors appropriately** for debugging + +## Troubleshooting + +### Common Issues + +**"Invalid credentials" error:** +- Verify your API key is correct +- Check that DSN format is correct (without `https://`) +- Ensure account has proper permissions + +**"Chat not found" error:** +- Verify chat ID exists and is accessible +- Check that account is properly connected +- Refresh chat list to get current IDs + +**Search not finding contacts:** +- Use `unipile_get_all_stored_contacts` to see what contacts are saved +- Try broader search terms or check spelling +- Use progressive search by not providing `account_type` filter +- Check if contact exists in recent chats with different variations + +**Contact data not saving:** +- Verify required `name` field is provided to `unipile_save_contact` +- Check that contact ID generation is working (uses whatsapp_chat_id, linkedin_chat_id, email, or name) +- Use `unipile_get_all_stored_contacts` to verify storage +- Ensure persistence is enabled in your MCP setup + +### Debug Mode + +Enable verbose logging to debug issues: +```javascript +console.log('CONTEXT', context); // In tool handlers +console.log('CREDENTIALS', credentials); +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add comprehensive tests +4. Update documentation +5. Submit a pull request + +## License + +This project is licensed under the MIT License. + +## Support + +For support and questions: +- Check the [Unipile API documentation](https://developer.unipile.com) +- Review the MCP framework documentation +- Open an issue in the repository + +--- + +**Version**: 2.0.0 +**Last Updated**: January 15, 2025 +**MCP Framework**: Compatible with MCP 1.0+ + +### Recent Updates (v2.0.0) +- ✅ **Smart Contact Search**: Lexical search with progressive fallback to chat history +- ✅ **Complete Contact Management**: Save, update, and manage contacts with rich fields +- ✅ **LLM-Optimized Responses**: Cleaned and filtered API responses +- ✅ **Client-side Batch Limiting**: Custom batch_size implementation +- ✅ **Schema Consistency**: Unified contact data structure across all tools +- ✅ **Automatic Message Tracking**: Intelligent contact frequency tracking \ No newline at end of file diff --git a/packages/mcp-connectors/src/connectors/elevenlabs.ts b/packages/mcp-connectors/src/connectors/elevenlabs.ts deleted file mode 100644 index b952f7a2..00000000 --- a/packages/mcp-connectors/src/connectors/elevenlabs.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { mcpConnectorConfig } from '@stackone/mcp-config-types'; -import { z } from 'zod'; - -const ELEVENLABS_API_BASE = 'https://api.elevenlabs.io/v1'; - -// Type definitions for ElevenLabs API responses -interface ElevenLabsVoice { - voice_id: string; - name: string; - category: string; - description?: string; - preview_url?: string; - available_for_tiers: string[]; - settings?: { - stability: number; - similarity_boost: number; - }; - samples?: ElevenLabsSample[]; -} - -interface ElevenLabsSample { - sample_id: string; - file_name: string; - mime_type: string; - size_bytes: number; - hash: string; -} - -interface ElevenLabsVoicesResponse { - voices: ElevenLabsVoice[]; -} - -interface ElevenLabsUser { - user_id: string; - subscription: Record; - available_characters: number; - used_characters: number; - can_extend_character_limit: boolean; - can_use_instant_voice_cloning: boolean; - can_use_professional_voice_cloning: boolean; - api_tier: string; -} - -interface ElevenLabsTranscriptionResult { - text: string; - language?: string; - duration?: number; - speakers?: unknown[]; -} - -// Helper function to make API calls to ElevenLabs -const makeElevenLabsRequest = async ( - endpoint: string, - apiKey: string, - options: RequestInit = {} -): Promise => { - const url = `${ELEVENLABS_API_BASE}${endpoint}`; - - return fetch(url, { - ...options, - headers: { - 'xi-api-key': apiKey, - 'Content-Type': 'application/json', - ...options.headers, - }, - }); -}; - -// Helper function to convert audio stream to base64 -const streamToBase64 = async (stream: ReadableStream): Promise => { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - - // Combine all chunks into a single Uint8Array - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const combined = new Uint8Array(totalLength); - let offset = 0; - - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.length; - } - - // Convert to base64 - const binary = String.fromCharCode(...combined); - return btoa(binary); -}; - -export const ElevenLabsConnectorConfig = mcpConnectorConfig({ - name: 'ElevenLabs', - key: 'elevenlabs', - logo: 'https://stackone-logos.com/api/elevenlabs/filled/svg', - version: '1.0.0', - credentials: z.object({ - apiKey: z - .string() - .describe( - 'ElevenLabs API Key :: xi_1234567890abcdefghijklmnopqrstuv :: https://elevenlabs.io/docs/api-reference/authentication' - ), - }), - setup: z.object({}), - examplePrompt: - 'Generate speech from the text "Hello, welcome to our AI-powered assistant!" using Rachel\'s voice, then list all available voices for future use.', - tools: (tool) => ({ - TEXT_TO_SPEECH: tool({ - name: 'text-to-speech', - description: - 'Convert text to speech using ElevenLabs. Returns base64-encoded audio data.', - schema: z.object({ - text: z.string().describe('The text to convert to speech'), - voice_id: z.string().optional().describe('Voice ID to use (default: Rachel)'), - model_id: z - .string() - .optional() - .describe('Model ID to use (default: eleven_multilingual_v2)'), - output_format: z - .enum([ - 'mp3_44100_128', - 'mp3_44100_64', - 'mp3_22050_32', - 'pcm_16000', - 'pcm_22050', - 'pcm_24000', - 'pcm_44100', - ]) - .optional() - .describe('Audio output format'), - stability: z.number().min(0).max(1).optional().describe('Voice stability (0-1)'), - similarity_boost: z - .number() - .min(0) - .max(1) - .optional() - .describe('Voice similarity boost (0-1)'), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const voiceId = args.voice_id || 'EXAVITQu4vr4xnSDxMaL'; // Rachel voice - const requestBody = { - text: args.text, - model_id: args.model_id || 'eleven_multilingual_v2', - voice_settings: { - stability: args.stability || 0.5, - similarity_boost: args.similarity_boost || 0.75, - }, - }; - - const response = await makeElevenLabsRequest( - `/text-to-speech/${voiceId}?output_format=${args.output_format || 'mp3_44100_128'}`, - apiKey, - { - method: 'POST', - body: JSON.stringify(requestBody), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - if (!response.body) { - throw new Error('No audio data received from ElevenLabs API'); - } - - const base64Audio = await streamToBase64(response.body); - - return JSON.stringify({ - success: true, - audio_base64: base64Audio, - format: args.output_format || 'mp3_44100_128', - voice_id: voiceId, - text_length: args.text.length, - message: - 'Audio generated successfully. Use the audio_base64 field to access the audio data.', - }); - } catch (error) { - console.error('Text-to-speech error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - LIST_VOICES: tool({ - name: 'list-voices', - description: 'Get a list of available voices from ElevenLabs', - schema: z.object({ - include_shared: z - .boolean() - .optional() - .describe('Include shared voices from the library'), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const response = await makeElevenLabsRequest('/voices', apiKey); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const data = (await response.json()) as ElevenLabsVoicesResponse; - - const voices = - data.voices?.map((voice) => ({ - voice_id: voice.voice_id, - name: voice.name, - category: voice.category, - description: voice.description || '', - preview_url: voice.preview_url, - available_for_tiers: voice.available_for_tiers, - settings: voice.settings, - })) || []; - - // If include_shared is true, also fetch shared voices - if (args.include_shared) { - try { - const sharedResponse = await makeElevenLabsRequest( - '/shared-voices', - apiKey - ); - if (sharedResponse.ok) { - const sharedData = - (await sharedResponse.json()) as ElevenLabsVoicesResponse; - const sharedVoices = - sharedData.voices?.map((voice) => ({ - voice_id: voice.voice_id, - name: voice.name, - category: 'shared', - description: voice.description || '', - preview_url: voice.preview_url, - available_for_tiers: voice.available_for_tiers, - settings: voice.settings, - })) || []; - voices.push(...sharedVoices); - } - } catch (sharedError) { - console.warn('Failed to fetch shared voices:', sharedError); - } - } - - return JSON.stringify({ - success: true, - voices, - count: voices.length, - }); - } catch (error) { - console.error('List voices error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - GET_VOICE: tool({ - name: 'get-voice', - description: 'Get detailed information about a specific voice', - schema: z.object({ - voice_id: z.string().describe('The voice ID to get information for'), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const response = await makeElevenLabsRequest( - `/voices/${args.voice_id}`, - apiKey - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const voice = (await response.json()) as ElevenLabsVoice; - - return JSON.stringify({ - success: true, - voice: { - voice_id: voice.voice_id, - name: voice.name, - category: voice.category, - description: voice.description || '', - preview_url: voice.preview_url, - available_for_tiers: voice.available_for_tiers, - settings: voice.settings, - samples: - voice.samples?.map((sample) => ({ - sample_id: sample.sample_id, - file_name: sample.file_name, - mime_type: sample.mime_type, - size_bytes: sample.size_bytes, - hash: sample.hash, - })) || [], - }, - }); - } catch (error) { - console.error('Get voice error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - SPEECH_TO_TEXT: tool({ - name: 'speech-to-text', - description: - 'Convert speech to text using ElevenLabs. Requires audio file URL or base64 data.', - schema: z.object({ - audio_base64: z.string().optional().describe('Base64-encoded audio data'), - audio_url: z.string().optional().describe('URL to audio file'), - model_id: z - .string() - .optional() - .describe('Model to use for transcription (default: scribe)'), - language: z - .string() - .optional() - .describe('Language code (auto-detected if not provided)'), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - if (!args.audio_base64 && !args.audio_url) { - throw new Error('Either audio_base64 or audio_url must be provided'); - } - - let audioData: Uint8Array; - - if (args.audio_base64) { - // Decode base64 to binary - const binaryString = atob(args.audio_base64); - audioData = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - audioData[i] = binaryString.charCodeAt(i); - } - } else if (args.audio_url) { - // Fetch audio from URL - const audioResponse = await fetch(args.audio_url); - if (!audioResponse.ok) { - throw new Error(`Failed to fetch audio from URL: ${audioResponse.status}`); - } - const arrayBuffer = await audioResponse.arrayBuffer(); - audioData = new Uint8Array(arrayBuffer); - } else { - throw new Error('No audio data provided'); - } - - // Create FormData for multipart request - const formData = new FormData(); - const audioBlob = new Blob([audioData], { type: 'audio/mpeg' }); - formData.append('audio', audioBlob, 'audio.mp3'); - formData.append('model', args.model_id || 'scribe'); - - if (args.language) { - formData.append('language', args.language); - } - - const response = await fetch(`${ELEVENLABS_API_BASE}/speech-to-text`, { - method: 'POST', - headers: { - 'xi-api-key': apiKey, - }, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const result = (await response.json()) as ElevenLabsTranscriptionResult; - - return JSON.stringify({ - success: true, - transcript: result.text || '', - language: result.language, - duration: result.duration, - speakers: result.speakers, - }); - } catch (error) { - console.error('Speech-to-text error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - GENERATE_SOUND_EFFECTS: tool({ - name: 'generate-sound-effects', - description: 'Generate sound effects from text description using ElevenLabs', - schema: z.object({ - text: z.string().describe('Description of the sound effect to generate'), - duration_seconds: z - .number() - .optional() - .describe('Duration in seconds (default: auto)'), - prompt_influence: z - .number() - .min(0) - .max(1) - .optional() - .describe('How closely to follow the prompt (0-1)'), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const requestBody = { - text: args.text, - duration_seconds: args.duration_seconds, - prompt_influence: args.prompt_influence || 0.3, - }; - - const response = await makeElevenLabsRequest('/sound-generation', apiKey, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - if (!response.body) { - throw new Error('No audio data received from ElevenLabs API'); - } - - const base64Audio = await streamToBase64(response.body); - - return JSON.stringify({ - success: true, - audio_base64: base64Audio, - description: args.text, - duration: args.duration_seconds, - message: - 'Sound effect generated successfully. Use the audio_base64 field to access the audio data.', - }); - } catch (error) { - console.error('Sound effects generation error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - GET_USER_INFO: tool({ - name: 'get-user-info', - description: 'Get user account information and usage statistics', - schema: z.object({}), - handler: async (_args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const response = await makeElevenLabsRequest('/user', apiKey); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const user = (await response.json()) as ElevenLabsUser; - - return JSON.stringify({ - success: true, - user: { - user_id: user.user_id, - subscription: user.subscription, - available_characters: user.available_characters, - used_characters: user.used_characters, - can_extend_character_limit: user.can_extend_character_limit, - can_use_instant_voice_cloning: user.can_use_instant_voice_cloning, - can_use_professional_voice_cloning: user.can_use_professional_voice_cloning, - api_tier: user.api_tier, - }, - }); - } catch (error) { - console.error('Get user info error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - }), -}); diff --git a/packages/mcp-connectors/src/connectors/unipile.spec.ts b/packages/mcp-connectors/src/connectors/unipile.spec.ts new file mode 100644 index 00000000..0fe19dd8 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/unipile.spec.ts @@ -0,0 +1,662 @@ +import type { MCPToolDefinition } from '@stackone/mcp-config-types'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { describe, expect, it } from 'vitest'; +import { createMockConnectorContext } from '../__mocks__/context'; +import { UnipileConnectorConfig } from './unipile'; + +describe('#UnipileConnector', () => { + describe('.GET_ACCOUNTS', () => { + describe('when credentials are valid', () => { + it('returns cleaned account data', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/accounts', () => { + return HttpResponse.json({ + items: [ + { + id: 'account-1', + name: 'Test WhatsApp Account', + type: 'WHATSAPP', + created_at: '2024-01-01T10:00:00Z', + sources: [{ id: 'source-1', status: 'OK' }], + extra_field: 'should_be_filtered_out', + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.GET_ACCOUNTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + }); + + const actual = await tool.handler({}, mockContext); + const response = JSON.parse(actual); + + expect(response.accounts).toHaveLength(1); + expect(response.accounts[0]).toEqual({ + id: 'account-1', + name: 'Test WhatsApp Account', + type: 'WHATSAPP', + status: 'OK', + source_id: 'source-1', + created_at: '2024-01-01T10:00:00Z', + }); + expect(response.count).toBe(1); + + server.close(); + }); + }); + + describe('when account has no sources', () => { + it('handles missing sources gracefully', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/accounts', () => { + return HttpResponse.json({ + items: [ + { + id: 'account-2', + name: 'Account Without Sources', + type: 'LINKEDIN', + created_at: '2024-01-01T10:00:00Z', + sources: [], + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.GET_ACCOUNTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + }); + + const actual = await tool.handler({}, mockContext); + const response = JSON.parse(actual); + + expect(response.accounts[0].status).toBe('UNKNOWN'); + expect(response.accounts[0].source_id).toBe('account-2'); + + server.close(); + }); + }); + }); + + describe('.GET_CHATS', () => { + describe('when account_id is provided', () => { + it('returns cleaned chat data', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/chats', () => { + return HttpResponse.json({ + items: [ + { + id: 'chat-1', + name: 'Felix Enslin', + unread_count: 2, + timestamp: '2024-01-01T10:00:00Z', + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.GET_CHATS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + }); + + const actual = await tool.handler( + { account_id: 'source-1', limit: 10 }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.chats).toHaveLength(1); + expect(response.chats[0]).toEqual({ + id: 'chat-1', + name: 'Felix Enslin', + unread: 2, + timestamp: '2024-01-01T10:00:00Z', + }); + expect(response.count).toBe(1); + + server.close(); + }); + }); + + describe('when chat has no name', () => { + it('uses fallback name', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/chats', () => { + return HttpResponse.json({ + items: [ + { + id: 'chat-2', + name: null, + unread_count: 0, + timestamp: '2024-01-01T10:00:00Z', + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.GET_CHATS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + }); + + const actual = await tool.handler({ account_id: 'source-1' }, mockContext); + const response = JSON.parse(actual); + + expect(response.chats[0].name).toBe('Unnamed Chat'); + + server.close(); + }); + }); + }); + + describe('.GET_CHAT_MESSAGES', () => { + describe('when chat_id is provided', () => { + it('returns cleaned message data', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/messages', () => { + return HttpResponse.json({ + items: [ + { + id: 'msg-1', + text: 'Hello there!', + timestamp: '2024-01-01T10:00:00Z', + is_sender: 1, + attachments: [{ type: 'image' }], + quoted: { text: 'Original message' }, + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.GET_CHAT_MESSAGES as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + }); + + const actual = await tool.handler( + { chat_id: 'chat-1', batch_size: 10 }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.messages).toHaveLength(1); + expect(response.messages[0]).toEqual({ + id: 'msg-1', + text: 'Hello there!', + timestamp: '2024-01-01T10:00:00Z', + is_sender: true, + has_attachments: true, + quoted_text: 'Original message', + }); + + server.close(); + }); + }); + + describe('when batch_size is provided', () => { + it('limits results to batch_size', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/messages', () => { + return HttpResponse.json({ + items: [ + { + id: 'msg-1', + text: 'Message 1', + timestamp: '2024-01-01T10:00:00Z', + is_sender: 0, + }, + { + id: 'msg-2', + text: 'Message 2', + timestamp: '2024-01-01T10:01:00Z', + is_sender: 1, + }, + { + id: 'msg-3', + text: 'Message 3', + timestamp: '2024-01-01T10:02:00Z', + is_sender: 0, + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.GET_CHAT_MESSAGES as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + }); + + const actual = await tool.handler( + { chat_id: 'chat-1', batch_size: 2 }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.messages).toHaveLength(2); + expect(response.count).toBe(2); + expect(response.total_available).toBe(3); + + server.close(); + }); + }); + }); + + describe('.SEND_MESSAGE', () => { + describe('when message is sent successfully', () => { + it('tracks message count and returns response', async () => { + const server = setupServer( + http.post( + 'https://api8.unipile.com:13851/chats/chat-1/messages', + async ({ request }) => { + const body = (await request.json()) as { text: string }; + expect(body.text).toBe('Hello Felix!'); + return HttpResponse.json({ id: 'sent-msg-1', status: 'sent' }); + } + ) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.SEND_MESSAGE as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + data: {}, + }); + + await tool.handler( + { + chat_id: 'chat-1', + text: 'Hello Felix!', + contact_name: 'Felix', + }, + mockContext + ); + + // Check that contact data was stored + expect(mockContext.setData).toHaveBeenCalledWith( + 'unipile_contacts', + expect.objectContaining({ + 'chat-1': expect.objectContaining({ + id: 'chat-1', + name: 'Felix', + message_count: 1, + }), + }) + ); + + server.close(); + }); + }); + + describe('when contact already exists', () => { + it('increments message count', async () => { + const server = setupServer( + http.post('https://api8.unipile.com:13851/chats/chat-1/messages', () => { + return HttpResponse.json({ id: 'sent-msg-1', status: 'sent' }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.SEND_MESSAGE as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + data: { + 'chat-1': { + id: 'chat-1', + name: 'Felix', + message_count: 5, + phone_number: '+1234567890', + created_at: '2024-01-01T09:00:00Z', + }, + }, + }); + + await tool.handler( + { + chat_id: 'chat-1', + text: 'Another message', + }, + mockContext + ); + + expect(mockContext.setData).toHaveBeenCalledWith( + 'unipile_contacts', + expect.objectContaining({ + 'chat-1': expect.objectContaining({ + message_count: 6, + }), + }) + ); + + server.close(); + }); + }); + }); + + describe('.SAVE_CONTACT', () => { + describe('when saving new contact', () => { + it('creates contact with generated ID', async () => { + const tool = UnipileConnectorConfig.tools.SAVE_CONTACT as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + data: {}, + }); + + const actual = await tool.handler( + { + name: 'Felix Enslin', + phone_number: '+1234567890', + whatsapp_chat_id: 'chat-felix-123', + email: 'felix@example.com', + notes: 'Great contact', + }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.success).toBe(true); + expect(response.contact_id).toBe('chat-felix-123'); + expect(response.contact.name).toBe('Felix Enslin'); + expect(response.contact.phone_number).toBe('+1234567890'); + expect(response.contact.message_count).toBe(0); + expect(mockContext.setData).toHaveBeenCalled(); + }); + }); + + describe('when updating existing contact', () => { + it('preserves existing message count and created_at', async () => { + const tool = UnipileConnectorConfig.tools.SAVE_CONTACT as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + data: { + 'chat-felix-123': { + id: 'chat-felix-123', + name: 'Felix', + message_count: 10, + created_at: '2024-01-01T09:00:00Z', + }, + }, + }); + + const actual = await tool.handler( + { + name: 'Felix Enslin Updated', + whatsapp_chat_id: 'chat-felix-123', + email: 'felix.new@example.com', + }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.contact.name).toBe('Felix Enslin Updated'); + expect(response.contact.message_count).toBe(10); + expect(response.contact.created_at).toBe('2024-01-01T09:00:00Z'); + expect(response.contact.email).toBe('felix.new@example.com'); + }); + }); + }); + + describe('.UPDATE_CONTACT', () => { + describe('when contact exists', () => { + it('updates specified fields only', async () => { + const tool = UnipileConnectorConfig.tools.UPDATE_CONTACT as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + data: { + 'contact-123': { + id: 'contact-123', + name: 'Original Name', + phone_number: '+1111111111', + email: 'old@example.com', + message_count: 5, + custom_fields: { company: 'OldCorp' }, + created_at: '2024-01-01T09:00:00Z', + }, + }, + }); + + const actual = await tool.handler( + { + contact_id: 'contact-123', + name: 'Updated Name', + email: 'new@example.com', + custom_fields: { company: 'NewCorp', role: 'Manager' }, + }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.success).toBe(true); + expect(response.contact.name).toBe('Updated Name'); + expect(response.contact.phone_number).toBe('+1111111111'); + expect(response.contact.email).toBe('new@example.com'); + expect(response.contact.message_count).toBe(5); + expect(response.contact.custom_fields).toEqual({ + company: 'NewCorp', + role: 'Manager', + }); + }); + }); + + describe('when contact does not exist', () => { + it('returns error with available contacts', async () => { + const tool = UnipileConnectorConfig.tools.UPDATE_CONTACT as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + data: { + 'contact-456': { id: 'contact-456', name: 'Other Contact' }, + }, + }); + + const actual = await tool.handler( + { + contact_id: 'nonexistent-contact', + name: 'Updated Name', + }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.error).toContain( + 'Contact with ID "nonexistent-contact" not found' + ); + expect(response.available_contacts).toEqual(['contact-456']); + }); + }); + }); + + describe('.GET_ALL_STORED_CONTACTS', () => { + describe('when contacts exist', () => { + it('returns all contact data with field info', async () => { + const tool = UnipileConnectorConfig.tools + .GET_ALL_STORED_CONTACTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + data: { + 'contact-1': { + id: 'contact-1', + name: 'Felix Enslin', + phone_number: '+1234567890', + message_count: 10, + }, + 'contact-2': { + id: 'contact-2', + name: 'Jane Doe', + email: 'jane@example.com', + message_count: 5, + }, + }, + }); + + const actual = await tool.handler({}, mockContext); + const response = JSON.parse(actual); + + expect(response.contacts).toHaveLength(2); + expect(response.count).toBe(2); + expect(response.fields_available).toContain('message_count'); + expect(response.fields_available).toContain('phone_number'); + }); + }); + }); + + describe('.SEARCH_CONTACTS', () => { + describe('when searching stored contacts', () => { + it('finds contacts using lexical search', async () => { + const tool = UnipileConnectorConfig.tools.SEARCH_CONTACTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + data: { + 'contact-1': { + id: 'contact-1', + name: 'Felix Enslin', + phone_number: '+1234567890', + whatsapp_chat_id: 'chat-felix-123', + }, + 'contact-2': { + id: 'contact-2', + name: 'John Doe', + email: 'john@example.com', + }, + }, + }); + + const actual = await tool.handler({ query: 'Felix' }, mockContext); + const response = JSON.parse(actual); + + expect(response.found_contacts).toBe(true); + expect(response.best_match.name).toBe('Felix Enslin'); + expect(response.best_match.confidence).toBe('high'); + expect(response.recommendation).toContain('Use chat_id: contact-1'); + }); + }); + + describe('when searching chat history', () => { + it('searches progressive time periods', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/chats', () => { + return HttpResponse.json({ + items: [ + { + id: 'chat-jiro-456', + name: 'Jiro Blogs', + unread_count: 1, + timestamp: '2024-01-01T10:00:00Z', + }, + ], + }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.SEARCH_CONTACTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + data: {}, + }); + + const actual = await tool.handler( + { + query: 'Jiro', + account_type: 'WHATSAPP', + }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.found_contacts).toBe(true); + expect(response.best_match.name).toBe('Jiro Blogs'); + expect(response.best_match.id).toBe('chat-jiro-456'); + + server.close(); + }); + }); + + describe('when no contacts found', () => { + it('returns helpful suggestions', async () => { + const server = setupServer( + http.get('https://api8.unipile.com:13851/chats', () => { + return HttpResponse.json({ items: [] }); + }) + ); + server.listen(); + + const tool = UnipileConnectorConfig.tools.SEARCH_CONTACTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, + data: {}, + }); + + const actual = await tool.handler({ query: 'NonexistentPerson' }, mockContext); + const response = JSON.parse(actual); + + expect(response.found_contacts).toBe(false); + expect(response.recommendation).toContain('No contacts found'); + expect(response.suggested_next_steps).toContain( + 'Try GET_RECENT_MESSAGES and search content' + ); + + server.close(); + }); + }); + }); + + describe('.CLEAR_CONTACT_MEMORY', () => { + describe('when clearing contacts', () => { + it('clears all stored contact data', async () => { + const tool = UnipileConnectorConfig.tools + .CLEAR_CONTACT_MEMORY as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const response = JSON.parse(actual); + + expect(response.success).toBe(true); + expect(response.message).toContain('Contact memory cleared'); + expect(mockContext.setData).toHaveBeenCalledWith('unipile_contacts', {}); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/mcp-connectors/src/connectors/unipile.ts b/packages/mcp-connectors/src/connectors/unipile.ts new file mode 100644 index 00000000..3b9b720e --- /dev/null +++ b/packages/mcp-connectors/src/connectors/unipile.ts @@ -0,0 +1,977 @@ +import { mcpConnectorConfig } from '@stackone/mcp-config-types'; +import { z } from 'zod'; +import { createIndex, search } from '../utils/lexical-search'; + +interface UnipileAccount { + id: string; + name: string; + type: string; + created_at: string; + sources: Array<{ + id: string; + status: string; + [key: string]: unknown; + }>; + connection_params?: any; + groups?: any[]; + [key: string]: unknown; +} + +interface UnipileAccountsResponse { + items: UnipileAccount[]; + cursor?: string; + [key: string]: unknown; +} + +interface UnipileChat { + id: string; + name: string | null; + type: number; + folder: string[]; + unread: number; + archived: number; + read_only: number; + timestamp: string; + account_id: string; + account_type?: string; + unread_count: number; + provider_id: string; + attendee_provider_id: string; + muted_until?: string | null; + [key: string]: unknown; +} + +interface UnipileChatsResponse { + items: UnipileChat[]; + cursor?: string; + [key: string]: unknown; +} + +interface UnipileMessage { + id: string; + text: string | null; + timestamp: string; + sender_id: string; + chat_id: string; + account_id: string; + provider_id: string; + chat_provider_id: string; + sender_attendee_id: string; + seen: number; + edited: number; + hidden: number; + deleted: number; + delivered: number; + is_sender: number; + is_event: number; + attachments: Array<{ + id: string; + type: string; + mimetype?: string; + size?: any; + gif?: boolean; + unavailable: boolean; + }>; + reactions: any[]; + seen_by: Record; + behavior?: any; + original?: string; + event_type?: number; + quoted?: { + text: string; + sender_id: string; + attachments: any[]; + provider_id: string; + }; + subject?: string | null; + chat_info?: { + id: string; + name: string; + account_type: string; + account_id: string; + }; + [key: string]: unknown; +} + +interface UnipileMessagesResponse { + items: UnipileMessage[]; + cursor?: string; + [key: string]: unknown; +} + +interface UnipileEmail { + id: string; + subject: string; + date: string; + role: string; + folders: string[]; + has_attachments: boolean; + from?: string; + to: string[]; + cc: string[]; + body_markdown?: string; + attachments?: Array<{ + name: string; + size: number; + type: string; + }>; + [key: string]: unknown; +} + +interface UnipileEmailsResponse { + items: UnipileEmail[]; + cursor?: string; + [key: string]: unknown; +} + +class UnipileClient { + private baseUrl: string; + private headers: { 'X-API-Key': string; 'Content-Type': string }; + + constructor(dsn: string, apiKey: string) { + this.baseUrl = dsn.startsWith('http') ? dsn : `https://${dsn}`; + this.headers = { + 'X-API-Key': apiKey, + 'Content-Type': 'application/json', + }; + } + + async getAccounts(): Promise { + const response = await fetch(`${this.baseUrl}/accounts`, { + headers: this.headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch accounts: ${response.statusText}`); + } + + return response.json() as Promise; + } + + async getChats(accountId: string, limit = 10): Promise { + const cleanAccountId = accountId.replace(/_[A-Z]+$/, ''); + const params = new URLSearchParams({ + account_id: cleanAccountId, + limit: limit.toString(), + }); + + const response = await fetch(`${this.baseUrl}/chats?${params}`, { + headers: this.headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.statusText}`); + } + + return response.json() as Promise; + } + + async getMessages(chatId: string, batchSize = 100): Promise { + const params = new URLSearchParams({ + chat_id: chatId, + batch_size: batchSize.toString(), + }); + + const response = await fetch(`${this.baseUrl}/messages?${params}`, { + headers: this.headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch messages: ${response.statusText}`); + } + + return response.json() as Promise; + } + + async getEmails(accountId: string, limit = 10): Promise { + const cleanAccountId = accountId.replace(/_[A-Z]+$/, ''); + const params = new URLSearchParams({ + account_id: cleanAccountId, + limit: limit.toString(), + }); + + const response = await fetch(`${this.baseUrl}/emails?${params}`, { + headers: this.headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch emails: ${response.statusText}`); + } + + return response.json() as Promise; + } + + async getAllMessages(accountId: string, limit = 10): Promise { + const chatsResponse = await this.getChats(accountId, limit); + const allMessages: UnipileMessage[] = []; + + for (const chat of chatsResponse.items) { + try { + const messagesResponse = await this.getMessages(chat.id); + const messagesWithChatInfo = messagesResponse.items.map((message) => ({ + ...message, + chat_info: { + id: chat.id, + name: chat.name || 'Unnamed', + account_type: chat.account_type || 'WHATSAPP', + account_id: chat.account_id, + }, + })); + allMessages.push(...messagesWithChatInfo); + } catch (error) { + // Continue with other chats if one fails + console.warn(`Failed to fetch messages for chat ${chat.id}:`, error); + } + } + + return allMessages; + } + + async sendMessage(chatId: string, text: string): Promise { + const response = await fetch(`${this.baseUrl}/chats/${chatId}/messages`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ + text: text, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send message: ${response.statusText}`); + } + + return response.json(); + } +} + +export const UnipileConnectorConfig = mcpConnectorConfig({ + name: 'Unipile', + key: 'unipile', + version: '1.0.0', + logo: 'https://stackone-logos.com/api/unipile/filled/svg', + credentials: z.object({ + dsn: z + .string() + .describe( + 'Unipile DSN endpoint :: api8.unipile.com:13851 :: Get from your Unipile dashboard' + ), + apiKey: z + .string() + .describe( + 'Unipile API Key :: your-api-key-here :: Get from your Unipile dashboard' + ), + }), + setup: z.object({}), + examplePrompt: + 'Get all connected accounts, list messages from LinkedIn, retrieve recent emails, and get chat messages from a specific conversation.', + tools: (tool) => ({ + GET_ACCOUNTS: tool({ + name: 'unipile_get_accounts', + description: + 'Get all connected messaging accounts from supported platforms: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Returns clean account details including ID, name, type, status, source_id, and creation date.', + schema: z.object({}), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + const response = await client.getAccounts(); + + // Transform response to include only useful fields for LLM + const cleanedAccounts = response.items.map((account) => ({ + id: account.id, + name: account.name, + type: account.type, + created_at: account.created_at, + status: account.sources?.[0]?.status || 'UNKNOWN', + source_id: account.sources?.[0]?.id || account.id, + })); + + return JSON.stringify({ + accounts: cleanedAccounts, + count: cleanedAccounts.length, + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get accounts: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + GET_CHATS: tool({ + name: 'unipile_get_chats', + description: + 'Get the most recent chats for a specific account, ordered by timestamp (newest first). Supports messaging platforms: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger.', + schema: z.object({ + account_id: z + .string() + .describe( + "The ID of the account to get chats from. Use the source ID from the account's sources array." + ), + + limit: z + .number() + .optional() + .describe('Maximum number of chats to return (default: 10)'), + }), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + const response = await client.getChats(args.account_id, args.limit); + + // Transform response to include only essential fields for LLM + const cleanedChats = response.items.map((chat) => ({ + id: chat.id, + name: chat.name || 'Unnamed Chat', + unread: chat.unread_count, + timestamp: chat.timestamp, + })); + + return JSON.stringify({ + chats: cleanedChats, + count: cleanedChats.length, + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get chats: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + GET_CHAT_MESSAGES: tool({ + name: 'unipile_get_chat_messages', + description: + 'Get messages from a specific chat conversation. Returns clean message data with text, timestamps, and sender info. Much more focused than GET_RECENT_MESSAGES.', + schema: z.object({ + chat_id: z.string().describe('The ID of the chat to get messages from'), + batch_size: z + .number() + .optional() + .describe('Number of messages to return (default: all available messages)'), + }), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + const response = await client.getMessages(args.chat_id, args.batch_size); + + // Transform response to include only essential fields for LLM + const items = response.items || response; + if (!Array.isArray(items)) { + return JSON.stringify({ + error: 'Unexpected response format', + raw_response: response, + }); + } + + const cleanedMessages = items.map((message) => ({ + id: message.id, + text: message.text || '[No text content]', + timestamp: message.timestamp, + is_sender: message.is_sender === 1, + has_attachments: message.attachments?.length > 0, + quoted_text: message.quoted?.text || null, + })); + + // Apply our own batch_size filtering if specified + const finalMessages = args.batch_size + ? cleanedMessages.slice(0, args.batch_size) + : cleanedMessages; + + return JSON.stringify({ + messages: finalMessages, + count: finalMessages.length, + total_available: cleanedMessages.length, + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get chat messages: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + GET_RECENT_MESSAGES: tool({ + name: 'unipile_get_recent_messages', + description: + 'Get recent messages from all chats associated with a specific account. ⚠️ WARNING: This returns A LOT of data from multiple chats. RECOMMENDED: Use GET_CHATS first to see available chats, then use GET_CHAT_MESSAGES for specific conversations. Only use this for broad message overview.', + schema: z.object({ + account_id: z + .string() + .describe( + "The source ID of the account to get messages from. Use the id from the account's sources array." + ), + batch_size: z + .number() + .optional() + .describe('Number of messages to return (default: all available messages)'), + }), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + const messages = await client.getAllMessages(args.account_id, args.batch_size); + + // Transform response to include only essential fields for LLM + const items = Array.isArray(messages) + ? messages + : (messages as any).items || []; + if (!Array.isArray(items)) { + return JSON.stringify({ + error: 'Unexpected response format', + raw_response: messages, + }); + } + + const cleanedMessages = items.map((message) => ({ + id: message.id, + text: message.text || '[No text content]', + timestamp: message.timestamp, + is_sender: message.is_sender === 1, + chat_name: message.chat_info?.name || 'Unknown Chat', + chat_id: message.chat_id, + has_attachments: message.attachments?.length > 0, + quoted_text: message.quoted?.text || null, + })); + + // Apply our own batch_size filtering if specified + const finalMessages = args.batch_size + ? cleanedMessages.slice(0, args.batch_size) + : cleanedMessages; + + return JSON.stringify({ + messages: finalMessages, + count: finalMessages.length, + total_available: cleanedMessages.length, + warning: + 'This tool returns data from multiple chats. Consider using GET_CHATS then GET_CHAT_MESSAGES for specific conversations.', + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get recent messages: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + GET_EMAILS: tool({ + name: 'unipile_get_emails', + description: + 'Get recent emails from a specific account. Returns email details including subject, body in markdown format, sender, recipients, attachments, and metadata. URLs are automatically removed from the email body for security.', + schema: z.object({ + account_id: z.string().describe('The ID of the account to get emails from'), + limit: z + .number() + .optional() + .describe('Maximum number of emails to return (default: 10)'), + }), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + const response = await client.getEmails(args.account_id, args.limit); + return JSON.stringify(response); + } catch (error) { + return JSON.stringify({ + error: `Failed to get emails: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + SEND_MESSAGE: tool({ + name: 'unipile_send_message', + description: + 'Send a text message to a specific chat. IMPORTANT: If user says "message [person name]", first use SEARCH_CONTACTS to find their chat_id, then use this tool. Works with all messaging platforms: WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Automatically tracks message count for all contacts.', + schema: z.object({ + chat_id: z.string().describe('The ID of the chat to send the message to'), + text: z.string().describe('The text message to send'), + contact_name: z + .string() + .optional() + .describe('Name of the contact (optional, helps with contact tracking)'), + platform: z + .string() + .optional() + .describe('Platform type (e.g., WHATSAPP, LINKEDIN) - stored in custom_fields'), + account_id: z + .string() + .optional() + .describe('Account ID - stored in custom_fields'), + }), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + + // Send the message + const response = await client.sendMessage(args.chat_id, args.text); + + // Track message count for any contact (automatic frequency tracking) + if (args.chat_id) { + const contactsData = + (await context.getData>('unipile_contacts')) || {}; + const now = new Date().toISOString(); + + if (contactsData[args.chat_id]) { + // Update existing contact - preserve all existing fields, just increment message count + contactsData[args.chat_id] = { + ...contactsData[args.chat_id], + message_count: (contactsData[args.chat_id].message_count || 0) + 1, + updated_at: now, + // Update name if provided and different + ...(args.contact_name && + args.contact_name !== contactsData[args.chat_id].name + ? { name: args.contact_name } + : {}), + }; + } else { + // Create minimal contact for message tracking (user can enrich later with SAVE_CONTACT) + contactsData[args.chat_id] = { + id: args.chat_id, + name: args.contact_name || 'Unknown Contact', + message_count: 1, + created_at: now, + updated_at: now, + // Store platform info in custom_fields to maintain schema consistency + custom_fields: { + platform: args.platform, + account_id: args.account_id, + }, + }; + } + + await context.setData('unipile_contacts', contactsData); + } + + return JSON.stringify(response); + } catch (error) { + return JSON.stringify({ + error: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + GET_ALL_STORED_CONTACTS: tool({ + name: 'unipile_get_all_stored_contacts', + description: + 'Get all stored contacts from MCP persistent memory with complete field details including phone_number, whatsapp_chat_id, linkedin_chat_id, custom_fields, and notes.', + schema: z.object({ + include_details: z + .boolean() + .optional() + .describe('Include full contact details (default: true)'), + }), + handler: async (args, context) => { + try { + // Use MCP's built-in persistence + const contactsData = + (await context.getData>('unipile_contacts')) || {}; + const contacts = Object.values(contactsData); + + if (args.include_details === false) { + // Return minimal info if requested + const minimalContacts = contacts.map((contact) => ({ + id: contact.id, + name: contact.name, + created_at: contact.created_at, + updated_at: contact.updated_at, + })); + return JSON.stringify({ contacts: minimalContacts, count: contacts.length }); + } + + return JSON.stringify({ + contacts, + count: contacts.length, + fields_available: [ + 'id', + 'name', + 'phone_number', + 'whatsapp_chat_id', + 'linkedin_chat_id', + 'email', + 'custom_fields', + 'notes', + 'message_count', + 'created_at', + 'updated_at', + ], + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get stored contacts: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + SAVE_CONTACT: tool({ + name: 'unipile_save_contact', + description: + 'Save contact details. Use this proactively to save frequently messaged contacts. Use phone_number (not "phone"), whatsapp_chat_id (not in custom_fields) for proper field mapping. When saving contacts from WhatsApp chats, ALWAYS include both phone_number and whatsapp_chat_id from the chat data.', + schema: z.object({ + name: z.string().describe('Contact name (required)'), + phone_number: z + .string() + .optional() + .describe('Phone number for WhatsApp/SMS - USE THIS FIELD, not "phone"'), + whatsapp_chat_id: z + .string() + .optional() + .describe( + 'WhatsApp chat ID from Unipile for direct messaging - USE THIS FIELD, not custom_fields' + ), + linkedin_chat_id: z + .string() + .optional() + .describe('LinkedIn chat ID from Unipile for direct messaging'), + email: z.string().optional().describe('Email address'), + custom_fields: z + .record(z.any()) + .optional() + .describe('Additional fields like company, birthday, etc. (key-value pairs)'), + notes: z.string().optional().describe('Additional notes about the contact'), + }), + handler: async (args, context) => { + try { + const contactsData = + (await context.getData>('unipile_contacts')) || {}; + const now = new Date().toISOString(); + + // Use whatsapp_chat_id as primary ID, fallback to phone_number or create a hash + const contactId = + args.whatsapp_chat_id || + args.linkedin_chat_id || + args.phone_number || + `contact_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Build the contact object + const contactData = { + id: contactId, + name: args.name, + phone_number: args.phone_number, + whatsapp_chat_id: args.whatsapp_chat_id, + linkedin_chat_id: args.linkedin_chat_id, + email: args.email, + notes: args.notes, + custom_fields: args.custom_fields || {}, + message_count: contactsData[contactId]?.message_count || 0, // Preserve existing message count + created_at: contactsData[contactId]?.created_at || now, + updated_at: now, + }; + + // Remove undefined fields to keep the data clean + Object.keys(contactData).forEach((key) => { + if ((contactData as any)[key] === undefined) { + delete (contactData as any)[key]; + } + }); + + contactsData[contactId] = contactData; + await context.setData('unipile_contacts', contactsData); + + return JSON.stringify({ + success: true, + message: `Contact "${args.name}" saved successfully`, + contact_id: contactId, + contact: contactData, + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to save contact: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + UPDATE_CONTACT: tool({ + name: 'unipile_update_contact', + description: + 'Update an existing contact with new information. Use contact_id from previously saved contacts. IMPORTANT: Use phone_number (not "phone"), whatsapp_chat_id (not in custom_fields) for proper field mapping. Only updates provided fields, preserves existing data.', + schema: z.object({ + contact_id: z + .string() + .describe('ID of the contact to update (from previous save/get operations)'), + name: z.string().optional().describe('Update contact name'), + phone_number: z + .string() + .optional() + .describe('Update phone number - USE THIS FIELD, not "phone"'), + whatsapp_chat_id: z + .string() + .optional() + .describe( + 'Update WhatsApp chat ID from Unipile - USE THIS FIELD, not custom_fields' + ), + linkedin_chat_id: z + .string() + .optional() + .describe('Update LinkedIn chat ID from Unipile'), + email: z.string().optional().describe('Update email address'), + custom_fields: z + .record(z.any()) + .optional() + .describe('Update or add custom fields (merges with existing)'), + notes: z.string().optional().describe('Update notes about the contact'), + }), + handler: async (args, context) => { + try { + const contactsData = + (await context.getData>('unipile_contacts')) || {}; + + if (!contactsData[args.contact_id]) { + return JSON.stringify({ + error: `Contact with ID "${args.contact_id}" not found`, + available_contacts: Object.keys(contactsData), + }); + } + + const existingContact = contactsData[args.contact_id]; + const now = new Date().toISOString(); + + // Build updated contact by merging new data with existing + const updatedContact = { + ...existingContact, + name: args.name ?? existingContact.name, + phone_number: args.phone_number ?? existingContact.phone_number, + whatsapp_chat_id: args.whatsapp_chat_id ?? existingContact.whatsapp_chat_id, + linkedin_chat_id: args.linkedin_chat_id ?? existingContact.linkedin_chat_id, + email: args.email ?? existingContact.email, + notes: args.notes ?? existingContact.notes, + custom_fields: { + ...(existingContact.custom_fields || {}), + ...(args.custom_fields || {}), + }, + message_count: existingContact.message_count || 0, // Preserve message count + updated_at: now, + }; + + // Remove undefined fields to keep data clean + Object.keys(updatedContact).forEach((key) => { + if ((updatedContact as any)[key] === undefined) { + delete (updatedContact as any)[key]; + } + }); + + contactsData[args.contact_id] = updatedContact; + await context.setData('unipile_contacts', contactsData); + + return JSON.stringify({ + success: true, + message: `Contact "${updatedContact.name}" updated successfully`, + contact_id: args.contact_id, + contact: updatedContact, + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to update contact: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + CLEAR_CONTACT_MEMORY: tool({ + name: 'unipile_clear_contact_memory', + description: 'Clear all stored contact frequency data from MCP persistent memory.', + schema: z.object({}), + handler: async (args, context) => { + try { + // Use MCP's built-in persistence + await context.setData('unipile_contacts', {}); + return JSON.stringify({ + success: true, + message: 'Contact memory cleared using MCP persistence', + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to clear contact memory: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + SEARCH_CONTACTS: tool({ + name: 'unipile_search_contacts', + description: + 'Smart contact search that finds people by name. First checks stored contacts, then progressively searches chat history (last week → last month) using intelligent text matching. Use this when user says "message [person name]" to find their chat_id for messaging.', + schema: z.object({ + query: z + .string() + .describe('Name or partial name to search for (e.g., "Jiro", "Jiro Blogs")'), + account_type: z + .enum([ + 'WHATSAPP', + 'LINKEDIN', + 'SLACK', + 'TWITTER', + 'MESSENGER', + 'INSTAGRAM', + 'TELEGRAM', + ]) + .optional() + .describe('Platform to search in (optional)'), + account_id: z + .string() + .optional() + .describe('Specific account ID to search in (optional)'), + }), + handler: async (args, context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const searchResults: any[] = []; + + // Stage 1: Search stored contacts first (instant) + const contactsData = + (await context.getData>('unipile_contacts')) || {}; + const storedContacts = Object.values(contactsData); + + if (storedContacts.length > 0) { + const contactIndex = await createIndex(storedContacts, { + fields: ['name', 'phone_number', 'notes'], + threshold: 0.1, + maxResults: 5, + }); + const contactMatches = await search(contactIndex, args.query); + + if (contactMatches.length > 0) { + searchResults.push({ + source: 'stored_contacts', + matches: contactMatches.map((match) => ({ + ...match.item, + score: match.score, + confidence: 'high', + })), + }); + } + } + + // Stage 2-4: Progressive chat search (last week → last month) + const now = new Date(); + const searchStages = [ + { period: '7 days', limit: 20, days: 7 }, + { period: '7 days', limit: 100, days: 7 }, + { period: '30 days', limit: 100, days: 30 }, + ]; + + for (const stage of searchStages) { + // Only search chats if we haven't found high-confidence matches yet + const hasHighConfidenceMatch = searchResults.some((result) => + result.matches.some((match: any) => match.score > 0.5) + ); + + if (hasHighConfidenceMatch) break; + + const afterDate = new Date(now.getTime() - stage.days * 24 * 60 * 60 * 1000); + + // Build API call directly since getChats method doesn't support all parameters + const chatsParams = new URLSearchParams({ + limit: stage.limit.toString(), + after: afterDate.toISOString(), + }); + + if (args.account_type) chatsParams.append('account_type', args.account_type); + if (args.account_id) chatsParams.append('account_id', args.account_id); + + const baseUrl = dsn.startsWith('http') ? dsn : `https://${dsn}`; + const response = await fetch(`${baseUrl}/chats?${chatsParams.toString()}`, { + headers: { + 'X-API-Key': apiKey, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`API call failed: ${response.status}`); + } + + const chatsResponse = (await response.json()) as UnipileChatsResponse; + const chats = chatsResponse.items || []; + + if (chats.length > 0) { + const chatIndex = await createIndex(chats, { + fields: ['name'], + threshold: 0.1, + maxResults: 10, + }); + const chatMatches = await search(chatIndex, args.query); + + if (chatMatches.length > 0) { + searchResults.push({ + source: `chats_${stage.period}_${stage.limit}`, + period: stage.period, + searched_chats: chats.length, + matches: chatMatches.map((match) => ({ + id: match.item.id, + name: match.item.name || 'Unknown Chat', + unread: match.item.unread_count, + timestamp: match.item.timestamp, + score: match.score, + confidence: match.score > 0.5 ? 'high' : 'medium', + })), + }); + } + } + } + + // Summary and recommendations + const allMatches = searchResults.flatMap((result) => result.matches); + const bestMatch = allMatches.reduce( + (best, current) => (current.score > (best?.score || 0) ? current : best), + null + ); + + return JSON.stringify({ + query: args.query, + found_contacts: allMatches.length > 0, + best_match: bestMatch, + search_stages: searchResults, + total_matches: allMatches.length, + recommendation: bestMatch + ? `Found "${bestMatch.name}" with ${bestMatch.confidence} confidence. Use chat_id: ${bestMatch.id || bestMatch.whatsapp_chat_id}` + : `No contacts found matching "${args.query}". You may need to search message content or this person hasn't been messaged recently.`, + suggested_next_steps: bestMatch + ? [ + `SEND_MESSAGE with chat_id: ${bestMatch.id || bestMatch.whatsapp_chat_id}`, + ] + : [ + `Try GET_RECENT_MESSAGES and search content`, + `Use SAVE_CONTACT to add this person manually`, + ], + }); + } catch (error) { + return JSON.stringify({ + error: `Contact search failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + }), + resources: (resource) => ({ + ACCOUNTS: resource({ + name: 'unipile_accounts', + description: + 'List of connected messaging accounts from supported platforms: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger', + uri: 'unipile://accounts', + mimeType: 'application/json', + handler: async (context) => { + try { + const { dsn, apiKey } = await context.getCredentials(); + const client = new UnipileClient(dsn, apiKey); + const response = await client.getAccounts(); + + // Transform response to include only useful fields for LLM + const cleanedAccounts = response.items.map((account) => ({ + id: account.id, + name: account.name, + type: account.type, + created_at: account.created_at, + status: account.sources?.[0]?.status || 'UNKNOWN', + source_id: account.sources?.[0]?.id || account.id, + })); + + return JSON.stringify({ + accounts: cleanedAccounts, + count: cleanedAccounts.length, + }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get accounts: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + }), +}); diff --git a/packages/mcp-connectors/src/index.ts b/packages/mcp-connectors/src/index.ts index bd784f61..e72fa1e8 100644 --- a/packages/mcp-connectors/src/index.ts +++ b/packages/mcp-connectors/src/index.ts @@ -9,7 +9,7 @@ import { DeelConnectorConfig } from './connectors/deel'; import { DeepseekConnectorConfig } from './connectors/deepseek'; import { DocumentationConnectorConfig } from './connectors/documentation'; import { DuckDuckGoConnectorConfig } from './connectors/duckduckgo'; -import { ElevenLabsConnectorConfig } from './connectors/elevenlabs'; + import { ExaConnectorConfig } from './connectors/exa'; import { FalConnectorConfig } from './connectors/fal'; import { FirefliesConnectorConfig } from './connectors/fireflies'; @@ -39,6 +39,7 @@ import { TestConnectorConfig } from './connectors/test'; import { TinybirdConnectorConfig } from './connectors/tinybird'; import { TodoistConnectorConfig } from './connectors/todoist'; import { TurbopufferConnectorConfig } from './connectors/turbopuffer'; +import { UnipileConnectorConfig } from './connectors/unipile'; import { WandbConnectorConfig } from './connectors/wandb'; import { XeroConnectorConfig } from './connectors/xero'; @@ -53,7 +54,6 @@ export const Connectors: readonly MCPConnectorConfig[] = [ DeepseekConnectorConfig, DocumentationConnectorConfig, DuckDuckGoConnectorConfig, - ElevenLabsConnectorConfig, ExaConnectorConfig, FalConnectorConfig, GitHubConnectorConfig, @@ -81,6 +81,7 @@ export const Connectors: readonly MCPConnectorConfig[] = [ TinybirdConnectorConfig, TodoistConnectorConfig, TurbopufferConnectorConfig, + UnipileConnectorConfig, WandbConnectorConfig, XeroConnectorConfig, ] as const; @@ -96,7 +97,6 @@ export { DeepseekConnectorConfig, DocumentationConnectorConfig, DuckDuckGoConnectorConfig, - ElevenLabsConnectorConfig, ExaConnectorConfig, FalConnectorConfig, GitHubConnectorConfig, @@ -124,6 +124,7 @@ export { TinybirdConnectorConfig, TodoistConnectorConfig, TurbopufferConnectorConfig, + UnipileConnectorConfig, WandbConnectorConfig, XeroConnectorConfig, }; diff --git a/scripts/start-server.ts b/scripts/start-server.ts new file mode 100644 index 00000000..45cab379 --- /dev/null +++ b/scripts/start-server.ts @@ -0,0 +1,364 @@ +import { randomUUID } from 'node:crypto'; +import { parseArgs } from 'node:util'; +import { StreamableHTTPTransport } from '@hono/mcp'; +import { serve } from '@hono/node-server'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ConnectorContext, MCPConnectorConfig } from '@stackone/mcp-config-types'; +import { Connectors } from '@stackone/mcp-connectors'; +import { Hono } from 'hono'; +import { logger } from 'hono/logger'; + +// Helper to format timestamps for logs +const getTimestamp = () => new Date().toISOString(); + +// Custom logger format +const customLogger = ( + message: string, + level: 'info' | 'error' | 'debug' | 'warn' = 'info' +) => { + const timestamp = getTimestamp(); + const prefix = { + info: '📘', + error: '❌', + debug: '🔍', + warn: '⚠️', + }[level]; + console.log(`[${timestamp}] ${prefix} ${message}`); +}; + +const getConnectorByKey = (connectorKey: string): MCPConnectorConfig | null => { + const connector = Connectors.find((c) => c.key === connectorKey) as MCPConnectorConfig; + return connector || null; +}; + +const createRuntimeConnectorContext = ( + credentials: Record = {}, + setup: Record = {} +): ConnectorContext => { + const dataStore = new Map(); + const cacheStore = new Map(); + + return { + getCredentials: async () => credentials, + getSetup: async () => setup, + getData: async (key?: string): Promise => { + if (key === undefined) { + return Object.fromEntries(dataStore) as T; + } + return (dataStore.get(key) as T) || null; + }, + setData: async ( + keyOrData: string | Record, + value?: unknown + ): Promise => { + if (typeof keyOrData === 'string') { + dataStore.set(keyOrData, value); + } else { + for (const [k, v] of Object.entries(keyOrData)) { + dataStore.set(k, v); + } + } + }, + readCache: async (key: string): Promise => { + return cacheStore.get(key) || null; + }, + writeCache: async (key: string, value: string): Promise => { + cacheStore.set(key, value); + }, + }; +}; + +const printUsage = () => { + console.log('🚀 MCP Connector Server'); + console.log(''); + console.log('Usage: bun start --connector [options]'); + console.log(''); + console.log('Options:'); + console.log(' --connector Connector key (required)'); + console.log(' --credentials JSON string with connector credentials'); + console.log(' --setup JSON string with connector setup configuration'); + console.log(' --port Port to run server on (default: 3000)'); + console.log(' --help Show this help message'); + console.log(''); + console.log(`Available connectors (${Connectors.length}):`); + const sortedConnectors = Connectors.map((c) => c.key).sort(); + console.log(sortedConnectors.join(', ')); + console.log(''); + console.log('Examples:'); + console.log(' bun start --connector test'); + console.log(' bun start --connector asana --credentials \'{"apiKey":"sk-xxx"}\''); + console.log( + ' bun start --connector github --credentials \'{"token":"ghp_xxx"}\' --setup \'{"org":"myorg"}\'' + ); +}; + +const startServer = async (): Promise<{ app: Hono; port: number }> => { + const app = new Hono(); + + // Add request logging middleware + app.use( + logger((str, ..._rest) => { + customLogger(`Request: ${str}`, 'info'); + }) + ); + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + connector: { + type: 'string', + short: 'c', + }, + credentials: { + type: 'string', + }, + setup: { + type: 'string', + }, + port: { + type: 'string', + default: '3000', + }, + help: { + type: 'boolean', + short: 'h', + }, + }, + strict: true, + allowPositionals: true, + }); + + if (values.help) { + printUsage(); + process.exit(0); + } + + const connectorKey = values.connector; + + if (!connectorKey) { + console.error('❌ Connector key is required'); + console.log(''); + printUsage(); + process.exit(1); + } + + const connectorConfig = getConnectorByKey(connectorKey); + + if (!connectorConfig) { + console.error(`❌ Connector "${connectorKey}" not found`); + console.log(''); + console.log(`Available connectors (${Connectors.length}):`); + console.log( + Connectors.map((c) => c.key) + .sort() + .join(', ') + ); + process.exit(1); + } + + let credentials = {}; + let setup = {}; + + if (values.credentials) { + try { + credentials = JSON.parse(values.credentials); + } catch (error) { + console.error( + '❌ Invalid credentials JSON:', + error instanceof Error ? error.message : String(error) + ); + process.exit(1); + } + } + + if (values.setup) { + try { + setup = JSON.parse(values.setup); + } catch (error) { + console.error( + '❌ Invalid setup JSON:', + error instanceof Error ? error.message : String(error) + ); + process.exit(1); + } + } + + const context = createRuntimeConnectorContext(credentials, setup); + + const server = new McpServer({ + name: `${connectorConfig.name} MCP Server (disco.dev)`, + version: connectorConfig.version, + }); + + for (const tool of Object.values(connectorConfig.tools)) { + server.tool(tool.name, tool.description, tool.schema.shape, async (args: unknown) => { + const startTime = Date.now(); + customLogger(`Tool invoked: ${tool.name}`, 'info'); + customLogger(`Tool args: ${JSON.stringify(args, null, 2)}`, 'debug'); + + try { + const result = await tool.handler(args, context); + const duration = Date.now() - startTime; + customLogger(`Tool completed: ${tool.name} (${duration}ms)`, 'info'); + { + let resultPreview: string; + if (typeof result === 'string') { + resultPreview = result.substring(0, 200) + (result.length > 200 ? '...' : ''); + } else if (result !== undefined && result !== null) { + const strResult = + typeof result === 'object' ? JSON.stringify(result) : String(result); + resultPreview = + strResult.substring(0, 200) + (strResult.length > 200 ? '...' : ''); + } else { + resultPreview = String(result); + } + customLogger(`Tool result preview: ${resultPreview}`, 'debug'); + } + + return { + content: [{ type: 'text' as const, text: String(result) }], + }; + } catch (error) { + const duration = Date.now() - startTime; + customLogger(`Tool failed: ${tool.name} (${duration}ms)`, 'error'); + customLogger( + `Error details: ${error instanceof Error ? error.stack : String(error)}`, + 'error' + ); + + return { + content: [ + { + type: 'text' as const, + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + }); + } + + for (const resource of Object.values(connectorConfig.resources)) { + server.resource(resource.name, resource.uri, async (uri: URL) => { + const startTime = Date.now(); + customLogger(`Resource accessed: ${resource.name}`, 'info'); + customLogger(`Resource URI: ${uri.toString()}`, 'debug'); + + try { + const result = await resource.handler(context); + const duration = Date.now() - startTime; + customLogger(`Resource fetched: ${resource.name} (${duration}ms)`, 'info'); + if (typeof result === 'string' || Array.isArray(result)) { + customLogger(`Resource size: ${result.length} chars`, 'debug'); + } else { + customLogger(`Resource type: ${typeof result}`, 'debug'); + } + + return { + contents: [ + { + type: 'text' as const, + text: String(result), + uri: uri.toString(), + }, + ], + }; + } catch (error) { + const duration = Date.now() - startTime; + customLogger(`Resource failed: ${resource.name} (${duration}ms)`, 'error'); + customLogger( + `Error details: ${error instanceof Error ? error.stack : String(error)}`, + 'error' + ); + + return { + contents: [ + { + type: 'text' as const, + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + uri: uri.toString(), + }, + ], + }; + } + }); + } + + const transport = new StreamableHTTPTransport(); + + let isConnected = false; + customLogger('Connecting MCP server to transport...', 'info'); + + const connectedToServer = server + .connect(transport) + .then(() => { + isConnected = true; + customLogger('MCP server connected successfully', 'info'); + }) + .catch((error) => { + customLogger( + `Failed to connect MCP server: ${error instanceof Error ? error.message : String(error)}`, + 'error' + ); + throw error; + }); + + app.all('/mcp', async (c) => { + const requestId = randomUUID(); + customLogger( + `MCP request received [${requestId}] - ${c.req.method} ${c.req.url}`, + 'info' + ); + + try { + if (!isConnected) { + customLogger(`Waiting for MCP connection [${requestId}]...`, 'debug'); + await connectedToServer; + } + + customLogger(`Processing MCP request [${requestId}]`, 'debug'); + const response = await transport.handleRequest(c); + customLogger(`MCP request completed [${requestId}]`, 'info'); + return response; + } catch (error) { + customLogger( + `MCP request failed [${requestId}]: ${error instanceof Error ? error.message : String(error)}`, + 'error' + ); + if (error instanceof Error) { + customLogger(`Stack trace: ${error.stack}`, 'error'); + } + throw error; + } + }); + + const port = Number.parseInt(values.port || '3000', 10); + + customLogger('Starting MCP Connector Server...', 'info'); + customLogger(`Connector: ${connectorConfig.name} (${connectorConfig.key})`, 'info'); + customLogger(`Version: ${connectorConfig.version}`, 'info'); + customLogger(`Tools: ${Object.keys(connectorConfig.tools).length}`, 'info'); + customLogger(`Resources: ${Object.keys(connectorConfig.resources).length}`, 'info'); + customLogger(`Port: ${port}`, 'info'); + + if (Object.keys(credentials).length > 0) { + customLogger(`Credentials: ${Object.keys(credentials).length} keys provided`, 'info'); + customLogger(`Credential keys: ${Object.keys(credentials).join(', ')}`, 'debug'); + } + + if (Object.keys(setup).length > 0) { + customLogger(`Setup: ${Object.keys(setup).length} config keys provided`, 'info'); + customLogger('Setup config detected (values redacted for security)', 'debug'); + } + + if (connectorConfig.examplePrompt) { + customLogger(`Example: ${connectorConfig.examplePrompt}`, 'info'); + } + + customLogger(`MCP endpoint: http://localhost:${port}/mcp`, 'info'); + customLogger('Server ready and listening for requests!', 'info'); + + return { app, port }; +}; + +const { app, port } = await startServer(); +serve({ fetch: app.fetch, port, hostname: 'localhost' });