From 6c4f4bea7feadc24efdcf311287308b8e8dd8c05 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 20 Apr 2026 20:37:36 -0400 Subject: [PATCH] feat(mcp): add MoneyMapApiClient typed HttpClient for API communication Co-Authored-By: Claude Opus 4.6 --- MoneyMap.Mcp/MoneyMapApiClient.cs | 241 ++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 MoneyMap.Mcp/MoneyMapApiClient.cs diff --git a/MoneyMap.Mcp/MoneyMapApiClient.cs b/MoneyMap.Mcp/MoneyMapApiClient.cs new file mode 100644 index 0000000..b21a91c --- /dev/null +++ b/MoneyMap.Mcp/MoneyMapApiClient.cs @@ -0,0 +1,241 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace MoneyMap.Mcp; + +public class MoneyMapApiClient +{ + private readonly HttpClient _http; + + public MoneyMapApiClient(HttpClient http) => _http = http; + + public async Task HealthCheckAsync() + { + return await GetAsync("/api/health"); + } + + // --- Transactions --- + + public async Task SearchTransactionsAsync( + string? query, string? startDate, string? endDate, string? category, + string? merchantName, decimal? minAmount, decimal? maxAmount, + int? accountId, int? cardId, string? type, bool? uncategorizedOnly, int? limit) + { + var qs = BuildQueryString( + ("query", query), ("startDate", startDate), ("endDate", endDate), + ("category", category), ("merchantName", merchantName), + ("minAmount", minAmount?.ToString()), ("maxAmount", maxAmount?.ToString()), + ("accountId", accountId?.ToString()), ("cardId", cardId?.ToString()), + ("type", type), ("uncategorizedOnly", uncategorizedOnly?.ToString()), + ("limit", limit?.ToString())); + + return await GetAsync($"/api/transactions{qs}"); + } + + public async Task GetTransactionAsync(long transactionId) + { + return await GetAsync($"/api/transactions/{transactionId}"); + } + + public async Task UpdateTransactionCategoryAsync(long[] transactionIds, string category, string? merchantName) + { + var body = new { TransactionIds = transactionIds, Category = category, MerchantName = merchantName }; + return await PutAsync($"/api/transactions/{transactionIds[0]}/category", body); + } + + public async Task BulkRecategorizeAsync(string namePattern, string toCategory, string? fromCategory, string? merchantName, bool dryRun) + { + var body = new { NamePattern = namePattern, ToCategory = toCategory, FromCategory = fromCategory, MerchantName = merchantName, DryRun = dryRun }; + return await PostAsync("/api/transactions/bulk-recategorize", body); + } + + public async Task GetSpendingSummaryAsync(string startDate, string endDate, int? accountId) + { + var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString())); + return await GetAsync($"/api/transactions/spending-summary{qs}"); + } + + public async Task GetIncomeSummaryAsync(string startDate, string endDate, int? accountId) + { + var qs = BuildQueryString(("startDate", startDate), ("endDate", endDate), ("accountId", accountId?.ToString())); + return await GetAsync($"/api/transactions/income-summary{qs}"); + } + + // --- Budgets --- + + public async Task GetBudgetStatusAsync(string? asOfDate) + { + var qs = BuildQueryString(("asOfDate", asOfDate)); + return await GetAsync($"/api/budgets/status{qs}"); + } + + public async Task CreateBudgetAsync(string? category, decimal amount, string period, string startDate) + { + var body = new { Category = category, Amount = amount, Period = period, StartDate = startDate }; + return await PostAsync("/api/budgets", body); + } + + public async Task UpdateBudgetAsync(int budgetId, decimal? amount, string? period, bool? isActive) + { + var body = new { Amount = amount, Period = period, IsActive = isActive }; + return await PutAsync($"/api/budgets/{budgetId}", body); + } + + // --- Categories --- + + public async Task ListCategoriesAsync() + { + return await GetAsync("/api/categories"); + } + + public async Task GetCategoryMappingsAsync(string? category) + { + var qs = BuildQueryString(("category", category)); + return await GetAsync($"/api/categories/mappings{qs}"); + } + + public async Task AddCategoryMappingAsync(string pattern, string category, string? merchantName, int priority) + { + var body = new { Pattern = pattern, Category = category, MerchantName = merchantName, Priority = priority }; + return await PostAsync("/api/categories/mappings", body); + } + + // --- Receipts --- + + public async Task ListReceiptsAsync(long? transactionId, string? parseStatus, int? limit) + { + var qs = BuildQueryString(("transactionId", transactionId?.ToString()), ("parseStatus", parseStatus), ("limit", limit?.ToString())); + return await GetAsync($"/api/receipts{qs}"); + } + + public async Task GetReceiptDetailsAsync(long receiptId) + { + return await GetAsync($"/api/receipts/{receiptId}"); + } + + public async Task GetReceiptImageAsync(long receiptId) + { + return await GetAsync($"/api/receipts/{receiptId}/image"); + } + + public async Task GetReceiptTextAsync(long receiptId) + { + return await GetAsync($"/api/receipts/{receiptId}/text"); + } + + // --- Merchants --- + + public async Task ListMerchantsAsync(string? query) + { + var qs = BuildQueryString(("query", query)); + return await GetAsync($"/api/merchants{qs}"); + } + + public async Task MergeMerchantsAsync(int sourceMerchantId, int targetMerchantId) + { + var body = new { SourceMerchantId = sourceMerchantId, TargetMerchantId = targetMerchantId }; + return await PostAsync("/api/merchants/merge", body); + } + + // --- Accounts --- + + public async Task ListAccountsAsync() + { + return await GetAsync("/api/accounts"); + } + + public async Task ListCardsAsync(int? accountId) + { + var qs = BuildQueryString(("accountId", accountId?.ToString())); + return await GetAsync($"/api/accounts/cards{qs}"); + } + + // --- Dashboard --- + + public async Task GetDashboardAsync(int? topCategoriesCount, int? recentTransactionsCount) + { + var qs = BuildQueryString(("topCategoriesCount", topCategoriesCount?.ToString()), ("recentTransactionsCount", recentTransactionsCount?.ToString())); + return await GetAsync($"/api/dashboard{qs}"); + } + + public async Task GetMonthlyTrendAsync(int? months, string? category) + { + var qs = BuildQueryString(("months", months?.ToString()), ("category", category)); + return await GetAsync($"/api/dashboard/monthly-trend{qs}"); + } + + // --- HTTP Helpers --- + + private async Task GetAsync(string path) + { + try + { + var response = await _http.GetAsync(path); + if (response.IsSuccessStatusCode) + return await response.Content.ReadAsStringAsync(); + if (response.StatusCode == HttpStatusCode.NotFound) + { + var body = await response.Content.ReadAsStringAsync(); + return body.Length > 0 ? body : "Not found"; + } + return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}"; + } + catch (HttpRequestException ex) + { + return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}"; + } + } + + private async Task PostAsync(string path, object body) + { + try + { + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(path, content); + if (response.IsSuccessStatusCode) + return await response.Content.ReadAsStringAsync(); + if (response.StatusCode == HttpStatusCode.NotFound) + { + var responseBody = await response.Content.ReadAsStringAsync(); + return responseBody.Length > 0 ? responseBody : "Not found"; + } + return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}"; + } + catch (HttpRequestException ex) + { + return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}"; + } + } + + private async Task PutAsync(string path, object body) + { + try + { + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await _http.PutAsync(path, content); + if (response.IsSuccessStatusCode) + return await response.Content.ReadAsStringAsync(); + if (response.StatusCode == HttpStatusCode.NotFound) + { + var responseBody = await response.Content.ReadAsStringAsync(); + return responseBody.Length > 0 ? responseBody : "Not found"; + } + return $"API error: {(int)response.StatusCode} - {response.ReasonPhrase}"; + } + catch (HttpRequestException ex) + { + return $"MoneyMap API is not reachable at {_http.BaseAddress}. Ensure the web app is running. Error: {ex.Message}"; + } + } + + private static string BuildQueryString(params (string key, string? value)[] parameters) + { + var pairs = parameters + .Where(p => !string.IsNullOrWhiteSpace(p.value)) + .Select(p => $"{Uri.EscapeDataString(p.key)}={Uri.EscapeDataString(p.value!)}"); + + var qs = string.Join("&", pairs); + return qs.Length > 0 ? $"?{qs}" : ""; + } +}