Files
PaperlessMCP/PaperlessMCP/Tools/DocumentTools.cs
T
2026-04-12 11:55:33 -04:00

566 lines
23 KiB
C#

using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;
using PaperlessMCP.Client;
using PaperlessMCP.Models.Common;
using PaperlessMCP.Models.Documents;
using static PaperlessMCP.Utils.ParsingHelpers;
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
).ConfigureAwait(false);
// 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).ConfigureAwait(false);
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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 result = await client.UpdateDocumentWithResultAsync(id, request).ConfigureAwait(false);
if (!result.IsSuccess)
{
var error = result.Error!;
var errorResponse = McpErrorResponse.Create(
error.StatusCode == System.Net.HttpStatusCode.NotFound ? ErrorCodes.NotFound : ErrorCodes.UpstreamError,
$"Failed to update document {id}: {error.Message}",
new { status_code = (int)error.StatusCode, response_body = error.ResponseBody },
new McpMeta { PaperlessBaseUrl = client.BaseUrl }
);
return JsonSerializer.Serialize(errorResponse);
}
var response = McpResponse<Document>.Success(
result.Value!,
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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).ConfigureAwait(false);
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").ConfigureAwait(false);
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);
}
}