Skip to main content
Glama
graysonlevino

Addepar MCP Server

README.md
# Addepar MCP Server

Read-only MCP server exposing Addepar portfolio and ownership data to Claude.

Built for use in financial reporting at a registered investment advisor. The
governing principles, in priority order, are reliability, accuracy, security,
then convenience.

---

## What this server guarantees

It does **not** guarantee that any number is correct in the real world. That is
not something any tool can honestly promise, because Addepar itself carries
stale marks: a query run "as of today" routinely returns a value marked weeks
earlier, because private funds mark quarterly.

What it guarantees is total honesty about provenance:

- It never invents a number.
- It never silently drops data.
- It always states what it does not know.

There are deliberately **no confidence scores**. A figure labelled "94% confident"
is false precision, and false precision is worse than useless in a compliance
context. Instead every response carries a structured `caveats` array which is
empty when the result is clean, so its emptiness is an affirmative statement
rather than an absence of checking.

### The null rule

A `null` value and a `0.0` value are different facts and are never merged.

Confirmed in live data, two positions in the same household:

| Position | Value | Meaning |
| --- | --- | --- |
| Leslie A Dahl, WRD Capital | `0.0` | Addepar computed a value, and it is zero |
| W Robert Dahl, Goldman Sachs -400P | `null` | No value computed, reason unstated |

Coercing that null to zero and summing produces a total that is confidently wrong
and looks entirely plausible. Nulls are therefore excluded from sums, counted,
and named in `NULL_VALUES_EXCLUDED`.

### Caveat codes

| Code | Raised when |
| --- | --- |
| `STALE_VALUATION` | A position was marked more than 35 days before the requested date |
| `NULL_VALUES_EXCLUDED` | One or more positions returned no computed value |
| `AMBIGUOUS_MATCH` | A lookup found more than one plausible candidate |
| `PATTERN_MATCH_USED` | Name matching was used, which is non-exhaustive by nature |
| `DEPTH_CAP_REACHED` | Traversal stopped early, suggesting circular nesting |
| `MIXED_VALUATION_DATES` | A total combines values marked on different dates |
| `RESULT_TRUNCATED` | More rows exist than were returned; totals still cover all |
| `UNVERIFIED_CITATION` | No confirmed UI link pattern exists for this object type |

---

## Tools

| Tool | Question it answers |
| --- | --- |
| `resolve_entity` | Turn a name into a specific Addepar ID and object type |
| `get_ownership_rollup` | Total exposure, targeted holdings, or beneficial ownership |
| `get_group_exposure` | Exposure across a family of related funds |
| `get_entity_attributes` | How one client's holdings are classified |
| `list_views` | Which saved reports exist |
| `get_view_data` | Run one of the firm's own saved reports |
| `get_commitments` | Committed, called, and unfunded capital |

All seven declare `read_only_hint=True`, so a trusted client can skip
confirmation prompts. The real guarantee is structural: see below.

Keep the tool count disciplined. Tool definitions load into the model's context
on every request, so each one costs tokens whether used or not, and a bloated
surface measurably degrades tool selection. Prefer adding a parameter to an
existing tool over adding a near-duplicate.

---

## Architecture

```
src/addepar_mcp/
  config.py       Settings from environment. No secrets in code.
  errors.py       Three failure classes. Extends the SDK ToolError.
  models.py       The response contract. Caveats, provenance, disclosure.
  client.py       Read-only HTTP client. Cannot construct a mutating request.
  tree.py         Traversal and null-safe arithmetic. No network dependency.
  citations.py    UI links, only for confirmed URL patterns.
  audit.py        Structured JSON Lines compliance record.
  auth.py         Per-request identity extraction. Entra ready.
  runtime.py      Shared runtime container.
  server.py       Entrypoint, transports, identity middleware.
  tools/          One module per tool, each exposing register(mcp).
```

Adding a tool means adding a module and one line in `tools/__init__.py`.

### Read-only, enforced in code

The client exposes only `get` and `query`, where `query` is a POST restricted to
a fixed allowlist of read-only query endpoints. There is no code path that can
issue PATCH, PUT, or DELETE, and no way to POST to an arbitrary path. Attempting
one raises `ReadOnlyViolationError`.

This is deliberate rather than decorative. No v1 use case writes, and a bug that
mutated a client's ownership structure would not be fully recoverable.

### Fail closed

On a timeout or a rate limit hit mid-operation, tools return an error and no
data. They never return a partial tree or a smaller total, because a truncated
ownership total is indistinguishable from a correct one at a glance.

