diff --git a/README.md b/README.md index 6e4b76e..e0a1c7f 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,27 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io } ``` +### macOS Keychain (JSON) + +If you do not want to store the service account token directly in your MCP config, macOS users can store it in Keychain and configure the server to read it at startup instead: + +```json +{ + "mcpServers": { + "1password": { + "command": "npx", + "args": ["-y", "@takescake/1password-mcp"], + "env": { + "OP_KEYCHAIN_SERVICE": "op-service-account-claude-automation", + "OP_KEYCHAIN_ACCOUNT": "your-macos-username" + } + } + } +} +``` + +Precedence is: CLI arguments (`--service-account-token` / `--token`) > `OP_SERVICE_ACCOUNT_TOKEN` > macOS Keychain lookup. `OP_KEYCHAIN_ACCOUNT` is optional if your Keychain service name is already unique enough. + ### OpenAI Codex (TOML) **Option A** (stores the token in config): @@ -94,6 +115,8 @@ Then set `OP_SERVICE_ACCOUNT_TOKEN` in your shell/session/CI environment. > **Note:** `codex mcp add ... --env OP_SERVICE_ACCOUNT_TOKEN=...` writes the token into Codex config. Use `env_vars` if you want the config to reference only the variable name. +On macOS, you can also omit `OP_SERVICE_ACCOUNT_TOKEN` and set `OP_KEYCHAIN_SERVICE` (plus optional `OP_KEYCHAIN_ACCOUNT`) to read the token from Keychain at startup. + ### CLI Options ``` diff --git a/server.json b/server.json index b2688bd..763486d 100644 --- a/server.json +++ b/server.json @@ -18,10 +18,24 @@ "environmentVariables": [ { "name": "OP_SERVICE_ACCOUNT_TOKEN", - "description": "The Service Account Token from 1Password", - "isRequired": true, + "description": "The Service Account Token from 1Password (required unless OP_KEYCHAIN_SERVICE is used on macOS)", + "isRequired": false, "isSecret": true, "format": "string" + }, + { + "name": "OP_KEYCHAIN_SERVICE", + "description": "macOS only: Keychain service name to read the 1Password service account token from when OP_SERVICE_ACCOUNT_TOKEN is not set", + "isRequired": false, + "isSecret": false, + "format": "string" + }, + { + "name": "OP_KEYCHAIN_ACCOUNT", + "description": "macOS only: Optional Keychain account name to narrow the lookup used with OP_KEYCHAIN_SERVICE", + "isRequired": false, + "isSecret": false, + "format": "string" } ] } diff --git a/src/client.ts b/src/client.ts index ce56f1d..2cf6987 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,7 @@ export function requireServiceAccountToken(): string { if (!config.serviceAccountToken) { log("error", "Missing service account token."); throw new Error( - "Service account token is required. Provide it via --service-account-token or OP_SERVICE_ACCOUNT_TOKEN.", + "Service account token is required. Provide it via --service-account-token, OP_SERVICE_ACCOUNT_TOKEN, or macOS Keychain with OP_KEYCHAIN_SERVICE.", ); } return config.serviceAccountToken; diff --git a/src/config.ts b/src/config.ts index d95383d..57c2b45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ * Server configuration: CLI arguments, environment variables, constants. */ +import { execFileSync } from "node:child_process"; import { LOG_LEVEL_VALUES, type LogLevel } from "./types.js"; export const SERVER_NAME = "1password-mcp"; @@ -31,11 +32,75 @@ export interface ServerConfig { /** Service account token (may be undefined until first use). */ serviceAccountToken: string | undefined; /** Where the token came from. */ - tokenSource: "args" | "env" | "missing"; + tokenSource: "args" | "env" | "keychain" | "missing"; } let _config: ServerConfig | undefined; +interface MacOsKeychainLookupOptions { + service?: string; + account?: string; + platform?: NodeJS.Platform; + execFileSyncImpl?: typeof execFileSync; +} + +export function readMacOsKeychainToken({ + service, + account, + platform = process.platform, + execFileSyncImpl = execFileSync, +}: MacOsKeychainLookupOptions): string | undefined { + if (!service || platform !== "darwin") return undefined; + + const args = ["find-generic-password"]; + if (account) args.push("-a", account); + args.push("-s", service, "-w"); + + try { + const token = execFileSyncImpl("security", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return token || undefined; + } catch { + return undefined; + } +} + +export function resolveServiceAccountToken({ + tokenFromArgs, + env = process.env, + readKeychainToken = readMacOsKeychainToken, +}: { + tokenFromArgs?: string; + env?: NodeJS.ProcessEnv; + readKeychainToken?: (options: { + service?: string; + account?: string; + }) => string | undefined; +} = {}): Pick { + const tokenFromEnv = env.OP_SERVICE_ACCOUNT_TOKEN; + let tokenFromKeychain: string | undefined; + if (!tokenFromArgs && !tokenFromEnv) { + tokenFromKeychain = readKeychainToken({ + service: env.OP_KEYCHAIN_SERVICE, + account: env.OP_KEYCHAIN_ACCOUNT, + }); + } + + const serviceAccountToken = tokenFromArgs ?? tokenFromEnv ?? tokenFromKeychain; + + const tokenSource: ServerConfig["tokenSource"] = tokenFromArgs + ? "args" + : tokenFromEnv + ? "env" + : tokenFromKeychain + ? "keychain" + : "missing"; + + return { serviceAccountToken, tokenSource }; +} + /** Build and cache the server configuration. */ export function getConfig(): ServerConfig { if (_config) return _config; @@ -61,14 +126,8 @@ export function getConfig(): ServerConfig { const tokenFromArgs = getArgValue("service-account-token") ?? getArgValue("token"); - const serviceAccountToken = - tokenFromArgs ?? process.env.OP_SERVICE_ACCOUNT_TOKEN; - - const tokenSource: ServerConfig["tokenSource"] = tokenFromArgs - ? "args" - : process.env.OP_SERVICE_ACCOUNT_TOKEN - ? "env" - : "missing"; + const { serviceAccountToken, tokenSource } = + resolveServiceAccountToken({ tokenFromArgs }); _config = { logLevel: logLevelRaw, diff --git a/tests/config.test.ts b/tests/config.test.ts index be9c48d..002fd8f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -3,8 +3,15 @@ */ import { readFileSync } from "node:fs"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { getConfig, resetConfig, SERVER_NAME, SERVER_VERSION } from "../src/config.js"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + getConfig, + readMacOsKeychainToken, + resetConfig, + resolveServiceAccountToken, + SERVER_NAME, + SERVER_VERSION, +} from "../src/config.js"; const packageJson = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), @@ -22,6 +29,8 @@ describe("config", () => { delete process.env.OP_INTEGRATION_NAME; delete process.env.OP_INTEGRATION_VERSION; delete process.env.OP_SERVICE_ACCOUNT_TOKEN; + delete process.env.OP_KEYCHAIN_SERVICE; + delete process.env.OP_KEYCHAIN_ACCOUNT; }); afterEach(() => { @@ -103,6 +112,78 @@ describe("config", () => { expect(config.serviceAccountToken).toBe("arg-token"); }); + it("runs the expected macOS keychain lookup command", () => { + const execFileSyncImpl = vi.fn(() => "keychain-token\n"); + + const token = readMacOsKeychainToken({ + service: "op-service-account", + account: "alice", + platform: "darwin", + execFileSyncImpl, + }); + + expect(token).toBe("keychain-token"); + expect(execFileSyncImpl).toHaveBeenCalledWith("security", [ + "find-generic-password", + "-a", + "alice", + "-s", + "op-service-account", + "-w", + ], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + }); + + it("skips macOS keychain lookup on non-macOS platforms", () => { + const execFileSyncImpl = vi.fn(); + + const token = readMacOsKeychainToken({ + service: "op-service-account", + platform: "linux", + execFileSyncImpl, + }); + + expect(token).toBeUndefined(); + expect(execFileSyncImpl).not.toHaveBeenCalled(); + }); + + it("resolves token from macOS keychain when configured", () => { + const readKeychainToken = vi.fn(() => "keychain-token"); + + const config = resolveServiceAccountToken({ + env: { + OP_KEYCHAIN_SERVICE: "op-service-account", + OP_KEYCHAIN_ACCOUNT: "alice", + }, + readKeychainToken, + }); + + expect(config.tokenSource).toBe("keychain"); + expect(config.serviceAccountToken).toBe("keychain-token"); + expect(readKeychainToken).toHaveBeenCalledWith({ + service: "op-service-account", + account: "alice", + }); + }); + + it("prefers env token over macOS keychain lookup", () => { + const readKeychainToken = vi.fn(() => "keychain-token"); + + const config = resolveServiceAccountToken({ + env: { + OP_SERVICE_ACCOUNT_TOKEN: "env-token", + OP_KEYCHAIN_SERVICE: "op-service-account", + }, + readKeychainToken, + }); + + expect(config.tokenSource).toBe("env"); + expect(config.serviceAccountToken).toBe("env-token"); + expect(readKeychainToken).not.toHaveBeenCalled(); + }); + it("uses default integration name/version", () => { process.argv = ["node", "index.js"]; const config = getConfig();