From 93caace19bba09be0fc666228844e313ac3566d7 Mon Sep 17 00:00:00 2001 From: Florian Henze Date: Tue, 26 May 2026 01:43:28 +0200 Subject: [PATCH 1/3] feat: add get_tag tool Adds a get_tag detail-getter to bring tags in line with correspondents, document_types and custom_fields, all of which already expose both list_* and get_* variants. Without get_tag, agents have to fall back to list_tags(name__iexact=...) and the asymmetry shows up as a foot-gun (agents try get_tag first and fail). The tool returns the same Tag payload as the other detail-getters, with matching_algorithm enhanced from a numeric id to {id, name} via the existing enhanceMatchingAlgorithm helper. E2E coverage in e2e.test.ts asserts that a freshly-created tag is returned by get_tag with id, name, non-empty slug, and the expanded matching_algorithm shape. --- e2e/e2e.test.ts | 30 ++++++++++++++++++++++++++++++ src/api/PaperlessAPI.ts | 4 ++++ src/tools/tags.ts | 14 ++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/e2e/e2e.test.ts b/e2e/e2e.test.ts index 060b2d1..f28b888 100644 --- a/e2e/e2e.test.ts +++ b/e2e/e2e.test.ts @@ -167,6 +167,35 @@ describe("Paperless MCP E2E scenario", () => { assert.strictEqual(found.name, RUN_TAG); }); + it("get_tag returns the tag by ID with full detail fields", async () => { + assert.ok(state.tagId, "tag must be created before get_tag"); + const result = (await client.callTool({ + name: "get_tag", + arguments: { id: state.tagId }, + })) as ToolResult; + assertOk(result, "get_tag"); + const tag = parseToolText(result) as { + id: number; + name: string; + slug: string; + matching_algorithm: { id: number; name: string }; + }; + assert.strictEqual(tag.id, state.tagId); + assert.strictEqual(tag.name, RUN_TAG); + assert.ok( + typeof tag.slug === "string" && tag.slug.length > 0, + `slug should be a non-empty string, got ${JSON.stringify(tag.slug)}` + ); + assert.ok( + tag.matching_algorithm && + typeof tag.matching_algorithm === "object" && + typeof tag.matching_algorithm.name === "string", + `matching_algorithm should be expanded to {id,name}, got ${JSON.stringify( + tag.matching_algorithm + )}` + ); + }); + it("list_correspondents returns the correspondent created earlier in this run", async () => { assert.ok(state.correspondentId, "correspondent must be created first"); const result = (await client.callTool({ @@ -407,4 +436,5 @@ describe("Paperless MCP E2E scenario", () => { `tag ${state.tagId} should be removed, got tags=${JSON.stringify(removedTagIds)}` ); }); + }); diff --git a/src/api/PaperlessAPI.ts b/src/api/PaperlessAPI.ts index b18422a..89afc04 100644 --- a/src/api/PaperlessAPI.ts +++ b/src/api/PaperlessAPI.ts @@ -221,6 +221,10 @@ export class PaperlessAPI { return this.request("/tags/"); } + async getTag(id: number): Promise { + return this.request(`/tags/${id}/`); + } + async createTag(data: Partial): Promise { return this.request("/tags/", { method: "POST", diff --git a/src/tools/tags.ts b/src/tools/tags.ts index fe66ba6..b097e2b 100644 --- a/src/tools/tags.ts +++ b/src/tools/tags.ts @@ -45,6 +45,20 @@ export function registerTagTools(server: McpServer, api: PaperlessAPI) { }) ); + server.tool( + "get_tag", + "Get a specific tag by ID with full details including matching rules.", + { id: z.number() }, + withErrorHandling(async (args, extra) => { + if (!api) throw new Error("Please configure API connection first"); + const tag = await api.getTag(args.id); + const enhancedTag = enhanceMatchingAlgorithm(tag); + return { + content: [{ type: "text", text: JSON.stringify(enhancedTag) }], + }; + }) + ); + server.tool( "create_tag", "Create a new tag with optional color, matching pattern, and matching algorithm for automatic document tagging.", From 78b29b9573241d80f364103038d58cf109cfc146 Mon Sep 17 00:00:00 2001 From: Florian Henze Date: Tue, 26 May 2026 01:43:59 +0200 Subject: [PATCH 2/3] feat: add storage_paths CRUD tool family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage paths were not exposed at all — no list/get/create/update/delete and no bulk_edit. They appeared only as a foreign-key parameter (storage_path) on list_documents, post_document, update_document and bulk_edit_documents, so agents had no way to discover or manage them via the MCP and had to fall back to raw API calls. The new tool family mirrors the correspondents/document_types pattern: list_storage_paths, get_storage_path, create_storage_path, update_storage_path, delete_storage_path (with the same confirm-flag discipline as delete_correspondent) and bulk_edit_storage_paths. Storage paths carry an additional required `path` field, a Django template string such as "{{ correspondent }}/{{ created_year }}/{{ title }}". The tool descriptions document this and reference the Paperless-NGX docs for available placeholders. E2E coverage in e2e.test.ts walks the full lifecycle: - create_storage_path returns the new path with id, name and template - get_storage_path returns the same payload with expanded matching_algorithm - list_storage_paths includes the new id - update_storage_path renames and preserves the path template - bulk_edit_documents method=set_storage_path assigns the path to the uploaded test document, and get_document reflects the assignment - delete_storage_path requires confirm=true; the deleted id then surfaces an isError from get_storage_path (404 round-trip) Side note: this also closes the self-evidence gap from the existing list_documents description, which already points agents at a list_storage_paths tool that previously did not exist. --- e2e/e2e.test.ts | 166 +++++++++++++++++++++++++++++++ src/api/PaperlessAPI.ts | 39 ++++++++ src/api/types.ts | 17 ++++ src/server.ts | 2 + src/tools/storagePaths.ts | 203 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 427 insertions(+) create mode 100644 src/tools/storagePaths.ts diff --git a/e2e/e2e.test.ts b/e2e/e2e.test.ts index f28b888..19c567c 100644 --- a/e2e/e2e.test.ts +++ b/e2e/e2e.test.ts @@ -12,6 +12,9 @@ const MCP_URL = process.env.MCP_URL ?? `http://localhost:${MCP_PORT}/mcp`; const RUN_TAG = `e2e-tag-${Date.now()}`; const RUN_CORRESPONDENT = `E2E Corp ${Date.now()}`; const RUN_DOCUMENT_TYPE = `E2E Type ${Date.now()}`; +const RUN_STORAGE_PATH = `E2E Storage ${Date.now()}`; +const RUN_STORAGE_PATH_RENAMED = `E2E Storage Renamed ${Date.now()}`; +const RUN_STORAGE_PATH_TEMPLATE = "e2e/{{ created_year }}/{{ title }}"; const RUN_DOCUMENT_TITLE = `E2E Document ${Date.now()}`; // Paperless rejects duplicate uploads by checksum. When the same suite runs @@ -42,6 +45,7 @@ const state: { tagId?: number; correspondentId?: number; documentTypeId?: number; + storagePathId?: number; documentId?: number; } = {}; @@ -226,6 +230,98 @@ describe("Paperless MCP E2E scenario", () => { assert.strictEqual(found.name, RUN_DOCUMENT_TYPE); }); + it("create_storage_path creates a storage path and returns it with an id", async () => { + const result = (await client.callTool({ + name: "create_storage_path", + arguments: { + name: RUN_STORAGE_PATH, + path: RUN_STORAGE_PATH_TEMPLATE, + }, + })) as ToolResult; + assertOk(result, "create_storage_path"); + const storagePath = parseToolText(result) as { + id: number; + name: string; + path: string; + }; + assert.ok( + typeof storagePath.id === "number", + `storage_path.id should be a number, got ${JSON.stringify(storagePath)}` + ); + assert.strictEqual(storagePath.name, RUN_STORAGE_PATH); + assert.strictEqual(storagePath.path, RUN_STORAGE_PATH_TEMPLATE); + state.storagePathId = storagePath.id; + }); + + it("get_storage_path returns the storage path by ID with full detail fields", async () => { + assert.ok(state.storagePathId, "storage path must be created first"); + const result = (await client.callTool({ + name: "get_storage_path", + arguments: { id: state.storagePathId }, + })) as ToolResult; + assertOk(result, "get_storage_path"); + const storagePath = parseToolText(result) as { + id: number; + name: string; + path: string; + slug: string; + matching_algorithm: { id: number; name: string }; + }; + assert.strictEqual(storagePath.id, state.storagePathId); + assert.strictEqual(storagePath.name, RUN_STORAGE_PATH); + assert.strictEqual(storagePath.path, RUN_STORAGE_PATH_TEMPLATE); + assert.ok( + typeof storagePath.slug === "string" && storagePath.slug.length > 0, + "slug should be a non-empty string" + ); + assert.ok( + storagePath.matching_algorithm && + typeof storagePath.matching_algorithm.name === "string", + "matching_algorithm should be expanded to {id,name}" + ); + }); + + it("list_storage_paths returns the storage path created earlier in this run", async () => { + assert.ok(state.storagePathId, "storage path must be created first"); + const result = (await client.callTool({ + name: "list_storage_paths", + arguments: {}, + })) as ToolResult; + assertOk(result, "list_storage_paths"); + const data = parseToolText(result) as { + results: { id: number; name: string }[]; + }; + assert.ok(Array.isArray(data.results), "results should be an array"); + const found = data.results.find((sp) => sp.id === state.storagePathId); + assert.ok( + found, + `storage_path id=${state.storagePathId} not found in list_storage_paths` + ); + assert.strictEqual(found.name, RUN_STORAGE_PATH); + }); + + it("update_storage_path renames the storage path and the change is visible via get", async () => { + assert.ok(state.storagePathId, "storage path must be created first"); + const updateResult = (await client.callTool({ + name: "update_storage_path", + arguments: { + id: state.storagePathId, + name: RUN_STORAGE_PATH_RENAMED, + }, + })) as ToolResult; + assertOk(updateResult, "update_storage_path"); + + const getResult = (await client.callTool({ + name: "get_storage_path", + arguments: { id: state.storagePathId }, + })) as ToolResult; + assertOk(getResult, "get_storage_path after update"); + const updated = parseToolText(getResult) as { name: string; path: string }; + assert.strictEqual(updated.name, RUN_STORAGE_PATH_RENAMED); + // path must be unchanged — update only sent `name`. + assert.strictEqual(updated.path, RUN_STORAGE_PATH_TEMPLATE); + }); + it("post_document uploads a PDF and resolves to a document id", async () => { const base64Pdf = MINIMAL_PDF.toString("base64"); const result = (await client.callTool({ @@ -437,4 +533,74 @@ describe("Paperless MCP E2E scenario", () => { ); }); + it("bulk_edit_documents set_storage_path assigns the storage path and get_document reflects it", async () => { + assert.ok( + state.documentId && state.storagePathId, + "document and storage path must exist" + ); + const setResult = (await client.callTool({ + name: "bulk_edit_documents", + arguments: { + documents: [state.documentId], + method: "set_storage_path", + storage_path: state.storagePathId, + }, + })) as ToolResult; + assertOk(setResult, "bulk_edit_documents set_storage_path"); + + const docAfterSet = (await client.callTool({ + name: "get_document", + arguments: { id: state.documentId }, + })) as ToolResult; + assertOk(docAfterSet, "get_document after set_storage_path"); + const setData = parseToolText(docAfterSet) as { + storage_path: number | { id: number } | null; + }; + const assignedId = + typeof setData.storage_path === "number" + ? setData.storage_path + : setData.storage_path?.id; + assert.strictEqual( + assignedId, + state.storagePathId, + `document storage_path should be ${state.storagePathId}, got ${JSON.stringify( + setData.storage_path + )}` + ); + }); + + it("delete_storage_path requires confirm=true and then removes the storage path", async () => { + assert.ok(state.storagePathId, "storage path must be created first"); + + // Unconfirmed delete must surface an isError result. + const unconfirmed = (await client.callTool({ + name: "delete_storage_path", + arguments: { id: state.storagePathId, confirm: false }, + })) as ToolResult; + assert.ok( + unconfirmed.isError, + "delete_storage_path without confirm=true should return isError" + ); + + // Confirmed delete succeeds. + const deleted = (await client.callTool({ + name: "delete_storage_path", + arguments: { id: state.storagePathId, confirm: true }, + })) as ToolResult; + assertOk(deleted, "delete_storage_path"); + const payload = parseToolText(deleted) as { status: string }; + assert.strictEqual(payload.status, "deleted"); + + // Subsequent get_storage_path must fail (404). + const gone = (await client.callTool({ + name: "get_storage_path", + arguments: { id: state.storagePathId }, + })) as ToolResult; + assert.ok( + gone.isError, + `get_storage_path for deleted id should be isError, got ${errorText( + gone + )}` + ); + }); }); diff --git a/src/api/PaperlessAPI.ts b/src/api/PaperlessAPI.ts index 89afc04..ead6441 100644 --- a/src/api/PaperlessAPI.ts +++ b/src/api/PaperlessAPI.ts @@ -11,7 +11,9 @@ import { GetCorrespondentsResponse, GetCustomFieldsResponse, GetDocumentTypesResponse, + GetStoragePathsResponse, GetTagsResponse, + StoragePath, Tag, } from "./types"; import { headersToObject } from "./utils"; @@ -284,6 +286,43 @@ export class PaperlessAPI { }); } + // Storage path operations + async getStoragePaths( + queryString?: string + ): Promise { + const url = queryString + ? `/storage_paths/?${queryString}` + : "/storage_paths/"; + return this.request(url); + } + + async getStoragePath(id: number): Promise { + return this.request(`/storage_paths/${id}/`); + } + + async createStoragePath(data: Partial): Promise { + return this.request("/storage_paths/", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async updateStoragePath( + id: number, + data: Partial + ): Promise { + return this.request(`/storage_paths/${id}/`, { + method: "PATCH", + body: JSON.stringify(data), + }); + } + + async deleteStoragePath(id: number): Promise { + return this.request(`/storage_paths/${id}/`, { + method: "DELETE", + }); + } + // Document type operations async getDocumentTypes(): Promise { return this.request("/document_types/"); diff --git a/src/api/types.ts b/src/api/types.ts index c90769d..f38585c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -147,6 +147,23 @@ export interface DocumentType { export interface GetDocumentTypesResponse extends PaginationResponse {} +export interface StoragePath { + id: number; + slug: string; + name: string; + path: string; + match: string; + matching_algorithm: MatchingAlgorithm; + is_insensitive: boolean; + document_count: number; + owner: number | null; + permissions: Record; + user_can_change: boolean; +} + +export interface GetStoragePathsResponse + extends PaginationResponse {} + export interface BulkEditDocumentsResult { result: string; } diff --git a/src/server.ts b/src/server.ts index e732f64..f4cac0d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,7 @@ import { registerCorrespondentTools } from "./tools/correspondents"; import { registerCustomFieldTools } from "./tools/customFields"; import { registerDocumentTools } from "./tools/documents"; import { registerDocumentTypeTools } from "./tools/documentTypes"; +import { registerStoragePathTools } from "./tools/storagePaths"; import { registerTagTools } from "./tools/tags"; export interface CreateMcpServerOptions { @@ -29,6 +30,7 @@ export function createMcpServer({ registerTagTools(server, api); registerCorrespondentTools(server, api); registerDocumentTypeTools(server, api); + registerStoragePathTools(server, api); registerCustomFieldTools(server, api); return server; } diff --git a/src/tools/storagePaths.ts b/src/tools/storagePaths.ts new file mode 100644 index 0000000..509120d --- /dev/null +++ b/src/tools/storagePaths.ts @@ -0,0 +1,203 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; +import { z } from "zod"; +import { PaperlessAPI } from "../api/PaperlessAPI"; +import { MATCHING_ALGORITHM_DESCRIPTION } from "../api/types"; +import { + enhanceMatchingAlgorithm, + enhanceMatchingAlgorithmArray, +} from "../api/utils"; +import { withErrorHandling } from "./utils/middlewares"; +import { buildQueryString } from "./utils/queryString"; + +export function registerStoragePathTools( + server: McpServer, + api: PaperlessAPI +) { + server.tool( + "list_storage_paths", + "List all storage paths with optional filtering and pagination. Storage paths define how documents are organized in the filesystem (e.g. '02_Privat/{{ created_year }}/{{ title }}').", + { + page: z.number().optional(), + page_size: z.number().optional(), + name__icontains: z.string().optional(), + name__iendswith: z.string().optional(), + name__iexact: z.string().optional(), + name__istartswith: z.string().optional(), + ordering: z.string().optional(), + }, + withErrorHandling(async (args, extra) => { + if (!api) throw new Error("Please configure API connection first"); + const queryString = buildQueryString(args); + const response = await api.getStoragePaths(queryString); + const enhancedResults = enhanceMatchingAlgorithmArray( + response.results || [] + ); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ...response, + results: enhancedResults, + }), + }, + ], + }; + }) + ); + + server.tool( + "get_storage_path", + "Get a specific storage path by ID with full details including the path template and matching rules.", + { id: z.number() }, + withErrorHandling(async (args, extra) => { + if (!api) throw new Error("Please configure API connection first"); + const response = await api.getStoragePath(args.id); + const enhancedStoragePath = enhanceMatchingAlgorithm(response); + return { + content: [ + { type: "text", text: JSON.stringify(enhancedStoragePath) }, + ], + }; + }) + ); + + server.tool( + "create_storage_path", + "Create a new storage path. The 'path' field is a template string using Django template syntax (e.g. '{{ correspondent }}/{{ created_year }}/{{ title }}'). See the Paperless-NGX docs for available placeholders.", + { + name: z.string(), + path: z + .string() + .describe( + "Storage path template, e.g. '{{ correspondent }}/{{ created_year }}/{{ title }}'" + ), + match: z.string().optional(), + matching_algorithm: z + .number() + .int() + .min(0) + .max(6) + .optional() + .describe(MATCHING_ALGORITHM_DESCRIPTION), + }, + withErrorHandling(async (args, extra) => { + if (!api) throw new Error("Please configure API connection first"); + const response = await api.createStoragePath(args); + const enhancedStoragePath = enhanceMatchingAlgorithm(response); + return { + content: [ + { type: "text", text: JSON.stringify(enhancedStoragePath) }, + ], + }; + }) + ); + + server.tool( + "update_storage_path", + "Update an existing storage path's name, path template, matching pattern, or matching algorithm.", + { + id: z.number(), + name: z.string(), + path: z + .string() + .optional() + .describe( + "Storage path template, e.g. '{{ correspondent }}/{{ created_year }}/{{ title }}'" + ), + match: z.string().optional(), + matching_algorithm: z + .number() + .int() + .min(0) + .max(6) + .optional() + .describe(MATCHING_ALGORITHM_DESCRIPTION), + }, + withErrorHandling(async (args, extra) => { + if (!api) throw new Error("Please configure API connection first"); + const { id, ...data } = args; + const response = await api.updateStoragePath(id, data); + const enhancedStoragePath = enhanceMatchingAlgorithm(response); + return { + content: [ + { type: "text", text: JSON.stringify(enhancedStoragePath) }, + ], + }; + }) + ); + + server.tool( + "delete_storage_path", + "⚠️ DESTRUCTIVE: Permanently delete a storage path from the entire system. Documents assigned to this storage path will lose the assignment.", + { + id: z.number(), + confirm: z + .boolean() + .describe("Must be true to confirm this destructive operation"), + }, + withErrorHandling(async (args, extra) => { + 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." + ); + } + await api.deleteStoragePath(args.id); + return { + content: [ + { type: "text", text: JSON.stringify({ status: "deleted" }) }, + ], + }; + }) + ); + + server.tool( + "bulk_edit_storage_paths", + "Bulk edit storage paths. ⚠️ WARNING: 'delete' operation permanently removes storage paths from the entire system.", + { + storage_path_ids: z.array(z.number()), + operation: z.enum(["set_permissions", "delete"]), + confirm: z + .boolean() + .optional() + .describe( + "Must be true when operation is 'delete' to confirm destructive operation" + ), + owner: z.number().optional(), + permissions: z + .object({ + view: z.object({ + users: z.array(z.number()).optional(), + groups: z.array(z.number()).optional(), + }), + change: z.object({ + users: z.array(z.number()).optional(), + groups: z.array(z.number()).optional(), + }), + }) + .optional(), + merge: z.boolean().optional(), + }, + withErrorHandling(async (args, extra) => { + if (!api) throw new Error("Please configure API connection first"); + if (args.operation === "delete" && !args.confirm) { + throw new Error( + "Confirmation required for destructive operation. Set confirm: true to proceed." + ); + } + return api.bulkEditObjects( + args.storage_path_ids, + "storage_paths", + args.operation, + args.operation === "set_permissions" + ? { + owner: args.owner, + permissions: args.permissions, + merge: args.merge, + } + : {} + ); + }) + ); +} From 943aa6a10665d3ed9bdf973775557094990af366 Mon Sep 17 00:00:00 2001 From: "Baruch Odem (Rothkoff)" Date: Tue, 26 May 2026 09:11:22 +0300 Subject: [PATCH 3/3] Create seven-pigs-know.md --- .changeset/seven-pigs-know.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/seven-pigs-know.md diff --git a/.changeset/seven-pigs-know.md b/.changeset/seven-pigs-know.md new file mode 100644 index 0000000..ed8e5ec --- /dev/null +++ b/.changeset/seven-pigs-know.md @@ -0,0 +1,5 @@ +--- +"@baruchiro/paperless-mcp": patch +--- + +feat: add get_tag tool and storage_paths CRUD