feat: add k8s deployment pipeline and code quality improvements

- Add deploy step to Woodpecker CI release pipeline
- Create ParsingHelpers utility to deduplicate ParseIntArray/ParseDate
- Add ConfigureAwait(false) to all async calls (library best practice)
- Fix resource disposal in UploadDocumentInternalAsync
- Configure HttpClient default 30s timeout
- Remove unused GetAllPagesAsync method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Barry Walker
2026-01-13 18:21:00 -05:00
parent dba6c453c4
commit 6cac693e9c
12 changed files with 176 additions and 200 deletions
+19
View File
@@ -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]
+61 -57
View File
@@ -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<JsonElement>(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<JsonDocument>(cancellationToken);
var json = await response.Content.ReadFromJsonAsync<JsonDocument>(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<PaginatedResult<DocumentSearchResult>>(url, cancellationToken)
return await GetAsync<PaginatedResult<DocumentSearchResult>>(url, cancellationToken).ConfigureAwait(false)
?? new PaginatedResult<DocumentSearchResult>();
}
@@ -172,7 +172,7 @@ public class PaperlessClient
/// </summary>
public async Task<Document?> GetDocumentAsync(int id, CancellationToken cancellationToken = default)
{
return await GetAsync<Document>($"api/documents/{id}/", cancellationToken);
return await GetAsync<Document>($"api/documents/{id}/", cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -180,7 +180,7 @@ public class PaperlessClient
/// </summary>
public async Task<Document?> UpdateDocumentAsync(int id, DocumentUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<Document>($"api/documents/{id}/", request, cancellationToken);
return await PatchAsync<Document>($"api/documents/{id}/", request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -188,7 +188,7 @@ public class PaperlessClient
/// </summary>
public async Task<bool> DeleteDocumentAsync(int id, CancellationToken cancellationToken = default)
{
return await DeleteAsync($"api/documents/{id}/", cancellationToken);
return await DeleteAsync($"api/documents/{id}/", cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -204,7 +204,7 @@ public class PaperlessClient
() => new ByteArrayContent(fileContent),
fileName,
metadata,
cancellationToken);
cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -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
/// </summary>
public async Task<int?> GetNextAsnAsync(CancellationToken cancellationToken = default)
{
return await GetAsync<int?>("api/documents/next_asn/", cancellationToken);
return await GetAsync<int?>("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<PaginatedResult<Tag>>($"api/tags/?{queryParams}", cancellationToken)
return await GetAsync<PaginatedResult<Tag>>($"api/tags/?{queryParams}", cancellationToken).ConfigureAwait(false)
?? new PaginatedResult<Tag>();
}
public async Task<Tag?> GetTagAsync(int id, CancellationToken cancellationToken = default)
{
return await GetAsync<Tag>($"api/tags/{id}/", cancellationToken);
return await GetAsync<Tag>($"api/tags/{id}/", cancellationToken).ConfigureAwait(false);
}
public async Task<Tag?> CreateTagAsync(TagCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostAsync<Tag>("api/tags/", request, cancellationToken);
return await PostAsync<Tag>("api/tags/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<Tag?> UpdateTagAsync(int id, TagUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<Tag>($"api/tags/{id}/", request, cancellationToken);
return await PatchAsync<Tag>($"api/tags/{id}/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> 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<PaginatedResult<Correspondent>>($"api/correspondents/?{queryParams}", cancellationToken)
return await GetAsync<PaginatedResult<Correspondent>>($"api/correspondents/?{queryParams}", cancellationToken).ConfigureAwait(false)
?? new PaginatedResult<Correspondent>();
}
public async Task<Correspondent?> GetCorrespondentAsync(int id, CancellationToken cancellationToken = default)
{
return await GetAsync<Correspondent>($"api/correspondents/{id}/", cancellationToken);
return await GetAsync<Correspondent>($"api/correspondents/{id}/", cancellationToken).ConfigureAwait(false);
}
public async Task<Correspondent?> CreateCorrespondentAsync(CorrespondentCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostAsync<Correspondent>("api/correspondents/", request, cancellationToken);
return await PostAsync<Correspondent>("api/correspondents/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<Correspondent?> UpdateCorrespondentAsync(int id, CorrespondentUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<Correspondent>($"api/correspondents/{id}/", request, cancellationToken);
return await PatchAsync<Correspondent>($"api/correspondents/{id}/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> 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<PaginatedResult<DocumentType>>($"api/document_types/?{queryParams}", cancellationToken)
return await GetAsync<PaginatedResult<DocumentType>>($"api/document_types/?{queryParams}", cancellationToken).ConfigureAwait(false)
?? new PaginatedResult<DocumentType>();
}
public async Task<DocumentType?> GetDocumentTypeAsync(int id, CancellationToken cancellationToken = default)
{
return await GetAsync<DocumentType>($"api/document_types/{id}/", cancellationToken);
return await GetAsync<DocumentType>($"api/document_types/{id}/", cancellationToken).ConfigureAwait(false);
}
public async Task<DocumentType?> CreateDocumentTypeAsync(DocumentTypeCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostAsync<DocumentType>("api/document_types/", request, cancellationToken);
return await PostAsync<DocumentType>("api/document_types/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<DocumentType?> UpdateDocumentTypeAsync(int id, DocumentTypeUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<DocumentType>($"api/document_types/{id}/", request, cancellationToken);
return await PatchAsync<DocumentType>($"api/document_types/{id}/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> 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<PaginatedResult<StoragePath>>($"api/storage_paths/?{queryParams}", cancellationToken)
return await GetAsync<PaginatedResult<StoragePath>>($"api/storage_paths/?{queryParams}", cancellationToken).ConfigureAwait(false)
?? new PaginatedResult<StoragePath>();
}
public async Task<StoragePath?> GetStoragePathAsync(int id, CancellationToken cancellationToken = default)
{
return await GetAsync<StoragePath>($"api/storage_paths/{id}/", cancellationToken);
return await GetAsync<StoragePath>($"api/storage_paths/{id}/", cancellationToken).ConfigureAwait(false);
}
public async Task<StoragePath?> CreateStoragePathAsync(StoragePathCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostAsync<StoragePath>("api/storage_paths/", request, cancellationToken);
return await PostAsync<StoragePath>("api/storage_paths/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<StoragePath?> UpdateStoragePathAsync(int id, StoragePathUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<StoragePath>($"api/storage_paths/{id}/", request, cancellationToken);
return await PatchAsync<StoragePath>($"api/storage_paths/{id}/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> 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<PaginatedResult<CustomField>>($"api/custom_fields/?{queryParams}", cancellationToken)
return await GetAsync<PaginatedResult<CustomField>>($"api/custom_fields/?{queryParams}", cancellationToken).ConfigureAwait(false)
?? new PaginatedResult<CustomField>();
}
public async Task<CustomField?> GetCustomFieldAsync(int id, CancellationToken cancellationToken = default)
{
return await GetAsync<CustomField>($"api/custom_fields/{id}/", cancellationToken);
return await GetAsync<CustomField>($"api/custom_fields/{id}/", cancellationToken).ConfigureAwait(false);
}
public async Task<CustomField?> CreateCustomFieldAsync(CustomFieldCreateRequest request, CancellationToken cancellationToken = default)
{
return await PostAsync<CustomField>("api/custom_fields/", request, cancellationToken);
return await PostAsync<CustomField>("api/custom_fields/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<CustomField?> UpdateCustomFieldAsync(int id, CustomFieldUpdateRequest request, CancellationToken cancellationToken = default)
{
return await PatchAsync<CustomField>($"api/custom_fields/{id}/", request, cancellationToken);
return await PatchAsync<CustomField>($"api/custom_fields/{id}/", request, cancellationToken).ConfigureAwait(false);
}
public async Task<bool> 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<T>(JsonOptions, cancellationToken);
return await response.Content.ReadFromJsonAsync<T>(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<T>(JsonOptions, cancellationToken);
return await response.Content.ReadFromJsonAsync<T>(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<T>(JsonOptions, cancellationToken);
return await response.Content.ReadFromJsonAsync<T>(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);
}
@@ -19,33 +19,6 @@ public record PaginatedResult<T>
[JsonPropertyName("results")]
public List<T> Results { get; init; } = [];
/// <summary>
/// Gets all items from all pages using the provided fetch function.
/// </summary>
public static async Task<List<T>> GetAllPagesAsync(
Func<int, Task<PaginatedResult<T>>> fetchPage,
int maxPages = 100,
CancellationToken cancellationToken = default)
{
var allResults = new List<T>();
var page = 1;
while (page <= maxPages)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await fetchPage(page);
allResults.AddRange(result.Results);
if (string.IsNullOrEmpty(result.Next))
break;
page++;
}
return allResults;
}
}
/// <summary>
+1
View File
@@ -87,6 +87,7 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration
var options = sp.GetRequiredService<IOptions<PaperlessOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Add("Accept", "application/json; version=9");
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddHttpMessageHandler<PaperlessAuthHandler>()
.AddPolicyHandler(retryPolicy);
+8 -18
View File
@@ -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<object>.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();
}
}
+9 -9
View File
@@ -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<object>.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)
{
+14 -32
View File
@@ -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;
}
}
+8 -18
View File
@@ -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<object>.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();
}
}
+3 -3
View File
@@ -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<string> 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<string> 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
{
+8 -18
View File
@@ -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<object>.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();
}
}
+8 -18
View File
@@ -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<object>.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();
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace PaperlessMCP.Utils;
/// <summary>
/// Shared parsing utilities for MCP tool parameters.
/// </summary>
public static class ParsingHelpers
{
/// <summary>
/// Parses a comma-separated string of integers into an array.
/// </summary>
/// <param name="input">Comma-separated integer values (e.g., "1,2,3")</param>
/// <returns>Array of parsed integers, or null if input is empty/whitespace</returns>
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();
}
/// <summary>
/// Parses a date string into a DateTime.
/// </summary>
/// <param name="input">Date string in any standard format</param>
/// <returns>Parsed DateTime, or null if input is empty/invalid</returns>
public static DateTime? ParseDate(string? input)
{
if (string.IsNullOrWhiteSpace(input))
return null;
return DateTime.TryParse(input, out var date) ? date : null;
}
}