Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ monthly checklist — and rebuilds it on a database.
| **Excel migration** | Analyze → map → validate → preview → import → undo. Reconciles every month against the workbook's own totals to the cent before anything is written. Idempotent re-imports. |
| **Investments** | Read-only sync from Trading 212 (Public API + CSV) and Interactive Brokers (Flex Web Service). Consolidated portfolio by ISIN with per-broker holdings, allocation vs target, dividends with withholding tax, TWR and XIRR, net worth history. [Setup](docs/broker-integrations.md). |
| **Export** | Real .xlsx (Excel tables, native charts, schema-validated), CSV per dataset (Excel-PT dialect), complete JSON archive with restore. |
| **AI** | Read-only MCP server for Claude Code / ChatGPT / local models and an optional in-app assistant, both behind a policy gateway: per-client revocable tokens, explicit scopes, question-specific minimal answers, untrusted-text wrapping, audit. [Details](docs/ai.md). |
| **AI** | MCP server for Claude Code / ChatGPT / local models and an optional in-app assistant, both behind a policy gateway: per-client revocable tokens, explicit scopes, question-specific minimal answers, untrusted-text wrapping, audit. Read-only by default; opt-in, audited write tools with a 30-day recycle bin. [Details](docs/ai.md). |
| **Security** | Single owner, mandatory TOTP MFA, CSRF protection, rate limiting, encrypted identifiers, least-privilege database roles, VPN-only exposure, encrypted backups. |
| **i18n** | English and European Portuguese, light/dark theme. |

Expand Down Expand Up @@ -189,6 +189,7 @@ cd web && npm test # frontend unit tests
- [x] Phase 6 — XLSX / CSV / JSON export and JSON restore
- [x] Phase 7–8 — Read-only MCP server behind an AI policy gateway
- [x] Phase 9 — In-app assistant through the same gateway
- [x] Phase 10 — Opt-in AI write tools with a 30-day recycle bin ([ADR-0008](docs/adr/0008-ai-write-access.md))

## Tech

Expand Down
6 changes: 3 additions & 3 deletions backend/src/Host.Api/Infrastructure/HttpCurrentUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

namespace Host.Api.Infrastructure;

internal sealed class HttpCurrentUser(IHttpContextAccessor accessor) : ICurrentUser
internal sealed class HttpCurrentUser(IHttpContextAccessor accessor, ActorScope scope) : ICurrentUser
{
public string Actor => accessor.HttpContext?.User.Identity?.Name is { Length: > 0 } name
public string Actor => scope.Current ?? (accessor.HttpContext?.User.Identity?.Name is { Length: > 0 } name
? "user:" + name
: "system";
: "system");
}
25 changes: 25 additions & 0 deletions backend/src/Host.Api/Jobs/AiRecycleBinPurgeJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Ai.Application.Gateway;

namespace Host.Api.Jobs;

