From 10219468a42111c7507bc8956b3b7b1b42c15cd4 Mon Sep 17 00:00:00 2001 From: lchavasse Date: Thu, 28 Aug 2025 19:32:22 +0100 Subject: [PATCH 1/5] git rm scripts/start-server.tsAdded call functionality for 11labs --- CLAUDE.md | 90 +++ bun.lock | 1 + package.json | 1 + .../src/connectors/elevenlabs.spec.ts | 654 ++++++++++++++++++ .../src/connectors/elevenlabs.ts | 287 +++++++- scripts/start-server.ts | 364 ++++++++++ 6 files changed, 1396 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 packages/mcp-connectors/src/connectors/elevenlabs.spec.ts create mode 100644 scripts/start-server.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..46ff7415 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a TypeScript monorepo for MCP (Model Context Protocol) connectors that powers disco.dev. It contains pre-built connectors for 35+ popular SaaS tools like GitHub, Slack, Notion, Jira, etc. + +## Development Commands + +### Install dependencies +```bash +bun install +``` + +### Start a connector server for testing +```bash +# No credentials needed (test connector) +bun start --connector test + +# With credentials for production connectors +bun start --connector asana --credentials '{"apiKey":"your-api-key"}' +bun start --connector github --credentials '{"token":"ghp_xxx"}' --setup '{"org":"myorg"}' +``` + +### Build and type checking +```bash +bun run build # Build all packages +bun run typecheck # Type check all packages +bun run test # Run all tests +``` + +### Code quality +```bash +bun run check # Run Biome linter and formatter +bun run check:fix # Auto-fix linting issues +``` + +## Architecture + +### Monorepo Structure +- **packages/mcp-connectors/**: Main connectors package with 35+ connector implementations +- **packages/mcp-config-types/**: Shared TypeScript types and Zod schemas for connector configurations +- **scripts/**: Development scripts for starting and testing connector servers +- **apps/testing-agent/**: Testing utilities + +### Connector Architecture +Each connector is defined using the `mcpConnectorConfig` factory function with: +- **Metadata**: name, key, version, logo, example prompt +- **Credentials schema**: Zod schema defining required API keys/tokens +- **Setup schema**: Zod schema for optional configuration +- **Tools**: Functions that can be called by MCP clients +- **Resources**: Static data that can be retrieved + +### Key Components +- **ConnectorContext**: Provides credential access, data persistence, and caching +- **MCPConnectorConfig**: Type-safe connector configuration with tools and resources +- **Transport layer**: HTTP transport using Hono framework for MCP protocol + +### Tools vs Resources +- **Tools**: Active functions that perform operations (API calls, data processing) +- **Resources**: Passive data retrieval (documentation, static content) + +### Adding New Connectors +1. Create new connector file in `packages/mcp-connectors/src/connectors/` +2. Follow the pattern of existing connectors (see `test.ts` for simple example) +3. Export the connector config and add to `packages/mcp-connectors/src/index.ts` +4. Update credentials/setup schemas in `packages/mcp-config-types/src/` if needed + +## Development Notes + +### Runtime Environment +- Uses **Bun** as package manager and runtime +- **Turbo** for monorepo orchestration +- **Biome** for linting and formatting (90 char line width, single quotes) +- **Vitest** for testing +- **TypeScript 5.9** with strict settings + +### Server Architecture +The development server (`scripts/start-server.ts`) creates: +- MCP server instance with connector tools and resources +- HTTP transport layer using Hono +- Request/response logging with timestamps +- Error handling and debugging output +- Support for credentials and setup configuration via CLI args + +### Key Files +- `packages/mcp-connectors/src/index.ts`: Main connector registry +- `packages/mcp-config-types/src/config.ts`: Connector configuration factory +- `scripts/start-server.ts`: Development server implementation \ 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/elevenlabs.spec.ts b/packages/mcp-connectors/src/connectors/elevenlabs.spec.ts new file mode 100644 index 00000000..6b0de90a --- /dev/null +++ b/packages/mcp-connectors/src/connectors/elevenlabs.spec.ts @@ -0,0 +1,654 @@ +import { describe, expect, it } from "vitest"; +import type { MCPToolDefinition } from "@stackone/mcp-config-types"; +import { createMockConnectorContext } from "../__mocks__/context"; +import { ElevenLabsConnectorConfig } from "./elevenlabs"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +const server = setupServer(); + +describe("#ElevenLabsConnector", () => { + describe(".TEXT_TO_SPEECH", () => { + describe("when text is provided", () => { + describe("and API key is valid", () => { + it("returns base64 audio data successfully", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/text-to-speech/*", () => { + // Mock audio binary data + const mockAudio = new Uint8Array([1, 2, 3, 4, 5]); + return new HttpResponse(mockAudio, { + status: 200, + headers: { "Content-Type": "audio/mpeg" }, + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.TEXT_TO_SPEECH as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({ text: "Hello world" }, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.audio_base64).toBeDefined(); + expect(result.format).toBe("mp3_44100_128"); + + server.close(); + }); + }); + + describe("and API key is invalid", () => { + it("returns error message", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/text-to-speech/*", () => { + return HttpResponse.json( + { detail: { status: "invalid_api_key", message: "Invalid API key" } }, + { status: 401 } + ); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.TEXT_TO_SPEECH as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({ text: "Hello world" }, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("401"); + + server.close(); + }); + }); + + describe("and no audio data is returned", () => { + it("returns error message", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/text-to-speech/*", () => { + return new HttpResponse(null, { status: 200 }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.TEXT_TO_SPEECH as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({ text: "Hello world" }, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("No audio data received"); + + server.close(); + }); + }); + }); + }); + + describe(".LIST_VOICES", () => { + describe("when API call succeeds", () => { + describe("and voices are returned", () => { + it("returns list of voices successfully", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/voices", () => { + return HttpResponse.json({ + voices: [ + { + voice_id: "voice1", + name: "Rachel", + category: "premade", + description: "A calm voice", + available_for_tiers: ["free", "pro"], + }, + ], + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.LIST_VOICES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.voices).toHaveLength(1); + expect(result.voices[0].voice_id).toBe("voice1"); + expect(result.voices[0].name).toBe("Rachel"); + + server.close(); + }); + }); + + describe("and include_shared is true", () => { + it("includes shared voices in the response", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/voices", () => { + return HttpResponse.json({ voices: [] }); + }), + http.get("https://api.elevenlabs.io/v1/shared-voices", () => { + return HttpResponse.json({ + voices: [ + { + voice_id: "shared1", + name: "Community Voice", + category: "shared", + }, + ], + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.LIST_VOICES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({ include_shared: true }, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.voices).toHaveLength(1); + expect(result.voices[0].category).toBe("shared"); + + server.close(); + }); + }); + }); + + describe("when API call fails", () => { + it("returns error message", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/voices", () => { + return HttpResponse.json({ detail: "Not Found" }, { status: 404 }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.LIST_VOICES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("404"); + + server.close(); + }); + }); + }); + + describe(".CREATE_AGENT", () => { + describe("when required parameters are provided", () => { + describe("and API call succeeds", () => { + it("returns agent ID successfully", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/agents/create", () => { + return HttpResponse.json({ + agent_id: "agent_123", + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.CREATE_AGENT as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + agent_prompt: "You are a helpful assistant", + first_message: "Hello! How can I help you?", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.agent_id).toBe("agent_123"); + expect(result.voice_id).toBe("EXAVITQu4vr4xnSDxMaL"); + + server.close(); + }); + }); + + describe("and optional parameters are provided", () => { + it("uses the provided optional parameters", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/agents/create", () => { + return HttpResponse.json({ + agent_id: "agent_456", + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.CREATE_AGENT as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + name: "Test Agent", + agent_prompt: "You are a test assistant", + first_message: "Test greeting", + voice_id: "custom_voice", + language: "es", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.name).toBe("Test Agent"); + expect(result.voice_id).toBe("custom_voice"); + expect(result.language).toBe("es"); + + server.close(); + }); + }); + }); + + describe("when API call fails", () => { + it("returns error message for validation errors", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/agents/create", () => { + return HttpResponse.json( + { + detail: [ + { + type: "missing", + loc: ["body", "conversation_config", "agent", "prompt"], + msg: "Field required", + }, + ], + }, + { status: 422 } + ); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.CREATE_AGENT as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + agent_prompt: "", + first_message: "Hello", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("422"); + + server.close(); + }); + }); + }); + + describe(".LIST_PHONE_NUMBERS", () => { + describe("when phone numbers exist", () => { + describe("and API returns standard format", () => { + it("returns phone numbers successfully", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/convai/phone-numbers", () => { + return HttpResponse.json({ + phone_numbers: [ + { + phone_number_id: "phnum_123", + phone_number: "+1234567890", + name: "Main Line", + status: "active", + provider: "twilio", + country_code: "US", + }, + ], + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.LIST_PHONE_NUMBERS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.phone_numbers).toHaveLength(1); + expect(result.phone_numbers[0].phone_number_id).toBe("phnum_123"); + expect(result.count).toBe(1); + + server.close(); + }); + }); + + describe("and API returns array directly", () => { + it("handles array response format", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/convai/phone-numbers", () => { + return HttpResponse.json([ + { + id: "phnum_456", + number: "+9876543210", + name: "Support Line", + }, + ]); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.LIST_PHONE_NUMBERS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.phone_numbers).toHaveLength(1); + expect(result.phone_numbers[0].phone_number_id).toBe("phnum_456"); + expect(result.phone_numbers[0].phone_number).toBe("+9876543210"); + + server.close(); + }); + }); + + describe("and no phone numbers exist", () => { + it("returns empty list with helpful message", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/convai/phone-numbers", () => { + return HttpResponse.json({ phone_numbers: [] }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.LIST_PHONE_NUMBERS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.phone_numbers).toHaveLength(0); + expect(result.message).toContain("No phone numbers found"); + + server.close(); + }); + }); + }); + }); + + describe(".MAKE_PHONE_CALL", () => { + describe("when all required parameters are provided", () => { + describe("and call is successful", () => { + it("returns call details successfully", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", () => { + return HttpResponse.json({ + success: true, + message: "Call initiated", + conversation_id: "conv_123", + callSid: "CA123456789", + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + agent_id: "agent_123", + from_phone_number_id: "phnum_123", + to_number: "+1234567890", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.conversation_id).toBe("conv_123"); + expect(result.callSid).toBe("CA123456789"); + expect(result.agent_id).toBe("agent_123"); + expect(result.to_number).toBe("+1234567890"); + + server.close(); + }); + }); + + describe("and optional message is provided", () => { + it("includes the additional context", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", (req) => { + // Verify the request body includes additional_context + return HttpResponse.json({ + success: true, + message: "Call initiated with context", + conversation_id: "conv_456", + callSid: "CA987654321", + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + agent_id: "agent_123", + from_phone_number_id: "phnum_123", + to_number: "+1234567890", + message: "This is a test call", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.conversation_id).toBe("conv_456"); + + server.close(); + }); + }); + }); + + describe("when API call fails", () => { + describe("and agent is not found", () => { + it("returns error message", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", () => { + return HttpResponse.json({ detail: "Agent not found" }, { status: 404 }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + agent_id: "invalid_agent", + from_phone_number_id: "phnum_123", + to_number: "+1234567890", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("404"); + + server.close(); + }); + }); + + describe("and phone number is invalid", () => { + it("returns validation error", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", () => { + return HttpResponse.json( + { + detail: [ + { + type: "value_error", + loc: ["body", "to_number"], + msg: "Invalid phone number format", + }, + ], + }, + { status: 422 } + ); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + agent_id: "agent_123", + from_phone_number_id: "phnum_123", + to_number: "invalid_number", + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("422"); + + server.close(); + }); + }); + }); + }); + + describe(".GET_USER_INFO", () => { + describe("when API call succeeds", () => { + it("returns user information successfully", async () => { + server.use( + http.get("https://api.elevenlabs.io/v1/user", () => { + return HttpResponse.json({ + user_id: "user_123", + subscription: { tier: "pro" }, + available_characters: 10000, + used_characters: 2500, + can_extend_character_limit: false, + can_use_instant_voice_cloning: true, + can_use_professional_voice_cloning: true, + api_tier: "pro", + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.GET_USER_INFO as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.user.user_id).toBe("user_123"); + expect(result.user.available_characters).toBe(10000); + expect(result.user.api_tier).toBe("pro"); + + server.close(); + }); + }); + }); + + describe(".SPEECH_TO_TEXT", () => { + describe("when audio data is provided", () => { + describe("and base64 audio is provided", () => { + it("returns transcription successfully", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/speech-to-text", () => { + return HttpResponse.json({ + text: "Hello world", + language: "en", + duration: 2.5, + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.SPEECH_TO_TEXT as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + // Mock base64 audio data + const mockAudioBase64 = btoa("mock audio data"); + + const actual = await tool.handler( + { + audio_base64: mockAudioBase64, + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.transcript).toBe("Hello world"); + expect(result.language).toBe("en"); + + server.close(); + }); + }); + + describe("and no audio data is provided", () => { + it("returns error message", async () => { + const tool = ElevenLabsConnectorConfig.tools.SPEECH_TO_TEXT as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler({}, mockContext); + const result = JSON.parse(actual); + + expect(result.success).toBe(false); + expect(result.error).toContain("audio_base64 or audio_url must be provided"); + }); + }); + }); + }); + + describe(".GENERATE_SOUND_EFFECTS", () => { + describe("when text description is provided", () => { + it("returns generated sound effect successfully", async () => { + server.use( + http.post("https://api.elevenlabs.io/v1/sound-generation", () => { + const mockAudio = new Uint8Array([10, 20, 30, 40, 50]); + return new HttpResponse(mockAudio, { + status: 200, + headers: { "Content-Type": "audio/mpeg" }, + }); + }) + ); + server.listen(); + + const tool = ElevenLabsConnectorConfig.tools.GENERATE_SOUND_EFFECTS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + + const actual = await tool.handler( + { + text: "Sound of rain falling", + duration_seconds: 5, + }, + mockContext + ); + const result = JSON.parse(actual); + + expect(result.success).toBe(true); + expect(result.audio_base64).toBeDefined(); + expect(result.description).toBe("Sound of rain falling"); + + server.close(); + }); + }); + }); +}); diff --git a/packages/mcp-connectors/src/connectors/elevenlabs.ts b/packages/mcp-connectors/src/connectors/elevenlabs.ts index b952f7a2..6045d290 100644 --- a/packages/mcp-connectors/src/connectors/elevenlabs.ts +++ b/packages/mcp-connectors/src/connectors/elevenlabs.ts @@ -48,6 +48,57 @@ interface ElevenLabsTranscriptionResult { speakers?: unknown[]; } +// Conversational AI interfaces +interface ConversationConfig { + agent?: { + prompt?: { + prompt?: string; + }; + first_message?: string; + language?: string; + }; + asr?: { + quality?: string; + provider?: string; + }; + tts?: { + model_id?: string; + voice_id?: string; + }; + transcriber?: { + model?: string; + language?: string; + }; + llm?: { + model?: string; + provider?: string; + }; +} + +interface AgentCreateResponse { + agent_id: string; +} + +interface PhoneCallResponse { + success: boolean; + message: string; + conversation_id?: string; + callSid?: string; +} + +interface PhoneNumber { + phone_number_id: string; + phone_number: string; + name?: string; + status: string; + provider: string; + country_code: string; +} + +interface PhoneNumbersResponse { + phone_numbers: PhoneNumber[]; +} + // Helper function to make API calls to ElevenLabs const makeElevenLabsRequest = async ( endpoint: string, @@ -106,7 +157,7 @@ export const ElevenLabsConnectorConfig = mcpConnectorConfig({ }), 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.', + 'First list available phone numbers to see what Twilio numbers are configured, then create a conversational AI agent that introduces itself as a customer service representative, and finally use it to make a phone call to +1234567890 with a brief greeting message.', tools: (tool) => ({ TEXT_TO_SPEECH: tool({ name: 'text-to-speech', @@ -505,5 +556,239 @@ export const ElevenLabsConnectorConfig = mcpConnectorConfig({ } }, }), + + CREATE_AGENT: tool({ + name: 'create-agent', + description: + 'Create a conversational AI agent for voice interactions and phone calls', + schema: z.object({ + name: z + .string() + .optional() + .describe('Name for the agent to make it easier to find'), + agent_prompt: z + .string() + .describe("The system prompt that guides the agent's behavior and personality"), + first_message: z + .string() + .describe( + 'The first message the agent will speak when starting a conversation' + ), + voice_id: z + .string() + .optional() + .describe('Voice ID to use for the agent (default: Rachel)'), + language: z + .string() + .optional() + .describe('Language code for the agent (default: en)'), + model_id: z + .string() + .optional() + .describe('TTS model ID (default: eleven_turbo_v2)'), + + asr_quality: z + .enum(['low', 'medium', 'high']) + .optional() + .describe('ASR quality setting'), + llm_model: z.string().optional().describe('LLM model to use (default: gpt-4)'), + tags: z + .array(z.string()) + .optional() + .describe('Tags to help classify and filter the agent'), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + + const conversationConfig: ConversationConfig = { + agent: { + prompt: { + prompt: args.agent_prompt, + }, + first_message: args.first_message, + language: args.language || 'en', + }, + asr: { + quality: args.asr_quality || 'high', + provider: 'elevenlabs', + }, + tts: { + model_id: args.model_id || 'eleven_turbo_v2', + voice_id: args.voice_id || 'EXAVITQu4vr4xnSDxMaL', // Rachel voice + }, + llm: { + model: args.llm_model || 'gpt-4', + provider: 'openai', + }, + }; + + const requestBody = { + conversation_config: conversationConfig, + name: args.name || null, + tags: args.tags || null, + }; + + const response = await makeElevenLabsRequest('/convai/agents/create', apiKey, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); + } + + const result = (await response.json()) as AgentCreateResponse; + + return JSON.stringify({ + success: true, + agent_id: result.agent_id, + name: args.name || 'Unnamed Agent', + voice_id: args.voice_id || 'EXAVITQu4vr4xnSDxMaL', + language: args.language || 'en', + message: + 'Conversational AI agent created successfully. Use this agent_id to make phone calls.', + }); + } catch (error) { + console.error('Create agent error:', error); + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } + }, + }), + + LIST_PHONE_NUMBERS: tool({ + name: 'list-phone-numbers', + description: + 'List all phone numbers (Twilio numbers) available for making outbound calls with conversational AI agents', + schema: z.object({}), + handler: async (_args, context) => { + try { + const { apiKey } = await context.getCredentials(); + + const response = await makeElevenLabsRequest('/convai/phone-numbers', apiKey); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); + } + + const result = await response.json(); + + // Handle different possible response structures + let phoneNumbers = []; + if (result.phone_numbers && Array.isArray(result.phone_numbers)) { + phoneNumbers = result.phone_numbers; + } else if (Array.isArray(result)) { + phoneNumbers = result; + } else { + // Log the actual response structure for debugging + console.log('Unexpected API response structure:', JSON.stringify(result, null, 2)); + } + + return JSON.stringify({ + success: true, + phone_numbers: phoneNumbers.map((phone: any) => ({ + phone_number_id: phone.phone_number_id || phone.id, + phone_number: phone.phone_number || phone.number, + name: phone.name || 'Unnamed', + status: phone.status || 'unknown', + provider: phone.provider || 'unknown', + country_code: phone.country_code || phone.country, + })), + count: phoneNumbers.length, + raw_response: result, + message: + phoneNumbers.length > 0 + ? 'Use the phone_number_id from this list as the from_phone_number_id parameter when making phone calls.' + : 'No phone numbers found. You may need to add a phone number to your ElevenLabs account first.', + }); + } catch (error) { + console.error('List phone numbers error:', error); + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } + }, + }), + + MAKE_PHONE_CALL: tool({ + name: 'make-phone-call', + description: + 'Initiate an outbound phone call using a conversational AI agent to deliver a message', + schema: z.object({ + agent_id: z + .string() + .describe('The ID of the conversational AI agent to use for the call'), + to_number: z + .string() + .describe( + 'The phone number to call (in international format, e.g., +1234567890)' + ), + message: z + .string() + .optional() + .describe( + 'Additional context or message for the agent (will be part of the conversation flow)' + ), + from_phone_number_id: z + .string() + .describe( + 'The ID of the phone number (Twilio number) to use for making the outbound call. You must first import your Twilio number into ElevenLabs via the dashboard.' + ), + }), + handler: async (args, context) => { + try { + const { apiKey } = await context.getCredentials(); + + const requestBody: Record = { + agent_id: args.agent_id, + agent_phone_number_id: args.from_phone_number_id, + to_number: args.to_number, + }; + + // Add any additional context message + if (args.message) { + requestBody.additional_context = args.message; + } + + const response = await makeElevenLabsRequest( + `/convai/twilio/outbound-call`, + apiKey, + { + method: 'POST', + body: JSON.stringify(requestBody), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); + } + + const result = (await response.json()) as PhoneCallResponse; + + return JSON.stringify({ + success: true, + conversation_id: result.conversation_id, + callSid: result.callSid, + agent_id: args.agent_id, + to_number: args.to_number, + from_phone_number_id: args.from_phone_number_id, + message: result.message || 'Phone call initiated successfully via Twilio.', + }); + } catch (error) { + console.error('Make phone call error:', error); + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } + }, + }), }), }); 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' }); From 6b0c251b8c34b9044ef5160a17a48850160e223c Mon Sep 17 00:00:00 2001 From: lchavasse Date: Thu, 28 Aug 2025 19:40:35 +0100 Subject: [PATCH 2/5] added unipile --- .../src/connectors/UNIPILE_README.md | 412 +++++++++++++++ .../src/connectors/unipile.spec.ts | 330 ++++++++++++ .../mcp-connectors/src/connectors/unipile.ts | 498 ++++++++++++++++++ 3 files changed, 1240 insertions(+) create mode 100644 packages/mcp-connectors/src/connectors/UNIPILE_README.md create mode 100644 packages/mcp-connectors/src/connectors/unipile.spec.ts create mode 100644 packages/mcp-connectors/src/connectors/unipile.ts 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..22e8f217 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/UNIPILE_README.md @@ -0,0 +1,412 @@ +# Unipile MCP Connector + +A comprehensive Model Context Protocol (MCP) connector for the Unipile API, enabling multi-platform messaging integration with 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 frequency tracking with persistent memory to identify your most frequently messaged contacts. + +## 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 Memory +- **Persistent frequency tracking**: Automatically tracks message frequency using MCP's built-in persistence +- **Smart ranking**: Sorts contacts by message count and recent activity +- **Top contacts**: Get your most frequently messaged contacts (configurable limit) +- **Memory management**: Clear or view stored contact data + +### 🔧 Technical Features +- **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 + +## 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 all messages from a specific chat. + +```typescript +{ + chat_id: string, // Chat ID to get messages from + batch_size?: number // Number of messages (default: 100) +} +``` + +#### `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 to a specific chat with automatic frequency tracking. + +```typescript +{ + chat_id: string, // Chat ID to send message to + text: string, // Message text to send + contact_name?: string, // Contact name (for frequency tracking) + platform?: string, // Platform type (e.g., WHATSAPP, LINKEDIN) + account_id?: string // Account ID (for frequency tracking) +} +``` + +**Example:** +```typescript +{ + "chat_id": "hxkfCnylUGmwBJy2nRvkSw", + "text": "Hello! How are you?", + "contact_name": "Marco Hack Hack", + "platform": "WHATSAPP", + "account_id": "your-account-id" +} +``` + +### 🧠 Persistent Memory Tools + +#### `unipile_get_top_contacts` +Get the most frequently messaged contacts based on persistent memory. + +```typescript +{ + limit?: number // Max contacts to return (default: 10) +} +``` + +**Returns:** +```json +{ + "contacts": [ + { + "id": "chat_id", + "name": "Contact Name", + "messageCount": 15, + "lastMessageTime": "2025-08-28T18:20:49.623Z", + "platform": "WHATSAPP", + "accountId": "account_id" + } + ] +} +``` + +#### `unipile_get_all_stored_contacts` +Get all stored contacts from persistent memory with frequency data. + +```typescript +// No parameters required +// Returns: All contacts with their frequency data and total count +``` + +#### `unipile_clear_contact_memory` +Clear all stored contact frequency data from persistent memory. + +```typescript +// No parameters required +// Returns: Success confirmation +``` + +## Usage Examples + +### Basic Message Sending + +```typescript +// Send a simple message +await unipile_send_message({ + chat_id: "chat_123", + text: "Hello there!" +}); + +// Send with frequency tracking +await unipile_send_message({ + chat_id: "chat_123", + text: "Hello there!", + contact_name: "John Doe", + platform: "WHATSAPP", + account_id: "account_456" +}); +``` + +### Get Top Contacts + +```typescript +// Get top 10 most frequent contacts +const topContacts = await unipile_get_top_contacts({ limit: 10 }); + +// Get top 5 contacts +const top5 = await unipile_get_top_contacts({ limit: 5 }); +``` + +### 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": { + "chat_id_1": { + "id": "chat_id_1", + "name": "Contact Name", + "messageCount": 15, + "lastMessageTime": "2025-08-28T18:20:49.623Z", + "platform": "WHATSAPP", + "accountId": "account_id" + } + } +} +``` + +## API Response Formats + +### Accounts Response +```json +{ + "items": [ + { + "id": "account_id", + "name": "Phone Number", + "type": "WHATSAPP", + "created_at": "2025-08-28T17:21:14.163Z", + "sources": [ + { + "id": "source_id_MESSAGING", + "status": "OK" + } + ] + } + ] +} +``` + +### Chats Response +```json +{ + "items": [ + { + "id": "chat_id", + "name": "Chat Name", + "type": 1, + "folder": ["INBOX"], + "unread": 0, + "timestamp": "2025-08-28T17:49:58.000Z", + "account_id": "account_id", + "account_type": "WHATSAPP", + "unread_count": 6 + } + ] +} +``` + +### Messages Response +```json +{ + "items": [ + { + "id": "msg_id", + "text": "Hello world", + "timestamp": "2025-08-28T17:49:58.000Z", + "sender_id": "sender@example.com", + "chat_id": "chat_id", + "account_id": "account_id", + "is_sender": 0, + "attachments": [] + } + ] +} +``` + +## 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 + +### Message Sending +1. **Always provide contact info** for frequency tracking +2. **Use descriptive contact names** for better memory organization +3. **Check chat existence** before sending messages +4. **Handle rate limits** gracefully in production + +### Memory Management +1. **Regularly check top contacts** to understand communication patterns +2. **Clear memory periodically** if needed for privacy +3. **Use appropriate limits** when fetching contacts to avoid large responses + +### 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 + +**Messages not being tracked:** +- Ensure all optional parameters are provided to `send_message` +- Check that contact data is being stored with `get_all_stored_contacts` +- Verify persistence is working with `get_top_contacts` + +### 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**: 1.0.0 +**Last Updated**: August 28, 2025 +**MCP Framework**: Compatible with MCP 1.0+ \ No newline at end of file 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..27e0f8b8 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/unipile.spec.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import type { MCPToolDefinition } from '@stackone/mcp-config-types'; +import { createMockConnectorContext } from '../__mocks__/context'; +import { UnipileConnectorConfig } from './unipile'; + +const server = setupServer( + http.get('https://api8.unipile.com:13851/accounts', () => { + return HttpResponse.json({ + items: [ + { + id: 'account-1', + name: 'Test Account', + type: 'WHATSAPP', + created_at: '2024-01-01', + sources: [ + { id: 'source-1', status: 'OK' }, + { id: 'source-2_MAILS', status: 'OK' }, + ], + }, + ], + }); + }), + http.get('https://api8.unipile.com:13851/chats', ({ request }) => { + const url = new URL(request.url); + const accountId = url.searchParams.get('account_id'); + const limit = url.searchParams.get('limit'); + + return HttpResponse.json({ + items: [ + { + id: 'chat-1', + name: 'Test Chat', + type: 1, + folder: ['INBOX'], + unread: 0, + archived: 0, + read_only: 0, + timestamp: '2024-01-01T10:00:00Z', + account_id: accountId, + account_type: 'WHATSAPP', + unread_count: 0, + provider_id: 'test-provider', + attendee_provider_id: 'test-attendee', + muted_until: null, + }, + ], + }); + }), + http.get('https://api8.unipile.com:13851/messages', ({ request }) => { + const url = new URL(request.url); + const chatId = url.searchParams.get('chat_id'); + + return HttpResponse.json({ + items: [ + { + id: 'msg-1', + text: 'Hello, this is a test message', + timestamp: '2024-01-01T10:00:00Z', + sender_id: 'sender-1', + chat_id: chatId, + account_id: 'account-1', + provider_id: 'msg-provider-1', + chat_provider_id: 'chat-provider-1', + sender_attendee_id: 'attendee-1', + seen: 0, + edited: 0, + hidden: 0, + deleted: 0, + delivered: 1, + is_sender: 0, + is_event: 0, + attachments: [], + reactions: [], + seen_by: {}, + behavior: null, + subject: null, + }, + ], + }); + }), + http.get('https://api8.unipile.com:13851/emails', ({ request }) => { + const url = new URL(request.url); + const accountId = url.searchParams.get('account_id'); + + return HttpResponse.json({ + items: [ + { + id: 'email-1', + subject: 'Test Email', + date: '2024-01-01T10:00:00Z', + role: 'inbox', + folders: ['INBOX'], + has_attachments: false, + from: 'sender@example.com', + to: ['recipient@example.com'], + cc: [], + body_markdown: '# Test Email\n\nThis is a test email.', + }, + ], + }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('#UnipileConnector', () => { + describe('.GET_ACCOUNTS', () => { + describe('when credentials are valid', () => { + it('returns list of connected accounts', async () => { + const tool = UnipileConnectorConfig.tools.GET_ACCOUNTS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler({}, mockContext); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + expect(response.items[0].id).toBe('account-1'); + expect(response.items[0].name).toBe('Test Account'); + }); + }); + + describe('when API request fails', () => { + it('returns error message', async () => { + server.use( + http.get('https://api8.unipile.com:13851/accounts', () => { + return new HttpResponse(null, { status: 401 }); + }) + ); + + const tool = UnipileConnectorConfig.tools.GET_ACCOUNTS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'invalid-key', + }); + + const actual = await tool.handler({}, mockContext); + const response = JSON.parse(actual); + + expect(response.error).toContain('Failed to get accounts'); + }); + }); + }); + + describe('.GET_CHATS', () => { + describe('when account_id is provided', () => { + it('returns list of chats for the account', async () => { + const tool = UnipileConnectorConfig.tools.GET_CHATS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + 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.items).toHaveLength(1); + expect(response.items[0].id).toBe('chat-1'); + expect(response.items[0].name).toBe('Test Chat'); + }); + }); + + describe('when account_id has suffix', () => { + it('removes suffix and returns chats', async () => { + const tool = UnipileConnectorConfig.tools.GET_CHATS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { account_id: 'source-1_MESSAGING' }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + }); + }); + }); + + describe('.GET_CHAT_MESSAGES', () => { + describe('when chat_id is provided', () => { + it('returns messages from the chat', async () => { + const tool = UnipileConnectorConfig.tools.GET_CHAT_MESSAGES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { chat_id: 'chat-1', batch_size: 100 }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + expect(response.items[0].id).toBe('msg-1'); + expect(response.items[0].text).toBe('Hello, this is a test message'); + }); + }); + + describe('when batch_size is not provided', () => { + it('uses default batch size', async () => { + const tool = UnipileConnectorConfig.tools.GET_CHAT_MESSAGES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler({ chat_id: 'chat-1' }, mockContext); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + }); + }); + }); + + describe('.GET_RECENT_MESSAGES', () => { + describe('when account_id is provided', () => { + it('returns messages from all chats in the account', async () => { + const tool = UnipileConnectorConfig.tools.GET_RECENT_MESSAGES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { account_id: 'source-1', batch_size: 20 }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.messages).toHaveLength(1); + expect(response.messages[0].chat_info).toBeDefined(); + expect(response.messages[0].chat_info.id).toBe('chat-1'); + }); + }); + + describe('when batch_size is not provided', () => { + it('uses default batch size', async () => { + const tool = UnipileConnectorConfig.tools.GET_RECENT_MESSAGES as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + 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.messages).toHaveLength(1); + }); + }); + }); + + describe('.GET_EMAILS', () => { + describe('when account_id is provided', () => { + it('returns emails from the account', async () => { + const tool = UnipileConnectorConfig.tools.GET_EMAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { account_id: 'source-2', limit: 10 }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + expect(response.items[0].id).toBe('email-1'); + expect(response.items[0].subject).toBe('Test Email'); + }); + }); + + describe('when account_id has mail suffix', () => { + it('removes suffix and returns emails', async () => { + const tool = UnipileConnectorConfig.tools.GET_EMAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler( + { account_id: 'source-2_MAILS' }, + mockContext + ); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + }); + }); + + describe('when limit is not provided', () => { + it('uses default limit', async () => { + const tool = UnipileConnectorConfig.tools.GET_EMAILS as MCPToolDefinition; + const mockContext = createMockConnectorContext(); + mockContext.getCredentials.mockResolvedValue({ + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }); + + const actual = await tool.handler({ account_id: 'source-2' }, mockContext); + const response = JSON.parse(actual); + + expect(response.items).toHaveLength(1); + }); + }); + }); +}); \ 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..1f283b5a --- /dev/null +++ b/packages/mcp-connectors/src/connectors/unipile.ts @@ -0,0 +1,498 @@ +import { mcpConnectorConfig } from '@stackone/mcp-config-types'; +import { z } from 'zod'; + +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 = `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 account details including connection parameters, ID, name, creation date, signatures, groups, and sources.', + 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(); + return JSON.stringify(response); + } 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 all available chats for a specific account. 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); + return JSON.stringify(response); + } 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 all messages from a specific chat. Supports messages from: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Returns message details including text content, sender info, timestamps, and participant information.', + 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 fetch (default: 100)'), + }), + 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); + return JSON.stringify(response); + } 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. Supports messages from: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Returns message details including text content, sender info, timestamps, attachments, reactions, quoted messages, and metadata.', + 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 fetch per chat (default: 20)'), + }), + 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); + return JSON.stringify({ messages }); + } 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. Works with all supported messaging platforms: WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Automatically tracks contact frequency for persistent memory.', + 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 (for frequency tracking)'), + platform: z.string().optional().describe('Platform type (e.g., WHATSAPP, LINKEDIN)'), + account_id: z.string().optional().describe('Account ID for frequency tracking'), + }), + 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 contact frequency using MCP persistence if contact info provided + if (args.contact_name && args.platform && args.account_id) { + const contactsData = await context.getData>('unipile_contacts') || {}; + const now = new Date().toISOString(); + + if (contactsData[args.chat_id]) { + // Update existing contact + contactsData[args.chat_id].messageCount++; + contactsData[args.chat_id].lastMessageTime = now; + contactsData[args.chat_id].name = args.contact_name; // Update name in case it changed + } else { + // Add new contact + contactsData[args.chat_id] = { + id: args.chat_id, + name: args.contact_name, + lastMessageTime: now, + messageCount: 1, + platform: args.platform, + accountId: 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_TOP_CONTACTS: tool({ + name: 'unipile_get_top_contacts', + description: 'Get the most frequently messaged contacts based on MCP persistent memory. Returns up to 10 contacts sorted by message frequency and recent activity.', + schema: z.object({ + limit: z.number().optional().describe('Maximum number of contacts to return (default: 10)'), + }), + handler: async (args, context) => { + try { + // Use MCP's built-in persistence + const contactsData = await context.getData>('unipile_contacts') || {}; + + // Convert to array and sort + const contacts = Object.values(contactsData) + .sort((a: any, b: any) => { + if (a.messageCount !== b.messageCount) { + return b.messageCount - a.messageCount; + } + return new Date(b.lastMessageTime).getTime() - new Date(a.lastMessageTime).getTime(); + }) + .slice(0, args.limit || 10); + + return JSON.stringify({ contacts }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get top contacts: ${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 their frequency data.', + schema: z.object({}), + handler: async (args, context) => { + try { + // Use MCP's built-in persistence + const contactsData = await context.getData>('unipile_contacts') || {}; + const contacts = Object.values(contactsData); + return JSON.stringify({ contacts, count: contacts.length }); + } catch (error) { + return JSON.stringify({ + error: `Failed to get stored contacts: ${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)}`, + }); + } + }, + }), + }), + 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(); + return JSON.stringify(response); + } catch (error) { + return JSON.stringify({ + error: `Failed to get accounts: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }, + }), + }), +}); \ No newline at end of file From 2c1c19bb7349466ca23b5a61972e645a73d08aae Mon Sep 17 00:00:00 2001 From: lchavasse Date: Thu, 28 Aug 2025 19:54:55 +0100 Subject: [PATCH 3/5] feat: export UnipileConnectorConfig in main index --- packages/mcp-connectors/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/mcp-connectors/src/index.ts b/packages/mcp-connectors/src/index.ts index bd784f61..6d11e032 100644 --- a/packages/mcp-connectors/src/index.ts +++ b/packages/mcp-connectors/src/index.ts @@ -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'; @@ -81,6 +82,7 @@ export const Connectors: readonly MCPConnectorConfig[] = [ TinybirdConnectorConfig, TodoistConnectorConfig, TurbopufferConnectorConfig, + UnipileConnectorConfig, WandbConnectorConfig, XeroConnectorConfig, ] as const; @@ -124,6 +126,7 @@ export { TinybirdConnectorConfig, TodoistConnectorConfig, TurbopufferConnectorConfig, + UnipileConnectorConfig, WandbConnectorConfig, XeroConnectorConfig, }; From 692796439903f94aa3b418ce39c6683081917e50 Mon Sep 17 00:00:00 2001 From: lchavasse Date: Sat, 30 Aug 2025 19:17:14 +0100 Subject: [PATCH 4/5] Filtering responses for cleaner output to LLM and adding to persistent storage function. --- .gitignore | 5 +- CLAUDE.md | 90 -- .../src/connectors/UNIPILE_README.md | 6 +- .../src/connectors/elevenlabs.spec.ts | 654 --------------- .../src/connectors/elevenlabs.ts | 794 ------------------ .../mcp-connectors/src/connectors/unipile.ts | 183 +++- packages/mcp-connectors/src/index.ts | 4 +- 7 files changed, 179 insertions(+), 1557 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 packages/mcp-connectors/src/connectors/elevenlabs.spec.ts delete mode 100644 packages/mcp-connectors/src/connectors/elevenlabs.ts 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/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 46ff7415..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,90 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is a TypeScript monorepo for MCP (Model Context Protocol) connectors that powers disco.dev. It contains pre-built connectors for 35+ popular SaaS tools like GitHub, Slack, Notion, Jira, etc. - -## Development Commands - -### Install dependencies -```bash -bun install -``` - -### Start a connector server for testing -```bash -# No credentials needed (test connector) -bun start --connector test - -# With credentials for production connectors -bun start --connector asana --credentials '{"apiKey":"your-api-key"}' -bun start --connector github --credentials '{"token":"ghp_xxx"}' --setup '{"org":"myorg"}' -``` - -### Build and type checking -```bash -bun run build # Build all packages -bun run typecheck # Type check all packages -bun run test # Run all tests -``` - -### Code quality -```bash -bun run check # Run Biome linter and formatter -bun run check:fix # Auto-fix linting issues -``` - -## Architecture - -### Monorepo Structure -- **packages/mcp-connectors/**: Main connectors package with 35+ connector implementations -- **packages/mcp-config-types/**: Shared TypeScript types and Zod schemas for connector configurations -- **scripts/**: Development scripts for starting and testing connector servers -- **apps/testing-agent/**: Testing utilities - -### Connector Architecture -Each connector is defined using the `mcpConnectorConfig` factory function with: -- **Metadata**: name, key, version, logo, example prompt -- **Credentials schema**: Zod schema defining required API keys/tokens -- **Setup schema**: Zod schema for optional configuration -- **Tools**: Functions that can be called by MCP clients -- **Resources**: Static data that can be retrieved - -### Key Components -- **ConnectorContext**: Provides credential access, data persistence, and caching -- **MCPConnectorConfig**: Type-safe connector configuration with tools and resources -- **Transport layer**: HTTP transport using Hono framework for MCP protocol - -### Tools vs Resources -- **Tools**: Active functions that perform operations (API calls, data processing) -- **Resources**: Passive data retrieval (documentation, static content) - -### Adding New Connectors -1. Create new connector file in `packages/mcp-connectors/src/connectors/` -2. Follow the pattern of existing connectors (see `test.ts` for simple example) -3. Export the connector config and add to `packages/mcp-connectors/src/index.ts` -4. Update credentials/setup schemas in `packages/mcp-config-types/src/` if needed - -## Development Notes - -### Runtime Environment -- Uses **Bun** as package manager and runtime -- **Turbo** for monorepo orchestration -- **Biome** for linting and formatting (90 char line width, single quotes) -- **Vitest** for testing -- **TypeScript 5.9** with strict settings - -### Server Architecture -The development server (`scripts/start-server.ts`) creates: -- MCP server instance with connector tools and resources -- HTTP transport layer using Hono -- Request/response logging with timestamps -- Error handling and debugging output -- Support for credentials and setup configuration via CLI args - -### Key Files -- `packages/mcp-connectors/src/index.ts`: Main connector registry -- `packages/mcp-config-types/src/config.ts`: Connector configuration factory -- `scripts/start-server.ts`: Development server implementation \ No newline at end of file diff --git a/packages/mcp-connectors/src/connectors/UNIPILE_README.md b/packages/mcp-connectors/src/connectors/UNIPILE_README.md index 22e8f217..cd617abb 100644 --- a/packages/mcp-connectors/src/connectors/UNIPILE_README.md +++ b/packages/mcp-connectors/src/connectors/UNIPILE_README.md @@ -374,9 +374,9 @@ Common error scenarios: - Refresh chat list to get current IDs **Messages not being tracked:** -- Ensure all optional parameters are provided to `send_message` -- Check that contact data is being stored with `get_all_stored_contacts` -- Verify persistence is working with `get_top_contacts` +- Ensure all optional parameters are provided to `unipile_send_message` +- Check that contact data is being stored with `unipile_get_all_stored_contacts` +- Verify persistence is working with `unipile_get_top_contacts` ### Debug Mode diff --git a/packages/mcp-connectors/src/connectors/elevenlabs.spec.ts b/packages/mcp-connectors/src/connectors/elevenlabs.spec.ts deleted file mode 100644 index 6b0de90a..00000000 --- a/packages/mcp-connectors/src/connectors/elevenlabs.spec.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MCPToolDefinition } from "@stackone/mcp-config-types"; -import { createMockConnectorContext } from "../__mocks__/context"; -import { ElevenLabsConnectorConfig } from "./elevenlabs"; -import { http, HttpResponse } from "msw"; -import { setupServer } from "msw/node"; - -const server = setupServer(); - -describe("#ElevenLabsConnector", () => { - describe(".TEXT_TO_SPEECH", () => { - describe("when text is provided", () => { - describe("and API key is valid", () => { - it("returns base64 audio data successfully", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/text-to-speech/*", () => { - // Mock audio binary data - const mockAudio = new Uint8Array([1, 2, 3, 4, 5]); - return new HttpResponse(mockAudio, { - status: 200, - headers: { "Content-Type": "audio/mpeg" }, - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.TEXT_TO_SPEECH as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({ text: "Hello world" }, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.audio_base64).toBeDefined(); - expect(result.format).toBe("mp3_44100_128"); - - server.close(); - }); - }); - - describe("and API key is invalid", () => { - it("returns error message", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/text-to-speech/*", () => { - return HttpResponse.json( - { detail: { status: "invalid_api_key", message: "Invalid API key" } }, - { status: 401 } - ); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.TEXT_TO_SPEECH as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({ text: "Hello world" }, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("401"); - - server.close(); - }); - }); - - describe("and no audio data is returned", () => { - it("returns error message", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/text-to-speech/*", () => { - return new HttpResponse(null, { status: 200 }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.TEXT_TO_SPEECH as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({ text: "Hello world" }, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("No audio data received"); - - server.close(); - }); - }); - }); - }); - - describe(".LIST_VOICES", () => { - describe("when API call succeeds", () => { - describe("and voices are returned", () => { - it("returns list of voices successfully", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/voices", () => { - return HttpResponse.json({ - voices: [ - { - voice_id: "voice1", - name: "Rachel", - category: "premade", - description: "A calm voice", - available_for_tiers: ["free", "pro"], - }, - ], - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.LIST_VOICES as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.voices).toHaveLength(1); - expect(result.voices[0].voice_id).toBe("voice1"); - expect(result.voices[0].name).toBe("Rachel"); - - server.close(); - }); - }); - - describe("and include_shared is true", () => { - it("includes shared voices in the response", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/voices", () => { - return HttpResponse.json({ voices: [] }); - }), - http.get("https://api.elevenlabs.io/v1/shared-voices", () => { - return HttpResponse.json({ - voices: [ - { - voice_id: "shared1", - name: "Community Voice", - category: "shared", - }, - ], - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.LIST_VOICES as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({ include_shared: true }, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.voices).toHaveLength(1); - expect(result.voices[0].category).toBe("shared"); - - server.close(); - }); - }); - }); - - describe("when API call fails", () => { - it("returns error message", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/voices", () => { - return HttpResponse.json({ detail: "Not Found" }, { status: 404 }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.LIST_VOICES as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("404"); - - server.close(); - }); - }); - }); - - describe(".CREATE_AGENT", () => { - describe("when required parameters are provided", () => { - describe("and API call succeeds", () => { - it("returns agent ID successfully", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/agents/create", () => { - return HttpResponse.json({ - agent_id: "agent_123", - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.CREATE_AGENT as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - agent_prompt: "You are a helpful assistant", - first_message: "Hello! How can I help you?", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.agent_id).toBe("agent_123"); - expect(result.voice_id).toBe("EXAVITQu4vr4xnSDxMaL"); - - server.close(); - }); - }); - - describe("and optional parameters are provided", () => { - it("uses the provided optional parameters", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/agents/create", () => { - return HttpResponse.json({ - agent_id: "agent_456", - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.CREATE_AGENT as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - name: "Test Agent", - agent_prompt: "You are a test assistant", - first_message: "Test greeting", - voice_id: "custom_voice", - language: "es", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.name).toBe("Test Agent"); - expect(result.voice_id).toBe("custom_voice"); - expect(result.language).toBe("es"); - - server.close(); - }); - }); - }); - - describe("when API call fails", () => { - it("returns error message for validation errors", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/agents/create", () => { - return HttpResponse.json( - { - detail: [ - { - type: "missing", - loc: ["body", "conversation_config", "agent", "prompt"], - msg: "Field required", - }, - ], - }, - { status: 422 } - ); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.CREATE_AGENT as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - agent_prompt: "", - first_message: "Hello", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("422"); - - server.close(); - }); - }); - }); - - describe(".LIST_PHONE_NUMBERS", () => { - describe("when phone numbers exist", () => { - describe("and API returns standard format", () => { - it("returns phone numbers successfully", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/convai/phone-numbers", () => { - return HttpResponse.json({ - phone_numbers: [ - { - phone_number_id: "phnum_123", - phone_number: "+1234567890", - name: "Main Line", - status: "active", - provider: "twilio", - country_code: "US", - }, - ], - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.LIST_PHONE_NUMBERS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.phone_numbers).toHaveLength(1); - expect(result.phone_numbers[0].phone_number_id).toBe("phnum_123"); - expect(result.count).toBe(1); - - server.close(); - }); - }); - - describe("and API returns array directly", () => { - it("handles array response format", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/convai/phone-numbers", () => { - return HttpResponse.json([ - { - id: "phnum_456", - number: "+9876543210", - name: "Support Line", - }, - ]); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.LIST_PHONE_NUMBERS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.phone_numbers).toHaveLength(1); - expect(result.phone_numbers[0].phone_number_id).toBe("phnum_456"); - expect(result.phone_numbers[0].phone_number).toBe("+9876543210"); - - server.close(); - }); - }); - - describe("and no phone numbers exist", () => { - it("returns empty list with helpful message", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/convai/phone-numbers", () => { - return HttpResponse.json({ phone_numbers: [] }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.LIST_PHONE_NUMBERS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.phone_numbers).toHaveLength(0); - expect(result.message).toContain("No phone numbers found"); - - server.close(); - }); - }); - }); - }); - - describe(".MAKE_PHONE_CALL", () => { - describe("when all required parameters are provided", () => { - describe("and call is successful", () => { - it("returns call details successfully", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", () => { - return HttpResponse.json({ - success: true, - message: "Call initiated", - conversation_id: "conv_123", - callSid: "CA123456789", - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - agent_id: "agent_123", - from_phone_number_id: "phnum_123", - to_number: "+1234567890", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.conversation_id).toBe("conv_123"); - expect(result.callSid).toBe("CA123456789"); - expect(result.agent_id).toBe("agent_123"); - expect(result.to_number).toBe("+1234567890"); - - server.close(); - }); - }); - - describe("and optional message is provided", () => { - it("includes the additional context", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", (req) => { - // Verify the request body includes additional_context - return HttpResponse.json({ - success: true, - message: "Call initiated with context", - conversation_id: "conv_456", - callSid: "CA987654321", - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - agent_id: "agent_123", - from_phone_number_id: "phnum_123", - to_number: "+1234567890", - message: "This is a test call", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.conversation_id).toBe("conv_456"); - - server.close(); - }); - }); - }); - - describe("when API call fails", () => { - describe("and agent is not found", () => { - it("returns error message", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", () => { - return HttpResponse.json({ detail: "Agent not found" }, { status: 404 }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - agent_id: "invalid_agent", - from_phone_number_id: "phnum_123", - to_number: "+1234567890", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("404"); - - server.close(); - }); - }); - - describe("and phone number is invalid", () => { - it("returns validation error", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/convai/twilio/outbound-call", () => { - return HttpResponse.json( - { - detail: [ - { - type: "value_error", - loc: ["body", "to_number"], - msg: "Invalid phone number format", - }, - ], - }, - { status: 422 } - ); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.MAKE_PHONE_CALL as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - agent_id: "agent_123", - from_phone_number_id: "phnum_123", - to_number: "invalid_number", - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("422"); - - server.close(); - }); - }); - }); - }); - - describe(".GET_USER_INFO", () => { - describe("when API call succeeds", () => { - it("returns user information successfully", async () => { - server.use( - http.get("https://api.elevenlabs.io/v1/user", () => { - return HttpResponse.json({ - user_id: "user_123", - subscription: { tier: "pro" }, - available_characters: 10000, - used_characters: 2500, - can_extend_character_limit: false, - can_use_instant_voice_cloning: true, - can_use_professional_voice_cloning: true, - api_tier: "pro", - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.GET_USER_INFO as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.user.user_id).toBe("user_123"); - expect(result.user.available_characters).toBe(10000); - expect(result.user.api_tier).toBe("pro"); - - server.close(); - }); - }); - }); - - describe(".SPEECH_TO_TEXT", () => { - describe("when audio data is provided", () => { - describe("and base64 audio is provided", () => { - it("returns transcription successfully", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/speech-to-text", () => { - return HttpResponse.json({ - text: "Hello world", - language: "en", - duration: 2.5, - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.SPEECH_TO_TEXT as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - // Mock base64 audio data - const mockAudioBase64 = btoa("mock audio data"); - - const actual = await tool.handler( - { - audio_base64: mockAudioBase64, - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.transcript).toBe("Hello world"); - expect(result.language).toBe("en"); - - server.close(); - }); - }); - - describe("and no audio data is provided", () => { - it("returns error message", async () => { - const tool = ElevenLabsConnectorConfig.tools.SPEECH_TO_TEXT as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler({}, mockContext); - const result = JSON.parse(actual); - - expect(result.success).toBe(false); - expect(result.error).toContain("audio_base64 or audio_url must be provided"); - }); - }); - }); - }); - - describe(".GENERATE_SOUND_EFFECTS", () => { - describe("when text description is provided", () => { - it("returns generated sound effect successfully", async () => { - server.use( - http.post("https://api.elevenlabs.io/v1/sound-generation", () => { - const mockAudio = new Uint8Array([10, 20, 30, 40, 50]); - return new HttpResponse(mockAudio, { - status: 200, - headers: { "Content-Type": "audio/mpeg" }, - }); - }) - ); - server.listen(); - - const tool = ElevenLabsConnectorConfig.tools.GENERATE_SOUND_EFFECTS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - - const actual = await tool.handler( - { - text: "Sound of rain falling", - duration_seconds: 5, - }, - mockContext - ); - const result = JSON.parse(actual); - - expect(result.success).toBe(true); - expect(result.audio_base64).toBeDefined(); - expect(result.description).toBe("Sound of rain falling"); - - server.close(); - }); - }); - }); -}); diff --git a/packages/mcp-connectors/src/connectors/elevenlabs.ts b/packages/mcp-connectors/src/connectors/elevenlabs.ts deleted file mode 100644 index 6045d290..00000000 --- a/packages/mcp-connectors/src/connectors/elevenlabs.ts +++ /dev/null @@ -1,794 +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[]; -} - -// Conversational AI interfaces -interface ConversationConfig { - agent?: { - prompt?: { - prompt?: string; - }; - first_message?: string; - language?: string; - }; - asr?: { - quality?: string; - provider?: string; - }; - tts?: { - model_id?: string; - voice_id?: string; - }; - transcriber?: { - model?: string; - language?: string; - }; - llm?: { - model?: string; - provider?: string; - }; -} - -interface AgentCreateResponse { - agent_id: string; -} - -interface PhoneCallResponse { - success: boolean; - message: string; - conversation_id?: string; - callSid?: string; -} - -interface PhoneNumber { - phone_number_id: string; - phone_number: string; - name?: string; - status: string; - provider: string; - country_code: string; -} - -interface PhoneNumbersResponse { - phone_numbers: PhoneNumber[]; -} - -// 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: - 'First list available phone numbers to see what Twilio numbers are configured, then create a conversational AI agent that introduces itself as a customer service representative, and finally use it to make a phone call to +1234567890 with a brief greeting message.', - 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', - }); - } - }, - }), - - CREATE_AGENT: tool({ - name: 'create-agent', - description: - 'Create a conversational AI agent for voice interactions and phone calls', - schema: z.object({ - name: z - .string() - .optional() - .describe('Name for the agent to make it easier to find'), - agent_prompt: z - .string() - .describe("The system prompt that guides the agent's behavior and personality"), - first_message: z - .string() - .describe( - 'The first message the agent will speak when starting a conversation' - ), - voice_id: z - .string() - .optional() - .describe('Voice ID to use for the agent (default: Rachel)'), - language: z - .string() - .optional() - .describe('Language code for the agent (default: en)'), - model_id: z - .string() - .optional() - .describe('TTS model ID (default: eleven_turbo_v2)'), - - asr_quality: z - .enum(['low', 'medium', 'high']) - .optional() - .describe('ASR quality setting'), - llm_model: z.string().optional().describe('LLM model to use (default: gpt-4)'), - tags: z - .array(z.string()) - .optional() - .describe('Tags to help classify and filter the agent'), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const conversationConfig: ConversationConfig = { - agent: { - prompt: { - prompt: args.agent_prompt, - }, - first_message: args.first_message, - language: args.language || 'en', - }, - asr: { - quality: args.asr_quality || 'high', - provider: 'elevenlabs', - }, - tts: { - model_id: args.model_id || 'eleven_turbo_v2', - voice_id: args.voice_id || 'EXAVITQu4vr4xnSDxMaL', // Rachel voice - }, - llm: { - model: args.llm_model || 'gpt-4', - provider: 'openai', - }, - }; - - const requestBody = { - conversation_config: conversationConfig, - name: args.name || null, - tags: args.tags || null, - }; - - const response = await makeElevenLabsRequest('/convai/agents/create', apiKey, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const result = (await response.json()) as AgentCreateResponse; - - return JSON.stringify({ - success: true, - agent_id: result.agent_id, - name: args.name || 'Unnamed Agent', - voice_id: args.voice_id || 'EXAVITQu4vr4xnSDxMaL', - language: args.language || 'en', - message: - 'Conversational AI agent created successfully. Use this agent_id to make phone calls.', - }); - } catch (error) { - console.error('Create agent error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - LIST_PHONE_NUMBERS: tool({ - name: 'list-phone-numbers', - description: - 'List all phone numbers (Twilio numbers) available for making outbound calls with conversational AI agents', - schema: z.object({}), - handler: async (_args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const response = await makeElevenLabsRequest('/convai/phone-numbers', apiKey); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const result = await response.json(); - - // Handle different possible response structures - let phoneNumbers = []; - if (result.phone_numbers && Array.isArray(result.phone_numbers)) { - phoneNumbers = result.phone_numbers; - } else if (Array.isArray(result)) { - phoneNumbers = result; - } else { - // Log the actual response structure for debugging - console.log('Unexpected API response structure:', JSON.stringify(result, null, 2)); - } - - return JSON.stringify({ - success: true, - phone_numbers: phoneNumbers.map((phone: any) => ({ - phone_number_id: phone.phone_number_id || phone.id, - phone_number: phone.phone_number || phone.number, - name: phone.name || 'Unnamed', - status: phone.status || 'unknown', - provider: phone.provider || 'unknown', - country_code: phone.country_code || phone.country, - })), - count: phoneNumbers.length, - raw_response: result, - message: - phoneNumbers.length > 0 - ? 'Use the phone_number_id from this list as the from_phone_number_id parameter when making phone calls.' - : 'No phone numbers found. You may need to add a phone number to your ElevenLabs account first.', - }); - } catch (error) { - console.error('List phone numbers error:', error); - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - }); - } - }, - }), - - MAKE_PHONE_CALL: tool({ - name: 'make-phone-call', - description: - 'Initiate an outbound phone call using a conversational AI agent to deliver a message', - schema: z.object({ - agent_id: z - .string() - .describe('The ID of the conversational AI agent to use for the call'), - to_number: z - .string() - .describe( - 'The phone number to call (in international format, e.g., +1234567890)' - ), - message: z - .string() - .optional() - .describe( - 'Additional context or message for the agent (will be part of the conversation flow)' - ), - from_phone_number_id: z - .string() - .describe( - 'The ID of the phone number (Twilio number) to use for making the outbound call. You must first import your Twilio number into ElevenLabs via the dashboard.' - ), - }), - handler: async (args, context) => { - try { - const { apiKey } = await context.getCredentials(); - - const requestBody: Record = { - agent_id: args.agent_id, - agent_phone_number_id: args.from_phone_number_id, - to_number: args.to_number, - }; - - // Add any additional context message - if (args.message) { - requestBody.additional_context = args.message; - } - - const response = await makeElevenLabsRequest( - `/convai/twilio/outbound-call`, - apiKey, - { - method: 'POST', - body: JSON.stringify(requestBody), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`ElevenLabs API error: ${response.status} - ${errorText}`); - } - - const result = (await response.json()) as PhoneCallResponse; - - return JSON.stringify({ - success: true, - conversation_id: result.conversation_id, - callSid: result.callSid, - agent_id: args.agent_id, - to_number: args.to_number, - from_phone_number_id: args.from_phone_number_id, - message: result.message || 'Phone call initiated successfully via Twilio.', - }); - } catch (error) { - console.error('Make phone call 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.ts b/packages/mcp-connectors/src/connectors/unipile.ts index 1f283b5a..aecedd28 100644 --- a/packages/mcp-connectors/src/connectors/unipile.ts +++ b/packages/mcp-connectors/src/connectors/unipile.ts @@ -128,7 +128,7 @@ class UnipileClient { private headers: { 'X-API-Key': string; 'Content-Type': string }; constructor(dsn: string, apiKey: string) { - this.baseUrl = `https://${dsn}`; + this.baseUrl = dsn.startsWith('http') ? dsn : `https://${dsn}`; this.headers = { 'X-API-Key': apiKey, 'Content-Type': 'application/json', @@ -266,14 +266,28 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ 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 account details including connection parameters, ID, name, creation date, signatures, groups, and sources.', + 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(); - return JSON.stringify(response); + + // 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)}`, @@ -283,7 +297,7 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), GET_CHATS: tool({ name: 'unipile_get_chats', - description: 'Get all available chats for a specific account. Supports messaging platforms: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger.', + 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)'), @@ -293,7 +307,19 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ const { dsn, apiKey } = await context.getCredentials(); const client = new UnipileClient(dsn, apiKey); const response = await client.getChats(args.account_id, args.limit); - return JSON.stringify(response); + + // 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)}`, @@ -303,17 +329,45 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), GET_CHAT_MESSAGES: tool({ name: 'unipile_get_chat_messages', - description: 'Get all messages from a specific chat. Supports messages from: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Returns message details including text content, sender info, timestamps, and participant information.', + 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 fetch (default: 100)'), + 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); - return JSON.stringify(response); + + // 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)}`, @@ -323,17 +377,48 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), GET_RECENT_MESSAGES: tool({ name: 'unipile_get_recent_messages', - description: 'Get recent messages from all chats associated with a specific account. Supports messages from: Mobile, Mail, WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Returns message details including text content, sender info, timestamps, attachments, reactions, quoted messages, and metadata.', + 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 fetch per chat (default: 20)'), + 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); - return JSON.stringify({ messages }); + + // 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)}`, @@ -458,6 +543,66 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ } }, }), + SAVE_CONTACT: tool({ + name: 'unipile_save_contact', + description: 'Save or update a contact with essential information. Stores clean contact data without platform metadata. Allows flexible custom fields that the LLM can define. This is intentional - only call when user specifically wants to save contact information.', + schema: z.object({ + name: z.string().describe('Contact name (required)'), + phone_number: z.string().optional().describe('Phone number for WhatsApp/SMS'), + whatsapp_chat_id: z.string().optional().describe('WhatsApp chat ID for direct messaging'), + linkedin_chat_id: z.string().optional().describe('LinkedIn chat ID for direct messaging'), + email: z.string().optional().describe('Email address'), + custom_fields: z.record(z.any()).optional().describe('Any additional fields the LLM wants to store (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 || {}, + 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)}`, + }); + } + }, + }), CLEAR_CONTACT_MEMORY: tool({ name: 'unipile_clear_contact_memory', description: 'Clear all stored contact frequency data from MCP persistent memory.', @@ -486,7 +631,21 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ const { dsn, apiKey } = await context.getCredentials(); const client = new UnipileClient(dsn, apiKey); const response = await client.getAccounts(); - return JSON.stringify(response); + + // 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 6d11e032..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'; @@ -54,7 +54,6 @@ export const Connectors: readonly MCPConnectorConfig[] = [ DeepseekConnectorConfig, DocumentationConnectorConfig, DuckDuckGoConnectorConfig, - ElevenLabsConnectorConfig, ExaConnectorConfig, FalConnectorConfig, GitHubConnectorConfig, @@ -98,7 +97,6 @@ export { DeepseekConnectorConfig, DocumentationConnectorConfig, DuckDuckGoConnectorConfig, - ElevenLabsConnectorConfig, ExaConnectorConfig, FalConnectorConfig, GitHubConnectorConfig, From 8541e8239a978b2bae106f9dba5817a61d3a96db Mon Sep 17 00:00:00 2001 From: lchavasse Date: Sat, 30 Aug 2025 23:01:35 +0100 Subject: [PATCH 5/5] Added a smart search contacts function that uses saved contacts and then searches through previous chats to find contacts as quickly as possible, allowing you to send messages with the functions. --- .../src/connectors/UNIPILE_README.md | 359 ++++++--- .../src/connectors/unipile.spec.ts | 748 +++++++++++++----- .../mcp-connectors/src/connectors/unipile.ts | 554 ++++++++++--- 3 files changed, 1237 insertions(+), 424 deletions(-) diff --git a/packages/mcp-connectors/src/connectors/UNIPILE_README.md b/packages/mcp-connectors/src/connectors/UNIPILE_README.md index cd617abb..ec3e4339 100644 --- a/packages/mcp-connectors/src/connectors/UNIPILE_README.md +++ b/packages/mcp-connectors/src/connectors/UNIPILE_README.md @@ -1,10 +1,10 @@ # Unipile MCP Connector -A comprehensive Model Context Protocol (MCP) connector for the Unipile API, enabling multi-platform messaging integration with persistent contact frequency tracking. +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 frequency tracking with persistent memory to identify your most frequently messaged contacts. +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 @@ -15,17 +15,29 @@ The Unipile MCP Connector provides seamless access to messaging across multiple - **Email integration**: Access and manage emails - **Real-time messaging**: Send messages with immediate delivery -### 🧠 Intelligent Contact Memory +### 🧠 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 -- **Top contacts**: Get your most frequently messaged contacts (configurable limit) -- **Memory management**: Clear or view stored contact data +- **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 @@ -72,15 +84,17 @@ Get all available chats for a specific account. ``` #### `unipile_get_chat_messages` -Get all messages from a specific chat. +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) + 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. @@ -104,18 +118,23 @@ Get recent emails from a specific account. ### 💬 Messaging #### `unipile_send_message` -Send a text message to a specific chat with automatic frequency tracking. +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 (for frequency tracking) - platform?: string, // Platform type (e.g., WHATSAPP, LINKEDIN) - account_id?: string // Account ID (for frequency tracking) + 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 { @@ -127,43 +146,110 @@ Send a text message to a specific chat with automatic frequency tracking. } ``` -### 🧠 Persistent Memory Tools +### 🧠 Contact Management Tools -#### `unipile_get_top_contacts` -Get the most frequently messaged contacts based on persistent memory. +#### `unipile_search_contacts` +Intelligent contact search using lexical search with progressive fallback to chat history. ```typescript { - limit?: number // Max contacts to return (default: 10) + query: string, // Search query (name, email, etc.) + account_type?: string, // Filter by platform (WHATSAPP, LINKEDIN, etc.) + account_id?: string // Filter by specific account } ``` -**Returns:** +**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 { - "contacts": [ - { - "id": "chat_id", - "name": "Contact Name", - "messageCount": 15, - "lastMessageTime": "2025-08-28T18:20:49.623Z", - "platform": "WHATSAPP", - "accountId": "account_id" - } - ] + "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 from persistent memory with frequency data. +Get all stored contacts with complete field information. ```typescript // No parameters required -// Returns: All contacts with their frequency data and total count +``` + +**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 frequency data from persistent memory. +Clear all stored contact data from persistent memory. ```typescript // No parameters required @@ -172,33 +258,67 @@ Clear all stored contact frequency data from persistent memory. ## Usage Examples -### Basic Message Sending +### Smart Contact Search & Messaging ```typescript -// Send a simple message -await unipile_send_message({ - chat_id: "chat_123", - text: "Hello there!" +// Step 1: Search for a contact by name +const searchResult = await unipile_search_contacts({ + query: "Felix", + account_type: "WHATSAPP" }); -// Send with frequency tracking -await unipile_send_message({ - chat_id: "chat_123", - text: "Hello there!", - contact_name: "John Doe", - platform: "WHATSAPP", - account_id: "account_456" +// 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" }); ``` -### Get Top Contacts +### Batch Operations ```typescript -// Get top 10 most frequent contacts -const topContacts = await unipile_get_top_contacts({ limit: 10 }); +// Get all contacts to review +const allContacts = await unipile_get_all_stored_contacts(); +console.log(`Managing ${allContacts.count} contacts`); -// Get top 5 contacts -const top5 = await unipile_get_top_contacts({ limit: 5 }); +// Clear memory when needed (for privacy) +await unipile_clear_contact_memory(); ``` ### Account Discovery @@ -228,77 +348,96 @@ The connector uses MCP's built-in persistence system (`context.setData` and `con ```json { "unipile_contacts": { - "chat_id_1": { - "id": "chat_id_1", - "name": "Contact Name", - "messageCount": 15, - "lastMessageTime": "2025-08-28T18:20:49.623Z", - "platform": "WHATSAPP", - "accountId": "account_id" + "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 -### Accounts Response +All responses are filtered and optimized for LLM consumption, removing unnecessary fields and providing clean, relevant data. + +### Cleaned Accounts Response ```json { - "items": [ + "accounts": [ { "id": "account_id", - "name": "Phone Number", + "name": "Phone Number", "type": "WHATSAPP", - "created_at": "2025-08-28T17:21:14.163Z", - "sources": [ - { - "id": "source_id_MESSAGING", - "status": "OK" - } - ] + "status": "OK", + "source_id": "source_id_MESSAGING", + "created_at": "2025-08-28T17:21:14.163Z" } - ] + ], + "count": 1 } ``` -### Chats Response +### Cleaned Chats Response ```json { - "items": [ + "chats": [ { "id": "chat_id", - "name": "Chat Name", - "type": 1, - "folder": ["INBOX"], - "unread": 0, - "timestamp": "2025-08-28T17:49:58.000Z", - "account_id": "account_id", - "account_type": "WHATSAPP", - "unread_count": 6 + "name": "Felix Enslin", + "unread": 6, + "timestamp": "2025-08-28T17:49:58.000Z" } - ] + ], + "count": 1 } ``` -### Messages Response +### Cleaned Messages Response ```json { - "items": [ + "messages": [ { "id": "msg_id", "text": "Hello world", "timestamp": "2025-08-28T17:49:58.000Z", - "sender_id": "sender@example.com", - "chat_id": "chat_id", - "account_id": "account_id", - "is_sender": 0, - "attachments": [] + "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): @@ -342,16 +481,23 @@ Common error scenarios: ## Best Practices -### Message Sending -1. **Always provide contact info** for frequency tracking -2. **Use descriptive contact names** for better memory organization -3. **Check chat existence** before sending messages -4. **Handle rate limits** gracefully in production +### 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 -### Memory Management -1. **Regularly check top contacts** to understand communication patterns -2. **Clear memory periodically** if needed for privacy -3. **Use appropriate limits** when fetching contacts to avoid large responses +### 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 @@ -373,10 +519,17 @@ Common error scenarios: - Check that account is properly connected - Refresh chat list to get current IDs -**Messages not being tracked:** -- Ensure all optional parameters are provided to `unipile_send_message` -- Check that contact data is being stored with `unipile_get_all_stored_contacts` -- Verify persistence is working with `unipile_get_top_contacts` +**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 @@ -407,6 +560,14 @@ For support and questions: --- -**Version**: 1.0.0 -**Last Updated**: August 28, 2025 -**MCP Framework**: Compatible with MCP 1.0+ \ No newline at end of file +**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/unipile.spec.ts b/packages/mcp-connectors/src/connectors/unipile.spec.ts index 27e0f8b8..0fe19dd8 100644 --- a/packages/mcp-connectors/src/connectors/unipile.spec.ts +++ b/packages/mcp-connectors/src/connectors/unipile.spec.ts @@ -1,163 +1,121 @@ -import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest'; +import type { MCPToolDefinition } from '@stackone/mcp-config-types'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import type { MCPToolDefinition } from '@stackone/mcp-config-types'; +import { describe, expect, it } from 'vitest'; import { createMockConnectorContext } from '../__mocks__/context'; import { UnipileConnectorConfig } from './unipile'; -const server = setupServer( - http.get('https://api8.unipile.com:13851/accounts', () => { - return HttpResponse.json({ - items: [ - { - id: 'account-1', - name: 'Test Account', - type: 'WHATSAPP', - created_at: '2024-01-01', - sources: [ - { id: 'source-1', status: 'OK' }, - { id: 'source-2_MAILS', status: 'OK' }, - ], - }, - ], - }); - }), - http.get('https://api8.unipile.com:13851/chats', ({ request }) => { - const url = new URL(request.url); - const accountId = url.searchParams.get('account_id'); - const limit = url.searchParams.get('limit'); - - return HttpResponse.json({ - items: [ - { - id: 'chat-1', - name: 'Test Chat', - type: 1, - folder: ['INBOX'], - unread: 0, - archived: 0, - read_only: 0, - timestamp: '2024-01-01T10:00:00Z', - account_id: accountId, - account_type: 'WHATSAPP', - unread_count: 0, - provider_id: 'test-provider', - attendee_provider_id: 'test-attendee', - muted_until: null, - }, - ], - }); - }), - http.get('https://api8.unipile.com:13851/messages', ({ request }) => { - const url = new URL(request.url); - const chatId = url.searchParams.get('chat_id'); - - return HttpResponse.json({ - items: [ - { - id: 'msg-1', - text: 'Hello, this is a test message', - timestamp: '2024-01-01T10:00:00Z', - sender_id: 'sender-1', - chat_id: chatId, - account_id: 'account-1', - provider_id: 'msg-provider-1', - chat_provider_id: 'chat-provider-1', - sender_attendee_id: 'attendee-1', - seen: 0, - edited: 0, - hidden: 0, - deleted: 0, - delivered: 1, - is_sender: 0, - is_event: 0, - attachments: [], - reactions: [], - seen_by: {}, - behavior: null, - subject: null, - }, - ], - }); - }), - http.get('https://api8.unipile.com:13851/emails', ({ request }) => { - const url = new URL(request.url); - const accountId = url.searchParams.get('account_id'); - - return HttpResponse.json({ - items: [ - { - id: 'email-1', - subject: 'Test Email', - date: '2024-01-01T10:00:00Z', - role: 'inbox', - folders: ['INBOX'], - has_attachments: false, - from: 'sender@example.com', - to: ['recipient@example.com'], - cc: [], - body_markdown: '# Test Email\n\nThis is a test email.', - }, - ], - }); - }) -); - -beforeAll(() => server.listen()); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); - describe('#UnipileConnector', () => { describe('.GET_ACCOUNTS', () => { describe('when credentials are valid', () => { - it('returns list of connected accounts', async () => { + 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(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + 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.items).toHaveLength(1); - expect(response.items[0].id).toBe('account-1'); - expect(response.items[0].name).toBe('Test Account'); + 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 API request fails', () => { - it('returns error message', async () => { - server.use( + describe('when account has no sources', () => { + it('handles missing sources gracefully', async () => { + const server = setupServer( http.get('https://api8.unipile.com:13851/accounts', () => { - return new HttpResponse(null, { status: 401 }); + 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(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'invalid-key', + 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.error).toContain('Failed to get accounts'); + 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 list of chats for the account', async () => { + 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(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, }); const actual = await tool.handler( @@ -166,164 +124,538 @@ describe('#UnipileConnector', () => { ); const response = JSON.parse(actual); - expect(response.items).toHaveLength(1); - expect(response.items[0].id).toBe('chat-1'); - expect(response.items[0].name).toBe('Test Chat'); + 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 account_id has suffix', () => { - it('removes suffix and returns chats', async () => { + 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(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, }); - const actual = await tool.handler( - { account_id: 'source-1_MESSAGING' }, - mockContext - ); + const actual = await tool.handler({ account_id: 'source-1' }, mockContext); const response = JSON.parse(actual); - expect(response.items).toHaveLength(1); + expect(response.chats[0].name).toBe('Unnamed Chat'); + + server.close(); }); }); }); describe('.GET_CHAT_MESSAGES', () => { describe('when chat_id is provided', () => { - it('returns messages from the chat', async () => { + 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(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, }); const actual = await tool.handler( - { chat_id: 'chat-1', batch_size: 100 }, + { chat_id: 'chat-1', batch_size: 10 }, mockContext ); const response = JSON.parse(actual); - expect(response.items).toHaveLength(1); - expect(response.items[0].id).toBe('msg-1'); - expect(response.items[0].text).toBe('Hello, this is a test message'); + 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 not provided', () => { - it('uses default batch size', async () => { + 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(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + const mockContext = createMockConnectorContext({ + credentials: { + dsn: 'api8.unipile.com:13851', + apiKey: 'test-api-key', + }, }); - const actual = await tool.handler({ chat_id: 'chat-1' }, mockContext); + const actual = await tool.handler( + { chat_id: 'chat-1', batch_size: 2 }, + mockContext + ); const response = JSON.parse(actual); - expect(response.items).toHaveLength(1); + expect(response.messages).toHaveLength(2); + expect(response.count).toBe(2); + expect(response.total_available).toBe(3); + + server.close(); }); }); }); - describe('.GET_RECENT_MESSAGES', () => { - describe('when account_id is provided', () => { - it('returns messages from all chats in the account', async () => { - const tool = UnipileConnectorConfig.tools.GET_RECENT_MESSAGES as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + 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( - { account_id: 'source-1', batch_size: 20 }, + { + 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.messages).toHaveLength(1); - expect(response.messages[0].chat_info).toBeDefined(); - expect(response.messages[0].chat_info.id).toBe('chat-1'); + 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 batch_size is not provided', () => { - it('uses default batch size', async () => { - const tool = UnipileConnectorConfig.tools.GET_RECENT_MESSAGES as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + 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({ account_id: 'source-1' }, mockContext); + 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.messages).toHaveLength(1); + 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('.GET_EMAILS', () => { - describe('when account_id is provided', () => { - it('returns emails from the account', async () => { - const tool = UnipileConnectorConfig.tools.GET_EMAILS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + 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( - { account_id: 'source-2', limit: 10 }, + { + 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.items).toHaveLength(1); - expect(response.items[0].id).toBe('email-1'); - expect(response.items[0].subject).toBe('Test Email'); + 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 account_id has mail suffix', () => { - it('removes suffix and returns emails', async () => { - const tool = UnipileConnectorConfig.tools.GET_EMAILS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + 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( - { account_id: 'source-2_MAILS' }, + { + contact_id: 'nonexistent-contact', + name: 'Updated Name', + }, mockContext ); const response = JSON.parse(actual); - expect(response.items).toHaveLength(1); + expect(response.error).toContain( + 'Contact with ID "nonexistent-contact" not found' + ); + expect(response.available_contacts).toEqual(['contact-456']); }); }); + }); - describe('when limit is not provided', () => { - it('uses default limit', async () => { - const tool = UnipileConnectorConfig.tools.GET_EMAILS as MCPToolDefinition; - const mockContext = createMockConnectorContext(); - mockContext.getCredentials.mockResolvedValue({ - dsn: 'api8.unipile.com:13851', - apiKey: 'test-api-key', + 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({ account_id: 'source-2' }, mockContext); + 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.items).toHaveLength(1); + expect(response.success).toBe(true); + expect(response.message).toContain('Contact memory cleared'); + expect(mockContext.setData).toHaveBeenCalledWith('unipile_contacts', {}); }); }); }); diff --git a/packages/mcp-connectors/src/connectors/unipile.ts b/packages/mcp-connectors/src/connectors/unipile.ts index aecedd28..3b9b720e 100644 --- a/packages/mcp-connectors/src/connectors/unipile.ts +++ b/packages/mcp-connectors/src/connectors/unipile.ts @@ -1,5 +1,6 @@ import { mcpConnectorConfig } from '@stackone/mcp-config-types'; import { z } from 'zod'; +import { createIndex, search } from '../utils/lexical-search'; interface UnipileAccount { id: string; @@ -207,7 +208,7 @@ class UnipileClient { for (const chat of chatsResponse.items) { try { const messagesResponse = await this.getMessages(chat.id); - const messagesWithChatInfo = messagesResponse.items.map(message => ({ + const messagesWithChatInfo = messagesResponse.items.map((message) => ({ ...message, chat_info: { id: chat.id, @@ -266,16 +267,17 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ 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.', + 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 => ({ + const cleanedAccounts = response.items.map((account) => ({ id: account.id, name: account.name, type: account.type, @@ -283,10 +285,10 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ status: account.sources?.[0]?.status || 'UNKNOWN', source_id: account.sources?.[0]?.id || account.id, })); - + return JSON.stringify({ accounts: cleanedAccounts, - count: cleanedAccounts.length + count: cleanedAccounts.length, }); } catch (error) { return JSON.stringify({ @@ -297,28 +299,37 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), 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.', + 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)'), + 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 => ({ + 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 + count: cleanedChats.length, }); } catch (error) { return JSON.stringify({ @@ -329,27 +340,31 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), 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.', + 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)'), + 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 + error: 'Unexpected response format', + raw_response: response, }); } - - const cleanedMessages = items.map(message => ({ + + const cleanedMessages = items.map((message) => ({ id: message.id, text: message.text || '[No text content]', timestamp: message.timestamp, @@ -357,16 +372,16 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ 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 + const finalMessages = args.batch_size ? cleanedMessages.slice(0, args.batch_size) : cleanedMessages; - + return JSON.stringify({ messages: finalMessages, count: finalMessages.length, - total_available: cleanedMessages.length + total_available: cleanedMessages.length, }); } catch (error) { return JSON.stringify({ @@ -377,27 +392,37 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), 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.', + 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)'), + 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 || []); + const items = Array.isArray(messages) + ? messages + : (messages as any).items || []; if (!Array.isArray(items)) { return JSON.stringify({ - error: "Unexpected response format", - raw_response: messages + error: 'Unexpected response format', + raw_response: messages, }); } - - const cleanedMessages = items.map(message => ({ + + const cleanedMessages = items.map((message) => ({ id: message.id, text: message.text || '[No text content]', timestamp: message.timestamp, @@ -407,17 +432,18 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ 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 + 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." + warning: + 'This tool returns data from multiple chats. Consider using GET_CHATS then GET_CHAT_MESSAGES for specific conversations.', }); } catch (error) { return JSON.stringify({ @@ -428,10 +454,14 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), 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.', + 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)'), + limit: z + .number() + .optional() + .describe('Maximum number of emails to return (default: 10)'), }), handler: async (args, context) => { try { @@ -448,47 +478,69 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), SEND_MESSAGE: tool({ name: 'unipile_send_message', - description: 'Send a text message to a specific chat. Works with all supported messaging platforms: WhatsApp, LinkedIn, Slack, Twitter, Telegram, Instagram, Messenger. Automatically tracks contact frequency for persistent memory.', + 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 (for frequency tracking)'), - platform: z.string().optional().describe('Platform type (e.g., WHATSAPP, LINKEDIN)'), - account_id: z.string().optional().describe('Account ID for frequency tracking'), + 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 contact frequency using MCP persistence if contact info provided - if (args.contact_name && args.platform && args.account_id) { - const contactsData = await context.getData>('unipile_contacts') || {}; + + // 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 - contactsData[args.chat_id].messageCount++; - contactsData[args.chat_id].lastMessageTime = now; - contactsData[args.chat_id].name = args.contact_name; // Update name in case it changed + // 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 { - // Add new contact + // 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, - lastMessageTime: now, - messageCount: 1, - platform: args.platform, - accountId: args.account_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({ @@ -497,45 +549,51 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ } }, }), - GET_TOP_CONTACTS: tool({ - name: 'unipile_get_top_contacts', - description: 'Get the most frequently messaged contacts based on MCP persistent memory. Returns up to 10 contacts sorted by message frequency and recent activity.', + 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({ - limit: z.number().optional().describe('Maximum number of contacts to return (default: 10)'), + 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') || {}; - - // Convert to array and sort - const contacts = Object.values(contactsData) - .sort((a: any, b: any) => { - if (a.messageCount !== b.messageCount) { - return b.messageCount - a.messageCount; - } - return new Date(b.lastMessageTime).getTime() - new Date(a.lastMessageTime).getTime(); - }) - .slice(0, args.limit || 10); - - return JSON.stringify({ contacts }); - } catch (error) { + 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({ - error: `Failed to get top contacts: ${error instanceof Error ? error.message : String(error)}`, + 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', + ], }); - } - }, - }), - GET_ALL_STORED_CONTACTS: tool({ - name: 'unipile_get_all_stored_contacts', - description: 'Get all stored contacts from MCP persistent memory with their frequency data.', - schema: z.object({}), - handler: async (args, context) => { - try { - // Use MCP's built-in persistence - const contactsData = await context.getData>('unipile_contacts') || {}; - const contacts = Object.values(contactsData); - return JSON.stringify({ contacts, count: contacts.length }); } catch (error) { return JSON.stringify({ error: `Failed to get stored contacts: ${error instanceof Error ? error.message : String(error)}`, @@ -545,27 +603,44 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }), SAVE_CONTACT: tool({ name: 'unipile_save_contact', - description: 'Save or update a contact with essential information. Stores clean contact data without platform metadata. Allows flexible custom fields that the LLM can define. This is intentional - only call when user specifically wants to save contact information.', + 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'), - whatsapp_chat_id: z.string().optional().describe('WhatsApp chat ID for direct messaging'), - linkedin_chat_id: z.string().optional().describe('LinkedIn chat ID for direct messaging'), + 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('Any additional fields the LLM wants to store (key-value pairs)'), + 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 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)}`; - + 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, @@ -576,25 +651,26 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ 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 => { + 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 + contact: contactData, }); } catch (error) { return JSON.stringify({ @@ -603,6 +679,91 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ } }, }), + 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.', @@ -611,7 +772,10 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ try { // Use MCP's built-in persistence await context.setData('unipile_contacts', {}); - return JSON.stringify({ success: true, message: 'Contact memory cleared using MCP persistence' }); + 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)}`, @@ -619,11 +783,167 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ } }, }), + 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', + 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) => { @@ -631,9 +951,9 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ 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 => ({ + const cleanedAccounts = response.items.map((account) => ({ id: account.id, name: account.name, type: account.type, @@ -641,10 +961,10 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ status: account.sources?.[0]?.status || 'UNKNOWN', source_id: account.sources?.[0]?.id || account.id, })); - + return JSON.stringify({ accounts: cleanedAccounts, - count: cleanedAccounts.length + count: cleanedAccounts.length, }); } catch (error) { return JSON.stringify({ @@ -654,4 +974,4 @@ export const UnipileConnectorConfig = mcpConnectorConfig({ }, }), }), -}); \ No newline at end of file +});