Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-bulk-custom-field-parameters.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
})
```

Expand Down
9 changes: 5 additions & 4 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand Down Expand Up @@ -150,8 +152,7 @@ export interface BulkEditDocumentsResult {
}

export interface BulkEditParameters {
assign_custom_fields?: number[];
assign_custom_fields_values?: CustomFieldInstanceRequest[];
add_custom_fields?: Record<string, CustomFieldInstanceRequest["value"]>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
remove_custom_fields?: number[];
add_tags?: number[];
remove_tags?: number[];
Expand Down
93 changes: 93 additions & 0 deletions src/tools/documents.test.ts
Original file line number Diff line number Diff line change
@@ -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],
});
});
76 changes: 66 additions & 10 deletions src/tools/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, BulkCustomFieldValue>;
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<T extends Record<string, unknown>>(
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",
Expand Down Expand Up @@ -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: [
Expand Down
Loading