@rui.branco/1password-mcp 0.1.2 → 0.1.3

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 (2) hide show
  1. package/index.js +790 -679
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,6 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Handle setup subcommand
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
4
15
  if (process.argv[2] === "setup") {
5
16
  require("./setup.js");
6
17
  return;
@@ -22,7 +33,10 @@ const { spawn, execSync } = require("child_process");
22
33
  const PKG_VERSION = require("./package.json").version;
23
34
  const INTEGRATION_NAME = "1password-mcp";
24
35
 
25
- // Auto-update: check GitHub for new commits, install in background
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
+ // ============================================================================
26
40
  const GITHUB_REPO = "rui-branco/1password-mcp";
27
41
  const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
28
42
  try {
@@ -47,60 +61,99 @@ try {
47
61
  );
48
62
  child.unref();
49
63
  }
50
- } catch {}
64
+ } catch {
65
+ /* offline or git not available — no-op */
66
+ }
51
67
 
52
- // ============ CONFIG ============
68
+ // ============================================================================
69
+ // Config
70
+ // ============================================================================
53
71
 
54
- const configDir = path.join(process.env.HOME, ".config/1password-mcp");
55
- const configPath = path.join(configDir, "config.json");
72
+ const CONFIG_DIR = path.join(process.env.HOME, ".config/1password-mcp");
73
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
56
74
 
57
- if (!fs.existsSync(configDir)) {
58
- fs.mkdirSync(configDir, { recursive: true });
75
+ function ensureConfigDir() {
76
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
59
77
  }
60
78
 
61
79
  function loadConfigFile() {
62
80
  try {
63
- return JSON.parse(fs.readFileSync(configPath, "utf8"));
81
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
64
82
  } catch {
65
83
  return {};
66
84
  }
67
85
  }
68
86
 
69
87
  function saveConfigFile(cfg) {
70
- fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
88
+ ensureConfigDir();
89
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
71
90
  try {
72
- fs.chmodSync(configPath, 0o600);
73
- } catch {}
91
+ fs.chmodSync(CONFIG_PATH, 0o600);
92
+ } catch {
93
+ /* chmod can fail on non-POSIX filesystems — non-fatal */
94
+ }
74
95
  }
75
96
 
76
- const rawConfig = fs.existsSync(configPath) ? loadConfigFile() : { instances: [] };
97
+ ensureConfigDir();
77
98
 
78
- let instances = (rawConfig.instances || []).map((i) => ({
79
- name: i.name,
80
- // "serviceAccount" (default, needs ops_ token) or "desktop" (uses 1Password 8 app biometric unlock)
81
- authType: i.authType || (i.token ? "serviceAccount" : "desktop"),
82
- token: i.token,
83
- accountName: i.accountName,
84
- description: i.description || "",
85
- }));
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
+ }
86
112
 
87
- const defaultInstance =
88
- (rawConfig.defaultInstance && instances.find((i) => i.name === rawConfig.defaultInstance)) ||
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)) ||
89
122
  instances[0] ||
90
123
  null;
91
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
+ */
92
129
  function getInstanceByName(name) {
93
130
  if (!name) return defaultInstance;
94
131
  return instances.find((i) => i.name === name) || defaultInstance;
95
132
  }
96
133
 
97
- // ============ 1PASSWORD CLIENT ============
134
+ // ============================================================================
135
+ // 1Password SDK client
136
+ // ============================================================================
98
137
 
99
- // Cache SDK clients per instance so we don't re-auth on every call. The SDK
100
- // client holds a WebAssembly runtime internally, so recreating it every call
101
- // would be wasteful.
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.
102
140
  const clientCache = new Map();
