Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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/improve-monetary-field-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@baruchiro/paperless-mcp": patch
---

Improve UX for monetary custom fields: clarify currency format in tool descriptions, and add client-side validation that catches common mistakes (e.g., trailing `$` like `10.00$`) with actionable error messages suggesting the correct format (e.g., `USD10.00`).
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
branches:
- main
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".node-version"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"paperless-mcp": "build/index.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "node --require ts-node/register --test src/**/*.test.ts",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new PR to run GitHub Actions Workflow with this tests o
for every PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't open a new PR, but I've added .github/workflows/ci.yml to this PR in eff830c. It runs npm test on every PR and on pushes to main.

"start": "ts-node src/index.ts",
"build": "tsc",
"dxt-pack": "dxt pack",
Expand Down
2 changes: 1 addition & 1 deletion src/tools/customFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function registerCustomFieldTools(server: McpServer, api: PaperlessAPI) {

server.tool(
"create_custom_field",
"Create a new custom field with a specified data type (string, url, date, boolean, integer, float, monetary, documentlink, or select).",
"Create a new custom field with a specified data type (string, url, date, boolean, integer, float, monetary, documentlink, or select). For monetary fields, values must use currency code prefix format (e.g., USD10.00, GBP123.45) — NOT trailing symbol format (e.g., 10.00$).",
{
name: z.string(),
data_type: z.enum([
Expand Down
13 changes: 9 additions & 4 deletions src/tools/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { convertDocsWithNames } from "../api/documentEnhancer";
import { PaperlessAPI } from "../api/PaperlessAPI";
import { arrayNotEmpty, objectNotEmpty } from "./utils/empty";
import { withErrorHandling } from "./utils/middlewares";
import { validateCustomFields } from "./utils/monetary";
import { CUSTOM_FIELD_VALUE_DESCRIPTION } from "./utils/descriptions";

export function registerDocumentTools(server: McpServer, api: PaperlessAPI) {
server.tool(
Expand Down Expand Up @@ -43,7 +45,7 @@ export function registerDocumentTools(server: McpServer, api: PaperlessAPI) {
z.boolean(),
z.array(z.number()),
z.null(),
]),
]).describe(CUSTOM_FIELD_VALUE_DESCRIPTION),
})
)
.optional()
Expand Down Expand Up @@ -91,6 +93,8 @@ export function registerDocumentTools(server: McpServer, api: PaperlessAPI) {
}
const { documents, method, add_custom_fields, ...parameters } = args;

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) {
Expand Down Expand Up @@ -365,9 +369,7 @@ export function registerDocumentTools(server: McpServer, api: PaperlessAPI) {
z.array(z.number()),
z.null(),
])
.describe(
"The value for the custom field. For documentlink fields, use a single document ID (e.g., 123) or an array of document IDs (e.g., [123, 456])."
),
.describe(CUSTOM_FIELD_VALUE_DESCRIPTION),
})
)
.optional()
Expand All @@ -376,6 +378,9 @@ export function registerDocumentTools(server: McpServer, api: PaperlessAPI) {
withErrorHandling(async (args, extra) => {
if (!api) throw new Error("Please configure API connection first");
const { id, ...updateData } = args;

validateCustomFields(updateData.custom_fields);

const response = await api.updateDocument(id, updateData);

return convertDocsWithNames(response, api);
Expand Down
2 changes: 2 additions & 0 deletions src/tools/utils/descriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const CUSTOM_FIELD_VALUE_DESCRIPTION =
"The value for the custom field. For monetary fields, use currency code prefix format (e.g., USD10.00, GBP123.45, EUR9.99) — NOT trailing symbol format (e.g., 10.00$). For documentlink fields, use a single document ID (e.g., 123) or an array of document IDs (e.g., [123, 456]).";
53 changes: 53 additions & 0 deletions src/tools/utils/monetary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getMonetaryValidationError } from "./monetary";

test("returns null for non-monetary strings", () => {
assert.equal(getMonetaryValidationError("hello"), null);
assert.equal(getMonetaryValidationError("some text"), null);
assert.equal(getMonetaryValidationError(""), null);
});

test("returns null for valid monetary prefix format", () => {
assert.equal(getMonetaryValidationError("USD10.00"), null);
assert.equal(getMonetaryValidationError("GBP123.45"), null);
assert.equal(getMonetaryValidationError("EUR9.99"), null);
assert.equal(getMonetaryValidationError("ILS50.00"), null);
});

test("returns error for trailing dollar sign", () => {
const err = getMonetaryValidationError("10.00$");
assert.ok(err);
assert.match(err, /USD10\.00/);
assert.match(err, /currency code as a prefix/);
});

test("returns error for trailing euro sign", () => {
const err = getMonetaryValidationError("123€");
assert.ok(err);
assert.match(err, /EUR123\.00/);
});

test("returns error for trailing pound sign", () => {
const err = getMonetaryValidationError("50£");
assert.ok(err);
assert.match(err, /GBP50\.00/);
});

test("returns error for trailing shekel sign", () => {
const err = getMonetaryValidationError("100₪");
assert.ok(err);
assert.match(err, /ILS100\.00/);
});

test("returns error for trailing rupee sign", () => {
const err = getMonetaryValidationError("200₹");
assert.ok(err);
assert.match(err, /INR200\.00/);
});

test("returns error for trailing yen sign", () => {
const err = getMonetaryValidationError("500¥");
assert.ok(err);
assert.match(err, /JPY500\.00/);
});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to test every symbol. The idea is clear after the first symbol.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed to a single representative case in 72eec16.

41 changes: 41 additions & 0 deletions src/tools/utils/monetary.ts
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too many comments. And too long. You should consider omitting them at all.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed all comments in 5224c05.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const SYMBOL_TO_CODE: Record<string, string> = {
$: "USD",
"€": "EUR",
"£": "GBP",
"¥": "JPY",
"₹": "INR",
"₪": "ILS",
};

const TRAILING_SYMBOL_REGEX = new RegExp(
`^(\\d+(?:\\.\\d+)?)[${Object.keys(SYMBOL_TO_CODE).join("")}]$`
);

export function getMonetaryValidationError(value: string): string | null {
const trailingMatch = TRAILING_SYMBOL_REGEX.exec(value);
if (trailingMatch) {
const amount = trailingMatch[1];
const symbol = value.slice(-1);
const code = SYMBOL_TO_CODE[symbol] || "USD";
const numericAmount = parseFloat(amount);
const formattedAmount = isNaN(numericAmount) ? amount : numericAmount.toFixed(2);
return (
`Invalid monetary format "${value}". ` +
`Paperless-NGX requires the currency code as a prefix, e.g. "${code}${formattedAmount}". ` +
`Use the format: {CURRENCY_CODE}{amount} (e.g., USD10.00, GBP123.45, EUR9.99).`
);
}

return null;
}
Comment on lines +14 to +30
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This must be covered with unit test.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added src/tools/utils/monetary.test.ts with 8 unit tests (covering valid formats, non-monetary strings, and all 6 currency symbols including ILS) using Node's built-in node:test runner in 7bb0c4f. Updated package.json test script — all tests pass.


export function validateCustomFields(
custom_fields: { field: number; value: unknown }[] | undefined
) {
custom_fields
?.filter((cf) => typeof cf.value === "string")
.forEach((cf) => {
const monetaryError = getMonetaryValidationError(cf.value as string);
if (monetaryError) throw new Error(monetaryError);
});
}