Metadata-Version: 2.5
Name: mercury-multiorg-mcp
Version: 0.1.3
Summary: Unofficial read-only MCP server exposing multiple Mercury banking organizations to one AI session, routed by explicit entity key.
Project-URL: Homepage, https://github.com/dkaleganov/personal-ai-systems/tree/main/mercury-multiorg-mcp
Project-URL: Source, https://github.com/dkaleganov/personal-ai-systems
Project-URL: Documentation, https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/docs/tools.md
Project-URL: Changelog, https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/CHANGELOG.md
Project-URL: Issues, https://github.com/dkaleganov/personal-ai-systems/issues
Author: Dima Kaleganov
License-Expression: MIT
License-File: LICENSE
Keywords: banking,mcp,mercury,multi-org,read-only
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.11
Requires-Dist: httpx<0.29,>=0.28
Requires-Dist: mcp<3,>=2.2
Requires-Dist: pydantic<3,>=2.13
Requires-Dist: python-dotenv<2,>=1.2
Requires-Dist: pyyaml<7,>=6.0
Description-Content-Type: text/markdown

# mercury-multiorg-mcp

<!-- mcp-name: io.github.dkaleganov/mercury-multiorg-mcp -->

Unofficial. Not affiliated with or endorsed by Mercury.

Read-only [MCP](https://modelcontextprotocol.io) server that exposes **several
Mercury organizations to one AI session**. Mercury's hosted MCP and its API
tokens are single-organization per connection; this server holds one
read-only token per org and routes every tool call by an explicit `entity`
key.

Version 0.1.3 (see [CHANGELOG.md](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/CHANGELOG.md)). The maintainer tags
releases as `mercury-vX.Y.Z` on the monorepo; find the commit to pin with
`git ls-remote --tags https://github.com/dkaleganov/personal-ai-systems 'mercury-v0.1.3^{}'`.
The complete tool reference with every returned field is in
[docs/tools.md](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/docs/tools.md); design notes and the build history are in
the project brief on GitHub,
[CLAUDE.md](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/CLAUDE.md)
(not shipped in the sdist).

## Security model

What leaves this server falls into four classes, and the guarantees differ:

| Class | What it is | Guarantee |
| --- | --- | --- |
| **Structured fields** | Every key of every object in a tool result | Allowlisted at every level: each object, and each nested object inside it, is projected through an explicit allowlist copied from the live schema. A key that is not listed does not leave the server, at any depth. Account numbers and tax ids appear only as their last four digits; routing numbers, counterparty bank details, postal addresses, card expiry, presigned download URLs, invoice pay-page slugs, webhook receiver URLs, and webhook signing secrets are never returned. |
| **Tool errors** | The text of an `is_error` result | Upstream HTTP-status errors contain the status, a masked endpoint label and a fixed hint. Validation and configuration errors use their own actionable formats. Resolved known-token values of at least eight characters are scrubbed. Nothing from Mercury's response body or headers is quoted, and no argument you passed is echoed (an invalid id is reported as "invalid id format"). Argument-validation failures (a wrong type, a missing required argument) are rendered by this server as the field path and the expected type only, for example `year: expected an integer (int_parsing)`; the MCP SDK's own rendering, which quotes the value you passed, never reaches the client. |
| **Free-text fields** | Transaction memos, counterparty names, bank descriptions, invoice memos and notes, attachment file names, customer and user names | Returned verbatim. They are third-party text and can contain anything, including instructions aimed at the model and identifiers typed by a human. Treat every tool result as untrusted data, never as instructions. |
| **Documents** | Statement and invoice PDFs from `get_statement_pdf` / `get_invoice_pdf` | **Verbatim and unredacted**, opt-in only. A statement PDF contains the full account number, routing number, address, and every transaction. The two tools exist only when the server is started with `--allow-documents` (or `MERCURY_ALLOW_DOCUMENTS=1`); `server_info.documents_enabled` reports the setting. The body must arrive as `application/pdf` (or `application/octet-stream`), start with `%PDF-`, and carry a `%%EOF` marker within the last 2 KiB once trailing PDF whitespace is ignored; anything else is a clean error. That is an envelope check, not PDF parsing: a document that passes it can still be malformed inside, and the bytes are returned exactly as received. |

The rest of the model:

- **Read-only.** Only `GET` endpoints have client methods; the package has no
  code path that can move money, edit recipients, or change anything.
- **Stdio only.** The server never opens a network listener.
- **Explicit entity.** Call `list_entities` to discover entity keys. Every
  tool that accesses Mercury requires an explicit `entity` and identifies it
  in its successful result. `list_entities` and `server_info` require no
  entity argument. There is no default entity.
- **Tokens stay in the environment.** The registry names an env var per org
  (it must be named `MERCURY_TOKEN_…`, so a registry cannot point the server
  at some other secret); the server reads that env var and nothing else.
  Errors and logs never contain more than the last four characters of a
  token. Literal known-token scrubbing applies to values of 8 or more
  characters; a shorter configured value is not literal-scrubbed (the
  `secret-token:` shape scrub and the `Authorization` header scrub still
  apply, and no message quotes upstream or caller data in the first
  place). At startup the server warns, per entity, when a configured value
  does not carry Mercury's documented `secret-token:` prefix.
- **Only Mercury hosts.** `--api-base` / `MERCURY_API_BASE` must be
  `https://api.mercury.com`, `https://api-sandbox.mercury.com`, or a
  loopback mock, unless `--allow-custom-api-base` is passed on the command
  line. An inherited environment variable alone can never redirect the
  bearer token to another host.
- **Byte limits on wire bytes.** Every request declines compression
  (`Accept-Encoding: identity`). JSON/PDF reads reject nonidentity encoding
  before reading. Keepalive closes bodies unread. Limits are 10 MiB
  (10,485,760 bytes) for PDF and 32 MiB (33,554,432 bytes) for JSON,
  enforced on the bytes actually received while streaming. A small
  compressed body can no longer expand past the limit in memory. Error
  responses are never read at all.
- **Complete or loud.** Walks stop at the requested limit or API end.
  Missing/wrong `page` objects fail; optional terminal `nextPage` may be
  absent or null. Exact duplicate IDs are dropped and counted; conflicting
  contents fail. A page with no fresh usable rows while more are advertised
  fails. A walk that needs more than 200 pages fails, and a treasury
  `cursor` that is not a non-negative integer fails. Duplicate counts are
  reported as `duplicates_dropped` on every paginated result and under
  `reportable_totals.totals`, so a total is never built on a stalled,
  malformed, or double-counted walk.
- **Windowed feeds are walked in full.** Mercury documents no sort key for
  events or treasury transactions, so a client-side window (`since` on
  `list_events`, `start`/`end` on `list_treasury_transactions`) walks the
  whole bounded feed (90 days of events; the treasury ledger up to 200
  pages), filters and sorts newest first here, then applies `limit`.
  `truncated` is exact. The cost is proportional to the feed, not the window.
- **Binary documents stay in memory.** PDFs come back as an embedded
  `application/pdf` blob (base64), never written to disk.
- **Path ids are validated.** Every id that becomes part of a request path
  must be a single safe segment; nothing can redirect a call to another
  endpoint.
- **Startup errors are one line, exit 2.** A missing or malformed registry
  (including non-string YAML keys), an unreadable file, a bad `--env-file`,
  or a disallowed API host prints one line to stderr and exits with status
  2. The redacting exception hooks are installed before anything is loaded,
  so no startup path can print an unredacted traceback.
- **Never files anything.** `reportable_totals` is a pre-filing cross-check.
  Mercury has no 1099 filing endpoint; filing happens in each org's
  dashboard.

**Hygiene.** This package lives in a public monorepo. Tracked files,
fixtures, and commit messages carry no tokens, account numbers, or
financial identifiers, with two deliberate exceptions. First, the
maintainer's own name appears in the package `authors` metadata and the
monorepo README (approved by the repository owner); business and personal
names of anyone else do not appear. Second, a history note: the first
Phase 1 commit's fixtures used a real, public ABA routing number as sample
data; it was replaced with an obviously fake value in the next commit, so
it is absent from every tagged file tree but remains in their ancestry. It
identifies a bank, not an account, and the history was deliberately not
rewritten. This release passed a full-history gitleaks scan.

## Install

From PyPI, running the pinned release with `uvx` (no clone needed):

```bash
uvx mercury-multiorg-mcp@0.1.3 --entities /private/path/entities.yaml
```

`uvx <package>@<version>` runs exactly that release in an isolated, cached
environment. `pip install 'mercury-multiorg-mcp==0.1.3'` also works and puts
`mercury-multiorg-mcp` and `mercury-multiorg-mcp-keepalive` on your `PATH`.

Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/) (for `uvx`).

