# NexusCRM MCP Server

Lets Claude self-serve **read-only** NexusCRM data — accounts, opportunities,
pipeline — for people who have NexusCRM access but **not** BigQuery. It reuses the
`nexuscrm-connect` OAuth-PKCE token (runs as the signed-in user), so it shows
exactly the records that user could see in the NexusCRM UI, nothing more.

It does **not** reimplement auth: the OAuth flow lives in `../scripts/nexus_connect.mjs`
+ `../lib/nexus_core.mjs`, and the token is shared at `~/.nexuscrm-connect/token.json`.

## Tools

**Sign-in:**

| Tool | Purpose |
| --- | --- |
| `connect` | Start OAuth+PKCE sign-in in-chat — opens a browser on this machine and caches a token. Use when a tool reports `needs_auth`. Local machine only; raise the client's per-tool timeout above ~90s. |

**Convenience (read):**

| Tool | Purpose |
| --- | --- |
| `query_my_accounts` | Accounts the user can see; filter by Industry / Region / Segmentation / Vertical. |
| `query_opportunities` | Opportunities by Net New ARR; filter by owner, fiscal_quarter (Launch_Date), stage, account; `open_only` / `realized_only`. |
| `query_activities` | Activities (Task object) — logged calls/meetings/to-dos; filter by owner, status, subtype, related record, last-N-days. |
| `query_field_history` | FLS/RLS-respecting change log for one record, optionally narrowed to one tracked field. |
| `get_account` | One account (by `Account_ID__c` or Name) + opportunity pipeline rollup. |

**Generic / discovery (read) — so the server isn't limited to a fixed object set:**

| Tool | Purpose |
| --- | --- |
| `list_objects` | Discover available SObjects (name/label/queryable/createable), optional name filter and history opt-in. |
| `describe_object` | Fields of any object (name/type/createable/picklist) — discover field names without hardcoding. |
| `query_records` | Read **any** SObject (Task, Event, Contact, Lead, custom…) with fields / where / order_by. |
| `run_soql` | Raw read-only SOQL escape hatch (SELECT only, LIMIT clamped ≤200). |

> **Activity (Task) reporting, end to end:** `query_activities` (or `query_records {object:"Task"}`) +
> `describe_object {object:"Task"}` give sales a full activity *data* report **today** under the read
> token. This MCP server intentionally does not create saved reports or activities; use the CRM UI
> or an independently authorized product workflow for those mutations.

### "My" semantics
The token already scopes visibility to what the signed-in user can see, so the query
tools apply **no `OwnerId` filter** by default — "my accounts" = "accounts I can see".
Pass `owner_id` to narrow to one owner. (The read-only scope excludes the identity
endpoint, so there is no reliable server-side "current user Id" to filter on.)

### Business semantics (verified live — keep these exact)
> Canonical, shared source of truth: [`../references/nexuscrm-semantics.md`](../references/nexuscrm-semantics.md)
> (consumed by this MCP **and** the SFDC cloner skills — covers the schema-vs-doctrine two-layer
> model, field truth, fiscal calendar, open/closed rules, and the verified SOQL dialect limits).
>
> **These 口径 are externalized** (stage sequence, closed/realized stages, stage aliases, fiscal-year
> start month, and `productChain` — product-line truth chain, per-object money-field truth table,
> deprecated legacy line objects): built-in defaults are overridable by a JSON file at
> `NEXUS_SEMANTICS_FILE` (default `~/.nexuscrm-connect/semantics.json`) — so they can be changed
> fleet-wide (e.g. IT/MDM file push) without rebuilding/re-signing. `get_semantics` shows the active
> 口径 + source + version. See [`../../docs/distribution-and-upgrade.md`](../../docs/distribution-and-upgrade.md).
>
> `productChain` merges **deep** (top level + `moneyFields`), so a partial override — e.g. updating
> just the Quote/Order money guidance after the #3873 P1 rollup re-source — never wipes sibling keys.
>
> `objectLifecycle` is the retirement declaration, and two rules govern what an override may do to
> it:
>
> 1. **The built-ins are a floor.** Every built-in entry is always present. An override is applied
>    on top of one only when the result is valid and no weaker; otherwise the built-in stands. It is
>    never *discarded* for being malformed — dropping an entry would mean no write block, no hidden
>    object and no warning, which is the loosest outcome there is, so "reject the bad entry" would
>    be fail-open wearing fail-closed's clothes.
> 2. **On a built-in, only `status` is overridable, and only upward.** `retired` outranks
>    `deprecated` and the stricter side wins, so an override may raise `deprecated` to `retired` but
>    never the reverse. `supersededBy` and `retiredOn` are statements of fact — they have no safety
>    direction to be monotonic in, so no validation makes them safe to accept from a config file, and
>    a well-formed lie (`supersededBy: "WrongObject__c"`) would pass every check and make the guidance
>    confidently wrong. Correcting a fact is a source change under review, the same stance
>    `deprecatedFields` takes.
> 3. **A promotion drops the built-in `note`.** The note reads like a fact but its truth depends on
>    the status — `PoC__c`'s explains that its rows are still read there, which is right while it is
>    `deprecated` and false the moment an override raises it to `retired`. Carrying it forward would
>    tell an agent to read `Credits__c` *and* to read `PoC__c`. The status-derived sentence still says
>    what to do; only the object-specific colour is lost. An override that leaves the status alone
>    keeps the note.
>
> An object with **no** built-in can be added by an override outright, and is rejected outright if
> incomplete — rejecting it removes no existing protection. Matching is case-insensitive, and two
> spellings of one object collapse into a single entry at the stricter status.
>
> The older `productChain.deprecatedLineObjects` array is **still honoured** — a fleet override may
> be the only thing retiring some object, and ignoring it would silently reopen that object to
> writes and to discovery. Every name on it is treated as `retired`, matching what the guard it
> replaced did to every listed object; that holds even where the built-in entry says `deprecated`,
> since the array's meaning is "hide it and block writes". Where a built-in exists it supplies the
> successor and date; where it does not, the messages say the object is retired and state plainly
> that no successor is recorded, rather than naming a blank. An `objectLifecycle` entry for the same
> object enriches it — supplying a successor drops the name-only marker — so migrating an entry
> upgrades it. Prefer `objectLifecycle` for new overrides: it can say what replaced the object and
> when.
>
> The two states drive two *read* behaviours: `retired` objects are hidden from default
> `list_objects`, `deprecated` ones are not (their historical rows have not moved anywhere else, so
> hiding them would remove the only path to the data); both are annotated on `describe_object`, with
> wording that says where to **read** — the axis the states actually differ on. This MCP server has
> no create dispatch at all.

