From 8ddbcf0d44bebf426586ddedcc73d936bb8aeafc Mon Sep 17 00:00:00 2001 From: Nick Ponomar Date: Wed, 8 Jul 2026 13:50:41 +0200 Subject: [PATCH 1/3] Add document notes tools (create/list/delete) Adds MCP tools for Paperless document notes, backed by the /api/documents/{id}/notes/ endpoint: create_document_note, list_document_notes, and delete_document_note. Notes are the natural place for an audit trail or progress notes on a document. Closes #124 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014EoK17h8dBErKg9cwNFoLn --- .changeset/document-notes-tools.md | 5 + README.md | 42 +++++++ src/api/PaperlessAPI.ts | 19 ++++ src/server.ts | 2 + src/tools/notes.test.ts | 172 +++++++++++++++++++++++++++++ src/tools/notes.ts | 68 ++++++++++++ 6 files changed, 308 insertions(+) create mode 100644 .changeset/document-notes-tools.md create mode 100644 src/tools/notes.test.ts create mode 100644 src/tools/notes.ts diff --git a/.changeset/document-notes-tools.md b/.changeset/document-notes-tools.md new file mode 100644 index 0000000..53c7dd3 --- /dev/null +++ b/.changeset/document-notes-tools.md @@ -0,0 +1,5 @@ +--- +"@baruchiro/paperless-mcp": minor +--- + +Add MCP tools for document notes: `create_document_note`, `list_document_notes`, and `delete_document_note` (backed by the Paperless `/api/documents/{id}/notes/` endpoint). Notes are the natural place for an audit trail or progress notes on a document. diff --git a/README.md b/README.md index f9c69cd..03d2034 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,48 @@ post_document({ }) ``` +### Document Notes + +#### list_document_notes +List all notes attached to a document. + +Parameters: +- id: Document ID + +```typescript +list_document_notes({ + id: 123 +}) +``` + +#### create_document_note +Add a note to a document. Returns the document's full list of notes. + +Parameters: +- id: Document ID +- note: The note text to add + +```typescript +create_document_note({ + id: 123, + note: "Invoice paid on 2026-06-30 from Commerzbank account." +}) +``` + +#### delete_document_note +Delete a single note from a document by its note ID. + +Parameters: +- id: Document ID +- note_id: The ID of the note to delete + +```typescript +delete_document_note({ + id: 123, + note_id: 5 +}) +``` + ### Tag Operations #### list_tags diff --git a/src/api/PaperlessAPI.ts b/src/api/PaperlessAPI.ts index dcce817..b4c9b1a 100644 --- a/src/api/PaperlessAPI.ts +++ b/src/api/PaperlessAPI.ts @@ -16,6 +16,7 @@ import { MailAccount, MailRule, GetTagsResponse, + Note, Tag, } from "./types"; import { headersToObject } from "./utils"; @@ -220,6 +221,24 @@ export class PaperlessAPI { return response; } + // Document note operations + async getDocumentNotes(id: number): Promise { + return this.request(`/documents/${id}/notes/`); + } + + async createDocumentNote(id: number, note: string): Promise { + return this.request(`/documents/${id}/notes/`, { + method: "POST", + body: JSON.stringify({ note }), + }); + } + + async deleteDocumentNote(id: number, noteId: number): Promise { + return this.request(`/documents/${id}/notes/?id=${noteId}`, { + method: "DELETE", + }); + } + // Tag operations async getTags(): Promise { return this.request("/tags/"); diff --git a/src/server.ts b/src/server.ts index 81931e2..cc43af5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { registerCustomFieldTools } from "./tools/customFields"; import { registerDocumentTools } from "./tools/documents"; import { registerDocumentTypeTools } from "./tools/documentTypes"; import { registerMailTools } from "./tools/mail"; +import { registerNoteTools } from "./tools/notes"; import { registerTagTools } from "./tools/tags"; export interface CreateMcpServerOptions { @@ -29,6 +30,7 @@ export function createMcpServer({ ); registerDocumentTools(server, api); registerDocumentResources(server, api); + registerNoteTools(server, api); registerTagTools(server, api); registerCorrespondentTools(server, api); registerDocumentTypeTools(server, api); diff --git a/src/tools/notes.test.ts b/src/tools/notes.test.ts new file mode 100644 index 0000000..a1634e4 --- /dev/null +++ b/src/tools/notes.test.ts @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { test, describe } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; +import type { CallToolResult, JSONRPCMessage } from "@modelcontextprotocol/sdk/types"; +import { PaperlessAPI } from "../api/PaperlessAPI"; +import { Note } from "../api/types"; +import { registerNoteTools } from "./notes"; + +class TestTransport implements Transport { + peer?: TestTransport; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + async start(): Promise {} + + async send(message: JSONRPCMessage): Promise { + queueMicrotask(() => this.peer?.onmessage?.(message)); + } + + async close(): Promise { + this.onclose?.(); + } +} + +function createTransportPair() { + const clientTransport = new TestTransport(); + const serverTransport = new TestTransport(); + clientTransport.peer = serverTransport; + serverTransport.peer = clientTransport; + return { clientTransport, serverTransport }; +} + +function parseToolText(result: CallToolResult) { + const item = result.content?.[0]; + if (!item || item.type !== "text") { + throw new Error("Expected text tool response"); + } + return JSON.parse(item.text); +} + +interface NoteApiCalls { + getDocumentNotes: number[]; + createDocumentNote: Array<[number, string]>; + deleteDocumentNote: Array<[number, number]>; +} + +function createNoteApi(notes: Note[] = []) { + const calls: NoteApiCalls = { + getDocumentNotes: [], + createDocumentNote: [], + deleteDocumentNote: [], + }; + const api = { + getDocumentNotes: async (id: number) => { + calls.getDocumentNotes.push(id); + return notes; + }, + createDocumentNote: async (id: number, note: string) => { + calls.createDocumentNote.push([id, note]); + return notes; + }, + deleteDocumentNote: async (id: number, noteId: number) => { + calls.deleteDocumentNote.push([id, noteId]); + return notes; + }, + } as unknown as PaperlessAPI; + return { api, calls }; +} + +async function withNoteClient( + api: PaperlessAPI, + run: (client: Client) => Promise +) { + const server = new McpServer({ name: "paperless-note-test", version: "1.0.0" }); + registerNoteTools(server, api); + + const client = new Client({ + name: "paperless-note-test-client", + version: "1.0.0", + }); + const { clientTransport, serverTransport } = createTransportPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + await run(client); + } finally { + await client.close(); + await server.close(); + } +} + +describe("document note tools", () => { + const sampleNotes: Note[] = [ + { + id: 5, + note: "Paid 2026-06-30", + created: "2026-06-30T10:00:00Z", + user: { id: 3, username: "nick" }, + }, + ]; + + test("create_document_note posts the note text to the document", async () => { + const { api, calls } = createNoteApi(sampleNotes); + + await withNoteClient(api, async (client) => { + const result = (await client.callTool({ + name: "create_document_note", + arguments: { id: 1740, note: "Antwort an Finanzamt versendet" }, + })) as CallToolResult; + assert.ok(!result.isError, parseToolText(result)?.error); + assert.deepEqual(parseToolText(result), sampleNotes); + }); + + assert.deepEqual(calls.createDocumentNote, [ + [1740, "Antwort an Finanzamt versendet"], + ]); + }); + + test("list_document_notes fetches notes for the document", async () => { + const { api, calls } = createNoteApi(sampleNotes); + + await withNoteClient(api, async (client) => { + const result = (await client.callTool({ + name: "list_document_notes", + arguments: { id: 42 }, + })) as CallToolResult; + assert.ok(!result.isError, parseToolText(result)?.error); + assert.deepEqual(parseToolText(result), sampleNotes); + }); + + assert.deepEqual(calls.getDocumentNotes, [42]); + }); + + test("delete_document_note removes a note by its note ID", async () => { + const { api, calls } = createNoteApi([]); + + await withNoteClient(api, async (client) => { + const result = (await client.callTool({ + name: "delete_document_note", + arguments: { id: 42, note_id: 5 }, + })) as CallToolResult; + assert.ok(!result.isError, parseToolText(result)?.error); + }); + + assert.deepEqual(calls.deleteDocumentNote, [[42, 5]]); + }); + + test("create_document_note rejects an empty note", async () => { + const { api, calls } = createNoteApi(sampleNotes); + + await withNoteClient(api, async (client) => { + await assert.rejects( + client.callTool({ + name: "create_document_note", + arguments: { id: 1, note: "" }, + }), + "expected a validation error for an empty note" + ); + }); + + assert.equal( + calls.createDocumentNote.length, + 0, + "no note should be created when validation fails" + ); + }); +}); diff --git a/src/tools/notes.ts b/src/tools/notes.ts new file mode 100644 index 0000000..b70af74 --- /dev/null +++ b/src/tools/notes.ts @@ -0,0 +1,68 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; +import { z } from "zod"; +import { PaperlessAPI } from "../api/PaperlessAPI"; +import { withErrorHandling } from "./utils/middlewares"; + +export function registerNoteTools(server: McpServer, api: PaperlessAPI) { + server.tool( + "list_document_notes", + "List all notes attached to a document. Notes are free-text comments on a document and are the natural place for an audit trail (e.g. \"invoice paid on X from account Y\") or progress notes on an action item.", + { + id: z.number().describe("The document ID"), + }, + withErrorHandling(async (args) => { + if (!api) throw new Error("Please configure API connection first"); + const notes = await api.getDocumentNotes(args.id); + return { + content: [ + { + type: "text", + text: JSON.stringify(notes), + }, + ], + }; + }) + ); + + server.tool( + "create_document_note", + "Add a note to a document. Use this to record an audit trail or progress note directly on the document. Returns the document's full list of notes after the note is added.", + { + id: z.number().describe("The document ID"), + note: z.string().min(1).describe("The note text to add"), + }, + withErrorHandling(async (args) => { + if (!api) throw new Error("Please configure API connection first"); + const notes = await api.createDocumentNote(args.id, args.note); + return { + content: [ + { + type: "text", + text: JSON.stringify(notes), + }, + ], + }; + }) + ); + + server.tool( + "delete_document_note", + "Delete a single note from a document by its note ID. Returns the document's remaining notes.", + { + id: z.number().describe("The document ID"), + note_id: z.number().describe("The ID of the note to delete"), + }, + withErrorHandling(async (args) => { + if (!api) throw new Error("Please configure API connection first"); + const notes = await api.deleteDocumentNote(args.id, args.note_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(notes), + }, + ], + }; + }) + ); +} From 417fa49d05b4f482a379840bb4c66d98f4114d0e Mon Sep 17 00:00:00 2001 From: Nick Ponomar Date: Wed, 8 Jul 2026 15:03:44 +0200 Subject: [PATCH 2/3] Address review: confirm on delete, JSDoc, DRY handlers, robust test - delete_document_note now requires confirm: true and warns it is irreversible (matches the other destructive tools). - Add JSDoc to the three new PaperlessAPI note methods. - Extract a shared notesResult() helper to de-duplicate the tool response construction. - Make the empty-note test accept either a protocol rejection or an isError result (the pinned MCP SDK rejects zod violations with -32602; newer SDKs return isError), and add a delete-without-confirm test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014EoK17h8dBErKg9cwNFoLn --- README.md | 6 +++-- src/api/PaperlessAPI.ts | 18 ++++++++++++++ src/tools/notes.test.ts | 42 ++++++++++++++++++++++++++++----- src/tools/notes.ts | 52 +++++++++++++++++++---------------------- 4 files changed, 82 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 03d2034..39be090 100644 --- a/README.md +++ b/README.md @@ -320,16 +320,18 @@ create_document_note({ ``` #### delete_document_note -Delete a single note from a document by its note ID. +⚠️ Delete a single note from a document by its note ID. This operation is irreversible. Parameters: - id: Document ID - note_id: The ID of the note to delete +- confirm: Must be `true` to confirm this destructive operation ```typescript delete_document_note({ id: 123, - note_id: 5 + note_id: 5, + confirm: true }) ``` diff --git a/src/api/PaperlessAPI.ts b/src/api/PaperlessAPI.ts index b4c9b1a..21056ef 100644 --- a/src/api/PaperlessAPI.ts +++ b/src/api/PaperlessAPI.ts @@ -222,10 +222,22 @@ export class PaperlessAPI { } // Document note operations + + /** + * Retrieve all notes attached to a document. + * @param id - The document ID. + * @returns The document's notes. + */ async getDocumentNotes(id: number): Promise { return this.request(`/documents/${id}/notes/`); } + /** + * Create a note on a document. + * @param id - The document ID. + * @param note - The note text to add. + * @returns The document's full notes list after creation. + */ async createDocumentNote(id: number, note: string): Promise { return this.request(`/documents/${id}/notes/`, { method: "POST", @@ -233,6 +245,12 @@ export class PaperlessAPI { }); } + /** + * Delete a note from a document by its note ID. + * @param id - The document ID. + * @param noteId - The ID of the note to delete. + * @returns The document's remaining notes after deletion. + */ async deleteDocumentNote(id: number, noteId: number): Promise { return this.request(`/documents/${id}/notes/?id=${noteId}`, { method: "DELETE", diff --git a/src/tools/notes.test.ts b/src/tools/notes.test.ts index a1634e4..d958461 100644 --- a/src/tools/notes.test.ts +++ b/src/tools/notes.test.ts @@ -136,13 +136,13 @@ describe("document note tools", () => { assert.deepEqual(calls.getDocumentNotes, [42]); }); - test("delete_document_note removes a note by its note ID", async () => { + test("delete_document_note removes a note by its note ID when confirmed", async () => { const { api, calls } = createNoteApi([]); await withNoteClient(api, async (client) => { const result = (await client.callTool({ name: "delete_document_note", - arguments: { id: 42, note_id: 5 }, + arguments: { id: 42, note_id: 5, confirm: true }, })) as CallToolResult; assert.ok(!result.isError, parseToolText(result)?.error); }); @@ -150,16 +150,46 @@ describe("document note tools", () => { assert.deepEqual(calls.deleteDocumentNote, [[42, 5]]); }); + test("delete_document_note refuses to delete without confirmation", async () => { + const { api, calls } = createNoteApi([]); + + await withNoteClient(api, async (client) => { + const result = (await client.callTool({ + name: "delete_document_note", + arguments: { id: 42, note_id: 5, confirm: false }, + })) as CallToolResult; + assert.ok(result.isError, "expected an error when confirm is false"); + assert.match(parseToolText(result)?.error ?? "", /confirm/i); + }); + + assert.equal( + calls.deleteDocumentNote.length, + 0, + "no delete should be sent without confirmation" + ); + }); + test("create_document_note rejects an empty note", async () => { const { api, calls } = createNoteApi(sampleNotes); await withNoteClient(api, async (client) => { - await assert.rejects( - client.callTool({ + // Depending on the installed MCP SDK version, an input-schema (zod) + // violation either rejects with a protocol error (-32602) or resolves + // with a CallToolResult carrying isError: true. Accept both so the test + // stays correct across SDK versions. + let rejected = false; + let result: CallToolResult | undefined; + try { + result = (await client.callTool({ name: "create_document_note", arguments: { id: 1, note: "" }, - }), - "expected a validation error for an empty note" + })) as CallToolResult; + } catch { + rejected = true; + } + assert.ok( + rejected || result?.isError, + "expected an empty note to be rejected" ); }); diff --git a/src/tools/notes.ts b/src/tools/notes.ts index b70af74..2a1a766 100644 --- a/src/tools/notes.ts +++ b/src/tools/notes.ts @@ -1,8 +1,20 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; import { z } from "zod"; import { PaperlessAPI } from "../api/PaperlessAPI"; +import { Note } from "../api/types"; import { withErrorHandling } from "./utils/middlewares"; +function notesResult(notes: Note[]) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(notes), + }, + ], + }; +} + export function registerNoteTools(server: McpServer, api: PaperlessAPI) { server.tool( "list_document_notes", @@ -12,15 +24,7 @@ export function registerNoteTools(server: McpServer, api: PaperlessAPI) { }, withErrorHandling(async (args) => { if (!api) throw new Error("Please configure API connection first"); - const notes = await api.getDocumentNotes(args.id); - return { - content: [ - { - type: "text", - text: JSON.stringify(notes), - }, - ], - }; + return notesResult(await api.getDocumentNotes(args.id)); }) ); @@ -33,36 +37,28 @@ export function registerNoteTools(server: McpServer, api: PaperlessAPI) { }, withErrorHandling(async (args) => { if (!api) throw new Error("Please configure API connection first"); - const notes = await api.createDocumentNote(args.id, args.note); - return { - content: [ - { - type: "text", - text: JSON.stringify(notes), - }, - ], - }; + return notesResult(await api.createDocumentNote(args.id, args.note)); }) ); server.tool( "delete_document_note", - "Delete a single note from a document by its note ID. Returns the document's remaining notes.", + "⚠️ DESTRUCTIVE: Permanently delete a single note from a document by its note ID. This operation is irreversible. Returns the document's remaining notes.", { id: z.number().describe("The document ID"), note_id: z.number().describe("The ID of the note to delete"), + confirm: z + .boolean() + .describe("Must be true to confirm this destructive operation"), }, withErrorHandling(async (args) => { if (!api) throw new Error("Please configure API connection first"); - const notes = await api.deleteDocumentNote(args.id, args.note_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(notes), - }, - ], - }; + if (!args.confirm) { + throw new Error( + "Confirmation required for destructive operation. Set confirm: true to proceed." + ); + } + return notesResult(await api.deleteDocumentNote(args.id, args.note_id)); }) ); } From 67acb951117e4d0e9609dc35e766bad7d2c9f90f Mon Sep 17 00:00:00 2001 From: Nick Ponomar Date: Wed, 8 Jul 2026 19:06:42 +0200 Subject: [PATCH 3/3] Rename note method param id -> documentId per review Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014EoK17h8dBErKg9cwNFoLn --- src/api/PaperlessAPI.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/api/PaperlessAPI.ts b/src/api/PaperlessAPI.ts index 21056ef..9a40fd7 100644 --- a/src/api/PaperlessAPI.ts +++ b/src/api/PaperlessAPI.ts @@ -225,21 +225,21 @@ export class PaperlessAPI { /** * Retrieve all notes attached to a document. - * @param id - The document ID. + * @param documentId - The document ID. * @returns The document's notes. */ - async getDocumentNotes(id: number): Promise { - return this.request(`/documents/${id}/notes/`); + async getDocumentNotes(documentId: number): Promise { + return this.request(`/documents/${documentId}/notes/`); } /** * Create a note on a document. - * @param id - The document ID. + * @param documentId - The document ID. * @param note - The note text to add. * @returns The document's full notes list after creation. */ - async createDocumentNote(id: number, note: string): Promise { - return this.request(`/documents/${id}/notes/`, { + async createDocumentNote(documentId: number, note: string): Promise { + return this.request(`/documents/${documentId}/notes/`, { method: "POST", body: JSON.stringify({ note }), }); @@ -247,12 +247,12 @@ export class PaperlessAPI { /** * Delete a note from a document by its note ID. - * @param id - The document ID. + * @param documentId - The document ID. * @param noteId - The ID of the note to delete. * @returns The document's remaining notes after deletion. */ - async deleteDocumentNote(id: number, noteId: number): Promise { - return this.request(`/documents/${id}/notes/?id=${noteId}`, { + async deleteDocumentNote(documentId: number, noteId: number): Promise { + return this.request(`/documents/${documentId}/notes/?id=${noteId}`, { method: "DELETE", }); }