### From source / pinned commit

From a clone:

```bash
git clone https://github.com/dkaleganov/personal-ai-systems.git   # or git@github.com:dkaleganov/personal-ai-systems.git
cd personal-ai-systems/mercury-multiorg-mcp
uv sync
uv run mercury-multiorg-mcp --entities /private/path/entities.yaml
```

Or pin a full commit SHA with `uvx` (pin a SHA, not a tag: `@tag` +
`#subdirectory=` has had resolver bugs in uv, and SHAs are cache-safe):

```bash
uvx --from 'git+https://github.com/dkaleganov/personal-ai-systems@<FULL_COMMIT_SHA>#subdirectory=mercury-multiorg-mcp' \
  mercury-multiorg-mcp --entities /private/path/entities.yaml
```

## Configure

1. In each Mercury org: org switcher → All Settings → Tokens → create a
   **Read Only** token (no IP allowlist required).
2. Copy [`entities.example.yaml`](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/entities.example.yaml) to a private location outside this repo and
   list your orgs (`key`, `display_name`, `token_env`; the env var name must
   start with `MERCURY_TOKEN_`).
3. Export one env var per org, named as in the registry, in the environment
   that launches the server ([`.env.example`](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/.env.example) shows the names). Configuration
   is read from process environment variables only; **no `.env` file is
   read unless you pass `--env-file <path>`**, so nothing is picked up by
   accident from the repo, your home directory, or a `uvx` cache.
