From abdec977c288983e32f67c134dbe6bba4edc9901 Mon Sep 17 00:00:00 2001 From: BatteryShark Date: Sat, 6 Sep 2025 14:42:32 -0400 Subject: [PATCH] Added keyring support for secrets Added keyring support to store Todoist API key in the user keychain as an alternative to using environment variables. --- README.md | 19 ++++ docs/dev-setup.md | 23 +++- docs/mcp-server.md | 42 ++++++- package-lock.json | 223 ++++++++++++++++++++++++++++++++++++++ package.json | 7 +- scripts/setup-keychain.js | 155 ++++++++++++++++++++++++++ src/main.ts | 27 ++++- src/setup-keychain.ts | 156 ++++++++++++++++++++++++++ src/utils/keychain.ts | 55 ++++++++++ 9 files changed, 697 insertions(+), 10 deletions(-) create mode 100755 scripts/setup-keychain.js create mode 100644 src/setup-keychain.ts create mode 100644 src/utils/keychain.ts diff --git a/README.md b/README.md index e08391b..e89908b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,25 @@ You can run the MCP server directly with npx: npx @doist/todoist-ai ``` +### Secure Credential Storage + +For enhanced security, you can store your Todoist API key in your system's keychain instead of using environment variables: + +```bash +# Store API key securely in keychain (prompts for hidden input) +npx todoist-ai-setup-keychain + +# Run - automatically uses keychain when no TODOIST_API_KEY env var is set +npx @doist/todoist-ai +``` + +This stores your API key securely in: +- **macOS**: Keychain Access +- **Windows**: Windows Credential Manager +- **Linux**: Secret Service (GNOME Keyring, KWallet, etc.) + +The base URL can still be configured via `TODOIST_BASE_URL` environment variable if needed. + For more details on setting up and using the MCP server, including creating custom servers, see [docs/mcp-server.md](docs/mcp-server.md). ## Features diff --git a/docs/dev-setup.md b/docs/dev-setup.md index 69f2d3b..5068321 100644 --- a/docs/dev-setup.md +++ b/docs/dev-setup.md @@ -6,7 +6,28 @@ npm run setup ``` -## 2. Configure environment variables +## 2. Configure credentials + +You have two options for configuring your Todoist credentials: + +### Option A: Using Keychain (Recommended) + +Store your API key securely in the user keychain: + +```sh +npm run setup-keychain +``` + +This will securely prompt you for your API key (hidden input with asterisks) and store it in your user's keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). + +The server will automatically use the keychain API key when no `TODOIST_API_KEY` environment variable is set. You can still set the base URL if needed: + +```env +# Optional: Set custom base URL +TODOIST_BASE_URL=https://local.todoist.com/rest/v2 +``` + +### Option B: Using Environment Variables Update the `.env` file with your Todoist token: diff --git a/docs/mcp-server.md b/docs/mcp-server.md index a0c28aa..ccc2200 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -10,7 +10,7 @@ The easiest way to use this MCP server is with npx: npx @doist/todoist-ai ``` -You'll need to set your Todoist API key as an environment variable `TODOIST_API_KEY`. +You'll need to set your Todoist API key as an environment variable `TODOIST_API_KEY`, or store it securely in keychain (automatically used when no environment variable is set). ## Local Development Setup @@ -51,6 +51,7 @@ Then, proceed depending on the MCP protocol transport you'll use. Add this section to your `mcp.json` config in Claude, Cursor, etc.: +#### Using Environment Variables ```json { "mcpServers": { @@ -66,10 +67,30 @@ Add this section to your `mcp.json` config in Claude, Cursor, etc.: } ``` +#### Using Keychain (Recommended) +First, store your API key securely: +```bash +npx todoist-ai-setup-keychain +``` + +Then configure without any environment variables (automatically uses keychain): +```json +{ + "mcpServers": { + "todoist-ai": { + "type": "stdio", + "command": "npx", + "args": ["@doist/todoist-ai"] + } + } +} +``` + ### Using local installation Add this `todoist-ai-tools` section to your `mcp.json` config in Cursor, Claude, Raycast, etc. +#### Using Environment Variables ```json { "mcpServers": { @@ -87,9 +108,24 @@ Add this `todoist-ai-tools` section to your `mcp.json` config in Cursor, Claude, } ``` -Update the configuration above as follows +#### Using Keychain (Recommended) +```json +{ + "mcpServers": { + "todoist-ai-tools": { + "type": "stdio", + "command": "node", + "args": [ + "/Users//code/todoist-ai-tools/dist/main.js" + ] + } + } +} +``` -- Replace `TODOIST_API_KEY` with your Todoist API token. +Update the configuration above as follows: +- For environment variable setup: Replace `TODOIST_API_KEY` with your Todoist API token. +- For keychain setup: Run `npm run setup-keychain` first to store your API key securely. - Replace the path in the `args` array with the correct path to where you cloned the repository > [!NOTE] diff --git a/package-lock.json b/package-lock.json index e13f6f9..19cb18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,14 @@ "dependencies": { "@doist/todoist-api-typescript": "5.1.2", "@modelcontextprotocol/sdk": "^1.11.1", + "@napi-rs/keyring": "^1.2.0", "date-fns": "^4.1.0", "dotenv": "^16.5.0", "zod": "^3.25.7" }, + "bin": { + "todoist-ai": "dist/main.js" + }, "devDependencies": { "@biomejs/biome": "1.9.4", "@types/express": "^5.0.2", @@ -1754,6 +1758,225 @@ "node": ">= 0.6" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", + "integrity": "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.2.0", + "@napi-rs/keyring-darwin-x64": "1.2.0", + "@napi-rs/keyring-freebsd-x64": "1.2.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", + "@napi-rs/keyring-linux-arm64-musl": "1.2.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-musl": "1.2.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", + "@napi-rs/keyring-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.2.0.tgz", + "integrity": "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.2.0.tgz", + "integrity": "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", diff --git a/package.json b/package.json index c4c2b8e..8c7e268 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "todoist-ai": "dist/main.js" + "todoist-ai": "dist/main.js", + "todoist-ai-setup-keychain": "dist/setup-keychain.js" }, "files": [ "dist", @@ -28,10 +29,11 @@ "scripts": { "test": "jest", "build": "rimraf dist && npx tsc --project tsconfig.json", - "postbuild": "chmod +x dist/main.js", + "postbuild": "chmod +x dist/main.js && chmod +x dist/setup-keychain.js", "start": "npm run build && npx @modelcontextprotocol/inspector node dist/main.js", "dev": "concurrently \"npx tsc --watch\" \"nodemon --watch dist --ext js --exec 'npx @modelcontextprotocol/inspector node dist/main.js'\"", "setup": "cp .env.example .env && npm install && npm run build", + "setup-keychain": "npm run build && node scripts/setup-keychain.js", "test:executable": "npm run build && node scripts/test-executable.cjs", "type-check": "npx tsc --noEmit", "biome:sort-imports": "biome check --formatter-enabled=false --linter-enabled=false --organize-imports-enabled=true --write .", @@ -46,6 +48,7 @@ "dependencies": { "@doist/todoist-api-typescript": "5.1.2", "@modelcontextprotocol/sdk": "^1.11.1", + "@napi-rs/keyring": "^1.2.0", "date-fns": "^4.1.0", "dotenv": "^16.5.0", "zod": "^3.25.7" diff --git a/scripts/setup-keychain.js b/scripts/setup-keychain.js new file mode 100755 index 0000000..8f79e2c --- /dev/null +++ b/scripts/setup-keychain.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +/** + * Setup script to store Todoist credentials in the system keychain + * + * Usage: + * node scripts/setup-keychain.js + * + * Or run interactively: + * node scripts/setup-keychain.js + */ + +import { createInterface } from 'readline' +import { storeCredentials, hasCredentials, clearCredentials } from '../dist/utils/keychain.js' + +function createReadlineInterface() { + return createInterface({ + input: process.stdin, + output: process.stdout + }) +} + +function question(rl, prompt) { + return new Promise((resolve) => { + rl.question(prompt, resolve) + }) +} + +function questionHidden(rl, prompt) { + return new Promise((resolve) => { + // Hide input by muting stdout + const stdin = process.stdin + const stdout = process.stdout + + stdout.write(prompt) + stdin.setRawMode(true) + stdin.resume() + stdin.setEncoding('utf8') + + let input = '' + const onData = (char) => { + switch (char) { + case '\n': + case '\r': + case '\u0004': // Ctrl+D + stdin.setRawMode(false) + stdin.pause() + stdin.removeListener('data', onData) + stdout.write('\n') + resolve(input) + break + case '\u0003': // Ctrl+C + process.exit(1) + break + case '\u007f': // Backspace + case '\b': + if (input.length > 0) { + input = input.slice(0, -1) + stdout.write('\b \b') + } + break + default: + if (char >= ' ') { // Printable characters + input += char + stdout.write('*') + } + break + } + } + + stdin.on('data', onData) + }) +} + +async function getCredentialsInteractively() { + const rl = createReadlineInterface() + + try { + console.log('Setting up Todoist API key in keychain...\n') + console.log('Note: Base URL can be set via TODOIST_BASE_URL environment variable if needed.\n') + + const apiKey = await questionHidden(rl, 'Enter your Todoist API key (hidden): ') + if (!apiKey.trim()) { + throw new Error('API key is required') + } + + return { + apiKey: apiKey.trim() + } + } finally { + rl.close() + } +} + +function getCredentialsFromArgs() { + const [, , apiKey] = process.argv + + if (!apiKey) { + return null + } + + return { + apiKey + } +} + +async function confirmOverwrite() { + const rl = createReadlineInterface() + + try { + const answer = await question(rl, 'Credentials already exist. Overwrite? (y/N): ') + return answer.toLowerCase().startsWith('y') + } finally { + rl.close() + } +} + +async function main() { + try { + // Check if credentials already exist + if (hasCredentials()) { + console.log('⚠️ Credentials already exist in keychain.') + const shouldOverwrite = await confirmOverwrite() + + if (!shouldOverwrite) { + console.log('Setup cancelled.') + process.exit(0) + } + + console.log('Clearing existing credentials...') + clearCredentials() + } + + // Get credentials from command line args or interactively + let credentials = getCredentialsFromArgs() + + if (!credentials) { + credentials = await getCredentialsInteractively() + } + + // Store credentials + console.log('Storing credentials in keychain...') + storeCredentials(credentials) + + console.log('✅ API key stored successfully!') + console.log('\nThe server will automatically use the keychain API key when no TODOIST_API_KEY environment variable is set.') + console.log('You can now run: npx @doist/todoist-ai') + + } catch (error) { + console.error('❌ Error:', error.message) + process.exit(1) + } +} + +main() diff --git a/src/main.ts b/src/main.ts index 2c76a8e..b7bdada 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,14 +2,33 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import dotenv from 'dotenv' import { getMcpServer } from './mcp-server.js' +import { getCredentials, hasCredentials } from './utils/keychain.js' -function main() { +function getConfig(): { todoistApiKey: string; baseUrl?: string } { + // Base URL always comes from environment variables (not sensitive) const baseUrl = process.env.TODOIST_BASE_URL - const todoistApiKey = process.env.TODOIST_API_KEY - if (!todoistApiKey) { - throw new Error('TODOIST_API_KEY is not set') + + // Try environment variable first + const envApiKey = process.env.TODOIST_API_KEY + if (envApiKey) { + return { todoistApiKey: envApiKey, baseUrl } + } + + // Fallback to keychain if no environment variable is set + if (hasCredentials()) { + const { apiKey } = getCredentials() + return { todoistApiKey: apiKey, baseUrl } } + + // Neither environment variable nor keychain has the API key + throw new Error( + 'TODOIST_API_KEY is not set as environment variable and no API key found in keychain. ' + + 'Either set TODOIST_API_KEY environment variable or run the setup script to store your API key in keychain.' + ) +} +function main() { + const { todoistApiKey, baseUrl } = getConfig() const server = getMcpServer({ todoistApiKey, baseUrl }) const transport = new StdioServerTransport() server diff --git a/src/setup-keychain.ts b/src/setup-keychain.ts new file mode 100644 index 0000000..a4987bd --- /dev/null +++ b/src/setup-keychain.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +/** + * Setup script to store Todoist credentials in the system keychain + * This is a TypeScript version that gets compiled to dist/ + * + * Usage: + * node dist/setup-keychain.js + * + * Or run interactively: + * node dist/setup-keychain.js + */ + +import { createInterface } from 'readline' +import { storeCredentials, hasCredentials, clearCredentials } from './utils/keychain.js' + +function createReadlineInterface() { + return createInterface({ + input: process.stdin, + output: process.stdout + }) +} + +function question(rl: ReturnType, prompt: string): Promise { + return new Promise((resolve) => { + rl.question(prompt, resolve) + }) +} + +function questionHidden(_rl: ReturnType, prompt: string): Promise { + return new Promise((resolve) => { + // Hide input by muting stdout + const stdin = process.stdin + const stdout = process.stdout + + stdout.write(prompt) + stdin.setRawMode(true) + stdin.resume() + stdin.setEncoding('utf8') + + let input = '' + const onData = (char: string) => { + switch (char) { + case '\n': + case '\r': + case '\u0004': // Ctrl+D + stdin.setRawMode(false) + stdin.pause() + stdin.removeListener('data', onData) + stdout.write('\n') + resolve(input) + break + case '\u0003': // Ctrl+C + process.exit(1) + break + case '\u007f': // Backspace + case '\b': + if (input.length > 0) { + input = input.slice(0, -1) + stdout.write('\b \b') + } + break + default: + if (char >= ' ') { // Printable characters + input += char + stdout.write('*') + } + break + } + } + + stdin.on('data', onData) + }) +} + +async function getCredentialsInteractively(): Promise<{ apiKey: string }> { + const rl = createReadlineInterface() + + try { + console.log('Setting up Todoist API key in keychain...\n') + console.log('Note: Base URL can be set via TODOIST_BASE_URL environment variable if needed.\n') + + const apiKey = await questionHidden(rl, 'Enter your Todoist API key (hidden): ') + if (!apiKey.trim()) { + throw new Error('API key is required') + } + + return { + apiKey: apiKey.trim() + } + } finally { + rl.close() + } +} + +function getCredentialsFromArgs(): { apiKey: string } | null { + const [, , apiKey] = process.argv + + if (!apiKey) { + return null + } + + return { + apiKey + } +} + +async function confirmOverwrite(): Promise { + const rl = createReadlineInterface() + + try { + const answer = await question(rl, 'Credentials already exist. Overwrite? (y/N): ') + return answer.toLowerCase().startsWith('y') + } finally { + rl.close() + } +} + +async function main(): Promise { + try { + // Check if credentials already exist + if (hasCredentials()) { + console.log('⚠️ Credentials already exist in keychain.') + const shouldOverwrite = await confirmOverwrite() + + if (!shouldOverwrite) { + console.log('Setup cancelled.') + process.exit(0) + } + + console.log('Clearing existing credentials...') + clearCredentials() + } + + // Get credentials from command line args or interactively + let credentials = getCredentialsFromArgs() + + if (!credentials) { + credentials = await getCredentialsInteractively() + } + + // Store credentials + console.log('Storing credentials in keychain...') + storeCredentials(credentials) + + console.log('✅ API key stored successfully!') + console.log('\nThe server will automatically use the keychain API key when no TODOIST_API_KEY environment variable is set.') + console.log('You can now run: npx @doist/todoist-ai') + + } catch (error) { + console.error('❌ Error:', (error as Error).message) + process.exit(1) + } +} + +main() diff --git a/src/utils/keychain.ts b/src/utils/keychain.ts new file mode 100644 index 0000000..0c938c9 --- /dev/null +++ b/src/utils/keychain.ts @@ -0,0 +1,55 @@ +import { Entry } from '@napi-rs/keyring' + +const SERVICE_NAME = 'com.mcp.todoist-ai' +const API_KEY_ACCOUNT = 'api_key' + +/** + * Store Todoist API key in the user keychain + */ +export function storeCredentials({ apiKey }: { apiKey: string }): void { + const apiKeyEntry = Entry.withTarget('user', SERVICE_NAME, API_KEY_ACCOUNT) + apiKeyEntry.setPassword(apiKey) +} + +/** + * Retrieve Todoist API key from the user keychain + */ +export function getCredentials(): { apiKey: string } { + try { + const apiKeyEntry = Entry.withTarget('user', SERVICE_NAME, API_KEY_ACCOUNT) + const apiKey = apiKeyEntry.getPassword() + + if (!apiKey) { + throw new Error('API key not found in keychain') + } + + return { apiKey } + } catch (error) { + throw new Error(`Failed to retrieve credentials from keychain: ${error}`) + } +} + +/** + * Check if credentials exist in the user keychain + */ +export function hasCredentials(): boolean { + try { + const apiKeyEntry = Entry.withTarget('user', SERVICE_NAME, API_KEY_ACCOUNT) + const apiKey = apiKeyEntry.getPassword() + return Boolean(apiKey && apiKey.trim()) + } catch { + return false + } +} + +/** + * Remove API key from the user keychain + */ +export function clearCredentials(): void { + try { + const apiKeyEntry = Entry.withTarget('user', SERVICE_NAME, API_KEY_ACCOUNT) + apiKeyEntry.deletePassword() + } catch { + // Ignore if not found + } +}