@rui.branco/1password-mcp 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +122 -122
  2. package/assets/icon.svg +1 -0
  3. package/icons.js +22 -0
  4. package/index.js +1302 -1296
  5. package/package.json +4 -2
  6. package/setup.js +213 -210
package/index.js CHANGED
@@ -1,1296 +1,1302 @@
1
- #!/usr/bin/env node
2
-
3
- // ============================================================================
4
- // 1Password MCP Server
5
- //
6
- // Exposes 1Password vault/item operations as MCP tools for Claude Code. Uses
7
- // the official @1password/sdk with either a service-account token or desktop
8
- // app auth (delegating unlock to 1Password 8 via DesktopAuth).
9
- //
10
- // Multi-instance: several 1Password accounts can be configured; each tool
11
- // accepts an optional `instance` parameter to pick which one to hit.
12
- // ============================================================================
13
-
14
- // Handle `1password-mcp setup` subcommand
15
- if (process.argv[2] === "setup") {
16
- require("./setup.js");
17
- return;
18
- }
19
-
20
- const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
21
- const {
22
- StdioServerTransport,
23
- } = require("@modelcontextprotocol/sdk/server/stdio.js");
24
- const {
25
- CallToolRequestSchema,
26
- ListToolsRequestSchema,
27
- } = require("@modelcontextprotocol/sdk/types.js");
28
- const fs = require("fs");
29
- const path = require("path");
30
- const sdk = require("@1password/sdk");
31
- const { spawn, execSync } = require("child_process");
32
-
33
- const PKG_VERSION = require("./package.json").version;
34
- const INTEGRATION_NAME = "1password-mcp";
35
-
36
- // ============================================================================
37
- // Auto-update — same pattern as jira-mcp: check GitHub for a newer commit on
38
- // every start, install in a detached background process if found.
39
- // ============================================================================
40
- const GITHUB_REPO = "rui-branco/1password-mcp";
41
- const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
42
- try {
43
- const localSha = fs.existsSync(INSTALLED_SHA_FILE)
44
- ? fs.readFileSync(INSTALLED_SHA_FILE, "utf-8").trim()
45
- : "";
46
- const remoteSha = execSync(
47
- `git ls-remote https://github.com/${GITHUB_REPO}.git HEAD`,
48
- { stdio: "pipe", timeout: 5000 },
49
- )
50
- .toString()
51
- .split("\t")[0]
52
- .trim();
53
- if (remoteSha && remoteSha !== localSha) {
54
- const child = spawn(
55
- "sh",
56
- [
57
- "-c",
58
- `npm install -g git+ssh://git@github.com/${GITHUB_REPO}.git && echo "${remoteSha}" > "${INSTALLED_SHA_FILE}"`,
59
- ],
60
- { stdio: "ignore", detached: true },
61
- );
62
- child.unref();
63
- }
64
- } catch {
65
- /* offline or git not available — no-op */
66
- }
67
-
68
- // ============================================================================
69
- // Config
70
- // ============================================================================
71
-
72
- const CONFIG_DIR = path.join(process.env.HOME, ".config/1password-mcp");
73
- const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
74
-
75
- function ensureConfigDir() {
76
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
77
- }
78
-
79
- function loadConfigFile() {
80
- try {
81
- return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
82
- } catch {
83
- return {};
84
- }
85
- }
86
-
87
- function saveConfigFile(cfg) {
88
- ensureConfigDir();
89
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
90
- try {
91
- fs.chmodSync(CONFIG_PATH, 0o600);
92
- } catch {
93
- /* chmod can fail on non-POSIX filesystems — non-fatal */
94
- }
95
- }
96
-
97
- ensureConfigDir();
98
-
99
- /**
100
- * Normalize a persisted config entry into the runtime instance shape.
101
- * Accepts legacy records that only had a token and infers authType.
102
- */
103
- function normalizeInstance(raw) {
104
- return {
105
- name: raw.name,
106
- authType: raw.authType || (raw.token ? "serviceAccount" : "desktop"),
107
- token: raw.token,
108
- accountName: raw.accountName,
109
- description: raw.description || "",
110
- };
111
- }
112
-
113
- const rawConfig = fs.existsSync(CONFIG_PATH)
114
- ? loadConfigFile()
115
- : { instances: [] };
116
-
117
- let instances = (rawConfig.instances || []).map(normalizeInstance);
118
-
119
- let defaultInstance =
120
- (rawConfig.defaultInstance &&
121
- instances.find((i) => i.name === rawConfig.defaultInstance)) ||
122
- instances[0] ||
123
- null;
124
-
125
- /**
126
- * Resolve an instance by name, falling back to the default if the name is
127
- * omitted or unknown. Returns null only when no instances are configured at all.
128
- */
129
- function getInstanceByName(name) {
130
- if (!name) return defaultInstance;
131
- return instances.find((i) => i.name === name) || defaultInstance;
132
- }
133
-
134
- // ============================================================================
135
- // 1Password SDK client
136
- // ============================================================================
137
-
138
- // Cache SDK clients per instance — createClient spins up a WASM runtime and
139
- // negotiates an auth session, so we want exactly one per process lifetime.
140
- const clientCache = new Map();
141
-
142
- function buildAuth(instance) {
143
- if (instance.authType === "desktop") {
144
- if (!instance.accountName) {
145
- throw new Error(
146
- `Instance "${instance.name}" is desktop-auth but has no accountName. Re-run setup.`,
147
- );
148
- }
149
- return new sdk.DesktopAuth(instance.accountName);
150
- }
151
- if (!instance.token) {
152
- throw new Error(`Instance "${instance.name}" has no service account token.`);
153
- }
154
- return instance.token;
155
- }
156
-
157
- async function getClient(instance) {
158
- if (!instance) {
159
- throw new Error(
160
- "No 1Password instance configured. Run `1password-mcp setup` or call op_add_instance.",
161
- );
162
- }
163
- if (clientCache.has(instance.name)) return clientCache.get(instance.name);
164
-
165
- const client = await sdk.createClient({
166
- auth: buildAuth(instance),
167
- integrationName: INTEGRATION_NAME,
168
- integrationVersion: `v${PKG_VERSION}`,
169
- });
170
- clientCache.set(instance.name, client);
171
- return client;
172
- }
173
-
174
- function invalidateClient(name) {
175
- clientCache.delete(name);
176
- }
177
-
178
- /**
179
- * Detect errors that indicate the IPC / desktop-app session is dead so the
180
- * cached SDK client can be discarded and a fresh one created on retry.
181
- */
182
- function isSessionError(e) {
183
- if (e instanceof sdk.DesktopSessionExpiredError) return true;
184
- // Belt-and-suspenders: catch IPC / auth wording from the native layer even
185
- // when the SDK wraps it in a generic Error.
186
- return /IPC|session.expired|desktop.auth|socket.closed/i.test(e.message);
187
- }
188
-
189
- // ============================================================================
190
- // Helpers — vault/item resolution, redaction, formatting
191
- // ============================================================================
192
-
193
- async function collect(asyncIterable) {
194
- const out = [];
195
- for await (const x of asyncIterable) out.push(x);
196
- return out;
197
- }
198
-
199
- /**
200
- * Resolve a vault from a reference that may be an id, exact title (case-insensitive),
201
- * or prefix of a title. Returns null when nothing matches.
202
- */
203
- async function resolveVault(client, vaultRef) {
204
- if (!vaultRef) return null;
205
- const vaults = await collect(await client.vaults.list());
206
- const byId = vaults.find((v) => v.id === vaultRef);
207
- if (byId) return byId;
208
- const lower = String(vaultRef).toLowerCase();
209
- return (
210
- vaults.find((v) => (v.title || "").toLowerCase() === lower) ||
211
- vaults.find((v) => (v.title || "").toLowerCase().startsWith(lower)) ||
212
- null
213
- );
214
- }
215
-
216
- /**
217
- * Same strategy as resolveVault but for items inside a specific vault.
218
- */
219
- async function resolveItem(client, vault, itemRef) {
220
- if (!itemRef) return null;
221
- const items = await collect(await client.items.list(vault.id));
222
- const byId = items.find((i) => i.id === itemRef);
223
- if (byId) return byId;
224
- const lower = String(itemRef).toLowerCase();
225
- return (
226
- items.find((i) => (i.title || "").toLowerCase() === lower) ||
227
- items.find((i) => (i.title || "").toLowerCase().startsWith(lower)) ||
228
- null
229
- );
230
- }
231
-
232
- /**
233
- * Shape a single 1Password field for output. Concealed and TOTP values are
234
- * replaced with "[REDACTED]" unless includeConcealed=true. OTP codes are only
235
- * surfaced when concealed values are revealed.
236
- */
237
- function redactField(field, includeConcealed) {
238
- const out = {
239
- id: field.id,
240
- title: field.title,
241
- type: field.fieldType,
242
- sectionId: field.sectionId || undefined,
243
- };
244
- const isSecret = field.fieldType === "Concealed" || field.fieldType === "Totp";
245
- if (!isSecret || includeConcealed) {
246
- out.value = field.value;
247
- if (
248
- field.details &&
249
- field.details.type === "Otp" &&
250
- field.details.content &&
251
- field.details.content.code
252
- ) {
253
- out.otp = field.details.content.code;
254
- }
255
- } else {
256
- out.value = "[REDACTED]";
257
- }
258
- return out;
259
- }
260
-
261
- /**
262
- * Flatten a full 1Password item (as returned by client.items.get) into a
263
- * stable JSON shape that's safe to display to the model.
264
- */
265
- function formatItem(item, includeConcealed) {
266
- return {
267
- id: item.id,
268
- title: item.title,
269
- category: item.category,
270
- vaultId: item.vaultId,
271
- tags: item.tags || [],
272
- websites: (item.websites || []).map((w) => ({
273
- url: w.url,
274
- label: w.label,
275
- })),
276
- fields: (item.fields || []).map((f) => redactField(f, includeConcealed)),
277
- sections: (item.sections || []).map((s) => ({ id: s.id, title: s.title })),
278
- };
279
- }
280
-
281
- // ============================================================================
282
- // Tool definitions (exposed via tools/list)
283
- // ============================================================================
284
-
285
- const TOOL_DEFINITIONS = [
286
- {
287
- name: "op_list_instances",
288
- description:
289
- "List configured 1Password instances (service accounts or desktop). Tokens are never returned.",
290
- inputSchema: { type: "object", properties: {}, required: [] },
291
- },
292
- {
293
- name: "op_add_instance",
294
- description:
295
- "Add or update a 1Password instance. Two auth modes: (1) 'serviceAccount' — provide token (ops_...). (2) 'desktop' — provide accountName (e.g. 'my.1password.com'); auth is delegated to the installed 1Password 8 desktop app via biometric unlock. Config is stored at ~/.config/1password-mcp/config.json with 0600 permissions.",
296
- inputSchema: {
297
- type: "object",
298
- properties: {
299
- name: {
300
- type: "string",
301
- description: "Unique name (e.g., 'work', 'personal').",
302
- },
303
- authType: {
304
- type: "string",
305
- description: "'serviceAccount' (default) or 'desktop'.",
306
- },
307
- token: {
308
- type: "string",
309
- description:
310
- "Service account token (ops_...) — required for authType=serviceAccount.",
311
- },
312
- accountName: {
313
- type: "string",
314
- description:
315
- "1Password account (e.g. 'my.1password.com' or shorthand) — required for authType=desktop. Uses the logged-in 1Password 8 app.",
316
- },
317
- description: {
318
- type: "string",
319
- description: "Optional human-readable description.",
320
- },
321
- setDefault: {
322
- type: "boolean",
323
- description: "Set this instance as the default (default: false).",
324
- },
325
- },
326
- required: ["name"],
327
- },
328
- },
329
- {
330
- name: "op_remove_instance",
331
- description:
332
- "Remove a 1Password instance by name. Cannot remove the last one.",
333
- inputSchema: {
334
- type: "object",
335
- properties: {
336
- name: { type: "string", description: "Instance name to remove." },
337
- },
338
- required: ["name"],
339
- },
340
- },
341
- {
342
- name: "op_list_vaults",
343
- description:
344
- "List all vaults visible to the given instance's 1Password account.",
345
- inputSchema: {
346
- type: "object",
347
- properties: {
348
- instance: {
349
- type: "string",
350
- description: "Instance name. Uses default if omitted.",
351
- },
352
- },
353
- required: [],
354
- },
355
- },
356
- {
357
- name: "op_list_items",
358
- description:
359
- "List items in a vault. `vault` accepts a vault id or title (case-insensitive exact, then prefix).",
360
- inputSchema: {
361
- type: "object",
362
- properties: {
363
- vault: { type: "string", description: "Vault id or title." },
364
- instance: {
365
- type: "string",
366
- description: "Instance name. Uses default if omitted.",
367
- },
368
- },
369
- required: ["vault"],
370
- },
371
- },
372
- {
373
- name: "op_search_items",
374
- description:
375
- "Search items by title across vaults. When the user asks for a specific credential by name (e.g. 'give me the wifi password', 'what's my GitHub login'), call this with `reveal: true` to get the password/credential values back in a single call — no follow-up op_get_item needed. If `vault` is omitted, searches every vault the account can see. When there are 1–5 matches with reveal=true, return all of them; the user already consented to seeing concealed values by asking for them.",
376
- inputSchema: {
377
- type: "object",
378
- properties: {
379
- query: {
380
- type: "string",
381
- description: "Case-insensitive substring of the item title.",
382
- },
383
- vault: {
384
- type: "string",
385
- description: "Optional vault id or title to scope the search.",
386
- },
387
- limit: {
388
- type: "number",
389
- description: "Max results (default 25, or 5 when reveal=true to avoid fetching too many items).",
390
- },
391
- reveal: {
392
- type: "boolean",
393
- description:
394
- "If true, fetch each match's full item and return concealed field values (passwords, TOTPs) in cleartext. Use this when the user is asking for a credential by name — one call gets search + retrieval + reveal. Defaults to false.",
395
- },
396
- instance: {
397
- type: "string",
398
- description: "Instance name. Uses default if omitted.",
399
- },
400
- },
401
- required: ["query"],
402
- },
403
- },
404
- {
405
- name: "op_get_item",
406
- description:
407
- "Fetch a full item with all fields. By default concealed fields (passwords, TOTP) are redacted; set includeConcealed=true to return them in cleartext. `item` and `vault` accept ids or titles.",
408
- inputSchema: {
409
- type: "object",
410
- properties: {
411
- vault: { type: "string", description: "Vault id or title." },
412
- item: { type: "string", description: "Item id or title." },
413
- includeConcealed: {
414
- type: "boolean",
415
- description: "Return concealed values in cleartext (default: false).",
416
- },
417
- instance: {
418
- type: "string",
419
- description: "Instance name. Uses default if omitted.",
420
- },
421
- },
422
- required: ["vault", "item"],
423
- },
424
- },
425
- {
426
- name: "op_get_secret",
427
- description:
428
- "Resolve a 1Password secret reference (op://vault/item/field) and return the value.",
429
- inputSchema: {
430
- type: "object",
431
- properties: {
432
- reference: {
433
- type: "string",
434
- description: "Secret reference like 'op://Private/GitHub/password'.",
435
- },
436
- instance: {
437
- type: "string",
438
- description: "Instance name. Uses default if omitted.",
439
- },
440
- },
441
- required: ["reference"],
442
- },
443
- },
444
- {
445
- name: "op_generate_password",
446
- description:
447
- "Generate a password using 1Password's generator (Random / Memorable / Pin). Does not talk to the account.",
448
- inputSchema: {
449
- type: "object",
450
- properties: {
451
- type: {
452
- type: "string",
453
- description: "'Random' (default), 'Memorable', or 'Pin'.",
454
- },
455
- length: {
456
- type: "number",
457
- description: "Length for Random/Pin (default 20/8).",
458
- },
459
- includeDigits: {
460
- type: "boolean",
461
- description: "Random only (default true).",
462
- },
463
- includeSymbols: {
464
- type: "boolean",
465
- description: "Random only (default true).",
466
- },
467
- wordCount: {
468
- type: "number",
469
- description: "Memorable only (default 5).",
470
- },
471
- capitalize: {
472
- type: "boolean",
473
- description: "Memorable only (default true).",
474
- },
475
- },
476
- required: [],
477
- },
478
- },
479
- {
480
- name: "op_create_item",
481
- description:
482
- "Create a new item in a vault. Fields is an array of { title, type, value, sectionId? }. Type is Text | Concealed | Totp | Email | Url | Phone.",
483
- inputSchema: {
484
- type: "object",
485
- properties: {
486
- vault: { type: "string", description: "Vault id or title." },
487
- title: { type: "string", description: "Item title." },
488
- category: {
489
- type: "string",
490
- description:
491
- "Item category (default: Login). One of: Login, SecureNote, Password, ApiCredentials, Database, Server, Email, Identity, CreditCard, etc.",
492
- },
493
- fields: {
494
- type: "array",
495
- description:
496
- "Field definitions: [{ title, type, value, sectionId? }].",
497
- items: {
498
- type: "object",
499
- properties: {
500
- title: { type: "string" },
501
- type: { type: "string" },
502
- value: { type: "string" },
503
- sectionId: { type: "string" },
504
- },
505
- required: ["title", "type", "value"],
506
- },
507
- },
508
- tags: { type: "array", items: { type: "string" } },
509
- websites: {
510
- type: "array",
511
- items: {
512
- type: "object",
513
- properties: {
514
- url: { type: "string" },
515
- label: { type: "string" },
516
- },
517
- required: ["url"],
518
- },
519
- },
520
- notes: {
521
- type: "string",
522
- description:
523
- "Optional notes (stored as a Text field called 'notesPlain').",
524
- },
525
- instance: {
526
- type: "string",
527
- description: "Instance name. Uses default if omitted.",
528
- },
529
- },
530
- required: ["vault", "title"],
531
- },
532
- },
533
- {
534
- name: "op_update_item",
535
- description:
536
- "Update any part of an existing item — title, category, fields, tags, websites, sections, or notes. Omitted parameters are preserved. Fields are patched by title (existing fields match case-insensitively; new fields require `type`). Tags, websites, and sections are REPLACED when passed.",
537
- inputSchema: {
538
- type: "object",
539
- properties: {
540
- vault: { type: "string", description: "Vault id or title." },
541
- item: { type: "string", description: "Item id or title to update." },
542
- title: { type: "string", description: "New title (optional)." },
543
- category: {
544
- type: "string",
545
- description:
546
- "New category (rare). One of Login, SecureNote, Password, ApiCredentials, Database, Server, Email, Identity, CreditCard, etc.",
547
- },
548
- fields: {
549
- type: "array",
550
- description:
551
- "Field patches: [{title, value, type?, sectionId?}]. Existing fields matched by title have their values replaced. New fields require `type` (Text | Concealed | Totp | Email | Url | Phone).",
552
- items: {
553
- type: "object",
554
- properties: {
555
- title: { type: "string" },
556
- value: { type: "string" },
557
- type: { type: "string" },
558
- sectionId: { type: "string" },
559
- },
560
- required: ["title", "value"],
561
- },
562
- },
563
- tags: {
564
- type: "array",
565
- items: { type: "string" },
566
- description:
567
- "Replace the tag list. Omit to preserve existing tags; pass [] to clear.",
568
- },
569
- websites: {
570
- type: "array",
571
- description:
572
- "Replace the website list. Each website is {url, label?, autofillBehavior?}. Omit to preserve existing websites.",
573
- items: {
574
- type: "object",
575
- properties: {
576
- url: { type: "string" },
577
- label: { type: "string" },
578
- autofillBehavior: {
579
- type: "string",
580
- description: "'AnywhereOnWebsite' (default) or 'Never'.",
581
- },
582
- },
583
- required: ["url"],
584
- },
585
- },
586
- sections: {
587
- type: "array",
588
- description: "Replace the section list. Each section is {title, id?}.",
589
- items: {
590
- type: "object",
591
- properties: {
592
- title: { type: "string" },
593
- id: { type: "string" },
594
- },
595
- required: ["title"],
596
- },
597
- },
598
- notes: {
599
- type: "string",
600
- description:
601
- "Replace the notesPlain field. Omit to preserve existing notes.",
602
- },
603
- instance: {
604
- type: "string",
605
- description: "Instance name. Uses default if omitted.",
606
- },
607
- },
608
- required: ["vault", "item"],
609
- },
610
- },
611
- {
612
- name: "op_update_vault",
613
- description:
614
- "Update a vault's title and/or description. Pass at least one of them.",
615
- inputSchema: {
616
- type: "object",
617
- properties: {
618
- vault: { type: "string", description: "Vault id or title." },
619
- title: { type: "string", description: "New title (optional)." },
620
- description: {
621
- type: "string",
622
- description: "New description (optional).",
623
- },
624
- instance: {
625
- type: "string",
626
- description: "Instance name. Uses default if omitted.",
627
- },
628
- },
629
- required: ["vault"],
630
- },
631
- },
632
- {
633
- name: "op_delete_item",
634
- description: "Delete an item by vault + item reference.",
635
- inputSchema: {
636
- type: "object",
637
- properties: {
638
- vault: { type: "string", description: "Vault id or title." },
639
- item: { type: "string", description: "Item id or title." },
640
- instance: {
641
- type: "string",
642
- description: "Instance name. Uses default if omitted.",
643
- },
644
- },
645
- required: ["vault", "item"],
646
- },
647
- },
648
- {
649
- name: "op_create_vault",
650
- description: "Create a new vault.",
651
- inputSchema: {
652
- type: "object",
653
- properties: {
654
- title: { type: "string", description: "Vault title." },
655
- description: {
656
- type: "string",
657
- description: "Optional description.",
658
- },
659
- instance: {
660
- type: "string",
661
- description: "Instance name. Uses default if omitted.",
662
- },
663
- },
664
- required: ["title"],
665
- },
666
- },
667
- {
668
- name: "op_delete_vault",
669
- description: "Delete a vault by id or title.",
670
- inputSchema: {
671
- type: "object",
672
- properties: {
673
- vault: { type: "string", description: "Vault id or title." },
674
- instance: {
675
- type: "string",
676
- description: "Instance name. Uses default if omitted.",
677
- },
678
- },
679
- required: ["vault"],
680
- },
681
- },
682
- ];
683
-
684
- // ============================================================================
685
- // Handlers
686
- //
687
- // Each handler is keyed by tool name and receives the tool arguments. It
688
- // should return a plain string (rendered as text content by the dispatcher)
689
- // or throw on failure. The dispatcher handles MCP wrapping and error shape.
690
- // ============================================================================
691
-
692
- async function addInstanceHandler(args) {
693
- const instName = args.name.trim();
694
- const authType = args.authType || (args.token ? "serviceAccount" : "desktop");
695
-
696
- if (authType === "serviceAccount") {
697
- if (!args.token) {
698
- throw new Error("serviceAccount authType requires a token.");
699
- }
700
- if (!/^ops_[A-Za-z0-9_\-]+/.test(args.token)) {
701
- throw new Error(
702
- "Token does not look like a 1Password service account token (expected prefix 'ops_').",
703
- );
704
- }
705
- } else if (authType === "desktop") {
706
- if (!args.accountName) {
707
- throw new Error(
708
- "desktop authType requires accountName (e.g. 'my.1password.com').",
709
- );
710
- }
711
- } else {
712
- throw new Error(`Unknown authType "${authType}".`);
713
- }
714
-
715
- // Validate by creating a client and listing one vault.
716
- const probeAuth =
717
- authType === "desktop" ? new sdk.DesktopAuth(args.accountName) : args.token;
718
- const tempClient = await sdk.createClient({
719
- auth: probeAuth,
720
- integrationName: INTEGRATION_NAME,
721
- integrationVersion: `v${PKG_VERSION}`,
722
- });
723
- const probe = [];
724
- for await (const v of await tempClient.vaults.list()) {
725
- probe.push(v);
726
- if (probe.length >= 1) break;
727
- }
728
-
729
- const newInst = {
730
- name: instName,
731
- authType,
732
- token: authType === "serviceAccount" ? args.token : undefined,
733
- accountName: authType === "desktop" ? args.accountName : undefined,
734
- description: args.description || "",
735
- };
736
- const existingIdx = instances.findIndex((i) => i.name === instName);
737
- if (existingIdx >= 0) instances[existingIdx] = newInst;
738
- else instances.push(newInst);
739
- invalidateClient(instName);
740
-
741
- const savedConfig = loadConfigFile();
742
- if (!savedConfig.instances) savedConfig.instances = [];
743
- const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
744
- const toSave = { name: instName, authType };
745
- if (authType === "serviceAccount") toSave.token = args.token;
746
- if (authType === "desktop") toSave.accountName = args.accountName;
747
- if (args.description) toSave.description = args.description;
748
- if (savedIdx >= 0) savedConfig.instances[savedIdx] = toSave;
749
- else savedConfig.instances.push(toSave);
750
- if (args.setDefault || !savedConfig.defaultInstance) {
751
- savedConfig.defaultInstance = instName;
752
- defaultInstance = newInst;
753
- }
754
- saveConfigFile(savedConfig);
755
-
756
- const verb = existingIdx >= 0 ? "Updated" : "Added";
757
- const suffix = args.setDefault ? " Set as default." : "";
758
- return `${verb} instance "${instName}" (${authType}).${suffix}`;
759
- }
760
-
761
- async function removeInstanceHandler(args) {
762
- if (instances.length <= 1) {
763
- throw new Error("Cannot remove the last remaining instance.");
764
- }
765
- const idx = instances.findIndex((i) => i.name === args.name);
766
- if (idx < 0) throw new Error(`Instance "${args.name}" not found.`);
767
-
768
- instances.splice(idx, 1);
769
- invalidateClient(args.name);
770
-
771
- const savedConfig = loadConfigFile();
772
- if (savedConfig.instances) {
773
- savedConfig.instances = savedConfig.instances.filter(
774
- (i) => i.name !== args.name,
775
- );
776
- if (savedConfig.defaultInstance === args.name) {
777
- savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
778
- defaultInstance = instances[0] || null;
779
- }
780
- saveConfigFile(savedConfig);
781
- }
782
- return `Removed instance "${args.name}".`;
783
- }
784
-
785
- function listInstancesHandler() {
786
- if (instances.length === 0) {
787
- return "No instances configured. Run `1password-mcp setup` or call op_add_instance.";
788
- }
789
- const def = defaultInstance?.name;
790
- const lines = instances.map((inst) => {
791
- const isDefault = inst.name === def ? " **(default)**" : "";
792
- const desc = inst.description ? ` - ${inst.description}` : "";
793
- const auth =
794
- inst.authType === "desktop"
795
- ? ` (desktop: ${inst.accountName})`
796
- : " (serviceAccount)";
797
- return `- **${inst.name}**${isDefault}${auth}${desc}`;
798
- });
799
- return `# 1Password Instances (${instances.length})\n\n${lines.join("\n")}`;
800
- }
801
-
802
- async function listVaultsHandler(args) {
803
- const client = await getClient(getInstanceByName(args.instance));
804
- const vaults = await collect(await client.vaults.list());
805
- if (vaults.length === 0) {
806
- return "No vaults visible to this instance.";
807
- }
808
- const lines = vaults.map((v) => `- **${v.title}** (id: ${v.id})`);
809
- return `# Vaults (${vaults.length})\n\n${lines.join("\n")}`;
810
- }
811
-
812
- async function listItemsHandler(args) {
813
- const client = await getClient(getInstanceByName(args.instance));
814
- const vault = await resolveVault(client, args.vault);
815
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
816
-
817
- const items = await collect(await client.items.list(vault.id));
818
- if (items.length === 0) {
819
- return `No items in vault "${vault.title}".`;
820
- }
821
- const lines = items.map(
822
- (i) => `- **${i.title}** (id: ${i.id}, category: ${i.category})`,
823
- );
824
- return `# Items in ${vault.title} (${items.length})\n\n${lines.join("\n")}`;
825
- }
826
-
827
- /**
828
- * Core search implementation — pulled out of the handler so it can be unit
829
- * tested with a fake client. Accepts an already-resolved client.
830
- */
831
- async function searchItems(client, { query, vault, reveal, limit }) {
832
- const wantReveal = !!reveal;
833
- const max = limit || (wantReveal ? 5 : 25);
834
- const q = String(query).toLowerCase();
835
-
836
- let targetVaults;
837
- if (vault) {
838
- const v = await resolveVault(client, vault);
839
- if (!v) throw new Error(`Vault "${vault}" not found.`);
840
- targetVaults = [v];
841
- } else {
842
- targetVaults = await collect(await client.vaults.list());
843
- }
844
-
845
- const hits = [];
846
- for (const v of targetVaults) {
847
- for await (const it of await client.items.list(v.id)) {
848
- if ((it.title || "").toLowerCase().includes(q)) {
849
- hits.push({ ...it, _vaultId: v.id, _vaultTitle: v.title });
850
- if (hits.length >= max) break;
851
- }
852
- }
853
- if (hits.length >= max) break;
854
- }
855
-
856
- if (hits.length === 0) return `No items matched "${query}".`;
857
-
858
- if (!wantReveal) {
859
- const lines = hits.map(
860
- (it) =>
861
- `- **${it.title}** (id: ${it.id}, vault: ${it._vaultTitle}, category: ${it.category})`,
862
- );
863
- return `# Search: "${query}" (${hits.length})\n\n${lines.join("\n")}`;
864
- }
865
-
866
- // reveal=true: fetch each hit's full item in parallel and render every
867
- // field (including concealed values). One-shot path for "give me the X
868
- // credential" style queries.
869
- const full = await Promise.all(
870
- hits.map(async (it) => {
871
- try {
872
- const item = await client.items.get(it._vaultId, it.id);
873
- return { item, _vaultTitle: it._vaultTitle, _error: null };
874
- } catch (e) {
875
- return { item: it, _vaultTitle: it._vaultTitle, _error: e.message };
876
- }
877
- }),
878
- );
879
-
880
- const sections = full.map((entry) => {
881
- if (entry._error) {
882
- return `## ${entry.item.title}\n\n_Failed to fetch: ${entry._error}_`;
883
- }
884
- const it = entry.item;
885
- const fields = (it.fields || [])
886
- .map((f) => redactField(f, true))
887
- .filter((f) => f.value !== undefined && f.value !== "");
888
- const fieldLines = fields.length
889
- ? fields
890
- .map((f) => {
891
- const otp = f.otp ? ` (OTP now: ${f.otp})` : "";
892
- return `- **${f.title}** (${f.type}): ${f.value}${otp}`;
893
- })
894
- .join("\n")
895
- : "_(no fields)_";
896
- const websites = (it.websites || []).map((w) => w.url).filter(Boolean);
897
- const websiteLine = websites.length
898
- ? `- **URLs:** ${websites.join(", ")}\n`
899
- : "";
900
- return `## ${it.title} (${it.category})\n\n- **Vault:** ${entry._vaultTitle}\n- **Item ID:** ${it.id}\n${websiteLine}\n${fieldLines}`;
901
- });
902
-
903
- return `# Search (revealed): "${query}" (${hits.length})\n\n${sections.join("\n\n")}`;
904
- }
905
-
906
- async function searchItemsHandler(args) {
907
- const client = await getClient(getInstanceByName(args.instance));
908
- return searchItems(client, args);
909
- }
910
-
911
- async function getItemHandler(args) {
912
- const client = await getClient(getInstanceByName(args.instance));
913
- const vault = await resolveVault(client, args.vault);
914
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
915
- const itemRef = await resolveItem(client, vault, args.item);
916
- if (!itemRef) {
917
- throw new Error(
918
- `Item "${args.item}" not found in vault "${vault.title}".`,
919
- );
920
- }
921
- const full = await client.items.get(vault.id, itemRef.id);
922
- const formatted = formatItem(full, !!args.includeConcealed);
923
-
924
- const lines = [
925
- `# ${formatted.title} (${formatted.category})`,
926
- "",
927
- `- **Vault:** ${vault.title} (${formatted.vaultId})`,
928
- `- **Item ID:** ${formatted.id}`,
929
- ];
930
- if (formatted.tags.length) lines.push(`- **Tags:** ${formatted.tags.join(", ")}`);
931
- if (formatted.websites.length) {
932
- lines.push(
933
- `- **Websites:** ${formatted.websites.map((w) => w.url).join(", ")}`,
934
- );
935
- }
936
- if (formatted.fields.length) {
937
- lines.push("", "## Fields", "");
938
- for (const f of formatted.fields) {
939
- const val = f.value === undefined ? "" : f.value;
940
- const otp = f.otp ? ` (OTP now: ${f.otp})` : "";
941
- lines.push(`- **${f.title}** (${f.type}): ${val}${otp}`);
942
- }
943
- }
944
- if (!args.includeConcealed) {
945
- lines.push(
946
- "",
947
- "_Concealed fields are redacted. Call with includeConcealed=true to reveal._",
948
- );
949
- }
950
- return lines.join("\n");
951
- }
952
-
953
- async function getSecretHandler(args) {
954
- const client = await getClient(getInstanceByName(args.instance));
955
- const value = await client.secrets.resolve(args.reference);
956
- return `Secret for ${args.reference}:\n${value}`;
957
- }
958
-
959
- function generatePasswordHandler(args) {
960
- const type = args.type || "Random";
961
- let generated;
962
- if (type === "Pin") {
963
- generated = sdk.Secrets.generatePassword({
964
- type: "Pin",
965
- parameters: { length: args.length || 8 },
966
- });
967
- } else if (type === "Memorable") {
968
- generated = sdk.Secrets.generatePassword({
969
- type: "Memorable",
970
- parameters: {
971
- separatorType: sdk.SeparatorType.Digits,
972
- capitalize: args.capitalize !== false,
973
- wordListType: sdk.WordListType.FullWords,
974
- wordCount: args.wordCount || 5,
975
- },
976
- });
977
- } else {
978
- generated = sdk.Secrets.generatePassword({
979
- type: "Random",
980
- parameters: {
981
- includeDigits: args.includeDigits !== false,
982
- includeSymbols: args.includeSymbols !== false,
983
- length: args.length || 20,
984
- },
985
- });
986
- }
987
- const pwd =
988
- typeof generated === "string" ? generated : generated.password || generated;
989
- return `Generated (${type}):\n${pwd}`;
990
- }
991
-
992
- async function updateItemHandler(args) {
993
- const client = await getClient(getInstanceByName(args.instance));
994
- const vault = await resolveVault(client, args.vault);
995
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
996
- const itemRef = await resolveItem(client, vault, args.item);
997
- if (!itemRef) {
998
- throw new Error(
999
- `Item "${args.item}" not found in vault "${vault.title}".`,
1000
- );
1001
- }
1002
-
1003
- const current = await client.items.get(vault.id, itemRef.id);
1004
- const updated = { ...current };
1005
-
1006
- // --- title (rename) ---
1007
- if (args.title) updated.title = args.title;
1008
-
1009
- // --- category (rarely changed, but the SDK allows it) ---
1010
- if (args.category) {
1011
- const cat = sdk.ItemCategory[args.category];
1012
- if (!cat) throw new Error(`Unknown category "${args.category}".`);
1013
- updated.category = cat;
1014
- }
1015
-
1016
- // --- tags (replace when passed; preserved when omitted) ---
1017
- if (args.tags) updated.tags = args.tags;
1018
-
1019
- // --- websites (replace when passed; preserved when omitted) ---
1020
- if (Array.isArray(args.websites)) {
1021
- updated.websites = args.websites.map((w) => ({
1022
- url: w.url,
1023
- label: w.label || "website",
1024
- autofillBehavior:
1025
- w.autofillBehavior === "Never"
1026
- ? sdk.AutofillBehavior.Never
1027
- : sdk.AutofillBehavior.AnywhereOnWebsite,
1028
- }));
1029
- }
1030
-
1031
- // --- sections (replace when passed; preserved when omitted) ---
1032
- if (Array.isArray(args.sections)) {
1033
- updated.sections = args.sections.map((s) => ({
1034
- id: s.id || s.title.toLowerCase().replace(/\s+/g, "_"),
1035
- title: s.title,
1036
- }));
1037
- }
1038
-
1039
- // --- fields (patch by title, add new ones with explicit type) ---
1040
- const nextFields = [...(current.fields || [])];
1041
- const fieldIndexByTitle = new Map(
1042
- nextFields.map((f, idx) => [(f.title || "").toLowerCase(), idx]),
1043
- );
1044
-
1045
- function upsertField({ title, value, type, sectionId }) {
1046
- const key = title.toLowerCase();
1047
- const existingIdx = fieldIndexByTitle.get(key);
1048
- if (existingIdx !== undefined) {
1049
- nextFields[existingIdx] = { ...nextFields[existingIdx], value };
1050
- return;
1051
- }
1052
- if (!type) {
1053
- throw new Error(
1054
- `Field "${title}" doesn't exist on the item — pass a 'type' to add it as a new field.`,
1055
- );
1056
- }
1057
- const ft = sdk.ItemFieldType[type];
1058
- if (!ft) throw new Error(`Unknown field type "${type}".`);
1059
- const added = {
1060
- id: title.toLowerCase().replace(/\s+/g, "_"),
1061
- title,
1062
- fieldType: ft,
1063
- value,
1064
- };
1065
- if (sectionId) added.sectionId = sectionId;
1066
- fieldIndexByTitle.set(key, nextFields.length);
1067
- nextFields.push(added);
1068
- }
1069
-
1070
- if (Array.isArray(args.fields)) {
1071
- for (const patch of args.fields) upsertField(patch);
1072
- }
1073
-
1074
- // --- notes: stored as a 'notesPlain' Text field, so patch it the same way ---
1075
- if (args.notes !== undefined) {
1076
- upsertField({ title: "notesPlain", value: args.notes, type: "Text" });
1077
- }
1078
-
1079
- updated.fields = nextFields;
1080
-
1081
- const result = await client.items.put(updated);
1082
- return `Updated item "${result.title}" (id: ${result.id}) in vault "${vault.title}".`;
1083
- }
1084
-
1085
- async function updateVaultHandler(args) {
1086
- const client = await getClient(getInstanceByName(args.instance));
1087
- const vault = await resolveVault(client, args.vault);
1088
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1089
- if (!args.title && args.description === undefined) {
1090
- throw new Error("Provide at least one of title or description.");
1091
- }
1092
- await client.vaults.update(vault.id, {
1093
- title: args.title || vault.title,
1094
- description: args.description !== undefined ? args.description : vault.description || "",
1095
- });
1096
- return `Updated vault "${args.title || vault.title}" (id: ${vault.id}).`;
1097
- }
1098
-
1099
- async function createItemHandler(args) {
1100
- const client = await getClient(getInstanceByName(args.instance));
1101
- const vault = await resolveVault(client, args.vault);
1102
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1103
-
1104
- const category = sdk.ItemCategory[args.category || "Login"];
1105
- if (!category) throw new Error(`Unknown category "${args.category}".`);
1106
-
1107
- const fields = (args.fields || []).map((f) => {
1108
- const ft = sdk.ItemFieldType[f.type];
1109
- if (!ft) throw new Error(`Unknown field type "${f.type}"`);
1110
- const out = {
1111
- id: f.title.toLowerCase().replace(/\s+/g, "_"),
1112
- title: f.title,
1113
- fieldType: ft,
1114
- value: f.value,
1115
- };
1116
- if (f.sectionId) out.sectionId = f.sectionId;
1117
- return out;
1118
- });
1119
- if (args.notes) {
1120
- fields.push({
1121
- id: "notesPlain",
1122
- title: "notesPlain",
1123
- fieldType: sdk.ItemFieldType.Text,
1124
- value: args.notes,
1125
- });
1126
- }
1127
-
1128
- const item = await client.items.create({
1129
- title: args.title,
1130
- category,
1131
- vaultId: vault.id,
1132
- fields,
1133
- tags: args.tags || [],
1134
- websites: (args.websites || []).map((w) => ({
1135
- url: w.url,
1136
- label: w.label || "website",
1137
- autofillBehavior: sdk.AutofillBehavior.AnywhereOnWebsite,
1138
- })),
1139
- });
1140
- return `Created item **${item.title}** (id: ${item.id}) in vault "${vault.title}".`;
1141
- }
1142
-
1143
- async function deleteItemHandler(args) {
1144
- const client = await getClient(getInstanceByName(args.instance));
1145
- const vault = await resolveVault(client, args.vault);
1146
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1147
- const itemRef = await resolveItem(client, vault, args.item);
1148
- if (!itemRef) {
1149
- throw new Error(
1150
- `Item "${args.item}" not found in vault "${vault.title}".`,
1151
- );
1152
- }
1153
- await client.items.delete(vault.id, itemRef.id);
1154
- return `Deleted item "${itemRef.title}" from vault "${vault.title}".`;
1155
- }
1156
-
1157
- async function createVaultHandler(args) {
1158
- const client = await getClient(getInstanceByName(args.instance));
1159
- const vault = await client.vaults.create({
1160
- title: args.title,
1161
- description: args.description || "",
1162
- });
1163
- return `Created vault **${vault.title}** (id: ${vault.id}).`;
1164
- }
1165
-
1166
- async function deleteVaultHandler(args) {
1167
- const client = await getClient(getInstanceByName(args.instance));
1168
- const vault = await resolveVault(client, args.vault);
1169
- if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1170
- await client.vaults.delete(vault.id);
1171
- return `Deleted vault "${vault.title}".`;
1172
- }
1173
-
1174
- const HANDLERS = {
1175
- op_list_instances: listInstancesHandler,
1176
- op_add_instance: addInstanceHandler,
1177
- op_remove_instance: removeInstanceHandler,
1178
- op_list_vaults: listVaultsHandler,
1179
- op_list_items: listItemsHandler,
1180
- op_search_items: searchItemsHandler,
1181
- op_get_item: getItemHandler,
1182
- op_get_secret: getSecretHandler,
1183
- op_generate_password: generatePasswordHandler,
1184
- op_create_item: createItemHandler,
1185
- op_update_item: updateItemHandler,
1186
- op_delete_item: deleteItemHandler,
1187
- op_create_vault: createVaultHandler,
1188
- op_update_vault: updateVaultHandler,
1189
- op_delete_vault: deleteVaultHandler,
1190
- };
1191
-
1192
- // Drift guard: every declared tool must have a handler and vice versa. This is
1193
- // evaluated at module load so a missing/extra handler fails fast.
1194
- (function assertHandlerCompleteness() {
1195
- const toolNames = new Set(TOOL_DEFINITIONS.map((t) => t.name));
1196
- const handlerNames = new Set(Object.keys(HANDLERS));
1197
- for (const name of toolNames) {
1198
- if (!handlerNames.has(name)) {
1199
- throw new Error(`Tool "${name}" is declared but has no handler.`);
1200
- }
1201
- }
1202
- for (const name of handlerNames) {
1203
- if (!toolNames.has(name)) {
1204
- throw new Error(`Handler "${name}" exists but is not declared in TOOL_DEFINITIONS.`);
1205
- }
1206
- }
1207
- })();
1208
-
1209
- // ============================================================================
1210
- // MCP server wiring
1211
- // ============================================================================
1212
-
1213
- const server = new Server(
1214
- { name: INTEGRATION_NAME, version: PKG_VERSION },
1215
- { capabilities: { tools: {} } },
1216
- );
1217
-
1218
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
1219
- tools: TOOL_DEFINITIONS,
1220
- }));
1221
-
1222
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1223
- const { name, arguments: args } = request.params;
1224
- const handler = HANDLERS[name];
1225
- if (!handler) {
1226
- return {
1227
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
1228
- isError: true,
1229
- };
1230
- }
1231
- for (let attempt = 0; attempt < 2; attempt++) {
1232
- try {
1233
- const result = await handler(args || {});
1234
- return { content: [{ type: "text", text: String(result) }] };
1235
- } catch (e) {
1236
- if (attempt === 0 && isSessionError(e)) {
1237
- // Stale IPC connection — purge cached clients and retry once so the
1238
- // next getClient() call creates a fresh session.
1239
- clientCache.clear();
1240
- continue;
1241
- }
1242
- return {
1243
- content: [{ type: "text", text: `Error: ${e.message}` }],
1244
- isError: true,
1245
- };
1246
- }
1247
- }
1248
- });
1249
-
1250
- async function main() {
1251
- const transport = new StdioServerTransport();
1252
- await server.connect(transport);
1253
- }
1254
-
1255
- if (require.main === module) {
1256
- main().catch((e) => {
1257
- console.error(e);
1258
- process.exit(1);
1259
- });
1260
- }
1261
-
1262
- // ============================================================================
1263
- // Test exports
1264
- // ============================================================================
1265
-
1266
- if (typeof module !== "undefined") {
1267
- module.exports = {
1268
- // config
1269
- CONFIG_DIR,
1270
- CONFIG_PATH,
1271
- loadConfigFile,
1272
- saveConfigFile,
1273
- normalizeInstance,
1274
- // instance resolution
1275
- getInstanceByName,
1276
- // helpers
1277
- collect,
1278
- resolveVault,
1279
- resolveItem,
1280
- redactField,
1281
- formatItem,
1282
- searchItems,
1283
- // handler map + tool list (for drift guard tests)
1284
- TOOL_DEFINITIONS,
1285
- HANDLERS,
1286
- // direct handler access for unit tests that don't need the MCP transport
1287
- listInstancesHandler,
1288
- listVaultsHandler,
1289
- listItemsHandler,
1290
- searchItemsHandler,
1291
- getItemHandler,
1292
- generatePasswordHandler,
1293
- updateItemHandler,
1294
- updateVaultHandler,
1295
- };
1296
- }
1
+ #!/usr/bin/env node
2
+
3
+ // ============================================================================
4
+ // 1Password MCP Server
5
+ //
6
+ // Exposes 1Password vault/item operations as MCP tools for Claude Code. Uses
7
+ // the official @1password/sdk with either a service-account token or desktop
8
+ // app auth (delegating unlock to 1Password 8 via DesktopAuth).
9
+ //
10
+ // Multi-instance: several 1Password accounts can be configured; each tool
11
+ // accepts an optional `instance` parameter to pick which one to hit.
12
+ // ============================================================================
13
+
14
+ // Handle `1password-mcp setup` subcommand
15
+ if (process.argv[2] === "setup") {
16
+ require("./setup.js");
17
+ return;
18
+ }
19
+
20
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
21
+ const {
22
+ StdioServerTransport,
23
+ } = require("@modelcontextprotocol/sdk/server/stdio.js");
24
+ const {
25
+ CallToolRequestSchema,
26
+ ListToolsRequestSchema,
27
+ } = require("@modelcontextprotocol/sdk/types.js");
28
+ const fs = require("fs");
29
+ const os = require("os");
30
+ const path = require("path");
31
+ const sdk = require("@1password/sdk");
32
+ const { ICONS } = require("./icons.js");
33
+ const { spawn } = require("child_process");
34
+
35
+ const PKG_VERSION = require("./package.json").version;
36
+ const INTEGRATION_NAME = "1password-mcp";
37
+
38
+ // ============================================================================
39
+ // Auto-update — same pattern as jira-mcp: ask the npm registry on every start
40
+ // what the latest published version is, and install it in the background when
41
+ // it isn't the one we're running. The package ships through npm, so there's no
42
+ // git involved: a plain fetch plus an npm install. Runs detached from startup
43
+ // so the server begins serving stdio immediately.
44
+ // ============================================================================
45
+ const NPM_PACKAGE = "@rui.branco/1password-mcp";
46
+ (async () => {
47
+ try {
48
+ const res = await fetch(
49
+ `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE)}/latest`,
50
+ { signal: AbortSignal.timeout(5000) },
51
+ );
52
+ const latest = (await res.json()).version;
53
+ if (latest && latest !== PKG_VERSION) {
54
+ // Passed as one shell string rather than an argv array: npm is npm.cmd on
55
+ // Windows, and since Node 18.20.2 spawning a .cmd without a shell throws
56
+ // EINVAL outright (CVE-2024-27980 hardening). shell:true picks cmd.exe
57
+ // there and /bin/sh elsewhere, so this stays portable — unlike the old
58
+ // hardcoded `sh`, which simply doesn't exist on Windows. The command is a
59
+ // fixed literal with no interpolation, so there's nothing to inject.
60
+ const child = spawn(`npm install -g ${NPM_PACKAGE}`, {
61
+ shell: true,
62
+ stdio: "ignore",
63
+ });
64
+ // A spawn failure (npm missing from PATH) arrives as an async 'error'
65
+ // event, which no try/catch around the spawn can see — left unhandled it
66
+ // would take the whole server down at startup. Swallow it here.
67
+ child.on("error", () => {});
68
+ }
69
+ } catch {
70
+ /* offline, slow registry, or bad response — no-op */
71
+ }
72
+ })();
73
+
74
+ // ============================================================================
75
+ // Config
76
+ // ============================================================================
77
+
78
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "1password-mcp");
79
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
80
+
81
+ function ensureConfigDir() {
82
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
83
+ }
84
+
85
+ function loadConfigFile() {
86
+ try {
87
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ function saveConfigFile(cfg) {
94
+ ensureConfigDir();
95
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
96
+ try {
97
+ fs.chmodSync(CONFIG_PATH, 0o600);
98
+ } catch {
99
+ /* chmod can fail on non-POSIX filesystems — non-fatal */
100
+ }
101
+ }
102
+
103
+ ensureConfigDir();
104
+
105
+ /**
106
+ * Normalize a persisted config entry into the runtime instance shape.
107
+ * Accepts legacy records that only had a token and infers authType.
108
+ */
109
+ function normalizeInstance(raw) {
110
+ return {
111
+ name: raw.name,
112
+ authType: raw.authType || (raw.token ? "serviceAccount" : "desktop"),
113
+ token: raw.token,
114
+ accountName: raw.accountName,
115
+ description: raw.description || "",
116
+ };
117
+ }
118
+
119
+ const rawConfig = fs.existsSync(CONFIG_PATH)
120
+ ? loadConfigFile()
121
+ : { instances: [] };
122
+
123
+ let instances = (rawConfig.instances || []).map(normalizeInstance);
124
+
125
+ let defaultInstance =
126
+ (rawConfig.defaultInstance &&
127
+ instances.find((i) => i.name === rawConfig.defaultInstance)) ||
128
+ instances[0] ||
129
+ null;
130
+
131
+ /**
132
+ * Resolve an instance by name, falling back to the default if the name is
133
+ * omitted or unknown. Returns null only when no instances are configured at all.
134
+ */
135
+ function getInstanceByName(name) {
136
+ if (!name) return defaultInstance;
137
+ return instances.find((i) => i.name === name) || defaultInstance;
138
+ }
139
+
140
+ // ============================================================================
141
+ // 1Password SDK client
142
+ // ============================================================================
143
+
144
+ // Cache SDK clients per instance — createClient spins up a WASM runtime and
145
+ // negotiates an auth session, so we want exactly one per process lifetime.
146
+ const clientCache = new Map();
147
+
148
+ function buildAuth(instance) {
149
+ if (instance.authType === "desktop") {
150
+ if (!instance.accountName) {
151
+ throw new Error(
152
+ `Instance "${instance.name}" is desktop-auth but has no accountName. Re-run setup.`,
153
+ );
154
+ }
155
+ return new sdk.DesktopAuth(instance.accountName);
156
+ }
157
+ if (!instance.token) {
158
+ throw new Error(`Instance "${instance.name}" has no service account token.`);
159
+ }
160
+ return instance.token;
161
+ }
162
+
163
+ async function getClient(instance) {
164
+ if (!instance) {
165
+ throw new Error(
166
+ "No 1Password instance configured. Run `1password-mcp setup` or call op_add_instance.",
167
+ );
168
+ }
169
+ if (clientCache.has(instance.name)) return clientCache.get(instance.name);
170
+
171
+ const client = await sdk.createClient({
172
+ auth: buildAuth(instance),
173
+ integrationName: INTEGRATION_NAME,
174
+ integrationVersion: `v${PKG_VERSION}`,
175
+ });
176
+ clientCache.set(instance.name, client);
177
+ return client;
178
+ }
179
+
180
+ function invalidateClient(name) {
181
+ clientCache.delete(name);
182
+ }
183
+
184
+ /**
185
+ * Detect errors that indicate the IPC / desktop-app session is dead so the
186
+ * cached SDK client can be discarded and a fresh one created on retry.
187
+ */
188
+ function isSessionError(e) {
189
+ if (e instanceof sdk.DesktopSessionExpiredError) return true;
190
+ // Belt-and-suspenders: catch IPC / auth wording from the native layer even
191
+ // when the SDK wraps it in a generic Error.
192
+ return /IPC|session.expired|desktop.auth|socket.closed/i.test(e.message);
193
+ }
194
+
195
+ // ============================================================================
196
+ // Helpers — vault/item resolution, redaction, formatting
197
+ // ============================================================================
198
+
199
+ async function collect(asyncIterable) {
200
+ const out = [];
201
+ for await (const x of asyncIterable) out.push(x);
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Resolve a vault from a reference that may be an id, exact title (case-insensitive),
207
+ * or prefix of a title. Returns null when nothing matches.
208
+ */
209
+ async function resolveVault(client, vaultRef) {
210
+ if (!vaultRef) return null;
211
+ const vaults = await collect(await client.vaults.list());
212
+ const byId = vaults.find((v) => v.id === vaultRef);
213
+ if (byId) return byId;
214
+ const lower = String(vaultRef).toLowerCase();
215
+ return (
216
+ vaults.find((v) => (v.title || "").toLowerCase() === lower) ||
217
+ vaults.find((v) => (v.title || "").toLowerCase().startsWith(lower)) ||
218
+ null
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Same strategy as resolveVault but for items inside a specific vault.
224
+ */
225
+ async function resolveItem(client, vault, itemRef) {
226
+ if (!itemRef) return null;
227
+ const items = await collect(await client.items.list(vault.id));
228
+ const byId = items.find((i) => i.id === itemRef);
229
+ if (byId) return byId;
230
+ const lower = String(itemRef).toLowerCase();
231
+ return (
232
+ items.find((i) => (i.title || "").toLowerCase() === lower) ||
233
+ items.find((i) => (i.title || "").toLowerCase().startsWith(lower)) ||
234
+ null
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Shape a single 1Password field for output. Concealed and TOTP values are
240
+ * replaced with "[REDACTED]" unless includeConcealed=true. OTP codes are only
241
+ * surfaced when concealed values are revealed.
242
+ */
243
+ function redactField(field, includeConcealed) {
244
+ const out = {
245
+ id: field.id,
246
+ title: field.title,
247
+ type: field.fieldType,
248
+ sectionId: field.sectionId || undefined,
249
+ };
250
+ const isSecret = field.fieldType === "Concealed" || field.fieldType === "Totp";
251
+ if (!isSecret || includeConcealed) {
252
+ out.value = field.value;
253
+ if (
254
+ field.details &&
255
+ field.details.type === "Otp" &&
256
+ field.details.content &&
257
+ field.details.content.code
258
+ ) {
259
+ out.otp = field.details.content.code;
260
+ }
261
+ } else {
262
+ out.value = "[REDACTED]";
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Flatten a full 1Password item (as returned by client.items.get) into a
269
+ * stable JSON shape that's safe to display to the model.
270
+ */
271
+ function formatItem(item, includeConcealed) {
272
+ return {
273
+ id: item.id,
274
+ title: item.title,
275
+ category: item.category,
276
+ vaultId: item.vaultId,
277
+ tags: item.tags || [],
278
+ websites: (item.websites || []).map((w) => ({
279
+ url: w.url,
280
+ label: w.label,
281
+ })),
282
+ fields: (item.fields || []).map((f) => redactField(f, includeConcealed)),
283
+ sections: (item.sections || []).map((s) => ({ id: s.id, title: s.title })),
284
+ };
285
+ }
286
+
287
+ // ============================================================================
288
+ // Tool definitions (exposed via tools/list)
289
+ // ============================================================================
290
+
291
+ const TOOL_DEFINITIONS = [
292
+ {
293
+ name: "op_list_instances",
294
+ description:
295
+ "List configured 1Password instances (service accounts or desktop). Tokens are never returned.",
296
+ inputSchema: { type: "object", properties: {}, required: [] },
297
+ },
298
+ {
299
+ name: "op_add_instance",
300
+ description:
301
+ "Add or update a 1Password instance. Two auth modes: (1) 'serviceAccount' — provide token (ops_...). (2) 'desktop' — provide accountName (e.g. 'my.1password.com'); auth is delegated to the installed 1Password 8 desktop app via biometric unlock. Config is stored at ~/.config/1password-mcp/config.json with 0600 permissions.",
302
+ inputSchema: {
303
+ type: "object",
304
+ properties: {
305
+ name: {
306
+ type: "string",
307
+ description: "Unique name (e.g., 'work', 'personal').",
308
+ },
309
+ authType: {
310
+ type: "string",
311
+ description: "'serviceAccount' (default) or 'desktop'.",
312
+ },
313
+ token: {
314
+ type: "string",
315
+ description:
316
+ "Service account token (ops_...) — required for authType=serviceAccount.",
317
+ },
318
+ accountName: {
319
+ type: "string",
320
+ description:
321
+ "1Password account (e.g. 'my.1password.com' or shorthand) — required for authType=desktop. Uses the logged-in 1Password 8 app.",
322
+ },
323
+ description: {
324
+ type: "string",
325
+ description: "Optional human-readable description.",
326
+ },
327
+ setDefault: {
328
+ type: "boolean",
329
+ description: "Set this instance as the default (default: false).",
330
+ },
331
+ },
332
+ required: ["name"],
333
+ },
334
+ },
335
+ {
336
+ name: "op_remove_instance",
337
+ description:
338
+ "Remove a 1Password instance by name. Cannot remove the last one.",
339
+ inputSchema: {
340
+ type: "object",
341
+ properties: {
342
+ name: { type: "string", description: "Instance name to remove." },
343
+ },
344
+ required: ["name"],
345
+ },
346
+ },
347
+ {
348
+ name: "op_list_vaults",
349
+ description:
350
+ "List all vaults visible to the given instance's 1Password account.",
351
+ inputSchema: {
352
+ type: "object",
353
+ properties: {
354
+ instance: {
355
+ type: "string",
356
+ description: "Instance name. Uses default if omitted.",
357
+ },
358
+ },
359
+ required: [],
360
+ },
361
+ },
362
+ {
363
+ name: "op_list_items",
364
+ description:
365
+ "List items in a vault. `vault` accepts a vault id or title (case-insensitive exact, then prefix).",
366
+ inputSchema: {
367
+ type: "object",
368
+ properties: {
369
+ vault: { type: "string", description: "Vault id or title." },
370
+ instance: {
371
+ type: "string",
372
+ description: "Instance name. Uses default if omitted.",
373
+ },
374
+ },
375
+ required: ["vault"],
376
+ },
377
+ },
378
+ {
379
+ name: "op_search_items",
380
+ description:
381
+ "Search items by title across vaults. When the user asks for a specific credential by name (e.g. 'give me the wifi password', 'what's my GitHub login'), call this with `reveal: true` to get the password/credential values back in a single call — no follow-up op_get_item needed. If `vault` is omitted, searches every vault the account can see. When there are 1–5 matches with reveal=true, return all of them; the user already consented to seeing concealed values by asking for them.",
382
+ inputSchema: {
383
+ type: "object",
384
+ properties: {
385
+ query: {
386
+ type: "string",
387
+ description: "Case-insensitive substring of the item title.",
388
+ },
389
+ vault: {
390
+ type: "string",
391
+ description: "Optional vault id or title to scope the search.",
392
+ },
393
+ limit: {
394
+ type: "number",
395
+ description: "Max results (default 25, or 5 when reveal=true to avoid fetching too many items).",
396
+ },
397
+ reveal: {
398
+ type: "boolean",
399
+ description:
400
+ "If true, fetch each match's full item and return concealed field values (passwords, TOTPs) in cleartext. Use this when the user is asking for a credential by name — one call gets search + retrieval + reveal. Defaults to false.",
401
+ },
402
+ instance: {
403
+ type: "string",
404
+ description: "Instance name. Uses default if omitted.",
405
+ },
406
+ },
407
+ required: ["query"],
408
+ },
409
+ },
410
+ {
411
+ name: "op_get_item",
412
+ description:
413
+ "Fetch a full item with all fields. By default concealed fields (passwords, TOTP) are redacted; set includeConcealed=true to return them in cleartext. `item` and `vault` accept ids or titles.",
414
+ inputSchema: {
415
+ type: "object",
416
+ properties: {
417
+ vault: { type: "string", description: "Vault id or title." },
418
+ item: { type: "string", description: "Item id or title." },
419
+ includeConcealed: {
420
+ type: "boolean",
421
+ description: "Return concealed values in cleartext (default: false).",
422
+ },
423
+ instance: {
424
+ type: "string",
425
+ description: "Instance name. Uses default if omitted.",
426
+ },
427
+ },
428
+ required: ["vault", "item"],
429
+ },
430
+ },
431
+ {
432
+ name: "op_get_secret",
433
+ description:
434
+ "Resolve a 1Password secret reference (op://vault/item/field) and return the value.",
435
+ inputSchema: {
436
+ type: "object",
437
+ properties: {
438
+ reference: {
439
+ type: "string",
440
+ description: "Secret reference like 'op://Private/GitHub/password'.",
441
+ },
442
+ instance: {
443
+ type: "string",
444
+ description: "Instance name. Uses default if omitted.",
445
+ },
446
+ },
447
+ required: ["reference"],
448
+ },
449
+ },
450
+ {
451
+ name: "op_generate_password",
452
+ description:
453
+ "Generate a password using 1Password's generator (Random / Memorable / Pin). Does not talk to the account.",
454
+ inputSchema: {
455
+ type: "object",
456
+ properties: {
457
+ type: {
458
+ type: "string",
459
+ description: "'Random' (default), 'Memorable', or 'Pin'.",
460
+ },
461
+ length: {
462
+ type: "number",
463
+ description: "Length for Random/Pin (default 20/8).",
464
+ },
465
+ includeDigits: {
466
+ type: "boolean",
467
+ description: "Random only (default true).",
468
+ },
469
+ includeSymbols: {
470
+ type: "boolean",
471
+ description: "Random only (default true).",
472
+ },
473
+ wordCount: {
474
+ type: "number",
475
+ description: "Memorable only (default 5).",
476
+ },
477
+ capitalize: {
478
+ type: "boolean",
479
+ description: "Memorable only (default true).",
480
+ },
481
+ },
482
+ required: [],
483
+ },
484
+ },
485
+ {
486
+ name: "op_create_item",
487
+ description:
488
+ "Create a new item in a vault. Fields is an array of { title, type, value, sectionId? }. Type is Text | Concealed | Totp | Email | Url | Phone.",
489
+ inputSchema: {
490
+ type: "object",
491
+ properties: {
492
+ vault: { type: "string", description: "Vault id or title." },
493
+ title: { type: "string", description: "Item title." },
494
+ category: {
495
+ type: "string",
496
+ description:
497
+ "Item category (default: Login). One of: Login, SecureNote, Password, ApiCredentials, Database, Server, Email, Identity, CreditCard, etc.",
498
+ },
499
+ fields: {
500
+ type: "array",
501
+ description:
502
+ "Field definitions: [{ title, type, value, sectionId? }].",
503
+ items: {
504
+ type: "object",
505
+ properties: {
506
+ title: { type: "string" },
507
+ type: { type: "string" },
508
+ value: { type: "string" },
509
+ sectionId: { type: "string" },
510
+ },
511
+ required: ["title", "type", "value"],
512
+ },
513
+ },
514
+ tags: { type: "array", items: { type: "string" } },
515
+ websites: {
516
+ type: "array",
517
+ items: {
518
+ type: "object",
519
+ properties: {
520
+ url: { type: "string" },
521
+ label: { type: "string" },
522
+ },
523
+ required: ["url"],
524
+ },
525
+ },
526
+ notes: {
527
+ type: "string",
528
+ description:
529
+ "Optional notes (stored as a Text field called 'notesPlain').",
530
+ },
531
+ instance: {
532
+ type: "string",
533
+ description: "Instance name. Uses default if omitted.",
534
+ },
535
+ },
536
+ required: ["vault", "title"],
537
+ },
538
+ },
539
+ {
540
+ name: "op_update_item",
541
+ description:
542
+ "Update any part of an existing item — title, category, fields, tags, websites, sections, or notes. Omitted parameters are preserved. Fields are patched by title (existing fields match case-insensitively; new fields require `type`). Tags, websites, and sections are REPLACED when passed.",
543
+ inputSchema: {
544
+ type: "object",
545
+ properties: {
546
+ vault: { type: "string", description: "Vault id or title." },
547
+ item: { type: "string", description: "Item id or title to update." },
548
+ title: { type: "string", description: "New title (optional)." },
549
+ category: {
550
+ type: "string",
551
+ description:
552
+ "New category (rare). One of Login, SecureNote, Password, ApiCredentials, Database, Server, Email, Identity, CreditCard, etc.",
553
+ },
554
+ fields: {
555
+ type: "array",
556
+ description:
557
+ "Field patches: [{title, value, type?, sectionId?}]. Existing fields matched by title have their values replaced. New fields require `type` (Text | Concealed | Totp | Email | Url | Phone).",
558
+ items: {
559
+ type: "object",
560
+ properties: {
561
+ title: { type: "string" },
562
+ value: { type: "string" },
563
+ type: { type: "string" },
564
+ sectionId: { type: "string" },
565
+ },
566
+ required: ["title", "value"],
567
+ },
568
+ },
569
+ tags: {
570
+ type: "array",
571
+ items: { type: "string" },
572
+ description:
573
+ "Replace the tag list. Omit to preserve existing tags; pass [] to clear.",
574
+ },
575
+ websites: {
576
+ type: "array",
577
+ description:
578
+ "Replace the website list. Each website is {url, label?, autofillBehavior?}. Omit to preserve existing websites.",
579
+ items: {
580
+ type: "object",
581
+ properties: {
582
+ url: { type: "string" },
583
+ label: { type: "string" },
584
+ autofillBehavior: {
585
+ type: "string",
586
+ description: "'AnywhereOnWebsite' (default) or 'Never'.",
587
+ },
588
+ },
589
+ required: ["url"],
590
+ },
591
+ },
592
+ sections: {
593
+ type: "array",
594
+ description: "Replace the section list. Each section is {title, id?}.",
595
+ items: {
596
+ type: "object",
597
+ properties: {
598
+ title: { type: "string" },
599
+ id: { type: "string" },
600
+ },
601
+ required: ["title"],
602
+ },
603
+ },
604
+ notes: {
605
+ type: "string",
606
+ description:
607
+ "Replace the notesPlain field. Omit to preserve existing notes.",
608
+ },
609
+ instance: {
610
+ type: "string",
611
+ description: "Instance name. Uses default if omitted.",
612
+ },
613
+ },
614
+ required: ["vault", "item"],
615
+ },
616
+ },
617
+ {
618
+ name: "op_update_vault",
619
+ description:
620
+ "Update a vault's title and/or description. Pass at least one of them.",
621
+ inputSchema: {
622
+ type: "object",
623
+ properties: {
624
+ vault: { type: "string", description: "Vault id or title." },
625
+ title: { type: "string", description: "New title (optional)." },
626
+ description: {
627
+ type: "string",
628
+ description: "New description (optional).",
629
+ },
630
+ instance: {
631
+ type: "string",
632
+ description: "Instance name. Uses default if omitted.",
633
+ },
634
+ },
635
+ required: ["vault"],
636
+ },
637
+ },
638
+ {
639
+ name: "op_delete_item",
640
+ description: "Delete an item by vault + item reference.",
641
+ inputSchema: {
642
+ type: "object",
643
+ properties: {
644
+ vault: { type: "string", description: "Vault id or title." },
645
+ item: { type: "string", description: "Item id or title." },
646
+ instance: {
647
+ type: "string",
648
+ description: "Instance name. Uses default if omitted.",
649
+ },
650
+ },
651
+ required: ["vault", "item"],
652
+ },
653
+ },
654
+ {
655
+ name: "op_create_vault",
656
+ description: "Create a new vault.",
657
+ inputSchema: {
658
+ type: "object",
659
+ properties: {
660
+ title: { type: "string", description: "Vault title." },
661
+ description: {
662
+ type: "string",
663
+ description: "Optional description.",
664
+ },
665
+ instance: {
666
+ type: "string",
667
+ description: "Instance name. Uses default if omitted.",
668
+ },
669
+ },
670
+ required: ["title"],
671
+ },
672
+ },
673
+ {
674
+ name: "op_delete_vault",
675
+ description: "Delete a vault by id or title.",
676
+ inputSchema: {
677
+ type: "object",
678
+ properties: {
679
+ vault: { type: "string", description: "Vault id or title." },
680
+ instance: {
681
+ type: "string",
682
+ description: "Instance name. Uses default if omitted.",
683
+ },
684
+ },
685
+ required: ["vault"],
686
+ },
687
+ },
688
+ ];
689
+
690
+ // ============================================================================
691
+ // Handlers
692
+ //
693
+ // Each handler is keyed by tool name and receives the tool arguments. It
694
+ // should return a plain string (rendered as text content by the dispatcher)
695
+ // or throw on failure. The dispatcher handles MCP wrapping and error shape.
696
+ // ============================================================================
697
+
698
+ async function addInstanceHandler(args) {
699
+ const instName = args.name.trim();
700
+ const authType = args.authType || (args.token ? "serviceAccount" : "desktop");
701
+
702
+ if (authType === "serviceAccount") {
703
+ if (!args.token) {
704
+ throw new Error("serviceAccount authType requires a token.");
705
+ }
706
+ if (!/^ops_[A-Za-z0-9_\-]+/.test(args.token)) {
707
+ throw new Error(
708
+ "Token does not look like a 1Password service account token (expected prefix 'ops_').",
709
+ );
710
+ }
711
+ } else if (authType === "desktop") {
712
+ if (!args.accountName) {
713
+ throw new Error(
714
+ "desktop authType requires accountName (e.g. 'my.1password.com').",
715
+ );
716
+ }
717
+ } else {
718
+ throw new Error(`Unknown authType "${authType}".`);
719
+ }
720
+
721
+ // Validate by creating a client and listing one vault.
722
+ const probeAuth =
723
+ authType === "desktop" ? new sdk.DesktopAuth(args.accountName) : args.token;
724
+ const tempClient = await sdk.createClient({
725
+ auth: probeAuth,
726
+ integrationName: INTEGRATION_NAME,
727
+ integrationVersion: `v${PKG_VERSION}`,
728
+ });
729
+ const probe = [];
730
+ for await (const v of await tempClient.vaults.list()) {
731
+ probe.push(v);
732
+ if (probe.length >= 1) break;
733
+ }
734
+
735
+ const newInst = {
736
+ name: instName,
737
+ authType,
738
+ token: authType === "serviceAccount" ? args.token : undefined,
739
+ accountName: authType === "desktop" ? args.accountName : undefined,
740
+ description: args.description || "",
741
+ };
742
+ const existingIdx = instances.findIndex((i) => i.name === instName);
743
+ if (existingIdx >= 0) instances[existingIdx] = newInst;
744
+ else instances.push(newInst);
745
+ invalidateClient(instName);
746
+
747
+ const savedConfig = loadConfigFile();
748
+ if (!savedConfig.instances) savedConfig.instances = [];
749
+ const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
750
+ const toSave = { name: instName, authType };
751
+ if (authType === "serviceAccount") toSave.token = args.token;
752
+ if (authType === "desktop") toSave.accountName = args.accountName;
753
+ if (args.description) toSave.description = args.description;
754
+ if (savedIdx >= 0) savedConfig.instances[savedIdx] = toSave;
755
+ else savedConfig.instances.push(toSave);
756
+ if (args.setDefault || !savedConfig.defaultInstance) {
757
+ savedConfig.defaultInstance = instName;
758
+ defaultInstance = newInst;
759
+ }
760
+ saveConfigFile(savedConfig);
761
+
762
+ const verb = existingIdx >= 0 ? "Updated" : "Added";
763
+ const suffix = args.setDefault ? " Set as default." : "";
764
+ return `${verb} instance "${instName}" (${authType}).${suffix}`;
765
+ }
766
+
767
+ async function removeInstanceHandler(args) {
768
+ if (instances.length <= 1) {
769
+ throw new Error("Cannot remove the last remaining instance.");
770
+ }
771
+ const idx = instances.findIndex((i) => i.name === args.name);
772
+ if (idx < 0) throw new Error(`Instance "${args.name}" not found.`);
773
+
774
+ instances.splice(idx, 1);
775
+ invalidateClient(args.name);
776
+
777
+ const savedConfig = loadConfigFile();
778
+ if (savedConfig.instances) {
779
+ savedConfig.instances = savedConfig.instances.filter(
780
+ (i) => i.name !== args.name,
781
+ );
782
+ if (savedConfig.defaultInstance === args.name) {
783
+ savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
784
+ defaultInstance = instances[0] || null;
785
+ }
786
+ saveConfigFile(savedConfig);
787
+ }
788
+ return `Removed instance "${args.name}".`;
789
+ }
790
+
791
+ function listInstancesHandler() {
792
+ if (instances.length === 0) {
793
+ return "No instances configured. Run `1password-mcp setup` or call op_add_instance.";
794
+ }
795
+ const def = defaultInstance?.name;
796
+ const lines = instances.map((inst) => {
797
+ const isDefault = inst.name === def ? " **(default)**" : "";
798
+ const desc = inst.description ? ` - ${inst.description}` : "";
799
+ const auth =
800
+ inst.authType === "desktop"
801
+ ? ` (desktop: ${inst.accountName})`
802
+ : " (serviceAccount)";
803
+ return `- **${inst.name}**${isDefault}${auth}${desc}`;
804
+ });
805
+ return `# 1Password Instances (${instances.length})\n\n${lines.join("\n")}`;
806
+ }
807
+
808
+ async function listVaultsHandler(args) {
809
+ const client = await getClient(getInstanceByName(args.instance));
810
+ const vaults = await collect(await client.vaults.list());
811
+ if (vaults.length === 0) {
812
+ return "No vaults visible to this instance.";
813
+ }
814
+ const lines = vaults.map((v) => `- **${v.title}** (id: ${v.id})`);
815
+ return `# Vaults (${vaults.length})\n\n${lines.join("\n")}`;
816
+ }
817
+
818
+ async function listItemsHandler(args) {
819
+ const client = await getClient(getInstanceByName(args.instance));
820
+ const vault = await resolveVault(client, args.vault);
821
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
822
+
823
+ const items = await collect(await client.items.list(vault.id));
824
+ if (items.length === 0) {
825
+ return `No items in vault "${vault.title}".`;
826
+ }
827
+ const lines = items.map(
828
+ (i) => `- **${i.title}** (id: ${i.id}, category: ${i.category})`,
829
+ );
830
+ return `# Items in ${vault.title} (${items.length})\n\n${lines.join("\n")}`;
831
+ }
832
+
833
+ /**
834
+ * Core search implementation — pulled out of the handler so it can be unit
835
+ * tested with a fake client. Accepts an already-resolved client.
836
+ */
837
+ async function searchItems(client, { query, vault, reveal, limit }) {
838
+ const wantReveal = !!reveal;
839
+ const max = limit || (wantReveal ? 5 : 25);
840
+ const q = String(query).toLowerCase();
841
+
842
+ let targetVaults;
843
+ if (vault) {
844
+ const v = await resolveVault(client, vault);
845
+ if (!v) throw new Error(`Vault "${vault}" not found.`);
846
+ targetVaults = [v];
847
+ } else {
848
+ targetVaults = await collect(await client.vaults.list());
849
+ }
850
+
851
+ const hits = [];
852
+ for (const v of targetVaults) {
853
+ for await (const it of await client.items.list(v.id)) {
854
+ if ((it.title || "").toLowerCase().includes(q)) {
855
+ hits.push({ ...it, _vaultId: v.id, _vaultTitle: v.title });
856
+ if (hits.length >= max) break;
857
+ }
858
+ }
859
+ if (hits.length >= max) break;
860
+ }
861
+
862
+ if (hits.length === 0) return `No items matched "${query}".`;
863
+
864
+ if (!wantReveal) {
865
+ const lines = hits.map(
866
+ (it) =>
867
+ `- **${it.title}** (id: ${it.id}, vault: ${it._vaultTitle}, category: ${it.category})`,
868
+ );
869
+ return `# Search: "${query}" (${hits.length})\n\n${lines.join("\n")}`;
870
+ }
871
+
872
+ // reveal=true: fetch each hit's full item in parallel and render every
873
+ // field (including concealed values). One-shot path for "give me the X
874
+ // credential" style queries.
875
+ const full = await Promise.all(
876
+ hits.map(async (it) => {
877
+ try {
878
+ const item = await client.items.get(it._vaultId, it.id);
879
+ return { item, _vaultTitle: it._vaultTitle, _error: null };
880
+ } catch (e) {
881
+ return { item: it, _vaultTitle: it._vaultTitle, _error: e.message };
882
+ }
883
+ }),
884
+ );
885
+
886
+ const sections = full.map((entry) => {
887
+ if (entry._error) {
888
+ return `## ${entry.item.title}\n\n_Failed to fetch: ${entry._error}_`;
889
+ }
890
+ const it = entry.item;
891
+ const fields = (it.fields || [])
892
+ .map((f) => redactField(f, true))
893
+ .filter((f) => f.value !== undefined && f.value !== "");
894
+ const fieldLines = fields.length
895
+ ? fields
896
+ .map((f) => {
897
+ const otp = f.otp ? ` (OTP now: ${f.otp})` : "";
898
+ return `- **${f.title}** (${f.type}): ${f.value}${otp}`;
899
+ })
900
+ .join("\n")
901
+ : "_(no fields)_";
902
+ const websites = (it.websites || []).map((w) => w.url).filter(Boolean);
903
+ const websiteLine = websites.length
904
+ ? `- **URLs:** ${websites.join(", ")}\n`
905
+ : "";
906
+ return `## ${it.title} (${it.category})\n\n- **Vault:** ${entry._vaultTitle}\n- **Item ID:** ${it.id}\n${websiteLine}\n${fieldLines}`;
907
+ });
908
+
909
+ return `# Search (revealed): "${query}" (${hits.length})\n\n${sections.join("\n\n")}`;
910
+ }
911
+
912
+ async function searchItemsHandler(args) {
913
+ const client = await getClient(getInstanceByName(args.instance));
914
+ return searchItems(client, args);
915
+ }
916
+
917
+ async function getItemHandler(args) {
918
+ const client = await getClient(getInstanceByName(args.instance));
919
+ const vault = await resolveVault(client, args.vault);
920
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
921
+ const itemRef = await resolveItem(client, vault, args.item);
922
+ if (!itemRef) {
923
+ throw new Error(
924
+ `Item "${args.item}" not found in vault "${vault.title}".`,
925
+ );
926
+ }
927
+ const full = await client.items.get(vault.id, itemRef.id);
928
+ const formatted = formatItem(full, !!args.includeConcealed);
929
+
930
+ const lines = [
931
+ `# ${formatted.title} (${formatted.category})`,
932
+ "",
933
+ `- **Vault:** ${vault.title} (${formatted.vaultId})`,
934
+ `- **Item ID:** ${formatted.id}`,
935
+ ];
936
+ if (formatted.tags.length) lines.push(`- **Tags:** ${formatted.tags.join(", ")}`);
937
+ if (formatted.websites.length) {
938
+ lines.push(
939
+ `- **Websites:** ${formatted.websites.map((w) => w.url).join(", ")}`,
940
+ );
941
+ }
942
+ if (formatted.fields.length) {
943
+ lines.push("", "## Fields", "");
944
+ for (const f of formatted.fields) {
945
+ const val = f.value === undefined ? "" : f.value;
946
+ const otp = f.otp ? ` (OTP now: ${f.otp})` : "";
947
+ lines.push(`- **${f.title}** (${f.type}): ${val}${otp}`);
948
+ }
949
+ }
950
+ if (!args.includeConcealed) {
951
+ lines.push(
952
+ "",
953
+ "_Concealed fields are redacted. Call with includeConcealed=true to reveal._",
954
+ );
955
+ }
956
+ return lines.join("\n");
957
+ }
958
+
959
+ async function getSecretHandler(args) {
960
+ const client = await getClient(getInstanceByName(args.instance));
961
+ const value = await client.secrets.resolve(args.reference);
962
+ return `Secret for ${args.reference}:\n${value}`;
963
+ }
964
+
965
+ function generatePasswordHandler(args) {
966
+ const type = args.type || "Random";
967
+ let generated;
968
+ if (type === "Pin") {
969
+ generated = sdk.Secrets.generatePassword({
970
+ type: "Pin",
971
+ parameters: { length: args.length || 8 },
972
+ });
973
+ } else if (type === "Memorable") {
974
+ generated = sdk.Secrets.generatePassword({
975
+ type: "Memorable",
976
+ parameters: {
977
+ separatorType: sdk.SeparatorType.Digits,
978
+ capitalize: args.capitalize !== false,
979
+ wordListType: sdk.WordListType.FullWords,
980
+ wordCount: args.wordCount || 5,
981
+ },
982
+ });
983
+ } else {
984
+ generated = sdk.Secrets.generatePassword({
985
+ type: "Random",
986
+ parameters: {
987
+ includeDigits: args.includeDigits !== false,
988
+ includeSymbols: args.includeSymbols !== false,
989
+ length: args.length || 20,
990
+ },
991
+ });
992
+ }
993
+ const pwd =
994
+ typeof generated === "string" ? generated : generated.password || generated;
995
+ return `Generated (${type}):\n${pwd}`;
996
+ }
997
+
998
+ async function updateItemHandler(args) {
999
+ const client = await getClient(getInstanceByName(args.instance));
1000
+ const vault = await resolveVault(client, args.vault);
1001
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1002
+ const itemRef = await resolveItem(client, vault, args.item);
1003
+ if (!itemRef) {
1004
+ throw new Error(
1005
+ `Item "${args.item}" not found in vault "${vault.title}".`,
1006
+ );
1007
+ }
1008
+
1009
+ const current = await client.items.get(vault.id, itemRef.id);
1010
+ const updated = { ...current };
1011
+
1012
+ // --- title (rename) ---
1013
+ if (args.title) updated.title = args.title;
1014
+
1015
+ // --- category (rarely changed, but the SDK allows it) ---
1016
+ if (args.category) {
1017
+ const cat = sdk.ItemCategory[args.category];
1018
+ if (!cat) throw new Error(`Unknown category "${args.category}".`);
1019
+ updated.category = cat;
1020
+ }
1021
+
1022
+ // --- tags (replace when passed; preserved when omitted) ---
1023
+ if (args.tags) updated.tags = args.tags;
1024
+
1025
+ // --- websites (replace when passed; preserved when omitted) ---
1026
+ if (Array.isArray(args.websites)) {
1027
+ updated.websites = args.websites.map((w) => ({
1028
+ url: w.url,
1029
+ label: w.label || "website",
1030
+ autofillBehavior:
1031
+ w.autofillBehavior === "Never"
1032
+ ? sdk.AutofillBehavior.Never
1033
+ : sdk.AutofillBehavior.AnywhereOnWebsite,
1034
+ }));
1035
+ }
1036
+
1037
+ // --- sections (replace when passed; preserved when omitted) ---
1038
+ if (Array.isArray(args.sections)) {
1039
+ updated.sections = args.sections.map((s) => ({
1040
+ id: s.id || s.title.toLowerCase().replace(/\s+/g, "_"),
1041
+ title: s.title,
1042
+ }));
1043
+ }
1044
+
1045
+ // --- fields (patch by title, add new ones with explicit type) ---
1046
+ const nextFields = [...(current.fields || [])];
1047
+ const fieldIndexByTitle = new Map(
1048
+ nextFields.map((f, idx) => [(f.title || "").toLowerCase(), idx]),
1049
+ );
1050
+
1051
+ function upsertField({ title, value, type, sectionId }) {
1052
+ const key = title.toLowerCase();
1053
+ const existingIdx = fieldIndexByTitle.get(key);
1054
+ if (existingIdx !== undefined) {
1055
+ nextFields[existingIdx] = { ...nextFields[existingIdx], value };
1056
+ return;
1057
+ }
1058
+ if (!type) {
1059
+ throw new Error(
1060
+ `Field "${title}" doesn't exist on the item — pass a 'type' to add it as a new field.`,
1061
+ );
1062
+ }
1063
+ const ft = sdk.ItemFieldType[type];
1064
+ if (!ft) throw new Error(`Unknown field type "${type}".`);
1065
+ const added = {
1066
+ id: title.toLowerCase().replace(/\s+/g, "_"),
1067
+ title,
1068
+ fieldType: ft,
1069
+ value,
1070
+ };
1071
+ if (sectionId) added.sectionId = sectionId;
1072
+ fieldIndexByTitle.set(key, nextFields.length);
1073
+ nextFields.push(added);
1074
+ }
1075
+
1076
+ if (Array.isArray(args.fields)) {
1077
+ for (const patch of args.fields) upsertField(patch);
1078
+ }
1079
+
1080
+ // --- notes: stored as a 'notesPlain' Text field, so patch it the same way ---
1081
+ if (args.notes !== undefined) {
1082
+ upsertField({ title: "notesPlain", value: args.notes, type: "Text" });
1083
+ }
1084
+
1085
+ updated.fields = nextFields;
1086
+
1087
+ const result = await client.items.put(updated);
1088
+ return `Updated item "${result.title}" (id: ${result.id}) in vault "${vault.title}".`;
1089
+ }
1090
+
1091
+ async function updateVaultHandler(args) {
1092
+ const client = await getClient(getInstanceByName(args.instance));
1093
+ const vault = await resolveVault(client, args.vault);
1094
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1095
+ if (!args.title && args.description === undefined) {
1096
+ throw new Error("Provide at least one of title or description.");
1097
+ }
1098
+ await client.vaults.update(vault.id, {
1099
+ title: args.title || vault.title,
1100
+ description: args.description !== undefined ? args.description : vault.description || "",
1101
+ });
1102
+ return `Updated vault "${args.title || vault.title}" (id: ${vault.id}).`;
1103
+ }
1104
+
1105
+ async function createItemHandler(args) {
1106
+ const client = await getClient(getInstanceByName(args.instance));
1107
+ const vault = await resolveVault(client, args.vault);
1108
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1109
+
1110
+ const category = sdk.ItemCategory[args.category || "Login"];
1111
+ if (!category) throw new Error(`Unknown category "${args.category}".`);
1112
+
1113
+ const fields = (args.fields || []).map((f) => {
1114
+ const ft = sdk.ItemFieldType[f.type];
1115
+ if (!ft) throw new Error(`Unknown field type "${f.type}"`);
1116
+ const out = {
1117
+ id: f.title.toLowerCase().replace(/\s+/g, "_"),
1118
+ title: f.title,
1119
+ fieldType: ft,
1120
+ value: f.value,
1121
+ };
1122
+ if (f.sectionId) out.sectionId = f.sectionId;
1123
+ return out;
1124
+ });
1125
+ if (args.notes) {
1126
+ fields.push({
1127
+ id: "notesPlain",
1128
+ title: "notesPlain",
1129
+ fieldType: sdk.ItemFieldType.Text,
1130
+ value: args.notes,
1131
+ });
1132
+ }
1133
+
1134
+ const item = await client.items.create({
1135
+ title: args.title,
1136
+ category,
1137
+ vaultId: vault.id,
1138
+ fields,
1139
+ tags: args.tags || [],
1140
+ websites: (args.websites || []).map((w) => ({
1141
+ url: w.url,
1142
+ label: w.label || "website",
1143
+ autofillBehavior: sdk.AutofillBehavior.AnywhereOnWebsite,
1144
+ })),
1145
+ });
1146
+ return `Created item **${item.title}** (id: ${item.id}) in vault "${vault.title}".`;
1147
+ }
1148
+
1149
+ async function deleteItemHandler(args) {
1150
+ const client = await getClient(getInstanceByName(args.instance));
1151
+ const vault = await resolveVault(client, args.vault);
1152
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1153
+ const itemRef = await resolveItem(client, vault, args.item);
1154
+ if (!itemRef) {
1155
+ throw new Error(
1156
+ `Item "${args.item}" not found in vault "${vault.title}".`,
1157
+ );
1158
+ }
1159
+ await client.items.delete(vault.id, itemRef.id);
1160
+ return `Deleted item "${itemRef.title}" from vault "${vault.title}".`;
1161
+ }
1162
+
1163
+ async function createVaultHandler(args) {
1164
+ const client = await getClient(getInstanceByName(args.instance));
1165
+ const vault = await client.vaults.create({
1166
+ title: args.title,
1167
+ description: args.description || "",
1168
+ });
1169
+ return `Created vault **${vault.title}** (id: ${vault.id}).`;
1170
+ }
1171
+
1172
+ async function deleteVaultHandler(args) {
1173
+ const client = await getClient(getInstanceByName(args.instance));
1174
+ const vault = await resolveVault(client, args.vault);
1175
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
1176
+ await client.vaults.delete(vault.id);
1177
+ return `Deleted vault "${vault.title}".`;
1178
+ }
1179
+
1180
+ const HANDLERS = {
1181
+ op_list_instances: listInstancesHandler,
1182
+ op_add_instance: addInstanceHandler,
1183
+ op_remove_instance: removeInstanceHandler,
1184
+ op_list_vaults: listVaultsHandler,
1185
+ op_list_items: listItemsHandler,
1186
+ op_search_items: searchItemsHandler,
1187
+ op_get_item: getItemHandler,
1188
+ op_get_secret: getSecretHandler,
1189
+ op_generate_password: generatePasswordHandler,
1190
+ op_create_item: createItemHandler,
1191
+ op_update_item: updateItemHandler,
1192
+ op_delete_item: deleteItemHandler,
1193
+ op_create_vault: createVaultHandler,
1194
+ op_update_vault: updateVaultHandler,
1195
+ op_delete_vault: deleteVaultHandler,
1196
+ };
1197
+
1198
+ // Drift guard: every declared tool must have a handler and vice versa. This is
1199
+ // evaluated at module load so a missing/extra handler fails fast.
1200
+ (function assertHandlerCompleteness() {
1201
+ const toolNames = new Set(TOOL_DEFINITIONS.map((t) => t.name));
1202
+ const handlerNames = new Set(Object.keys(HANDLERS));
1203
+ for (const name of toolNames) {
1204
+ if (!handlerNames.has(name)) {
1205
+ throw new Error(`Tool "${name}" is declared but has no handler.`);
1206
+ }
1207
+ }
1208
+ for (const name of handlerNames) {
1209
+ if (!toolNames.has(name)) {
1210
+ throw new Error(`Handler "${name}" exists but is not declared in TOOL_DEFINITIONS.`);
1211
+ }
1212
+ }
1213
+ })();
1214
+
1215
+ // ============================================================================
1216
+ // MCP server wiring
1217
+ // ============================================================================
1218
+
1219
+ const server = new Server(
1220
+ { name: INTEGRATION_NAME, version: PKG_VERSION, icons: ICONS },
1221
+ { capabilities: { tools: {} } },
1222
+ );
1223
+
1224
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1225
+ tools: TOOL_DEFINITIONS,
1226
+ }));
1227
+
1228
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1229
+ const { name, arguments: args } = request.params;
1230
+ const handler = HANDLERS[name];
1231
+ if (!handler) {
1232
+ return {
1233
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
1234
+ isError: true,
1235
+ };
1236
+ }
1237
+ for (let attempt = 0; attempt < 2; attempt++) {
1238
+ try {
1239
+ const result = await handler(args || {});
1240
+ return { content: [{ type: "text", text: String(result) }] };
1241
+ } catch (e) {
1242
+ if (attempt === 0 && isSessionError(e)) {
1243
+ // Stale IPC connection — purge cached clients and retry once so the
1244
+ // next getClient() call creates a fresh session.
1245
+ clientCache.clear();
1246
+ continue;
1247
+ }
1248
+ return {
1249
+ content: [{ type: "text", text: `Error: ${e.message}` }],
1250
+ isError: true,
1251
+ };
1252
+ }
1253
+ }
1254
+ });
1255
+
1256
+ async function main() {
1257
+ const transport = new StdioServerTransport();
1258
+ await server.connect(transport);
1259
+ }
1260
+
1261
+ if (require.main === module) {
1262
+ main().catch((e) => {
1263
+ console.error(e);
1264
+ process.exit(1);
1265
+ });
1266
+ }
1267
+
1268
+ // ============================================================================
1269
+ // Test exports
1270
+ // ============================================================================
1271
+
1272
+ if (typeof module !== "undefined") {
1273
+ module.exports = {
1274
+ // config
1275
+ CONFIG_DIR,
1276
+ CONFIG_PATH,
1277
+ loadConfigFile,
1278
+ saveConfigFile,
1279
+ normalizeInstance,
1280
+ // instance resolution
1281
+ getInstanceByName,
1282
+ // helpers
1283
+ collect,
1284
+ resolveVault,
1285
+ resolveItem,
1286
+ redactField,
1287
+ formatItem,
1288
+ searchItems,
1289
+ // handler map + tool list (for drift guard tests)
1290
+ TOOL_DEFINITIONS,
1291
+ HANDLERS,
1292
+ // direct handler access for unit tests that don't need the MCP transport
1293
+ listInstancesHandler,
1294
+ listVaultsHandler,
1295
+ listItemsHandler,
1296
+ searchItemsHandler,
1297
+ getItemHandler,
1298
+ generatePasswordHandler,
1299
+ updateItemHandler,
1300
+ updateVaultHandler,
1301
+ };
1302
+ }