4. Optional: `MERCURY_API_BASE=https://api-sandbox.mercury.com` with
   sandbox-created tokens.
5. Optional: `--allow-documents` (or `MERCURY_ALLOW_DOCUMENTS=1`) to register
   the two PDF tools. Leave it off unless the session really needs
   unredacted documents.

### Command line

| Flag / env var | Meaning |
| --- | --- |
| `--entities PATH` / `MERCURY_ENTITIES_FILE` | Entity registry YAML. Required (flag wins over env var); there is no implicit default. |
| `--env-file PATH` | Load this dotenv file before resolving tokens. Existing env vars win. Without the flag no dotenv file is read from anywhere. |
| `--api-base URL` / `MERCURY_API_BASE` | Mercury API host, default `https://api.mercury.com`. Allowed: production, `https://api-sandbox.mercury.com`, or plain `http://` on `localhost` / `127.0.0.1` for mocks. |
| `--allow-custom-api-base` | Permit any other `https://` host. Never set this from an environment variable; it exists so a custom host is always a deliberate command-line choice. |
| `--allow-documents` / `MERCURY_ALLOW_DOCUMENTS=1` | Register `get_statement_pdf` and `get_invoice_pdf` (documents are returned unredacted). Off by default: 24 tools without it, 26 with it. |
| `--version` | Print the package version and exit. |

Startup problems (missing or malformed registry, invalid YAML, unreadable
file, bad API base) print one line to stderr and exit with status 2. Stdout
is reserved for the MCP protocol.

Mercury deletes an API token after 45 days of inactivity (the token
inactivity clock) and separately downgrades permissions unused for 45 days.
Run `mercury-multiorg-mcp-keepalive` on a schedule so the inactivity clock
never expires; see [docs/keepalive.md](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/docs/keepalive.md) for cron and
launchd snippets.

## Works with any MCP client

Compatible with MCP clients that support local stdio servers and the
negotiated protocol version. Configure the following command on the client
host, with access to the private registry and environment file. Document
display and client approval policies vary. This server does not expose HTTP
or SSE. The command and arguments are the same in every client:

```text
command: uvx
args:    mercury-multiorg-mcp@0.1.3 --entities /private/path/entities.yaml
optional extra arg: --allow-documents   (registers the two unredacted PDF tools)
```