This company is **ARR-driven**. The tools encode these rules so reports come out right:

- **Deal size = `ARR_Amount_USD__c`** (its label is literally *"Net New ARR Amount (USD)"*).
  **Never** use `Amount` or `ACV_Amount_USD__c` for deal size.
- **Time = `Launch_Date__c`**, not `CloseDate`. ARR is realized when a deal *launches*
  (subscription live / cloud customer live). `CloseDate` is legacy (tied to old Closed Won)
  and is intentionally **not** used.
- **Fiscal year starts April 1.** `FY27 = 2026-04-01 .. 2027-03-31`, so
  `fiscal_quarter="FY27Q1"` → `Launch_Date__c` in `2026-04-01 .. 2026-06-30`.
- **Open vs closed (from `StageName`, not `IsWon`/`IsClosed`):** a deal is **OPEN** until it is
  `Launched` (ARR realized) or `Closed Lost`. **`Deal Closed` is still OPEN.** Legacy `Closed Won`
  counts as closed. `query_opportunities`: `open_only=true` = open pipeline; `realized_only=true` =
  booked ARR (Launched only).
- **Current stages:** Prospecting → Discovery & Qualification → Technical Validation →
  Business Validation → Negotiation → Deal Closed → Launched. `Negotiating` is normalized to
  `Negotiation`; `Contract Review` and `Closed Won` are legacy.

### Result limits
Every read caps at **200 rows** and reports `{ returned, totalSize, truncated }` so
Claude knows when there is more. `run_soql` injects or clamps a `LIMIT`.

### SOQL dialect limits (this CRM ≠ vanilla Salesforce)
- No `ORDER BY ... NULLS LAST/FIRST` → use plain `ASC`/`DESC`.
- No `LAST_N_DAYS:N` date literal → use an absolute ISO datetime (`Launch_Date__c >= 2026-04-01`).
  `query_activities`'s `since_days` does this for you.
- **Only one aggregate function per `SELECT`** — `COUNT()` and `SUM()` must be separate queries.

### Other data caveats
- Duplicate Opportunity rows exist → `get_account` dedupes by `Id`; do the same when summing in `run_soql`.
- `list_objects` **curates by default** (drops history/`__Share`/`__Feed`/metadata + system
  objects, every `retired` object in `objectLifecycle` — currently the legacy line-item chain
  `Opportunity_Product__c`/`Quote_Product__c`/`Order_Product__c` — and ops residue tables
  `_ops_*`/`*_backup`/`*_backfill_*`; keeps other custom `__c` and core business objects). Note
  `deprecated` objects such as `PoC__c` **stay listed**: their historical rows have not moved
  anywhere else, so hiding them would remove the only path to the data. Pass `include_history:true`
  to discover queryable history objects, or `include_all:true` to see everything. Use
  `query_field_history` to read one record without guessing whether the virtual name is
  `AccountHistory`, `OpportunityFieldHistory`, or `Widget__History`.
- Every object in `objectLifecycle` is annotated in `describe_object`. Reads stay possible in both
  states; where they *belong* is what the two states differ on, and the annotation says so per object.
  `Order_Product__c`
  in particular is a real audit archive (5,523 rows, kept deliberately), not an empty husk — read it
  when the question is about the pre-migration archive. Policy: `get_semantics` → `objectLifecycle`
  and `productChain` (also §6 of the canonical semantics reference).