---

## Setup

```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
cp .env.example .env      # then fill in credentials
.venv/bin/python -m pytest tests/ -q
```

Run locally over stdio:

```bash
TRANSPORT=stdio .venv/bin/python -m addepar_mcp.server
```

Run over HTTP:

```bash
TRANSPORT=http HOST=0.0.0.0 PORT=8080 .venv/bin/python -m addepar_mcp.server
```

Health check at `GET /healthz`. MCP endpoint at `/mcp`.

---

## Deployment and authentication

Deploy remotely over HTTPS so there is one instance to operate rather than one
per workstation.

### Identity, and why it matters

Two separate identity layers, and conflating them causes confusion later:

- **Caller identity**: who invoked the tool. Extracted per request, written to
  every audit record.
- **Upstream identity**: what Addepar's own log shows, which is the single
  service credential regardless of who asked.

This means the server-side audit log is the authoritative answer to "who looked
at client data." Addepar's log will not corroborate it at the user level.

Two supported modes:

| Mode | Per-user attribution |
| --- | --- |
| Shared organization credential (`static_headers`) | **No.** An admin enters one credential; every user's request carries it and all users are indistinguishable. |
| Per-user OAuth | **Yes.** Each user consents individually, so the request identifies them. |

If compliance needs to answer "who asked for what," OAuth is not optional. It is
the only configuration that produces the answer.

For this deployment the natural authorization server is the firm's Entra ID
tenant, since they already run Microsoft 365. That ties access to real corporate
accounts and makes it governable through the firm's own SSO rather than a
credential held by an outside party.

Set `REQUIRE_AUTH=true` once OAuth is configured. Until then the server records
calls as unattributed, which is honest but does not satisfy a per-user audit
requirement.

### Deployment gotchas worth knowing in advance

- Redirect URI for hosted Claude surfaces is
  `https://claude.ai/api/mcp/auth_callback`.
- Anthropic egress originates from `160.79.104.0/21`. Both this server **and**
  the authorization server's discovery endpoints must be reachable from that
  range. A firewall in front of the identity provider breaks the flow even when
  the MCP server itself is reachable.
- With Entra ID, the MCP server URL must also be registered as an Application ID
  URI on the app registration, or the token request fails with `AADSTS9010010`.
- Claude allows roughly 10 seconds for discovery and token endpoints, and 30
  seconds for refresh. Slow endpoints appear as intermittent connection failures
  rather than clean errors.

### Signature verification is not implemented

`auth.py` decodes JWT claims for audit purposes but does **not** verify
signatures. That is acceptable on a trusted network and is **not** acceptable
once the server is reachable by untrusted callers. Replace it with real JWKS
verification (fetch tenant keys, verify signature, check issuer, audience, and
expiry) before exposing it. Identity from an unverified token is a claim, not a
fact. This was left visibly incomplete rather than stubbed to look finished.

---

## Audit logging

Structured JSON Lines, one object per tool call, written to `AUDIT_LOG_PATH`.
Each record carries timestamp, tool, caller identity, arguments, outcome,
duration, Addepar request IDs, entities touched, row count, caveat codes, and
any error.

The log must live server-side. A conversation transcript is not a durable
record: it is user-deletable, and on some surfaces not archivable at all. If the
only trace of a data access lives in a chat window, it does not exist for
compliance purposes.

**Raise this in the compliance conversation:** these records contain entity
names, dollar amounts, and access patterns. The log therefore sits inside the
same compliance perimeter as the underlying client data, with the same retention
and access questions attached. Decide early whether it writes to its own store
or emits into the existing archiving pipeline, because two stores of the same
client data doubles the compliance surface.

---

## Citations

Rule: emit a link only when both the object type and the ID namespace are
confirmed. Otherwise emit the reproducible query. A confidently wrong citation is
worse than none, because it looks authoritative and sends someone to the wrong
place.

| Object | Pattern | Status |
| --- | --- | --- |
| Entity detail | `/app/tools/details/entity/{entity_id}` | Confirmed |
| View on entity | `/app/tools/portfolio/entity/{portfolio_id}/view/{view_id}` | Confirmed |
| View on group | `/app/tools/portfolio/group/{group_id}/view/{view_id}` | **Inferred, not emitted** |
| Position detail | unknown, may not exist | Not emitted |

Computed aggregates such as total exposure do not exist as Addepar objects and
have no native URL. They are cited by deep linking a saved view rooted at the
same portfolio, which lands the user in a report the firm built and already
trusts.