To run a pinned commit instead of the PyPI release, replace the first
argument with `--from`, `git+https://github.com/dkaleganov/personal-ai-systems@<FULL_COMMIT_SHA>#subdirectory=mercury-multiorg-mcp`, `mercury-multiorg-mcp`
(see "From source / pinned commit").

Tokens reach the server as environment variables named in your registry.
Two ways to supply them: an `env` block in the client's config (only where
the client expands placeholders such as `${MERCURY_TOKEN_ACME_MAIN}` from
your shell; a literal token in a config file is a secret on disk), or a
private dotenv file. A private dotenv file passed with
`--env-file /private/path/mercury.env` avoids client-specific placeholder
expansion. The client host must be able to read it; existing process
environment variables win. Keep the registry and the dotenv file outside
any repository and readable only by your user.

### Claude Code (`.mcp.json`)

Claude Code expands `${VAR}` from its environment. This repository includes
an example `.mcp.json`; clients that discover this format may offer to
launch it. It points to the synthetic example registry and contains no
credentials (so `list_accounts` returns a clean per-entity error).

```json
{
  "mcpServers": {
    "mercury-multiorg": {
      "command": "uvx",
      "args": [
        "mercury-multiorg-mcp@0.1.3",
        "--entities",
        "/private/path/entities.yaml"
      ],
      "env": {
        "MERCURY_TOKEN_ACME_MAIN": "${MERCURY_TOKEN_ACME_MAIN}"
      }
    }
  }
}
```

### Claude Desktop (`claude_desktop_config.json`)

Open Settings → Developer → Edit Config. Use `--env-file` so this setup
does not depend on client-specific placeholder expansion:

```json
{
  "mcpServers": {
    "mercury-multiorg": {
      "command": "uvx",
      "args": [
        "mercury-multiorg-mcp@0.1.3",
        "--entities",
        "/private/path/entities.yaml",
        "--env-file",
        "/private/path/mercury.env"
      ]
    }
  }
}
```

### Codex CLI (`~/.codex/config.toml`)

```toml
[mcp_servers.mercury-multiorg]
command = "uvx"
args = [
  "mercury-multiorg-mcp@0.1.3",
  "--entities", "/private/path/entities.yaml",
  "--env-file", "/private/path/mercury.env",
]
# `env = { MERCURY_TOKEN_ACME_MAIN = "..." }` is also accepted, but values there are
# literal, so prefer --env-file over putting a token in this file.
```

### Cursor / Windsurf legacy Cascade (`mcp.json`)

Cursor uses `.cursor/mcp.json` or `~/.cursor/mcp.json`. Windsurf legacy
Cascade uses `~/.codeium/windsurf/mcp_config.json`. The current default
Devin Local agent uses its own CLI configuration; this example targets
legacy Cascade. Both use an `mcpServers` map. Use `--env-file` unless your
client's documentation says it expands environment placeholders.

```json
{
  "mcpServers": {
    "mercury-multiorg": {
      "command": "uvx",
      "args": [
        "mercury-multiorg-mcp@0.1.3",
        "--entities",
        "/private/path/entities.yaml",
        "--env-file",
        "/private/path/mercury.env"
      ]
    }
  }
}
```

### VS Code (`.vscode/mcp.json`)

VS Code uses a `servers` map (not `mcpServers`) and an explicit
`"type": "stdio"`:

```json
{
  "servers": {
    "mercury-multiorg": {
      "type": "stdio",
      "command": "uvx",
      "args": [
        "mercury-multiorg-mcp@0.1.3",
        "--entities", "/private/path/entities.yaml",
        "--env-file", "/private/path/mercury.env"
      ]
    }
  }
}
```

### Gemini CLI (`~/.gemini/settings.json` or `.gemini/settings.json`)

```json
{
  "mcpServers": {
    "mercury-multiorg": {
      "command": "uvx",
      "args": [
        "mercury-multiorg-mcp@0.1.3",
        "--entities",
        "/private/path/entities.yaml",
        "--env-file",
        "/private/path/mercury.env"
      ]
    }
  }
}
```

### Any other client

Any client that supports local stdio servers and the negotiated protocol
version, running on a host that can read the private registry and
environment file: point it at the same `uvx` command and arguments. The
server never opens a network listener, so an HTTP or SSE transport is not
offered.

