Skip to content

Commit 9e31fbe

Browse files
Merge pull request #6 from CakeRepository/copilot/feature-support-macos-keychain
Add macOS Keychain fallback for 1Password service account token
2 parents 219d9b0 + a6c051a commit 9e31fbe

5 files changed

Lines changed: 191 additions & 14 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io
6868
}
6969
```
7070

71+
### macOS Keychain (JSON)
72+
73+
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:
74+
75+
```json
76+
{
77+
"mcpServers": {
78+
"1password": {
79+
"command": "npx",
80+
"args": ["-y", "@takescake/1password-mcp"],
81+
"env": {
82+
"OP_KEYCHAIN_SERVICE": "op-service-account-claude-automation",
83+
"OP_KEYCHAIN_ACCOUNT": "your-macos-username"
84+
}
85+
}
86+
}
87+
}
88+
```
89+
90+
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.
91+
7192
### OpenAI Codex (TOML)
7293

7394
**Option A** (stores the token in config):
@@ -94,6 +115,8 @@ Then set `OP_SERVICE_ACCOUNT_TOKEN` in your shell/session/CI environment.
94115

95116
> **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.
96117
118+
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.
119+
97120
### CLI Options
98121

99122
```

server.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@
1818
"environmentVariables": [
1919
{
2020
"name": "OP_SERVICE_ACCOUNT_TOKEN",
21-
"description": "The Service Account Token from 1Password",
22-
"isRequired": true,
21+
"description": "The Service Account Token from 1Password (required unless OP_KEYCHAIN_SERVICE is used on macOS)",
22+
"isRequired": false,
2323
"isSecret": true,
2424
"format": "string"
25+
},
26+
{
27+
"name": "OP_KEYCHAIN_SERVICE",
28+
"description": "macOS only: Keychain service name to read the 1Password service account token from when OP_SERVICE_ACCOUNT_TOKEN is not set",
29+
"isRequired": false,
30+
"isSecret": false,
31+
"format": "string"
32+
},
33+
{
34+
"name": "OP_KEYCHAIN_ACCOUNT",
35+
"description": "macOS only: Optional Keychain account name to narrow the lookup used with OP_KEYCHAIN_SERVICE",
36+
"isRequired": false,
37+
"isSecret": false,
38+
"format": "string"
2539
}
2640
]
2741
}