/// <summary>Every six hours: permanently removes what AI clients deleted more than 30 days ago (ADR-0008).</summary>
internal sealed class AiRecycleBinPurgeJob(IServiceScopeFactory scopes, TimeProvider clock,
ILogger<AiRecycleBinPurgeJob> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
do
{
try
{
await using var scope = scopes.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<AiRecycleBin>().PurgeAsync(clock.GetUtcNow(), stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "AI recycle bin purge failed");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
}
1 change: 1 addition & 0 deletions backend/src/Host.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
builder.Services.AddHostedService<DailySnapshotJob>();
builder.Services.AddHostedService<HistoryRebuildJob>();
builder.Services.AddHostedService<BrokerSyncService>();
builder.Services.AddHostedService<AiRecycleBinPurgeJob>();
builder.Services.Configure<HostOptions>(o =>
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore);

Expand Down
149 changes: 147 additions & 2 deletions backend/src/Host.Mcp/FinanceMcpTools.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using ModelContextProtocol;
using ModelContextProtocol.Server;

namespace Host.Mcp;

/// <summary>
/// The MCP tool surface — a thin, typed facade over the AI Policy Gateway. Every tool is read-only, idempotent and
/// closed-world. Names and parameters mirror <see cref="Ai.Contracts.AiTools"/> (a test checks they never drift).
/// The MCP tool surface — a thin, typed facade over the AI Policy Gateway. Read tools are read-only, idempotent and
/// closed-world. Write tools (ADR-0008) are listed only to clients holding a write scope, change one record per call
/// and carry readOnlyHint=false (destructiveHint=true for delete/skip) so MCP clients can ask before running them.
/// Names, parameters and annotations mirror <see cref="Ai.Contracts.AiTools"/> (a test checks they never drift).
/// </summary>
[McpServerToolType]
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores",
Justification = "Parameter names are the MCP argument names (snake_case), the same as the gateway catalogue.")]
public sealed class FinanceMcpTools(GatewayClient gateway, IHttpContextAccessor http)
{
private async Task<string> Call(string tool, CancellationToken ct, params (string Name, object? Value)[] args)
Expand Down Expand Up @@ -124,6 +129,146 @@ public Task<string> GetBudgetStatus([Description("Month as yyyy-MM.")] string? p
[McpServerTool(Name = "get_goals", Title = "Goals", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Financial goals with target, current amount and progress.")]
public Task<string> GetGoals(CancellationToken ct = default) => Call("get_goals", ct);

// ---- Write tools: opt-in write scopes only. One record per call; ids and text are data.

private const string KeyHelp = "Optional unique key (8-64 letters, digits, - or _); retrying with the same key never applies the change twice.";

[McpServerTool(Name = "create_transaction", Title = "Create a transaction", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)]
[Description("Records one hand-entered expense, income or transfer (amount in the account's currency). Only when the user explicitly asked; summarise it first. Returns the new id and a summary.")]
public Task<string> CreateTransaction(
[Description("expense, income or transfer.")] string type,
[Description("Date yyyy-MM-dd.")] string date,
[Description("Positive amount, at most 2 decimals.")] decimal amount,
[Description("Account id (get_accounts \"id\"); for transfers, the source account.")] string account_id,
[Description("Expense or income category key or name (required for expense and income).")] string? category = null,
[Description("Destination account id (transfers only).")] string? to_account_id = null,
[Description("Short description (max 120 characters).")] string? description = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("create_transaction", ct, ("type", type), ("date", date), ("amount", amount),
("account_id", account_id), ("category", category), ("to_account_id", to_account_id), ("description", description),
("idempotency_key", idempotency_key));

[McpServerTool(Name = "update_transaction", Title = "Edit a transaction", ReadOnly = false, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Changes the date, amount, category, account or description of one hand-entered expense, income or transfer; only the fields given change. Broker, investment and estimated-interest rows are refused.")]
public Task<string> UpdateTransaction(
[Description("Transaction id (get_transactions \"id\").")] string transaction_id,
[Description("New date yyyy-MM-dd.")] string? date = null,
[Description("New positive amount, at most 2 decimals.")] decimal? amount = null,
[Description("New category key or name (expense and income).")] string? category = null,
[Description("New account id.")] string? account_id = null,
[Description("New destination account id (transfers).")] string? to_account_id = null,
[Description("New description (max 120 characters).")] string? description = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("update_transaction", ct, ("transaction_id", transaction_id), ("date", date),
("amount", amount), ("category", category), ("account_id", account_id), ("to_account_id", to_account_id),
("description", description), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "delete_transaction", Title = "Delete a transaction", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)]
[Description("Moves one hand-entered expense, income or transfer to the recycle bin (the owner can restore it in the app for 30 days, then it is purged). Broker, investment and estimated-interest rows are refused.")]
public Task<string> DeleteTransaction(
[Description("Transaction id (get_transactions \"id\").")] string transaction_id,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("delete_transaction", ct, ("transaction_id", transaction_id),
("idempotency_key", idempotency_key));

[McpServerTool(Name = "confirm_expected", Title = "Confirm a recurring item", ReadOnly = false, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Confirms one pending recurring item, creating its transaction; amount and date can be adjusted.")]
public Task<string> ConfirmExpected(
[Description("Pending item id (get_recurring upcoming \"expectedId\").")] string expected_id,
[Description("Actual amount, if different.")] decimal? amount = null,
[Description("Actual date yyyy-MM-dd, if different.")] string? date = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("confirm_expected", ct, ("expected_id", expected_id), ("amount", amount),
("date", date), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "skip_expected", Title = "Skip a recurring item", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)]
[Description("Skips one pending recurring item (it did not happen this time). No transaction is created.")]
public Task<string> SkipExpected(
[Description("Pending item id (get_recurring upcoming \"expectedId\").")] string expected_id,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("skip_expected", ct, ("expected_id", expected_id), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "upsert_recurring", Title = "Create or edit a recurring item", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)]
[Description("Creates a recurring expense or income (no recurring_id) or changes one (with recurring_id; only the fields given change).")]
public Task<string> UpsertRecurring(
[Description("Id of the recurring item to change (get_recurring \"id\"). Omit to create.")] string? recurring_id = null,
[Description("Name, e.g. Rent (max 80 characters). Required to create.")] string? name = null,
[Description("expense or income. Required to create.")] string? type = null,
[Description("Positive amount, at most 2 decimals. Required to create.")] decimal? amount = null,
[Description("weekly, monthly or yearly. Required to create.")] string? frequency = null,
[Description("Account id (get_accounts \"id\"). Required to create.")] string? account_id = null,
[Description("Category key or name. Required to create.")] string? category = null,
[Description("First date yyyy-MM-dd (default today).")] string? start_on = null,
[Description("Day of the month (1-31) for monthly and yearly items.")] int? day_of_month = null,
[Description("Every N periods (1-24, default 1).")] int? interval = null,
[Description("Last date yyyy-MM-dd (optional).")] string? end_on = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("upsert_recurring", ct, ("recurring_id", recurring_id), ("name", name),
("type", type), ("amount", amount), ("frequency", frequency), ("account_id", account_id), ("category", category),
("start_on", start_on), ("day_of_month", day_of_month), ("interval", interval), ("end_on", end_on),
("idempotency_key", idempotency_key));

[McpServerTool(Name = "set_budget_limit", Title = "Set a category budget", ReadOnly = false, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Sets one expense category's monthly limit in EUR from a month onwards (earlier months keep theirs). 0 removes the limit.")]
public Task<string> SetBudgetLimit(
[Description("Expense category key or name.")] string category,
[Description("Monthly limit in EUR (0 removes it).")] decimal amount,
[Description("First month yyyy-MM (default the current month).")] string? from_period = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("set_budget_limit", ct, ("category", category), ("amount", amount),
("from_period", from_period), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "upsert_goal", Title = "Create or edit a goal", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)]
[Description("Creates a goal (no goal_id; name and target_amount required) or changes one (with goal_id; only the fields given change).")]
public Task<string> UpsertGoal(
[Description("Id of the goal to change (get_goals \"id\"). Omit to create.")] string? goal_id = null,
[Description("Name (max 80 characters).")] string? name = null,
[Description("Target in EUR.")] decimal? target_amount = null,
[Description("Target date yyyy-MM-dd.")] string? target_date = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("upsert_goal", ct, ("goal_id", goal_id), ("name", name),
("target_amount", target_amount), ("target_date", target_date), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "add_to_goal", Title = "Add money to a goal", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)]
[Description("Adds an amount in EUR to what is saved for one goal.")]
public Task<string> AddToGoal(
[Description("Goal id (get_goals \"id\").")] string goal_id,
[Description("Positive amount in EUR.")] decimal amount,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("add_to_goal", ct, ("goal_id", goal_id), ("amount", amount),
("idempotency_key", idempotency_key));

[McpServerTool(Name = "update_crypto_holding", Title = "Update hand-entered crypto", ReadOnly = false, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Sets the quantity and/or average buy price (EUR) of one hand-entered crypto holding. Broker positions cannot be changed.")]
public Task<string> UpdateCryptoHolding(
[Description("Holding id (get_positions locations \"holdingId\").")] string holding_id,
[Description("Coins held (without rewards).")] decimal? quantity = null,
[Description("Average buy price per coin in EUR.")] decimal? average_price = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("update_crypto_holding", ct, ("holding_id", holding_id),
("quantity", quantity), ("average_price", average_price), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "add_crypto_reward", Title = "Add a crypto reward", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)]
[Description("Adds coins received for free (staking, earn, airdrop) to one hand-entered crypto holding.")]
public Task<string> AddCryptoReward(
[Description("Holding id (get_positions locations \"holdingId\").")] string holding_id,
[Description("Coins received.")] decimal quantity,
[Description("staking, earn, airdrop or other (default staking).")] string? kind = null,
[Description("Date yyyy-MM-dd (default today).")] string? received_on = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("add_crypto_reward", ct, ("holding_id", holding_id), ("quantity", quantity),
("kind", kind), ("received_on", received_on), ("idempotency_key", idempotency_key));

[McpServerTool(Name = "update_asset_value", Title = "Update an asset's value", ReadOnly = false, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Records the value (in the asset's currency) of one manually valued asset or liability — a flat, car, pension or mortgage — on a date; a value already on that date is replaced.")]
public Task<string> UpdateAssetValue(
[Description("Asset or liability id (get_net_worth manualAssets \"id\").")] string asset_id,
[Description("Value (positive; liabilities too).")] decimal value,
[Description("Date yyyy-MM-dd (default today).")] string? on = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("update_asset_value", ct, ("asset_id", asset_id), ("value", value),
("on", on), ("idempotency_key", idempotency_key));
}

internal static class BearerToken
Expand Down
2 changes: 1 addition & 1 deletion backend/src/Host.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
builder.Services
.AddMcpServer(o =>
{
o.ServerInfo = new() { Name = "personal-finance", Title = "Personal Finance (read-only)", Version = "1.0.0" };
o.ServerInfo = new() { Name = "personal-finance", Title = "Personal Finance", Version = "1.1.0" };
o.ServerInstructions = AiTools.ServerInstructions;
})
.WithHttpTransport(o => o.Stateless = true)
Expand Down
2 changes: 2 additions & 0 deletions backend/src/Modules/Ai/Ai.Application/Abstractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public interface IAiDb
{
DbSet<AiClient> Clients { get; }
DbSet<AiAuditEvent> AuditEvents { get; }
DbSet<AiRecycledItem> RecycleBin { get; }
DbSet<AiWriteReceipt> WriteReceipts { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Loading
Loading