## Tools

Every tool that accesses Mercury requires an explicit `entity` and
identifies it in its successful result; `list_entities` and `server_info`
require no entity argument. The seven tools with a `limit` argument return
`count` and `truncated`. Full-list tools have no public limit. Paginated
results also expose duplicate diagnostics (`duplicates_dropped`: identical
rows the walk dropped). Full field-by-field reference:
[docs/tools.md](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/docs/tools.md).

| Tool | Arguments | Returns |
| --- | --- | --- |
| `list_entities` | — | entity keys, display names, whether each token env var is set |
| `server_info` | — | package version, API base, entity count, `documents_enabled` (no secrets) |
| `list_accounts` | `entity` | accounts with balances, `accountNumberLast4` |
| `list_transactions` | `entity`, `account_id?`, `start?`, `end?`, `search?`, `limit=100` | transactions in Mercury API `desc` order, `truncated` flag |
| `reportable_totals` | `entity`, `year`, `threshold?` (default 600 through 2025, 2000 from 2026, for nonemployee services and certain MISC payments; finite, at most 1,000,000,000) | per-recipient 1099 cross-check totals; `needs_review` buckets, `unclassified`, `excluded_summary` |
| `list_recipients` | `entity` | recipients: id, name, nickname, status, default payment method, date last paid, emails, `isBusiness` |
| `list_tax_docs` | `entity` | tax-form attachments per recipient, plus `recipients_without_docs` |
| `get_org` | `entity` | id, legal name, DBAs, kind, subscription tier, billing cadence, `einLast4` |
| `list_statements` | `entity`, `account_id`, `start?`, `end?`, `limit=100` | statement metadata in Mercury API `desc` order (masked account number and EIN, `transactionCount`) |
| `get_statement_pdf` (opt-in) | `entity`, `statement_id` | the statement PDF as an embedded blob (≤ 10 MB), **unredacted**; only with `--allow-documents` |
| `list_treasury` | `entity` | treasury accounts with balances and monthly net returns |
| `list_treasury_transactions` | `entity`, `treasury_id`, `start?`, `end?`, `limit=100` | treasury ledger rows in Mercury API `desc` order; with a date window the whole ledger is walked, filtered, and sorted by `canonicalDay` newest first |
| `list_treasury_statements` | `entity`, `treasury_id`, `document_type?` | treasury statements and tax documents (metadata) |
| `list_credit_accounts` | `entity` | credit accounts with balances |
| `list_cards` | `entity`, `account_id?`, `status?`, `limit=100` | cards: last four, name, nickname, kind, type, status, limits, budgets, locks |
| `get_card` | `entity`, `card_id` | one card, same fields |
| `list_categories` | `entity` | custom expense categories |
| `list_merchants` | `entity`, `search?`, `limit=100` | priority merchants (id, name) |
| `list_customers` | `entity` | AR customers: id, name, email, `deletedAt` |
| `list_invoices` | `entity`, `status?`, `start?`, `end?`, `limit=100` | AR invoices (filters applied client-side after a full walk) |
| `get_invoice` | `entity`, `invoice_id` | one invoice with service period and line items |
| `get_invoice_pdf` (opt-in) | `entity`, `invoice_id` | the invoice PDF as an embedded blob (≤ 10 MB), **unredacted**; only with `--allow-documents` |
| `list_invoice_attachments` | `entity`, `invoice_id` | attachment ids and file names (no URLs) |
| `list_users` | `entity` | users: id, first and last name, email, role |
| `list_events` | `entity`, `since?`, `resource_type?`, `limit=100` | change events in Mercury API `desc` order; with `since` the whole 90-day feed is walked, filtered, and sorted by `occurredAt` newest first; patches re-projected per resource allowlist |
| `list_webhooks` | `entity` | webhook endpoints: id, `url_fingerprint`, status, `enabled`, event types, filter paths (never the secret or any part of the receiver URL) |

`start` / `end` on `list_transactions` filter on `createdAt` (`YYYY-MM-DD` or
ISO 8601). The Mercury dashboard displays `postedAt`, so a date range may
differ slightly from the UI.

### Returns for the Phase 1 and 2 tools

