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
71 changes: 71 additions & 0 deletions .changeset/mcp-securities-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@accounter/server': minor
'@accounter/mcp-server': minor
---

Expose securities over the MCP connector.

The securities domain was reachable only through the web UI. An assistant connected over MCP knew
securities as a charge *type* and nothing more: it could not say what the tenant holds, what it paid,
what it traded, or which security is behind a charge.

**Two new tools.** `accounter_list_security_holdings` is the portfolio — one row per security with
units held, weighted average cost per unit bought, totals bought and sold, and the span of the
ingested history, with a closed-position toggle and free-text search over name, symbol, ISIN,
exchange, currency and every source identifier. Search, ordering (biggest live position first) and
the row cap happen in the tool: upstream takes no search argument, a portfolio is tens to low
hundreds of rows, and matching the `/securities` screen's own rules is what stops the two drifting.

`accounter_get_security_executions` is the trade history behind it — buys, sales, dividends,
interest, redemptions and transfers, newest first and really paginated, narrowed by security, trade
date and kind. The three identity filters union with each other, since ids, ISINs and symbols are
three ways of naming one axis; asking for one ISIN and one symbol means both securities, not the
empty overlap.

**The numbers carry their own caveats.** A position is arithmetic over a scraped trade history: the
bank reports no holding, there are no market prices anywhere in the system, pre-history holdings and
splits are invisible, a negative quantity means a history that starts mid-life, and a null amount
means nothing was ingested rather than zero. Amounts are each security's own trade currency and are
never converted. Asked what a portfolio is worth, a model will otherwise add a shekel column to a
dollar one — so the holdings tool computes the sums that *are* valid, per currency, and emits a
machine-readable `caveats` array alongside them. Quantities and average costs are never summed at
all.

**`includeSecurities` on `accounter_get_charges`**, following the existing `includeTransactions` /
`includeDocuments` idiom. A foreign-securities charge is the one place where what happened is not in
the charge: the cash leg is a bank row and the trade lives in a separate feed. Each security reports
the `securityBusinessId` the other two tools are addressed by, so a charge answer can be followed
into the portfolio. Three states stay distinct — not asked for, no key the feed knows, and a traded
key whose reference scrape is stale.

**Server:** a new `Query.securityExecutions(filters, page, limit, includeCharges)` with SQL pushdown,
reusing the existing execution and page-info types. It has two paths, because charge links and
pagination do not compose: `matchExecutionsToTransactions` is greedy and one-to-one over the sets it
is handed, so pairing a page's slice would let an execution on page 2 claim the cash movement
belonging to one on page 1 — the same execution reporting a different charge at a different page
size. Requesting links therefore switches to an unpaginated match per security, capped at ten of
them, and both paths order identically so they cannot disagree about what page 1 is. Also adds
`SecurityBusiness.ownerId`, `SecurityHistoryExecution.securityBusiness` and
`ChargeSecurity.securityBusiness`, so rows are owner-tagged, a flat cross-security list can be
grouped, and a charge reaches the security's own identity through the key-to-ISIN bridge.

**Migration:** the four securities tables' read predicates were still pinned to the singular
`get_current_business_id()`. They were all created after `rls-multi-business-scope`, whose 45-table
list they were never in, and no later migration broadened them. The consequence was a silent
narrowing rather than a leak: a request whose scope spanned several businesses saw securities for one
of them, with nothing in the response saying so. That broke the web client's business switcher, and
it would have broken the connector harder — it forwards its resolved scope upstream and echoes that
scope back, so the caller was told it had seen more than it had. Reads now follow
`get_current_business_scope()` while **writes stay single-tenant**: `USING` is what selects the rows
a statement may act on, and Postgres consults it for DELETE and UPDATE as well as SELECT, so
widening it alone would authorize deleting another in-scope business's row — or updating one into
the write target's ownership, moving it between businesses. Two restrictive per-command policies
pin both back to the explicit target, and the scraper ingestion path is unaffected.

