diff --git a/.github/workflows/pr-validator.yml b/.github/workflows/pr-validator.yml index ec5f8ed..c6f31b9 100644 --- a/.github/workflows/pr-validator.yml +++ b/.github/workflows/pr-validator.yml @@ -12,6 +12,10 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/package.json b/package.json index f129ca4..f13c124 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "packages/openapi-mcp-server", "packages/mcp" ], + "bin": { + "twilio-mcp-server": "./packages/mcp/build/index.js" + }, "scripts": { "build": "npm run build --workspaces", "lint": "npm run lint --workspaces", diff --git a/packages/mcp/README.md b/packages/mcp/README.md index a8c2203..59dc4ea 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -28,6 +28,8 @@ The easiest way to get started is to edit the configuration of your client to po } ``` +Alternatively, use the `npx -y @twilio-alpha config` command to set your credentials, choose tags and services, and automatically save MCP client configuration for Cursor or Claude Desktop. + Visit [Twilio API Keys docs](https://www.twilio.com/docs/iam/api-keys) for information on how to find/create your apiKey/apiSecret. ## Configuration Parameters diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bd82773..8a53792 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -36,6 +36,7 @@ "@twilio-alpha/openapi-mcp-server": "0.1.2", "@apidevtools/swagger-parser": "^10.1.1", "@modelcontextprotocol/sdk": "^1.7.0", + "chalk": "^5.4.1", "inquirer": "^12.5.0", "keytar": "^7.9.0", "minimist": "^1.2.8", diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts new file mode 100644 index 0000000..f941e64 --- /dev/null +++ b/packages/mcp/src/config.ts @@ -0,0 +1,316 @@ +/* eslint-disable no-console */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import chalk from 'chalk'; +import inquirer from 'inquirer'; + +import { auth, isValidTwilioSid } from '@app/utils'; + +interface Credentials { + accountSid: string; + apiKey: string; + apiSecret: string; +} + +interface MCPConfig { + mcpServers: { + [key: string]: { + command: string; + args: string[]; + }; + }; +} + +interface OpenAPIConfig { + tags?: string; + services?: string; +} + +const CONFIG_PATHS = { + CURSOR: path.join(os.homedir(), '.cursor', 'mcp.json'), + CLAUDE: path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Claude', + 'claude_desktop_config.json', + ), +} as const; + +const MCP_SERVER_NAME = 'twilio'; +const DEFAULT_SERVICE = 'twilio_api_v2010'; + +async function readConfigFile(configPath: string): Promise { + try { + if (fs.existsSync(configPath)) { + return JSON.parse(fs.readFileSync(configPath, 'utf8')); + } + } catch (error) { + console.error( + chalk.red('✗'), + `Error reading configuration file: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + return { mcpServers: {} }; +} + +async function configureCursor(executableArgs: string[]) { + console.info(chalk.green('👀'), 'Checking for Cursor configuration...'); + + const cursorConfig = await readConfigFile(CONFIG_PATHS.CURSOR); + + if (MCP_SERVER_NAME in cursorConfig.mcpServers) { + console.info( + chalk.yellow('→'), + "Twilio MCP server already configured; we'll update it with the new configuration.", + ); + } + + cursorConfig.mcpServers[MCP_SERVER_NAME] = { + command: 'npx', + args: executableArgs, + }; + + try { + fs.writeFileSync( + CONFIG_PATHS.CURSOR, + JSON.stringify(cursorConfig, null, 2), + ); + console.info(chalk.green('✔'), 'Cursor configuration set!'); + } catch (error) { + console.error( + chalk.red('✗'), + `Failed to write Cursor configuration: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + process.exit(1); + } +} + +async function configureClaudeDesktop(executableArgs: string[]) { + console.info( + chalk.green('👀'), + 'Checking for Claude Desktop configuration...', + ); + + const existingConfig = await readConfigFile(CONFIG_PATHS.CLAUDE); + + if (MCP_SERVER_NAME in existingConfig.mcpServers) { + console.info( + chalk.yellow('→'), + "Twilio MCP server already configured; we'll update it with the new configuration.", + ); + } + + existingConfig.mcpServers[MCP_SERVER_NAME] = { + command: 'npx', + args: executableArgs, + }; + + try { + fs.writeFileSync( + CONFIG_PATHS.CLAUDE, + JSON.stringify(existingConfig, null, 2), + ); + console.info(chalk.green('✔'), 'Claude Desktop configuration set!'); + } catch (error) { + console.error( + chalk.red('✗'), + `Failed to write Claude Desktop configuration: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + process.exit(1); + } +} + +async function promptForOverwrite(currentSid: string): Promise { + const { overwrite } = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: `Credentials already set for account \`${currentSid}\`. Overwrite?`, + default: false, + }, + ]); + return overwrite; +} + +async function promptForCredentials(): Promise { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'accountSid', + message: 'Enter your Twilio account SID:', + validate: (input) => + isValidTwilioSid(input, 'AC') || 'Invalid Account SID format', + }, + { + type: 'password', + name: 'apiKey', + message: 'Enter your Twilio API Key SID:', + validate: (input) => + isValidTwilioSid(input, 'SK') || 'Invalid API Key SID format', + }, + { + type: 'password', + name: 'apiSecret', + message: 'Enter your Twilio API Key Secret:', + validate: (input) => input.length > 0 || 'API Secret is required', + }, + ]); + + return answers; +} + +async function promptForOpenAPIConfig(): Promise { + const tagsAnswers = await inquirer.prompt([ + { + type: 'confirm', + name: 'tags', + message: 'Do you want to use specific Twilio OpenAPI tags?', + default: false, + }, + ]); + + if (tagsAnswers.tags) { + const { tags } = await inquirer.prompt([ + { + type: 'input', + name: 'tags', + message: + 'Enter the Twilio OpenAPI tags you want to use (comma-separated):', + validate: (input) => { + if (!input.trim()) { + return 'Tags cannot be empty'; + } + + const tagList = input.split(',').map((tag) => tag.trim()); + + return tagList.every((tag) => tag.length > 0) || 'Invalid tag format'; + }, + }, + ]); + return { tags }; + } + + const servicesAnswers = await inquirer.prompt([ + { + type: 'confirm', + name: 'services', + message: 'Do you want to use specific Twilio OpenAPI services?', + default: false, + }, + ]); + + if (servicesAnswers.services) { + const { services } = await inquirer.prompt([ + { + type: 'input', + name: 'services', + message: + 'Enter the Twilio OpenAPI services you want to use (comma-separated):', + validate: (input) => { + if (!input.trim()) { + return 'Services cannot be empty'; + } + + const serviceList = input.split(',').map((service) => service.trim()); + + return ( + serviceList.every((service) => service.length > 0) || + 'Invalid service format' + ); + }, + }, + ]); + return { services }; + } + + console.info( + chalk.yellow('→'), + `No services selected; the default service will be used, \`${DEFAULT_SERVICE}\`.`, + ); + return {}; +} + +async function configureClient(executableArgs: string[]) { + const { clientConfig } = await inquirer.prompt([ + { + type: 'list', + name: 'clientConfig', + message: 'Which MCP client you want to configure?', + choices: [ + { + name: 'None / Manual Configuration', + value: 'manual', + }, + { + name: 'Cursor', + value: 'cursor', + }, + { + name: 'Claude Desktop', + value: 'claude-desktop', + }, + ], + }, + ]); + + switch (clientConfig) { + case 'cursor': + await configureCursor(executableArgs); + break; + case 'claude-desktop': + await configureClaudeDesktop(executableArgs); + break; + case 'manual': + default: + console.info( + chalk.green('👀'), + 'Use the following `npx` command for the configuration in your MCP client:', + ); + console.info(`npx ${executableArgs.join(' ')}`); + } +} + +export default async function config() { + let currentCredentials = await auth.getCredentials(); + let shouldOverwrite = false; + + if (currentCredentials) { + shouldOverwrite = await promptForOverwrite(currentCredentials.accountSid); + } + + if (currentCredentials && !shouldOverwrite) { + console.info(chalk.green('✔'), 'Keeping existing credentials'); + } else { + const authAnswers = await promptForCredentials(); + await auth.setCredentials( + authAnswers.accountSid, + authAnswers.apiKey, + authAnswers.apiSecret, + ); + currentCredentials = await auth.getCredentials(); + } + + const openAPIConfig = await promptForOpenAPIConfig(); + + const executableArgs = ['-y', '@twilio-alpha/mcp']; + + if (openAPIConfig.tags) { + executableArgs.push('--tags', openAPIConfig.tags); + } + + if (openAPIConfig.services) { + executableArgs.push('--services', openAPIConfig.services); + } + + await configureClient(executableArgs); + console.info(chalk.green('✔'), 'All set!'); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e8ce240..39fadce 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,14 +1,14 @@ #!/usr/bin/env node import { logger } from '@twilio-alpha/openapi-mcp-server'; -import init from '@app/init'; +import config from '@app/config'; import main from '@app/main'; const command = process.argv[2]; -if (command === 'init') { - init().catch((error) => { - logger.error(`Fatal error in init(): ${error}`); +if (command === 'config') { + config().catch((error) => { + logger.error(`Fatal error in config(): ${error}`); process.exit(1); }); } else { diff --git a/packages/mcp/src/init.ts b/packages/mcp/src/init.ts deleted file mode 100644 index 687b8b0..0000000 --- a/packages/mcp/src/init.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { logger } from '@twilio-alpha/openapi-mcp-server'; -import inquirer from 'inquirer'; - -import { auth } from '@app/utils'; - -export default async function init() { - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'accountSid', - message: 'Enter your Twilio account SID:', - }, - { - type: 'input', - name: 'apiKey', - message: 'Enter your Twilio API key:', - }, - { - type: 'input', - name: 'apiSecret', - message: 'Enter your Twilio API secret:', - }, - ]); - - if (!answers.accountSid || !answers.apiKey || !answers.apiSecret) { - logger.error('Error: All fields are required'); - process.exit(1); - } - - await auth.setCredentials( - answers.accountSid, - answers.apiKey, - answers.apiSecret, - ); - - logger.info('Credentials set'); -} diff --git a/packages/mcp/src/utils/args.ts b/packages/mcp/src/utils/args.ts index b7eb656..b36eb67 100644 --- a/packages/mcp/src/utils/args.ts +++ b/packages/mcp/src/utils/args.ts @@ -49,6 +49,7 @@ const parsedArgs = async (argv: string[]): Promise => { firstArg.includes('/') ) { const credsMatch = firstArg.match(/^([^/]+)\/([^:]+):(.+)$/); + if (credsMatch) { const potentialAccountSid = credsMatch[1]; const potentialApiKey = credsMatch[2]; @@ -65,11 +66,12 @@ const parsedArgs = async (argv: string[]): Promise => { } } - if (!isValidTwilioSid(accountSid, 'AC')) { + if (accountSid && !isValidTwilioSid(accountSid, 'AC')) { logger.error('Error: Invalid AccountSid'); process.exit(1); } - if (!isValidTwilioSid(apiKey, 'SK')) { + + if (apiKey && !isValidTwilioSid(apiKey, 'SK')) { logger.error('Error: Invalid ApiKey'); process.exit(1); } diff --git a/packages/mcp/tests/config.spec.ts b/packages/mcp/tests/config.spec.ts new file mode 100644 index 0000000..0cc9c2f --- /dev/null +++ b/packages/mcp/tests/config.spec.ts @@ -0,0 +1,226 @@ +import fs from 'fs'; + +import inquirer, { type Answers, type Question } from 'inquirer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isValidTwilioSid } from '@app/utils'; + +import config from '../src/config'; + +// Mock dependencies +vi.mock('inquirer', () => ({ + default: { + prompt: vi.fn(), + }, +})); +vi.mock('fs'); +vi.mock('@app/utils'); + +interface ValidatedQuestion extends Question { + validate?: (input: string) => boolean | string; + name: string; +} + +// Helper to get around type issues with inquirer.prompt mocking +const mockInquirerPrompt = ( + impl: (questions: Question[] | Question) => any, +) => { + return vi.mocked(inquirer.prompt).mockImplementation(impl); +}; + +describe('config', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock fs.existsSync to return true by default + vi.mocked(fs.existsSync).mockReturnValue(true); + // Mock fs.readFileSync to return empty config + vi.mocked(fs.readFileSync).mockReturnValue('{"mcpServers":{}}'); + // Mock isValidTwilioSid to return true by default + vi.mocked(isValidTwilioSid).mockReturnValue(true); + }); + + it('should prompt for client selection', async () => { + // Mock the prompts sequence + const mockPrompt = vi.mocked(inquirer.prompt); + + // Setup the mock to return the desired values + mockPrompt.mockResolvedValueOnce({ clientConfig: 'cursor' } as any); + mockPrompt.mockResolvedValueOnce({ tags: false } as any); + mockPrompt.mockResolvedValueOnce({ services: false } as any); + mockPrompt.mockResolvedValueOnce({ + accountSid: 'AC1234567890abcdef1234567890abcdef', + apiKey: 'SK1234567890abcdef1234567890abcdef', + apiSecret: 'secret123', + } as any); + + // Start the config process + await config(); + + // Verify the prompt was called with correct arguments + expect(inquirer.prompt).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'list', + name: 'clientConfig', + message: 'Which MCP client you want to configure?', + }), + ]); + }); + + it('should prompt for credentials after OpenAPI configuration', async () => { + // Mock the prompts sequence + const mockPrompt = vi.mocked(inquirer.prompt); + + // Setup the mock to return the desired values + mockPrompt.mockResolvedValueOnce({ clientConfig: 'cursor' } as any); + mockPrompt.mockResolvedValueOnce({ tags: false } as any); + mockPrompt.mockResolvedValueOnce({ services: false } as any); + mockPrompt.mockResolvedValueOnce({ + accountSid: 'AC1234567890abcdef1234567890abcdef', + apiKey: 'SK1234567890abcdef1234567890abcdef', + apiSecret: 'secret123', + } as any); + + // Start the config process + await config(); + + // Get all calls to inquirer.prompt + const promptCalls = mockPrompt.mock.calls; + + // Find the credentials prompt call (it should be the last one) + const credentialsQuestions = promptCalls + .map((call) => call[0]) + .find( + (questions) => + Array.isArray(questions) && + questions.some((q) => q.name === 'accountSid'), + ); + + // Verify the credentials prompts + expect(credentialsQuestions).toBeDefined(); + expect(credentialsQuestions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'accountSid', + type: 'input', + message: 'Enter your Twilio account SID:', + validate: expect.any(Function), + }), + expect.objectContaining({ + name: 'apiKey', + type: 'password', + message: 'Enter your Twilio API Key SID:', + validate: expect.any(Function), + }), + expect.objectContaining({ + name: 'apiSecret', + type: 'password', + message: 'Enter your Twilio API Key Secret:', + validate: expect.any(Function), + }), + ]), + ); + }); + + it('should validate account SID format', async () => { + let validationFunction: ((input: string) => boolean | string) | undefined; + + // Setup inquirer.prompt to capture validation function + mockInquirerPrompt((questions: Question[] | Question) => { + if (Array.isArray(questions)) { + const accountSidQuestion = questions.find( + (q) => q.name === 'accountSid', + ) as ValidatedQuestion; + if (accountSidQuestion?.validate) { + validationFunction = accountSidQuestion.validate; + } + } + + return { + accountSid: 'AC1234567890abcdef1234567890abcdef', + apiKey: 'SK1234567890abcdef1234567890abcdef', + apiSecret: 'secret123', + } as any; + }); + + // Start the config process + await config(); + + // Mock isValidTwilioSid to return false for invalid SID + vi.mocked(isValidTwilioSid).mockReturnValue(false); + + // Test validation + expect(validationFunction?.('AC123')).toBe('Invalid Account SID format'); + + // Mock isValidTwilioSid to return true for valid SID + vi.mocked(isValidTwilioSid).mockReturnValue(true); + expect(validationFunction?.('AC1234567890abcdef1234567890abcdef')).toBe( + true, + ); + }); + + it('should validate API Key format', async () => { + let validationFunction: ((input: string) => boolean | string) | undefined; + + // Setup inquirer.prompt to capture validation function + mockInquirerPrompt((questions: Question[] | Question) => { + if (Array.isArray(questions)) { + const apiKeyQuestion = questions.find( + (q) => q.name === 'apiKey', + ) as ValidatedQuestion; + if (apiKeyQuestion?.validate) { + validationFunction = apiKeyQuestion.validate; + } + } + + return { + accountSid: 'AC1234567890abcdef1234567890abcdef', + apiKey: 'SK1234567890abcdef1234567890abcdef', + apiSecret: 'secret123', + } as any; + }); + + // Start the config process + await config(); + + // Mock isValidTwilioSid to return false for invalid SID + vi.mocked(isValidTwilioSid).mockReturnValue(false); + + // Test validation + expect(validationFunction?.('SK123')).toBe('Invalid API Key SID format'); + + // Mock isValidTwilioSid to return true for valid SID + vi.mocked(isValidTwilioSid).mockReturnValue(true); + expect(validationFunction?.('SK1234567890abcdef1234567890abcdef')).toBe( + true, + ); + }); + + it('should validate API Secret is not empty', async () => { + let validationFunction: ((input: string) => boolean | string) | undefined; + + // Setup inquirer.prompt to capture validation function + mockInquirerPrompt((questions: Question[] | Question) => { + if (Array.isArray(questions)) { + const apiSecretQuestion = questions.find( + (q) => q.name === 'apiSecret', + ) as ValidatedQuestion; + if (apiSecretQuestion?.validate) { + validationFunction = apiSecretQuestion.validate; + } + } + + return { + accountSid: 'AC1234567890abcdef1234567890abcdef', + apiKey: 'SK1234567890abcdef1234567890abcdef', + apiSecret: 'secret123', + } as any; + }); + + // Start the config process + await config(); + + // Test validation + expect(validationFunction?.('')).toBe('API Secret is required'); + expect(validationFunction?.('secret123')).toBe(true); + }); +});