```text
list_entities               entities[] {entity, display_name, token_configured}
server_info                 name, version, api_base, entity_count, entities_with_token, transport, read_only,
                            documents_enabled
list_accounts               entity, duplicates_dropped, accounts[] {id, name, nickname, legalBusinessName, kind, type,
                            status, availableBalance, currentBalance, createdAt, canReceiveTransactions,
                            dashboardLink, accountNumberLast4}
list_transactions           entity, filters, count, truncated, duplicates_dropped, transactions[] {id, accountId,
                            amount, status, kind, createdAt, postedAt, estimatedDeliveryDate, failedAt,
                            reasonForFailure, counterpartyId, counterpartyName, counterpartyNickname,
                            bankDescription, externalMemo, note, mercuryCategory,
                            categoryData {id, name, visibleForCardSpend, visibleForOther, visibleForReimbursements},
                            merchant {id, category, categoryCode, currency, amount}, checkNumber, cardId,
                            currencyExchangeInfo {convertedFromAmount, convertedFromCurrency, convertedToAmount,
                            convertedToCurrency, exchangeRate, feeAmount, feePercentage, feeTransactionId},
                            dashboardLink}
list_recipients             entity, count, duplicates_dropped, recipients[] {id, name, nickname, status,
                            defaultPaymentMethod, dateLastPaid, emails [strings], contactEmail, isBusiness}
list_tax_docs               see the `list_tax_docs` section below (documents[] {id, recipientId, recipientName,
                            fileName, formType, uploadedAt})
reportable_totals           see the `reportable_totals` section below
```

### Returns for the Phase 3 tools

```text
get_org                     entity, organization {id, legalBusinessName, dbas [{dbaName, dbaIsDefault}], kind,
                            subscriptionTier, billingCadence, einLast4}
list_statements             entity, account_id, filters, count, truncated, duplicates_dropped,
                            statements[] {id, startDate, endDate, endingBalance, companyLegalName,
                            accountNumberLast4, einLast4, transactionCount}
get_statement_pdf           content[0] text {entity, statement_id, mimeType, bytes, encoding, redacted: false};
                            content[1] embedded resource {uri, mimeType: application/pdf, blob (base64)}
list_treasury               entity, count, duplicates_dropped, treasury_accounts[] {id, status, availableBalance, currentBalance,
                            createdAt, netReturns[] {month, netAmount, treasuryFee, status,
                            dividends[] {id, type, securityName, amount}}}
list_treasury_transactions  entity, treasury_id, filters, count, truncated, duplicates_dropped, transactions[] {id, accountId, type,
                            amount, balance, canonicalDay, description, additionalDetails, security,
                            details {creditDescription, depositCounterpartyId, feeDescription,
                            manualAmendmentDescription, security, sweepDirection, tradeAction,
                            withdrawalCounterpartyId}}
list_treasury_statements    entity, treasury_id, filters, count, duplicates_dropped, statements[] {id, accountId, documentType,
                            description, periodStart, periodEnd, creationDate, createdAt, updatedAt}
list_credit_accounts        entity, count, credit_accounts[] {id, status, availableBalance, currentBalance, createdAt}
list_cards                  entity, filters, count, truncated, duplicates_dropped, cards[] {id, accountId, userId, nameOnCard, nickname,
                            lastFour, kind, type, status, physicalCardStatus, isAgentCard, spendLimitType,
                            spendLimit {amountCents, atmAmountCents, interval},
                            budgets[] {id, name, amountCents, remainingAmountCents}, merchantLock {id, name},
                            categoryLocks [strings], createdAt, updatedAt}
get_card                    entity, card {same fields as one list_cards row}
list_categories             entity, count, duplicates_dropped, categories[] {id, name, visibleForCardSpend, visibleForOther,
                            visibleForReimbursements}
list_merchants              entity, filters, count, truncated, duplicates_dropped, merchants[] {id, name}
list_customers              entity, count, duplicates_dropped, customers[] {id, name, email, deletedAt}
list_invoices               entity, filters, count, truncated, duplicates_dropped, invoices[] {id, invoiceNumber, status, amount,
                            currencyCode, customerId, destinationAccountId, invoiceDate, dueDate, createdAt,
                            updatedAt, canceledAt, poNumber, payerMemo, internalNote, ccEmails, achDebitEnabled,
                            creditCardEnabled, useRealAccountNumber}
get_invoice                 entity, invoice {list fields + servicePeriodStartDate, servicePeriodEndDate,
                            lineItems[] {name, quantity, unitPrice, salesTaxRate}}
get_invoice_pdf             same two blocks as get_statement_pdf, keyed by invoice_id
list_invoice_attachments    entity, invoice_id, count, attachments[] {id, fileName}
list_users                  entity, count, duplicates_dropped, users[] {userId, firstName, lastName, email, organizationRole}
list_events                 entity, filters, count, truncated, duplicates_dropped, events[] {id, resourceType,
                            resourceId, operationType, resourceVersion, occurredAt, changedPaths, mergePatch,
                            previousValues, patchOmitted?}
list_webhooks               entity, count, duplicates_dropped, webhooks[] {id, url_fingerprint (first 8 hex chars of sha256 of the
                            receiver URL), status, enabled, eventTypes, filterPaths, createdAt, updatedAt}
```

