diff --git a/README.md b/README.md index a223822..5a3e4c9 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ The `--client` flag specifies which MCP client you're installing for: - `goose` - `zed` - `warp` (outputs config to copy/paste into Warp's cloud-based settings) +- `codex` (OpenAI's Codex CLI tool) ## License diff --git a/package.json b/package.json index c36150c..60c4b61 100644 --- a/package.json +++ b/package.json @@ -1,95 +1,96 @@ { - "name": "install-mcp", - "version": "1.8.0", - "description": "A CLI tool to install and manage MCP servers.", - "bin": { - "install-mcp": "./bin/run" - }, - "directories": { - "lib": "src", - "bin": "bin" - }, - "files": [ - "dist", - "bin" - ], - "repository": { - "type": "git", - "url": "git+ssh://git@github.com/supermemoryai/install-mcp.git" - }, - "scripts": { - "build": "tsup-node", - "build:watch": "tsup-node --watch", - "clean": "rimraf dist", - "commit": "cz", - "commitlint": "commitlint --edit", - "compile": "tsc", - "format": "prettier . --check", - "format:fix": "prettier . --write", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "start": "ts-node ./bin/run.ts", - "start:node": "node ./bin/run", - "test": "jest", - "test:watch": "jest --watchAll", - "prepare": "husky", - "release": "semantic-release" - }, - "keywords": [ - "typescript", - "starter", - "cli", - "mcp" - ], - "author": "Dhravya Shah ", - "license": "MIT", - "devDependencies": { - "@commitlint/cli": "^18.6.1", - "@commitlint/config-conventional": "^18.6.3", - "@jest/globals": "^29.7.0", - "@tsconfig/node20": "^20.1.6", - "@types/jest": "^29.5.14", - "@types/js-yaml": "^4.0.9", - "@types/node": "^20.19.8", - "@types/prompts": "^2.4.9", - "@types/signale": "^1.4.7", - "@types/yargs": "^17.0.33", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "commitizen": "^4.3.1", - "cz-conventional-changelog": "^3.3.0", - "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.2", - "eslint-plugin-jest": "^27.9.0", - "eslint-plugin-prettier": "^5.5.3", - "eslint-plugin-unused-imports": "^3.2.0", - "husky": "^9.1.7", - "jest": "^29.7.0", - "prettier": "^3.6.2", - "rimraf": "^5.0.10", - "semantic-release": "^23.1.1", - "ts-jest": "^29.4.0", - "ts-node": "^10.9.2", - "tsup": "^8.5.0", - "typescript": "^5.8.3" - }, - "dependencies": { - "consola": "^3.4.2", - "dotenv": "^16.6.1", - "giget": "^1.2.5", - "js-yaml": "^4.1.0", - "picocolors": "^1.1.1", - "yargs": "^17.7.2" - }, - "pnpm": { - "overrides": { - "tmp": "^0.2.4" - } - }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } - }, - "packageManager": "pnpm@10.13.1" + "name": "install-mcp", + "version": "1.9.0", + "description": "A CLI tool to install and manage MCP servers.", + "bin": { + "install-mcp": "./bin/run" + }, + "directories": { + "lib": "src", + "bin": "bin" + }, + "files": [ + "dist", + "bin" + ], + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/supermemoryai/install-mcp.git" + }, + "scripts": { + "build": "tsup-node", + "build:watch": "tsup-node --watch", + "clean": "rimraf dist", + "commit": "cz", + "commitlint": "commitlint --edit", + "compile": "tsc", + "format": "prettier . --check", + "format:fix": "prettier . --write", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "start": "ts-node ./bin/run.ts", + "start:node": "node ./bin/run", + "test": "jest", + "test:watch": "jest --watchAll", + "prepare": "husky", + "release": "semantic-release" + }, + "keywords": [ + "typescript", + "starter", + "cli", + "mcp" + ], + "author": "Dhravya Shah ", + "license": "MIT", + "devDependencies": { + "@commitlint/cli": "^18.6.1", + "@commitlint/config-conventional": "^18.6.3", + "@jest/globals": "^29.7.0", + "@tsconfig/node20": "^20.1.6", + "@types/jest": "^29.5.14", + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.19.8", + "@types/prompts": "^2.4.9", + "@types/signale": "^1.4.7", + "@types/yargs": "^17.0.33", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "commitizen": "^4.3.1", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.2", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.5.3", + "eslint-plugin-unused-imports": "^3.2.0", + "husky": "^9.1.7", + "jest": "^29.7.0", + "prettier": "^3.6.2", + "rimraf": "^5.0.10", + "semantic-release": "^23.1.1", + "ts-jest": "^29.4.0", + "ts-node": "^10.9.2", + "tsup": "^8.5.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "consola": "^3.4.2", + "dotenv": "^16.6.1", + "giget": "^1.2.5", + "js-yaml": "^4.1.0", + "picocolors": "^1.1.1", + "@iarna/toml": "^2.2.5", + "yargs": "^17.7.2" + }, + "pnpm": { + "overrides": { + "tmp": "^0.2.4" + } + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "packageManager": "pnpm@10.13.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef05b8e..b69fbf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@iarna/toml': + specifier: ^2.2.5 + version: 2.2.5 consola: specifier: ^3.4.2 version: 3.4.2 @@ -570,6 +573,9 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -4203,6 +4209,8 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@iarna/toml@2.2.5': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 diff --git a/src/client-config.test.ts b/src/client-config.test.ts index 08bd791..7f7232f 100644 --- a/src/client-config.test.ts +++ b/src/client-config.test.ts @@ -57,12 +57,13 @@ describe('client-config', () => { 'claude-code', 'goose', 'zed', + 'codex', ]), ) }) - it('should have at least 13 clients', () => { - expect(clientNames.length).toBeGreaterThanOrEqual(13) + it('should have at least 14 clients', () => { + expect(clientNames.length).toBeGreaterThanOrEqual(14) }) }) @@ -171,6 +172,11 @@ describe('client-config', () => { const result = getConfigPath('vscode') expect(result.configKey).toBe('mcp.servers') }) + + it('should handle codex with nested config key', () => { + const result = getConfigPath('codex') + expect(result.configKey).toBe('mcp_servers') + }) }) describe('readConfig', () => { @@ -213,6 +219,13 @@ describe('client-config', () => { const result = readConfig('vscode') expect(result).toEqual({ mcp: { servers: {} } }) }) + + it('should handle codex nested config key', () => { + mockFs.existsSync.mockReturnValue(false) + + const result = readConfig('codex') + expect(result).toEqual({ mcp_servers: {} }) + }) }) describe('writeConfig', () => { @@ -278,6 +291,13 @@ describe('client-config', () => { expect(mockFs.writeFileSync).toHaveBeenCalled() }) + + it('should handle codex nested config key', () => { + const config = { mcp_servers: { server1: { command: 'test' } } } + writeConfig(config, 'codex') + + expect(mockFs.writeFileSync).toHaveBeenCalled() + }) }) describe('platform-specific paths', () => { diff --git a/src/client-config.ts b/src/client-config.ts index feafc65..55351d0 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -2,9 +2,10 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' +import * as TOML from '@iarna/toml' import yaml from 'js-yaml' -import { verbose } from './logger' +import { logger, verbose } from './logger' // import { execFileSync } from "node:child_process" export interface ClientConfig { @@ -17,7 +18,7 @@ interface ClientFileTarget { path: string localPath?: string configKey: string - format?: 'json' | 'yaml' // Add format property for different file types + format?: 'json' | 'yaml' | 'toml' // Add format property for different file types } type ClientInstallTarget = ClientFileTarget @@ -133,6 +134,12 @@ function getClientPaths(): { [key: string]: ClientInstallTarget } { : path.join(homeDir, '.config', 'zed', 'settings.json'), configKey: 'context_servers', }, + codex: { + type: 'file', + path: path.join(process.env.CODEX_HOME || path.join(homeDir, '.codex'), 'config.toml'), + configKey: 'mcp_servers', + format: 'toml', + }, } } @@ -150,6 +157,7 @@ export const clientNames = [ 'claude-code', 'goose', 'zed', + 'codex', ] // Helper function to get nested value from an object using dot notation @@ -220,6 +228,8 @@ export function readConfig(client: string, local?: boolean): ClientConfig { let rawConfig: ClientConfig if (configPath.format === 'yaml') { rawConfig = (yaml.load(fileContent) as ClientConfig) || {} + } else if (configPath.format === 'toml') { + rawConfig = TOML.parse(fileContent) as ClientConfig } else { rawConfig = JSON.parse(fileContent) } @@ -291,6 +301,8 @@ function writeConfigFile(config: ClientConfig, target: ClientFileTarget): void { if (target.format === 'yaml') { existingConfig = (yaml.load(fileContent) as ClientConfig) || {} + } else if (target.format === 'toml') { + existingConfig = TOML.parse(fileContent) as ClientConfig } else { existingConfig = JSON.parse(fileContent) } @@ -315,11 +327,13 @@ function writeConfigFile(config: ClientConfig, target: ClientFileTarget): void { lineWidth: -1, noRefs: true, }) + } else if (target.format === 'toml') { + configContent = TOML.stringify(mergedConfig) } else { configContent = JSON.stringify(mergedConfig, null, 2) } fs.writeFileSync(target.path, configContent) - console.log(target.path) + logger.info(`Config written to: ${target.path}`) verbose('Config successfully written') } diff --git a/src/commands/install.test.ts b/src/commands/install.test.ts index 6b61492..1967c62 100644 --- a/src/commands/install.test.ts +++ b/src/commands/install.test.ts @@ -655,6 +655,119 @@ describe('install command', () => { ) }) + it('should handle environment variables with --env flag', async () => { + const argv: ArgumentsCamelCase = { + client: 'claude', + target: 'test-package', + env: ['API_KEY', 'secret123', 'DEBUG', 'true'], + yes: true, + _: [], + $0: 'install-mcp', + } + + await handler(argv) + + expect(mockClientConfig.writeConfig).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: { + 'test-package': { + command: 'npx', + args: ['test-package'], + env: { + API_KEY: 'secret123', + DEBUG: 'true', + }, + }, + }, + }), + 'claude', + undefined, + ) + }) + + it('should handle environment variables with URL installation', async () => { + const argv: ArgumentsCamelCase = { + client: 'cline', + target: 'https://example.com/server', + env: ['TOKEN', 'abc123'], + yes: true, + _: [], + $0: 'install-mcp', + } + + // Mock OAuth prompt to return false + mockLogger.prompt.mockResolvedValueOnce(false) + + await handler(argv) + + expect(mockClientConfig.writeConfig).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServers: { + 'example-com': { + command: 'npx', + args: ['-y', 'mcp-remote@latest', 'https://example.com/server'], + env: { + TOKEN: 'abc123', + }, + }, + }, + }), + 'cline', + undefined, + ) + }) + + it('should handle goose client with environment variables (using envs field)', async () => { + const argv: ArgumentsCamelCase = { + client: 'goose', + target: 'test-package', + env: ['VAR1', 'value1', 'VAR2', 'value2'], + yes: true, + _: [], + $0: 'install-mcp', + } + + mockClientConfig.getConfigPath.mockReturnValue({ + type: 'file', + path: '/test/goose-config.yaml', + configKey: 'extensions', + format: 'yaml', + }) + + mockClientConfig.readConfig.mockReturnValue({ extensions: {} }) + + // Add mock for getNestedValue to handle 'extensions' key + mockClientConfig.getNestedValue.mockImplementation((obj, path) => { + if (path === 'extensions') return obj.extensions || {} + if (path === 'mcpServers') return obj.mcpServers || {} + if (path === 'mcp.servers') return obj.mcp?.servers || {} + return undefined + }) + + await handler(argv) + + expect(mockClientConfig.writeConfig).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: { + 'test-package': expect.objectContaining({ + name: 'test-package', + cmd: 'npx', + args: ['test-package'], + enabled: true, + envs: { + VAR1: 'value1', + VAR2: 'value2', + }, + type: 'stdio', + timeout: 300, + }), + }, + }), + 'goose', + undefined, + ) + }) + it('should handle malformed URL', async () => { const argv: ArgumentsCamelCase = { client: 'claude', diff --git a/src/commands/install.ts b/src/commands/install.ts index 366148e..931621b 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -30,16 +30,17 @@ function setServerConfig( // Set the server config if (servers) { if (client === 'goose') { - // Goose has a different config structure + // Goose has a different config structure and uses 'envs' instead of 'env' + const { env, command, args, ...rest } = serverConfig servers[serverName] = { name: serverName, - cmd: serverConfig.command, - args: serverConfig.args, + cmd: command, + args: args, enabled: true, - envs: {}, + envs: env || {}, type: 'stdio', timeout: 300, - ...serverConfig, // Allow overriding defaults + ...rest, // Allow overriding other defaults } } else if (client === 'zed') { // Zed has a different config structure @@ -47,7 +48,7 @@ function setServerConfig( source: 'custom', command: serverConfig.command, args: serverConfig.args, - env: {}, + env: serverConfig.env || {}, ...serverConfig, // Allow overriding defaults } } else { @@ -65,6 +66,7 @@ export interface InstallArgv { header?: string[] oauth?: 'yes' | 'no' project?: string + env?: string[] } export const command = '$0 [target]' @@ -107,6 +109,11 @@ export function builder(yargs: Argv): Argv { description: 'Whether the server uses OAuth authentication (yes/no). If not specified, you will be prompted.', choices: ['yes', 'no'], } as const) + .option('env', { + type: 'array', + description: 'Environment variables to pass to the server (format: --env key value)', + default: [], + }) } function isUrl(input: string): boolean { @@ -165,6 +172,24 @@ function isSupermemoryUrl(input: string): boolean { } } +// Parse environment variables from array format into key-value object +function parseEnvVars(envArray?: string[]): { [key: string]: string } | undefined { + if (!envArray || envArray.length === 0) { + return undefined + } + + const envObj: { [key: string]: string } = {} + for (let i = 0; i < envArray.length; i += 2) { + const key = envArray[i] + const value = envArray[i + 1] + if (key && value !== undefined) { + envObj[key] = value + } + } + + return Object.keys(envObj).length > 0 ? envObj : undefined +} + // Run the authentication flow for remote servers before installation. async function runAuthentication(url: string): Promise { logger.info(`Running authentication for ${url}`) @@ -200,6 +225,7 @@ export async function handler(argv: ArgumentsCamelCase) { const name = argv.name || inferNameFromInput(target) const command = buildCommand(target) + const envVars = parseEnvVars(argv.env) // Resolve Supermemory project header when installing its URL let projectHeader: string | undefined @@ -247,7 +273,7 @@ export async function handler(argv: ArgumentsCamelCase) { [name]: { command: isUrl(target) ? 'npx' : command.split(' ')[0], args: warpArgs, - env: {}, + env: envVars || {}, working_directory: null, start_on_launch: true, }, @@ -315,29 +341,25 @@ export async function handler(argv: ArgumentsCamelCase) { if (projectHeader) { args.push('--header', projectHeader) } - setServerConfig( - config, - configKey, - name, - { - command: 'npx', - args: args, - }, - argv.client, - ) + const serverConfig: ClientConfig = { + command: 'npx', + args: args, + } + if (envVars) { + serverConfig.env = envVars + } + setServerConfig(config, configKey, name, serverConfig, argv.client) } else { // Command-based installation (including simple package names) const cmdParts = command.split(' ') - setServerConfig( - config, - configKey, - name, - { - command: cmdParts[0], - args: cmdParts.slice(1), - }, - argv.client, - ) + const serverConfig: ClientConfig = { + command: cmdParts[0], + args: cmdParts.slice(1), + } + if (envVars) { + serverConfig.env = envVars + } + setServerConfig(config, configKey, name, serverConfig, argv.client) } writeConfig(config, argv.client, argv.local)