Widening the read scope also changed what "unique" means underneath it. A Poalim security key is
unique only *within* an owner, so two businesses that both trade one security carry it under the
same key — ordinary for a multi-business tenant. While reads were pinned to one business a key-only
lookup could not go wrong; spanning owners, it files one business's trades under the other's
security. The execution queries now resolve the relation in SQL by joining the identifier bridge on
`(owner_id, identifier_value)` and returning the security business per row, and the two lookups that
remain in memory take an owner-qualified key.
2 changes: 2 additions & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ const config: CodegenConfig = {
SecurityHolding: '../modules/foreign-securities/types.js#SecurityHoldingProto',
SecurityPosition: '../modules/foreign-securities/types.js#SecurityPositionWithIdProto',
SecurityExecution: '../modules/foreign-securities/types.js#SecurityExecutionRow',
PaginatedSecurityExecutions:
'../modules/foreign-securities/types.js#PaginatedSecurityExecutionsProto',
SecurityIdentifier: '../modules/foreign-securities/types.js#SecurityIdentifierRow',
Shaam6111Report: '../modules/reports/types.js#Shaam6111ReportProto',
SortCode: '../modules/sort-codes/types.js#IGetSortCodesByIdsResult',
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export default [
'PaginatedCharges',
'PaginatedBusinesses',
'PaginatedFinancialEntities',
'PaginatedSecurityExecutions',
'PCNFileResult',
'PCNRawData',
'ReportCommentary',
Expand Down
78 changes: 65 additions & 13 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ Phase 1 (read-only) is feature-complete. The server provides: strict startup env
transport with `/health`, `/metrics`, the OAuth protected-resource metadata endpoint, and the MCP
route (`POST /mcp`, JSON-RPC 2.0) with graceful shutdown; Auth0 bearer-token verification; identity
mapping to an internal user + business-membership context with memberships resolved from the
Accounter GraphQL server; a curated registry of twelve read-only tools
(`accounter_list_business_memberships`, `accounter_search_charges`, `accounter_get_charges`,
`accounter_get_transactions`, `accounter_get_documents`, `accounter_get_ledger_records`,
`accounter_get_contracts`, `accounter_list_tags`, `accounter_list_tax_categories`,
Accounter GraphQL server; a curated registry of fourteen read-only tools
(`accounter_list_business_memberships`, `accounter_explain_terminology`, `accounter_search_charges`,
`accounter_get_charges`, `accounter_get_transactions`, `accounter_get_documents`,
`accounter_get_ledger_records`, `accounter_get_contracts`, `accounter_list_security_holdings`,
`accounter_get_security_executions`, `accounter_list_tags`, `accounter_list_tax_categories`,
`accounter_list_businesses`, `accounter_balance_report`) each gated by strict input validation, a
per-tool authorization policy, and business-scope narrowing forwarded upstream as
`x-business-scope`; a hardened upstream GraphQL client (timeout, bounded retries, header
Expand Down Expand Up @@ -127,6 +128,18 @@ scope, because it _is_ the scope.
`accounter_search_charges`. A charge whose `owner` falls outside the resolved scope is dropped as
defense-in-depth on top of RLS.

A **foreign-securities** charge additionally carries the security traded and the portfolio
executions behind the cash movement, opt-in via `includeSecurities`. That charge type is the one
place where what happened is not in the charge itself: the cash leg is a bank row, and the trade
lives in a separate ingested feed. Each security reports the `securityBusinessId` that
`accounter_list_security_holdings` and `accounter_get_security_executions` are addressed by, so
the answer can be followed into the portfolio. Three states stay distinct: the field is **absent**
for a charge that is not a securities one and for one fetched without the flag, an **empty array**
means the transaction descriptions carried no key the ingested feed knows, and
`referenceFound: false` on a present security means the reference scrape is stale for a key that
_is_ traded. Nesting executions multiplies the payload, so pair it with explicit `chargeIds` or a
small `pageSize`.

Both charge tools build their filter from one definition (`tools/charge-filters.ts`), and
`schema-contract.test.ts` checks that definition against `input ChargeFilter` in `schema.graphql`,
so a field added upstream fails the suite instead of quietly becoming unreachable. Three fields —
Expand Down Expand Up @@ -162,9 +175,32 @@ scope, because it _is_ the scope.
owner filter and is forwarded as the upstream `filters.ownerIds`; there is no separate owner input
to drift from it. Each row reports its `ownerId` plus the client, period, amount, billing cycle,
document type, product/plan, and purchase orders.
- **`accounter_list_security_holdings`** — the **securities portfolio**: one row per security with
units held, weighted average cost per unit bought, totals bought and sold, and the span of the
ingested trade history. `includeClosed` also returns securities traded but no longer held;
`search` matches name (either language), symbol, ISIN, exchange, currency and every source
identifier — the same fields the `/securities` screen searches, so the two cannot drift. Upstream
takes no search argument and a portfolio is tens to low hundreds of rows, so the filtering,
ordering (by |quantity| descending, biggest live position first) and row cap all happen in the
tool. Two things about the numbers are load-bearing: the position is **derived** by adding up
scraped executions rather than read from a bank balance, and amounts are in each security's own
trade currency and are never converted. The response therefore carries `byCurrency` subtotals —
the only valid aggregation — plus a machine-readable `caveats` array, rather than leaving a model
to add a shekel column to a dollar one. Quantities and average costs are never summed at all.
- **`accounter_get_security_executions`** — the **trade history** behind that portfolio: buys,
sales, dividends, interest, redemptions, distributions and transfers, newest first, with dates,
direction, quantity, unit price, net value, commission and Israeli tax. Narrow by security
(`securityBusinessIds`, `isins` or `symbols` — three ways of naming one axis, so they union with
each other), by trade date, and by `tradeTypes`/`transactionTypes`. Really paginated upstream
(1-based `page` here, 0-based there) with `pagination` echoed. `includeCharges` additionally
resolves the charge each trade's cash movement landed on, and **requires naming the securities**:
the pairing is greedy and one-to-one over a security's whole history, so it cannot be computed
from a page — see the note in `docs/connector-gaps-and-decisions.md`. Asking for it unnarrowed is
refused here as a `VALIDATION_ERROR` rather than upstream as an `UPSTREAM_ERROR`, so the failure
says what to add.
- **`accounter_list_tags`** — list tags for categorizing charges, optionally filtered by name and by
`memberBusinessIds`. Rows carry `ownerId`. Deterministically sorted (name, then id) and
size-capped (≤ 500).
size-capped (≤ 1000).
- **`accounter_list_tax_categories`** — list tax categories (id, name, `ownerId`, IRS code,
bookkeeping sort code, active flag), optionally filtered by name, active status, or
`memberBusinessIds`. Same deterministic sort + cap.
Expand All @@ -177,8 +213,8 @@ scope, because it _is_ the scope.
short. Use `accounter_list_business_memberships` instead for just the caller's own memberships and
roles.
- **`accounter_balance_report`** — read-only balance report (transactions) for **exactly one** of
your businesses over a bounded date range (≤ 366 days), selected by the required singular
`memberBusinessId`. Requires `business_owner`/`accountant` role; rows are capped at 500 with a
your businesses over a bounded date range (≤ 1096 days), selected by the required singular
`memberBusinessId`. Requires `business_owner`/`accountant` role; rows are capped at 1000 with a
`truncated` flag. Every row carries `ownerId` — the one business the report ran for, which the
response also reports once alongside the echoed `scope`.

Expand Down Expand Up @@ -479,9 +515,23 @@ curl -s -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \
curl -s -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"accounter_list_tags","arguments":{"memberBusinessIds":["00000000-0000-4000-8000-000000000000"]}}}'

# 8. Securities: the portfolio, then the trades behind one row.
# Expect byCurrency subtotals and a caveats array, and a securityBusinessId to
# carry into step 9.
curl -s -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"accounter_list_security_holdings","arguments":{}}}'

