feat(mcp): add mercury bank module#1356
Conversation
Bundle a `mercury` module into the ix-mcp kernel so a session can `import mercury` (or rely on lazy auto-bind) and pull Mercury bank data as polars DataFrames, mirroring the slack/beeper/linear modules. Every Mercury read resource is reachable as polars over the public REST API (base https://api.mercury.com/api/v1, Bearer auth): accounts, transactions (per-account and org-wide), cards, statements, recipients, recipient attachments, categories, credit, treasury (+ its transactions and statements), users, events, customers, invoices, safes, send-money approval requests, webhooks; plus single-item gets and attach_receipt. Auth follows the slack/beeper pattern: MERCURY_API_TOKEN / MERCURY_TOKEN env or ~/.config/mercury/token (mode 0600, written by mercury.login); status() reports not-configured and data calls raise MercuryError when no token is set. No token is committed. Registered in the module registry (lazy auto-bind + api() + requirements), bundled in the nix interpreter, with a mercuryBundled surface assertion and the credential pinned against the module constants in the requirements smoke test. Strict-typechecked (zuban + ruff ANN).
There was a problem hiding this comment.
AI review found issues in this pull request.
Verdict: patch is incorrect
Confidence: 0.87
The new Mercury client exposes a per-user bank credential and bank data without the shared-session guard used by comparable personal-data modules, and its generic frame builder retains a known all-empty timestamp failure mode.
- P1
packages/mcp/src/mercury/mercury/__init__.py:272Mercury token and bank data are available in shared rooms - P2
packages/mcp/src/mercury/mercury/__init__.py:461Generic timestamp columns still crash when every value is blank
| token = _token() | ||
| url = f"{_base_url()}{path}" | ||
| try: | ||
| async with httpx.AsyncClient(timeout=_TIMEOUT) as client: | ||
| resp = await client.request( | ||
| method, | ||
| url, | ||
| params=params, | ||
| files=files, | ||
| data=data, | ||
| headers={"Authorization": f"Bearer {token}"}, | ||
| ) |
There was a problem hiding this comment.
Mercury token and bank data are available in shared rooms
Every Mercury data call funnels through _request, which reads the per-user token and sends it as Authorization: Bearer ... to _base_url() without any IX_MCP_SHARED guard. Other personal credential modules in this package refuse shared rooms before reading/sending tokens; without the same guard here, a shared-room participant can cause account/transaction data to be returned into shared state, and the env-overridable base URL can redirect the bearer token to a non-Mercury endpoint.
| time_cols = [c for c in df.columns if _is_time_key(c) and df.schema[c] == pl.Utf8] | ||
| if time_cols: | ||
| df = df.with_columns( | ||
| pl.col(c).str.to_datetime(time_zone="UTC", strict=False).alias(c) for c in time_cols | ||
| ) |
There was a problem hiding this comment.
Generic timestamp columns still crash when every value is blank
_records_frame parses any timestamp-looking UTF-8 column directly with str.to_datetime(..., strict=False). The typed _frame helper above explicitly avoids this because Polars raises when there is no parseable sample, but generic resource endpoints still hit that path if a column such as paidAt, cancelledAt, or dueDate is blank for every returned row. That breaks the advertised generic "every resource as a polars frame" surface for common optional timestamp fields.
| time_cols = [c for c in df.columns if _is_time_key(c) and df.schema[c] == pl.Utf8] | |
| if time_cols: | |
| df = df.with_columns( | |
| pl.col(c).str.to_datetime(time_zone="UTC", strict=False).alias(c) for c in time_cols | |
| ) | |
| time_cols = [c for c in df.columns if _is_time_key(c) and df.schema[c] == pl.Utf8] | |
| for c in time_cols: | |
| has_value = bool( | |
| (df.get_column(c).str.strip_chars().str.len_chars().fill_null(0) > 0).any() | |
| ) | |
| if has_value: | |
| df = df.with_columns( | |
| pl.col(c).str.to_datetime(time_zone="UTC", strict=False).alias(c) | |
| ) | |
| else: | |
| df = df.with_columns(pl.lit(None, dtype=_TS).alias(c)) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 10a57bcc47
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| stays out of logs. Raises :exc:`MercuryError` on a transport failure or an | ||
| HTTP error status; a 401/403 names the re-login step. | ||
| """ | ||
| token = _token() |
There was a problem hiding this comment.
Add a shared-room guard before Mercury requests
When IX_MCP_SHARED is set for a multiplayer/shared kernel, this still reads the user's Mercury token and sends requests, so calls like accounts() or transactions() can publish bank balances and transaction history into shared state. The neighboring personal-data modules guard before network access in this mode; Mercury should do the same before reading the token or issuing the request.
Useful? React with 👍 / 👎.
| Raises :exc:`MercuryError` when no token is configured, the id is not found, | ||
| or the API is unreachable. | ||
| """ | ||
| acct = account_id or await _first_account_id() |
There was a problem hiding this comment.
Use the global transaction lookup by default
When callers use the advertised transaction(id) form without account_id, this chooses the first account before fetching; any transaction belonging to a different Mercury account will be looked up under the wrong account and return 404. Mercury also exposes the org-wide GET /transaction/{transactionId} endpoint, so the one-argument helper should not depend on arbitrary account ordering.
Useful? React with 👍 / 👎.
|
|
||
| async def user(id: str) -> dict[str, Any]: | ||
| """One user by id, as a dict (``GET /user/{id}``).""" | ||
| resp = await _request("GET", f"/user/{id}") |
There was a problem hiding this comment.
Use
/users/{id} for user lookup
For any valid Mercury user id, this calls the singular /user/{id} path, but the public API documents the resource as GET /users/{userId}. As written, await mercury.user(id) will hit an unregistered endpoint and raise a 404 even though users() can list the ids.
Useful? React with 👍 / 👎.
|
|
||
| async def event(id: str) -> dict[str, Any]: | ||
| """One event by id, as a dict (``GET /event/{id}``).""" | ||
| resp = await _request("GET", f"/event/{id}") |
There was a problem hiding this comment.
| return await _list_frame( | ||
| "/customers", "customers", params={"limit": max(1, min(limit, 1000))} |
There was a problem hiding this comment.
Prefix customer reads with
/ar
The accounts-receivable customer API is under /ar/customers, but this list helper calls /customers. With a valid token, await mercury.customers() will request an endpoint Mercury does not register and fail instead of returning the customer DataFrame advertised by the registry.
Useful? React with 👍 / 👎.
| return await _list_frame( | ||
| "/invoices", "invoices", params={"limit": max(1, min(limit, 1000))} |
| "/send-money-approval-requests", | ||
| "requests", | ||
| "approvalRequests", | ||
| params=params or None, |
There was a problem hiding this comment.
Route approval requests to
/request-send-money
The Mercury API documents the send-money approval request list at GET /request-send-money; this uses /send-money-approval-requests, so the helper will 404 whenever callers try to inspect pending approvals. The existing accountId/status params can be sent to the documented path.
Useful? React with 👍 / 👎.
| # 1-row frame for the matched id (empty frame if not found). | ||
| async def card(card_id: str, *, account_id: str | None = None) -> pl.DataFrame: | ||
| """One card by id as a 1-row polars DataFrame (filtered from :func:`cards`).""" | ||
| frame = await cards(account_id=account_id) |
There was a problem hiding this comment.
Search all accounts for card lookups
When account_id is omitted, card(id) only loads cards for the first Mercury account, so organizations with multiple accounts get an empty frame for any card attached to a later account. Since the cards API is per-account and this helper is advertised as lookup-by-id, it should either require account_id or enumerate all accounts before filtering.
Useful? React with 👍 / 👎.
| df = df.with_columns( | ||
| pl.col(c).str.to_datetime(time_zone="UTC", strict=False).alias(c) for c in time_cols | ||
| ) |
There was a problem hiding this comment.
Guard empty timestamp columns in generic frames
For generic list endpoints, any timestamp-looking column whose rows are all empty strings still goes through str.to_datetime; the module already notes that Polars raises when it has no parseable sample, so resources with an optional date field populated only as blanks will crash instead of returning nulls. Mirror the _frame has_value check here before parsing these columns.
Useful? React with 👍 / 👎.
| timestamp columns are parsed to UTC datetimes. ``limit`` caps the rows; | ||
| narrow with ``status`` and ``start`` / ``end`` (``YYYY-MM-DD`` or ISO 8601). | ||
| """ | ||
| params: dict[str, Any] = {"limit": max(1, min(limit, 1000))} |
There was a problem hiding this comment.
Default org-wide transactions to newest first
When callers use all_transactions() without date filters, Mercury's org-wide transaction endpoint defaults to ascending order, so the default limit=100 page returns the oldest transactions rather than the recent feed users expect from the neighboring transactions() helper. Add an order="desc" parameter or a newest_first option here so default org-wide reads don't silently analyze stale history.
Useful? React with 👍 / 👎.
Blast radius
1 added, 0 removed pie showData title Rebuilt checks by category
"rust" : 43
"image" : 15
"site" : 2
"agent" : 1
"blast" : 1
"eval" : 1
"lint" : 1
flowchart LR
c0["ix-mcp-mercury-python-module"]
c1["ix-notebook-mcp-module"]
c2["blast-radius-test"]
c3["agent-skills"]
c4["lint"]
c5["rust-mcp.requirementsSmoke"]
c0 --> k0["agent-skills"]
c0 --> k2["eval"]
c0 --> k3["image-development-base"]
c0 --> k4["image-kernel-dev"]
c0 --> k5["image-minecraft"]
c1 --> k0["agent-skills"]
c1 --> k2["eval"]
c1 --> k3["image-development-base"]
c1 --> k4["image-kernel-dev"]
c1 --> k5["image-minecraft"]
changed checks (63)
|
Summary
Adds a
mercurymodule to the ix-mcp kernel so a session canimport mercury(or rely on lazy auto-bind) and pull Mercury bank data as polars DataFrames, mirroring theslack/beeper/linearmodules.Every Mercury read resource is reachable as polars over the public REST API (base
https://api.mercury.com/api/v1, Bearer auth):account(id), cards /card(id), statementstransaction(id)recipient(id), recipient_attachments, categories, credituser(id), events /event(id), customers /customer(id), invoices /invoice(id)Each list endpoint returns a
pl.DataFrame(typed fixed schemas for accounts/transactions; a generic record-to-frame builder that parses timestamp columns to UTC datetimes for the rest). Single-item gets return a dict. Plusattach_receipt(tx_id, file)to upload a receipt/bill/other attachment.How to use
Auth: set
MERCURY_API_TOKEN(orMERCURY_TOKEN), ormercury.login(token)writes~/.config/mercury/token(mode 0600). Mint a token in the Mercury dashboard (Settings -> API Tokens; it carries thesecret-token:prefix). No token is committed; unconfiguredstatus()reports not-configured and data calls raiseMercuryError.Validation (local, aarch64-darwin)
nix build .#mcp-> green (mercury module compiled into the interpreter).#mcp.tests.strictTypecheck-> green (zuban --strict + ruff ANN overmercury).#mcp.tests.mercuryBundled-> green (full async surface + generic-frame + unconfigured-error assertions).#mcp.tests.requirementsSmoke-> green (credential pinned against the module's own constants)Lazy auto-bind,
api(), and the requirements report are all data-driven over the registry, so the newModule(...)row wires them automatically.🤖 Generated with Claude Code (Opus 4.8)
Note
Add Mercury Bank async API client module to the MCP package
mercuryPython module in packages/mcp/src/mercury/mercury/init.py implementing a full async REST client for the Mercury Bank API, returning polars DataFrames for all list endpoints (accounts, transactions, cards, statements, recipients, treasury, users, events, invoices, etc.).MERCURY_API_TOKEN/MERCURY_TOKENor~/.config/mercury/token).login/logouthelpers for token persistence (written to~/.config/mercury/tokenwith 0600 permissions) and astatusprobe that returnsconfigured=Falsewithout raising when no token is present.mercury._TOKEN_ENV_VARSandmercury._TOKEN_FILEagainst the registry.Macroscope summarized 10a57bc.