src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function requireServiceAccountToken(): string {
1616
if (!config.serviceAccountToken) {
1717
log("error", "Missing service account token.");
1818
throw new Error(
19-
"Service account token is required. Provide it via --service-account-token or OP_SERVICE_ACCOUNT_TOKEN.",
19+
"Service account token is required. Provide it via --service-account-token, OP_SERVICE_ACCOUNT_TOKEN, or macOS Keychain with OP_KEYCHAIN_SERVICE.",
2020
);
2121
}
2222
return config.serviceAccountToken;

src/config.ts

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Server configuration: CLI arguments, environment variables, constants.
33
*/
44

5+
import { execFileSync } from "node:child_process";
56
import { LOG_LEVEL_VALUES, type LogLevel } from "./types.js";
67

78
export const SERVER_NAME = "1password-mcp";
@@ -31,11 +32,75 @@ export interface ServerConfig {
3132
/** Service account token (may be undefined until first use). */
3233
serviceAccountToken: string | undefined;
3334
/** Where the token came from. */
34-
tokenSource: "args" | "env" | "missing";
35+
tokenSource: "args" | "env" | "keychain" | "missing";
3536
}
3637

3738
let _config: ServerConfig | undefined;
3839

40+
interface MacOsKeychainLookupOptions {
41+
service?: string;
42+
account?: string;
43+
platform?: NodeJS.Platform;
44+
execFileSyncImpl?: typeof execFileSync;
45+
}
46+
47+
export function readMacOsKeychainToken({
48+
service,
49+
account,
50+
platform = process.platform,
51+
execFileSyncImpl = execFileSync,
52+
}: MacOsKeychainLookupOptions): string | undefined {
53+
if (!service || platform !== "darwin") return undefined;
54+
55+
const args = ["find-generic-password"];
56+
if (account) args.push("-a", account);
57+
args.push("-s", service, "-w");
58+
59+
try {
60+
const token = execFileSyncImpl("security", args, {
61+
encoding: "utf8",
62+
stdio: ["ignore", "pipe", "ignore"],
63+
}).trim();
64+
return token || undefined;
65+
} catch {
66+
return undefined;
67+
}
68+
}
69+
70+
export function resolveServiceAccountToken({
71+
tokenFromArgs,
72+
env = process.env,
73+
readKeychainToken = readMacOsKeychainToken,
74+
}: {
75+
tokenFromArgs?: string;
76+
env?: NodeJS.ProcessEnv;
77+
readKeychainToken?: (options: {
78+
service?: string;
79+
account?: string;
80+
}) => string | undefined;
81+
} = {}): Pick<ServerConfig, "serviceAccountToken" | "tokenSource"> {
82+
const tokenFromEnv = env.OP_SERVICE_ACCOUNT_TOKEN;
83+
let tokenFromKeychain: string | undefined;
84+
if (!tokenFromArgs && !tokenFromEnv) {
85+
tokenFromKeychain = readKeychainToken({
86+
service: env.OP_KEYCHAIN_SERVICE,
87+
account: env.OP_KEYCHAIN_ACCOUNT,
88+
});
89+
}
90+
91+
const serviceAccountToken = tokenFromArgs ?? tokenFromEnv ?? tokenFromKeychain;
92+
93+
const tokenSource: ServerConfig["tokenSource"] = tokenFromArgs
94+
? "args"
95+
: tokenFromEnv
96+
? "env"
97+
: tokenFromKeychain
98+
? "keychain"
99+
: "missing";
100+
101+
return { serviceAccountToken, tokenSource };
102+
}
103+
39104
/** Build and cache the server configuration. */
40105
export function getConfig(): ServerConfig {
41106
if (_config) return _config;
@@ -61,14 +126,8 @@ export function getConfig(): ServerConfig {
61126
const tokenFromArgs =
62127
getArgValue("service-account-token") ?? getArgValue("token");
63128

64-
const serviceAccountToken =
65-
tokenFromArgs ?? process.env.OP_SERVICE_ACCOUNT_TOKEN;
66-
67-
const tokenSource: ServerConfig["tokenSource"] = tokenFromArgs
68-
? "args"
69-
: process.env.OP_SERVICE_ACCOUNT_TOKEN
70-
? "env"
71-
: "missing";
129+
const { serviceAccountToken, tokenSource } =
130+
resolveServiceAccountToken({ tokenFromArgs });
72131

73132
_config = {
74133
logLevel: logLevelRaw,

tests/config.test.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@
33
*/
44

55
import { readFileSync } from "node:fs";
6-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
7-
import { getConfig, resetConfig, SERVER_NAME, SERVER_VERSION } from "../src/config.js";
6+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
7+
import {
8+
getConfig,
9+
readMacOsKeychainToken,
10+
resetConfig,
11+
resolveServiceAccountToken,
12+
SERVER_NAME,
13+
SERVER_VERSION,
14+
} from "../src/config.js";
815

916
const packageJson = JSON.parse(
1017
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
@@ -22,6 +29,8 @@ describe("config", () => {
2229
delete process.env.OP_INTEGRATION_NAME;
2330
delete process.env.OP_INTEGRATION_VERSION;
2431
delete process.env.OP_SERVICE_ACCOUNT_TOKEN;
32+
delete process.env.OP_KEYCHAIN_SERVICE;
33+
delete process.env.OP_KEYCHAIN_ACCOUNT;
2534
});
2635

2736
afterEach(() => {
@@ -103,6 +112,78 @@ describe("config", () => {
103112
expect(config.serviceAccountToken).toBe("arg-token");
104113
});
105114

115+
it("runs the expected macOS keychain lookup command", () => {
116+
const execFileSyncImpl = vi.fn(() => "keychain-token\n");
117+
118+
const token = readMacOsKeychainToken({
119+
service: "op-service-account",
120+
account: "alice",
121+
platform: "darwin",
122+
execFileSyncImpl,
123+
});
124+
125+
expect(token).toBe("keychain-token");
126+
expect(execFileSyncImpl).toHaveBeenCalledWith("security", [
127+
"find-generic-password",
128+
"-a",
129+
"alice",
130+
"-s",
131+
"op-service-account",
132+
"-w",
133+
], {
134+
encoding: "utf8",
135+
stdio: ["ignore", "pipe", "ignore"],
136+
});
137+
});
138+
139+
it("skips macOS keychain lookup on non-macOS platforms", () => {
140+
const execFileSyncImpl = vi.fn();
141+
142+
const token = readMacOsKeychainToken({
143+
service: "op-service-account",
144+
platform: "linux",
145+
execFileSyncImpl,
146+
});
147+
148+
expect(token).toBeUndefined();
149+
expect(execFileSyncImpl).not.toHaveBeenCalled();
150+
});
151+
152+
it("resolves token from macOS keychain when configured", () => {
153+
const readKeychainToken = vi.fn(() => "keychain-token");
154+
155+
const config = resolveServiceAccountToken({
156+
env: {
157+
OP_KEYCHAIN_SERVICE: "op-service-account",
158+
OP_KEYCHAIN_ACCOUNT: "alice",
159+
},
160+
readKeychainToken,
161+
});
162+
163+
expect(config.tokenSource).toBe("keychain");
164+
expect(config.serviceAccountToken).toBe("keychain-token");
165+
expect(readKeychainToken).toHaveBeenCalledWith({
166+
service: "op-service-account",
167+
account: "alice",
168+
});
169+
});
170+
171+
it("prefers env token over macOS keychain lookup", () => {
172+
const readKeychainToken = vi.fn(() => "keychain-token");
173+
174+
const config = resolveServiceAccountToken({
175+
env: {
176+
OP_SERVICE_ACCOUNT_TOKEN: "env-token",
177+
OP_KEYCHAIN_SERVICE: "op-service-account",
178+
},
179+
readKeychainToken,
180+
});
181+
182+
expect(config.tokenSource).toBe("env");
183+
expect(config.serviceAccountToken).toBe("env-token");
184+
expect(readKeychainToken).not.toHaveBeenCalled();
185+
});
186+
106187
it("uses default integration name/version", () => {
107188
process.argv = ["node", "index.js"];
108189
const config = getConfig();

0 commit comments

Comments
 (0)