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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user