Every nested object above has its own allowlist; a key Mercury adds
tomorrow at any depth is dropped, not passed through.

Lists keep the API's default order (ascending by an undocumented sort key)
except transactions, statements, treasury transactions, and events, which
are requested in Mercury API `desc` order. Mercury documents no sort key,
so chronological (newest-first) order is guaranteed only for windowed
calls: windowed treasury calls sort by `canonicalDay`, and events with
`since` sort by `occurredAt`, before `limit` applies. An unwindowed call
returns Mercury API `desc` order as is. When `truncated` is true, the rows
kept are the head of whichever order applies.

Where the Mercury API has no server-side filter for a documented argument
(`since` on events, `start`/`end` on treasury transactions and invoices,
`status` on invoices) the tool walks the whole feed, applies the filter
here, and says so in `docs/tools.md`.

### `reportable_totals`

Counts only completed money movement (status `sent`) with an outgoing
amount, attributed to the calendar year by **`postedAt` in UTC** (the date
the dashboard shows). The API is queried with `postedStart` / `postedEnd`
padded by one day on each side; rows outside the year are dropped
client-side and counted under `excluded_summary.outside_year`. Every page
of the year is walked; a walk that cannot complete is an error, never a
partial total. The live docs define no semantics for transaction `kind`,
so the table only asserts what the kind name supports; real-organization
acceptance (September 2026) showed that negative `externalTransfer` rows
were the organization's own linked external accounts and cross-org
transfers, while genuine vendor-initiated ACH debits arrived as kind
`other`. Those two kinds are therefore set aside for a human rather than
counted.

| Decision | Kinds | Notes |
| --- | --- | --- |
| include | `outgoingPayment` | method from the payment details: `ach`, `domesticWire`, `internationalWire`, `check`, or `unknown` |
| include | `exogenousWireDrawdown` (negative amount) | wire drawdown, presumed counterparty-initiated; undocumented (`wireDrawdown`) |
| needs review | `externalTransfer` (negative amount) | `linked_account_transfers`: usually your own linked/external accounts or cross-org transfers; a vendor-initiated ACH debit could also appear |
| needs review | `other` (negative amount) | `unlabeled_debits`: no method signal; typically vendor-initiated ACH debits or Mercury product payments |
| exclude | `internalTransfer`, `treasuryTransfer` | the org's own accounts |
| exclude | `creditCardTransaction`, `debitCardTransaction`, `creditCardCredit`, `debitCardCredit` | the card processor files 1099-K |
| exclude | `wireFee`, `personalBankingSubscriptionFee`, `billingEngineSubscriptionFee`, `cardInternationalTransactionFee*` | bank fees and rebates |
| exclude | `incomingDomesticWire`, `incomingInternationalWire`, `checkDeposit`, `interestPayment` | money received |
| exclude | `currencyCloudReturn` | an international wire returned; the original may already be counted, net it by hand |
| exclude | `expenseReimbursement` | employee reimbursements |
| exclude | any includable, needs-review, or unclassified kind not `sent`, or with a non-negative amount | `not_settled:<status>` / `incoming` |
| unclassified | any kind not in the table, or a missing amount | listed one by one with a reason |

