diff --git a/.changeset/fix-bulk-custom-field-parameters.md b/.changeset/fix-bulk-custom-field-parameters.md new file mode 100644 index 0000000..2ae9a72 --- /dev/null +++ b/.changeset/fix-bulk-custom-field-parameters.md @@ -0,0 +1,5 @@ +--- +"@baruchiro/paperless-mcp": patch +--- + +Fix bulk document custom field edits to send Paperless-NGX compatible `add_custom_fields` parameters and preserve intentionally empty custom field values. diff --git a/README.md b/README.md index c3a3faa..aa67a28 100644 --- a/README.md +++ b/README.md @@ -224,9 +224,20 @@ bulk_edit_documents({ bulk_edit_documents({ documents: [12, 13], method: "modify_custom_fields", - add_custom_fields: { - "2": "שנה" - } + add_custom_fields: [ + { field: 2, value: "שנה" } + ], + remove_custom_fields: [] +}) + +// Set an empty custom field value, e.g. a date field used as a pending marker +bulk_edit_documents({ + documents: [14], + method: "modify_custom_fields", + add_custom_fields: [ + { field: 9, value: "" } + ], + remove_custom_fields: [] }) ``` diff --git a/src/api/types.ts b/src/api/types.ts index 00bb237..c90769d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -39,14 +39,16 @@ export interface CustomField { document_count: number; } +export type CustomFieldValue = string | number | boolean | number[] | null; + export interface CustomFieldInstance { field: number; - value: string | number | boolean | object | null; + value: CustomFieldValue; } export interface CustomFieldInstanceRequest { field: number; - value: string | number | boolean | object | null; + value: CustomFieldValue; } export interface PaginationResponse { @@ -150,8 +152,7 @@ export interface BulkEditDocumentsResult { } export interface BulkEditParameters { - assign_custom_fields?: number[]; - assign_custom_fields_values?: CustomFieldInstanceRequest[]; + add_custom_fields?: Record; remove_custom_fields?: number[]; add_tags?: number[]; remove_tags?: number[]; diff --git a/src/tools/documents.test.ts b/src/tools/documents.test.ts new file mode 100644 index 0000000..eb70671 --- /dev/null +++ b/src/tools/documents.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildBulkEditParameters } from "./documents"; + +test("buildBulkEditParameters sends Paperless bulk custom fields as id:value map", () => { + const parameters = buildBulkEditParameters( + { remove_custom_fields: [] }, + [ + { field: 9, value: "" }, + { field: 10, value: "2026-05-14" }, + ] + ); + + assert.deepEqual(parameters, { + remove_custom_fields: [], + add_custom_fields: { + "9": "", + "10": "2026-05-14", + }, + }); + assert.ok(!("assign_custom_fields" in parameters)); + assert.ok(!("assign_custom_fields_values" in parameters)); +}); + +test("buildBulkEditParameters preserves null custom field values", () => { + const parameters = buildBulkEditParameters({}, [{ field: 9, value: null }]); + + assert.deepEqual(parameters, { + add_custom_fields: { + "9": null, + }, + }); +}); + +test("buildBulkEditParameters includes Paperless-required empty custom field keys", () => { + const parameters = buildBulkEditParameters({}, undefined, true); + + assert.deepEqual(parameters, { + add_custom_fields: {}, + remove_custom_fields: [], + }); +}); + +test("buildBulkEditParameters preserves an empty custom fields array", () => { + const parameters = buildBulkEditParameters({}, []); + + assert.deepEqual(parameters, { + add_custom_fields: {}, + }); + assert.ok(!("remove_custom_fields" in parameters)); +}); + +test("buildBulkEditParameters preserves an empty custom fields array with defaults", () => { + const parameters = buildBulkEditParameters({}, [], true); + + assert.deepEqual(parameters, { + add_custom_fields: {}, + remove_custom_fields: [], + }); +}); + +test("buildBulkEditParameters combines base parameters with custom fields", () => { + const parameters = buildBulkEditParameters( + { add_tags: [3], remove_tags: [1, 2] }, + [{ field: 9, value: "pending" }] + ); + + assert.deepEqual(parameters, { + add_tags: [3], + remove_tags: [1, 2], + add_custom_fields: { + "9": "pending", + }, + }); +}); + +test("buildBulkEditParameters preserves supported custom field value types", () => { + const parameters = buildBulkEditParameters({}, [ + { field: 1, value: 42 }, + { field: 2, value: true }, + { field: 3, value: "" }, + { field: 4, value: null }, + { field: 5, value: [123, 456] }, + ]); + + assert.deepEqual(parameters.add_custom_fields, { + "1": 42, + "2": true, + "3": "", + "4": null, + "5": [123, 456], + }); +}); diff --git a/src/tools/documents.ts b/src/tools/documents.ts index 553ea9e..74799d4 100644 --- a/src/tools/documents.ts +++ b/src/tools/documents.ts @@ -7,6 +7,67 @@ import { withErrorHandling } from "./utils/middlewares"; import { validateCustomFields } from "./utils/monetary"; import { CUSTOM_FIELD_VALUE_DESCRIPTION } from "./utils/descriptions"; +export type BulkCustomFieldValue = string | number | boolean | number[] | null; + +export type BulkCustomFieldUpdate = { + field: number; + value: BulkCustomFieldValue; +}; + +export type BulkCustomFieldParameters = { + add_custom_fields?: Record; + remove_custom_fields?: number[]; +}; + +/** + * Builds Paperless-NGX bulk edit parameters from base parameters plus optional + * custom field updates. + * + * Paperless-NGX expects custom field bulk updates as an `add_custom_fields` + * record keyed by custom field id. `addCustomFields` is accepted as an array for + * the MCP tool schema and transformed into that id-to-value record while + * preserving supported value types, including `number[]` document links and + * `null` resets. Passing an empty `addCustomFields` array intentionally produces + * an empty `add_custom_fields` record. + * + * When `includeCustomFieldDefaults` is true, the function also initializes + * `add_custom_fields` and `remove_custom_fields` with empty defaults using + * nullish coalescing (`??=`). This keeps the `modify_custom_fields` method's + * payload shape acceptable to Paperless even when no field values are supplied. + * + * @param parameters - Base bulk edit parameters to include in the result. + * @param addCustomFields - Optional custom field updates to map by field id. + * @param includeCustomFieldDefaults - Whether to include empty custom field + * defaults required by `modify_custom_fields`. + * @returns The merged API parameters with custom field updates transformed into + * Paperless-NGX's `add_custom_fields` record shape. + */ +export function buildBulkEditParameters>( + parameters: T, + addCustomFields?: BulkCustomFieldUpdate[], + includeCustomFieldDefaults = false +): T & BulkCustomFieldParameters { + const apiParameters: T & BulkCustomFieldParameters = { + ...parameters, + }; + + if (addCustomFields) { + apiParameters.add_custom_fields = Object.fromEntries( + addCustomFields.map((customField) => [ + String(customField.field), + customField.value, + ]) + ); + } + + if (includeCustomFieldDefaults) { + apiParameters.add_custom_fields ??= {}; + apiParameters.remove_custom_fields ??= []; + } + + return apiParameters; +} + export function registerDocumentTools(server: McpServer, api: PaperlessAPI) { server.tool( "bulk_edit_documents", @@ -95,19 +156,14 @@ export function registerDocumentTools(server: McpServer, api: PaperlessAPI) { validateCustomFields(add_custom_fields); - // Transform add_custom_fields into the two separate API parameters - const apiParameters = { ...parameters }; - if (add_custom_fields && add_custom_fields.length > 0) { - apiParameters.assign_custom_fields = add_custom_fields.map( - (cf) => cf.field - ); - apiParameters.assign_custom_fields_values = add_custom_fields; - } - const response = await api.bulkEditDocuments( documents, method, - apiParameters + buildBulkEditParameters( + parameters, + add_custom_fields, + method === "modify_custom_fields" + ) ); return { content: [