Skip to content
Closed
17 changes: 16 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,22 @@ FROM alpine:3.21
# Without nodejs the spawn fires ENOENT on an unhandled error handler
# inside the SDK; the async iterator ends silently with zero messages
# and the run is misclassified as a 0-cost, 0-turn "success".
RUN apk --no-cache add ca-certificates tzdata postgresql16-client libstdc++ libgcc nodejs
# bash + ripgrep: the agent's own analysis tools (Bash, Grep) need a POSIX
# shell and ripgrep. Alpine ships neither (only busybox sh, no rg), so an
# agent that tried to grep a large saved tool-result hit
# "No suitable shell found" / "spawn .../vendor/ripgrep/.../rg ENOENT" and
# was left unable to do file-level analysis. We install both and point the
# SDK at the system rg via USE_BUILTIN_RIPGREP=0 (its bundled copy is not
# embedded in the Bun --compile output). See agent/sidecar/index.ts.
RUN apk --no-cache add ca-certificates tzdata postgresql16-client libstdc++ libgcc nodejs bash ripgrep

# SHELL: the Claude Agent SDK's Bash tool requires SHELL to point at a POSIX
# shell; the container env doesn't set it otherwise. USE_BUILTIN_RIPGREP=0:
# use the system ripgrep we installed instead of the SDK's vendored binary,
# which isn't present in the sidecar bundle. Both are also defaulted
# defensively in the sidecar itself for non-Docker installs.
ENV SHELL=/bin/bash \
USE_BUILTIN_RIPGREP=0

WORKDIR /app
COPY --from=builder /breadbox /app/breadbox
Expand Down
17 changes: 17 additions & 0 deletions agent/sidecar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ function configureRuntimeEnv(spec: JobSpec): void {
// when set; we set both so the cli.js init path can pick whichever
// it consults first.
process.env.CLAUDE_CONFIG_DIR = scratch;

// Analysis tooling: the SDK's Bash tool aborts with "No suitable shell
// found" when SHELL is unset, and its Grep tool spawns a vendored ripgrep
// that isn't embedded in our Bun --compile bundle (ENOENT). The Docker
// image sets both env vars and installs bash + ripgrep; here we default
// them defensively so non-Docker installs (the per-user worktree binary at
// ~/.breadbox/agent-bin) also get a working shell + system rg. Only set
// when unset so an operator override always wins.
if (!process.env.SHELL) {
process.env.SHELL = existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh";
}
// Prefer the system ripgrep (on PATH) over the SDK's bundled copy, which is
// absent from the compiled sidecar. Harmless where a system rg is present;
// where it isn't, Grep was already broken via the missing vendored binary.
if (process.env.USE_BUILTIN_RIPGREP === undefined) {
process.env.USE_BUILTIN_RIPGREP = "0";
}
}

