Skip to content

feat(mcp): add mercury bank module#1356

Open
Andrew Gazelka (andrewgazelka) wants to merge 1 commit into
mainfrom
mercury-mcp
Open

feat(mcp): add mercury bank module#1356
Andrew Gazelka (andrewgazelka) wants to merge 1 commit into
mainfrom
mercury-mcp

Conversation

@andrewgazelka

@andrewgazelka Andrew Gazelka (andrewgazelka) commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Adds a mercury module to 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 / account(id), cards / card(id), statements
  • transactions (per-account) + all_transactions (org-wide) / transaction(id)
  • recipients / recipient(id), recipient_attachments, categories, credit
  • treasury + treasury_transactions + treasury_statements
  • users / user(id), events / event(id), customers / customer(id), invoices / invoice(id)
  • safes, send_money_approval_requests, webhooks, organization

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. Plus attach_receipt(tx_id, file) to upload a receipt/bill/other attachment.

How to use

import mercury
mercury.login("secret-token:mercury_production_...")   # store token (mode 0600)
await mercury.status()                                 # {"configured": True, ...}
await mercury.accounts()                               # polars frame
await mercury.transactions(limit=50, status="sent")    # newest first; amount signed (neg=debit)
await mercury.attach_receipt("<tx id>", "receipt.pdf")

Auth: set MERCURY_API_TOKEN (or MERCURY_TOKEN), or mercury.login(token) writes ~/.config/mercury/token (mode 0600). Mint a token in the Mercury dashboard (Settings -> API Tokens; it carries the secret-token: prefix). No token is committed; unconfigured status() reports not-configured and data calls raise MercuryError.

Validation (local, aarch64-darwin)

  • nix build .#mcp -> green (mercury module compiled into the interpreter)
  • .#mcp.tests.strictTypecheck -> green (zuban --strict + ruff ANN over mercury)
  • .#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 new Module(...) row wires them automatically.

🤖 Generated with Claude Code (Opus 4.8)

Note

Add Mercury Bank async API client module to the MCP package

  • Adds a new mercury Python 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.).
  • Bundles the module into the Nix-managed Python environment in packages/mcp/default.nix and registers it in packages/mcp/ix_notebook_mcp/registry.py with declared credentials (MERCURY_API_TOKEN/MERCURY_TOKEN or ~/.config/mercury/token).
  • Adds login/logout helpers for token persistence (written to ~/.config/mercury/token with 0600 permissions) and a status probe that returns configured=False without raising when no token is present.
  • Extends the credential smoke tests to validate mercury._TOKEN_ENV_VARS and mercury._TOKEN_FILE against the registry.

Macroscope summarized 10a57bc.

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).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:272 Mercury token and bank data are available in shared rooms
  • P2 packages/mcp/src/mercury/mercury/__init__.py:461 Generic timestamp columns still crash when every value is blank

Comment on lines +272 to +283
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}"},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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.

Comment on lines +461 to +465
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
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

Suggested change
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))

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use /events/{id} for event lookup

For any event returned by events(), this calls /event/{id}, while the Mercury API exposes the singular event lookup at GET /events/{eventId}. That makes await mercury.event(id) consistently fail with a 404 for otherwise valid event ids.

Useful? React with 👍 / 👎.

Comment on lines +792 to +793
return await _list_frame(
"/customers", "customers", params={"limit": max(1, min(limit, 1000))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +806 to +807
return await _list_frame(
"/invoices", "invoices", params={"limit": max(1, min(limit, 1000))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefix invoice reads with /ar

Mercury's invoice list endpoint is GET /ar/invoices, but this calls /invoices. As a result, await mercury.invoices() will 404 for accounts with AR access instead of returning invoices.

Useful? React with 👍 / 👎.

Comment on lines +839 to +842
"/send-money-approval-requests",
"requests",
"approvalRequests",
params=params or None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +463 to +465
df = df.with_columns(
pl.col(c).str.to_datetime(time_zone="UTC", strict=False).alias(c) for c in time_cols
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Blast radius

64 of 1489 checks would rebuild between base 4604c6e and head 09b5da1.

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
Loading
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"]
Loading
changed checks (63)
  • agent-skills
  • blast-radius-test
  • eval
  • image-development-base
  • image-kernel-dev
  • image-minecraft
  • image-minecraft-bedrock
  • image-minecraft-status
  • image-minecraft_1.21.11-fabric
  • image-minecraft_1.21.11-paper
  • image-minecraft_26.1.2-fabric
  • image-minecraft_26.1.2-paper
  • image-minecraft_26w17a-fabric
  • image-minestom
  • image-neovim-ci
  • image-remote-desktop
  • image-symphony-codex
  • image-test-cluster-bootstrap
  • lint
  • rust-mcp.apiSmoke
  • rust-mcp.astlogBundled
  • rust-mcp.beeperBundled
  • rust-mcp.bindDefaultSmoke
  • rust-mcp.bindingsSmoke
  • rust-mcp.browserSmoke
  • rust-mcp.browserVdomSmoke
  • rust-mcp.dashboardLauncherSmoke
  • rust-mcp.dataLibsBundled
  • rust-mcp.engineBundled
  • rust-mcp.evalSmoke
  • rust-mcp.exaBundled
  • rust-mcp.feedSmoke
  • rust-mcp.fffBundled
  • rust-mcp.fleetClusterSmoke
  • rust-mcp.fleetSmoke
  • rust-mcp.gmailLibsBundled
  • rust-mcp.googleAuthBundled
  • rust-mcp.htpyBundled
  • rust-mcp.iphoneBundled
  • rust-mcp.ixGoogleBundled
  • rust-mcp.linearBundled
  • rust-mcp.nixSmoke
  • rust-mcp.noxAutotriageBundled
  • rust-mcp.requirementsSmoke
  • rust-mcp.richSmoke
  • rust-mcp.runtimeSmoke
  • rust-mcp.searchBundled
  • rust-mcp.serverTools
  • rust-mcp.sessionIdentitySmoke
  • rust-mcp.sessionSmoke
  • rust-mcp.shSmoke
  • rust-mcp.slackBundled
  • rust-mcp.sshAuthSockSmoke
  • rust-mcp.strictTypecheck
  • rust-mcp.tuiBundled
  • rust-mcp.vdomPropertiesSmoke
  • rust-mcp.viewSmoke
  • rust-mcp.wedgeSmoke
  • rust-mcp.worktreeSmoke
  • rust-mcp.xBundled
  • rust-mcp.yieldSmoke
  • site-case-tests
  • site-test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant