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..39be090 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,50 @@ 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. 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, + confirm: true +}) +``` + ### Tag Operations #### list_tags diff --git a/src/api/PaperlessAPI.ts b/src/api/PaperlessAPI.ts index dcce817..9a40fd7 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,42 @@ export class PaperlessAPI { return response; } + // Document note operations + + /** + * Retrieve all notes attached to a document. + * @param documentId - The document ID. + * @returns The document's notes. + */ + async getDocumentNotes(documentId: number): Promise { + return this.request(`/documents/${documentId}/notes/`); + } + + /** + * Create a note on a document. + * @param documentId - The document ID. + * @param note - The note text to add. + * @returns The document's full notes list after creation. + */ + async createDocumentNote(documentId: number, note: string): Promise { + return this.request(`/documents/${documentId}/notes/`, { + method: "POST", + body: JSON.stringify({ note }), + }); + } + + /** + * Delete a note from a document by its note 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(documentId: number, noteId: number): Promise { + return this.request(`/documents/${documentId}/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..d958461 --- /dev/null +++ b/src/tools/notes.test.ts @@ -0,0 +1,202 @@ +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 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, confirm: true }, + })) as CallToolResult; + assert.ok(!result.isError, parseToolText(result)?.error); + }); + + 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) => { + // 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: "" }, + })) as CallToolResult; + } catch { + rejected = true; + } + assert.ok( + rejected || result?.isError, + "expected an empty note to be rejected" + ); + }); + + 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..2a1a766 --- /dev/null +++ b/src/tools/notes.ts @@ -0,0 +1,64 @@ +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", + "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"); + return notesResult(await api.getDocumentNotes(args.id)); + }) + ); + + 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"); + return notesResult(await api.createDocumentNote(args.id, args.note)); + }) + ); + + server.tool( + "delete_document_note", + "⚠️ 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"); + 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)); + }) + ); +}