async function main() {
Expand Down
37 changes: 30 additions & 7 deletions docs/mcp-tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,19 @@ Aggregated spending totals. Default date range: 30 days.

### merchant_summary (Read)

Merchant-level statistics. Default date range: 90 days.
Aggregate spend per **merchant** over a window (default last 90 days) — the merchant-grouped companion to `transaction_summary` (which groups by category/time, not merchant). Each row: `merchant`, `transaction_count`, `total_amount`, `avg_amount`, `first_date`, `last_date`, `iso_currency_code`. Amounts follow the convention positive = money out (debit); never summed across currencies. Capped at 500 merchants.

| Parameter | Type | Description |
|-----------|------|-------------|
| `min_count` | int | Minimum transaction count (use 2 for recurring, 3 for subscriptions) |
| `spending_only` | bool | Exclude credits/refunds |
| `search` | string | Search merchant names |
| `exclude_search` | string | Exclude matching merchants |

Plus same date/account/user filters as `transaction_summary`.
| `start_date` / `end_date` | string | Window (YYYY-MM-DD). Defaults: 90 days ago → tomorrow |
| `min_count` | int | Minimum transaction count (use 2 for recurring, 3+ for subscriptions) |
| `spending_only` | bool | Only money-out amounts; exclude credits/refunds |
| `category_slug` | string | Filter by category before aggregating (parent includes children) |
| `account_id` / `user_id` | string | Filter by account or household member |
| `min_amount` / `max_amount` | float | Amount range (positive=debit, negative=credit) |
| `search` | string | Search merchant names (min 2 chars). Comma-separated values ORed |
| `search_mode` | string | `contains` (default) \| `words` \| `fuzzy` |
| `exclude_search` | string | Exclude merchants matching this text |

---

Expand Down Expand Up @@ -343,6 +346,26 @@ List rules with optional filters and cursor pagination. **Lean by default** (`fi
| `cursor` | string | Pagination cursor |
| `limit` | int | Results per page (default 50, max 500) |

### query_transaction_rules (Read)

The filterable/sortable analogue of `query_transactions`, for the rule set. Same lean-by-default `summary` projection as `list_transaction_rules`, but adds trigger / creator / hit-count filters and sorting so you can ask targeted questions — "which rules never fire?", "highest-impact rules for groceries", "agent-created rules" — instead of dumping the whole roster. To check whether **one** merchant is already covered before creating a rule, prefer `find_matching_rules`; use this when you want a filtered *slice* of rules or coverage analytics.

| Parameter | Type | Description |
|-----------|------|-------------|
| `category_slug` | string | Filter to rules whose `set_category` action targets this slug |
| `enabled` | bool | Filter by enabled status |
| `trigger` | string | `on_create` \| `on_change` (alias `on_update`) \| `always` |
| `creator_type` | string | `user` \| `agent` \| `system` |
| `search` | string | Substring / words / fuzzy search on rule name |
| `search_mode` | string | `contains` (default) \| `words` \| `fuzzy` |
| `min_hit_count` | int | Only rules with `hit_count >= n` (surfaces high-impact rules) |
| `only_unused` | bool | Only rules that have never fired (`hit_count = 0`) — dead/over-specific rules worth pruning |
| `sort_by` | string | `priority` (default, pipeline order) \| `hit_count` \| `last_hit_at` \| `created_at` \| `name` |
| `sort_order` | string | `asc` \| `desc` (per-column default otherwise) |
| `fields` | string | Field selection. Alias: `summary` (default). `all` → full definition. |
| `cursor` | string | Pagination cursor. Only valid with the default `priority` sort; an explicit `sort_by` returns a single top-N page with no `next_cursor`. |
| `limit` | int | Results per page (default 50, max 500) |

### update_transaction_rule (Write)

Every field is optional; omit to leave unchanged. Pass `conditions={}` to explicitly switch to match-all; pass `actions=[...]` to replace the entire action set; pass `expires_at=""` to clear expiry. Pass `stage` (`baseline` | `standard` | `refinement` | `override`) to re-slot a rule into a canonical pipeline stage without guessing a raw `priority`. If both `stage` and `priority` are supplied, `priority` wins.
Expand Down
1 change: 1 addition & 0 deletions internal/admin/settings_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func AgentsSettingsHandler(svc *service.Service, mcpServer *breadboxmcp.MCPServe
"update_transactions": "Tags",
"list_annotations": "Tags",
"list_transaction_rules": "Rules",
"query_transaction_rules": "Rules",
"create_transaction_rule": "Rules",
"update_transaction_rule": "Rules",
"delete_transaction_rule": "Rules",
Expand Down
55 changes: 55 additions & 0 deletions internal/mcp/response_shapes_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,61 @@ func TestListTransactionRulesLeanDefault(t *testing.T) {
requireKeys(t, "list_transaction_rules(all).rules[0]", ruleAll, "id", "name", "conditions", "actions", "created_at")
}

// TestQueryTransactionRulesShape pins that query_transaction_rules shares the
// lean-by-default projection and accepts the richer filter/sort knobs.
func TestQueryTransactionRulesShape(t *testing.T) {
f := seedFixtures(t)

// Default (no fields) → summary projection, same as the list mirror.
res, _, err := f.svc.handleQueryTransactionRules(f.ctx, nil, queryTransactionRulesInput{})
out := decodeToolResult[map[string]any](t, "query_transaction_rules", res, err)
requireKeys(t, "query_transaction_rules", out, "rules")
rules := asArray(t, "query_transaction_rules.rules", out["rules"])
if len(rules) == 0 {
t.Fatal("expected at least the seeded rule")
}
rule := asObject(t, "query_transaction_rules.rules[0]", rules[0])
requireKeys(t, "query_transaction_rules.rules[0]", rule, "id", "name", "enabled", "priority", "trigger", "hit_count")
requireAbsent(t, "query_transaction_rules.rules[0]", rule, "conditions", "actions", "created_at", "short_id")

// An explicit sort is accepted and suppresses the cursor (single top-N page).
resSorted, _, errSorted := f.svc.handleQueryTransactionRules(f.ctx, nil, queryTransactionRulesInput{SortBy: "hit_count"})
outSorted := decodeToolResult[map[string]any](t, "query_transaction_rules(sorted)", resSorted, errSorted)
if nc, ok := outSorted["next_cursor"].(string); ok && nc != "" {
t.Errorf("explicit sort_by must not emit next_cursor, got %q", nc)
}
}

// TestMerchantSummaryShape pins the merchant_summary tool: a merchants array of
// per-merchant aggregates plus a totals block. seedFixtures creates two
// "Whole Foods" rows, so a window covering them yields a merchant with count 2.
func TestMerchantSummaryShape(t *testing.T) {
f := seedFixtures(t)

res, _, err := f.svc.handleMerchantSummary(f.ctx, nil, merchantSummaryInput{
StartDate: "2026-04-01",
EndDate: "2026-05-01",
})
out := decodeToolResult[map[string]any](t, "merchant_summary", res, err)
requireKeys(t, "merchant_summary", out, "merchants", "totals")
merchants := asArray(t, "merchant_summary.merchants", out["merchants"])
if len(merchants) == 0 {
t.Fatal("expected at least one merchant in window")
}
row := asObject(t, "merchant_summary.merchants[0]", merchants[0])
requireKeys(t, "merchant_summary.merchants[0]", row,
"merchant", "transaction_count", "total_amount", "avg_amount", "first_date", "last_date")

// min_count=3 excludes the count-2 Whole Foods merchant → empty result.
resHi, _, errHi := f.svc.handleMerchantSummary(f.ctx, nil, merchantSummaryInput{
StartDate: "2026-04-01", EndDate: "2026-05-01", MinCount: 3,
})
outHi := decodeToolResult[map[string]any](t, "merchant_summary(min_count=3)", resHi, errHi)
if got := asArray(t, "merchant_summary(min_count=3).merchants", outHi["merchants"]); len(got) != 0 {
t.Errorf("min_count=3: expected no merchants, got %d", len(got))
}
}

// TestPreviewRuleResponseShape pins `sample_matches` (not `sample`) + sample
// row fields (transaction_id, not id).
func TestPreviewRuleResponseShape(t *testing.T) {
Expand Down
10 changes: 9 additions & 1 deletion internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,12 @@ func (s *MCPServer) buildToolRegistry() {
}, s.handleGetSyncStatus, s),
makeToolDefLogged(ToolSpec{
Name: "list_transaction_rules", Title: "List Rules", Classification: ToolRead,
Description: "List transaction rules. Filter by category_slug, enabled, or search by name. Read this before authoring new rules to avoid duplicates. Lean by default: returns a summary projection (name, enabled, priority, trigger, category, hit_count) without the conditions/actions trees — pass fields=all to inspect or audit full rule definitions. Mirror of breadbox://rules (which always returns full).",
Description: "List transaction rules (the roster). Filter by category_slug, enabled, or search by name. Lean by default: returns a summary projection (name, enabled, priority, trigger, category, hit_count) without the conditions/actions trees — pass fields=all to inspect or audit full rule definitions. Mirror of breadbox://rules. For richer analysis — filter by trigger/creator/hit-count or sort by impact — use query_transaction_rules; to check whether one specific merchant is already covered, use find_matching_rules.",
}, s.handleListTransactionRules, s),
makeToolDefLogged(ToolSpec{
Name: "query_transaction_rules", Title: "Query Rules", Classification: ToolRead,
Description: "Query and analyze the rule set — the rules analogue of query_transactions. Filter by category_slug, enabled, trigger (on_create|on_change|always), creator_type (user|agent|system), name search, min_hit_count, or only_unused (rules that have never fired). Sort by priority (default, pipeline order), hit_count, last_hit_at, created_at, or name. Lean by default (summary projection: name, enabled, priority, trigger, category, hit_count, last_hit_at — no conditions/actions trees); pass fields=all for the full definitions. Use this to audit coverage and prune dead rules (only_unused=true) without dumping the whole roster. To check coverage for ONE merchant before creating a rule, prefer find_matching_rules. Cursor pagination applies only to the default priority sort; an explicit sort_by returns a single top-N page (raise limit, max 500).",
}, s.handleQueryTransactionRules, s),
makeToolDefLogged(ToolSpec{
Name: "list_workflows", Title: "List Workflows", Classification: ToolRead,
Description: "List the household's automation layer: the `workflows` it has enabled (each carries name, slug, trigger sync|schedule|manual, schedule_cron, tool_scope, the source `preset` it was instantiated from, plus last_run_status + last_run_at), and the full catalog of available `presets` it could enable (slug, name, category, description, tool_scope, trigger, default schedule_cron, and whether it's already enabled). Read this to see what runs automatically before suggesting new rules or reports — an existing workflow may already cover the task. Enabling/configuring workflows is an admin-UI action (the /workflows gallery), not an MCP write.",
Expand Down Expand Up @@ -436,6 +440,10 @@ func (s *MCPServer) buildToolRegistry() {
Name: "transaction_summary", Title: "Spending Summary", Classification: ToolRead,
Description: "Get aggregated transaction totals grouped by category and/or time period. Replaces the need to paginate through thousands of individual transactions for spending analysis. Amounts follow the convention: positive = money out (debit), negative = money in (credit). Only includes non-deleted, non-pending transactions by default.",
}, s.handleTransactionSummary, s),
makeToolDefLogged(ToolSpec{
Name: "merchant_summary", Title: "Merchant Summary", Classification: ToolRead,
Description: "Aggregate spend per MERCHANT over a window (default last 90 days) — the merchant-grouped companion to transaction_summary, which groups by category/time but not merchant. Each row: merchant, transaction_count, total_amount, avg_amount, first_date, last_date. Amounts follow the convention positive = money out (debit), negative = money in (credit); never summed across currencies. Use min_count=2 to surface recurring merchants, min_count=3+ for subscriptions; spending_only=true to exclude credits/refunds in a spending report; first_date to spot newly-appearing merchants. Filter by category_slug, account_id, user_id, amount range, or search (fuzzy-tolerant for bank-feed name variants). Capped at 500 merchants.",
}, s.handleMerchantSummary, s),

// --- Apply review decisions ---
// update_transactions is the universal write for review work. It
Expand Down
2 changes: 2 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func TestToolRegistryScopeContract(t *testing.T) {
"query_transactions",
"count_transactions",
"transaction_summary",
"merchant_summary",
"list_annotations",
"preview_rule",
"find_matching_rules",
Expand All @@ -142,6 +143,7 @@ func TestToolRegistryScopeContract(t *testing.T) {
"list_tags",
"get_sync_status",
"list_transaction_rules",
"query_transaction_rules",
"list_series",
"get_series",
"explain_series_candidates",
Expand Down
53 changes: 51 additions & 2 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,56 @@ func (s *MCPServer) handleTransactionSummary(_ context.Context, _ *mcpsdk.CallTo
return jsonResult(result)
}

type merchantSummaryInput struct {
StartDate string `json:"start_date,omitempty" jsonschema:"Start date (YYYY-MM-DD) inclusive. Defaults to 90 days ago."`
EndDate string `json:"end_date,omitempty" jsonschema:"End date (YYYY-MM-DD) exclusive. Defaults to tomorrow."`
AccountID string `json:"account_id,omitempty" jsonschema:"Filter by account ID"`
UserID string `json:"user_id,omitempty" jsonschema:"Filter by user ID (family member)"`
CategorySlug string `json:"category_slug,omitempty" jsonschema:"Filter by category slug before aggregating (parent slug includes children)"`
MinCount int `json:"min_count,omitempty" jsonschema:"Minimum transaction count for a merchant to appear (default 1). Use 2 to surface recurring charges, 3+ for subscriptions."`
SpendingOnly *bool `json:"spending_only,omitempty" jsonschema:"Only count money-out (positive) amounts; exclude credits/refunds. Use for spending reports."`
MinAmount *float64 `json:"min_amount,omitempty" jsonschema:"Minimum transaction amount (positive=debit, negative=credit)"`
MaxAmount *float64 `json:"max_amount,omitempty" jsonschema:"Maximum transaction amount"`
Search string `json:"search,omitempty" jsonschema:"Search merchant names (min 2 chars). Comma-separated values are ORed."`
SearchMode string `json:"search_mode,omitempty" jsonschema:"Search mode: contains (default), words, fuzzy (typo-tolerant — good for bank-feed name variants)"`
ExcludeSearch string `json:"exclude_search,omitempty" jsonschema:"Exclude merchants whose name matches this text (min 2 chars)"`
}

// handleMerchantSummary aggregates spend per merchant over a window. Backs the
// same GetMerchantSummary the REST /transactions/merchants endpoint uses.
func (s *MCPServer) handleMerchantSummary(_ context.Context, _ *mcpsdk.CallToolRequest, input merchantSummaryInput) (*mcpsdk.CallToolResult, any, error) {
ctx := context.Background()

params := service.MerchantSummaryParams{
AccountID: optStr(input.AccountID),
UserID: optStr(input.UserID),
CategorySlug: optStr(input.CategorySlug),
MinCount: input.MinCount,
MinAmount: input.MinAmount,
MaxAmount: input.MaxAmount,
Search: optStr(input.Search),
ExcludeSearch: optStr(input.ExcludeSearch),
}

var err error
if params.StartDate, params.EndDate, err = parseDateRange(input.StartDate, input.EndDate); err != nil {
return errorResult(err), nil, nil
}
if params.SearchMode, err = parseSearchMode(input.SearchMode); err != nil {
return errorResult(err), nil, nil
}
if input.SpendingOnly != nil && *input.SpendingOnly {
params.SpendingOnly = true
}

result, err := s.svc.GetMerchantSummary(ctx, params)
if err != nil {
return errorResult(err), nil, nil
}

return jsonResult(result)
}

// --- Transaction Rules ---

type createTransactionRuleInput struct {
Expand Down Expand Up @@ -839,7 +889,7 @@ func shortIDPrefix(key string) (string, bool) {
// avoiding the unmarshal→walk→remarshal cycle. It scans the byte stream and
// collapses each object's own id/short_id pair:
//
// {"id":"<uuid>","short_id":"<short>"} → {"id":"<short>"}
// {"id":"<uuid>","short_id":"<short>"} → {"id":"<short>"}
//
// Only the bare "short_id" key triggers the rewrite; the "short_id" key is
// then dropped. If the sibling "id" field is missing or "short_id" is null,
Expand Down Expand Up @@ -1134,4 +1184,3 @@ func scanJSONString(data []byte, pos int) (string, int) {
}
return s, end
}

Loading
Loading