These patterns cannot be validated programmatically. The Addepar web app is a
single page application that returns HTTP 200 for every path, including
deliberate nonsense routes, so curl cannot distinguish a valid route from an
invalid one. Any new pattern must be confirmed by a human copying a real URL out
of the live UI.

---

## Validated behaviour

Verified against the live tenant on 2026-08-26. These are regression fixtures in
`tests/test_regression_fixtures.py`. If a refactor changes any of them, the
refactor is wrong until proven otherwise.

| Assertion | Value |
| --- | --- |
| Dahl household total | `486,034,402.38` |
| Loon Point Holdings II LLC, targeted, 4 occurrences summed | `21,427,660.34` |
| Pacific Lake family, 6 matches of 4,121 scanned | `15,229,060.19` |
| Charlotte's share of the shared LLC | `5,356,915.09` |
| Real maximum ownership depth | `5` |
| Households at the firm | `9` |

Two cross-checks make these trustworthy rather than merely recorded:

1. The household total is identical whether reached by grouping on `ownership`
   (nested legal hierarchy) or on `security` (flat holdings). Two entirely
   different query shapes, same number to the cent.
2. A shared LLC's own total equals the sum of the four sibling trusts' shares of
   it, reached from opposite traversal directions, with no double counting.

---

## Addepar API notes

Hard-won behaviour, recorded so it is not rediscovered painfully.

- **Every endpoint requires the `Addepar-Firm` header**, including
  `/v1/users/me`.
- **`filter[name]` on `/v1/entities` is silently ignored.** It is not a real
  filter: it returns arbitrary entities rather than name matches, which is worse
  than an error because it looks like it worked. `filter[entity_types]` is real
  and works.
- **Unfiltered `GET /v1/entities` returns a 400** ("cache is not responsible for
  firm 2142"). Adding any filter parameter avoids it. Addepar-side bug, worked
  around rather than reported.
- **Name search works on `POST /v1/groups/query`** via `display_names`. That is
  the only working name search in the API.
- **`ownership` grouping walks legal entities only.** It stops at holding-account
  style leaves and does not descend into the securities inside them. Use
  `security` grouping for actual investment lines.
- **Discrete filters are exact-match only.** No prefix or substring matching, so
  a partial name returns zero rows rather than a fuzzy match.
- **Errors are clean and specific**, for example
  `"Invalid grouping attribute: nonsense_grouping"`. They are relayed verbatim.
- **Latency is variable.** The household rollup normally completes in about 3
  seconds. One run took 47.8 seconds against Addepar's 60 second ceiling. The
  client timeout is set to 55 seconds so a clean error surfaces rather than the
  connection being cut mid-response.
- **Rate limits are firm-wide**, 50 requests per 15 minutes and 1,000 per 24
  hours, shared with every other integration at the firm. A limit can therefore
  trigger because of activity unrelated to this server.

### Same-name collisions

Three were found in a single session, which makes this the shape of the data
rather than bad luck:

| Name | Objects |
| --- | --- |
| Dahl 2012 Dynasty Trust | PERSON_NODE entity `31643590` and TRUST entity `31643598` |
| Pacific Lake Partners Long-Term Hold Fund One, L.P. | Appears twice |
| Dahl Family | GROUP `3192711` and HOUSEHOLD entity `31647552` |

Every response therefore discloses name, ID, **and** object type. IDs from
different namespaces are not interchangeable, and `portfolio_type` must match.

---

## SDK version note

This targets **MCP Python SDK 2.x**. If porting older code in this organisation:

- `FastMCP` is now `MCPServer`, from `mcp.server.mcpserver`.
- `ToolAnnotations` fields moved from camelCase to snake_case
  (`readOnlyHint` became `read_only_hint`).
- `stateless_http` and `json_response` moved off the constructor onto
  `streamable_http_app()`.
- Custom exceptions must inherit from the SDK's `ToolError`. Anything else is
  treated as a crash and its message is **kept server-side**, so the model
  receives only a generic failure. This silently breaks any error that was
  supposed to carry information back, such as an ambiguity candidate list.

---

## Known gaps

- `get_commitments` and `get_entity_attributes` follow the same validated
  patterns as the other tools but have not been exercised against live data.
  Commitment columns returned `0.0` during earlier probing and may need the
  period arguments the firm's saved views use.
- JWT signature verification is not implemented. See above.
- The group-rooted view URL pattern is inferred and deliberately not emitted.
- Credentials have been exposed in plaintext across multiple working sessions.
  The code reads from environment variables so rotation is a config change,
  but the rotation itself still needs doing before production use.