commit a37630aeac4272c04088f3b580e9cc2c47760925 Author: Barry Walker Date: Tue Jan 13 14:01:44 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c9929d --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# IDE +.idea/ +.vs/ +*.swp +*~ + +# macOS +.DS_Store + +# Test results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx +TestResults/ + +# NuGet +*.nupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ + +# Environment files (may contain secrets) +.env +.env.* +*.local + +# Claude Code local settings +.claude/ + +# Rider +.idea/ +*.sln.iml diff --git a/PaperlessMCP.Tests/Client/PaperlessClientTests.cs b/PaperlessMCP.Tests/Client/PaperlessClientTests.cs new file mode 100644 index 0000000..db668b0 --- /dev/null +++ b/PaperlessMCP.Tests/Client/PaperlessClientTests.cs @@ -0,0 +1,416 @@ +using System.Net; +using FluentAssertions; +using PaperlessMCP.Models.Correspondents; +using RichardSzalay.MockHttp; +using Xunit; +using PaperlessMCP.Models.CustomFields; +using PaperlessMCP.Models.DocumentTypes; +using PaperlessMCP.Models.StoragePaths; +using PaperlessMCP.Models.Tags; +using PaperlessMCP.Tests.Fixtures; + +namespace PaperlessMCP.Tests.Client; + +public class PaperlessClientTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public PaperlessClientTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + #region Ping Tests + + [Fact] + public async Task PingAsync_WhenSuccessful_ReturnsSuccess() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/") + .Respond(req => + { + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Headers.Add("X-Version", "2.0.0"); + return response; + }); + + // Act + var (success, version, error) = await _factory.Client.PingAsync(); + + // Assert + success.Should().BeTrue(); + version.Should().Be("2.0.0"); + error.Should().BeNull(); + } + + [Fact] + public async Task PingAsync_WhenUnauthorized_ReturnsFailure() + { + // Arrange + _factory.SetupGetWithStatus("api/", HttpStatusCode.Unauthorized); + + // Act + var (success, version, error) = await _factory.Client.PingAsync(); + + // Assert + success.Should().BeFalse(); + version.Should().BeNull(); + error.Should().Contain("401"); + } + + #endregion + + #region Document Tests + + [Fact] + public async Task SearchDocumentsAsync_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 _factory.Client.SearchDocumentsAsync(query: "test"); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(5); + result.Results.Should().HaveCount(5); + } + + [Fact] + public async Task SearchDocumentsAsync_WithFilters_ReturnsFilteredResults() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/documents/*") + .Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2)); + + // Act + var result = await _factory.Client.SearchDocumentsAsync( + query: "invoice", + tags: [1, 2], + correspondent: 3, + documentType: 4); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(2); + } + + [Fact] + public async Task GetDocumentAsync_WhenExists_ReturnsDocument() + { + // Arrange + _factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "My Document")); + + // Act + var result = await _factory.Client.GetDocumentAsync(1); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(1); + result.Title.Should().Be("My Document"); + } + + [Fact] + public async Task GetDocumentAsync_WhenNotFound_ReturnsNull() + { + // Arrange + _factory.SetupGetWithStatus("api/documents/999/", HttpStatusCode.NotFound); + + // Act + var result = await _factory.Client.GetDocumentAsync(999); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task DeleteDocumentAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + _factory.SetupDelete("api/documents/1/", HttpStatusCode.NoContent); + + // Act + var result = await _factory.Client.DeleteDocumentAsync(1); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task DeleteDocumentAsync_WhenNotFound_ReturnsFalse() + { + // Arrange + _factory.SetupDelete("api/documents/999/", HttpStatusCode.NotFound); + + // Act + var result = await _factory.Client.DeleteDocumentAsync(999); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task GetDocumentDownloadInfo_ReturnsCorrectUrls() + { + // Act + var result = _factory.Client.GetDocumentDownloadInfo(1, "Test Doc", "test.pdf"); + + // Assert + result.Id.Should().Be(1); + result.Title.Should().Be("Test Doc"); + result.OriginalFileName.Should().Be("test.pdf"); + result.DownloadUrl.Should().Be("https://paperless.example.com/api/documents/1/download/"); + result.PreviewUrl.Should().Be("https://paperless.example.com/api/documents/1/preview/"); + result.ThumbnailUrl.Should().Be("https://paperless.example.com/api/documents/1/thumb/"); + } + + #endregion + + #region Tag Tests + + [Fact] + public async Task GetTagsAsync_ReturnsTagList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/tags/*") + .Respond("application/json", TestFixtures.Tags.CreateTagListJson(5)); + + // Act + var result = await _factory.Client.GetTagsAsync(); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(5); + result.Results.Should().HaveCount(5); + } + + [Fact] + public async Task GetTagAsync_WhenExists_ReturnsTag() + { + // Arrange + _factory.SetupGet("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Important")); + + // Act + var result = await _factory.Client.GetTagAsync(1); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(1); + result.Name.Should().Be("Important"); + } + + [Fact] + public async Task CreateTagAsync_WhenSuccessful_ReturnsTag() + { + // Arrange + _factory.SetupPost("api/tags/", TestFixtures.Tags.CreateTagJson(5, "New Tag")); + + // Act + var result = await _factory.Client.CreateTagAsync(new TagCreateRequest { Name = "New Tag" }); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(5); + result.Name.Should().Be("New Tag"); + } + + [Fact] + public async Task DeleteTagAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + _factory.SetupDelete("api/tags/1/", HttpStatusCode.NoContent); + + // Act + var result = await _factory.Client.DeleteTagAsync(1); + + // Assert + result.Should().BeTrue(); + } + + #endregion + + #region Correspondent Tests + + [Fact] + public async Task GetCorrespondentsAsync_ReturnsCorrespondentList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/correspondents/*") + .Respond("application/json", TestFixtures.Correspondents.CreateCorrespondentListJson(3)); + + // Act + var result = await _factory.Client.GetCorrespondentsAsync(); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(3); + } + + [Fact] + public async Task CreateCorrespondentAsync_WhenSuccessful_ReturnsCorrespondent() + { + // Arrange + _factory.SetupPost("api/correspondents/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "ACME Corp")); + + // Act + var result = await _factory.Client.CreateCorrespondentAsync(new CorrespondentCreateRequest { Name = "ACME Corp" }); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("ACME Corp"); + } + + #endregion + + #region Document Type Tests + + [Fact] + public async Task GetDocumentTypesAsync_ReturnsDocumentTypeList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/document_types/*") + .Respond("application/json", TestFixtures.DocumentTypes.CreateDocumentTypeListJson(4)); + + // Act + var result = await _factory.Client.GetDocumentTypesAsync(); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(4); + } + + [Fact] + public async Task CreateDocumentTypeAsync_WhenSuccessful_ReturnsDocumentType() + { + // Arrange + _factory.SetupPost("api/document_types/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Invoice")); + + // Act + var result = await _factory.Client.CreateDocumentTypeAsync(new DocumentTypeCreateRequest { Name = "Invoice" }); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("Invoice"); + } + + #endregion + + #region Storage Path Tests + + [Fact] + public async Task GetStoragePathsAsync_ReturnsStoragePathList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/storage_paths/*") + .Respond("application/json", TestFixtures.StoragePaths.CreateStoragePathListJson(2)); + + // Act + var result = await _factory.Client.GetStoragePathsAsync(); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(2); + } + + [Fact] + public async Task CreateStoragePathAsync_WhenSuccessful_ReturnsStoragePath() + { + // Arrange + _factory.SetupPost("api/storage_paths/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "Archive")); + + // Act + var result = await _factory.Client.CreateStoragePathAsync(new StoragePathCreateRequest + { + Name = "Archive", + Path = "{correspondent}/{year}" + }); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("Archive"); + } + + #endregion + + #region Custom Field Tests + + [Fact] + public async Task GetCustomFieldsAsync_ReturnsCustomFieldList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/custom_fields/*") + .Respond("application/json", TestFixtures.CustomFields.CreateCustomFieldListJson(3)); + + // Act + var result = await _factory.Client.GetCustomFieldsAsync(); + + // Assert + result.Should().NotBeNull(); + result.Count.Should().Be(3); + } + + [Fact] + public async Task CreateCustomFieldAsync_WhenSuccessful_ReturnsCustomField() + { + // Arrange + _factory.SetupPost("api/custom_fields/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Invoice Number")); + + // Act + var result = await _factory.Client.CreateCustomFieldAsync(new CustomFieldCreateRequest + { + Name = "Invoice Number", + DataType = "string" + }); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("Invoice Number"); + } + + #endregion + + #region Bulk Operations Tests + + [Fact] + public async Task BulkEditDocumentsAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + _factory.SetupPost("api/documents/bulk_edit/", "{}"); + + // Act + var result = await _factory.Client.BulkEditDocumentsAsync([1, 2, 3], "add_tag", new { tag = 5 }); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task BulkEditObjectsAsync_WhenSuccessful_ReturnsTrue() + { + // Arrange + _factory.SetupPost("api/bulk_edit_objects/", "{}"); + + // Act + var result = await _factory.Client.BulkEditObjectsAsync([1, 2], "tags", "delete"); + + // Assert + result.Should().BeTrue(); + } + + #endregion +} diff --git a/PaperlessMCP.Tests/Fixtures/MockHttpClientFactory.cs b/PaperlessMCP.Tests/Fixtures/MockHttpClientFactory.cs new file mode 100644 index 0000000..6402420 --- /dev/null +++ b/PaperlessMCP.Tests/Fixtures/MockHttpClientFactory.cs @@ -0,0 +1,102 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using PaperlessMCP.Client; +using PaperlessMCP.Configuration; +using RichardSzalay.MockHttp; + +namespace PaperlessMCP.Tests.Fixtures; + +public class MockHttpClientFactory : IDisposable +{ + public MockHttpMessageHandler MockHandler { get; } + public HttpClient HttpClient { get; } + public PaperlessClient Client { get; } + public PaperlessOptions Options { get; } + + public MockHttpClientFactory(string baseUrl = "https://paperless.example.com") + { + Options = new PaperlessOptions + { + BaseUrl = baseUrl, + ApiToken = "test-token", + MaxPageSize = 100 + }; + + MockHandler = new MockHttpMessageHandler(); + HttpClient = MockHandler.ToHttpClient(); + HttpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + HttpClient.DefaultRequestHeaders.Add("Accept", "application/json; version=9"); + + var optionsMock = Substitute.For>(); + optionsMock.Value.Returns(Options); + + var logger = Substitute.For>(); + + Client = new PaperlessClient(HttpClient, optionsMock, logger); + } + + public MockedRequest SetupGet(string url, string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return MockHandler + .When(HttpMethod.Get, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond("application/json", responseJson); + } + + public MockedRequest SetupGetWithStatus(string url, HttpStatusCode statusCode) + { + return MockHandler + .When(HttpMethod.Get, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond(statusCode); + } + + public MockedRequest SetupPost(string url, string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return MockHandler + .When(HttpMethod.Post, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond("application/json", responseJson); + } + + public MockedRequest SetupPostWithStatus(string url, HttpStatusCode statusCode) + { + return MockHandler + .When(HttpMethod.Post, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond(statusCode); + } + + public MockedRequest SetupPatch(string url, string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return MockHandler + .When(HttpMethod.Patch, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond("application/json", responseJson); + } + + public MockedRequest SetupPatchWithStatus(string url, HttpStatusCode statusCode) + { + return MockHandler + .When(HttpMethod.Patch, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond(statusCode); + } + + public MockedRequest SetupDelete(string url, HttpStatusCode statusCode = HttpStatusCode.NoContent) + { + return MockHandler + .When(HttpMethod.Delete, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}") + .Respond(statusCode); + } + + public void Dispose() + { + HttpClient.Dispose(); + MockHandler.Dispose(); + } +} + +public static class MockHttpExtensions +{ + public static MockedRequest RespondWithJson(this MockedRequest request, string json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return request.Respond(statusCode, "application/json", json); + } +} diff --git a/PaperlessMCP.Tests/Fixtures/TestFixtures.cs b/PaperlessMCP.Tests/Fixtures/TestFixtures.cs new file mode 100644 index 0000000..4639f0b --- /dev/null +++ b/PaperlessMCP.Tests/Fixtures/TestFixtures.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.Correspondents; +using PaperlessMCP.Models.CustomFields; +using PaperlessMCP.Models.Documents; +using PaperlessMCP.Models.DocumentTypes; +using PaperlessMCP.Models.StoragePaths; +using PaperlessMCP.Models.Tags; + +namespace PaperlessMCP.Tests.Fixtures; + +public static class TestFixtures +{ + public static class Documents + { + public static Document CreateDocument(int id = 1, string title = "Test Document") => new() + { + Id = id, + Title = title, + Content = "This is test content for the document.", + Correspondent = 1, + DocumentType = 1, + StoragePath = 1, + Tags = [1, 2], + Created = DateTime.UtcNow.AddDays(-10), + Modified = DateTime.UtcNow, + Added = DateTime.UtcNow.AddDays(-5), + ArchiveSerialNumber = 1001, + OriginalFileName = "test_document.pdf", + ArchivedFileName = "test_document_archived.pdf", + Owner = 1, + CustomFields = [] + }; + + public static DocumentSearchResult CreateSearchResult(int id = 1, string title = "Test Document", double score = 1.5, string? content = null) => new() + { + Id = id, + Title = title, + Content = content ?? "This is test content for the document.", + Correspondent = 1, + DocumentType = 1, + Tags = [1, 2], + Created = DateTime.UtcNow.AddDays(-10), + SearchHit = new SearchHit + { + Score = score, + Highlights = $"test content", + Rank = 1 + } + }; + + public static PaginatedResult CreateSearchResults(int count = 3, string? content = null) => new() + { + Count = count, + Next = count > 25 ? "http://example.com/api/documents/?page=2" : null, + Previous = null, + Results = Enumerable.Range(1, Math.Min(count, 25)) + .Select(i => CreateSearchResult(i, $"Document {i}", content: content)) + .ToList() + }; + + public static string CreateSearchResultsJson(int count = 3, string? content = null) => + JsonSerializer.Serialize(CreateSearchResults(count, content)); + + /// + /// Creates a long content string for testing truncation behavior. + /// + public static string CreateLongContent(int length = 1000) => + string.Join(" ", Enumerable.Repeat("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", length / 56 + 1))[..length]; + + public static string CreateDocumentJson(int id = 1, string title = "Test Document") => + JsonSerializer.Serialize(CreateDocument(id, title)); + } + + public static class Tags + { + public static Tag CreateTag(int id = 1, string name = "Test Tag") => new() + { + Id = id, + Slug = name.ToLower().Replace(" ", "-"), + Name = name, + Color = "#ff0000", + TextColor = "#ffffff", + Match = "", + MatchingAlgorithm = 0, + IsInboxTag = false, + DocumentCount = 5, + Owner = 1 + }; + + public static PaginatedResult CreateTagList(int count = 3) => new() + { + Count = count, + Next = null, + Previous = null, + Results = Enumerable.Range(1, count) + .Select(i => CreateTag(i, $"Tag {i}")) + .ToList() + }; + + public static string CreateTagJson(int id = 1, string name = "Test Tag") => + JsonSerializer.Serialize(CreateTag(id, name)); + + public static string CreateTagListJson(int count = 3) => + JsonSerializer.Serialize(CreateTagList(count)); + } + + public static class Correspondents + { + public static Correspondent CreateCorrespondent(int id = 1, string name = "Test Correspondent") => new() + { + Id = id, + Slug = name.ToLower().Replace(" ", "-"), + Name = name, + Match = "", + MatchingAlgorithm = 0, + DocumentCount = 10, + LastCorrespondence = DateTime.UtcNow.AddDays(-1), + Owner = 1 + }; + + public static PaginatedResult CreateCorrespondentList(int count = 3) => new() + { + Count = count, + Next = null, + Previous = null, + Results = Enumerable.Range(1, count) + .Select(i => CreateCorrespondent(i, $"Correspondent {i}")) + .ToList() + }; + + public static string CreateCorrespondentJson(int id = 1, string name = "Test Correspondent") => + JsonSerializer.Serialize(CreateCorrespondent(id, name)); + + public static string CreateCorrespondentListJson(int count = 3) => + JsonSerializer.Serialize(CreateCorrespondentList(count)); + } + + public static class DocumentTypes + { + public static DocumentType CreateDocumentType(int id = 1, string name = "Test Type") => new() + { + Id = id, + Slug = name.ToLower().Replace(" ", "-"), + Name = name, + Match = "", + MatchingAlgorithm = 0, + DocumentCount = 15, + Owner = 1 + }; + + public static PaginatedResult CreateDocumentTypeList(int count = 3) => new() + { + Count = count, + Next = null, + Previous = null, + Results = Enumerable.Range(1, count) + .Select(i => CreateDocumentType(i, $"Type {i}")) + .ToList() + }; + + public static string CreateDocumentTypeJson(int id = 1, string name = "Test Type") => + JsonSerializer.Serialize(CreateDocumentType(id, name)); + + public static string CreateDocumentTypeListJson(int count = 3) => + JsonSerializer.Serialize(CreateDocumentTypeList(count)); + } + + public static class StoragePaths + { + public static StoragePath CreateStoragePath(int id = 1, string name = "Test Path") => new() + { + Id = id, + Slug = name.ToLower().Replace(" ", "-"), + Name = name, + Path = "{correspondent}/{document_type}", + Match = "", + MatchingAlgorithm = 0, + DocumentCount = 8, + Owner = 1 + }; + + public static PaginatedResult CreateStoragePathList(int count = 3) => new() + { + Count = count, + Next = null, + Previous = null, + Results = Enumerable.Range(1, count) + .Select(i => CreateStoragePath(i, $"Path {i}")) + .ToList() + }; + + public static string CreateStoragePathJson(int id = 1, string name = "Test Path") => + JsonSerializer.Serialize(CreateStoragePath(id, name)); + + public static string CreateStoragePathListJson(int count = 3) => + JsonSerializer.Serialize(CreateStoragePathList(count)); + } + + public static class CustomFields + { + public static CustomField CreateCustomField(int id = 1, string name = "Test Field", string dataType = "string") => new() + { + Id = id, + Name = name, + DataType = dataType, + ExtraData = dataType == "select" ? new CustomFieldExtraData { SelectOptions = ["Option 1", "Option 2"] } : null + }; + + public static PaginatedResult CreateCustomFieldList(int count = 3) => new() + { + Count = count, + Next = null, + Previous = null, + Results = Enumerable.Range(1, count) + .Select(i => CreateCustomField(i, $"Field {i}")) + .ToList() + }; + + public static string CreateCustomFieldJson(int id = 1, string name = "Test Field") => + JsonSerializer.Serialize(CreateCustomField(id, name)); + + public static string CreateCustomFieldListJson(int count = 3) => + JsonSerializer.Serialize(CreateCustomFieldList(count)); + } +} diff --git a/PaperlessMCP.Tests/PaperlessMCP.Tests.csproj b/PaperlessMCP.Tests/PaperlessMCP.Tests.csproj new file mode 100644 index 0000000..2ed8ac9 --- /dev/null +++ b/PaperlessMCP.Tests/PaperlessMCP.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/PaperlessMCP.Tests/Tools/CorrespondentToolsTests.cs b/PaperlessMCP.Tests/Tools/CorrespondentToolsTests.cs new file mode 100644 index 0000000..2a29033 --- /dev/null +++ b/PaperlessMCP.Tests/Tools/CorrespondentToolsTests.cs @@ -0,0 +1,158 @@ +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 CorrespondentToolsTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public CorrespondentToolsTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + [Fact] + public async Task List_ReturnsCorrespondentList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/correspondents/*") + .Respond("application/json", TestFixtures.Correspondents.CreateCorrespondentListJson(5)); + + // Act + var result = await CorrespondentTools.List(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetArrayLength().Should().Be(5); + } + + [Fact] + public async Task Get_WhenExists_ReturnsCorrespondent() + { + // Arrange + _factory.SetupGet("api/correspondents/1/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "ACME Corp")); + + // Act + var result = await CorrespondentTools.Get(_factory.Client, 1); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("ACME Corp"); + } + + [Fact] + public async Task Get_WhenNotFound_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/correspondents/999/", HttpStatusCode.NotFound); + + // Act + var result = await CorrespondentTools.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"); + } + + [Fact] + public async Task Create_WhenSuccessful_ReturnsCreatedCorrespondent() + { + // Arrange + _factory.SetupPost("api/correspondents/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "New Company")); + + // Act + var result = await CorrespondentTools.Create(_factory.Client, "New Company"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("New Company"); + } + + [Fact] + public async Task Update_WhenSuccessful_ReturnsUpdatedCorrespondent() + { + // Arrange + _factory.SetupPatch("api/correspondents/1/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "Updated Company")); + + // Act + var result = await CorrespondentTools.Update(_factory.Client, 1, name: "Updated Company"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Company"); + } + + [Fact] + public async Task Delete_WithoutConfirmation_ReturnsDryRun() + { + // Arrange + _factory.SetupGet("api/correspondents/1/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "To Delete")); + + // Act + var result = await CorrespondentTools.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_DeletesCorrespondent() + { + // Arrange + _factory.SetupDelete("api/correspondents/1/", HttpStatusCode.NoContent); + + // Act + var result = await CorrespondentTools.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(); + } + + [Fact] + public async Task BulkDelete_WithDryRun_ReturnsPreview() + { + // Act + var result = await CorrespondentTools.BulkDelete(_factory.Client, "1,2", dryRun: true); + + // 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 BulkDelete_WithConfirmation_ExecutesDeletion() + { + // Arrange + _factory.SetupPost("api/bulk_edit_objects/", "{}"); + + // Act + var result = await CorrespondentTools.BulkDelete(_factory.Client, "1,2", 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(); + } +} diff --git a/PaperlessMCP.Tests/Tools/CustomFieldToolsTests.cs b/PaperlessMCP.Tests/Tools/CustomFieldToolsTests.cs new file mode 100644 index 0000000..dbca664 --- /dev/null +++ b/PaperlessMCP.Tests/Tools/CustomFieldToolsTests.cs @@ -0,0 +1,199 @@ +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 CustomFieldToolsTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public CustomFieldToolsTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + [Fact] + public async Task List_ReturnsCustomFieldList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/custom_fields/*") + .Respond("application/json", TestFixtures.CustomFields.CreateCustomFieldListJson(4)); + + // Act + var result = await CustomFieldTools.List(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetArrayLength().Should().Be(4); + } + + [Fact] + public async Task Get_WhenExists_ReturnsCustomField() + { + // Arrange + _factory.SetupGet("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Invoice Number")); + + // Act + var result = await CustomFieldTools.Get(_factory.Client, 1); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Invoice Number"); + } + + [Fact] + public async Task Get_WhenNotFound_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/custom_fields/999/", HttpStatusCode.NotFound); + + // Act + var result = await CustomFieldTools.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"); + } + + [Fact] + public async Task Create_StringField_ReturnsCreatedField() + { + // Arrange + _factory.SetupPost("api/custom_fields/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Reference Number")); + + // Act + var result = await CustomFieldTools.Create(_factory.Client, "Reference Number", "string"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Reference Number"); + } + + [Fact] + public async Task Create_SelectField_IncludesOptions() + { + // Arrange + var selectField = TestFixtures.CustomFields.CreateCustomField(1, "Status", "select"); + _factory.SetupPost("api/custom_fields/", JsonSerializer.Serialize(selectField)); + + // Act + var result = await CustomFieldTools.Create( + _factory.Client, + "Status", + "select", + selectOptions: "Pending,Approved,Rejected"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task Update_WhenSuccessful_ReturnsUpdatedField() + { + // Arrange + _factory.SetupPatch("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Updated Field")); + + // Act + var result = await CustomFieldTools.Update(_factory.Client, 1, name: "Updated Field"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Field"); + } + + [Fact] + public async Task Delete_WithoutConfirmation_ReturnsDryRun() + { + // Arrange + _factory.SetupGet("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "To Delete")); + + // Act + var result = await CustomFieldTools.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_DeletesField() + { + // Arrange + _factory.SetupDelete("api/custom_fields/1/", HttpStatusCode.NoContent); + + // Act + var result = await CustomFieldTools.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(); + } + + [Fact] + public async Task Assign_WhenDocumentExists_AssignsFieldValue() + { + // Arrange + _factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc")); + _factory.SetupGet("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Invoice Number")); + _factory.SetupPatch("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc")); + + // Act + var result = await CustomFieldTools.Assign(_factory.Client, documentId: 1, fieldId: 1, value: "INV-001"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("document_id").GetInt32().Should().Be(1); + json.RootElement.GetProperty("result").GetProperty("field_id").GetInt32().Should().Be(1); + } + + [Fact] + public async Task Assign_WhenDocumentNotFound_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/documents/999/", HttpStatusCode.NotFound); + + // Act + var result = await CustomFieldTools.Assign(_factory.Client, documentId: 999, fieldId: 1, value: "test"); + + // 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 Assign_WhenFieldNotFound_ReturnsError() + { + // Arrange + _factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc")); + _factory.SetupGetWithStatus("api/custom_fields/999/", HttpStatusCode.NotFound); + + // Act + var result = await CustomFieldTools.Assign(_factory.Client, documentId: 1, fieldId: 999, value: "test"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse(); + json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND"); + } +} diff --git a/PaperlessMCP.Tests/Tools/DocumentToolsTests.cs b/PaperlessMCP.Tests/Tools/DocumentToolsTests.cs new file mode 100644 index 0000000..c017b74 --- /dev/null +++ b/PaperlessMCP.Tests/Tools/DocumentToolsTests.cs @@ -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 +} diff --git a/PaperlessMCP.Tests/Tools/DocumentTypeToolsTests.cs b/PaperlessMCP.Tests/Tools/DocumentTypeToolsTests.cs new file mode 100644 index 0000000..afa954b --- /dev/null +++ b/PaperlessMCP.Tests/Tools/DocumentTypeToolsTests.cs @@ -0,0 +1,146 @@ +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 DocumentTypeToolsTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public DocumentTypeToolsTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + [Fact] + public async Task List_ReturnsDocumentTypeList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/document_types/*") + .Respond("application/json", TestFixtures.DocumentTypes.CreateDocumentTypeListJson(4)); + + // Act + var result = await DocumentTypeTools.List(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetArrayLength().Should().Be(4); + } + + [Fact] + public async Task Get_WhenExists_ReturnsDocumentType() + { + // Arrange + _factory.SetupGet("api/document_types/1/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Invoice")); + + // Act + var result = await DocumentTypeTools.Get(_factory.Client, 1); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Invoice"); + } + + [Fact] + public async Task Get_WhenNotFound_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/document_types/999/", HttpStatusCode.NotFound); + + // Act + var result = await DocumentTypeTools.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"); + } + + [Fact] + public async Task Create_WhenSuccessful_ReturnsCreatedType() + { + // Arrange + _factory.SetupPost("api/document_types/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Contract")); + + // Act + var result = await DocumentTypeTools.Create(_factory.Client, "Contract"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Contract"); + } + + [Fact] + public async Task Update_WhenSuccessful_ReturnsUpdatedType() + { + // Arrange + _factory.SetupPatch("api/document_types/1/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Updated Type")); + + // Act + var result = await DocumentTypeTools.Update(_factory.Client, 1, name: "Updated Type"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Type"); + } + + [Fact] + public async Task Delete_WithoutConfirmation_ReturnsDryRun() + { + // Arrange + _factory.SetupGet("api/document_types/1/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "To Delete")); + + // Act + var result = await DocumentTypeTools.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_DeletesType() + { + // Arrange + _factory.SetupDelete("api/document_types/1/", HttpStatusCode.NoContent); + + // Act + var result = await DocumentTypeTools.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(); + } + + [Fact] + public async Task BulkDelete_WithConfirmation_ExecutesDeletion() + { + // Arrange + _factory.SetupPost("api/bulk_edit_objects/", "{}"); + + // Act + var result = await DocumentTypeTools.BulkDelete(_factory.Client, "1,2,3", 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(); + } +} diff --git a/PaperlessMCP.Tests/Tools/HealthToolsTests.cs b/PaperlessMCP.Tests/Tools/HealthToolsTests.cs new file mode 100644 index 0000000..f750065 --- /dev/null +++ b/PaperlessMCP.Tests/Tools/HealthToolsTests.cs @@ -0,0 +1,114 @@ +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 HealthToolsTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public HealthToolsTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + [Fact] + public async Task Ping_WhenConnected_ReturnsSuccess() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/") + .Respond(req => + { + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Headers.Add("X-Version", "2.5.0"); + return response; + }); + + // Act + var result = await HealthTools.Ping(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("connected").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task Ping_WhenConnectionFails_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/", HttpStatusCode.Unauthorized); + + // Act + var result = await HealthTools.Ping(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse(); + json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR"); + } + + [Fact] + public async Task GetCapabilities_ReturnsCapabilitiesInfo() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/") + .Respond(req => + { + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Headers.Add("X-Version", "2.5.0"); + return response; + }); + + _factory.SetupGet("api/status/", "{}"); + + // Act + var result = await HealthTools.GetCapabilities(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + + var capabilities = json.RootElement.GetProperty("result"); + capabilities.GetProperty("connected").GetBoolean().Should().BeTrue(); + capabilities.GetProperty("endpoints").Should().NotBeNull(); + capabilities.GetProperty("bulk_edit_methods").GetArrayLength().Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetCapabilities_IncludesAllEndpointCategories() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/") + .Respond(HttpStatusCode.OK); + + _factory.SetupGet("api/status/", "{}"); + + // Act + var result = await HealthTools.GetCapabilities(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + var endpoints = json.RootElement.GetProperty("result").GetProperty("endpoints"); + + endpoints.GetProperty("documents").Should().NotBeNull(); + endpoints.GetProperty("tags").Should().NotBeNull(); + endpoints.GetProperty("correspondents").Should().NotBeNull(); + endpoints.GetProperty("document_types").Should().NotBeNull(); + endpoints.GetProperty("storage_paths").Should().NotBeNull(); + endpoints.GetProperty("custom_fields").Should().NotBeNull(); + } +} diff --git a/PaperlessMCP.Tests/Tools/StoragePathToolsTests.cs b/PaperlessMCP.Tests/Tools/StoragePathToolsTests.cs new file mode 100644 index 0000000..7d7acf8 --- /dev/null +++ b/PaperlessMCP.Tests/Tools/StoragePathToolsTests.cs @@ -0,0 +1,146 @@ +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 StoragePathToolsTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public StoragePathToolsTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + [Fact] + public async Task List_ReturnsStoragePathList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/storage_paths/*") + .Respond("application/json", TestFixtures.StoragePaths.CreateStoragePathListJson(3)); + + // Act + var result = await StoragePathTools.List(_factory.Client); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetArrayLength().Should().Be(3); + } + + [Fact] + public async Task Get_WhenExists_ReturnsStoragePath() + { + // Arrange + _factory.SetupGet("api/storage_paths/1/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "Archive")); + + // Act + var result = await StoragePathTools.Get(_factory.Client, 1); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Archive"); + } + + [Fact] + public async Task Get_WhenNotFound_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/storage_paths/999/", HttpStatusCode.NotFound); + + // Act + var result = await StoragePathTools.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"); + } + + [Fact] + public async Task Create_WhenSuccessful_ReturnsCreatedPath() + { + // Arrange + _factory.SetupPost("api/storage_paths/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "New Archive")); + + // Act + var result = await StoragePathTools.Create(_factory.Client, "New Archive", "{correspondent}/{year}"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("New Archive"); + } + + [Fact] + public async Task Update_WhenSuccessful_ReturnsUpdatedPath() + { + // Arrange + _factory.SetupPatch("api/storage_paths/1/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "Updated Path")); + + // Act + var result = await StoragePathTools.Update(_factory.Client, 1, name: "Updated Path"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Path"); + } + + [Fact] + public async Task Delete_WithoutConfirmation_ReturnsDryRun() + { + // Arrange + _factory.SetupGet("api/storage_paths/1/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "To Delete")); + + // Act + var result = await StoragePathTools.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_DeletesPath() + { + // Arrange + _factory.SetupDelete("api/storage_paths/1/", HttpStatusCode.NoContent); + + // Act + var result = await StoragePathTools.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(); + } + + [Fact] + public async Task BulkDelete_WithConfirmation_ExecutesDeletion() + { + // Arrange + _factory.SetupPost("api/bulk_edit_objects/", "{}"); + + // Act + var result = await StoragePathTools.BulkDelete(_factory.Client, "1,2", 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(); + } +} diff --git a/PaperlessMCP.Tests/Tools/TagToolsTests.cs b/PaperlessMCP.Tests/Tools/TagToolsTests.cs new file mode 100644 index 0000000..91f7de0 --- /dev/null +++ b/PaperlessMCP.Tests/Tools/TagToolsTests.cs @@ -0,0 +1,206 @@ +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 TagToolsTests : IDisposable +{ + private readonly MockHttpClientFactory _factory; + + public TagToolsTests() + { + _factory = new MockHttpClientFactory(); + } + + public void Dispose() + { + _factory.Dispose(); + } + + [Fact] + public async Task List_ReturnsTagList() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/tags/*") + .Respond("application/json", TestFixtures.Tags.CreateTagListJson(5)); + + // Act + var result = await TagTools.List(_factory.Client); + + // 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 List_WithPagination_IncludesMetadata() + { + // Arrange + _factory.MockHandler + .When(HttpMethod.Get, "https://paperless.example.com/api/tags/*") + .Respond("application/json", TestFixtures.Tags.CreateTagListJson(10)); + + // Act + var result = await TagTools.List(_factory.Client, page: 2, pageSize: 5); + + // 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(5); + } + + [Fact] + public async Task Get_WhenTagExists_ReturnsTag() + { + // Arrange + _factory.SetupGet("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Important")); + + // Act + var result = await TagTools.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("name").GetString().Should().Be("Important"); + } + + [Fact] + public async Task Get_WhenTagNotFound_ReturnsError() + { + // Arrange + _factory.SetupGetWithStatus("api/tags/999/", HttpStatusCode.NotFound); + + // Act + var result = await TagTools.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"); + } + + [Fact] + public async Task Create_WhenSuccessful_ReturnsCreatedTag() + { + // Arrange + _factory.SetupPost("api/tags/", TestFixtures.Tags.CreateTagJson(5, "New Tag")); + + // Act + var result = await TagTools.Create(_factory.Client, "New Tag", color: "#00ff00"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("New Tag"); + } + + [Fact] + public async Task Create_WhenFails_ReturnsError() + { + // Arrange + _factory.SetupPostWithStatus("api/tags/", HttpStatusCode.BadRequest); + + // Act + var result = await TagTools.Create(_factory.Client, "Bad Tag"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse(); + json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR"); + } + + [Fact] + public async Task Update_WhenSuccessful_ReturnsUpdatedTag() + { + // Arrange + _factory.SetupPatch("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Updated Name")); + + // Act + var result = await TagTools.Update(_factory.Client, 1, name: "Updated Name"); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Name"); + } + + [Fact] + public async Task Delete_WithoutConfirmation_ReturnsDryRun() + { + // Arrange + _factory.SetupGet("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Tag to Delete")); + + // Act + var result = await TagTools.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_DeletesTag() + { + // Arrange + _factory.SetupDelete("api/tags/1/", HttpStatusCode.NoContent); + + // Act + var result = await TagTools.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(); + } + + [Fact] + public async Task BulkDelete_WithDryRun_ReturnsPreview() + { + // Act + var result = await TagTools.BulkDelete(_factory.Client, "1,2,3", dryRun: true); + + // Assert + var json = JsonDocument.Parse(result); + json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue(); + json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeFalse(); + json.RootElement.GetProperty("result").GetProperty("affected_ids").GetArrayLength().Should().Be(3); + } + + [Fact] + public async Task BulkDelete_WithConfirmation_ExecutesDeletion() + { + // Arrange + _factory.SetupPost("api/bulk_edit_objects/", "{}"); + + // Act + var result = await TagTools.BulkDelete(_factory.Client, "1,2,3", 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 BulkDelete_WithEmptyIds_ReturnsValidationError() + { + // Act + var result = await TagTools.BulkDelete(_factory.Client, "", 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"); + } +} diff --git a/PaperlessMCP.sln b/PaperlessMCP.sln new file mode 100644 index 0000000..795aaf6 --- /dev/null +++ b/PaperlessMCP.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaperlessMCP", "PaperlessMCP\PaperlessMCP.csproj", "{8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaperlessMCP.Tests", "PaperlessMCP.Tests\PaperlessMCP.Tests.csproj", "{33933071-3974-4E54-8DE2-590A4AB316A3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Debug|x64.Build.0 = Debug|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Debug|x86.Build.0 = Debug|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Release|Any CPU.Build.0 = Release|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Release|x64.ActiveCfg = Release|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Release|x64.Build.0 = Release|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Release|x86.ActiveCfg = Release|Any CPU + {8A47AF8F-D41E-4CDD-9A54-5F1F52A833DB}.Release|x86.Build.0 = Release|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Debug|x64.Build.0 = Debug|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Debug|x86.Build.0 = Debug|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Release|Any CPU.Build.0 = Release|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Release|x64.ActiveCfg = Release|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Release|x64.Build.0 = Release|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Release|x86.ActiveCfg = Release|Any CPU + {33933071-3974-4E54-8DE2-590A4AB316A3}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/PaperlessMCP/Client/PaperlessAuthHandler.cs b/PaperlessMCP/Client/PaperlessAuthHandler.cs new file mode 100644 index 0000000..f7d9d7e --- /dev/null +++ b/PaperlessMCP/Client/PaperlessAuthHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Options; +using PaperlessMCP.Configuration; + +namespace PaperlessMCP.Client; + +/// +/// HTTP message handler that adds the Paperless-ngx API token to requests. +/// +public class PaperlessAuthHandler : DelegatingHandler +{ + private readonly PaperlessOptions _options; + + public PaperlessAuthHandler(IOptions options) + { + _options = options.Value; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(_options.ApiToken)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Token", _options.ApiToken); + } + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/PaperlessMCP/Client/PaperlessClient.cs b/PaperlessMCP/Client/PaperlessClient.cs new file mode 100644 index 0000000..4d9f93b --- /dev/null +++ b/PaperlessMCP/Client/PaperlessClient.cs @@ -0,0 +1,711 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Web; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PaperlessMCP.Configuration; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.Correspondents; +using PaperlessMCP.Models.CustomFields; +using PaperlessMCP.Models.Documents; +using PaperlessMCP.Models.DocumentTypes; +using PaperlessMCP.Models.StoragePaths; +using PaperlessMCP.Models.Tags; + +namespace PaperlessMCP.Client; + +/// +/// Central client for all Paperless-ngx API operations. +/// +public class PaperlessClient +{ + private readonly HttpClient _httpClient; + private readonly PaperlessOptions _options; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + public PaperlessClient(HttpClient httpClient, IOptions options, ILogger logger) + { + _httpClient = httpClient; + _options = options.Value; + _logger = logger; + } + + public string BaseUrl => _options.BaseUrl; + + #region Health & Status + + /// + /// Checks connectivity and returns API root information. + /// + public async Task<(bool Success, string? Version, string? Error)> PingAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync("api/", cancellationToken); + + if (response.IsSuccessStatusCode) + { + // Try to extract version from response headers or body + var version = response.Headers.TryGetValues("X-Version", out var versions) + ? versions.FirstOrDefault() + : null; + + return (true, version, null); + } + + return (false, null, $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to ping Paperless API"); + return (false, null, ex.Message); + } + } + + /// + /// Gets status information from the Paperless instance. + /// + public async Task<(bool Success, JsonDocument? Status, string? Error)> GetStatusAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync("api/status/", cancellationToken); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadFromJsonAsync(cancellationToken); + return (true, json, null); + } + + return (false, null, $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Paperless status"); + return (false, null, ex.Message); + } + } + + #endregion + + #region Documents + + /// + /// Searches for documents with optional filters. + /// + public async Task> SearchDocumentsAsync( + string? query = null, + int[]? tags = null, + int[]? tagsExclude = null, + int? correspondent = null, + int? documentType = null, + int? storagePath = null, + DateTime? createdAfter = null, + DateTime? createdBefore = null, + DateTime? addedAfter = null, + DateTime? addedBefore = null, + int? archiveSerialNumber = null, + int page = 1, + int? pageSize = null, + string? ordering = null, + CancellationToken cancellationToken = default) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + + if (!string.IsNullOrEmpty(query)) + queryParams["query"] = query; + + if (tags?.Length > 0) + foreach (var tag in tags) + queryParams.Add("tags__id__in", tag.ToString()); + + if (tagsExclude?.Length > 0) + foreach (var tag in tagsExclude) + queryParams.Add("tags__id__none", tag.ToString()); + + if (correspondent.HasValue) + queryParams["correspondent__id"] = correspondent.Value.ToString(); + + if (documentType.HasValue) + queryParams["document_type__id"] = documentType.Value.ToString(); + + if (storagePath.HasValue) + queryParams["storage_path__id"] = storagePath.Value.ToString(); + + if (createdAfter.HasValue) + queryParams["created__date__gt"] = createdAfter.Value.ToString("yyyy-MM-dd"); + + if (createdBefore.HasValue) + queryParams["created__date__lt"] = createdBefore.Value.ToString("yyyy-MM-dd"); + + if (addedAfter.HasValue) + queryParams["added__date__gt"] = addedAfter.Value.ToString("yyyy-MM-dd"); + + if (addedBefore.HasValue) + queryParams["added__date__lt"] = addedBefore.Value.ToString("yyyy-MM-dd"); + + if (archiveSerialNumber.HasValue) + queryParams["archive_serial_number"] = archiveSerialNumber.Value.ToString(); + + queryParams["page"] = page.ToString(); + queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); + + if (!string.IsNullOrEmpty(ordering)) + queryParams["ordering"] = ordering; + + var url = $"api/documents/?{queryParams}"; + return await GetAsync>(url, cancellationToken) + ?? new PaginatedResult(); + } + + /// + /// Gets a document by ID. + /// + public async Task GetDocumentAsync(int id, CancellationToken cancellationToken = default) + { + return await GetAsync($"api/documents/{id}/", cancellationToken); + } + + /// + /// Updates a document. + /// + public async Task UpdateDocumentAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default) + { + return await PatchAsync($"api/documents/{id}/", request, cancellationToken); + } + + /// + /// Deletes a document. + /// + public async Task DeleteDocumentAsync(int id, CancellationToken cancellationToken = default) + { + return await DeleteAsync($"api/documents/{id}/", cancellationToken); + } + + /// + /// Uploads a new document from byte array. + /// + public async Task UploadDocumentAsync( + byte[] fileContent, + string fileName, + DocumentUploadRequest? metadata = null, + CancellationToken cancellationToken = default) + { + return await UploadDocumentInternalAsync( + () => new ByteArrayContent(fileContent), + fileName, + metadata, + cancellationToken); + } + + /// + /// Uploads a new document from a file path. More reliable for large files. + /// + public async Task<(string? TaskId, string? Error)> UploadDocumentFromPathAsync( + string filePath, + DocumentUploadRequest? metadata = null, + int maxRetries = 3, + CancellationToken cancellationToken = default) + { + // Validate file exists + if (!File.Exists(filePath)) + { + return (null, $"File not found: {filePath}"); + } + + var fileName = Path.GetFileName(filePath); + var fileInfo = new FileInfo(filePath); + + _logger.LogInformation("Starting upload of {FileName} ({Size:N0} bytes)", fileName, fileInfo.Length); + + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + // Use StreamContent for efficient memory usage with large files + await using var fileStream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 81920, // 80KB buffer + useAsync: true); + + var streamContent = new StreamContent(fileStream); + + var taskId = await UploadDocumentInternalAsync( + () => streamContent, + fileName, + metadata, + cancellationToken, + disposeContent: false); // StreamContent owns the stream + + if (taskId != null) + { + _logger.LogInformation("Successfully uploaded {FileName}, task ID: {TaskId}", fileName, taskId); + return (taskId, null); + } + + _logger.LogWarning("Upload attempt {Attempt}/{MaxRetries} failed for {FileName}", + attempt, maxRetries, fileName); + + if (attempt < maxRetries) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // Exponential backoff + _logger.LogInformation("Retrying in {Delay}...", delay); + await Task.Delay(delay, cancellationToken); + } + } + catch (IOException ex) when (attempt < maxRetries) + { + _logger.LogWarning(ex, "IO error on attempt {Attempt}/{MaxRetries}, retrying...", attempt, maxRetries); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken); + } + catch (HttpRequestException ex) when (attempt < maxRetries) + { + _logger.LogWarning(ex, "HTTP error on attempt {Attempt}/{MaxRetries}, retrying...", attempt, maxRetries); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Fatal error uploading {FileName}", fileName); + return (null, $"Upload failed: {ex.Message}"); + } + } + + return (null, $"Upload failed after {maxRetries} attempts"); + } + + private async Task UploadDocumentInternalAsync( + Func contentFactory, + string fileName, + DocumentUploadRequest? metadata, + CancellationToken cancellationToken, + bool disposeContent = true) + { + using var formContent = new MultipartFormDataContent(); + var fileContent = contentFactory(); + + try + { + formContent.Add(fileContent, "document", fileName); + + if (metadata != null) + { + if (!string.IsNullOrEmpty(metadata.Title)) + formContent.Add(new StringContent(metadata.Title), "title"); + + if (metadata.Correspondent.HasValue) + formContent.Add(new StringContent(metadata.Correspondent.Value.ToString()), "correspondent"); + + if (metadata.DocumentType.HasValue) + formContent.Add(new StringContent(metadata.DocumentType.Value.ToString()), "document_type"); + + if (metadata.StoragePath.HasValue) + formContent.Add(new StringContent(metadata.StoragePath.Value.ToString()), "storage_path"); + + if (metadata.Tags?.Count > 0) + foreach (var tag in metadata.Tags) + formContent.Add(new StringContent(tag.ToString()), "tags"); + + if (metadata.ArchiveSerialNumber.HasValue) + formContent.Add(new StringContent(metadata.ArchiveSerialNumber.Value.ToString()), "archive_serial_number"); + + if (metadata.Created.HasValue) + formContent.Add(new StringContent(metadata.Created.Value.ToString("yyyy-MM-dd")), "created"); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); // 5 minute timeout for uploads + + var response = await _httpClient.PostAsync("api/documents/post_document/", formContent, cts.Token); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadAsStringAsync(cts.Token); + return result.Trim('"'); // Returns task UUID + } + + var error = await response.Content.ReadAsStringAsync(cts.Token); + _logger.LogError("Failed to upload document: {StatusCode} - {Error}", response.StatusCode, error); + return null; + } + finally + { + if (disposeContent && fileContent is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + /// + /// Gets document download URLs. + /// + public DocumentDownload GetDocumentDownloadInfo(int id, string title, string? originalFileName) + { + var baseUrl = _options.BaseUrl.TrimEnd('/'); + return new DocumentDownload + { + Id = id, + Title = title, + OriginalFileName = originalFileName, + DownloadUrl = $"{baseUrl}/api/documents/{id}/download/", + PreviewUrl = $"{baseUrl}/api/documents/{id}/preview/", + ThumbnailUrl = $"{baseUrl}/api/documents/{id}/thumb/" + }; + } + + /// + /// Performs bulk edit operations on documents. + /// + public async Task BulkEditDocumentsAsync( + int[] documentIds, + string method, + object? parameters = null, + CancellationToken cancellationToken = default) + { + var request = new + { + documents = documentIds, + method, + parameters + }; + + try + { + var response = await _httpClient.PostAsJsonAsync("api/documents/bulk_edit/", request, JsonOptions, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to perform bulk edit"); + return false; + } + } + + /// + /// Gets the next available archive serial number. + /// + public async Task GetNextAsnAsync(CancellationToken cancellationToken = default) + { + return await GetAsync("api/documents/next_asn/", cancellationToken); + } + + #endregion + + #region Tags + + public async Task> GetTagsAsync(int page = 1, int? pageSize = null, string? ordering = null, CancellationToken cancellationToken = default) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["page"] = page.ToString(); + queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); + if (!string.IsNullOrEmpty(ordering)) + queryParams["ordering"] = ordering; + + return await GetAsync>($"api/tags/?{queryParams}", cancellationToken) + ?? new PaginatedResult(); + } + + public async Task GetTagAsync(int id, CancellationToken cancellationToken = default) + { + return await GetAsync($"api/tags/{id}/", cancellationToken); + } + + public async Task CreateTagAsync(TagCreateRequest request, CancellationToken cancellationToken = default) + { + return await PostAsync("api/tags/", request, cancellationToken); + } + + public async Task UpdateTagAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default) + { + return await PatchAsync($"api/tags/{id}/", request, cancellationToken); + } + + public async Task DeleteTagAsync(int id, CancellationToken cancellationToken = default) + { + return await DeleteAsync($"api/tags/{id}/", cancellationToken); + } + + #endregion + + #region Correspondents + + public async Task> GetCorrespondentsAsync(int page = 1, int? pageSize = null, string? ordering = null, CancellationToken cancellationToken = default) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["page"] = page.ToString(); + queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); + if (!string.IsNullOrEmpty(ordering)) + queryParams["ordering"] = ordering; + + return await GetAsync>($"api/correspondents/?{queryParams}", cancellationToken) + ?? new PaginatedResult(); + } + + public async Task GetCorrespondentAsync(int id, CancellationToken cancellationToken = default) + { + return await GetAsync($"api/correspondents/{id}/", cancellationToken); + } + + public async Task CreateCorrespondentAsync(CorrespondentCreateRequest request, CancellationToken cancellationToken = default) + { + return await PostAsync("api/correspondents/", request, cancellationToken); + } + + public async Task UpdateCorrespondentAsync(int id, CorrespondentUpdateRequest request, CancellationToken cancellationToken = default) + { + return await PatchAsync($"api/correspondents/{id}/", request, cancellationToken); + } + + public async Task DeleteCorrespondentAsync(int id, CancellationToken cancellationToken = default) + { + return await DeleteAsync($"api/correspondents/{id}/", cancellationToken); + } + + #endregion + + #region Document Types + + public async Task> GetDocumentTypesAsync(int page = 1, int? pageSize = null, string? ordering = null, CancellationToken cancellationToken = default) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["page"] = page.ToString(); + queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); + if (!string.IsNullOrEmpty(ordering)) + queryParams["ordering"] = ordering; + + return await GetAsync>($"api/document_types/?{queryParams}", cancellationToken) + ?? new PaginatedResult(); + } + + public async Task GetDocumentTypeAsync(int id, CancellationToken cancellationToken = default) + { + return await GetAsync($"api/document_types/{id}/", cancellationToken); + } + + public async Task CreateDocumentTypeAsync(DocumentTypeCreateRequest request, CancellationToken cancellationToken = default) + { + return await PostAsync("api/document_types/", request, cancellationToken); + } + + public async Task UpdateDocumentTypeAsync(int id, DocumentTypeUpdateRequest request, CancellationToken cancellationToken = default) + { + return await PatchAsync($"api/document_types/{id}/", request, cancellationToken); + } + + public async Task DeleteDocumentTypeAsync(int id, CancellationToken cancellationToken = default) + { + return await DeleteAsync($"api/document_types/{id}/", cancellationToken); + } + + #endregion + + #region Storage Paths + + public async Task> GetStoragePathsAsync(int page = 1, int? pageSize = null, string? ordering = null, CancellationToken cancellationToken = default) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["page"] = page.ToString(); + queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); + if (!string.IsNullOrEmpty(ordering)) + queryParams["ordering"] = ordering; + + return await GetAsync>($"api/storage_paths/?{queryParams}", cancellationToken) + ?? new PaginatedResult(); + } + + public async Task GetStoragePathAsync(int id, CancellationToken cancellationToken = default) + { + return await GetAsync($"api/storage_paths/{id}/", cancellationToken); + } + + public async Task CreateStoragePathAsync(StoragePathCreateRequest request, CancellationToken cancellationToken = default) + { + return await PostAsync("api/storage_paths/", request, cancellationToken); + } + + public async Task UpdateStoragePathAsync(int id, StoragePathUpdateRequest request, CancellationToken cancellationToken = default) + { + return await PatchAsync($"api/storage_paths/{id}/", request, cancellationToken); + } + + public async Task DeleteStoragePathAsync(int id, CancellationToken cancellationToken = default) + { + return await DeleteAsync($"api/storage_paths/{id}/", cancellationToken); + } + + #endregion + + #region Custom Fields + + public async Task> GetCustomFieldsAsync(int page = 1, int? pageSize = null, CancellationToken cancellationToken = default) + { + var queryParams = HttpUtility.ParseQueryString(string.Empty); + queryParams["page"] = page.ToString(); + queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); + + return await GetAsync>($"api/custom_fields/?{queryParams}", cancellationToken) + ?? new PaginatedResult(); + } + + public async Task GetCustomFieldAsync(int id, CancellationToken cancellationToken = default) + { + return await GetAsync($"api/custom_fields/{id}/", cancellationToken); + } + + public async Task CreateCustomFieldAsync(CustomFieldCreateRequest request, CancellationToken cancellationToken = default) + { + return await PostAsync("api/custom_fields/", request, cancellationToken); + } + + public async Task UpdateCustomFieldAsync(int id, CustomFieldUpdateRequest request, CancellationToken cancellationToken = default) + { + return await PatchAsync($"api/custom_fields/{id}/", request, cancellationToken); + } + + public async Task DeleteCustomFieldAsync(int id, CancellationToken cancellationToken = default) + { + return await DeleteAsync($"api/custom_fields/{id}/", cancellationToken); + } + + #endregion + + #region Bulk Operations + + /// + /// Performs bulk operations on metadata objects (tags, correspondents, etc.). + /// + public async Task BulkEditObjectsAsync( + int[] objectIds, + string objectType, + string operation, + object? parameters = null, + CancellationToken cancellationToken = default) + { + var request = new + { + objects = objectIds, + object_type = objectType, + operation, + parameters + }; + + try + { + var response = await _httpClient.PostAsJsonAsync("api/bulk_edit_objects/", request, JsonOptions, cancellationToken); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to perform bulk object edit"); + return false; + } + } + + #endregion + + #region HTTP Helpers + + private async Task GetAsync(string url, CancellationToken cancellationToken) + { + try + { + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + + await LogErrorResponse(response, "GET", url); + return default; + } + catch (Exception ex) + { + _logger.LogError(ex, "GET request failed: {Url}", url); + return default; + } + } + + private async Task PostAsync(string url, object request, CancellationToken cancellationToken) + { + try + { + var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + + await LogErrorResponse(response, "POST", url); + return default; + } + catch (Exception ex) + { + _logger.LogError(ex, "POST request failed: {Url}", url); + return default; + } + } + + private async Task PatchAsync(string url, object request, CancellationToken cancellationToken) + { + try + { + var content = JsonContent.Create(request, options: JsonOptions); + var response = await _httpClient.PatchAsync(url, content, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + + await LogErrorResponse(response, "PATCH", url); + return default; + } + catch (Exception ex) + { + _logger.LogError(ex, "PATCH request failed: {Url}", url); + return default; + } + } + + private async Task DeleteAsync(string url, CancellationToken cancellationToken) + { + try + { + var response = await _httpClient.DeleteAsync(url, cancellationToken); + + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NoContent) + { + return true; + } + + await LogErrorResponse(response, "DELETE", url); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "DELETE request failed: {Url}", url); + return false; + } + } + + private async Task LogErrorResponse(HttpResponseMessage response, string method, string url) + { + var body = await response.Content.ReadAsStringAsync(); + _logger.LogError("{Method} {Url} failed with {StatusCode}: {Body}", + method, url, (int)response.StatusCode, body); + } + + #endregion +} diff --git a/PaperlessMCP/Configuration/PaperlessOptions.cs b/PaperlessMCP/Configuration/PaperlessOptions.cs new file mode 100644 index 0000000..22fcf8a --- /dev/null +++ b/PaperlessMCP/Configuration/PaperlessOptions.cs @@ -0,0 +1,22 @@ +namespace PaperlessMCP.Configuration; + +/// +/// Configuration options for connecting to the Paperless-ngx API. +/// +public class PaperlessOptions +{ + /// + /// Base URL of the Paperless-ngx instance (e.g., https://docs.example.com). + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// API token for authentication. + /// + public string ApiToken { get; set; } = string.Empty; + + /// + /// Maximum page size for paginated requests. + /// + public int MaxPageSize { get; set; } = 100; +} diff --git a/PaperlessMCP/Dockerfile b/PaperlessMCP/Dockerfile new file mode 100644 index 0000000..a83ebb4 --- /dev/null +++ b/PaperlessMCP/Dockerfile @@ -0,0 +1,32 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build +WORKDIR /src + +# Copy project file and restore dependencies +COPY PaperlessMCP.csproj . +RUN dotnet restore + +# Copy source code and build +COPY . . +RUN dotnet publish -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime +WORKDIR /app + +# Copy published application +COPY --from=build /app/publish . + +# Set environment variables +ENV ASPNETCORE_URLS=http://+:5000 +ENV MCP_PORT=5000 + +# Expose port for HTTP transport +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/mcp || exit 1 + +# Run the application (HTTP mode by default) +ENTRYPOINT ["dotnet", "PaperlessMCP.dll"] diff --git a/PaperlessMCP/Models/Common/McpResponse.cs b/PaperlessMCP/Models/Common/McpResponse.cs new file mode 100644 index 0000000..88fc0e6 --- /dev/null +++ b/PaperlessMCP/Models/Common/McpResponse.cs @@ -0,0 +1,117 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.Common; + +/// +/// Standard response envelope for all MCP tool results. +/// +/// The type of the result payload. +public record McpResponse +{ + [JsonPropertyName("ok")] + public bool Ok { get; init; } + + [JsonPropertyName("result")] + public T? Result { get; init; } + + [JsonPropertyName("meta")] + public McpMeta Meta { get; init; } = new(); + + [JsonPropertyName("warnings")] + public List Warnings { get; init; } = []; + + public static McpResponse Success(T result, McpMeta? meta = null, List? warnings = null) => new() + { + Ok = true, + Result = result, + Meta = meta ?? new McpMeta(), + Warnings = warnings ?? [] + }; + + public static McpResponse Failure(McpError error, McpMeta? meta = null) => new() + { + Ok = false, + Result = default, + Meta = meta ?? new McpMeta(), + Warnings = [] + }; +} + +/// +/// Error response wrapper. +/// +public record McpErrorResponse +{ + [JsonPropertyName("ok")] + public bool Ok => false; + + [JsonPropertyName("error")] + public required McpError Error { get; init; } + + [JsonPropertyName("meta")] + public McpMeta Meta { get; init; } = new(); + + public static McpErrorResponse Create(string code, string message, object? details = null, McpMeta? meta = null) => new() + { + Error = new McpError + { + Code = code, + Message = message, + Details = details + }, + Meta = meta ?? new McpMeta() + }; +} + +/// +/// Error information. +/// +public record McpError +{ + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("details")] + public object? Details { get; init; } +} + +/// +/// Metadata included with all responses. +/// +public record McpMeta +{ + [JsonPropertyName("request_id")] + public string RequestId { get; init; } = Guid.NewGuid().ToString(); + + [JsonPropertyName("page")] + public int? Page { get; init; } + + [JsonPropertyName("page_size")] + public int? PageSize { get; init; } + + [JsonPropertyName("total")] + public int? Total { get; init; } + + [JsonPropertyName("next")] + public string? Next { get; init; } + + [JsonPropertyName("paperless_base_url")] + public string PaperlessBaseUrl { get; init; } = string.Empty; +} + +/// +/// Standard error codes used across all tools. +/// +public static class ErrorCodes +{ + public const string AuthFailed = "AUTH_FAILED"; + public const string NotFound = "NOT_FOUND"; + public const string Validation = "VALIDATION"; + public const string UpstreamError = "UPSTREAM_ERROR"; + public const string RateLimit = "RATE_LIMIT"; + public const string Unknown = "UNKNOWN"; + public const string ConfirmationRequired = "CONFIRMATION_REQUIRED"; +} diff --git a/PaperlessMCP/Models/Common/PaginatedResult.cs b/PaperlessMCP/Models/Common/PaginatedResult.cs new file mode 100644 index 0000000..34be5a7 --- /dev/null +++ b/PaperlessMCP/Models/Common/PaginatedResult.cs @@ -0,0 +1,85 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.Common; + +/// +/// Represents a paginated response from the Paperless-ngx API. +/// +/// The type of items in the results. +public record PaginatedResult +{ + [JsonPropertyName("count")] + public int Count { get; init; } + + [JsonPropertyName("next")] + public string? Next { get; init; } + + [JsonPropertyName("previous")] + public string? Previous { get; init; } + + [JsonPropertyName("results")] + public List Results { get; init; } = []; + + /// + /// Gets all items from all pages using the provided fetch function. + /// + public static async Task> GetAllPagesAsync( + Func>> fetchPage, + int maxPages = 100, + CancellationToken cancellationToken = default) + { + var allResults = new List(); + var page = 1; + + while (page <= maxPages) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await fetchPage(page); + allResults.AddRange(result.Results); + + if (string.IsNullOrEmpty(result.Next)) + break; + + page++; + } + + return allResults; + } +} + +/// +/// Represents bulk operation request parameters. +/// +public record BulkOperationRequest +{ + [JsonPropertyName("ids")] + public required int[] Ids { get; init; } + + [JsonPropertyName("dry_run")] + public bool DryRun { get; init; } = true; + + [JsonPropertyName("confirm")] + public bool Confirm { get; init; } +} + +/// +/// Represents the result of a bulk operation (dry run or actual). +/// +public record BulkOperationResult +{ + [JsonPropertyName("affected_ids")] + public int[] AffectedIds { get; init; } = []; + + [JsonPropertyName("current_values")] + public Dictionary? CurrentValues { get; init; } + + [JsonPropertyName("proposed_changes")] + public Dictionary? ProposedChanges { get; init; } + + [JsonPropertyName("warnings")] + public List Warnings { get; init; } = []; + + [JsonPropertyName("executed")] + public bool Executed { get; init; } +} diff --git a/PaperlessMCP/Models/Correspondents/Correspondent.cs b/PaperlessMCP/Models/Correspondents/Correspondent.cs new file mode 100644 index 0000000..e616f39 --- /dev/null +++ b/PaperlessMCP/Models/Correspondents/Correspondent.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.Correspondents; + +/// +/// Represents a correspondent in Paperless-ngx. +/// +public record Correspondent +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("slug")] + public string Slug { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int MatchingAlgorithm { get; init; } + + [JsonPropertyName("document_count")] + public int DocumentCount { get; init; } + + [JsonPropertyName("last_correspondence")] + public DateTime? LastCorrespondence { get; init; } + + [JsonPropertyName("owner")] + public int? Owner { get; init; } +} + +/// +/// Request to create a new correspondent. +/// +public record CorrespondentCreateRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } +} + +/// +/// Request to update an existing correspondent. +/// +public record CorrespondentUpdateRequest +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } +} diff --git a/PaperlessMCP/Models/CustomFields/CustomField.cs b/PaperlessMCP/Models/CustomFields/CustomField.cs new file mode 100644 index 0000000..c2ff1db --- /dev/null +++ b/PaperlessMCP/Models/CustomFields/CustomField.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.CustomFields; + +/// +/// Represents a custom field definition in Paperless-ngx. +/// +public record CustomField +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("data_type")] + public string DataType { get; init; } = string.Empty; + + [JsonPropertyName("extra_data")] + public CustomFieldExtraData? ExtraData { get; init; } +} + +/// +/// Extra data for select-type custom fields. +/// +public record CustomFieldExtraData +{ + [JsonPropertyName("select_options")] + public List? SelectOptions { get; init; } + + [JsonPropertyName("default_currency")] + public string? DefaultCurrency { get; init; } +} + +/// +/// Request to create a new custom field. +/// +public record CustomFieldCreateRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("data_type")] + public required string DataType { get; init; } + + [JsonPropertyName("extra_data")] + public CustomFieldExtraData? ExtraData { get; init; } +} + +/// +/// Request to update an existing custom field. +/// +public record CustomFieldUpdateRequest +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("extra_data")] + public CustomFieldExtraData? ExtraData { get; init; } +} + +/// +/// Custom field data types. +/// +public static class CustomFieldDataType +{ + public const string String = "string"; + public const string Url = "url"; + public const string Date = "date"; + public const string Boolean = "boolean"; + public const string Integer = "integer"; + public const string Float = "float"; + public const string Monetary = "monetary"; + public const string DocumentLink = "documentlink"; + public const string Select = "select"; +} + +/// +/// Request to assign a custom field value to a document. +/// +public record CustomFieldAssignRequest +{ + [JsonPropertyName("document_id")] + public required int DocumentId { get; init; } + + [JsonPropertyName("field_id")] + public required int FieldId { get; init; } + + [JsonPropertyName("value")] + public object? Value { get; init; } +} diff --git a/PaperlessMCP/Models/DocumentTypes/DocumentType.cs b/PaperlessMCP/Models/DocumentTypes/DocumentType.cs new file mode 100644 index 0000000..ea8efb6 --- /dev/null +++ b/PaperlessMCP/Models/DocumentTypes/DocumentType.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.DocumentTypes; + +/// +/// Represents a document type in Paperless-ngx. +/// +public record DocumentType +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("slug")] + public string Slug { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int MatchingAlgorithm { get; init; } + + [JsonPropertyName("document_count")] + public int DocumentCount { get; init; } + + [JsonPropertyName("owner")] + public int? Owner { get; init; } +} + +/// +/// Request to create a new document type. +/// +public record DocumentTypeCreateRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } +} + +/// +/// Request to update an existing document type. +/// +public record DocumentTypeUpdateRequest +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } +} diff --git a/PaperlessMCP/Models/Documents/Document.cs b/PaperlessMCP/Models/Documents/Document.cs new file mode 100644 index 0000000..f3c8ca8 --- /dev/null +++ b/PaperlessMCP/Models/Documents/Document.cs @@ -0,0 +1,275 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.Documents; + +/// +/// Represents a document in Paperless-ngx. +/// +public record Document +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("correspondent")] + public int? Correspondent { get; init; } + + [JsonPropertyName("document_type")] + public int? DocumentType { get; init; } + + [JsonPropertyName("storage_path")] + public int? StoragePath { get; init; } + + [JsonPropertyName("title")] + public string Title { get; init; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; init; } = string.Empty; + + [JsonPropertyName("tags")] + public List Tags { get; init; } = []; + + [JsonPropertyName("created")] + public DateTime? Created { get; init; } + + [JsonPropertyName("created_date")] + public DateOnly? CreatedDate { get; init; } + + [JsonPropertyName("modified")] + public DateTime? Modified { get; init; } + + [JsonPropertyName("added")] + public DateTime? Added { get; init; } + + [JsonPropertyName("archive_serial_number")] + public int? ArchiveSerialNumber { get; init; } + + [JsonPropertyName("original_file_name")] + public string? OriginalFileName { get; init; } + + [JsonPropertyName("archived_file_name")] + public string? ArchivedFileName { get; init; } + + [JsonPropertyName("owner")] + public int? Owner { get; init; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; init; } = []; + + [JsonPropertyName("notes")] + public List? Notes { get; init; } +} + +/// +/// Custom field value assigned to a document. +/// +public record DocumentCustomField +{ + [JsonPropertyName("field")] + public int Field { get; init; } + + [JsonPropertyName("value")] + public object? Value { get; init; } +} + +/// +/// Note attached to a document. +/// +public record DocumentNote +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("note")] + public string Note { get; init; } = string.Empty; + + [JsonPropertyName("created")] + public DateTime Created { get; init; } + + [JsonPropertyName("user")] + public int? User { get; init; } +} + +/// +/// Search hit information returned with search results. +/// +public record SearchHit +{ + [JsonPropertyName("score")] + public double? Score { get; init; } + + [JsonPropertyName("highlights")] + public string? Highlights { get; init; } + + [JsonPropertyName("rank")] + public int? Rank { get; init; } +} + +/// +/// Document with search hit information. +/// +public record DocumentSearchResult : Document +{ + [JsonPropertyName("__search_hit__")] + public SearchHit? SearchHit { get; init; } +} + +/// +/// Lightweight document summary for search results. +/// Excludes full content and notes to reduce response size. +/// +public record DocumentSummary +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("correspondent")] + public int? Correspondent { get; init; } + + [JsonPropertyName("document_type")] + public int? DocumentType { get; init; } + + [JsonPropertyName("storage_path")] + public int? StoragePath { get; init; } + + [JsonPropertyName("title")] + public string Title { get; init; } = string.Empty; + + [JsonPropertyName("content")] + public string? Content { get; init; } + + [JsonPropertyName("tags")] + public List Tags { get; init; } = []; + + [JsonPropertyName("created")] + public DateTime? Created { get; init; } + + [JsonPropertyName("modified")] + public DateTime? Modified { get; init; } + + [JsonPropertyName("added")] + public DateTime? Added { get; init; } + + [JsonPropertyName("archive_serial_number")] + public int? ArchiveSerialNumber { get; init; } + + [JsonPropertyName("original_file_name")] + public string? OriginalFileName { get; init; } + + [JsonPropertyName("__search_hit__")] + public SearchHit? SearchHit { get; init; } + + /// + /// Creates a DocumentSummary from a DocumentSearchResult. + /// + public static DocumentSummary FromSearchResult(DocumentSearchResult result, bool includeContent = false, int? contentMaxLength = null) + { + string? content = null; + if (includeContent && !string.IsNullOrEmpty(result.Content)) + { + content = contentMaxLength.HasValue && result.Content.Length > contentMaxLength.Value + ? result.Content[..contentMaxLength.Value] + "..." + : result.Content; + } + + return new DocumentSummary + { + Id = result.Id, + Correspondent = result.Correspondent, + DocumentType = result.DocumentType, + StoragePath = result.StoragePath, + Title = result.Title, + Content = content, + Tags = result.Tags, + Created = result.Created, + Modified = result.Modified, + Added = result.Added, + ArchiveSerialNumber = result.ArchiveSerialNumber, + OriginalFileName = result.OriginalFileName, + SearchHit = result.SearchHit + }; + } +} + +/// +/// Request to upload a new document. +/// +public record DocumentUploadRequest +{ + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("created")] + public DateTime? Created { get; init; } + + [JsonPropertyName("correspondent")] + public int? Correspondent { get; init; } + + [JsonPropertyName("document_type")] + public int? DocumentType { get; init; } + + [JsonPropertyName("storage_path")] + public int? StoragePath { get; init; } + + [JsonPropertyName("tags")] + public List? Tags { get; init; } + + [JsonPropertyName("archive_serial_number")] + public int? ArchiveSerialNumber { get; init; } + + [JsonPropertyName("custom_fields")] + public List? CustomFields { get; init; } +} + +/// +/// Request to update an existing document. +/// +public record DocumentUpdateRequest +{ + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("correspondent")] + public int? Correspondent { get; init; } + + [JsonPropertyName("document_type")] + public int? DocumentType { get; init; } + + [JsonPropertyName("storage_path")] + public int? StoragePath { get; init; } + + [JsonPropertyName("tags")] + public List? Tags { get; init; } + + [JsonPropertyName("archive_serial_number")] + public int? ArchiveSerialNumber { get; init; } + + [JsonPropertyName("custom_fields")] + public List? CustomFields { get; init; } + + [JsonPropertyName("created")] + public DateTime? Created { get; init; } +} + +/// +/// Download information for a document. +/// +public record DocumentDownload +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("title")] + public string Title { get; init; } = string.Empty; + + [JsonPropertyName("original_file_name")] + public string? OriginalFileName { get; init; } + + [JsonPropertyName("download_url")] + public string DownloadUrl { get; init; } = string.Empty; + + [JsonPropertyName("preview_url")] + public string? PreviewUrl { get; init; } + + [JsonPropertyName("thumbnail_url")] + public string? ThumbnailUrl { get; init; } +} diff --git a/PaperlessMCP/Models/StoragePaths/StoragePath.cs b/PaperlessMCP/Models/StoragePaths/StoragePath.cs new file mode 100644 index 0000000..5098ea4 --- /dev/null +++ b/PaperlessMCP/Models/StoragePaths/StoragePath.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.StoragePaths; + +/// +/// Represents a storage path in Paperless-ngx. +/// +public record StoragePath +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("slug")] + public string Slug { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; init; } = string.Empty; + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int MatchingAlgorithm { get; init; } + + [JsonPropertyName("document_count")] + public int DocumentCount { get; init; } + + [JsonPropertyName("owner")] + public int? Owner { get; init; } +} + +/// +/// Request to create a new storage path. +/// +public record StoragePathCreateRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } +} + +/// +/// Request to update an existing storage path. +/// +public record StoragePathUpdateRequest +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } +} diff --git a/PaperlessMCP/Models/Tags/Tag.cs b/PaperlessMCP/Models/Tags/Tag.cs new file mode 100644 index 0000000..95156ea --- /dev/null +++ b/PaperlessMCP/Models/Tags/Tag.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; + +namespace PaperlessMCP.Models.Tags; + +/// +/// Represents a tag in Paperless-ngx. +/// +public record Tag +{ + [JsonPropertyName("id")] + public int Id { get; init; } + + [JsonPropertyName("slug")] + public string Slug { get; init; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("color")] + public string? Color { get; init; } + + [JsonPropertyName("text_color")] + public string? TextColor { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int MatchingAlgorithm { get; init; } + + [JsonPropertyName("is_inbox_tag")] + public bool IsInboxTag { get; init; } + + [JsonPropertyName("document_count")] + public int DocumentCount { get; init; } + + [JsonPropertyName("owner")] + public int? Owner { get; init; } +} + +/// +/// Request to create a new tag. +/// +public record TagCreateRequest +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("color")] + public string? Color { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } + + [JsonPropertyName("is_inbox_tag")] + public bool? IsInboxTag { get; init; } +} + +/// +/// Request to update an existing tag. +/// +public record TagUpdateRequest +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("color")] + public string? Color { get; init; } + + [JsonPropertyName("match")] + public string? Match { get; init; } + + [JsonPropertyName("matching_algorithm")] + public int? MatchingAlgorithm { get; init; } + + [JsonPropertyName("is_inbox_tag")] + public bool? IsInboxTag { get; init; } +} + +/// +/// Matching algorithm types used by tags, correspondents, and document types. +/// +public static class MatchingAlgorithm +{ + public const int None = 0; + public const int Any = 1; + public const int All = 2; + public const int Literal = 3; + public const int Regex = 4; + public const int Fuzzy = 5; + public const int Auto = 6; +} diff --git a/PaperlessMCP/PaperlessMCP.csproj b/PaperlessMCP/PaperlessMCP.csproj new file mode 100644 index 0000000..b25d00b --- /dev/null +++ b/PaperlessMCP/PaperlessMCP.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + PaperlessMCP + PaperlessMCP + Linux + + + + + + + + + + diff --git a/PaperlessMCP/Program.cs b/PaperlessMCP/Program.cs new file mode 100644 index 0000000..ebd5618 --- /dev/null +++ b/PaperlessMCP/Program.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Configuration; +using Polly; +using Polly.Extensions.Http; + +var useStdio = args.Contains("--stdio"); + +if (useStdio) +{ + // stdio transport for local usage (Claude Desktop) + var builder = Host.CreateApplicationBuilder(args); + + ConfigureServices(builder.Services, builder.Configuration); + + builder.Logging.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); + + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + + await builder.Build().RunAsync(); +} +else +{ + // HTTP transport for remote usage + var builder = WebApplication.CreateBuilder(args); + + ConfigureServices(builder.Services, builder.Configuration); + + builder.Services + .AddMcpServer() + .WithToolsFromAssembly(); + + var app = builder.Build(); + + var port = app.Configuration.GetValue("Mcp:Port") + ?? (Environment.GetEnvironmentVariable("MCP_PORT") is string portStr && int.TryParse(portStr, out var p) ? p : 5000); + + app.MapMcp("/mcp"); + + app.Logger.LogInformation("PaperlessMCP server starting on port {Port}", port); + app.Logger.LogInformation("MCP endpoint available at: http://localhost:{Port}/mcp", port); + + await app.RunAsync($"http://0.0.0.0:{port}"); +} + +void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + // Configuration + services.Configure(options => + { + // Environment variables take precedence (support both naming conventions) + options.BaseUrl = Environment.GetEnvironmentVariable("PAPERLESS_BASE_URL") + ?? Environment.GetEnvironmentVariable("PAPERLESS_URL") + ?? configuration.GetValue("Paperless:BaseUrl") + ?? throw new InvalidOperationException("PAPERLESS_BASE_URL or PAPERLESS_URL is required"); + + options.ApiToken = Environment.GetEnvironmentVariable("PAPERLESS_API_TOKEN") + ?? Environment.GetEnvironmentVariable("PAPERLESS_TOKEN") + ?? configuration.GetValue("Paperless:ApiToken") + ?? throw new InvalidOperationException("PAPERLESS_API_TOKEN or PAPERLESS_TOKEN is required"); + + options.MaxPageSize = Environment.GetEnvironmentVariable("MAX_PAGE_SIZE") is string maxPageStr && int.TryParse(maxPageStr, out var maxPage) + ? maxPage + : configuration.GetValue("Paperless:MaxPageSize") ?? 100; + }); + + // Configure retry policy for transient errors + var retryPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + + // HttpClient for Paperless API + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/"); + client.DefaultRequestHeaders.Add("Accept", "application/json; version=9"); + }) + .AddHttpMessageHandler() + .AddPolicyHandler(retryPolicy); + + services.AddTransient(); +} diff --git a/PaperlessMCP/README.md b/PaperlessMCP/README.md new file mode 100644 index 0000000..30f6e80 --- /dev/null +++ b/PaperlessMCP/README.md @@ -0,0 +1,341 @@ +# PaperlessMCP + +A Model Context Protocol (MCP) server that provides AI-first tooling for managing [Paperless-ngx](https://docs.paperless-ngx.com/) instances via the official REST API. + +## Features + +- **Full Document Management**: Search, upload, download, update, and delete documents +- **Metadata Management**: CRUD operations for tags, correspondents, document types, storage paths, and custom fields +- **Bulk Operations**: Batch updates and deletions with dry-run support +- **Safety Guardrails**: Destructive operations require explicit confirmation +- **Dual Transport**: Supports both HTTP (for remote access) and stdio (for local Claude Desktop) +- **Structured Output**: All responses follow a consistent JSON envelope format + +## Requirements + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (Preview) +- A running [Paperless-ngx](https://docs.paperless-ngx.com/) instance +- API token from your Paperless-ngx instance + +## Getting Started + +### Configuration + +Set the following environment variables: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `PAPERLESS_BASE_URL` | Yes | - | Base URL of your Paperless-ngx instance | +| `PAPERLESS_API_TOKEN` | Yes | - | API token for authentication | +| `MCP_LOG_LEVEL` | No | `Information` | Logging level | +| `MAX_PAGE_SIZE` | No | `100` | Maximum page size for paginated requests | +| `MCP_PORT` | No | `5000` | HTTP port for remote transport | + +### Getting an API Token + +1. Log into your Paperless-ngx instance +2. Go to Settings > Administration +3. Create a new API token or use an existing one + +### Running Locally (stdio transport) + +For use with Claude Desktop or other local MCP clients: + +```bash +# Clone and build +cd PaperlessMCP +dotnet build + +# Run with stdio transport +PAPERLESS_BASE_URL=https://your-instance.com \ +PAPERLESS_API_TOKEN=your-token \ +dotnet run -- --stdio +``` + +### Running as HTTP Server + +For remote access or containerized deployments: + +```bash +# Run with HTTP transport (default) +PAPERLESS_BASE_URL=https://your-instance.com \ +PAPERLESS_API_TOKEN=your-token \ +dotnet run +``` + +The MCP endpoint will be available at `http://localhost:5000/mcp` + +### Docker + +```bash +# Using docker-compose +echo "PAPERLESS_BASE_URL=https://your-instance.com" > .env +echo "PAPERLESS_API_TOKEN=your-token" >> .env +docker-compose up -d + +# Or build and run directly +docker build -t paperless-mcp . +docker run -p 5000:5000 \ + -e PAPERLESS_BASE_URL=https://your-instance.com \ + -e PAPERLESS_API_TOKEN=your-token \ + paperless-mcp +``` + +## Claude Desktop Configuration + +Add to your Claude Desktop MCP configuration (`~/.config/claude/claude_desktop_config.json` on macOS/Linux or `%APPDATA%\Claude\claude_desktop_config.json` on Windows): + +```json +{ + "mcpServers": { + "paperless": { + "command": "dotnet", + "args": ["run", "--project", "/path/to/PaperlessMCP", "--", "--stdio"], + "env": { + "PAPERLESS_BASE_URL": "https://your-paperless-instance.com", + "PAPERLESS_API_TOKEN": "your-api-token" + } + } + } +} +``` + +## Available Tools + +### Health & Capability + +| Tool | Description | +|------|-------------| +| `paperless.ping` | Verify connectivity and authentication | +| `paperless.capabilities` | List supported endpoints and features | + +### Documents + +| Tool | Description | +|------|-------------| +| `paperless.documents.search` | Full-text search with filters | +| `paperless.documents.get` | Get document by ID | +| `paperless.documents.download` | Get download URLs | +| `paperless.documents.preview` | Get preview URL | +| `paperless.documents.thumbnail` | Get thumbnail URL | +| `paperless.documents.upload` | Upload new document | +| `paperless.documents.update` | Update document metadata | +| `paperless.documents.delete` | Delete document (requires confirmation) | +| `paperless.documents.bulk_update` | Bulk operations (with dry-run) | +| `paperless.documents.reprocess` | Reprocess document OCR | + +### Tags + +| Tool | Description | +|------|-------------| +| `paperless.tags.list` | List all tags | +| `paperless.tags.get` | Get tag by ID | +| `paperless.tags.create` | Create new tag | +| `paperless.tags.update` | Update tag | +| `paperless.tags.delete` | Delete tag (requires confirmation) | +| `paperless.tags.bulk_delete` | Bulk delete tags | + +### Correspondents + +| Tool | Description | +|------|-------------| +| `paperless.correspondents.list` | List all correspondents | +| `paperless.correspondents.get` | Get correspondent by ID | +| `paperless.correspondents.create` | Create new correspondent | +| `paperless.correspondents.update` | Update correspondent | +| `paperless.correspondents.delete` | Delete correspondent (requires confirmation) | +| `paperless.correspondents.bulk_delete` | Bulk delete correspondents | + +### Document Types + +| Tool | Description | +|------|-------------| +| `paperless.document_types.list` | List all document types | +| `paperless.document_types.get` | Get document type by ID | +| `paperless.document_types.create` | Create new document type | +| `paperless.document_types.update` | Update document type | +| `paperless.document_types.delete` | Delete document type (requires confirmation) | +| `paperless.document_types.bulk_delete` | Bulk delete document types | + +### Storage Paths + +| Tool | Description | +|------|-------------| +| `paperless.storage_paths.list` | List all storage paths | +| `paperless.storage_paths.get` | Get storage path by ID | +| `paperless.storage_paths.create` | Create new storage path | +| `paperless.storage_paths.update` | Update storage path | +| `paperless.storage_paths.delete` | Delete storage path (requires confirmation) | +| `paperless.storage_paths.bulk_delete` | Bulk delete storage paths | + +### Custom Fields + +| Tool | Description | +|------|-------------| +| `paperless.custom_fields.list` | List all custom field definitions | +| `paperless.custom_fields.get` | Get custom field by ID | +| `paperless.custom_fields.create` | Create new custom field | +| `paperless.custom_fields.update` | Update custom field | +| `paperless.custom_fields.delete` | Delete custom field (requires confirmation) | +| `paperless.custom_fields.assign` | Assign field value to document | + +## Response Format + +All tools return responses in a consistent format: + +### Success Response + +```json +{ + "ok": true, + "result": { ... }, + "meta": { + "request_id": "uuid", + "page": 1, + "page_size": 25, + "total": 123, + "next": null, + "paperless_base_url": "https://your-instance.com" + }, + "warnings": [] +} +``` + +### Error Response + +```json +{ + "ok": false, + "error": { + "code": "NOT_FOUND", + "message": "Document with ID 123 not found", + "details": null + }, + "meta": { + "request_id": "uuid", + "paperless_base_url": "https://your-instance.com" + } +} +``` + +### Error Codes + +| Code | Description | +|------|-------------| +| `AUTH_FAILED` | Authentication failed | +| `NOT_FOUND` | Resource not found | +| `VALIDATION` | Invalid input | +| `UPSTREAM_ERROR` | Paperless API error | +| `RATE_LIMIT` | Rate limited | +| `CONFIRMATION_REQUIRED` | Destructive operation requires confirmation | +| `UNKNOWN` | Unknown error | + +## Example Tool Calls + +### Search for Documents + +```json +{ + "tool": "paperless.documents.search", + "arguments": { + "query": "invoice", + "tags": "1,2", + "correspondent": 5, + "page": 1, + "pageSize": 25 + } +} +``` + +### Upload a Document + +```json +{ + "tool": "paperless.documents.upload", + "arguments": { + "fileContent": "base64-encoded-content", + "fileName": "invoice.pdf", + "title": "January Invoice", + "tags": "1,3", + "correspondent": 5 + } +} +``` + +### Bulk Add Tag (Dry Run) + +```json +{ + "tool": "paperless.documents.bulk_update", + "arguments": { + "documentIds": "1,2,3,4,5", + "operation": "add_tag", + "value": 10, + "dryRun": true, + "confirm": false + } +} +``` + +### Bulk Add Tag (Execute) + +```json +{ + "tool": "paperless.documents.bulk_update", + "arguments": { + "documentIds": "1,2,3,4,5", + "operation": "add_tag", + "value": 10, + "dryRun": false, + "confirm": true + } +} +``` + +### Delete Document with Confirmation + +```json +{ + "tool": "paperless.documents.delete", + "arguments": { + "id": 123, + "confirm": true + } +} +``` + +## Testing with curl + +### Test HTTP endpoint + +```bash +# Check if server is running +curl http://localhost:5000/mcp + +# The MCP protocol uses JSON-RPC, so you'd typically +# connect using an MCP client rather than raw curl +``` + +## Development + +```bash +# Restore dependencies +dotnet restore + +# Build +dotnet build + +# Run tests (if any) +dotnet test + +# Run in development mode +dotnet run +``` + +## License + +MIT + +## Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. diff --git a/PaperlessMCP/Tools/CorrespondentTools.cs b/PaperlessMCP/Tools/CorrespondentTools.cs new file mode 100644 index 0000000..713b020 --- /dev/null +++ b/PaperlessMCP/Tools/CorrespondentTools.cs @@ -0,0 +1,258 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.Correspondents; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for correspondent operations. +/// +[McpServerToolType] +public static class CorrespondentTools +{ + [McpServerTool(Name = "paperless.correspondents.list")] + [Description("List all correspondents with pagination.")] + public static async Task List( + PaperlessClient client, + [Description("Page number (default: 1)")] int page = 1, + [Description("Page size (default: 25, max: 100)")] int pageSize = 25, + [Description("Ordering field (e.g., 'name', '-document_count', 'last_correspondence')")] string? ordering = null) + { + var result = await client.GetCorrespondentsAsync(page, Math.Min(pageSize, 100), ordering); + + var response = McpResponse.Success( + result.Results, + new McpMeta + { + Page = page, + PageSize = pageSize, + Total = result.Count, + Next = result.Next, + PaperlessBaseUrl = client.BaseUrl + } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.correspondents.get")] + [Description("Get a correspondent by its ID.")] + public static async Task Get( + PaperlessClient client, + [Description("Correspondent ID")] int id) + { + var correspondent = await client.GetCorrespondentAsync(id); + + if (correspondent == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Correspondent with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + correspondent, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.correspondents.create")] + [Description("Create a new correspondent.")] + public static async Task Create( + PaperlessClient client, + [Description("Correspondent name")] string name, + [Description("Match pattern for auto-assignment")] string? match = null, + [Description("Matching algorithm (0=None, 1=Any, 2=All, 3=Literal, 4=Regex, 5=Fuzzy, 6=Auto)")] int? matchingAlgorithm = null) + { + var request = new CorrespondentCreateRequest + { + Name = name, + Match = match, + MatchingAlgorithm = matchingAlgorithm + }; + + var correspondent = await client.CreateCorrespondentAsync(request); + + if (correspondent == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to create correspondent", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + correspondent, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.correspondents.update")] + [Description("Update an existing correspondent.")] + public static async Task Update( + PaperlessClient client, + [Description("Correspondent ID")] int id, + [Description("New name (optional)")] string? name = null, + [Description("Match pattern (optional)")] string? match = null, + [Description("Matching algorithm (optional)")] int? matchingAlgorithm = null) + { + var request = new CorrespondentUpdateRequest + { + Name = name, + Match = match, + MatchingAlgorithm = matchingAlgorithm + }; + + var correspondent = await client.UpdateCorrespondentAsync(id, request); + + if (correspondent == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Correspondent with ID {id} not found or update failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + correspondent, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.correspondents.delete")] + [Description("Delete a correspondent. Requires explicit confirmation.")] + public static async Task Delete( + PaperlessClient client, + [Description("Correspondent ID")] int id, + [Description("Must be true to confirm deletion")] bool confirm = false) + { + if (!confirm) + { + var correspondent = await client.GetCorrespondentAsync(id); + + if (correspondent == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Correspondent with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Deletion requires confirm=true. This is a dry run showing what would be deleted.", + new { correspondent_id = id, name = correspondent.Name, document_count = correspondent.DocumentCount }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.DeleteCorrespondentAsync(id); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to delete correspondent with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { deleted = true, correspondent_id = id }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.correspondents.bulk_delete")] + [Description("Delete multiple correspondents. Supports dry run mode.")] + public static async Task BulkDelete( + PaperlessClient client, + [Description("Correspondent IDs (comma-separated)")] string correspondentIds, + [Description("Dry run mode - shows what would be deleted without applying")] bool dryRun = true, + [Description("Must be true to execute the deletion")] bool confirm = false) + { + var ids = ParseIntArray(correspondentIds); + + if (ids == null || ids.Length == 0) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "No valid correspondent IDs provided", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + if (dryRun || !confirm) + { + var dryRunResult = new BulkOperationResult + { + AffectedIds = ids, + Warnings = new List + { + dryRun ? "This is a dry run. Set dry_run=false and confirm=true to execute." : "Set confirm=true to execute the operation." + }, + Executed = false + }; + + var dryRunResponse = McpResponse.Success( + dryRunResult, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.BulkEditObjectsAsync(ids, "correspondents", "delete"); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Bulk delete operation failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var result = new BulkOperationResult + { + AffectedIds = ids, + Executed = true + }; + + var response = McpResponse.Success( + result, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + private static int[]? ParseIntArray(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var n) ? n : (int?)null) + .Where(n => n.HasValue) + .Select(n => n!.Value) + .ToArray(); + } +} diff --git a/PaperlessMCP/Tools/CustomFieldTools.cs b/PaperlessMCP/Tools/CustomFieldTools.cs new file mode 100644 index 0000000..af5a133 --- /dev/null +++ b/PaperlessMCP/Tools/CustomFieldTools.cs @@ -0,0 +1,305 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.CustomFields; +using PaperlessMCP.Models.Documents; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for custom field operations. +/// +[McpServerToolType] +public static class CustomFieldTools +{ + [McpServerTool(Name = "paperless.custom_fields.list")] + [Description("List all custom field definitions with pagination.")] + public static async Task List( + PaperlessClient client, + [Description("Page number (default: 1)")] int page = 1, + [Description("Page size (default: 25, max: 100)")] int pageSize = 25) + { + var result = await client.GetCustomFieldsAsync(page, Math.Min(pageSize, 100)); + + var response = McpResponse.Success( + result.Results, + new McpMeta + { + Page = page, + PageSize = pageSize, + Total = result.Count, + Next = result.Next, + PaperlessBaseUrl = client.BaseUrl + } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.custom_fields.get")] + [Description("Get a custom field definition by its ID.")] + public static async Task Get( + PaperlessClient client, + [Description("Custom field ID")] int id) + { + var customField = await client.GetCustomFieldAsync(id); + + if (customField == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Custom field with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + customField, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.custom_fields.create")] + [Description("Create a new custom field definition.")] + public static async Task Create( + PaperlessClient client, + [Description("Custom field name")] string name, + [Description("Data type: string, url, date, boolean, integer, float, monetary, documentlink, select")] string dataType, + [Description("Select options (comma-separated, for 'select' type only)")] string? selectOptions = null, + [Description("Default currency (for 'monetary' type only)")] string? defaultCurrency = null) + { + CustomFieldExtraData? extraData = null; + + if (dataType == CustomFieldDataType.Select && !string.IsNullOrEmpty(selectOptions)) + { + extraData = new CustomFieldExtraData + { + SelectOptions = selectOptions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList() + }; + } + else if (dataType == CustomFieldDataType.Monetary && !string.IsNullOrEmpty(defaultCurrency)) + { + extraData = new CustomFieldExtraData + { + DefaultCurrency = defaultCurrency + }; + } + + var request = new CustomFieldCreateRequest + { + Name = name, + DataType = dataType, + ExtraData = extraData + }; + + var customField = await client.CreateCustomFieldAsync(request); + + if (customField == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to create custom field", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + customField, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.custom_fields.update")] + [Description("Update an existing custom field definition.")] + public static async Task Update( + PaperlessClient client, + [Description("Custom field ID")] int id, + [Description("New name (optional)")] string? name = null, + [Description("Select options (comma-separated, for 'select' type only, optional)")] string? selectOptions = null, + [Description("Default currency (for 'monetary' type only, optional)")] string? defaultCurrency = null) + { + CustomFieldExtraData? extraData = null; + + if (!string.IsNullOrEmpty(selectOptions)) + { + extraData = new CustomFieldExtraData + { + SelectOptions = selectOptions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList() + }; + } + else if (!string.IsNullOrEmpty(defaultCurrency)) + { + extraData = new CustomFieldExtraData + { + DefaultCurrency = defaultCurrency + }; + } + + var request = new CustomFieldUpdateRequest + { + Name = name, + ExtraData = extraData + }; + + var customField = await client.UpdateCustomFieldAsync(id, request); + + if (customField == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Custom field with ID {id} not found or update failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + customField, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.custom_fields.delete")] + [Description("Delete a custom field definition. Requires explicit confirmation.")] + public static async Task Delete( + PaperlessClient client, + [Description("Custom field ID")] int id, + [Description("Must be true to confirm deletion")] bool confirm = false) + { + if (!confirm) + { + var customField = await client.GetCustomFieldAsync(id); + + if (customField == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Custom field with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Deletion requires confirm=true. This is a dry run showing what would be deleted.", + new { custom_field_id = id, name = customField.Name, data_type = customField.DataType }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.DeleteCustomFieldAsync(id); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to delete custom field with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { deleted = true, custom_field_id = id }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.custom_fields.assign")] + [Description("Assign a custom field value to a document.")] + public static async Task Assign( + PaperlessClient client, + [Description("Document ID")] int documentId, + [Description("Custom field ID")] int fieldId, + [Description("Value to assign (string, number, boolean, or date depending on field type)")] string value) + { + // Get current document to update its custom fields + var document = await client.GetDocumentAsync(documentId); + + if (document == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {documentId} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + // Get field definition to understand the data type + var field = await client.GetCustomFieldAsync(fieldId); + + if (field == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Custom field with ID {fieldId} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + // Parse value based on field type + object? parsedValue = field.DataType switch + { + CustomFieldDataType.Boolean => bool.TryParse(value, out var b) ? b : null, + CustomFieldDataType.Integer => int.TryParse(value, out var i) ? i : null, + CustomFieldDataType.Float => double.TryParse(value, out var d) ? d : null, + CustomFieldDataType.Date => value, // Keep as string for dates + _ => value + }; + + // Update custom fields list + var customFields = document.CustomFields.ToList(); + var existingIndex = customFields.FindIndex(cf => cf.Field == fieldId); + + if (existingIndex >= 0) + { + customFields[existingIndex] = new DocumentCustomField { Field = fieldId, Value = parsedValue }; + } + else + { + customFields.Add(new DocumentCustomField { Field = fieldId, Value = parsedValue }); + } + + // Update document + var updateRequest = new DocumentUpdateRequest + { + CustomFields = customFields + }; + + var updatedDocument = await client.UpdateDocumentAsync(documentId, updateRequest); + + if (updatedDocument == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to assign custom field to document", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new + { + document_id = documentId, + field_id = fieldId, + field_name = field.Name, + value = parsedValue, + message = "Custom field assigned successfully" + }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } +} diff --git a/PaperlessMCP/Tools/DocumentTools.cs b/PaperlessMCP/Tools/DocumentTools.cs new file mode 100644 index 0000000..c7ac816 --- /dev/null +++ b/PaperlessMCP/Tools/DocumentTools.cs @@ -0,0 +1,581 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.Documents; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for document operations. +/// +[McpServerToolType] +public static class DocumentTools +{ + [McpServerTool(Name = "paperless.documents.search")] + [Description("Search for documents with full-text search and filters. Supports pagination.")] + public static async Task Search( + PaperlessClient client, + [Description("Full-text search query")] string? query = null, + [Description("Filter by tag IDs (comma-separated)")] string? tags = null, + [Description("Exclude tag IDs (comma-separated)")] string? tagsExclude = null, + [Description("Filter by correspondent ID")] int? correspondent = null, + [Description("Filter by document type ID")] int? documentType = null, + [Description("Filter by storage path ID")] int? storagePath = null, + [Description("Filter by documents created after this date (YYYY-MM-DD)")] string? createdAfter = null, + [Description("Filter by documents created before this date (YYYY-MM-DD)")] string? createdBefore = null, + [Description("Filter by documents added after this date (YYYY-MM-DD)")] string? addedAfter = null, + [Description("Filter by documents added before this date (YYYY-MM-DD)")] string? addedBefore = null, + [Description("Filter by archive serial number")] int? archiveSerialNumber = null, + [Description("Page number (default: 1)")] int page = 1, + [Description("Page size (default: 25, max: 100)")] int pageSize = 25, + [Description("Ordering field (e.g., 'created', '-created', 'title')")] string? ordering = null, + [Description("Include document content in results (default: false). Use paperless.documents.get for full content.")] bool includeContent = false, + [Description("Max content length per document when includeContent=true (default: 500). Use 0 for unlimited.")] int contentMaxLength = 500) + { + var tagIds = ParseIntArray(tags); + var tagExcludeIds = ParseIntArray(tagsExclude); + + DateTime? createdAfterDate = ParseDate(createdAfter); + DateTime? createdBeforeDate = ParseDate(createdBefore); + DateTime? addedAfterDate = ParseDate(addedAfter); + DateTime? addedBeforeDate = ParseDate(addedBefore); + + var result = await client.SearchDocumentsAsync( + query: query, + tags: tagIds, + tagsExclude: tagExcludeIds, + correspondent: correspondent, + documentType: documentType, + storagePath: storagePath, + createdAfter: createdAfterDate, + createdBefore: createdBeforeDate, + addedAfter: addedAfterDate, + addedBefore: addedBeforeDate, + archiveSerialNumber: archiveSerialNumber, + page: page, + pageSize: Math.Min(pageSize, 100), + ordering: ordering + ); + + // Map to lightweight summaries to reduce response size + var summaries = result.Results + .Select(r => DocumentSummary.FromSearchResult( + r, + includeContent, + contentMaxLength > 0 ? contentMaxLength : null)) + .ToList(); + + var response = McpResponse.Success( + summaries, + new McpMeta + { + Page = page, + PageSize = pageSize, + Total = result.Count, + Next = result.Next, + PaperlessBaseUrl = client.BaseUrl + } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.get")] + [Description("Get a document by its ID.")] + public static async Task Get( + PaperlessClient client, + [Description("Document ID")] int id) + { + var document = await client.GetDocumentAsync(id); + + if (document == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + document, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.download")] + [Description("Get download URLs for a document's original file, preview, and thumbnail.")] + public static async Task Download( + PaperlessClient client, + [Description("Document ID")] int id) + { + var document = await client.GetDocumentAsync(id); + + if (document == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var downloadInfo = client.GetDocumentDownloadInfo(id, document.Title, document.OriginalFileName); + + var response = McpResponse.Success( + downloadInfo, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.preview")] + [Description("Get the preview URL for a document.")] + public static async Task Preview( + PaperlessClient client, + [Description("Document ID")] int id) + { + var document = await client.GetDocumentAsync(id); + + if (document == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var downloadInfo = client.GetDocumentDownloadInfo(id, document.Title, document.OriginalFileName); + + var response = McpResponse.Success( + new { id, title = document.Title, preview_url = downloadInfo.PreviewUrl }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.thumbnail")] + [Description("Get the thumbnail URL for a document.")] + public static async Task Thumbnail( + PaperlessClient client, + [Description("Document ID")] int id) + { + var document = await client.GetDocumentAsync(id); + + if (document == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var downloadInfo = client.GetDocumentDownloadInfo(id, document.Title, document.OriginalFileName); + + var response = McpResponse.Success( + new { id, title = document.Title, thumbnail_url = downloadInfo.ThumbnailUrl }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.upload")] + [Description("Upload a new document to Paperless-ngx. Provide file content as base64. For large files, use paperless.documents.upload_from_path instead.")] + public static async Task Upload( + PaperlessClient client, + [Description("Base64-encoded file content")] string fileContent, + [Description("Original filename with extension")] string fileName, + [Description("Document title (optional)")] string? title = null, + [Description("Correspondent ID (optional)")] int? correspondent = null, + [Description("Document type ID (optional)")] int? documentType = null, + [Description("Storage path ID (optional)")] int? storagePath = null, + [Description("Tag IDs (comma-separated, optional)")] string? tags = null, + [Description("Archive serial number (optional)")] int? archiveSerialNumber = null, + [Description("Created date (YYYY-MM-DD, optional)")] string? created = null) + { + byte[] fileBytes; + try + { + fileBytes = Convert.FromBase64String(fileContent); + } + catch (FormatException) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "Invalid base64 file content", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var metadata = new DocumentUploadRequest + { + Title = title, + Correspondent = correspondent, + DocumentType = documentType, + StoragePath = storagePath, + Tags = ParseIntArray(tags)?.ToList(), + ArchiveSerialNumber = archiveSerialNumber, + Created = ParseDate(created) + }; + + var taskId = await client.UploadDocumentAsync(fileBytes, fileName, metadata); + + if (taskId == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to upload document", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { task_id = taskId, status = "queued", message = "Document uploaded and queued for processing" }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.upload_from_path")] + [Description("Upload a document from a local file path. More reliable than base64 upload for large files. Includes automatic retries.")] + public static async Task UploadFromPath( + PaperlessClient client, + [Description("Absolute path to the file to upload")] string filePath, + [Description("Document title (optional, defaults to filename)")] string? title = null, + [Description("Correspondent ID (optional)")] int? correspondent = null, + [Description("Document type ID (optional)")] int? documentType = null, + [Description("Storage path ID (optional)")] int? storagePath = null, + [Description("Tag IDs (comma-separated, optional)")] string? tags = null, + [Description("Archive serial number (optional)")] int? archiveSerialNumber = null, + [Description("Created date (YYYY-MM-DD, optional)")] string? created = null) + { + // Expand ~ to home directory + if (filePath.StartsWith("~/")) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + filePath = Path.Combine(home, filePath[2..]); + } + + // Validate path + if (!Path.IsPathRooted(filePath)) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "File path must be absolute", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + if (!File.Exists(filePath)) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"File not found: {filePath}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var fileInfo = new FileInfo(filePath); + var metadata = new DocumentUploadRequest + { + Title = title ?? Path.GetFileNameWithoutExtension(filePath), + Correspondent = correspondent, + DocumentType = documentType, + StoragePath = storagePath, + Tags = ParseIntArray(tags)?.ToList(), + ArchiveSerialNumber = archiveSerialNumber, + Created = ParseDate(created) + }; + + var (taskId, error) = await client.UploadDocumentFromPathAsync(filePath, metadata); + + if (taskId == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + error ?? "Failed to upload document", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new + { + task_id = taskId, + status = "queued", + message = "Document uploaded and queued for processing", + file_name = fileInfo.Name, + file_size = fileInfo.Length + }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.update")] + [Description("Update document metadata (title, correspondent, type, tags, etc.).")] + public static async Task Update( + PaperlessClient client, + [Description("Document ID")] int id, + [Description("New title (optional)")] string? title = null, + [Description("Correspondent ID (optional, use -1 to clear)")] int? correspondent = null, + [Description("Document type ID (optional, use -1 to clear)")] int? documentType = null, + [Description("Storage path ID (optional, use -1 to clear)")] int? storagePath = null, + [Description("Tag IDs to set (comma-separated, optional)")] string? tags = null, + [Description("Archive serial number (optional)")] int? archiveSerialNumber = null, + [Description("Created date (YYYY-MM-DD, optional)")] string? created = null) + { + var request = new DocumentUpdateRequest + { + Title = title, + Correspondent = correspondent == -1 ? null : correspondent, + DocumentType = documentType == -1 ? null : documentType, + StoragePath = storagePath == -1 ? null : storagePath, + Tags = ParseIntArray(tags)?.ToList(), + ArchiveSerialNumber = archiveSerialNumber, + Created = ParseDate(created) + }; + + var document = await client.UpdateDocumentAsync(id, request); + + if (document == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found or update failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + document, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.delete")] + [Description("Delete a document. Requires explicit confirmation.")] + public static async Task Delete( + PaperlessClient client, + [Description("Document ID")] int id, + [Description("Must be true to confirm deletion")] bool confirm = false) + { + if (!confirm) + { + // Get document info for dry run + var document = await client.GetDocumentAsync(id); + + if (document == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Deletion requires confirm=true. This is a dry run showing what would be deleted.", + new + { + document_id = id, + title = document.Title, + original_file_name = document.OriginalFileName, + created = document.Created + }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.DeleteDocumentAsync(id); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to delete document with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { deleted = true, document_id = id }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.bulk_update")] + [Description("Perform bulk operations on multiple documents. Supports dry run mode.")] + public static async Task BulkUpdate( + PaperlessClient client, + [Description("Document IDs (comma-separated)")] string documentIds, + [Description("Operation: add_tag, remove_tag, set_correspondent, set_document_type, set_storage_path, delete, reprocess")] string operation, + [Description("Parameter value (e.g., tag ID, correspondent ID)")] int? value = null, + [Description("Dry run mode - shows what would change without applying")] bool dryRun = true, + [Description("Must be true to execute the operation")] bool confirm = false) + { + var ids = ParseIntArray(documentIds); + + if (ids == null || ids.Length == 0) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "No valid document IDs provided", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var validOperations = new[] { "add_tag", "remove_tag", "set_correspondent", "set_document_type", "set_storage_path", "delete", "reprocess" }; + if (!validOperations.Contains(operation)) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + $"Invalid operation. Valid operations: {string.Join(", ", validOperations)}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + if (dryRun || !confirm) + { + var dryRunResult = new BulkOperationResult + { + AffectedIds = ids, + Warnings = new List + { + dryRun ? "This is a dry run. Set dry_run=false and confirm=true to execute." : "Set confirm=true to execute the operation." + }, + Executed = false + }; + + var dryRunResponse = McpResponse.Success( + dryRunResult, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + object? parameters = operation switch + { + "add_tag" or "remove_tag" => new { tag = value }, + "set_correspondent" => new { correspondent = value }, + "set_document_type" => new { document_type = value }, + "set_storage_path" => new { storage_path = value }, + _ => null + }; + + var success = await client.BulkEditDocumentsAsync(ids, operation, parameters); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Bulk operation failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var result = new BulkOperationResult + { + AffectedIds = ids, + Executed = true + }; + + var response = McpResponse.Success( + result, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.documents.reprocess")] + [Description("Reprocess a document's OCR and content extraction.")] + public static async Task Reprocess( + PaperlessClient client, + [Description("Document ID")] int id, + [Description("Must be true to confirm reprocessing")] bool confirm = false) + { + if (!confirm) + { + var document = await client.GetDocumentAsync(id); + + if (document == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Reprocessing requires confirm=true. This will re-run OCR on the document.", + new { document_id = id, title = document.Title }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.BulkEditDocumentsAsync(new[] { id }, "reprocess"); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to reprocess document with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { document_id = id, status = "queued", message = "Document queued for reprocessing" }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + private static int[]? ParseIntArray(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var n) ? n : (int?)null) + .Where(n => n.HasValue) + .Select(n => n!.Value) + .ToArray(); + } + + private static DateTime? ParseDate(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return DateTime.TryParse(input, out var date) ? date : null; + } +} diff --git a/PaperlessMCP/Tools/DocumentTypeTools.cs b/PaperlessMCP/Tools/DocumentTypeTools.cs new file mode 100644 index 0000000..6e12a09 --- /dev/null +++ b/PaperlessMCP/Tools/DocumentTypeTools.cs @@ -0,0 +1,258 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.DocumentTypes; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for document type operations. +/// +[McpServerToolType] +public static class DocumentTypeTools +{ + [McpServerTool(Name = "paperless.document_types.list")] + [Description("List all document types with pagination.")] + public static async Task List( + PaperlessClient client, + [Description("Page number (default: 1)")] int page = 1, + [Description("Page size (default: 25, max: 100)")] int pageSize = 25, + [Description("Ordering field (e.g., 'name', '-document_count')")] string? ordering = null) + { + var result = await client.GetDocumentTypesAsync(page, Math.Min(pageSize, 100), ordering); + + var response = McpResponse.Success( + result.Results, + new McpMeta + { + Page = page, + PageSize = pageSize, + Total = result.Count, + Next = result.Next, + PaperlessBaseUrl = client.BaseUrl + } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.document_types.get")] + [Description("Get a document type by its ID.")] + public static async Task Get( + PaperlessClient client, + [Description("Document type ID")] int id) + { + var documentType = await client.GetDocumentTypeAsync(id); + + if (documentType == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document type with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + documentType, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.document_types.create")] + [Description("Create a new document type.")] + public static async Task Create( + PaperlessClient client, + [Description("Document type name")] string name, + [Description("Match pattern for auto-assignment")] string? match = null, + [Description("Matching algorithm (0=None, 1=Any, 2=All, 3=Literal, 4=Regex, 5=Fuzzy, 6=Auto)")] int? matchingAlgorithm = null) + { + var request = new DocumentTypeCreateRequest + { + Name = name, + Match = match, + MatchingAlgorithm = matchingAlgorithm + }; + + var documentType = await client.CreateDocumentTypeAsync(request); + + if (documentType == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to create document type", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + documentType, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.document_types.update")] + [Description("Update an existing document type.")] + public static async Task Update( + PaperlessClient client, + [Description("Document type ID")] int id, + [Description("New name (optional)")] string? name = null, + [Description("Match pattern (optional)")] string? match = null, + [Description("Matching algorithm (optional)")] int? matchingAlgorithm = null) + { + var request = new DocumentTypeUpdateRequest + { + Name = name, + Match = match, + MatchingAlgorithm = matchingAlgorithm + }; + + var documentType = await client.UpdateDocumentTypeAsync(id, request); + + if (documentType == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document type with ID {id} not found or update failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + documentType, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.document_types.delete")] + [Description("Delete a document type. Requires explicit confirmation.")] + public static async Task Delete( + PaperlessClient client, + [Description("Document type ID")] int id, + [Description("Must be true to confirm deletion")] bool confirm = false) + { + if (!confirm) + { + var documentType = await client.GetDocumentTypeAsync(id); + + if (documentType == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Document type with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Deletion requires confirm=true. This is a dry run showing what would be deleted.", + new { document_type_id = id, name = documentType.Name, document_count = documentType.DocumentCount }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.DeleteDocumentTypeAsync(id); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to delete document type with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { deleted = true, document_type_id = id }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.document_types.bulk_delete")] + [Description("Delete multiple document types. Supports dry run mode.")] + public static async Task BulkDelete( + PaperlessClient client, + [Description("Document type IDs (comma-separated)")] string documentTypeIds, + [Description("Dry run mode - shows what would be deleted without applying")] bool dryRun = true, + [Description("Must be true to execute the deletion")] bool confirm = false) + { + var ids = ParseIntArray(documentTypeIds); + + if (ids == null || ids.Length == 0) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "No valid document type IDs provided", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + if (dryRun || !confirm) + { + var dryRunResult = new BulkOperationResult + { + AffectedIds = ids, + Warnings = new List + { + dryRun ? "This is a dry run. Set dry_run=false and confirm=true to execute." : "Set confirm=true to execute the operation." + }, + Executed = false + }; + + var dryRunResponse = McpResponse.Success( + dryRunResult, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.BulkEditObjectsAsync(ids, "document_types", "delete"); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Bulk delete operation failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var result = new BulkOperationResult + { + AffectedIds = ids, + Executed = true + }; + + var response = McpResponse.Success( + result, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + private static int[]? ParseIntArray(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var n) ? n : (int?)null) + .Where(n => n.HasValue) + .Select(n => n!.Value) + .ToArray(); + } +} diff --git a/PaperlessMCP/Tools/HealthTools.cs b/PaperlessMCP/Tools/HealthTools.cs new file mode 100644 index 0000000..595e42c --- /dev/null +++ b/PaperlessMCP/Tools/HealthTools.cs @@ -0,0 +1,126 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for health checks and capability discovery. +/// +[McpServerToolType] +public static class HealthTools +{ + [McpServerTool(Name = "paperless.ping")] + [Description("Verify connectivity and authentication with the Paperless-ngx instance. Returns server version if available.")] + public static async Task Ping(PaperlessClient client) + { + var (success, version, error) = await client.PingAsync(); + + if (success) + { + var response = McpResponse.Success( + new { connected = true, version }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + error ?? "Failed to connect to Paperless instance", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + [McpServerTool(Name = "paperless.capabilities")] + [Description("Return supported API endpoints and detected Paperless-ngx version information.")] + public static async Task GetCapabilities(PaperlessClient client) + { + var (pingSuccess, version, _) = await client.PingAsync(); + var (statusSuccess, status, _) = await client.GetStatusAsync(); + + var capabilities = new + { + connected = pingSuccess, + version, + endpoints = new + { + documents = new + { + search = "/api/documents/", + get = "/api/documents/{id}/", + upload = "/api/documents/post_document/", + update = "/api/documents/{id}/", + delete = "/api/documents/{id}/", + download = "/api/documents/{id}/download/", + preview = "/api/documents/{id}/preview/", + thumbnail = "/api/documents/{id}/thumb/", + bulk_edit = "/api/documents/bulk_edit/" + }, + tags = new + { + list = "/api/tags/", + get = "/api/tags/{id}/", + create = "/api/tags/", + update = "/api/tags/{id}/", + delete = "/api/tags/{id}/" + }, + correspondents = new + { + list = "/api/correspondents/", + get = "/api/correspondents/{id}/", + create = "/api/correspondents/", + update = "/api/correspondents/{id}/", + delete = "/api/correspondents/{id}/" + }, + document_types = new + { + list = "/api/document_types/", + get = "/api/document_types/{id}/", + create = "/api/document_types/", + update = "/api/document_types/{id}/", + delete = "/api/document_types/{id}/" + }, + storage_paths = new + { + list = "/api/storage_paths/", + get = "/api/storage_paths/{id}/", + create = "/api/storage_paths/", + update = "/api/storage_paths/{id}/", + delete = "/api/storage_paths/{id}/" + }, + custom_fields = new + { + list = "/api/custom_fields/", + get = "/api/custom_fields/{id}/", + create = "/api/custom_fields/", + update = "/api/custom_fields/{id}/", + delete = "/api/custom_fields/{id}/" + }, + bulk_operations = "/api/bulk_edit_objects/" + }, + bulk_edit_methods = new[] + { + "set_correspondent", + "set_document_type", + "set_storage_path", + "add_tag", + "remove_tag", + "modify_tags", + "modify_custom_fields", + "delete", + "reprocess" + }, + status = statusSuccess ? status?.RootElement : null + }; + + var response = McpResponse.Success( + capabilities, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } +} diff --git a/PaperlessMCP/Tools/StoragePathTools.cs b/PaperlessMCP/Tools/StoragePathTools.cs new file mode 100644 index 0000000..facb2a9 --- /dev/null +++ b/PaperlessMCP/Tools/StoragePathTools.cs @@ -0,0 +1,262 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.StoragePaths; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for storage path operations. +/// +[McpServerToolType] +public static class StoragePathTools +{ + [McpServerTool(Name = "paperless.storage_paths.list")] + [Description("List all storage paths with pagination.")] + public static async Task List( + PaperlessClient client, + [Description("Page number (default: 1)")] int page = 1, + [Description("Page size (default: 25, max: 100)")] int pageSize = 25, + [Description("Ordering field (e.g., 'name', '-document_count')")] string? ordering = null) + { + var result = await client.GetStoragePathsAsync(page, Math.Min(pageSize, 100), ordering); + + var response = McpResponse.Success( + result.Results, + new McpMeta + { + Page = page, + PageSize = pageSize, + Total = result.Count, + Next = result.Next, + PaperlessBaseUrl = client.BaseUrl + } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.storage_paths.get")] + [Description("Get a storage path by its ID.")] + public static async Task Get( + PaperlessClient client, + [Description("Storage path ID")] int id) + { + var storagePath = await client.GetStoragePathAsync(id); + + if (storagePath == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Storage path with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + storagePath, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.storage_paths.create")] + [Description("Create a new storage path.")] + public static async Task Create( + PaperlessClient client, + [Description("Storage path name")] string name, + [Description("Path template (e.g., '{correspondent}/{document_type}')")] string path, + [Description("Match pattern for auto-assignment")] string? match = null, + [Description("Matching algorithm (0=None, 1=Any, 2=All, 3=Literal, 4=Regex, 5=Fuzzy, 6=Auto)")] int? matchingAlgorithm = null) + { + var request = new StoragePathCreateRequest + { + Name = name, + Path = path, + Match = match, + MatchingAlgorithm = matchingAlgorithm + }; + + var storagePath = await client.CreateStoragePathAsync(request); + + if (storagePath == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to create storage path", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + storagePath, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.storage_paths.update")] + [Description("Update an existing storage path.")] + public static async Task Update( + PaperlessClient client, + [Description("Storage path ID")] int id, + [Description("New name (optional)")] string? name = null, + [Description("Path template (optional)")] string? path = null, + [Description("Match pattern (optional)")] string? match = null, + [Description("Matching algorithm (optional)")] int? matchingAlgorithm = null) + { + var request = new StoragePathUpdateRequest + { + Name = name, + Path = path, + Match = match, + MatchingAlgorithm = matchingAlgorithm + }; + + var storagePath = await client.UpdateStoragePathAsync(id, request); + + if (storagePath == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Storage path with ID {id} not found or update failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + storagePath, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.storage_paths.delete")] + [Description("Delete a storage path. Requires explicit confirmation.")] + public static async Task Delete( + PaperlessClient client, + [Description("Storage path ID")] int id, + [Description("Must be true to confirm deletion")] bool confirm = false) + { + if (!confirm) + { + var storagePath = await client.GetStoragePathAsync(id); + + if (storagePath == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Storage path with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Deletion requires confirm=true. This is a dry run showing what would be deleted.", + new { storage_path_id = id, name = storagePath.Name, path = storagePath.Path, document_count = storagePath.DocumentCount }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.DeleteStoragePathAsync(id); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to delete storage path with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { deleted = true, storage_path_id = id }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.storage_paths.bulk_delete")] + [Description("Delete multiple storage paths. Supports dry run mode.")] + public static async Task BulkDelete( + PaperlessClient client, + [Description("Storage path IDs (comma-separated)")] string storagePathIds, + [Description("Dry run mode - shows what would be deleted without applying")] bool dryRun = true, + [Description("Must be true to execute the deletion")] bool confirm = false) + { + var ids = ParseIntArray(storagePathIds); + + if (ids == null || ids.Length == 0) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "No valid storage path IDs provided", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + if (dryRun || !confirm) + { + var dryRunResult = new BulkOperationResult + { + AffectedIds = ids, + Warnings = new List + { + dryRun ? "This is a dry run. Set dry_run=false and confirm=true to execute." : "Set confirm=true to execute the operation." + }, + Executed = false + }; + + var dryRunResponse = McpResponse.Success( + dryRunResult, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.BulkEditObjectsAsync(ids, "storage_paths", "delete"); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Bulk delete operation failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var result = new BulkOperationResult + { + AffectedIds = ids, + Executed = true + }; + + var response = McpResponse.Success( + result, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + private static int[]? ParseIntArray(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var n) ? n : (int?)null) + .Where(n => n.HasValue) + .Select(n => n!.Value) + .ToArray(); + } +} diff --git a/PaperlessMCP/Tools/TagTools.cs b/PaperlessMCP/Tools/TagTools.cs new file mode 100644 index 0000000..892e086 --- /dev/null +++ b/PaperlessMCP/Tools/TagTools.cs @@ -0,0 +1,266 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PaperlessMCP.Client; +using PaperlessMCP.Models.Common; +using PaperlessMCP.Models.Tags; + +namespace PaperlessMCP.Tools; + +/// +/// MCP tools for tag operations. +/// +[McpServerToolType] +public static class TagTools +{ + [McpServerTool(Name = "paperless.tags.list")] + [Description("List all tags with pagination.")] + public static async Task List( + PaperlessClient client, + [Description("Page number (default: 1)")] int page = 1, + [Description("Page size (default: 25, max: 100)")] int pageSize = 25, + [Description("Ordering field (e.g., 'name', '-document_count')")] string? ordering = null) + { + var result = await client.GetTagsAsync(page, Math.Min(pageSize, 100), ordering); + + var response = McpResponse.Success( + result.Results, + new McpMeta + { + Page = page, + PageSize = pageSize, + Total = result.Count, + Next = result.Next, + PaperlessBaseUrl = client.BaseUrl + } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.tags.get")] + [Description("Get a tag by its ID.")] + public static async Task Get( + PaperlessClient client, + [Description("Tag ID")] int id) + { + var tag = await client.GetTagAsync(id); + + if (tag == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Tag with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + tag, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.tags.create")] + [Description("Create a new tag.")] + public static async Task Create( + PaperlessClient client, + [Description("Tag name")] string name, + [Description("Hex color (e.g., '#ff0000')")] string? color = null, + [Description("Match pattern for auto-tagging")] string? match = null, + [Description("Matching algorithm (0=None, 1=Any, 2=All, 3=Literal, 4=Regex, 5=Fuzzy, 6=Auto)")] int? matchingAlgorithm = null, + [Description("Is inbox tag")] bool? isInboxTag = null) + { + var request = new TagCreateRequest + { + Name = name, + Color = color, + Match = match, + MatchingAlgorithm = matchingAlgorithm, + IsInboxTag = isInboxTag + }; + + var tag = await client.CreateTagAsync(request); + + if (tag == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Failed to create tag", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + tag, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.tags.update")] + [Description("Update an existing tag.")] + public static async Task Update( + PaperlessClient client, + [Description("Tag ID")] int id, + [Description("New name (optional)")] string? name = null, + [Description("Hex color (e.g., '#ff0000', optional)")] string? color = null, + [Description("Match pattern (optional)")] string? match = null, + [Description("Matching algorithm (optional)")] int? matchingAlgorithm = null, + [Description("Is inbox tag (optional)")] bool? isInboxTag = null) + { + var request = new TagUpdateRequest + { + Name = name, + Color = color, + Match = match, + MatchingAlgorithm = matchingAlgorithm, + IsInboxTag = isInboxTag + }; + + var tag = await client.UpdateTagAsync(id, request); + + if (tag == null) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Tag with ID {id} not found or update failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + tag, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.tags.delete")] + [Description("Delete a tag. Requires explicit confirmation.")] + public static async Task Delete( + PaperlessClient client, + [Description("Tag ID")] int id, + [Description("Must be true to confirm deletion")] bool confirm = false) + { + if (!confirm) + { + var tag = await client.GetTagAsync(id); + + if (tag == null) + { + var notFoundResponse = McpErrorResponse.Create( + ErrorCodes.NotFound, + $"Tag with ID {id} not found", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(notFoundResponse); + } + + var dryRunResponse = McpErrorResponse.Create( + ErrorCodes.ConfirmationRequired, + "Deletion requires confirm=true. This is a dry run showing what would be deleted.", + new { tag_id = id, name = tag.Name, document_count = tag.DocumentCount }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.DeleteTagAsync(id); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + $"Failed to delete tag with ID {id}", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var response = McpResponse.Success( + new { deleted = true, tag_id = id }, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + [McpServerTool(Name = "paperless.tags.bulk_delete")] + [Description("Delete multiple tags. Supports dry run mode.")] + public static async Task BulkDelete( + PaperlessClient client, + [Description("Tag IDs (comma-separated)")] string tagIds, + [Description("Dry run mode - shows what would be deleted without applying")] bool dryRun = true, + [Description("Must be true to execute the deletion")] bool confirm = false) + { + var ids = ParseIntArray(tagIds); + + if (ids == null || ids.Length == 0) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.Validation, + "No valid tag IDs provided", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + if (dryRun || !confirm) + { + var dryRunResult = new BulkOperationResult + { + AffectedIds = ids, + Warnings = new List + { + dryRun ? "This is a dry run. Set dry_run=false and confirm=true to execute." : "Set confirm=true to execute the operation." + }, + Executed = false + }; + + var dryRunResponse = McpResponse.Success( + dryRunResult, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(dryRunResponse); + } + + var success = await client.BulkEditObjectsAsync(ids, "tags", "delete"); + + if (!success) + { + var errorResponse = McpErrorResponse.Create( + ErrorCodes.UpstreamError, + "Bulk delete operation failed", + meta: new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(errorResponse); + } + + var result = new BulkOperationResult + { + AffectedIds = ids, + Executed = true + }; + + var response = McpResponse.Success( + result, + new McpMeta { PaperlessBaseUrl = client.BaseUrl } + ); + return JsonSerializer.Serialize(response); + } + + private static int[]? ParseIntArray(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var n) ? n : (int?)null) + .Where(n => n.HasValue) + .Select(n => n!.Value) + .ToArray(); + } +} diff --git a/PaperlessMCP/appsettings.json b/PaperlessMCP/appsettings.json new file mode 100644 index 0000000..3d158e4 --- /dev/null +++ b/PaperlessMCP/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ModelContextProtocol": "Debug" + } + }, + "Paperless": { + "BaseUrl": "", + "ApiToken": "", + "MaxPageSize": 100 + }, + "Mcp": { + "Port": 5000 + } +} diff --git a/PaperlessMCP/docker-compose.yml b/PaperlessMCP/docker-compose.yml new file mode 100644 index 0000000..3697812 --- /dev/null +++ b/PaperlessMCP/docker-compose.yml @@ -0,0 +1,40 @@ +version: '3.8' + +services: + paperless-mcp: + build: + context: . + dockerfile: Dockerfile + container_name: paperless-mcp + ports: + - "5000:5000" + environment: + # Required: Your Paperless-ngx instance URL + - PAPERLESS_BASE_URL=${PAPERLESS_BASE_URL:?PAPERLESS_BASE_URL is required} + # Required: API token from Paperless-ngx + - PAPERLESS_API_TOKEN=${PAPERLESS_API_TOKEN:?PAPERLESS_API_TOKEN is required} + # Optional: Logging level (default: Information) + - MCP_LOG_LEVEL=${MCP_LOG_LEVEL:-Information} + # Optional: Maximum page size for paginated requests (default: 100) + - MAX_PAGE_SIZE=${MAX_PAGE_SIZE:-100} + # Optional: MCP server port (default: 5000) + - MCP_PORT=${MCP_PORT:-5000} + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/mcp"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +# Example usage: +# +# 1. Create a .env file with your configuration: +# PAPERLESS_BASE_URL=https://your-paperless-instance.com +# PAPERLESS_API_TOKEN=your-api-token-here +# +# 2. Start the service: +# docker-compose up -d +# +# 3. Connect your MCP client to: +# http://localhost:5000/mcp