Recipients group by `counterpartyId` (confidence `high` when it matches a
recipient from `GET /recipients`, else `medium`) or, failing that, by
counterparty name (`low`). Id-groups that share a normalised name are
cross-referenced so a payee split across two ids is visible. The default
threshold is year-aware: 600 through tax year 2025, 2000 from 2026
(inflation-indexed from 2027). It is the default for nonemployee services
and certain MISC payments; supply the applicable category/year threshold
(see the IRS instructions for Forms 1099-MISC and 1099-NEC). The resolved
value is echoed. A threshold must be a finite
number between 0 and 1,000,000,000; anything else is an error (never
silently zero). Real-time payments appear under `ach` or `unknown`
depending on whether the API returns routing details for them.

Returns:

```text
entity, year, threshold            resolved threshold (default depends on year)
date_basis                         {field: "postedAt", timezone: "UTC",
                                    fallback_to_createdAt_count, api_filter: {postedStart, postedEnd}}
status_basis                       ["sent"]
totals                             reportable_total, reportable_payment_count, recipient_count,
                                   flagged_count, needs_review_total, needs_review_count,
                                   reportable_total_upper_bound (= reportable_total + needs_review_total),
                                   unclassified_count, transactions_scanned,
                                   duplicates_dropped (transaction rows), recipient_duplicates_dropped
recipients[]                       display_name, recipient_id (known recipient) | null, counterparty_id | null,
                                   grouping (counterparty_id | name | transaction), confidence (high | medium | low),
                                   total, payment_count, by_method {label: {count, total}}, flagged,
                                   possible_same_payee [other counterparty ids with the same normalised name],
                                   name_merged_total, flagged_for_review
needs_review                       {linked_account_transfers: [...], unlabeled_debits: [...]}; each entry:
                                   display_name, counterparty_id | null, count, total, by_kind {kind: {count, total}},
                                   would_flag (total >= threshold), sample_transaction_ids (max 3), hint (fixed string),
                                   possible_same_payee [ids], name_merged_total, would_flag_merged
unclassified[]                     id, kind, status, amount, postedAt, counterpartyName, reason
excluded_summary                   {category: {count, amount (signed, as returned by Mercury)}}
```

`fallback_to_createdAt_count` counts included rows that had no `postedAt`
and were placed by `createdAt` instead. Such rows cannot come back from the
posted-date filter, so the count is normally 0. Hints are fixed strings
chosen by kind and by a `Mercury ` name prefix; counterparty text itself
is data, never an instruction.

### `list_tax_docs`

Returns:

```text
entity
document_count, recipient_count, recipients_with_docs
duplicates_dropped                 {attachments, recipients}
documents[]                        id, recipientId, recipientName | null, fileName (verbatim third-party text),
                                   formType (w9 | w8BEN | w8BENE | unknown | null), uploadedAt
recipients_without_docs[]          id, name | null, status | null   (every recipient of any status with no attachment)
```

Download URLs are never returned.

## Keepalive

```bash
uvx --from 'mercury-multiorg-mcp==0.1.3' mercury-multiorg-mcp-keepalive --entities /private/path/entities.yaml
```

The keepalive executable is not named after the package, so `uvx` needs
`--from`; from a clone, `uv run mercury-multiorg-mcp-keepalive` runs the
same command.

One authenticated `GET /accounts` per configured entity, one line each
(`<timestamp> OK|FAIL <entity> HTTP <status>`), exit 1 if any entity fails
or no entity has a token, exit 2 on a configuration error. Same host rules
as the server (`--allow-custom-api-base` for anything but production,
sandbox, or loopback). Details, cadence, and cron / launchd snippets in
[docs/keepalive.md](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/docs/keepalive.md).

## Develop

```bash
uv sync
uv run pytest
```

Tests use synthetic JSON fixtures and a mock HTTP transport only. Nothing in
this package, its tests, or its history may contain real names, tokens, or
account identifiers (see the history note above for the one historical
exception, a public bank routing number).

## License

MIT (the monorepo `LICENSE` applies; a copy, [LICENSE](https://github.com/dkaleganov/personal-ai-systems/blob/mercury-v0.1.3/mercury-multiorg-mcp/LICENSE), ships in the package).
