From 2d5e976c69b94c6d38d433e47e5c681e32fe7ebe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:41:56 +0000 Subject: [PATCH 1/7] Initial plan From b3f9ee772bd1fc2fe1e8a1b1807481e0c37d30bd Mon Sep 17 00:00:00 2001 From: Albert Hazan Date: Mon, 22 Jun 2026 10:42:57 -0400 Subject: [PATCH 2/7] feat: add item_get tool Retrieve a full 1Password item (title, category, tags, notes, and all fields with id/title/type/section) via vault+item ID or a secret reference. Concealed field values are hidden behind a placeholder unless the caller passes reveal=true, mirroring password_read's conceal pattern. Co-Authored-By: Claude Opus 4.8 --- src/tools/index.ts | 2 + src/tools/item-get.ts | 134 ++++++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 93 ++++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/tools/item-get.ts diff --git a/src/tools/index.ts b/src/tools/index.ts index c7a66cc..433e7b4 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerVaultList } from "./vault-list.js"; import { registerItemLookup } from "./item-lookup.js"; import { registerItemDelete } from "./item-delete.js"; +import { registerItemGet } from "./item-get.js"; import { registerPasswordCreate } from "./password-create.js"; import { registerPasswordRead } from "./password-read.js"; import { registerPasswordUpdate } from "./password-update.js"; @@ -17,6 +18,7 @@ export function registerAllTools(server: McpServer): void { registerVaultList(server); registerItemLookup(server); registerItemDelete(server); + registerItemGet(server); registerPasswordCreate(server); registerPasswordRead(server); registerPasswordUpdate(server); diff --git a/src/tools/item-get.ts b/src/tools/item-get.ts new file mode 100644 index 0000000..bc85adc --- /dev/null +++ b/src/tools/item-get.ts @@ -0,0 +1,134 @@ +/** + * item_get — Retrieve a full 1Password item, with concealed values hidden by default. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { ItemFieldType, type Item, type ItemField } from "@1password/sdk"; +import { getClient } from "../client.js"; +import { log, logError } from "../logger.js"; +import { jsonResult, errorResult } from "../utils.js"; + +/** Placeholder returned in place of a concealed value when reveal is false. */ +const CONCEALED_PLACEHOLDER = "[concealed]"; + +/** + * Shape a single field for output. Concealed field values are replaced with a + * placeholder unless the caller explicitly asks to reveal them. + */ +function summarizeField( + field: ItemField, + reveal: boolean, +): { + id: string; + title: string; + type: ItemFieldType; + section?: string; + value: string; +} { + const concealed = field.fieldType === ItemFieldType.Concealed; + const value = concealed && !reveal ? CONCEALED_PLACEHOLDER : field.value; + return { + id: field.id, + title: field.title, + type: field.fieldType, + section: field.sectionId, + value, + }; +} + +export function registerItemGet(server: McpServer): void { + server.tool( + "item_get", + "Retrieve a full 1Password item — title, category, tags, notes, and all fields (id, title, type, section). Concealed field values are hidden unless reveal is true. Accepts a secret reference (op://vault/item) or vault ID + item ID.", + { + secretReference: z + .string() + .optional() + .describe( + "Secret reference in op://vault/item format. If provided, vaultId and itemId are ignored.", + ), + vaultId: z + .string() + .optional() + .describe("Vault ID containing the item (required if secretReference is not provided)."), + itemId: z + .string() + .optional() + .describe("Item ID to retrieve (required if secretReference is not provided)."), + reveal: z + .boolean() + .optional() + .describe( + "If true, include concealed field values in plaintext. Defaults to false for security.", + ), + }, + async ({ secretReference, vaultId, itemId, reveal }) => { + try { + log("debug", "Tool call: item_get.", { + secretReference: Boolean(secretReference), + vaultId, + itemId, + reveal, + }); + const client = await getClient(); + if (!client?.items?.get) { + throw new Error( + "Your @1password/sdk version does not support getting items.", + ); + } + + let resolvedVaultId = vaultId; + let resolvedItemId = itemId; + + if (secretReference) { + if (!client?.secrets?.resolveAll) { + throw new Error( + "Your @1password/sdk version does not support resolving secret references.", + ); + } + const resolved = await client.secrets.resolveAll([secretReference]); + const response = resolved.individualResponses[secretReference]; + if (!response?.content) { + const reason = response?.error?.type ?? "unknown"; + throw new Error( + `Could not resolve secret reference '${secretReference}' (${reason}).`, + ); + } + resolvedVaultId = response.content.vaultId; + resolvedItemId = response.content.itemId; + } + + if (!resolvedVaultId || !resolvedItemId) { + throw new Error( + "Provide secretReference or both vaultId and itemId.", + ); + } + + const item: Item = await client.items.get(resolvedVaultId, resolvedItemId); + const shouldReveal = reveal === true; + + return jsonResult({ + id: item.id, + title: item.title, + category: item.category, + vaultId: item.vaultId, + tags: item.tags ?? [], + notes: item.notes ?? "", + sections: (item.sections ?? []).map((section) => ({ + id: section.id, + title: section.title, + })), + fields: (item.fields ?? []).map((field) => + summarizeField(field, shouldReveal), + ), + websites: (item.websites ?? []).map((site) => site.url), + updatedAt: item.updatedAt, + }); + } catch (error) { + logError("item_get failed.", error); + return errorResult(error); + } + }, + ); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index c515b3b..186d6e7 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -49,11 +49,12 @@ describe("MCP Tools", () => { registerAllTools(server); }); - it("registers all 8 tools", () => { - expect(registeredTools.size).toBe(8); + it("registers all 9 tools", () => { + expect(registeredTools.size).toBe(9); expect(registeredTools.has("vault_list")).toBe(true); expect(registeredTools.has("item_lookup")).toBe(true); expect(registeredTools.has("item_delete")).toBe(true); + expect(registeredTools.has("item_get")).toBe(true); expect(registeredTools.has("password_create")).toBe(true); expect(registeredTools.has("password_read")).toBe(true); expect(registeredTools.has("password_update")).toBe(true); @@ -234,4 +235,92 @@ describe("MCP Tools", () => { expect(result.content[0].text).toContain("Provide secretReference or both vaultId and itemId"); }); }); + + describe("item_get", () => { + const sampleItem = { + id: "i1", + title: "GitHub", + category: "Login", + vaultId: "v1", + tags: ["dev"], + notes: "personal account", + sections: [{ id: "s1", title: "Extra" }], + fields: [ + { id: "username", title: "username", fieldType: "Text", value: "octocat" }, + { id: "password", title: "password", fieldType: "Concealed", value: "s3cr3t" }, + { id: "pin", title: "pin", fieldType: "Concealed", value: "1234", sectionId: "s1" }, + ], + websites: [{ url: "https://github.com", label: "website", autofillBehavior: "AnywhereOnWebsite" }], + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }; + + it("conceals concealed field values by default", async () => { + mockedGetClient.mockResolvedValue({ + items: { get: vi.fn().mockResolvedValue(sampleItem) }, + } as any); + + const handler = registeredTools.get("item_get")!.handler; + const result = await handler({ vaultId: "v1", itemId: "i1" }); + const data = JSON.parse(result.content[0].text); + + expect(data.title).toBe("GitHub"); + expect(data.notes).toBe("personal account"); + expect(data.tags).toEqual(["dev"]); + const username = data.fields.find((f: any) => f.id === "username"); + const password = data.fields.find((f: any) => f.id === "password"); + expect(username.value).toBe("octocat"); + expect(password.value).toBe("[concealed]"); + expect(password.section).toBeUndefined(); + const pin = data.fields.find((f: any) => f.id === "pin"); + expect(pin.section).toBe("s1"); + }); + + it("reveals concealed values when reveal is true", async () => { + mockedGetClient.mockResolvedValue({ + items: { get: vi.fn().mockResolvedValue(sampleItem) }, + } as any); + + const handler = registeredTools.get("item_get")!.handler; + const result = await handler({ vaultId: "v1", itemId: "i1", reveal: true }); + const data = JSON.parse(result.content[0].text); + + const password = data.fields.find((f: any) => f.id === "password"); + expect(password.value).toBe("s3cr3t"); + }); + + it("resolves vault/item from a secret reference", async () => { + const get = vi.fn().mockResolvedValue(sampleItem); + mockedGetClient.mockResolvedValue({ + items: { get }, + secrets: { + resolveAll: vi.fn().mockResolvedValue({ + individualResponses: { + "op://Personal/GitHub/password": { + content: { secret: "s3cr3t", itemId: "i1", vaultId: "v1" }, + }, + }, + }), + }, + } as any); + + const handler = registeredTools.get("item_get")!.handler; + const result = await handler({ secretReference: "op://Personal/GitHub/password" }); + const data = JSON.parse(result.content[0].text); + + expect(get).toHaveBeenCalledWith("v1", "i1"); + expect(data.id).toBe("i1"); + }); + + it("errors when neither secretReference nor vaultId/itemId provided", async () => { + mockedGetClient.mockResolvedValue({ + items: { get: vi.fn() }, + } as any); + + const handler = registeredTools.get("item_get")!.handler; + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Provide secretReference or both vaultId and itemId"); + }); + }); }); From 9472a909321ea8ff32eaf4f06c68249ab451a543 Mon Sep 17 00:00:00 2001 From: Albert Hazan Date: Mon, 22 Jun 2026 10:43:54 -0400 Subject: [PATCH 3/7] feat: add item_edit tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit an existing item after creation: title, notes (empty string clears notes — the create/edit/delete-notes capability), tags, website URL, and field upserts/removals. The item is fetched, changes are applied immutably, and written back via items.put; unreferenced fields are left untouched and field values are never logged. Co-Authored-By: Claude Opus 4.8 --- src/tools/index.ts | 2 + src/tools/item-edit.ts | 189 +++++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 98 ++++++++++++++++++++- 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 src/tools/item-edit.ts diff --git a/src/tools/index.ts b/src/tools/index.ts index 433e7b4..0d72ebd 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -7,6 +7,7 @@ import { registerVaultList } from "./vault-list.js"; import { registerItemLookup } from "./item-lookup.js"; import { registerItemDelete } from "./item-delete.js"; import { registerItemGet } from "./item-get.js"; +import { registerItemEdit } from "./item-edit.js"; import { registerPasswordCreate } from "./password-create.js"; import { registerPasswordRead } from "./password-read.js"; import { registerPasswordUpdate } from "./password-update.js"; @@ -19,6 +20,7 @@ export function registerAllTools(server: McpServer): void { registerItemLookup(server); registerItemDelete(server); registerItemGet(server); + registerItemEdit(server); registerPasswordCreate(server); registerPasswordRead(server); registerPasswordUpdate(server); diff --git a/src/tools/item-edit.ts b/src/tools/item-edit.ts new file mode 100644 index 0000000..81d381c --- /dev/null +++ b/src/tools/item-edit.ts @@ -0,0 +1,189 @@ +/** + * item_edit — Edit an existing 1Password item: title, notes, tags, url, and fields. + * + * The item is fetched, changes are applied immutably, and the result is written + * back via items.put. Unreferenced fields are preserved untouched. Field values + * are never logged. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + ItemFieldType, + AutofillBehavior, + type Item, + type ItemField, + type Website, +} from "@1password/sdk"; +import { getClient } from "../client.js"; +import { log, logError } from "../logger.js"; +import { jsonResult, errorResult } from "../utils.js"; + +/** A field upsert request from the caller. */ +const fieldInput = z.object({ + idOrTitle: z + .string() + .min(1) + .describe("Field id or title to match (case-insensitive). Created if absent."), + type: z + .enum(["text", "concealed"]) + .describe("Field type: 'text' for plain values, 'concealed' for secrets."), + value: z.string().describe("New value for the field."), + section: z + .string() + .optional() + .describe("Optional section id to associate the field with."), +}); + +/** Does a field match the caller-supplied id-or-title? */ +function fieldMatches(field: ItemField, idOrTitle: string): boolean { + const target = idOrTitle.toLowerCase(); + return ( + field.id?.toLowerCase() === target || + field.title?.toLowerCase() === target + ); +} + +export function registerItemEdit(server: McpServer): void { + server.tool( + "item_edit", + "Edit an existing 1Password item. Update the title, notes (pass an empty string to clear notes), tags, website URL, upsert fields, or remove fields. Only referenced fields are changed; all others are preserved.", + { + vaultId: z.string().min(1).describe("Vault ID containing the item."), + itemId: z.string().min(1).describe("Item ID to edit."), + title: z.string().min(1).optional().describe("New item title."), + notes: z + .string() + .optional() + .describe( + "Full replacement of the item's notes. Pass an empty string to clear notes.", + ), + tags: z + .array(z.string().min(1)) + .optional() + .describe("Replacement set of tags (replaces all existing tags)."), + url: z + .string() + .url() + .optional() + .describe("Replacement primary website URL for the item."), + fields: z + .array(fieldInput) + .optional() + .describe("Fields to upsert (create or update) by id or title."), + removeFields: z + .array(z.string().min(1)) + .optional() + .describe("Field ids or titles to remove from the item."), + }, + async ({ vaultId, itemId, title, notes, tags, url, fields, removeFields }) => { + try { + // NOTE: field values are intentionally excluded from logs. + log("debug", "Tool call: item_edit.", { + vaultId, + itemId, + changeTitle: title !== undefined, + changeNotes: notes !== undefined, + changeTags: tags !== undefined, + changeUrl: url !== undefined, + upsertCount: fields?.length ?? 0, + removeCount: removeFields?.length ?? 0, + }); + + const client = await getClient(); + if (!client?.items?.get) { + throw new Error( + "Your @1password/sdk version does not support getting items.", + ); + } + if (!client?.items?.put) { + throw new Error( + "Your @1password/sdk version does not support updating items.", + ); + } + + const existing: Item = await client.items.get(vaultId, itemId); + + // Build the next field list immutably, preserving unreferenced fields. + const removeSet = new Set( + (removeFields ?? []).map((value) => value.toLowerCase()), + ); + + let nextFields: ItemField[] = (existing.fields ?? []).filter( + (field) => + !removeSet.has(field.id?.toLowerCase()) && + !removeSet.has(field.title?.toLowerCase()), + ); + + for (const upsert of fields ?? []) { + const fieldType = + upsert.type === "concealed" + ? ItemFieldType.Concealed + : ItemFieldType.Text; + const index = nextFields.findIndex((field) => + fieldMatches(field, upsert.idOrTitle), + ); + + if (index >= 0) { + const current = nextFields[index]; + const replacement: ItemField = { + ...current, + fieldType, + value: upsert.value, + sectionId: upsert.section ?? current.sectionId, + }; + nextFields = nextFields.map((field, i) => + i === index ? replacement : field, + ); + } else { + nextFields = [ + ...nextFields, + { + id: upsert.idOrTitle, + title: upsert.idOrTitle, + fieldType, + value: upsert.value, + sectionId: upsert.section, + }, + ]; + } + } + + let nextWebsites: Website[] | undefined = existing.websites; + if (url !== undefined) { + nextWebsites = [ + { + url, + label: "website", + autofillBehavior: AutofillBehavior.AnywhereOnWebsite, + }, + ]; + } + + const updated: Item = { + ...existing, + title: title ?? existing.title, + notes: notes ?? existing.notes, + tags: tags ?? existing.tags, + websites: nextWebsites, + fields: nextFields, + }; + + const result: Item = await client.items.put(updated); + + return jsonResult({ + id: result.id, + title: result.title, + vaultId: result.vaultId, + category: result.category, + tags: result.tags ?? [], + fieldCount: result.fields?.length ?? 0, + updatedAt: result.updatedAt, + }); + } catch (error) { + logError("item_edit failed.", error); + return errorResult(error); + } + }, + ); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 186d6e7..4f1de42 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -49,12 +49,13 @@ describe("MCP Tools", () => { registerAllTools(server); }); - it("registers all 9 tools", () => { - expect(registeredTools.size).toBe(9); + it("registers all 10 tools", () => { + expect(registeredTools.size).toBe(10); expect(registeredTools.has("vault_list")).toBe(true); expect(registeredTools.has("item_lookup")).toBe(true); expect(registeredTools.has("item_delete")).toBe(true); expect(registeredTools.has("item_get")).toBe(true); + expect(registeredTools.has("item_edit")).toBe(true); expect(registeredTools.has("password_create")).toBe(true); expect(registeredTools.has("password_read")).toBe(true); expect(registeredTools.has("password_update")).toBe(true); @@ -323,4 +324,97 @@ describe("MCP Tools", () => { expect(result.content[0].text).toContain("Provide secretReference or both vaultId and itemId"); }); }); + + describe("item_edit", () => { + function makeClient() { + const put = vi.fn().mockImplementation((item: any) => Promise.resolve(item)); + const get = vi.fn().mockResolvedValue({ + id: "i1", + title: "Old Title", + category: "Login", + vaultId: "v1", + tags: ["old"], + notes: "old notes", + sections: [], + websites: [], + fields: [ + { id: "username", title: "username", fieldType: "Text", value: "user" }, + { id: "password", title: "password", fieldType: "Concealed", value: "old-pass" }, + { id: "legacy", title: "legacy", fieldType: "Text", value: "remove-me" }, + ], + version: 1, + files: [], + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }); + return { get, put }; + } + + it("updates title, replaces tags, and upserts/removes fields", async () => { + const { get, put } = makeClient(); + mockedGetClient.mockResolvedValue({ items: { get, put } } as any); + + const handler = registeredTools.get("item_edit")!.handler; + const result = await handler({ + vaultId: "v1", + itemId: "i1", + title: "New Title", + tags: ["new"], + fields: [ + { idOrTitle: "password", type: "concealed", value: "new-pass" }, + { idOrTitle: "api-key", type: "concealed", value: "abc123", section: "s1" }, + ], + removeFields: ["legacy"], + }); + const data = JSON.parse(result.content[0].text); + + expect(result.isError).toBeUndefined(); + const putArg = put.mock.calls[0][0]; + expect(putArg.title).toBe("New Title"); + expect(putArg.tags).toEqual(["new"]); + // unreferenced field preserved + expect(putArg.fields.find((f: any) => f.id === "username").value).toBe("user"); + // updated in place + expect(putArg.fields.find((f: any) => f.id === "password").value).toBe("new-pass"); + // new field created + const created = putArg.fields.find((f: any) => f.id === "api-key"); + expect(created.value).toBe("abc123"); + expect(created.fieldType).toBe("Concealed"); + expect(created.sectionId).toBe("s1"); + // removed + expect(putArg.fields.find((f: any) => f.id === "legacy")).toBeUndefined(); + expect(data.title).toBe("New Title"); + }); + + it("clears notes when given an empty string", async () => { + const { get, put } = makeClient(); + mockedGetClient.mockResolvedValue({ items: { get, put } } as any); + + const handler = registeredTools.get("item_edit")!.handler; + await handler({ vaultId: "v1", itemId: "i1", notes: "" }); + + expect(put.mock.calls[0][0].notes).toBe(""); + }); + + it("preserves notes when notes is omitted", async () => { + const { get, put } = makeClient(); + mockedGetClient.mockResolvedValue({ items: { get, put } } as any); + + const handler = registeredTools.get("item_edit")!.handler; + await handler({ vaultId: "v1", itemId: "i1", title: "X" }); + + expect(put.mock.calls[0][0].notes).toBe("old notes"); + }); + + it("errors when the SDK cannot update items", async () => { + mockedGetClient.mockResolvedValue({ + items: { get: vi.fn().mockResolvedValue({}) }, + } as any); + + const handler = registeredTools.get("item_edit")!.handler; + const result = await handler({ vaultId: "v1", itemId: "i1", title: "X" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("does not support updating items"); + }); + }); }); From f3c4a0602a6eb1101fd87aa1c500d1c6da457093 Mon Sep 17 00:00:00 2001 From: Albert Hazan Date: Mon, 22 Jun 2026 10:44:36 -0400 Subject: [PATCH 4/7] feat: add item_list tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List every item in a vault, returning id, title, category, tags, and updatedAt for each. Returns metadata only — no secret values are read or emitted. Also documents item_get/item_edit/item_list in the README tools table. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +++- src/tools/index.ts | 2 ++ src/tools/item-list.ts | 45 ++++++++++++++++++++++++++++++ tests/tools.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/tools/item-list.ts diff --git a/README.md b/README.md index e23d1e7..db35a4f 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,15 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io ## Features -### Tools (8) +### Tools (11) | Tool | Description | |------|-------------| | `vault_list` | List all accessible vaults | | `item_lookup` | Search items by title in a vault | +| `item_list` | List all items in a vault (id, title, category, tags, updatedAt) | +| `item_get` | Retrieve a full item (title, category, tags, notes, fields); conceals secret values unless `reveal` is true | +| `item_edit` | Edit an item's title, notes, tags, URL, and fields (upsert/remove); empty `notes` clears notes | | `item_delete` | Delete an item from a vault | | `password_create` | Create a new password/login item | | `password_read` | Retrieve a password via secret reference (`op://vault/item/field`) or vault/item ID | diff --git a/src/tools/index.ts b/src/tools/index.ts index 0d72ebd..e38a1c9 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -8,6 +8,7 @@ import { registerItemLookup } from "./item-lookup.js"; import { registerItemDelete } from "./item-delete.js"; import { registerItemGet } from "./item-get.js"; import { registerItemEdit } from "./item-edit.js"; +import { registerItemList } from "./item-list.js"; import { registerPasswordCreate } from "./password-create.js"; import { registerPasswordRead } from "./password-read.js"; import { registerPasswordUpdate } from "./password-update.js"; @@ -21,6 +22,7 @@ export function registerAllTools(server: McpServer): void { registerItemDelete(server); registerItemGet(server); registerItemEdit(server); + registerItemList(server); registerPasswordCreate(server); registerPasswordRead(server); registerPasswordUpdate(server); diff --git a/src/tools/item-list.ts b/src/tools/item-list.ts new file mode 100644 index 0000000..6f200a0 --- /dev/null +++ b/src/tools/item-list.ts @@ -0,0 +1,45 @@ +/** + * item_list — List all items in a 1Password vault (metadata only, no secrets). + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { ItemOverview } from "@1password/sdk"; +import { getClient } from "../client.js"; +import { log, logError } from "../logger.js"; +import { jsonResult, errorResult } from "../utils.js"; + +export function registerItemList(server: McpServer): void { + server.tool( + "item_list", + "List all items in a 1Password vault, returning id, title, category, tags, and updatedAt for each. Never returns secret values.", + { + vaultId: z.string().min(1).describe("Vault ID to list items from."), + }, + async ({ vaultId }) => { + try { + log("debug", "Tool call: item_list.", { vaultId }); + const client = await getClient(); + if (!client?.items?.list) { + throw new Error( + "Your @1password/sdk version does not support listing items.", + ); + } + + const items: ItemOverview[] = await client.items.list(vaultId); + const summary = (items ?? []).map((item) => ({ + id: item.id, + title: item.title, + category: item.category, + tags: item.tags ?? [], + updatedAt: item.updatedAt, + })); + + return jsonResult({ items: summary, count: summary.length }); + } catch (error) { + logError("item_list failed.", error); + return errorResult(error); + } + }, + ); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 4f1de42..1776a51 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -49,13 +49,14 @@ describe("MCP Tools", () => { registerAllTools(server); }); - it("registers all 10 tools", () => { - expect(registeredTools.size).toBe(10); + it("registers all 11 tools", () => { + expect(registeredTools.size).toBe(11); expect(registeredTools.has("vault_list")).toBe(true); expect(registeredTools.has("item_lookup")).toBe(true); expect(registeredTools.has("item_delete")).toBe(true); expect(registeredTools.has("item_get")).toBe(true); expect(registeredTools.has("item_edit")).toBe(true); + expect(registeredTools.has("item_list")).toBe(true); expect(registeredTools.has("password_create")).toBe(true); expect(registeredTools.has("password_read")).toBe(true); expect(registeredTools.has("password_update")).toBe(true); @@ -417,4 +418,62 @@ describe("MCP Tools", () => { expect(result.content[0].text).toContain("does not support updating items"); }); }); + + describe("item_list", () => { + it("returns item metadata without secret values", async () => { + mockedGetClient.mockResolvedValue({ + items: { + list: vi.fn().mockResolvedValue([ + { + id: "i1", + title: "GitHub", + category: "Login", + vaultId: "v1", + tags: ["dev"], + websites: [], + state: "active", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-02-01T00:00:00.000Z"), + }, + { + id: "i2", + title: "AWS", + category: "Password", + vaultId: "v1", + tags: [], + websites: [], + state: "active", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-03-01T00:00:00.000Z"), + }, + ]), + }, + } as any); + + const handler = registeredTools.get("item_list")!.handler; + const result = await handler({ vaultId: "v1" }); + const data = JSON.parse(result.content[0].text); + + expect(data.count).toBe(2); + expect(data.items[0]).toEqual({ + id: "i1", + title: "GitHub", + category: "Login", + tags: ["dev"], + updatedAt: "2024-02-01T00:00:00.000Z", + }); + // no value/password fields leaked + expect(JSON.stringify(data)).not.toContain("value"); + }); + + it("returns error when listing fails", async () => { + mockedGetClient.mockRejectedValue(new Error("list boom")); + + const handler = registeredTools.get("item_list")!.handler; + const result = await handler({ vaultId: "v1" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("list boom"); + }); + }); }); From 7ccb6e1c434efac37af09c734d7ce0b9cfef836e Mon Sep 17 00:00:00 2001 From: Albert Hazan Date: Mon, 22 Jun 2026 13:18:29 -0400 Subject: [PATCH 5/7] feat: add note_create tool Create a Secure Note item (ItemCategory.SecureNote) with a title, note body, optional tags, and optional custom text/concealed fields. Mirrors password_create conventions (items.create, errorResult, log/logError) and never logs field values. Closes #8. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- src/tools/index.ts | 2 + src/tools/note-create.ts | 103 +++++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 72 ++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 src/tools/note-create.ts diff --git a/README.md b/README.md index db35a4f..5f536c9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io ## Features -### Tools (11) +### Tools (12) | Tool | Description | |------|-------------| @@ -22,6 +22,7 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io | `item_get` | Retrieve a full item (title, category, tags, notes, fields); conceals secret values unless `reveal` is true | | `item_edit` | Edit an item's title, notes, tags, URL, and fields (upsert/remove); empty `notes` clears notes | | `item_delete` | Delete an item from a vault | +| `note_create` | Create a Secure Note item with optional tags and custom fields | | `password_create` | Create a new password/login item | | `password_read` | Retrieve a password via secret reference (`op://vault/item/field`) or vault/item ID | | `password_update` | Rotate/update an existing password | diff --git a/src/tools/index.ts b/src/tools/index.ts index e38a1c9..1e480a2 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ import { registerItemDelete } from "./item-delete.js"; import { registerItemGet } from "./item-get.js"; import { registerItemEdit } from "./item-edit.js"; import { registerItemList } from "./item-list.js"; +import { registerNoteCreate } from "./note-create.js"; import { registerPasswordCreate } from "./password-create.js"; import { registerPasswordRead } from "./password-read.js"; import { registerPasswordUpdate } from "./password-update.js"; @@ -23,6 +24,7 @@ export function registerAllTools(server: McpServer): void { registerItemGet(server); registerItemEdit(server); registerItemList(server); + registerNoteCreate(server); registerPasswordCreate(server); registerPasswordRead(server); registerPasswordUpdate(server); diff --git a/src/tools/note-create.ts b/src/tools/note-create.ts new file mode 100644 index 0000000..b66a743 --- /dev/null +++ b/src/tools/note-create.ts @@ -0,0 +1,103 @@ +/** + * note_create — Create a Secure Note item in a 1Password vault. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + ItemCategory, + ItemFieldType, + type ItemCreateParams, + type ItemField, +} from "@1password/sdk"; +import { getClient } from "../client.js"; +import { log, logError } from "../logger.js"; +import { jsonResult, errorResult } from "../utils.js"; + +/** An optional custom field to attach to the note. */ +const fieldInput = z.object({ + idOrTitle: z + .string() + .min(1) + .describe("Field id and title to create."), + type: z + .enum(["text", "concealed"]) + .describe("Field type: 'text' for plain values, 'concealed' for secrets."), + value: z.string().describe("Field value."), + section: z + .string() + .optional() + .describe("Optional section id to associate the field with."), +}); + +export function registerNoteCreate(server: McpServer): void { + server.tool( + "note_create", + "Create a Secure Note item in a 1Password vault, with optional tags and custom fields.", + { + vaultId: z.string().min(1).describe("Vault ID to create the note in."), + title: z.string().min(1).describe("Note title."), + notes: z + .string() + .describe("Note body text. Pass an empty string for an empty note."), + tags: z + .array(z.string().min(1)) + .optional() + .describe("Optional tags for organization."), + fields: z + .array(fieldInput) + .optional() + .describe("Optional custom fields to attach to the note."), + }, + async ({ vaultId, title, notes, tags, fields }) => { + try { + // NOTE: field values are intentionally excluded from logs. + log("debug", "Tool call: note_create.", { + vaultId, + title, + fieldCount: fields?.length ?? 0, + }); + const client = await getClient(); + if (!client?.items?.create) { + throw new Error( + "Your @1password/sdk version does not support creating items.", + ); + } + + const itemFields: ItemField[] = (fields ?? []).map((field) => ({ + id: field.idOrTitle, + title: field.idOrTitle, + fieldType: + field.type === "concealed" + ? ItemFieldType.Concealed + : ItemFieldType.Text, + value: field.value, + sectionId: field.section, + })); + + const params: ItemCreateParams = { + category: ItemCategory.SecureNote, + vaultId, + title, + notes, + tags, + fields: itemFields.length > 0 ? itemFields : undefined, + }; + + const item = await client.items.create(params); + + return jsonResult({ + id: item.id, + title: item.title, + vaultId: item.vaultId, + category: item.category, + tags: item.tags ?? [], + fieldCount: item.fields?.length ?? 0, + }); + } catch (error) { + logError("note_create failed.", error); + return errorResult(error); + } + }, + ); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 1776a51..d0de8d8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -49,14 +49,15 @@ describe("MCP Tools", () => { registerAllTools(server); }); - it("registers all 11 tools", () => { - expect(registeredTools.size).toBe(11); + it("registers all 12 tools", () => { + expect(registeredTools.size).toBe(12); expect(registeredTools.has("vault_list")).toBe(true); expect(registeredTools.has("item_lookup")).toBe(true); expect(registeredTools.has("item_delete")).toBe(true); expect(registeredTools.has("item_get")).toBe(true); expect(registeredTools.has("item_edit")).toBe(true); expect(registeredTools.has("item_list")).toBe(true); + expect(registeredTools.has("note_create")).toBe(true); expect(registeredTools.has("password_create")).toBe(true); expect(registeredTools.has("password_read")).toBe(true); expect(registeredTools.has("password_update")).toBe(true); @@ -476,4 +477,71 @@ describe("MCP Tools", () => { expect(result.content[0].text).toContain("list boom"); }); }); + + describe("note_create", () => { + it("creates a SecureNote item with mapped fields", async () => { + const create = vi.fn().mockImplementation((params: any) => + Promise.resolve({ + id: "n1", + title: params.title, + vaultId: params.vaultId, + category: params.category, + tags: params.tags ?? [], + fields: params.fields ?? [], + }), + ); + mockedGetClient.mockResolvedValue({ items: { create } } as any); + + const handler = registeredTools.get("note_create")!.handler; + const result = await handler({ + vaultId: "v1", + title: "Recovery codes", + notes: "keep safe", + tags: ["backup"], + fields: [{ idOrTitle: "code", type: "concealed", value: "abc-123" }], + }); + const data = JSON.parse(result.content[0].text); + + expect(result.isError).toBeUndefined(); + const params = create.mock.calls[0][0]; + expect(params.category).toBe("SecureNote"); + expect(params.notes).toBe("keep safe"); + expect(params.fields[0]).toEqual({ + id: "code", + title: "code", + fieldType: "Concealed", + value: "abc-123", + sectionId: undefined, + }); + expect(data.id).toBe("n1"); + expect(data.category).toBe("SecureNote"); + }); + + it("omits fields when none are provided", async () => { + const create = vi.fn().mockResolvedValue({ + id: "n2", + title: "Plain", + vaultId: "v1", + category: "SecureNote", + tags: [], + fields: [], + }); + mockedGetClient.mockResolvedValue({ items: { create } } as any); + + const handler = registeredTools.get("note_create")!.handler; + await handler({ vaultId: "v1", title: "Plain", notes: "" }); + + expect(create.mock.calls[0][0].fields).toBeUndefined(); + }); + + it("errors when the SDK cannot create items", async () => { + mockedGetClient.mockResolvedValue({ items: {} } as any); + + const handler = registeredTools.get("note_create")!.handler; + const result = await handler({ vaultId: "v1", title: "X", notes: "y" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("does not support creating items"); + }); + }); }); From f61a1749c66274313e071f6b3a41ec07570f3b3d Mon Sep 17 00:00:00 2001 From: Albert Hazan Date: Mon, 22 Jun 2026 13:19:40 -0400 Subject: [PATCH 6/7] feat: add item_archive tool Archive an item via the SDK's items.archive(vaultId, itemId), moving it to the archive instead of permanently deleting it. Guards with a capability check (like item_delete) so older SDKs return a clean errorResult. Closes #9. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 ++- src/tools/index.ts | 2 ++ src/tools/item-archive.ts | 36 ++++++++++++++++++++++++++++++++++++ tests/tools.test.ts | 29 +++++++++++++++++++++++++++-- 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/tools/item-archive.ts diff --git a/README.md b/README.md index 5f536c9..ae68fbb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io ## Features -### Tools (12) +### Tools (13) | Tool | Description | |------|-------------| @@ -22,6 +22,7 @@ A community-built [Model Context Protocol (MCP)](https://modelcontextprotocol.io | `item_get` | Retrieve a full item (title, category, tags, notes, fields); conceals secret values unless `reveal` is true | | `item_edit` | Edit an item's title, notes, tags, URL, and fields (upsert/remove); empty `notes` clears notes | | `item_delete` | Delete an item from a vault | +| `item_archive` | Archive an item (move to archive instead of permanently deleting) | | `note_create` | Create a Secure Note item with optional tags and custom fields | | `password_create` | Create a new password/login item | | `password_read` | Retrieve a password via secret reference (`op://vault/item/field`) or vault/item ID | diff --git a/src/tools/index.ts b/src/tools/index.ts index 1e480a2..4cc0f1f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ import { registerItemDelete } from "./item-delete.js"; import { registerItemGet } from "./item-get.js"; import { registerItemEdit } from "./item-edit.js"; import { registerItemList } from "./item-list.js"; +import { registerItemArchive } from "./item-archive.js"; import { registerNoteCreate } from "./note-create.js"; import { registerPasswordCreate } from "./password-create.js"; import { registerPasswordRead } from "./password-read.js"; @@ -24,6 +25,7 @@ export function registerAllTools(server: McpServer): void { registerItemGet(server); registerItemEdit(server); registerItemList(server); + registerItemArchive(server); registerNoteCreate(server); registerPasswordCreate(server); registerPasswordRead(server); diff --git a/src/tools/item-archive.ts b/src/tools/item-archive.ts new file mode 100644 index 0000000..269927e --- /dev/null +++ b/src/tools/item-archive.ts @@ -0,0 +1,36 @@ +/** + * item_archive — Archive an item in a 1Password vault (instead of hard-deleting). + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getClient } from "../client.js"; +import { log, logError } from "../logger.js"; +import { jsonResult, errorResult } from "../utils.js"; + +export function registerItemArchive(server: McpServer): void { + server.tool( + "item_archive", + "Archive an item in a 1Password vault. The item is moved to the archive and hidden from regular views, rather than being permanently deleted.", + { + vaultId: z.string().min(1).describe("Vault ID containing the item."), + itemId: z.string().min(1).describe("Item ID to archive."), + }, + async ({ vaultId, itemId }) => { + try { + log("debug", "Tool call: item_archive.", { vaultId, itemId }); + const client = await getClient(); + if (!client?.items?.archive) { + throw new Error( + "Your @1password/sdk version does not support archiving items.", + ); + } + await client.items.archive(vaultId, itemId); + return jsonResult({ archived: true, vaultId, itemId }); + } catch (error) { + logError("item_archive failed.", error); + return errorResult(error); + } + }, + ); +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index d0de8d8..2171f75 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -49,14 +49,15 @@ describe("MCP Tools", () => { registerAllTools(server); }); - it("registers all 12 tools", () => { - expect(registeredTools.size).toBe(12); + it("registers all 13 tools", () => { + expect(registeredTools.size).toBe(13); expect(registeredTools.has("vault_list")).toBe(true); expect(registeredTools.has("item_lookup")).toBe(true); expect(registeredTools.has("item_delete")).toBe(true); expect(registeredTools.has("item_get")).toBe(true); expect(registeredTools.has("item_edit")).toBe(true); expect(registeredTools.has("item_list")).toBe(true); + expect(registeredTools.has("item_archive")).toBe(true); expect(registeredTools.has("note_create")).toBe(true); expect(registeredTools.has("password_create")).toBe(true); expect(registeredTools.has("password_read")).toBe(true); @@ -544,4 +545,28 @@ describe("MCP Tools", () => { expect(result.content[0].text).toContain("does not support creating items"); }); }); + + describe("item_archive", () => { + it("archives an item and reports success", async () => { + const archive = vi.fn().mockResolvedValue(undefined); + mockedGetClient.mockResolvedValue({ items: { archive } } as any); + + const handler = registeredTools.get("item_archive")!.handler; + const result = await handler({ vaultId: "v1", itemId: "i1" }); + const data = JSON.parse(result.content[0].text); + + expect(archive).toHaveBeenCalledWith("v1", "i1"); + expect(data).toEqual({ archived: true, vaultId: "v1", itemId: "i1" }); + }); + + it("errors when the SDK cannot archive items", async () => { + mockedGetClient.mockResolvedValue({ items: {} } as any); + + const handler = registeredTools.get("item_archive")!.handler; + const result = await handler({ vaultId: "v1", itemId: "i1" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("does not support archiving items"); + }); + }); }); From e0025b3c30eccd8d9ad8865f147d460991a5190e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:44:23 +0000 Subject: [PATCH 7/7] fix: address PR review feedback --- src/tools/item-get.ts | 4 ++-- src/tools/note-create.ts | 2 +- tests/tools.test.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/tools/item-get.ts b/src/tools/item-get.ts index bc85adc..502138c 100644 --- a/src/tools/item-get.ts +++ b/src/tools/item-get.ts @@ -40,13 +40,13 @@ function summarizeField( export function registerItemGet(server: McpServer): void { server.tool( "item_get", - "Retrieve a full 1Password item — title, category, tags, notes, and all fields (id, title, type, section). Concealed field values are hidden unless reveal is true. Accepts a secret reference (op://vault/item) or vault ID + item ID.", + "Retrieve a full 1Password item — title, category, tags, notes, and all fields (id, title, type, section). Concealed field values are hidden unless reveal is true. Accepts a secret reference (op://vault/item/field) or vault ID + item ID.", { secretReference: z .string() .optional() .describe( - "Secret reference in op://vault/item format. If provided, vaultId and itemId are ignored.", + "Secret reference in op://vault/item/field format. If provided, vaultId and itemId are ignored.", ), vaultId: z .string() diff --git a/src/tools/note-create.ts b/src/tools/note-create.ts index b66a743..157a72d 100644 --- a/src/tools/note-create.ts +++ b/src/tools/note-create.ts @@ -19,7 +19,7 @@ const fieldInput = z.object({ idOrTitle: z .string() .min(1) - .describe("Field id and title to create."), + .describe("Field id or title to create."), type: z .enum(["text", "concealed"]) .describe("Field type: 'text' for plain values, 'concealed' for secrets."), diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 2171f75..3d9e60b 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -74,6 +74,22 @@ describe("MCP Tools", () => { } }); + it("documents item_get secret references with a field segment", () => { + const itemGet = registeredTools.get("item_get")!; + + expect(itemGet.description).toContain("op://vault/item/field"); + expect(itemGet.schema.secretReference.description).toContain( + "op://vault/item/field", + ); + }); + + it("documents note_create custom fields as id or title", () => { + const noteCreate = registeredTools.get("note_create")!; + const fieldInput = noteCreate.schema.fields.unwrap().element; + + expect(fieldInput.shape.idOrTitle.description).toBe("Field id or title to create."); + }); + describe("vault_list", () => { it("returns vault summaries", async () => { mockedGetClient.mockResolvedValue({