Skip to content
Open
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/seven-pigs-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@baruchiro/paperless-mcp": patch
---

feat: add get_tag tool and storage_paths CRUD
196 changes: 196 additions & 0 deletions e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +45,7 @@ const state: {
tagId?: number;
correspondentId?: number;
documentTypeId?: number;
storagePathId?: number;
documentId?: number;
} = {};

Expand Down Expand Up @@ -167,6 +171,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({
Expand Down Expand Up @@ -197,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);
});
Comment on lines +284 to +301

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make list_storage_paths lookup deterministic to avoid pagination flakiness.

This test can fail when the created item is outside the default page. Filter by name (or set an explicit large page_size) before asserting presence.

💡 Suggested change
   const result = (await client.callTool({
     name: "list_storage_paths",
-    arguments: {},
+    arguments: { name__iexact: RUN_STORAGE_PATH, page_size: 200 },
   })) as ToolResult;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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("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: { name__iexact: RUN_STORAGE_PATH, page_size: 200 },
})) 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);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/e2e.test.ts` around lines 284 - 301, The test "list_storage_paths returns
the storage path created earlier in this run" is flaky due to pagination; modify
the client.callTool invocation for "list_storage_paths" (the call in the it
block using client.callTool and parseToolText) to request a deterministic result
by either passing a filter for the storage path name (use RUN_STORAGE_PATH) or
increasing page_size to a sufficiently large value, then assert against the
filtered result (e.g., check data.results[0] or find by id as before) rather
than relying on the default page. Ensure you update the arguments object passed
to client.callTool so the API returns the created storage path reliably when
checking state.storagePathId and 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({
Expand Down Expand Up @@ -485,4 +610,75 @@ describe("Paperless MCP E2E scenario", () => {
`tag ${state.tagId} should be removed, got tags=${JSON.stringify(removedTagIds)}`
);
});

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
)}`
);
});
});
43 changes: 43 additions & 0 deletions src/api/PaperlessAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
GetCorrespondentsResponse,
GetCustomFieldsResponse,
GetDocumentTypesResponse,
GetStoragePathsResponse,
GetTagsResponse,
StoragePath,
Tag,
} from "./types";
import { headersToObject } from "./utils";
Expand Down Expand Up @@ -221,6 +223,10 @@ export class PaperlessAPI {
return this.request<GetTagsResponse>("/tags/");
}

async getTag(id: number): Promise<Tag> {
return this.request<Tag>(`/tags/${id}/`);
}

async createTag(data: Partial<Tag>): Promise<Tag> {
return this.request<Tag>("/tags/", {
method: "POST",
Expand Down Expand Up @@ -280,6 +286,43 @@ export class PaperlessAPI {
});
}

// Storage path operations
async getStoragePaths(
queryString?: string
): Promise<GetStoragePathsResponse> {
const url = queryString
? `/storage_paths/?${queryString}`
: "/storage_paths/";
return this.request<GetStoragePathsResponse>(url);
}

async getStoragePath(id: number): Promise<StoragePath> {
return this.request<StoragePath>(`/storage_paths/${id}/`);
}

async createStoragePath(data: Partial<StoragePath>): Promise<StoragePath> {
return this.request<StoragePath>("/storage_paths/", {
method: "POST",
body: JSON.stringify(data),
});
}

async updateStoragePath(
id: number,
data: Partial<StoragePath>
): Promise<StoragePath> {
return this.request<StoragePath>(`/storage_paths/${id}/`, {
method: "PATCH",
body: JSON.stringify(data),
});
}

async deleteStoragePath(id: number): Promise<void> {
return this.request<void>(`/storage_paths/${id}/`, {
method: "DELETE",
});
}

// Document type operations
async getDocumentTypes(): Promise<GetDocumentTypesResponse> {
return this.request<GetDocumentTypesResponse>("/document_types/");
Expand Down
17 changes: 17 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,23 @@ export interface DocumentType {
export interface GetDocumentTypesResponse
extends PaginationResponse<DocumentType> {}

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<string, unknown>;
user_can_change: boolean;
}

export interface GetStoragePathsResponse
extends PaginationResponse<StoragePath> {}

export interface BulkEditDocumentsResult {
result: string;
}
Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 {
Expand All @@ -31,6 +32,7 @@ export function createMcpServer({
registerTagTools(server, api);
registerCorrespondentTools(server, api);
registerDocumentTypeTools(server, api);
registerStoragePathTools(server, api);
registerCustomFieldTools(server, api);
return server;
}
Expand Down
Loading
Loading