Initial commit: Paperless-ngx MCP Server

A Model Context Protocol (MCP) server for Paperless-ngx document management.

Features:
- Full CRUD operations for documents, tags, correspondents, document types,
  storage paths, and custom fields
- Document upload with retry logic (base64 and file path)
- Bulk operations with dry-run support
- Search with full-text and metadata filtering
- Pagination support across all list operations
- Proper error handling with McpResponse wrapper

Built with .NET 10 and the official MCP SDK.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Barry Walker
2026-01-13 14:01:44 -05:00
commit a37630aeac
37 changed files with 6638 additions and 0 deletions
@@ -0,0 +1,582 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class DocumentToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public DocumentToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
#region Search Tests
[Fact]
public async Task Search_WithQuery_ReturnsResults()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(5));
// Act
var result = await DocumentTools.Search(_factory.Client, query: "invoice");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(5);
json.RootElement.GetProperty("meta").GetProperty("total").GetInt32().Should().Be(5);
}
[Fact]
public async Task Search_WithPagination_IncludesMetadata()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(50));
// Act
var result = await DocumentTools.Search(_factory.Client, page: 2, pageSize: 10);
// Assert
var json = JsonDocument.Parse(result);
var meta = json.RootElement.GetProperty("meta");
meta.GetProperty("page").GetInt32().Should().Be(2);
meta.GetProperty("page_size").GetInt32().Should().Be(10);
}
[Fact]
public async Task Search_WithFilters_PassesFiltersCorrectly()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2));
// Act
var result = await DocumentTools.Search(
_factory.Client,
query: "test",
tags: "1,2",
correspondent: 3,
documentType: 4,
createdAfter: "2024-01-01",
createdBefore: "2024-12-31"
);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task Search_ByDefault_ExcludesContent()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2));
// Act
var result = await DocumentTools.Search(_factory.Client, query: "test");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
var results = json.RootElement.GetProperty("result");
results.GetArrayLength().Should().Be(2);
// Content should be null when includeContent is false (default)
foreach (var doc in results.EnumerateArray())
{
doc.GetProperty("content").ValueKind.Should().Be(JsonValueKind.Null);
}
}
[Fact]
public async Task Search_WithIncludeContent_ReturnsContent()
{
// Arrange
var longContent = TestFixtures.Documents.CreateLongContent(1000);
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2, longContent));
// Act
var result = await DocumentTools.Search(
_factory.Client,
query: "test",
includeContent: true,
contentMaxLength: 0); // Unlimited
// Assert
var json = JsonDocument.Parse(result);
var results = json.RootElement.GetProperty("result");
foreach (var doc in results.EnumerateArray())
{
var content = doc.GetProperty("content").GetString();
content.Should().NotBeNullOrEmpty();
content.Should().Be(longContent);
}
}
[Fact]
public async Task Search_WithContentMaxLength_TruncatesContent()
{
// Arrange
var longContent = TestFixtures.Documents.CreateLongContent(1000);
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2, longContent));
// Act
var result = await DocumentTools.Search(
_factory.Client,
query: "test",
includeContent: true,
contentMaxLength: 100);
// Assert
var json = JsonDocument.Parse(result);
var results = json.RootElement.GetProperty("result");
foreach (var doc in results.EnumerateArray())
{
var content = doc.GetProperty("content").GetString();
content.Should().NotBeNullOrEmpty();
content!.Length.Should().Be(103); // 100 chars + "..."
content.Should().EndWith("...");
}
}
[Fact]
public async Task Search_ReturnsDocumentSummaryFields()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(1));
// Act
var result = await DocumentTools.Search(_factory.Client, query: "test");
// Assert
var json = JsonDocument.Parse(result);
var doc = json.RootElement.GetProperty("result")[0];
// DocumentSummary fields should be present
doc.GetProperty("id").GetInt32().Should().BeGreaterThan(0);
doc.GetProperty("title").GetString().Should().NotBeNullOrEmpty();
doc.GetProperty("correspondent").ValueKind.Should().NotBe(JsonValueKind.Undefined);
doc.GetProperty("document_type").ValueKind.Should().NotBe(JsonValueKind.Undefined);
doc.GetProperty("tags").GetArrayLength().Should().BeGreaterThanOrEqualTo(0);
doc.GetProperty("created").ValueKind.Should().NotBe(JsonValueKind.Undefined);
// SearchHit should be present
doc.GetProperty("__search_hit__").GetProperty("score").GetDouble().Should().BeGreaterThan(0);
}
#endregion
#region Get Tests
[Fact]
public async Task Get_WhenDocumentExists_ReturnsDocument()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Invoice"));
// Act
var result = await DocumentTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("id").GetInt32().Should().Be(1);
json.RootElement.GetProperty("result").GetProperty("title").GetString().Should().Be("Test Invoice");
}
[Fact]
public async Task Get_WhenDocumentNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await DocumentTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
#endregion
#region Download Tests
[Fact]
public async Task Download_WhenDocumentExists_ReturnsDownloadUrls()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await DocumentTools.Download(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
var downloadResult = json.RootElement.GetProperty("result");
downloadResult.GetProperty("download_url").GetString().Should().Contain("/api/documents/1/download/");
downloadResult.GetProperty("preview_url").GetString().Should().Contain("/api/documents/1/preview/");
downloadResult.GetProperty("thumbnail_url").GetString().Should().Contain("/api/documents/1/thumb/");
}
[Fact]
public async Task Preview_WhenDocumentExists_ReturnsPreviewUrl()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await DocumentTools.Preview(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("preview_url").GetString()
.Should().Contain("/api/documents/1/preview/");
}
[Fact]
public async Task Thumbnail_WhenDocumentExists_ReturnsThumbnailUrl()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await DocumentTools.Thumbnail(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("thumbnail_url").GetString()
.Should().Contain("/api/documents/1/thumb/");
}
#endregion
#region Upload Tests
[Fact]
public async Task Upload_WithValidBase64_ReturnsTaskId()
{
// Arrange
var fileContent = Convert.ToBase64String("Test file content"u8.ToArray());
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/documents/post_document/")
.Respond("application/json", "\"task-uuid-12345\"");
// Act
var result = await DocumentTools.Upload(
_factory.Client,
fileContent,
"test.pdf",
title: "Test Upload");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("task_id").GetString().Should().Be("task-uuid-12345");
}
[Fact]
public async Task Upload_WithInvalidBase64_ReturnsValidationError()
{
// Act
var result = await DocumentTools.Upload(
_factory.Client,
"not-valid-base64!!!",
"test.pdf");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
[Fact]
public async Task UploadFromPath_WhenFileNotFound_ReturnsError()
{
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
"/nonexistent/path/to/file.pdf");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task UploadFromPath_WithRelativePath_ReturnsValidationError()
{
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
"relative/path/to/file.pdf");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
[Fact]
public async Task UploadFromPath_WithValidFile_ReturnsTaskId()
{
// Arrange
var tempFile = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempFile, "Test file content for upload");
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/documents/post_document/")
.Respond("application/json", "\"task-uuid-from-path-12345\"");
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
tempFile,
title: "Test Path Upload");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("task_id").GetString().Should().Be("task-uuid-from-path-12345");
json.RootElement.GetProperty("result").GetProperty("file_name").GetString().Should().NotBeNullOrEmpty();
json.RootElement.GetProperty("result").GetProperty("file_size").GetInt64().Should().BeGreaterThan(0);
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public async Task UploadFromPath_ExpandsTildeToHome()
{
// This test verifies tilde expansion happens (even if file doesn't exist)
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
"~/nonexistent_test_file_12345.pdf");
// Assert - Should try to find the file (and fail with NOT_FOUND, not VALIDATION)
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
#endregion
#region Update Tests
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedDocument()
{
// Arrange
_factory.SetupPatch("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Updated Title"));
// Act
var result = await DocumentTools.Update(_factory.Client, 1, title: "Updated Title");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("title").GetString().Should().Be("Updated Title");
}
[Fact]
public async Task Update_WhenNotFound_ReturnsError()
{
// Arrange
_factory.SetupPatchWithStatus("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await DocumentTools.Update(_factory.Client, 999, title: "New Title");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
#endregion
#region Delete Tests
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Doc to Delete"));
// Act
var result = await DocumentTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesDocument()
{
// Arrange
_factory.SetupDelete("api/documents/1/", HttpStatusCode.NoContent);
// Act
var result = await DocumentTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
#endregion
#region Bulk Update Tests
[Fact]
public async Task BulkUpdate_WithDryRun_ReturnsPreview()
{
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "1,2,3",
operation: "add_tag",
value: 5,
dryRun: true,
confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task BulkUpdate_WithConfirmation_ExecutesOperation()
{
// Arrange
_factory.SetupPost("api/documents/bulk_edit/", "{}");
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "1,2,3",
operation: "add_tag",
value: 5,
dryRun: false,
confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkUpdate_WithInvalidOperation_ReturnsValidationError()
{
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "1,2,3",
operation: "invalid_operation",
dryRun: false,
confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
[Fact]
public async Task BulkUpdate_WithEmptyIds_ReturnsValidationError()
{
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "",
operation: "add_tag",
dryRun: false,
confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
#endregion
#region Reprocess Tests
[Fact]
public async Task Reprocess_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Doc to Reprocess"));
// Act
var result = await DocumentTools.Reprocess(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Reprocess_WithConfirmation_QueuesReprocessing()
{
// Arrange
_factory.SetupPost("api/documents/bulk_edit/", "{}");
// Act
var result = await DocumentTools.Reprocess(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("status").GetString().Should().Be("queued");
}
#endregion
}