diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 2896722..80e0d6c 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -118,3 +118,22 @@ steps: https://api.github.com/repos/barryw/PaperlessMCP/releases \ -d "{\"tag_name\":\"$$TAG\",\"name\":\"Release $$VERSION\",\"body\":\"Release $$VERSION\",\"draft\":false,\"prerelease\":false}" depends_on: [git-tag] + + # Deploy to Kubernetes (uses in-cluster service account) + - name: deploy + image: bitnami/kubectl:latest + commands: + - | + VERSION=$$(cat .version) + echo "Deploying version $$VERSION to Kubernetes" + + # Update deployment image to specific version tag + kubectl set image deployment/paperless-mcp \ + paperless-mcp=ghcr.io/barryw/paperlessmcp:v$$VERSION \ + -n default + + # Wait for rollout to complete + kubectl rollout status deployment/paperless-mcp -n default --timeout=120s + + echo "Deployment complete!" + depends_on: [docker, release] diff --git a/PaperlessMCP/Client/PaperlessClient.cs b/PaperlessMCP/Client/PaperlessClient.cs index 30522b4..4e60de5 100644 --- a/PaperlessMCP/Client/PaperlessClient.cs +++ b/PaperlessMCP/Client/PaperlessClient.cs @@ -48,12 +48,12 @@ public class PaperlessClient { try { - var response = await _httpClient.GetAsync("api/status/", cancellationToken); + var response = await _httpClient.GetAsync("api/status/", cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { // Extract version from the status response - var content = await response.Content.ReadAsStringAsync(cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var json = JsonSerializer.Deserialize(content); var version = json.TryGetProperty("pngx_version", out var versionProp) ? versionProp.GetString() @@ -78,11 +78,11 @@ public class PaperlessClient { try { - var response = await _httpClient.GetAsync("api/status/", cancellationToken); + var response = await _httpClient.GetAsync("api/status/", cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - var json = await response.Content.ReadFromJsonAsync(cancellationToken); + var json = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); return (true, json, null); } @@ -163,7 +163,7 @@ public class PaperlessClient queryParams["ordering"] = ordering; var url = $"api/documents/?{queryParams}"; - return await GetAsync>(url, cancellationToken) + return await GetAsync>(url, cancellationToken).ConfigureAwait(false) ?? new PaginatedResult(); } @@ -172,7 +172,7 @@ public class PaperlessClient /// public async Task GetDocumentAsync(int id, CancellationToken cancellationToken = default) { - return await GetAsync($"api/documents/{id}/", cancellationToken); + return await GetAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false); } /// @@ -180,7 +180,7 @@ public class PaperlessClient /// public async Task UpdateDocumentAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default) { - return await PatchAsync($"api/documents/{id}/", request, cancellationToken); + return await PatchAsync($"api/documents/{id}/", request, cancellationToken).ConfigureAwait(false); } /// @@ -188,7 +188,7 @@ public class PaperlessClient /// public async Task DeleteDocumentAsync(int id, CancellationToken cancellationToken = default) { - return await DeleteAsync($"api/documents/{id}/", cancellationToken); + return await DeleteAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false); } /// @@ -204,7 +204,7 @@ public class PaperlessClient () => new ByteArrayContent(fileContent), fileName, metadata, - cancellationToken); + cancellationToken).ConfigureAwait(false); } /// @@ -247,7 +247,7 @@ public class PaperlessClient fileName, metadata, cancellationToken, - disposeContent: false); // StreamContent owns the stream + disposeContent: false).ConfigureAwait(false); // StreamContent owns the stream if (taskId != null) { @@ -262,18 +262,18 @@ public class PaperlessClient { var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // Exponential backoff _logger.LogInformation("Retrying in {Delay}...", delay); - await Task.Delay(delay, cancellationToken); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } catch (IOException ex) when (attempt < maxRetries) { _logger.LogWarning(ex, "IO error on attempt {Attempt}/{MaxRetries}, retrying...", attempt, maxRetries); - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken).ConfigureAwait(false); } catch (HttpRequestException ex) when (attempt < maxRetries) { _logger.LogWarning(ex, "HTTP error on attempt {Attempt}/{MaxRetries}, retrying...", attempt, maxRetries); - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -294,10 +294,12 @@ public class PaperlessClient { using var formContent = new MultipartFormDataContent(); var fileContent = contentFactory(); + var addedToForm = false; try { formContent.Add(fileContent, "document", fileName); + addedToForm = true; if (metadata != null) { @@ -327,21 +329,23 @@ public class PaperlessClient using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(5)); // 5 minute timeout for uploads - var response = await _httpClient.PostAsync("api/documents/post_document/", formContent, cts.Token); + var response = await _httpClient.PostAsync("api/documents/post_document/", formContent, cts.Token).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - var result = await response.Content.ReadAsStringAsync(cts.Token); + var result = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); return result.Trim('"'); // Returns task UUID } - var error = await response.Content.ReadAsStringAsync(cts.Token); + var error = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); _logger.LogError("Failed to upload document: {StatusCode} - {Error}", response.StatusCode, error); return null; } finally { - if (disposeContent && fileContent is IDisposable disposable) + // Only dispose manually if we didn't add it to formContent + // (formContent owns and will dispose content added to it) + if (!addedToForm && disposeContent && fileContent is IDisposable disposable) { disposable.Dispose(); } @@ -383,7 +387,7 @@ public class PaperlessClient try { - var response = await _httpClient.PostAsJsonAsync("api/documents/bulk_edit/", request, JsonOptions, cancellationToken); + var response = await _httpClient.PostAsJsonAsync("api/documents/bulk_edit/", request, JsonOptions, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch (Exception ex) @@ -398,7 +402,7 @@ public class PaperlessClient /// public async Task GetNextAsnAsync(CancellationToken cancellationToken = default) { - return await GetAsync("api/documents/next_asn/", cancellationToken); + return await GetAsync("api/documents/next_asn/", cancellationToken).ConfigureAwait(false); } #endregion @@ -413,28 +417,28 @@ public class PaperlessClient if (!string.IsNullOrEmpty(ordering)) queryParams["ordering"] = ordering; - return await GetAsync>($"api/tags/?{queryParams}", cancellationToken) + return await GetAsync>($"api/tags/?{queryParams}", cancellationToken).ConfigureAwait(false) ?? new PaginatedResult(); } public async Task GetTagAsync(int id, CancellationToken cancellationToken = default) { - return await GetAsync($"api/tags/{id}/", cancellationToken); + return await GetAsync($"api/tags/{id}/", cancellationToken).ConfigureAwait(false); } public async Task CreateTagAsync(TagCreateRequest request, CancellationToken cancellationToken = default) { - return await PostAsync("api/tags/", request, cancellationToken); + return await PostAsync("api/tags/", request, cancellationToken).ConfigureAwait(false); } public async Task UpdateTagAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default) { - return await PatchAsync($"api/tags/{id}/", request, cancellationToken); + return await PatchAsync($"api/tags/{id}/", request, cancellationToken).ConfigureAwait(false); } public async Task DeleteTagAsync(int id, CancellationToken cancellationToken = default) { - return await DeleteAsync($"api/tags/{id}/", cancellationToken); + return await DeleteAsync($"api/tags/{id}/", cancellationToken).ConfigureAwait(false); } #endregion @@ -449,28 +453,28 @@ public class PaperlessClient if (!string.IsNullOrEmpty(ordering)) queryParams["ordering"] = ordering; - return await GetAsync>($"api/correspondents/?{queryParams}", cancellationToken) + return await GetAsync>($"api/correspondents/?{queryParams}", cancellationToken).ConfigureAwait(false) ?? new PaginatedResult(); } public async Task GetCorrespondentAsync(int id, CancellationToken cancellationToken = default) { - return await GetAsync($"api/correspondents/{id}/", cancellationToken); + return await GetAsync($"api/correspondents/{id}/", cancellationToken).ConfigureAwait(false); } public async Task CreateCorrespondentAsync(CorrespondentCreateRequest request, CancellationToken cancellationToken = default) { - return await PostAsync("api/correspondents/", request, cancellationToken); + return await PostAsync("api/correspondents/", request, cancellationToken).ConfigureAwait(false); } public async Task UpdateCorrespondentAsync(int id, CorrespondentUpdateRequest request, CancellationToken cancellationToken = default) { - return await PatchAsync($"api/correspondents/{id}/", request, cancellationToken); + return await PatchAsync($"api/correspondents/{id}/", request, cancellationToken).ConfigureAwait(false); } public async Task DeleteCorrespondentAsync(int id, CancellationToken cancellationToken = default) { - return await DeleteAsync($"api/correspondents/{id}/", cancellationToken); + return await DeleteAsync($"api/correspondents/{id}/", cancellationToken).ConfigureAwait(false); } #endregion @@ -485,28 +489,28 @@ public class PaperlessClient if (!string.IsNullOrEmpty(ordering)) queryParams["ordering"] = ordering; - return await GetAsync>($"api/document_types/?{queryParams}", cancellationToken) + return await GetAsync>($"api/document_types/?{queryParams}", cancellationToken).ConfigureAwait(false) ?? new PaginatedResult(); } public async Task GetDocumentTypeAsync(int id, CancellationToken cancellationToken = default) { - return await GetAsync($"api/document_types/{id}/", cancellationToken); + return await GetAsync($"api/document_types/{id}/", cancellationToken).ConfigureAwait(false); } public async Task CreateDocumentTypeAsync(DocumentTypeCreateRequest request, CancellationToken cancellationToken = default) { - return await PostAsync("api/document_types/", request, cancellationToken); + return await PostAsync("api/document_types/", request, cancellationToken).ConfigureAwait(false); } public async Task UpdateDocumentTypeAsync(int id, DocumentTypeUpdateRequest request, CancellationToken cancellationToken = default) { - return await PatchAsync($"api/document_types/{id}/", request, cancellationToken); + return await PatchAsync($"api/document_types/{id}/", request, cancellationToken).ConfigureAwait(false); } public async Task DeleteDocumentTypeAsync(int id, CancellationToken cancellationToken = default) { - return await DeleteAsync($"api/document_types/{id}/", cancellationToken); + return await DeleteAsync($"api/document_types/{id}/", cancellationToken).ConfigureAwait(false); } #endregion @@ -521,28 +525,28 @@ public class PaperlessClient if (!string.IsNullOrEmpty(ordering)) queryParams["ordering"] = ordering; - return await GetAsync>($"api/storage_paths/?{queryParams}", cancellationToken) + return await GetAsync>($"api/storage_paths/?{queryParams}", cancellationToken).ConfigureAwait(false) ?? new PaginatedResult(); } public async Task GetStoragePathAsync(int id, CancellationToken cancellationToken = default) { - return await GetAsync($"api/storage_paths/{id}/", cancellationToken); + return await GetAsync($"api/storage_paths/{id}/", cancellationToken).ConfigureAwait(false); } public async Task CreateStoragePathAsync(StoragePathCreateRequest request, CancellationToken cancellationToken = default) { - return await PostAsync("api/storage_paths/", request, cancellationToken); + return await PostAsync("api/storage_paths/", request, cancellationToken).ConfigureAwait(false); } public async Task UpdateStoragePathAsync(int id, StoragePathUpdateRequest request, CancellationToken cancellationToken = default) { - return await PatchAsync($"api/storage_paths/{id}/", request, cancellationToken); + return await PatchAsync($"api/storage_paths/{id}/", request, cancellationToken).ConfigureAwait(false); } public async Task DeleteStoragePathAsync(int id, CancellationToken cancellationToken = default) { - return await DeleteAsync($"api/storage_paths/{id}/", cancellationToken); + return await DeleteAsync($"api/storage_paths/{id}/", cancellationToken).ConfigureAwait(false); } #endregion @@ -555,28 +559,28 @@ public class PaperlessClient queryParams["page"] = page.ToString(); queryParams["page_size"] = (pageSize ?? _options.MaxPageSize).ToString(); - return await GetAsync>($"api/custom_fields/?{queryParams}", cancellationToken) + return await GetAsync>($"api/custom_fields/?{queryParams}", cancellationToken).ConfigureAwait(false) ?? new PaginatedResult(); } public async Task GetCustomFieldAsync(int id, CancellationToken cancellationToken = default) { - return await GetAsync($"api/custom_fields/{id}/", cancellationToken); + return await GetAsync($"api/custom_fields/{id}/", cancellationToken).ConfigureAwait(false); } public async Task CreateCustomFieldAsync(CustomFieldCreateRequest request, CancellationToken cancellationToken = default) { - return await PostAsync("api/custom_fields/", request, cancellationToken); + return await PostAsync("api/custom_fields/", request, cancellationToken).ConfigureAwait(false); } public async Task UpdateCustomFieldAsync(int id, CustomFieldUpdateRequest request, CancellationToken cancellationToken = default) { - return await PatchAsync($"api/custom_fields/{id}/", request, cancellationToken); + return await PatchAsync($"api/custom_fields/{id}/", request, cancellationToken).ConfigureAwait(false); } public async Task DeleteCustomFieldAsync(int id, CancellationToken cancellationToken = default) { - return await DeleteAsync($"api/custom_fields/{id}/", cancellationToken); + return await DeleteAsync($"api/custom_fields/{id}/", cancellationToken).ConfigureAwait(false); } #endregion @@ -603,7 +607,7 @@ public class PaperlessClient try { - var response = await _httpClient.PostAsJsonAsync("api/bulk_edit_objects/", request, JsonOptions, cancellationToken); + var response = await _httpClient.PostAsJsonAsync("api/bulk_edit_objects/", request, JsonOptions, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch (Exception ex) @@ -621,14 +625,14 @@ public class PaperlessClient { try { - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); } - await LogErrorResponse(response, "GET", url); + await LogErrorResponse(response, "GET", url).ConfigureAwait(false); return default; } catch (Exception ex) @@ -642,14 +646,14 @@ public class PaperlessClient { try { - var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken); + var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); } - await LogErrorResponse(response, "POST", url); + await LogErrorResponse(response, "POST", url).ConfigureAwait(false); return default; } catch (Exception ex) @@ -664,14 +668,14 @@ public class PaperlessClient try { var content = JsonContent.Create(request, options: JsonOptions); - var response = await _httpClient.PatchAsync(url, content, cancellationToken); + var response = await _httpClient.PatchAsync(url, content, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); } - await LogErrorResponse(response, "PATCH", url); + await LogErrorResponse(response, "PATCH", url).ConfigureAwait(false); return default; } catch (Exception ex) @@ -685,14 +689,14 @@ public class PaperlessClient { try { - var response = await _httpClient.DeleteAsync(url, cancellationToken); + var response = await _httpClient.DeleteAsync(url, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NoContent) { return true; } - await LogErrorResponse(response, "DELETE", url); + await LogErrorResponse(response, "DELETE", url).ConfigureAwait(false); return false; } catch (Exception ex) @@ -704,7 +708,7 @@ public class PaperlessClient private async Task LogErrorResponse(HttpResponseMessage response, string method, string url) { - var body = await response.Content.ReadAsStringAsync(); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); _logger.LogError("{Method} {Url} failed with {StatusCode}: {Body}", method, url, (int)response.StatusCode, body); } diff --git a/PaperlessMCP/Models/Common/PaginatedResult.cs b/PaperlessMCP/Models/Common/PaginatedResult.cs index 34be5a7..d67ca70 100644 --- a/PaperlessMCP/Models/Common/PaginatedResult.cs +++ b/PaperlessMCP/Models/Common/PaginatedResult.cs @@ -19,33 +19,6 @@ public record PaginatedResult [JsonPropertyName("results")] public List Results { get; init; } = []; - - /// - /// Gets all items from all pages using the provided fetch function. - /// - public static async Task> GetAllPagesAsync( - Func>> fetchPage, - int maxPages = 100, - CancellationToken cancellationToken = default) - { - var allResults = new List(); - var page = 1; - - while (page <= maxPages) - { - cancellationToken.ThrowIfCancellationRequested(); - - var result = await fetchPage(page); - allResults.AddRange(result.Results); - - if (string.IsNullOrEmpty(result.Next)) - break; - - page++; - } - - return allResults; - } } /// diff --git a/PaperlessMCP/Program.cs b/PaperlessMCP/Program.cs index 8d51d68..9c818f6 100644 --- a/PaperlessMCP/Program.cs +++ b/PaperlessMCP/Program.cs @@ -87,6 +87,7 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/"); client.DefaultRequestHeaders.Add("Accept", "application/json; version=9"); + client.Timeout = TimeSpan.FromSeconds(30); }) .AddHttpMessageHandler() .AddPolicyHandler(retryPolicy); diff --git a/PaperlessMCP/Tools/CorrespondentTools.cs b/PaperlessMCP/Tools/CorrespondentTools.cs index 713b020..c94effb 100644 --- a/PaperlessMCP/Tools/CorrespondentTools.cs +++ b/PaperlessMCP/Tools/CorrespondentTools.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using PaperlessMCP.Client; using PaperlessMCP.Models.Common; using PaperlessMCP.Models.Correspondents; +using static PaperlessMCP.Utils.ParsingHelpers; namespace PaperlessMCP.Tools; @@ -21,7 +22,7 @@ public static class CorrespondentTools [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 result = await client.GetCorrespondentsAsync(page, Math.Min(pageSize, 100), ordering).ConfigureAwait(false); var response = McpResponse.Success( result.Results, @@ -43,7 +44,7 @@ public static class CorrespondentTools PaperlessClient client, [Description("Correspondent ID")] int id) { - var correspondent = await client.GetCorrespondentAsync(id); + var correspondent = await client.GetCorrespondentAsync(id).ConfigureAwait(false); if (correspondent == null) { @@ -77,7 +78,7 @@ public static class CorrespondentTools MatchingAlgorithm = matchingAlgorithm }; - var correspondent = await client.CreateCorrespondentAsync(request); + var correspondent = await client.CreateCorrespondentAsync(request).ConfigureAwait(false); if (correspondent == null) { @@ -112,7 +113,7 @@ public static class CorrespondentTools MatchingAlgorithm = matchingAlgorithm }; - var correspondent = await client.UpdateCorrespondentAsync(id, request); + var correspondent = await client.UpdateCorrespondentAsync(id, request).ConfigureAwait(false); if (correspondent == null) { @@ -140,7 +141,7 @@ public static class CorrespondentTools { if (!confirm) { - var correspondent = await client.GetCorrespondentAsync(id); + var correspondent = await client.GetCorrespondentAsync(id).ConfigureAwait(false); if (correspondent == null) { @@ -161,7 +162,7 @@ public static class CorrespondentTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.DeleteCorrespondentAsync(id); + var success = await client.DeleteCorrespondentAsync(id).ConfigureAwait(false); if (!success) { @@ -219,7 +220,7 @@ public static class CorrespondentTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.BulkEditObjectsAsync(ids, "correspondents", "delete"); + var success = await client.BulkEditObjectsAsync(ids, "correspondents", "delete").ConfigureAwait(false); if (!success) { @@ -244,15 +245,4 @@ public static class CorrespondentTools return JsonSerializer.Serialize(response); } - private static int[]? ParseIntArray(string? input) - { - if (string.IsNullOrWhiteSpace(input)) - return null; - - return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(s => int.TryParse(s, out var n) ? n : (int?)null) - .Where(n => n.HasValue) - .Select(n => n!.Value) - .ToArray(); - } } diff --git a/PaperlessMCP/Tools/CustomFieldTools.cs b/PaperlessMCP/Tools/CustomFieldTools.cs index af5a133..3f25190 100644 --- a/PaperlessMCP/Tools/CustomFieldTools.cs +++ b/PaperlessMCP/Tools/CustomFieldTools.cs @@ -21,7 +21,7 @@ public static class CustomFieldTools [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 result = await client.GetCustomFieldsAsync(page, Math.Min(pageSize, 100)).ConfigureAwait(false); var response = McpResponse.Success( result.Results, @@ -43,7 +43,7 @@ public static class CustomFieldTools PaperlessClient client, [Description("Custom field ID")] int id) { - var customField = await client.GetCustomFieldAsync(id); + var customField = await client.GetCustomFieldAsync(id).ConfigureAwait(false); if (customField == null) { @@ -95,7 +95,7 @@ public static class CustomFieldTools ExtraData = extraData }; - var customField = await client.CreateCustomFieldAsync(request); + var customField = await client.CreateCustomFieldAsync(request).ConfigureAwait(false); if (customField == null) { @@ -146,7 +146,7 @@ public static class CustomFieldTools ExtraData = extraData }; - var customField = await client.UpdateCustomFieldAsync(id, request); + var customField = await client.UpdateCustomFieldAsync(id, request).ConfigureAwait(false); if (customField == null) { @@ -174,7 +174,7 @@ public static class CustomFieldTools { if (!confirm) { - var customField = await client.GetCustomFieldAsync(id); + var customField = await client.GetCustomFieldAsync(id).ConfigureAwait(false); if (customField == null) { @@ -195,7 +195,7 @@ public static class CustomFieldTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.DeleteCustomFieldAsync(id); + var success = await client.DeleteCustomFieldAsync(id).ConfigureAwait(false); if (!success) { @@ -223,7 +223,7 @@ public static class CustomFieldTools [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); + var document = await client.GetDocumentAsync(documentId).ConfigureAwait(false); if (document == null) { @@ -236,7 +236,7 @@ public static class CustomFieldTools } // Get field definition to understand the data type - var field = await client.GetCustomFieldAsync(fieldId); + var field = await client.GetCustomFieldAsync(fieldId).ConfigureAwait(false); if (field == null) { @@ -277,7 +277,7 @@ public static class CustomFieldTools CustomFields = customFields }; - var updatedDocument = await client.UpdateDocumentAsync(documentId, updateRequest); + var updatedDocument = await client.UpdateDocumentAsync(documentId, updateRequest).ConfigureAwait(false); if (updatedDocument == null) { diff --git a/PaperlessMCP/Tools/DocumentTools.cs b/PaperlessMCP/Tools/DocumentTools.cs index c7ac816..f53f66f 100644 --- a/PaperlessMCP/Tools/DocumentTools.cs +++ b/PaperlessMCP/Tools/DocumentTools.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using PaperlessMCP.Client; using PaperlessMCP.Models.Common; using PaperlessMCP.Models.Documents; +using static PaperlessMCP.Utils.ParsingHelpers; namespace PaperlessMCP.Tools; @@ -57,7 +58,7 @@ public static class DocumentTools page: page, pageSize: Math.Min(pageSize, 100), ordering: ordering - ); + ).ConfigureAwait(false); // Map to lightweight summaries to reduce response size var summaries = result.Results @@ -87,7 +88,7 @@ public static class DocumentTools PaperlessClient client, [Description("Document ID")] int id) { - var document = await client.GetDocumentAsync(id); + var document = await client.GetDocumentAsync(id).ConfigureAwait(false); if (document == null) { @@ -112,7 +113,7 @@ public static class DocumentTools PaperlessClient client, [Description("Document ID")] int id) { - var document = await client.GetDocumentAsync(id); + var document = await client.GetDocumentAsync(id).ConfigureAwait(false); if (document == null) { @@ -139,7 +140,7 @@ public static class DocumentTools PaperlessClient client, [Description("Document ID")] int id) { - var document = await client.GetDocumentAsync(id); + var document = await client.GetDocumentAsync(id).ConfigureAwait(false); if (document == null) { @@ -166,7 +167,7 @@ public static class DocumentTools PaperlessClient client, [Description("Document ID")] int id) { - var document = await client.GetDocumentAsync(id); + var document = await client.GetDocumentAsync(id).ConfigureAwait(false); if (document == null) { @@ -227,7 +228,7 @@ public static class DocumentTools Created = ParseDate(created) }; - var taskId = await client.UploadDocumentAsync(fileBytes, fileName, metadata); + var taskId = await client.UploadDocumentAsync(fileBytes, fileName, metadata).ConfigureAwait(false); if (taskId == null) { @@ -299,7 +300,7 @@ public static class DocumentTools Created = ParseDate(created) }; - var (taskId, error) = await client.UploadDocumentFromPathAsync(filePath, metadata); + var (taskId, error) = await client.UploadDocumentFromPathAsync(filePath, metadata).ConfigureAwait(false); if (taskId == null) { @@ -349,7 +350,7 @@ public static class DocumentTools Created = ParseDate(created) }; - var document = await client.UpdateDocumentAsync(id, request); + var document = await client.UpdateDocumentAsync(id, request).ConfigureAwait(false); if (document == null) { @@ -378,7 +379,7 @@ public static class DocumentTools if (!confirm) { // Get document info for dry run - var document = await client.GetDocumentAsync(id); + var document = await client.GetDocumentAsync(id).ConfigureAwait(false); if (document == null) { @@ -405,7 +406,7 @@ public static class DocumentTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.DeleteDocumentAsync(id); + var success = await client.DeleteDocumentAsync(id).ConfigureAwait(false); if (!success) { @@ -485,7 +486,7 @@ public static class DocumentTools _ => null }; - var success = await client.BulkEditDocumentsAsync(ids, operation, parameters); + var success = await client.BulkEditDocumentsAsync(ids, operation, parameters).ConfigureAwait(false); if (!success) { @@ -519,7 +520,7 @@ public static class DocumentTools { if (!confirm) { - var document = await client.GetDocumentAsync(id); + var document = await client.GetDocumentAsync(id).ConfigureAwait(false); if (document == null) { @@ -540,7 +541,7 @@ public static class DocumentTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.BulkEditDocumentsAsync(new[] { id }, "reprocess"); + var success = await client.BulkEditDocumentsAsync(new[] { id }, "reprocess").ConfigureAwait(false); if (!success) { @@ -559,23 +560,4 @@ public static class DocumentTools return JsonSerializer.Serialize(response); } - private static int[]? ParseIntArray(string? input) - { - if (string.IsNullOrWhiteSpace(input)) - return null; - - return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(s => int.TryParse(s, out var n) ? n : (int?)null) - .Where(n => n.HasValue) - .Select(n => n!.Value) - .ToArray(); - } - - private static DateTime? ParseDate(string? input) - { - if (string.IsNullOrWhiteSpace(input)) - return null; - - return DateTime.TryParse(input, out var date) ? date : null; - } } diff --git a/PaperlessMCP/Tools/DocumentTypeTools.cs b/PaperlessMCP/Tools/DocumentTypeTools.cs index 6e12a09..9bf14e4 100644 --- a/PaperlessMCP/Tools/DocumentTypeTools.cs +++ b/PaperlessMCP/Tools/DocumentTypeTools.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using PaperlessMCP.Client; using PaperlessMCP.Models.Common; using PaperlessMCP.Models.DocumentTypes; +using static PaperlessMCP.Utils.ParsingHelpers; namespace PaperlessMCP.Tools; @@ -21,7 +22,7 @@ public static class DocumentTypeTools [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 result = await client.GetDocumentTypesAsync(page, Math.Min(pageSize, 100), ordering).ConfigureAwait(false); var response = McpResponse.Success( result.Results, @@ -43,7 +44,7 @@ public static class DocumentTypeTools PaperlessClient client, [Description("Document type ID")] int id) { - var documentType = await client.GetDocumentTypeAsync(id); + var documentType = await client.GetDocumentTypeAsync(id).ConfigureAwait(false); if (documentType == null) { @@ -77,7 +78,7 @@ public static class DocumentTypeTools MatchingAlgorithm = matchingAlgorithm }; - var documentType = await client.CreateDocumentTypeAsync(request); + var documentType = await client.CreateDocumentTypeAsync(request).ConfigureAwait(false); if (documentType == null) { @@ -112,7 +113,7 @@ public static class DocumentTypeTools MatchingAlgorithm = matchingAlgorithm }; - var documentType = await client.UpdateDocumentTypeAsync(id, request); + var documentType = await client.UpdateDocumentTypeAsync(id, request).ConfigureAwait(false); if (documentType == null) { @@ -140,7 +141,7 @@ public static class DocumentTypeTools { if (!confirm) { - var documentType = await client.GetDocumentTypeAsync(id); + var documentType = await client.GetDocumentTypeAsync(id).ConfigureAwait(false); if (documentType == null) { @@ -161,7 +162,7 @@ public static class DocumentTypeTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.DeleteDocumentTypeAsync(id); + var success = await client.DeleteDocumentTypeAsync(id).ConfigureAwait(false); if (!success) { @@ -219,7 +220,7 @@ public static class DocumentTypeTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.BulkEditObjectsAsync(ids, "document_types", "delete"); + var success = await client.BulkEditObjectsAsync(ids, "document_types", "delete").ConfigureAwait(false); if (!success) { @@ -244,15 +245,4 @@ public static class DocumentTypeTools return JsonSerializer.Serialize(response); } - private static int[]? ParseIntArray(string? input) - { - if (string.IsNullOrWhiteSpace(input)) - return null; - - return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(s => int.TryParse(s, out var n) ? n : (int?)null) - .Where(n => n.HasValue) - .Select(n => n!.Value) - .ToArray(); - } } diff --git a/PaperlessMCP/Tools/HealthTools.cs b/PaperlessMCP/Tools/HealthTools.cs index 595e42c..0986e73 100644 --- a/PaperlessMCP/Tools/HealthTools.cs +++ b/PaperlessMCP/Tools/HealthTools.cs @@ -16,7 +16,7 @@ public static class HealthTools [Description("Verify connectivity and authentication with the Paperless-ngx instance. Returns server version if available.")] public static async Task Ping(PaperlessClient client) { - var (success, version, error) = await client.PingAsync(); + var (success, version, error) = await client.PingAsync().ConfigureAwait(false); if (success) { @@ -39,8 +39,8 @@ public static class HealthTools [Description("Return supported API endpoints and detected Paperless-ngx version information.")] public static async Task GetCapabilities(PaperlessClient client) { - var (pingSuccess, version, _) = await client.PingAsync(); - var (statusSuccess, status, _) = await client.GetStatusAsync(); + var (pingSuccess, version, _) = await client.PingAsync().ConfigureAwait(false); + var (statusSuccess, status, _) = await client.GetStatusAsync().ConfigureAwait(false); var capabilities = new { diff --git a/PaperlessMCP/Tools/StoragePathTools.cs b/PaperlessMCP/Tools/StoragePathTools.cs index facb2a9..2d4ee9f 100644 --- a/PaperlessMCP/Tools/StoragePathTools.cs +++ b/PaperlessMCP/Tools/StoragePathTools.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using PaperlessMCP.Client; using PaperlessMCP.Models.Common; using PaperlessMCP.Models.StoragePaths; +using static PaperlessMCP.Utils.ParsingHelpers; namespace PaperlessMCP.Tools; @@ -21,7 +22,7 @@ public static class StoragePathTools [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 result = await client.GetStoragePathsAsync(page, Math.Min(pageSize, 100), ordering).ConfigureAwait(false); var response = McpResponse.Success( result.Results, @@ -43,7 +44,7 @@ public static class StoragePathTools PaperlessClient client, [Description("Storage path ID")] int id) { - var storagePath = await client.GetStoragePathAsync(id); + var storagePath = await client.GetStoragePathAsync(id).ConfigureAwait(false); if (storagePath == null) { @@ -79,7 +80,7 @@ public static class StoragePathTools MatchingAlgorithm = matchingAlgorithm }; - var storagePath = await client.CreateStoragePathAsync(request); + var storagePath = await client.CreateStoragePathAsync(request).ConfigureAwait(false); if (storagePath == null) { @@ -116,7 +117,7 @@ public static class StoragePathTools MatchingAlgorithm = matchingAlgorithm }; - var storagePath = await client.UpdateStoragePathAsync(id, request); + var storagePath = await client.UpdateStoragePathAsync(id, request).ConfigureAwait(false); if (storagePath == null) { @@ -144,7 +145,7 @@ public static class StoragePathTools { if (!confirm) { - var storagePath = await client.GetStoragePathAsync(id); + var storagePath = await client.GetStoragePathAsync(id).ConfigureAwait(false); if (storagePath == null) { @@ -165,7 +166,7 @@ public static class StoragePathTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.DeleteStoragePathAsync(id); + var success = await client.DeleteStoragePathAsync(id).ConfigureAwait(false); if (!success) { @@ -223,7 +224,7 @@ public static class StoragePathTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.BulkEditObjectsAsync(ids, "storage_paths", "delete"); + var success = await client.BulkEditObjectsAsync(ids, "storage_paths", "delete").ConfigureAwait(false); if (!success) { @@ -248,15 +249,4 @@ public static class StoragePathTools return JsonSerializer.Serialize(response); } - private static int[]? ParseIntArray(string? input) - { - if (string.IsNullOrWhiteSpace(input)) - return null; - - return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(s => int.TryParse(s, out var n) ? n : (int?)null) - .Where(n => n.HasValue) - .Select(n => n!.Value) - .ToArray(); - } } diff --git a/PaperlessMCP/Tools/TagTools.cs b/PaperlessMCP/Tools/TagTools.cs index 892e086..e1ffa0c 100644 --- a/PaperlessMCP/Tools/TagTools.cs +++ b/PaperlessMCP/Tools/TagTools.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using PaperlessMCP.Client; using PaperlessMCP.Models.Common; using PaperlessMCP.Models.Tags; +using static PaperlessMCP.Utils.ParsingHelpers; namespace PaperlessMCP.Tools; @@ -21,7 +22,7 @@ public static class TagTools [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 result = await client.GetTagsAsync(page, Math.Min(pageSize, 100), ordering).ConfigureAwait(false); var response = McpResponse.Success( result.Results, @@ -43,7 +44,7 @@ public static class TagTools PaperlessClient client, [Description("Tag ID")] int id) { - var tag = await client.GetTagAsync(id); + var tag = await client.GetTagAsync(id).ConfigureAwait(false); if (tag == null) { @@ -81,7 +82,7 @@ public static class TagTools IsInboxTag = isInboxTag }; - var tag = await client.CreateTagAsync(request); + var tag = await client.CreateTagAsync(request).ConfigureAwait(false); if (tag == null) { @@ -120,7 +121,7 @@ public static class TagTools IsInboxTag = isInboxTag }; - var tag = await client.UpdateTagAsync(id, request); + var tag = await client.UpdateTagAsync(id, request).ConfigureAwait(false); if (tag == null) { @@ -148,7 +149,7 @@ public static class TagTools { if (!confirm) { - var tag = await client.GetTagAsync(id); + var tag = await client.GetTagAsync(id).ConfigureAwait(false); if (tag == null) { @@ -169,7 +170,7 @@ public static class TagTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.DeleteTagAsync(id); + var success = await client.DeleteTagAsync(id).ConfigureAwait(false); if (!success) { @@ -227,7 +228,7 @@ public static class TagTools return JsonSerializer.Serialize(dryRunResponse); } - var success = await client.BulkEditObjectsAsync(ids, "tags", "delete"); + var success = await client.BulkEditObjectsAsync(ids, "tags", "delete").ConfigureAwait(false); if (!success) { @@ -252,15 +253,4 @@ public static class TagTools return JsonSerializer.Serialize(response); } - private static int[]? ParseIntArray(string? input) - { - if (string.IsNullOrWhiteSpace(input)) - return null; - - return input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(s => int.TryParse(s, out var n) ? n : (int?)null) - .Where(n => n.HasValue) - .Select(n => n!.Value) - .ToArray(); - } } diff --git a/PaperlessMCP/Utils/ParsingHelpers.cs b/PaperlessMCP/Utils/ParsingHelpers.cs new file mode 100644 index 0000000..d486131 --- /dev/null +++ b/PaperlessMCP/Utils/ParsingHelpers.cs @@ -0,0 +1,37 @@ +namespace PaperlessMCP.Utils; + +/// +/// Shared parsing utilities for MCP tool parameters. +/// +public static class ParsingHelpers +{ + /// + /// Parses a comma-separated string of integers into an array. + /// + /// Comma-separated integer values (e.g., "1,2,3") + /// Array of parsed integers, or null if input is empty/whitespace + public 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(); + } + + /// + /// Parses a date string into a DateTime. + /// + /// Date string in any standard format + /// Parsed DateTime, or null if input is empty/invalid + public static DateTime? ParseDate(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + + return DateTime.TryParse(input, out var date) ? date : null; + } +}