103
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
+
104
157
  async function getClient(instance) {
105
158
  if (!instance) {
106
159
  throw new Error(
@@ -109,23 +162,8 @@ async function getClient(instance) {
109
162
  }
110
163
  if (clientCache.has(instance.name)) return clientCache.get(instance.name);
111
164
 
112
- let auth;
113
- if (instance.authType === "desktop") {
114
- if (!instance.accountName) {
115
- throw new Error(
116
- `Instance "${instance.name}" is desktop-auth but has no accountName. Re-run setup and provide your 1Password account (e.g. "my.1password.com" or "my").`,
117
- );
118
- }
119
- auth = new sdk.DesktopAuth(instance.accountName);
120
- } else {
121
- if (!instance.token) {
122
- throw new Error(`Instance "${instance.name}" has no service account token.`);
123
- }
124
- auth = instance.token;
125
- }
126
-
127
165
  const client = await sdk.createClient({
128
- auth,
166
+ auth: buildAuth(instance),
129
167
  integrationName: INTEGRATION_NAME,
130
168
  integrationVersion: `v${PKG_VERSION}`,
131
169
  });
@@ -137,37 +175,54 @@ function invalidateClient(name) {
137
175
  clientCache.delete(name);
138
176
  }
139
177
 
140
- // ============ HELPERS ============
178
+ // ============================================================================
179
+ // Helpers — vault/item resolution, redaction, formatting
180
+ // ============================================================================
181
+
182
+ async function collect(asyncIterable) {
183
+ const out = [];
184
+ for await (const x of asyncIterable) out.push(x);
185
+ return out;
186
+ }
141
187
 
142
- // Pick a vault by id OR by title (case-insensitive exact match, then prefix).
188
+ /**
189
+ * Resolve a vault from a reference that may be an id, exact title (case-insensitive),
190
+ * or prefix of a title. Returns null when nothing matches.
191
+ */
143
192
  async function resolveVault(client, vaultRef) {
144
193
  if (!vaultRef) return null;
145
- const vaults = [];
146
- for await (const v of await client.vaults.list()) vaults.push(v);
194
+ const vaults = await collect(await client.vaults.list());
147
195
  const byId = vaults.find((v) => v.id === vaultRef);
148
196
  if (byId) return byId;
149
197
  const lower = String(vaultRef).toLowerCase();
150
- const exact = vaults.find((v) => (v.title || "").toLowerCase() === lower);
151
- if (exact) return exact;
152
- const prefix = vaults.find((v) => (v.title || "").toLowerCase().startsWith(lower));
153
- if (prefix) return prefix;
154
- return null;
198
+ return (
199
+ vaults.find((v) => (v.title || "").toLowerCase() === lower) ||
200
+ vaults.find((v) => (v.title || "").toLowerCase().startsWith(lower)) ||
201
+ null
202
+ );
155
203
  }
156
204
 
205
+ /**
206
+ * Same strategy as resolveVault but for items inside a specific vault.
207
+ */
157
208
  async function resolveItem(client, vault, itemRef) {
158
209
  if (!itemRef) return null;
159
- const items = [];
160
- for await (const it of await client.items.list(vault.id)) items.push(it);
210
+ const items = await collect(await client.items.list(vault.id));
161
211
  const byId = items.find((i) => i.id === itemRef);
162
212
  if (byId) return byId;
163
213
  const lower = String(itemRef).toLowerCase();
164
- const exact = items.find((i) => (i.title || "").toLowerCase() === lower);
165
- if (exact) return exact;
166
- const prefix = items.find((i) => (i.title || "").toLowerCase().startsWith(lower));
167
- if (prefix) return prefix;
168
- return null;
214
+ return (
215
+ items.find((i) => (i.title || "").toLowerCase() === lower) ||
216
+ items.find((i) => (i.title || "").toLowerCase().startsWith(lower)) ||
217
+ null
218
+ );
169
219
  }
170
220
 
221
+ /**
222
+ * Shape a single 1Password field for output. Concealed and TOTP values are
223
+ * replaced with "[REDACTED]" unless includeConcealed=true. OTP codes are only
224
+ * surfaced when concealed values are revealed.
225
+ */
171
226
  function redactField(field, includeConcealed) {
172
227
  const out = {
173
228
  id: field.id,
@@ -178,8 +233,13 @@ function redactField(field, includeConcealed) {
178
233
  const isSecret = field.fieldType === "Concealed" || field.fieldType === "Totp";
179
234
  if (!isSecret || includeConcealed) {
180
235
  out.value = field.value;
181
- if (field.details && field.details.type === "Otp" && field.details.content) {
182
- out.otp = field.details.content.code || undefined;
236
+ if (
237
+ field.details &&
238
+ field.details.type === "Otp" &&
239
+ field.details.content &&
240
+ field.details.content.code
241
+ ) {
242
+ out.otp = field.details.content.code;
183
243
  }
184
244
  } else {
185
245
  out.value = "[REDACTED]";
@@ -187,6 +247,10 @@ function redactField(field, includeConcealed) {
187
247
  return out;
188
248
  }
189
249
 
250
+ /**
251
+ * Flatten a full 1Password item (as returned by client.items.get) into a
252
+ * stable JSON shape that's safe to display to the model.
253
+ */
190
254
  function formatItem(item, includeConcealed) {
191
255
  return {
192
256
  id: item.id,
@@ -194,682 +258,707 @@ function formatItem(item, includeConcealed) {
194
258
  category: item.category,
195
259
  vaultId: item.vaultId,
196
260
  tags: item.tags || [],
197
- websites: (item.websites || []).map((w) => ({ url: w.url, label: w.label })),
261
+ websites: (item.websites || []).map((w) => ({
262
+ url: w.url,
263
+ label: w.label,
264
+ })),
198
265
  fields: (item.fields || []).map((f) => redactField(f, includeConcealed)),
199
266
  sections: (item.sections || []).map((s) => ({ id: s.id, title: s.title })),
200
267
  };
201
268
  }
202
269
 
203
- // ============ MCP SERVER ============
204
-
205
- const server = new Server(
206
- { name: INTEGRATION_NAME, version: PKG_VERSION },
207
- { capabilities: { tools: {} } },
208
- );
209
-
210
- server.setRequestHandler(ListToolsRequestSchema, async () => {
211
- return {
212
- tools: [
213
- {
214
- name: "op_list_instances",
215
- description:
216
- "List configured 1Password instances (service accounts). Tokens are never returned.",
217
- inputSchema: { type: "object", properties: {}, required: [] },
218
- },
219
- {
220
- name: "op_add_instance",
221
- description:
222
- "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.",
223
- inputSchema: {
224
- type: "object",
225
- properties: {
226
- name: { type: "string", description: "Unique name (e.g., 'work', 'personal')." },
227
- authType: {
228
- type: "string",
229
- description: "'serviceAccount' (default) or 'desktop'.",
230
- },
231
- token: {
232
- type: "string",
233
- description: "Service account token (ops_...) — required for authType=serviceAccount.",
234
- },
235
- accountName: {
236
- type: "string",
237
- description: "1Password account (e.g. 'my.1password.com' or shorthand) — required for authType=desktop. Uses the logged-in 1Password 8 app.",
238
- },
239
- description: { type: "string", description: "Optional human-readable description." },
240
- setDefault: {
241
- type: "boolean",
242
- description: "Set this instance as the default (default: false).",
243
- },
244
- },
245
- required: ["name"],
270
+ // ============================================================================
271
+ // Tool definitions (exposed via tools/list)
272
+ // ============================================================================
273
+
274
+ const TOOL_DEFINITIONS = [
275
+ {
276
+ name: "op_list_instances",
277
+ description:
278
+ "List configured 1Password instances (service accounts or desktop). Tokens are never returned.",
279
+ inputSchema: { type: "object", properties: {}, required: [] },
280
+ },
281
+ {
282
+ name: "op_add_instance",
283
+ description:
284
+ "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.",
285
+ inputSchema: {
286
+ type: "object",
287
+ properties: {
288
+ name: {
289
+ type: "string",
290
+ description: "Unique name (e.g., 'work', 'personal').",
291
+ },
292
+ authType: {
293
+ type: "string",
294
+ description: "'serviceAccount' (default) or 'desktop'.",
295
+ },
296
+ token: {
297
+ type: "string",
298
+ description:
299
+ "Service account token (ops_...) — required for authType=serviceAccount.",
300
+ },
301
+ accountName: {
302
+ type: "string",
303
+ description:
304
+ "1Password account (e.g. 'my.1password.com' or shorthand) — required for authType=desktop. Uses the logged-in 1Password 8 app.",
305
+ },
306
+ description: {
307
+ type: "string",
308
+ description: "Optional human-readable description.",
309
+ },
310
+ setDefault: {
311
+ type: "boolean",
312
+ description: "Set this instance as the default (default: false).",
246
313
  },
247
314
  },
248
- {
249
- name: "op_remove_instance",
250
- description: "Remove a 1Password instance by name. Cannot remove the last one.",
251
- inputSchema: {
252
- type: "object",
253
- properties: {
254
- name: { type: "string", description: "Instance name to remove." },
255
- },
256
- required: ["name"],
315
+ required: ["name"],
316
+ },
317
+ },
318
+ {
319
+ name: "op_remove_instance",
320
+ description:
321
+ "Remove a 1Password instance by name. Cannot remove the last one.",
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: {
325
+ name: { type: "string", description: "Instance name to remove." },
326
+ },
327
+ required: ["name"],
328
+ },
329
+ },
330
+ {
331
+ name: "op_list_vaults",
332
+ description:
333
+ "List all vaults visible to the given instance's 1Password account.",
334
+ inputSchema: {
335
+ type: "object",
336
+ properties: {
337
+ instance: {
338
+ type: "string",
339
+ description: "Instance name. Uses default if omitted.",
257
340
  },
258
341
  },
259
- {
260
- name: "op_list_vaults",
261
- description: "List all vaults visible to the given instance's service account.",
262
- inputSchema: {
263
- type: "object",
264
- properties: {
265
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
266
- },
267
- required: [],
342
+ required: [],
343
+ },
344
+ },
345
+ {
346
+ name: "op_list_items",
347
+ description:
348
+ "List items in a vault. `vault` accepts a vault id or title (case-insensitive exact, then prefix).",
349
+ inputSchema: {
350
+ type: "object",
351
+ properties: {
352
+ vault: { type: "string", description: "Vault id or title." },
353
+ instance: {
354
+ type: "string",
355
+ description: "Instance name. Uses default if omitted.",
268
356
  },
269
357
  },
270
- {
271
- name: "op_list_items",
272
- description:
273
- "List items in a vault. `vault` accepts a vault id or title (case-insensitive exact, then prefix).",
274
- inputSchema: {
275
- type: "object",
276
- properties: {
277
- vault: { type: "string", description: "Vault id or title." },
278
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
279
- },
280
- required: ["vault"],
358
+ required: ["vault"],
359
+ },
360
+ },
361
+ {
362
+ name: "op_search_items",
363
+ description:
364
+ "Search items by title across vaults. If `vault` is omitted, searches every vault the account can see.",
365
+ inputSchema: {
366
+ type: "object",
367
+ properties: {
368
+ query: {
369
+ type: "string",
370
+ description: "Case-insensitive substring of the item title.",
371
+ },
372
+ vault: {
373
+ type: "string",
374
+ description: "Optional vault id or title to scope the search.",
375
+ },
376
+ limit: {
377
+ type: "number",
378
+ description: "Max results (default 25).",
379
+ },
380
+ instance: {
381
+ type: "string",
382
+ description: "Instance name. Uses default if omitted.",
281
383
  },
282
384
  },
283
- {
284
- name: "op_search_items",
285
- description:
286
- "Search items by title across vaults. If `vault` is omitted, searches every vault the service account can see.",
287
- inputSchema: {
288
- type: "object",
289
- properties: {
290
- query: { type: "string", description: "Case-insensitive substring of the item title." },
291
- vault: { type: "string", description: "Optional vault id or title to scope the search." },
292
- limit: { type: "number", description: "Max results (default 25)." },
293
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
294
- },
295
- required: ["query"],
385
+ required: ["query"],
386
+ },
387
+ },
388
+ {
389
+ name: "op_get_item",
390
+ description:
391
+ "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.",
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ vault: { type: "string", description: "Vault id or title." },
396
+ item: { type: "string", description: "Item id or title." },
397
+ includeConcealed: {
398
+ type: "boolean",
399
+ description: "Return concealed values in cleartext (default: false).",
400
+ },
401
+ instance: {
402
+ type: "string",
403
+ description: "Instance name. Uses default if omitted.",
296
404
  },
297
405
  },
298
- {
299
- name: "op_get_item",
300
- description:
301
- "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.",
302
- inputSchema: {
303
- type: "object",
304
- properties: {
305
- vault: { type: "string", description: "Vault id or title." },
306
- item: { type: "string", description: "Item id or title." },
307
- includeConcealed: {
308
- type: "boolean",
309
- description: "Return concealed values in cleartext (default: false).",
310
- },
311
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
312
- },
313
- required: ["vault", "item"],
406
+ required: ["vault", "item"],
407
+ },
408
+ },
409
+ {
410
+ name: "op_get_secret",
411
+ description:
412
+ "Resolve a 1Password secret reference (op://vault/item/field) and return the value.",
413
+ inputSchema: {
414
+ type: "object",
415
+ properties: {
416
+ reference: {
417
+ type: "string",
418
+ description: "Secret reference like 'op://Private/GitHub/password'.",
419
+ },
420
+ instance: {
421
+ type: "string",
422
+ description: "Instance name. Uses default if omitted.",
314
423
  },
315
424
  },
316
- {
317
- name: "op_get_secret",
318
- description:
319
- "Resolve a 1Password secret reference (op://vault/item/field) and return the value.",
320
- inputSchema: {
321
- type: "object",
322
- properties: {
323
- reference: {
324
- type: "string",
325
- description: "Secret reference like 'op://Private/GitHub/password'.",
326
- },
327
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
328
- },
329
- required: ["reference"],
425
+ required: ["reference"],
426
+ },
427
+ },
428
+ {
429
+ name: "op_generate_password",
430
+ description:
431
+ "Generate a password using 1Password's generator (Random / Memorable / Pin). Does not talk to the account.",
432
+ inputSchema: {
433
+ type: "object",
434
+ properties: {
435
+ type: {
436
+ type: "string",
437
+ description: "'Random' (default), 'Memorable', or 'Pin'.",
438
+ },
439
+ length: {
440
+ type: "number",
441
+ description: "Length for Random/Pin (default 20/8).",
442
+ },
443
+ includeDigits: {
444
+ type: "boolean",
445
+ description: "Random only (default true).",
446
+ },
447
+ includeSymbols: {
448
+ type: "boolean",
449
+ description: "Random only (default true).",
450
+ },
451
+ wordCount: {
452
+ type: "number",
453
+ description: "Memorable only (default 5).",
454
+ },
455
+ capitalize: {
456
+ type: "boolean",
457
+ description: "Memorable only (default true).",
330
458
  },
331
459
  },
332
- {
333
- name: "op_generate_password",
334
- description:
335
- "Generate a password using 1Password's generator (Random / Memorable / Pin). Does not talk to the account.",
336
- inputSchema: {
337
- type: "object",
338
- properties: {
339
- type: {
340
- type: "string",
341
- description: "'Random' (default), 'Memorable', or 'Pin'.",
460
+ required: [],
461
+ },
462
+ },
463
+ {
464
+ name: "op_create_item",
465
+ description:
466
+ "Create a new item in a vault. Fields is an array of { title, type, value, sectionId? }. Type is Text | Concealed | Totp | Email | Url | Phone.",
467
+ inputSchema: {
468
+ type: "object",
469
+ properties: {
470
+ vault: { type: "string", description: "Vault id or title." },
471
+ title: { type: "string", description: "Item title." },
472
+ category: {
473
+ type: "string",
474
+ description:
475
+ "Item category (default: Login). One of: Login, SecureNote, Password, ApiCredentials, Database, Server, Email, Identity, CreditCard, etc.",
476
+ },
477
+ fields: {
478
+ type: "array",
479
+ description:
480
+ "Field definitions: [{ title, type, value, sectionId? }].",
481
+ items: {
482
+ type: "object",
483
+ properties: {
484
+ title: { type: "string" },
485
+ type: { type: "string" },
486
+ value: { type: "string" },
487
+ sectionId: { type: "string" },
342
488
  },
343
- length: { type: "number", description: "Length for Random/Pin (default 20/8)." },
344
- includeDigits: { type: "boolean", description: "Random only (default true)." },
345
- includeSymbols: { type: "boolean", description: "Random only (default true)." },
346
- wordCount: { type: "number", description: "Memorable only (default 5)." },
347
- capitalize: { type: "boolean", description: "Memorable only (default true)." },
489
+ required: ["title", "type", "value"],
348
490
  },
349
- required: [],
350
491
  },
351
- },
352
- {
353
- name: "op_create_item",
354
- description:
355
- "Create a new item in a vault. Fields is an array of { title, type, value, sectionId? }. Type is Text | Concealed | Totp | Email | Url | Phone.",
356
- inputSchema: {
357
- type: "object",
358
- properties: {
359
- vault: { type: "string", description: "Vault id or title." },
360
- title: { type: "string", description: "Item title." },
361
- category: {
362
- type: "string",
363
- description:
364
- "Item category (default: Login). One of: Login, SecureNote, Password, ApiCredentials, Database, Server, Email, Identity, CreditCard, etc.",
492
+ tags: { type: "array", items: { type: "string" } },
493
+ websites: {
494
+ type: "array",
495
+ items: {
496
+ type: "object",
497
+ properties: {
498
+ url: { type: "string" },
499
+ label: { type: "string" },
365
500
  },
366
- fields: {
367
- type: "array",
368
- description: "Field definitions: [{ title, type, value, sectionId? }].",
369
- items: {
370
- type: "object",
371
- properties: {
372
- title: { type: "string" },
373
- type: { type: "string" },
374
- value: { type: "string" },
375
- sectionId: { type: "string" },
376
- },
377
- required: ["title", "type", "value"],
378
- },
379
- },
380
- tags: { type: "array", items: { type: "string" } },
381
- websites: {
382
- type: "array",
383
- items: {
384
- type: "object",
385
- properties: {
386
- url: { type: "string" },
387
- label: { type: "string" },
388
- },
389
- required: ["url"],
390
- },
391
- },
392
- notes: { type: "string", description: "Optional notes (stored as a Text field called 'notesPlain')." },
393
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
501
+ required: ["url"],
394
502
  },
395
- required: ["vault", "title"],
503
+ },
504
+ notes: {
505
+ type: "string",
506
+ description:
507
+ "Optional notes (stored as a Text field called 'notesPlain').",
508
+ },
509
+ instance: {
510
+ type: "string",
511
+ description: "Instance name. Uses default if omitted.",
396
512
  },
397
513
  },
398
- {
399
- name: "op_delete_item",
400
- description: "Delete an item by vault + item reference.",
401
- inputSchema: {
402
- type: "object",
403
- properties: {
404
- vault: { type: "string", description: "Vault id or title." },
405
- item: { type: "string", description: "Item id or title." },
406
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
407
- },
408
- required: ["vault", "item"],
514
+ required: ["vault", "title"],
515
+ },
516
+ },
517
+ {
518
+ name: "op_delete_item",
519
+ description: "Delete an item by vault + item reference.",
520
+ inputSchema: {
521
+ type: "object",
522
+ properties: {
523
+ vault: { type: "string", description: "Vault id or title." },
524
+ item: { type: "string", description: "Item id or title." },
525
+ instance: {
526
+ type: "string",
527
+ description: "Instance name. Uses default if omitted.",
409
528
  },
410
529
  },
411
- {
412
- name: "op_create_vault",
413
- description: "Create a new vault.",
414
- inputSchema: {
415
- type: "object",
416
- properties: {
417
- title: { type: "string", description: "Vault title." },
418
- description: { type: "string", description: "Optional description." },
419
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
420
- },
421
- required: ["title"],
530
+ required: ["vault", "item"],
531
+ },
532
+ },
533
+ {
534
+ name: "op_create_vault",
535
+ description: "Create a new vault.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ title: { type: "string", description: "Vault title." },
540
+ description: {
541
+ type: "string",
542
+ description: "Optional description.",
543
+ },
544
+ instance: {
545
+ type: "string",
546
+ description: "Instance name. Uses default if omitted.",
422
547
  },
423
548
  },
424
- {
425
- name: "op_delete_vault",
426
- description: "Delete a vault by id or title.",
427
- inputSchema: {
428
- type: "object",
429
- properties: {
430
- vault: { type: "string", description: "Vault id or title." },
431
- instance: { type: "string", description: "Instance name. Uses default if omitted." },
432
- },
433
- required: ["vault"],
549
+ required: ["title"],
550
+ },
551
+ },
552
+ {
553
+ name: "op_delete_vault",
554
+ description: "Delete a vault by id or title.",
555
+ inputSchema: {
556
+ type: "object",
557
+ properties: {
558
+ vault: { type: "string", description: "Vault id or title." },
559
+ instance: {
560
+ type: "string",
561
+ description: "Instance name. Uses default if omitted.",
434
562
  },
435
563
  },
436
- ],
564
+ required: ["vault"],
565
+ },
566
+ },
567
+ ];
568
+
569
+ // ============================================================================
570
+ // Handlers
571
+ //
572
+ // Each handler is keyed by tool name and receives the tool arguments. It
573
+ // should return a plain string (rendered as text content by the dispatcher)
574
+ // or throw on failure. The dispatcher handles MCP wrapping and error shape.
575
+ // ============================================================================
576
+
577
+ async function addInstanceHandler(args) {
578
+ const instName = args.name.trim();
579
+ const authType = args.authType || (args.token ? "serviceAccount" : "desktop");
580
+
581
+ if (authType === "serviceAccount") {
582
+ if (!args.token) {
583
+ throw new Error("serviceAccount authType requires a token.");
584
+ }
585
+ if (!/^ops_[A-Za-z0-9_\-]+/.test(args.token)) {
586
+ throw new Error(
587
+ "Token does not look like a 1Password service account token (expected prefix 'ops_').",
588
+ );
589
+ }
590
+ } else if (authType === "desktop") {
591
+ if (!args.accountName) {
592
+ throw new Error(
593
+ "desktop authType requires accountName (e.g. 'my.1password.com').",
594
+ );
595
+ }
596
+ } else {
597
+ throw new Error(`Unknown authType "${authType}".`);
598
+ }
599
+
600
+ // Validate by creating a client and listing one vault.
601
+ const probeAuth =
602
+ authType === "desktop" ? new sdk.DesktopAuth(args.accountName) : args.token;
603
+ const tempClient = await sdk.createClient({
604
+ auth: probeAuth,
605
+ integrationName: INTEGRATION_NAME,
606
+ integrationVersion: `v${PKG_VERSION}`,
607
+ });
608
+ const probe = [];
609
+ for await (const v of await tempClient.vaults.list()) {
610
+ probe.push(v);
611
+ if (probe.length >= 1) break;
612
+ }
613
+
614
+ const newInst = {
615
+ name: instName,
616
+ authType,
617
+ token: authType === "serviceAccount" ? args.token : undefined,
618
+ accountName: authType === "desktop" ? args.accountName : undefined,
619
+ description: args.description || "",
437
620
  };
438
- });
621
+ const existingIdx = instances.findIndex((i) => i.name === instName);
622
+ if (existingIdx >= 0) instances[existingIdx] = newInst;
623
+ else instances.push(newInst);
624
+ invalidateClient(instName);
625
+
626
+ const savedConfig = loadConfigFile();
627
+ if (!savedConfig.instances) savedConfig.instances = [];
628
+ const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
629
+ const toSave = { name: instName, authType };
630
+ if (authType === "serviceAccount") toSave.token = args.token;
631
+ if (authType === "desktop") toSave.accountName = args.accountName;
632
+ if (args.description) toSave.description = args.description;
633
+ if (savedIdx >= 0) savedConfig.instances[savedIdx] = toSave;
634
+ else savedConfig.instances.push(toSave);
635
+ if (args.setDefault || !savedConfig.defaultInstance) {
636
+ savedConfig.defaultInstance = instName;
637
+ defaultInstance = newInst;
638
+ }
639
+ saveConfigFile(savedConfig);
439
640
 
440
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
441
- const { name, arguments: args } = request.params;
641
+ const verb = existingIdx >= 0 ? "Updated" : "Added";
642
+ const suffix = args.setDefault ? " Set as default." : "";
643
+ return `${verb} instance "${instName}" (${authType}).${suffix}`;
644
+ }
442
645
 
443
- try {
444
- if (name === "op_list_instances") {
445
- if (instances.length === 0) {
446
- return {
447
- content: [
448
- {
449
- type: "text",
450
- text: "No instances configured. Run `1password-mcp setup` or call op_add_instance.",
451
- },
452
- ],
453
- };
454
- }
455
- const def = defaultInstance?.name;
456
- let text = `# 1Password Instances (${instances.length})\n\n`;
457
- for (const inst of instances) {
458
- const isDefault = inst.name === def ? " **(default)**" : "";
459
- const desc = inst.description ? ` - ${inst.description}` : "";
460
- const auth =
461
- inst.authType === "desktop"
462
- ? ` (desktop: ${inst.accountName})`
463
- : " (serviceAccount)";
464
- text += `- **${inst.name}**${isDefault}${auth}${desc}\n`;
465
- }
466
- return { content: [{ type: "text", text }] };
646
+ async function removeInstanceHandler(args) {
647
+ if (instances.length <= 1) {
648
+ throw new Error("Cannot remove the last remaining instance.");
649
+ }
650
+ const idx = instances.findIndex((i) => i.name === args.name);
651
+ if (idx < 0) throw new Error(`Instance "${args.name}" not found.`);
652
+
653
+ instances.splice(idx, 1);
654
+ invalidateClient(args.name);
655
+
656
+ const savedConfig = loadConfigFile();
657
+ if (savedConfig.instances) {
658
+ savedConfig.instances = savedConfig.instances.filter(
659
+ (i) => i.name !== args.name,
660
+ );
661
+ if (savedConfig.defaultInstance === args.name) {
662
+ savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
663
+ defaultInstance = instances[0] || null;
467
664
  }
665
+ saveConfigFile(savedConfig);
666
+ }
667
+ return `Removed instance "${args.name}".`;
668
+ }
468
669
 
469
- if (name === "op_add_instance") {
470
- const instName = args.name.trim();
471
- const authType = args.authType || (args.token ? "serviceAccount" : "desktop");
472
-
473
- if (authType === "serviceAccount") {
474
- if (!args.token) {
475
- return {
476
- content: [{ type: "text", text: "serviceAccount authType requires a token." }],
477
- isError: true,
478
- };
479
- }
480
- if (!/^ops_[A-Za-z0-9_\-]+/.test(args.token)) {
481
- return {
482
- content: [
483
- {
484
- type: "text",
485
- text: "Token does not look like a 1Password service account token (expected prefix 'ops_').",
486
- },
487
- ],
488
- isError: true,
489
- };
490
- }
491
- } else if (authType === "desktop") {
492
- if (!args.accountName) {
493
- return {
494
- content: [
495
- {
496
- type: "text",
497
- text: "desktop authType requires accountName (e.g. 'my.1password.com').",
498
- },
499
- ],
500
- isError: true,
501
- };
502
- }
503
- } else {
504
- return {
505
- content: [{ type: "text", text: `Unknown authType "${authType}".` }],
506
- isError: true,
507
- };
508
- }
670
+ function listInstancesHandler() {
671
+ if (instances.length === 0) {
672
+ return "No instances configured. Run `1password-mcp setup` or call op_add_instance.";
673
+ }
674
+ const def = defaultInstance?.name;
675
+ const lines = instances.map((inst) => {
676
+ const isDefault = inst.name === def ? " **(default)**" : "";
677
+ const desc = inst.description ? ` - ${inst.description}` : "";
678
+ const auth =
679
+ inst.authType === "desktop"
680
+ ? ` (desktop: ${inst.accountName})`
681
+ : " (serviceAccount)";
682
+ return `- **${inst.name}**${isDefault}${auth}${desc}`;
683
+ });
684
+ return `# 1Password Instances (${instances.length})\n\n${lines.join("\n")}`;
685
+ }
509
686
 
510
- // Validate by creating a client and listing one vault.
511
- try {
512
- const probeAuth =
513
- authType === "desktop"
514
- ? new sdk.DesktopAuth(args.accountName)
515
- : args.token;
516
- const tempClient = await sdk.createClient({
517
- auth: probeAuth,
518
- integrationName: INTEGRATION_NAME,
519
- integrationVersion: `v${PKG_VERSION}`,
520
- });
521
- const probe = [];
522
- for await (const v of await tempClient.vaults.list()) {
523
- probe.push(v);
524
- if (probe.length >= 1) break;
525
- }
526
- } catch (e) {
527
- return {
528
- content: [{ type: "text", text: `Auth validation failed: ${e.message}` }],
529
- isError: true,
530
- };
531
- }
687
+ async function listVaultsHandler(args) {
688
+ const client = await getClient(getInstanceByName(args.instance));
689
+ const vaults = await collect(await client.vaults.list());
690
+ if (vaults.length === 0) {
691
+ return "No vaults visible to this instance.";
692
+ }
693
+ const lines = vaults.map((v) => `- **${v.title}** (id: ${v.id})`);
694
+ return `# Vaults (${vaults.length})\n\n${lines.join("\n")}`;
695
+ }
532
696
 
533
- const newInst = {
534
- name: instName,
535
- authType,
536
- token: authType === "serviceAccount" ? args.token : undefined,
537
- accountName: authType === "desktop" ? args.accountName : undefined,
538
- description: args.description || "",
539
- };
540
- const existingIdx = instances.findIndex((i) => i.name === instName);
541
- if (existingIdx >= 0) instances[existingIdx] = newInst;
542
- else instances.push(newInst);
543
- invalidateClient(instName);
544
-
545
- const savedConfig = loadConfigFile();
546
- if (!savedConfig.instances) savedConfig.instances = [];
547
- const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
548
- const toSave = { name: instName, authType };
549
- if (authType === "serviceAccount") toSave.token = args.token;
550
- if (authType === "desktop") toSave.accountName = args.accountName;
551
- if (args.description) toSave.description = args.description;
552
- if (savedIdx >= 0) savedConfig.instances[savedIdx] = toSave;
553
- else savedConfig.instances.push(toSave);
554
- if (args.setDefault || !savedConfig.defaultInstance) {
555
- savedConfig.defaultInstance = instName;
556
- }
557
- saveConfigFile(savedConfig);
697
+ async function listItemsHandler(args) {
698
+ const client = await getClient(getInstanceByName(args.instance));
699
+ const vault = await resolveVault(client, args.vault);
700
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
558
701
 
559
- return {
560
- content: [
561
- {
562
- type: "text",
563
- text: `${existingIdx >= 0 ? "Updated" : "Added"} instance "${instName}" (${authType}).${args.setDefault ? " Set as default." : ""}`,
564
- },
565
- ],
566
- };
567
- }
702
+ const items = await collect(await client.items.list(vault.id));
703
+ if (items.length === 0) {
704
+ return `No items in vault "${vault.title}".`;
705
+ }
706
+ const lines = items.map(
707
+ (i) => `- **${i.title}** (id: ${i.id}, category: ${i.category})`,
708
+ );
709
+ return `# Items in ${vault.title} (${items.length})\n\n${lines.join("\n")}`;
710
+ }
568
711
 
569
- if (name === "op_remove_instance") {
570
- if (instances.length <= 1) {
571
- return {
572
- content: [{ type: "text", text: "Cannot remove the last remaining instance." }],
573
- isError: true,
574
- };
575
- }
576
- const idx = instances.findIndex((i) => i.name === args.name);
577
- if (idx < 0) {
578
- return {
579
- content: [{ type: "text", text: `Instance "${args.name}" not found.` }],
580
- isError: true,
581
- };
582
- }
583
- instances.splice(idx, 1);
584
- invalidateClient(args.name);
585
- const savedConfig = loadConfigFile();
586
- if (savedConfig.instances) {
587
- savedConfig.instances = savedConfig.instances.filter((i) => i.name !== args.name);
588
- if (savedConfig.defaultInstance === args.name) {
589
- savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
590
- }
591
- saveConfigFile(savedConfig);
592
- }
593
- return { content: [{ type: "text", text: `Removed instance "${args.name}".` }] };
594
- }
712
+ async function searchItemsHandler(args) {
713
+ const client = await getClient(getInstanceByName(args.instance));
714
+ const limit = args.limit || 25;
715
+ const q = String(args.query).toLowerCase();
595
716
 
596
- if (name === "op_list_vaults") {
597
- const inst = getInstanceByName(args.instance);
598
- const client = await getClient(inst);
599
- const vaults = [];
600
- for await (const v of await client.vaults.list()) vaults.push(v);
601
- if (vaults.length === 0) {
602
- return { content: [{ type: "text", text: "No vaults visible to this service account." }] };
717
+ let targetVaults;
718
+ if (args.vault) {
719
+ const v = await resolveVault(client, args.vault);
720
+ if (!v) throw new Error(`Vault "${args.vault}" not found.`);
721
+ targetVaults = [v];
722
+ } else {
723
+ targetVaults = await collect(await client.vaults.list());
724
+ }
725
+
726
+ const hits = [];
727
+ for (const v of targetVaults) {
728
+ for await (const it of await client.items.list(v.id)) {
729
+ if ((it.title || "").toLowerCase().includes(q)) {
730
+ hits.push({ ...it, _vaultTitle: v.title });
731
+ if (hits.length >= limit) break;
603
732
  }
604
- let text = `# Vaults (${vaults.length})\n\n`;
605
- for (const v of vaults) text += `- **${v.title}** (id: ${v.id})\n`;
606
- return { content: [{ type: "text", text }] };
607
733
  }
734
+ if (hits.length >= limit) break;
735
+ }
608
736
 
609
- if (name === "op_list_items") {
610
- const inst = getInstanceByName(args.instance);
611
- const client = await getClient(inst);
612
- const vault = await resolveVault(client, args.vault);
613
- if (!vault) {
614
- return {
615
- content: [{ type: "text", text: `Vault "${args.vault}" not found.` }],
616
- isError: true,
617
- };
618
- }
619
- const items = [];
620
- for await (const it of await client.items.list(vault.id)) items.push(it);
621
- if (items.length === 0) {
622
- return { content: [{ type: "text", text: `No items in vault "${vault.title}".` }] };
623
- }
624
- let text = `# Items in ${vault.title} (${items.length})\n\n`;
625
- for (const it of items) {
626
- text += `- **${it.title}** (id: ${it.id}, category: ${it.category})\n`;
627
- }
628
- return { content: [{ type: "text", text }] };
737
+ if (hits.length === 0) return `No items matched "${args.query}".`;
738
+ const lines = hits.map(
739
+ (it) =>
740
+ `- **${it.title}** (id: ${it.id}, vault: ${it._vaultTitle}, category: ${it.category})`,
741
+ );
742
+ return `# Search: "${args.query}" (${hits.length})\n\n${lines.join("\n")}`;
743
+ }
744
+
745
+ async function getItemHandler(args) {
746
+ const client = await getClient(getInstanceByName(args.instance));
747
+ const vault = await resolveVault(client, args.vault);
748
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
749
+ const itemRef = await resolveItem(client, vault, args.item);
750
+ if (!itemRef) {
751
+ throw new Error(
752
+ `Item "${args.item}" not found in vault "${vault.title}".`,
753
+ );
754
+ }
755
+ const full = await client.items.get(vault.id, itemRef.id);
756
+ const formatted = formatItem(full, !!args.includeConcealed);
757
+
758
+ const lines = [
759
+ `# ${formatted.title} (${formatted.category})`,
760
+ "",
761
+ `- **Vault:** ${vault.title} (${formatted.vaultId})`,
762
+ `- **Item ID:** ${formatted.id}`,
763
+ ];
764
+ if (formatted.tags.length) lines.push(`- **Tags:** ${formatted.tags.join(", ")}`);
765
+ if (formatted.websites.length) {
766
+ lines.push(
767
+ `- **Websites:** ${formatted.websites.map((w) => w.url).join(", ")}`,
768
+ );
769
+ }
770
+ if (formatted.fields.length) {
771
+ lines.push("", "## Fields", "");
772
+ for (const f of formatted.fields) {
773
+ const val = f.value === undefined ? "" : f.value;
774
+ const otp = f.otp ? ` (OTP now: ${f.otp})` : "";
775
+ lines.push(`- **${f.title}** (${f.type}): ${val}${otp}`);
629
776
  }
777
+ }
778
+ if (!args.includeConcealed) {
779
+ lines.push(
780
+ "",
781
+ "_Concealed fields are redacted. Call with includeConcealed=true to reveal._",
782
+ );
783
+ }
784
+ return lines.join("\n");
785
+ }
630
786
 
631
- if (name === "op_search_items") {
632
- const inst = getInstanceByName(args.instance);
633
- const client = await getClient(inst);
634
- const limit = args.limit || 25;
635
- const q = String(args.query).toLowerCase();
636
-
637
- let targetVaults = [];
638
- if (args.vault) {
639
- const v = await resolveVault(client, args.vault);
640
- if (!v) {
641
- return {
642
- content: [{ type: "text", text: `Vault "${args.vault}" not found.` }],
643
- isError: true,
644
- };
645
- }
646
- targetVaults = [v];
647
- } else {
648
- for await (const v of await client.vaults.list()) targetVaults.push(v);
649
- }
787
+ async function getSecretHandler(args) {
788
+ const client = await getClient(getInstanceByName(args.instance));
789
+ const value = await client.secrets.resolve(args.reference);
790
+ return `Secret for ${args.reference}:\n${value}`;
791
+ }
650
792
 
651
- const hits = [];
652
- for (const v of targetVaults) {
653
- for await (const it of await client.items.list(v.id)) {
654
- if ((it.title || "").toLowerCase().includes(q)) {
655
- hits.push({ ...it, _vaultTitle: v.title });
656
- if (hits.length >= limit) break;
657
- }
658
- }
659
- if (hits.length >= limit) break;
660
- }
793
+ function generatePasswordHandler(args) {
794
+ const type = args.type || "Random";
795
+ let generated;
796
+ if (type === "Pin") {
797
+ generated = sdk.Secrets.generatePassword({
798
+ type: "Pin",
799
+ parameters: { length: args.length || 8 },
800
+ });
801
+ } else if (type === "Memorable") {
802
+ generated = sdk.Secrets.generatePassword({
803
+ type: "Memorable",
804
+ parameters: {
805
+ separatorType: sdk.SeparatorType.Digits,
806
+ capitalize: args.capitalize !== false,
807
+ wordListType: sdk.WordListType.FullWords,
808
+ wordCount: args.wordCount || 5,
809
+ },
810
+ });
811
+ } else {
812
+ generated = sdk.Secrets.generatePassword({
813
+ type: "Random",
814
+ parameters: {
815
+ includeDigits: args.includeDigits !== false,
816
+ includeSymbols: args.includeSymbols !== false,
817
+ length: args.length || 20,
818
+ },
819
+ });
820
+ }
821
+ const pwd =
822
+ typeof generated === "string" ? generated : generated.password || generated;
823
+ return `Generated (${type}):\n${pwd}`;
824
+ }
661
825
 
662
- if (hits.length === 0) {
663
- return {
664
- content: [{ type: "text", text: `No items matched "${args.query}".` }],
665
- };
666
- }
667
- let text = `# Search: "${args.query}" (${hits.length})\n\n`;
668
- for (const it of hits) {
669
- text += `- **${it.title}** (id: ${it.id}, vault: ${it._vaultTitle}, category: ${it.category})\n`;
670
- }
671
- return { content: [{ type: "text", text }] };
672
- }
826
+ async function createItemHandler(args) {
827
+ const client = await getClient(getInstanceByName(args.instance));
828
+ const vault = await resolveVault(client, args.vault);
829
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
830
+
831
+ const category = sdk.ItemCategory[args.category || "Login"];
832
+ if (!category) throw new Error(`Unknown category "${args.category}".`);
833
+
834
+ const fields = (args.fields || []).map((f) => {
835
+ const ft = sdk.ItemFieldType[f.type];
836
+ if (!ft) throw new Error(`Unknown field type "${f.type}"`);
837
+ const out = {
838
+ id: f.title.toLowerCase().replace(/\s+/g, "_"),
839
+ title: f.title,
840
+ fieldType: ft,
841
+ value: f.value,
842
+ };
843
+ if (f.sectionId) out.sectionId = f.sectionId;
844
+ return out;
845
+ });
846
+ if (args.notes) {
847
+ fields.push({
848
+ id: "notesPlain",
849
+ title: "notesPlain",
850
+ fieldType: sdk.ItemFieldType.Text,
851
+ value: args.notes,
852
+ });
853
+ }
673
854
 
674
- if (name === "op_get_item") {
675
- const inst = getInstanceByName(args.instance);
676
- const client = await getClient(inst);
677
- const vault = await resolveVault(client, args.vault);
678
- if (!vault) {
679
- return {
680
- content: [{ type: "text", text: `Vault "${args.vault}" not found.` }],
681
- isError: true,
682
- };
683
- }
684
- const itemRef = await resolveItem(client, vault, args.item);
685
- if (!itemRef) {
686
- return {
687
- content: [{ type: "text", text: `Item "${args.item}" not found in vault "${vault.title}".` }],
688
- isError: true,
689
- };
690
- }
691
- const full = await client.items.get(vault.id, itemRef.id);
692
- const formatted = formatItem(full, !!args.includeConcealed);
693
- let text = `# ${formatted.title} (${formatted.category})\n\n`;
694
- text += `- **Vault:** ${vault.title} (${formatted.vaultId})\n`;
695
- text += `- **Item ID:** ${formatted.id}\n`;
696
- if (formatted.tags.length > 0) text += `- **Tags:** ${formatted.tags.join(", ")}\n`;
697
- if (formatted.websites.length > 0) {
698
- text += `- **Websites:** ${formatted.websites.map((w) => w.url).join(", ")}\n`;
699
- }
700
- if (formatted.fields.length > 0) {
701
- text += `\n## Fields\n\n`;
702
- for (const f of formatted.fields) {
703
- const val = f.value === undefined ? "" : f.value;
704
- text += `- **${f.title}** (${f.type}): ${val}${f.otp ? ` (OTP now: ${f.otp})` : ""}\n`;
705
- }
706
- }
707
- if (!args.includeConcealed) {
708
- text += `\n_Concealed fields are redacted. Call with includeConcealed=true to reveal._`;
709
- }
710
- return { content: [{ type: "text", text }] };
711
- }
855
+ const item = await client.items.create({
856
+ title: args.title,
857
+ category,
858
+ vaultId: vault.id,
859
+ fields,
860
+ tags: args.tags || [],
861
+ websites: (args.websites || []).map((w) => ({
862
+ url: w.url,
863
+ label: w.label || "website",
864
+ autofillBehavior: sdk.AutofillBehavior.AnywhereOnWebsite,
865
+ })),
866
+ });
867
+ return `Created item **${item.title}** (id: ${item.id}) in vault "${vault.title}".`;
868
+ }
712
869
 
713
- if (name === "op_get_secret") {
714
- const inst = getInstanceByName(args.instance);
715
- const client = await getClient(inst);
716
- const value = await client.secrets.resolve(args.reference);
717
- return {
718
- content: [{ type: "text", text: `Secret for ${args.reference}:\n${value}` }],
719
- };
720
- }
870
+ async function deleteItemHandler(args) {
871
+ const client = await getClient(getInstanceByName(args.instance));
872
+ const vault = await resolveVault(client, args.vault);
873
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
874
+ const itemRef = await resolveItem(client, vault, args.item);
875
+ if (!itemRef) {
876
+ throw new Error(
877
+ `Item "${args.item}" not found in vault "${vault.title}".`,
878
+ );
879
+ }
880
+ await client.items.delete(vault.id, itemRef.id);
881
+ return `Deleted item "${itemRef.title}" from vault "${vault.title}".`;
882
+ }
721
883
 
722
- if (name === "op_generate_password") {
723
- const type = args.type || "Random";
724
- let generated;
725
- if (type === "Pin") {
726
- generated = sdk.Secrets.generatePassword({
727
- type: "Pin",
728
- parameters: { length: args.length || 8 },
729
- });
730
- } else if (type === "Memorable") {
731
- generated = sdk.Secrets.generatePassword({
732
- type: "Memorable",
733
- parameters: {
734
- separatorType: sdk.SeparatorType.Digits,
735
- capitalize: args.capitalize !== false,
736
- wordListType: sdk.WordListType.FullWords,
737
- wordCount: args.wordCount || 5,
738
- },
739
- });
740
- } else {
741
- generated = sdk.Secrets.generatePassword({
742
- type: "Random",
743
- parameters: {
744
- includeDigits: args.includeDigits !== false,
745
- includeSymbols: args.includeSymbols !== false,
746
- length: args.length || 20,
747
- },
748
- });
749
- }
750
- const pwd = typeof generated === "string" ? generated : generated.password || generated;
751
- return { content: [{ type: "text", text: `Generated (${type}):\n${pwd}` }] };
752
- }
884
+ async function createVaultHandler(args) {
885
+ const client = await getClient(getInstanceByName(args.instance));
886
+ const vault = await client.vaults.create({
887
+ title: args.title,
888
+ description: args.description || "",
889
+ });
890
+ return `Created vault **${vault.title}** (id: ${vault.id}).`;
891
+ }
753
892
 
754
- if (name === "op_create_item") {
755
- const inst = getInstanceByName(args.instance);
756
- const client = await getClient(inst);
757
- const vault = await resolveVault(client, args.vault);
758
- if (!vault) {
759
- return {
760
- content: [{ type: "text", text: `Vault "${args.vault}" not found.` }],
761
- isError: true,
762
- };
763
- }
764
- const category = sdk.ItemCategory[args.category || "Login"];
765
- if (!category) {
766
- return {
767
- content: [{ type: "text", text: `Unknown category "${args.category}".` }],
768
- isError: true,
769
- };
770
- }
771
- const fields = (args.fields || []).map((f) => {
772
- const ft = sdk.ItemFieldType[f.type];
773
- if (!ft) throw new Error(`Unknown field type "${f.type}"`);
774
- const out = {
775
- id: f.title.toLowerCase().replace(/\s+/g, "_"),
776
- title: f.title,
777
- fieldType: ft,
778
- value: f.value,
779
- };
780
- if (f.sectionId) out.sectionId = f.sectionId;
781
- return out;
782
- });
783
- if (args.notes) {
784
- fields.push({
785
- id: "notesPlain",
786
- title: "notesPlain",
787
- fieldType: sdk.ItemFieldType.Text,
788
- value: args.notes,
789
- });
790
- }
791
- const item = await client.items.create({
792
- title: args.title,
793
- category,
794
- vaultId: vault.id,
795
- fields,
796
- tags: args.tags || [],
797
- websites: (args.websites || []).map((w) => ({
798
- url: w.url,
799
- label: w.label || "website",
800
- autofillBehavior: sdk.AutofillBehavior.AnywhereOnWebsite,
801
- })),
802
- });
803
- return {
804
- content: [
805
- {
806
- type: "text",
807
- text: `Created item **${item.title}** (id: ${item.id}) in vault "${vault.title}".`,
808
- },
809
- ],
810
- };
811
- }
893
+ async function deleteVaultHandler(args) {
894
+ const client = await getClient(getInstanceByName(args.instance));
895
+ const vault = await resolveVault(client, args.vault);
896
+ if (!vault) throw new Error(`Vault "${args.vault}" not found.`);
897
+ await client.vaults.delete(vault.id);
898
+ return `Deleted vault "${vault.title}".`;
899
+ }
812
900
 
813
- if (name === "op_delete_item") {
814
- const inst = getInstanceByName(args.instance);
815
- const client = await getClient(inst);
816
- const vault = await resolveVault(client, args.vault);
817
- if (!vault) {
818
- return {
819
- content: [{ type: "text", text: `Vault "${args.vault}" not found.` }],
820
- isError: true,
821
- };
822
- }
823
- const itemRef = await resolveItem(client, vault, args.item);
824
- if (!itemRef) {
825
- return {
826
- content: [{ type: "text", text: `Item "${args.item}" not found in vault "${vault.title}".` }],
827
- isError: true,
828
- };
829
- }
830
- await client.items.delete(vault.id, itemRef.id);
831
- return {
832
- content: [
833
- {
834
- type: "text",
835
- text: `Deleted item "${itemRef.title}" from vault "${vault.title}".`,
836
- },
837
- ],
838
- };
901
+ const HANDLERS = {
902
+ op_list_instances: listInstancesHandler,
903
+ op_add_instance: addInstanceHandler,
904
+ op_remove_instance: removeInstanceHandler,
905
+ op_list_vaults: listVaultsHandler,
906
+ op_list_items: listItemsHandler,
907
+ op_search_items: searchItemsHandler,
908
+ op_get_item: getItemHandler,
909
+ op_get_secret: getSecretHandler,
910
+ op_generate_password: generatePasswordHandler,
911
+ op_create_item: createItemHandler,
912
+ op_delete_item: deleteItemHandler,
913
+ op_create_vault: createVaultHandler,
914
+ op_delete_vault: deleteVaultHandler,
915
+ };
916
+
917
+ // Drift guard: every declared tool must have a handler and vice versa. This is
918
+ // evaluated at module load so a missing/extra handler fails fast.
919
+ (function assertHandlerCompleteness() {
920
+ const toolNames = new Set(TOOL_DEFINITIONS.map((t) => t.name));
921
+ const handlerNames = new Set(Object.keys(HANDLERS));
922
+ for (const name of toolNames) {
923
+ if (!handlerNames.has(name)) {
924
+ throw new Error(`Tool "${name}" is declared but has no handler.`);
839
925
  }
840
-
841
- if (name === "op_create_vault") {
842
- const inst = getInstanceByName(args.instance);
843
- const client = await getClient(inst);
844
- const vault = await client.vaults.create({
845
- title: args.title,
846
- description: args.description || "",
847
- });
848
- return {
849
- content: [
850
- { type: "text", text: `Created vault **${vault.title}** (id: ${vault.id}).` },
851
- ],
852
- };
926
+ }
927
+ for (const name of handlerNames) {
928
+ if (!toolNames.has(name)) {
929
+ throw new Error(`Handler "${name}" exists but is not declared in TOOL_DEFINITIONS.`);
853
930
  }
931
+ }
932
+ })();
854
933
 
855
- if (name === "op_delete_vault") {
856
- const inst = getInstanceByName(args.instance);
857
- const client = await getClient(inst);
858
- const vault = await resolveVault(client, args.vault);
859
- if (!vault) {
860
- return {
861
- content: [{ type: "text", text: `Vault "${args.vault}" not found.` }],
862
- isError: true,
863
- };
864
- }
865
- await client.vaults.delete(vault.id);
866
- return { content: [{ type: "text", text: `Deleted vault "${vault.title}".` }] };
867
- }
934
+ // ============================================================================
935
+ // MCP server wiring
936
+ // ============================================================================
868
937
 
869
- throw new Error(`Unknown tool: ${name}`);
870
- } catch (error) {
938
+ const server = new Server(
939
+ { name: INTEGRATION_NAME, version: PKG_VERSION },
940
+ { capabilities: { tools: {} } },
941
+ );
942
+
943
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
944
+ tools: TOOL_DEFINITIONS,
945
+ }));
946
+
947
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
948
+ const { name, arguments: args } = request.params;
949
+ const handler = HANDLERS[name];
950
+ if (!handler) {
871
951
  return {
872
- content: [{ type: "text", text: `Error: ${error.message}` }],
952
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
953
+ isError: true,
954
+ };
955
+ }
956
+ try {
957
+ const result = await handler(args || {});
958
+ return { content: [{ type: "text", text: String(result) }] };
959
+ } catch (e) {
960
+ return {
961
+ content: [{ type: "text", text: `Error: ${e.message}` }],
873
962
  isError: true,
874
963
  };
875
964
  }
@@ -887,13 +976,35 @@ if (require.main === module) {
887
976
  });
888
977
  }
889
978
 
890
- // Export for testing
979
+ // ============================================================================
980
+ // Test exports
981
+ // ============================================================================
982
+
891
983
  if (typeof module !== "undefined") {
892
984
  module.exports = {
985
+ // config
986
+ CONFIG_DIR,
987
+ CONFIG_PATH,
893
988
  loadConfigFile,
894
989
  saveConfigFile,
990
+ normalizeInstance,
991
+ // instance resolution
895
992
  getInstanceByName,
993
+ // helpers
994
+ collect,
995
+ resolveVault,
996
+ resolveItem,
896
997
  redactField,
897
998
  formatItem,
999
+ // handler map + tool list (for drift guard tests)
1000
+ TOOL_DEFINITIONS,
1001
+ HANDLERS,
1002
+ // direct handler access for unit tests that don't need the MCP transport
1003
+ listInstancesHandler,
1004
+ listVaultsHandler,
1005
+ listItemsHandler,
1006
+ searchItemsHandler,
1007
+ getItemHandler,
1008
+ generatePasswordHandler,
898
1009
  };
899
1010
  }