## Setup

```bash
# 1. Install deps (once)
cd ~/.claude/skills/nexuscrm-connect/mcp
npm install

# 2. Sign in once (caches a ~1h token, shared with the MCP server)
node ../scripts/nexus_connect.mjs auth

# 3. Offline sanity check (no network) and live smoke test (needs the token)
node server.mjs --selftest
node test_smoke.mjs
```

### Register with Claude Code (project or user scope)
Add to `.mcp.json` in your project root (or `~/.claude.json` for user scope) — see
`mcp.json` here for a ready-to-copy snippet (`install.sh` — shipped in the separate
`nexuscrm-connect-cli.tgz` artifact, not in the plugin bundle — fills in the absolute
`args` path for you; set it by hand only if you copy the snippet directly, since
`node` receives argv verbatim with no shell/tilde expansion):

```json
{
  "mcpServers": {
    "nexuscrm": {
      "command": "node",
      "args": ["/absolute/path/to/nexuscrm-connect/mcp/server.mjs"],
      "env": { "NEXUS_BASE_URL": "https://nexuscrm-new-prod.pingcap.com", "NEXUS_SCOPE": "external.records.read" }
    }
  }
}
```

### Register with Claude Desktop
Same `mcpServers` block in `~/Library/Application Support/Claude/claude_desktop_config.json`
(macOS) / `%APPDATA%\Claude\claude_desktop_config.json` (Windows), then restart the app.

## Token expiry & refresh
The cached access token lasts ~1h. The shipped client requests only `external.records.read`.
Do **not** add `offline_access` to `NEXUS_SCOPE`, ask an administrator to issue it, or present an
ordinary CRM refresh token as a resource-bound MCP credential. That separate resource-bound design
is deliberately dark until its upstream admission and consent gates ship.

For cache safety only, if a pre-existing issuer-provided refresh token is already in the local cache,
the client may rotate it on the non-interactive path or once after a `401`. A cross-process lease
covers the cache reread, refresh POST, and atomic save. The lease has a random owner nonce and an
mtime heartbeat; its expiry is always at least five seconds later than the HTTP deadline, and a
contender rechecks an expired lease before reclaiming it. Lease loss, timeout, or save failure fails
closed. `whoami` and `connect` report `refreshable` as an observation, not as setup guidance.

Without a refresh token, an expired token still yields a structured `needs_auth`
error. Recover in one of two ways:
- **`connect` tool** — run it in-chat; it opens a browser on this machine and caches a fresh
  token. `authorize()` blocks on the browser round-trip, so raise the MCP client's per-tool
  timeout above ~90s first (e.g. Codex `tool_timeout_sec >= 120`). Local machine only — it can't
  sign in for a headless/remote session or refresh an artifact unattended.
- **CLI** — re-run `node ../scripts/nexus_connect.mjs auth` in a terminal.

Outside the `connect` tool the server never opens a browser on its own (it can't, mid-tool-call).

## Local activity log (`diagnose`)

Every tool call is recorded to `~/.nexuscrm-connect/diagnostics.jsonl`, and
`nexus_connect.mjs diagnose` turns it into counts plus a concrete action per failure class.

**Local only. There is no upload path.** What is written:

| | stored |
|---|---|
| failures | tool, error code, and the query with quoted text, numbers and dates replaced by `?` — bare identifier-shaped tokens survive by design, see below |
| successes | tool name, row count, duration — **no query text at all** |

Reduction is an **allowlist scanner**, not a set of removal patterns: it emits only what it
recognises, so an input shape nobody anticipated becomes `?` rather than passing through. Quoted
text (terminated or not), numbers, dates, non-ASCII and unmodelled punctuation are all gone.

Exactly one class survives: bare identifier-shaped tokens, because field and object names are that
shape and "unknown column" is not actionable without them. A value written **unquoted** lands in
the same shape and is indistinguishable from a field name to any scanner — a bounded, deliberate
trade-off. If it is the wrong one for your data, turn recording off.

**The server's error text is never written down at all.** It is classified in memory — which of a
fixed set of known failure shapes it is — and only that classification is stored. Filtering it
instead would not have been enough: literal-stripping catches quoted strings and numbers, but an
unquoted value the engine echoes back (`duplicate value found: someone@example.com`) passes
straight through, and free text from another system cannot be filtered exhaustively. Nothing is
lost that you do not already have: the full error is returned to you by the call itself; this only
declines to keep a copy.

    NEXUS_NO_DIAGNOSTICS=1          # never record
    rm ~/.nexuscrm-connect/diagnostics.jsonl   # wipe what is there

The file is capped and trimmed from the front, so it does not grow without bound. Recording never
affects a tool call: it runs after the handler, and its own failures are swallowed.

## Staging
Point at staging by overriding env: `NEXUS_BASE_URL=https://nexuscrm-new-staging.pingcap.com`
and `NEXUS_CLIENT_ID=<staging_app_key>` in the `env` block (auth must be done with matching flags).