# 9. Executions for one security, newest first, with charge links.
# Run it twice with pageSize 5 and 100: the same execution must report the same
# chargeId either way — that invariant is why includeCharges needs the securities named.
curl -s -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"accounter_get_security_executions","arguments":{"securityBusinessIds":["<securityBusinessId from step 8>"],"includeCharges":true,"pageSize":5}}}'
```

The automated equivalent of steps 1–7 (with the Auth0 verifier and upstream mocked) lives in
The automated equivalent of steps 1–9 (with the Auth0 verifier and upstream mocked) lives in
`src/__tests__/mcp-e2e.test.ts` and runs with `yarn workspace @accounter/mcp-server test`.

## Troubleshooting
Expand Down Expand Up @@ -588,11 +638,13 @@ apply.

- There is no generic "run any query" surface: every capability is a curated tool with a strict
input schema, and the upstream client's read and write paths are separately guarded.
- Responses are **bounded** (date ranges ≤ 366 days, page size ≤ 50, list caps of 500, a
payload-size guard) — very large result sets are truncated with a `truncated`/`continuation` hint
rather than streamed in full. Inline uploads are bounded too: ≤ 10 documents, 256KB per file and
512KB per call once decoded, against a MIME allowlist. Inline base64 is only viable for small
files at all — see [Why inline upload is small](#why-inline-upload-is-small).
- Responses are **bounded** (date ranges ≤ 1096 days, page sizes ≤ 500, list caps of 200–1000
depending on the tool, a 60KB payload-size guard — every cap is an exported `MAX_*` constant so
the suite asserts it rather than this file being the record) — very large result sets are
truncated with a `truncated`/`continuation` hint rather than streamed in full. Inline uploads are
bounded too: ≤ 10 documents, 256KB per file and 512KB per call once decoded, against a MIME
allowlist. Inline base64 is only viable for small files at all — see
[Why inline upload is small](#why-inline-upload-is-small).
- Rate limiting and metrics are **in-process** (per replica); there is no shared/Redis-backed
limiter or Prometheus exposition yet (the limiter and metrics are behind swappable seams).
- Tracing is exported to OpenTelemetry/Grafana Tempo (opt-in via `OTEL_ENABLED=1`), but metrics
Expand Down
Loading
Loading