Initial commit: Paperless-ngx MCP Server
A Model Context Protocol (MCP) server for Paperless-ngx document management. Features: - Full CRUD operations for documents, tags, correspondents, document types, storage paths, and custom fields - Document upload with retry logic (base64 and file path) - Bulk operations with dry-run support - Search with full-text and metadata filtering - Pagination support across all list operations - Proper error handling with McpResponse wrapper Built with .NET 10 and the official MCP SDK. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+50
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<IOptions<PaperlessOptions>>();
|
||||||
|
optionsMock.Value.Returns(Options);
|
||||||
|
|
||||||
|
var logger = Substitute.For<ILogger<PaperlessClient>>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = $"<span>test</span> content",
|
||||||
|
Rank = 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PaginatedResult<DocumentSearchResult> 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));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a long content string for testing truncation behavior.
|
||||||
|
/// </summary>
|
||||||
|
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<Tag> 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<Correspondent> 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<DocumentType> 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<StoragePath> 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<CustomField> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.0.1" />
|
||||||
|
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\PaperlessMCP\PaperlessMCP.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PaperlessMCP.Configuration;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP message handler that adds the Paperless-ngx API token to requests.
|
||||||
|
/// </summary>
|
||||||
|
public class PaperlessAuthHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly PaperlessOptions _options;
|
||||||
|
|
||||||
|
public PaperlessAuthHandler(IOptions<PaperlessOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central client for all Paperless-ngx API operations.
|
||||||
|
/// </summary>
|
||||||
|
public class PaperlessClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly PaperlessOptions _options;
|
||||||
|
private readonly ILogger<PaperlessClient> _logger;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
|
};
|
||||||
|
|
||||||
|
public PaperlessClient(HttpClient httpClient, IOptions<PaperlessOptions> options, ILogger<PaperlessClient> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BaseUrl => _options.BaseUrl;
|
||||||
|
|
||||||
|
#region Health & Status
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks connectivity and returns API root information.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets status information from the Paperless instance.
|
||||||
|
/// </summary>
|
||||||
|
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<JsonDocument>(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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for documents with optional filters.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PaginatedResult<DocumentSearchResult>> 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<PaginatedResult<DocumentSearchResult>>(url, cancellationToken)
|
||||||
|
?? new PaginatedResult<DocumentSearchResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a document by ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Document?> GetDocumentAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Document>($"api/documents/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a document.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Document?> UpdateDocumentAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PatchAsync<Document>($"api/documents/{id}/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a document.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> DeleteDocumentAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DeleteAsync($"api/documents/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads a new document from byte array.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> UploadDocumentAsync(
|
||||||
|
byte[] fileContent,
|
||||||
|
string fileName,
|
||||||
|
DocumentUploadRequest? metadata = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await UploadDocumentInternalAsync(
|
||||||
|
() => new ByteArrayContent(fileContent),
|
||||||
|
fileName,
|
||||||
|
metadata,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads a new document from a file path. More reliable for large files.
|
||||||
|
/// </summary>
|
||||||
|
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<string?> UploadDocumentInternalAsync(
|
||||||
|
Func<HttpContent> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets document download URLs.
|
||||||
|
/// </summary>
|
||||||
|
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/"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs bulk edit operations on documents.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the next available archive serial number.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int?> GetNextAsnAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<int?>("api/documents/next_asn/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Tags
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<Tag>> 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<PaginatedResult<Tag>>($"api/tags/?{queryParams}", cancellationToken)
|
||||||
|
?? new PaginatedResult<Tag>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Tag?> GetTagAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Tag>($"api/tags/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Tag?> CreateTagAsync(TagCreateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PostAsync<Tag>("api/tags/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Tag?> UpdateTagAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PatchAsync<Tag>($"api/tags/{id}/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteTagAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DeleteAsync($"api/tags/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Correspondents
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<Correspondent>> 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<PaginatedResult<Correspondent>>($"api/correspondents/?{queryParams}", cancellationToken)
|
||||||
|
?? new PaginatedResult<Correspondent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Correspondent?> GetCorrespondentAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Correspondent>($"api/correspondents/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Correspondent?> CreateCorrespondentAsync(CorrespondentCreateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PostAsync<Correspondent>("api/correspondents/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Correspondent?> UpdateCorrespondentAsync(int id, CorrespondentUpdateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PatchAsync<Correspondent>($"api/correspondents/{id}/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteCorrespondentAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DeleteAsync($"api/correspondents/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Document Types
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<DocumentType>> 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<PaginatedResult<DocumentType>>($"api/document_types/?{queryParams}", cancellationToken)
|
||||||
|
?? new PaginatedResult<DocumentType>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocumentType?> GetDocumentTypeAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<DocumentType>($"api/document_types/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocumentType?> CreateDocumentTypeAsync(DocumentTypeCreateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PostAsync<DocumentType>("api/document_types/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocumentType?> UpdateDocumentTypeAsync(int id, DocumentTypeUpdateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PatchAsync<DocumentType>($"api/document_types/{id}/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteDocumentTypeAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DeleteAsync($"api/document_types/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Storage Paths
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<StoragePath>> 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<PaginatedResult<StoragePath>>($"api/storage_paths/?{queryParams}", cancellationToken)
|
||||||
|
?? new PaginatedResult<StoragePath>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoragePath?> GetStoragePathAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<StoragePath>($"api/storage_paths/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoragePath?> CreateStoragePathAsync(StoragePathCreateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PostAsync<StoragePath>("api/storage_paths/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoragePath?> UpdateStoragePathAsync(int id, StoragePathUpdateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PatchAsync<StoragePath>($"api/storage_paths/{id}/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteStoragePathAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DeleteAsync($"api/storage_paths/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Custom Fields
|
||||||
|
|
||||||
|
public async Task<PaginatedResult<CustomField>> 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<PaginatedResult<CustomField>>($"api/custom_fields/?{queryParams}", cancellationToken)
|
||||||
|
?? new PaginatedResult<CustomField>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomField?> GetCustomFieldAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<CustomField>($"api/custom_fields/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomField?> CreateCustomFieldAsync(CustomFieldCreateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PostAsync<CustomField>("api/custom_fields/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomField?> UpdateCustomFieldAsync(int id, CustomFieldUpdateRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PatchAsync<CustomField>($"api/custom_fields/{id}/", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteCustomFieldAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DeleteAsync($"api/custom_fields/{id}/", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Bulk Operations
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs bulk operations on metadata objects (tags, correspondents, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> 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<T?> GetAsync<T>(string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(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<T?> PostAsync<T>(string url, object request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(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<T?> PatchAsync<T>(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<T>(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<bool> 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace PaperlessMCP.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for connecting to the Paperless-ngx API.
|
||||||
|
/// </summary>
|
||||||
|
public class PaperlessOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base URL of the Paperless-ngx instance (e.g., https://docs.example.com).
|
||||||
|
/// </summary>
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API token for authentication.
|
||||||
|
/// </summary>
|
||||||
|
public string ApiToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum page size for paginated requests.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxPageSize { get; set; } = 100;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard response envelope for all MCP tool results.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of the result payload.</typeparam>
|
||||||
|
public record McpResponse<T>
|
||||||
|
{
|
||||||
|
[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<string> Warnings { get; init; } = [];
|
||||||
|
|
||||||
|
public static McpResponse<T> Success(T result, McpMeta? meta = null, List<string>? warnings = null) => new()
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Result = result,
|
||||||
|
Meta = meta ?? new McpMeta(),
|
||||||
|
Warnings = warnings ?? []
|
||||||
|
};
|
||||||
|
|
||||||
|
public static McpResponse<T> Failure(McpError error, McpMeta? meta = null) => new()
|
||||||
|
{
|
||||||
|
Ok = false,
|
||||||
|
Result = default,
|
||||||
|
Meta = meta ?? new McpMeta(),
|
||||||
|
Warnings = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error response wrapper.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error information.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata included with all responses.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard error codes used across all tools.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a paginated response from the Paperless-ngx API.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of items in the results.</typeparam>
|
||||||
|
public record PaginatedResult<T>
|
||||||
|
{
|
||||||
|
[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<T> Results { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all items from all pages using the provided fetch function.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<List<T>> GetAllPagesAsync(
|
||||||
|
Func<int, Task<PaginatedResult<T>>> fetchPage,
|
||||||
|
int maxPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var allResults = new List<T>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents bulk operation request parameters.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of a bulk operation (dry run or actual).
|
||||||
|
/// </summary>
|
||||||
|
public record BulkOperationResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("affected_ids")]
|
||||||
|
public int[] AffectedIds { get; init; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("current_values")]
|
||||||
|
public Dictionary<int, object>? CurrentValues { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("proposed_changes")]
|
||||||
|
public Dictionary<int, object>? ProposedChanges { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("warnings")]
|
||||||
|
public List<string> Warnings { get; init; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("executed")]
|
||||||
|
public bool Executed { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.Correspondents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a correspondent in Paperless-ngx.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new correspondent.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing correspondent.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.CustomFields;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a custom field definition in Paperless-ngx.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra data for select-type custom fields.
|
||||||
|
/// </summary>
|
||||||
|
public record CustomFieldExtraData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("select_options")]
|
||||||
|
public List<string>? SelectOptions { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("default_currency")]
|
||||||
|
public string? DefaultCurrency { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new custom field.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing custom field.
|
||||||
|
/// </summary>
|
||||||
|
public record CustomFieldUpdateRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("extra_data")]
|
||||||
|
public CustomFieldExtraData? ExtraData { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom field data types.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to assign a custom field value to a document.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.DocumentTypes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a document type in Paperless-ngx.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new document type.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing document type.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.Documents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a document in Paperless-ngx.
|
||||||
|
/// </summary>
|
||||||
|
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<int> 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<DocumentCustomField> CustomFields { get; init; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("notes")]
|
||||||
|
public List<DocumentNote>? Notes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom field value assigned to a document.
|
||||||
|
/// </summary>
|
||||||
|
public record DocumentCustomField
|
||||||
|
{
|
||||||
|
[JsonPropertyName("field")]
|
||||||
|
public int Field { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("value")]
|
||||||
|
public object? Value { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Note attached to a document.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search hit information returned with search results.
|
||||||
|
/// </summary>
|
||||||
|
public record SearchHit
|
||||||
|
{
|
||||||
|
[JsonPropertyName("score")]
|
||||||
|
public double? Score { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("highlights")]
|
||||||
|
public string? Highlights { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rank")]
|
||||||
|
public int? Rank { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Document with search hit information.
|
||||||
|
/// </summary>
|
||||||
|
public record DocumentSearchResult : Document
|
||||||
|
{
|
||||||
|
[JsonPropertyName("__search_hit__")]
|
||||||
|
public SearchHit? SearchHit { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight document summary for search results.
|
||||||
|
/// Excludes full content and notes to reduce response size.
|
||||||
|
/// </summary>
|
||||||
|
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<int> 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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a DocumentSummary from a DocumentSearchResult.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to upload a new document.
|
||||||
|
/// </summary>
|
||||||
|
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<int>? Tags { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("archive_serial_number")]
|
||||||
|
public int? ArchiveSerialNumber { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("custom_fields")]
|
||||||
|
public List<DocumentCustomField>? CustomFields { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing document.
|
||||||
|
/// </summary>
|
||||||
|
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<int>? Tags { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("archive_serial_number")]
|
||||||
|
public int? ArchiveSerialNumber { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("custom_fields")]
|
||||||
|
public List<DocumentCustomField>? CustomFields { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("created")]
|
||||||
|
public DateTime? Created { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download information for a document.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.StoragePaths;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a storage path in Paperless-ngx.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new storage path.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing storage path.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Models.Tags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a tag in Paperless-ngx.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new tag.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing tag.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matching algorithm types used by tags, correspondents, and document types.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>PaperlessMCP</RootNamespace>
|
||||||
|
<AssemblyName>PaperlessMCP</AssemblyName>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ModelContextProtocol" Version="0.2.0-preview.1" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.2.0-preview.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-*" />
|
||||||
|
<PackageReference Include="Polly" Version="8.5.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<int?>("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<PaperlessOptions>(options =>
|
||||||
|
{
|
||||||
|
// Environment variables take precedence (support both naming conventions)
|
||||||
|
options.BaseUrl = Environment.GetEnvironmentVariable("PAPERLESS_BASE_URL")
|
||||||
|
?? Environment.GetEnvironmentVariable("PAPERLESS_URL")
|
||||||
|
?? configuration.GetValue<string>("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<string>("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<int?>("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<PaperlessClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<PaperlessOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
|
||||||
|
client.DefaultRequestHeaders.Add("Accept", "application/json; version=9");
|
||||||
|
})
|
||||||
|
.AddHttpMessageHandler<PaperlessAuthHandler>()
|
||||||
|
.AddPolicyHandler(retryPolicy);
|
||||||
|
|
||||||
|
services.AddTransient<PaperlessAuthHandler>();
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for correspondent operations.
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public static class CorrespondentTools
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "paperless.correspondents.list")]
|
||||||
|
[Description("List all correspondents with pagination.")]
|
||||||
|
public static async Task<string> 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<object>.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<string> 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<Correspondent>.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<string> 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<Correspondent>.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<string> 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<Correspondent>.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<string> 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<object>.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<string> 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<string>
|
||||||
|
{
|
||||||
|
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<BulkOperationResult>.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<BulkOperationResult>.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for custom field operations.
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public static class CustomFieldTools
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "paperless.custom_fields.list")]
|
||||||
|
[Description("List all custom field definitions with pagination.")]
|
||||||
|
public static async Task<string> 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<object>.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<string> 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<CustomField>.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<string> 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<CustomField>.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<string> 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<CustomField>.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<string> 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<object>.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<string> 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<object>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for document operations.
|
||||||
|
/// </summary>
|
||||||
|
[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<string> 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<object>.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<string> 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<Document>.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<string> 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<DocumentDownload>.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<string> 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<object>.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<string> 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<object>.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<string> 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<object>.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<string> 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<object>.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<string> 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<Document>.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<string> 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<object>.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<string> 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<string>
|
||||||
|
{
|
||||||
|
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<BulkOperationResult>.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<BulkOperationResult>.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<string> 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<object>.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for document type operations.
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public static class DocumentTypeTools
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "paperless.document_types.list")]
|
||||||
|
[Description("List all document types with pagination.")]
|
||||||
|
public static async Task<string> 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<object>.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<string> 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<DocumentType>.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<string> 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<DocumentType>.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<string> 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<DocumentType>.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<string> 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<object>.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<string> 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<string>
|
||||||
|
{
|
||||||
|
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<BulkOperationResult>.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<BulkOperationResult>.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using PaperlessMCP.Client;
|
||||||
|
using PaperlessMCP.Models.Common;
|
||||||
|
|
||||||
|
namespace PaperlessMCP.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for health checks and capability discovery.
|
||||||
|
/// </summary>
|
||||||
|
[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<string> Ping(PaperlessClient client)
|
||||||
|
{
|
||||||
|
var (success, version, error) = await client.PingAsync();
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
var response = McpResponse<object>.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<string> 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<object>.Success(
|
||||||
|
capabilities,
|
||||||
|
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
|
||||||
|
);
|
||||||
|
return JsonSerializer.Serialize(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for storage path operations.
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public static class StoragePathTools
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "paperless.storage_paths.list")]
|
||||||
|
[Description("List all storage paths with pagination.")]
|
||||||
|
public static async Task<string> 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<object>.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<string> 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<StoragePath>.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<string> 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<StoragePath>.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<string> 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<StoragePath>.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<string> 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<object>.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<string> 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<string>
|
||||||
|
{
|
||||||
|
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<BulkOperationResult>.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<BulkOperationResult>.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP tools for tag operations.
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public static class TagTools
|
||||||
|
{
|
||||||
|
[McpServerTool(Name = "paperless.tags.list")]
|
||||||
|
[Description("List all tags with pagination.")]
|
||||||
|
public static async Task<string> 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<object>.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<string> 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<Tag>.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<string> 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<Tag>.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<string> 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<Tag>.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<string> 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<object>.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<string> 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<string>
|
||||||
|
{
|
||||||
|
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<BulkOperationResult>.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<BulkOperationResult>.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"ModelContextProtocol": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Paperless": {
|
||||||
|
"BaseUrl": "",
|
||||||
|
"ApiToken": "",
|
||||||
|
"MaxPageSize": 100
|
||||||
|
},
|
||||||
|
"Mcp": {
|
||||||
|
"Port": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user