# Build an Eno’niva data connector

You are Claude Code, working in this repository. Your task: add an **Eno’niva data connector** to this system. It's an
MCP server endpoint with **one tool**, `eno_get_contact_changes`, that gives Eno’niva a user's contacts **added, updated
or deleted since the last call**. Eno’niva calls it once a day per user and when the user clicks "Update now".

**Two flavors** (ask the user which one they want if it isn't clear; most companies want the directory one):

- **Custom connector**: each user pastes your MCP URL + an API key into Eno’niva. Build sections "What to build" through
  "Smoke test".
- **Directory service** (listed in Eno’niva's Connectors menu; users just click Connect): build the same
  `eno_get_contact_changes` tool **plus** `eno_connect` (and optionally `eno_disconnect`). Read "Directory service" at
  the end first: it changes how users are identified and how the secret is stored.

- Human spec (read it if anything here is unclear): https://enoniva.com/connectors/spec.html
- JSON Schema for the tool input/output: https://enoniva.com/connectors/schema.json
- Reference implementation (Node, no dependencies, ~100 lines): https://enoniva.com/connectors/example/connector.js
  (zip: https://enoniva.com/connectors/example.zip)

## Before you write code

1. Explore this codebase: the language and web framework, where contacts (people, customers, leads…) live, how users
   and their access are modeled, how API keys or settings are stored, and how migrations and tests are written.
2. Decide what a "contact" is here and which user owns or may see it. A user must only ever get contacts they are allowed
   to see in this system.
3. Tell the user your plan in a few lines (files, tables, the endpoint path), then implement it. Ask only if ownership of
   contacts is genuinely ambiguous.

## What to build

1. **Settings for the user (or the company admin)**: a toggle "Connect to Eno’niva", which
   - issues a random **API key** for this account (store only a hash; show it once),
   - stores the **Eno’niva signing secret** the user pastes in (it starts with `ensec_`; store it encrypted, never log
     it), and optionally the **connector id** (`dc_…`),
   - records that this user **authorized** Eno’niva (timestamp), and can be turned off (revocation).
2. **An MCP endpoint** (Streamable HTTP), e.g. `POST /mcp` or `POST /integrations/enoniva/mcp`, over https in production.
   Use the official MCP SDK for this language if the project can take the dependency; otherwise handle the four
   JSON-RPC messages by hand like the reference implementation.
3. **Change tracking** so you can answer "what changed since call N" (below).
4. **Tests** (checklist below) and a short section in the project's docs.

## Transport

JSON-RPC 2.0 over HTTP POST to one URL. Eno’niva sends `Accept: application/json, text/event-stream`; answering
`Content-Type: application/json` is fine.

| Message | Answer |
|---|---|
| `initialize` | `{protocolVersion: "2025-06-18", capabilities: {tools: {}}, serverInfo: {name, version}}`; you may set an `Mcp-Session-Id` response header (Eno’niva sends it back) |
| `notifications/initialized` (no `id`) | HTTP `202`, empty body |
| `tools/list` | `{tools: [{name: "eno_get_contact_changes", description, inputSchema, outputSchema, annotations: {readOnlyHint: true}}]}` (schemas: `$defs.input` / `$defs.output` of schema.json) |
| `tools/call` with `name: "eno_get_contact_changes"` | `{content: [{type: "text", text: JSON.stringify(result)}], structuredContent: result}` |
| `DELETE` with `Mcp-Session-Id` (no body, no signature) | `204`; forget the session |

Unknown methods: JSON-RPC error `-32601`. Respond within 30 s; keep responses under 8 MB. No redirects.

## Authentication (every POST)

Headers from Eno’niva:

- `X-Enoniva-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + rawBody)>`
- `X-Enoniva-Connector-Id: dc_…`
- `Authorization: Bearer <the API key you issued>` (when the user entered one in Eno’niva)

Rules (reject with HTTP `401` and a JSON body):

1. Read the **raw body bytes before JSON parsing**. Compute the HMAC with the stored secret (the full `ensec_…` string,
   UTF-8, as the key) over `t + "." + rawBody`, compare in **constant time**, and reject if `|now - t| > 300` seconds.
2. If an API key is present (or required by you), resolve it to the account; the signing secret checked must belong to
   that same account/connector.
3. Return data only for a user who **authorized Eno’niva** in this system and whose identity matches (next section).

```js
// Node reference
import crypto from 'node:crypto';
export function verifyEnoniva(secret, rawBody, header, now = Math.floor(Date.now() / 1000)) {
  const p = Object.fromEntries(String(header || '').split(',').map((kv) => kv.trim().split('=')));
  const t = Number(p.t);
  if (!Number.isInteger(t) || Math.abs(now - t) > 300 || !/^[0-9a-f]{64}$/.test(p.v1 || '')) return false;
  const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return crypto.timingSafeEqual(want, Buffer.from(p.v1, 'hex'));
}
```

```python
# Python reference
import hashlib, hmac, time
def verify_enoniva(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
        t, v1 = int(parts["t"]), parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    want = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, v1)
```

## The tool

Input (`arguments`):

```json
{
  "protocol": "enoniva.contacts/1",
  "user": { "phone": "+15551234567", "phone_verified": true, "email": "dana@example.com", "email_verified": true },
  "sequence": 8,
  "since_sequence": 7,
  "page_cursor": "optional, from the previous page of the same call"
}
```

- `user` has a phone (E.164), an email (lowercase), or both; Eno’niva has verified each one it sends
  (`*_verified: true`). Match **only** verified values, and only among users who authorized Eno’niva (and, with an API
  key, only that key's account). Never match a different user. No match → error `unknown_user`.
- Company workspace mode (optional): `user` is `{ "workspace_domain": "acme.com" }`. Support it only if API keys are
  company-scoped and you can verify the key's company owns that domain; otherwise answer `unknown_user`.
- `protocol` other than `enoniva.contacts/1` → error `unsupported_protocol`.
- Require `sequence > since_sequence >= 0` (integers), else `bad_request`.

Output (`structuredContent`):

```json
{
  "sequence": 8,
  "next_page_cursor": "omit on the last page",
  "changes": [
    { "op": "upsert", "external_id": "c-100", "updated_at": "2026-10-11T08:12:00Z",
      "contact": { "full_name": "Jim Halpert", "phones": ["+15705550100"], "emails": ["jim@dunder.example"],
                   "company": "Dunder Mifflin", "title": "Sales", "linkedin_url": "https://www.linkedin.com/in/jim",
                   "city": "Scranton", "country": "US", "tags": ["vip"], "last_interaction_at": "2026-10-09T15:00:00Z",
                   "interaction_counts": { "calls": 4, "messages": 0, "emails": 9, "meetings": 1 },
                   "owner_relationship": "customer", "url": "https://app.example.com/contacts/c-100" } },
    { "op": "delete", "external_id": "c-090", "updated_at": "2026-10-10T17:40:00Z" }
  ]
}
```

Contact fields (all optional except: at least one of `full_name`, `phones`, `emails`). Map from this system's model:

| Field | Rules |
|---|---|
| `full_name` | ≤ 200 chars |
| `phones` | ≤ 20, **E.164 only** (`+` country code; normalize, drop what you can't) |
| `emails` | ≤ 20, lowercase |
| `company`, `title` | ≤ 200 each |
| `linkedin_url` | `https://…linkedin.com/in/…` |
| `city` ≤ 100, `country` | ISO 3166-1 alpha-2 |
| `tags` | ≤ 20 strings ≤ 50 |
| `last_interaction_at` | ISO 8601 |
| `interaction_counts` | totals so far (not increments): `calls`, `messages`, `emails`, `meetings` |
| `owner_relationship` | one of `customer client prospect lead partner vendor supplier investor advisor colleague ex_colleague friend family acquaintance community other` |
| `url` | `https://` deep link to this contact in this system |
| `notes` | Eno’niva ignores notes; **don't send** them |

`upsert` = the **full current record** (not a diff). `external_id` = this system's stable id (string ≤ 200), never
reused for another person. Send no other personal data (no message contents, no special-category data).

## Change tracking with sequence numbers (the important part)

Eno’niva keeps the last sequence it fully ingested, `A`. Each call sends `sequence = A + 1`, `since_sequence = A`. If a
call fails anywhere, Eno’niva retries with **the same numbers**. Your job: return the same window for the same sequence.

1. Keep an append-only change log per user (or per company):
   `eno_contact_changes(id bigserial, owner_id, external_id, op 'upsert'|'delete', changed_at)`. Write a row whenever a
   contact the user can see is created, updated, deleted, or the user gains/loses access to it (a loss of access = a
   `delete` for that user). Use the existing ORM hooks / service layer / DB triggers, whatever fits this codebase.
   (If the system already has reliable `updated_at` + soft-delete tombstones, you may use them with a tiebreaker
   instead, but a log is simpler to get right.)
2. Keep checkpoints: `eno_checkpoints(connector_or_account, owner_id, sequence, log_position)`, unique on the first
   three. On a call:
   - If `checkpoint[sequence]` is missing, insert it as the **current max log id** (in the same transaction as reading
     it). If it exists (a retry), reuse it. Never move a checkpoint.
   - `from = since_sequence == 0 ? 0 : checkpoint[since_sequence]`. If `since_sequence > 0` and that checkpoint is
     missing → error `resync_required` (Eno’niva then asks for a full export).
   - The window is `(from, checkpoint[sequence]]`. Take the **latest log entry per external_id** in it. For `upsert`
     entries, load the contact's current record (if it was deleted meanwhile, send `delete`).
   - `since_sequence == 0` → full export: every contact the user can see as of the checkpoint (only upserts).
   - Delete checkpoints with `sequence < since_sequence` (they're acknowledged and never needed again).
3. Sort deterministically (by log id, or external_id) so pages are stable.

## Paging

- Page size 500–1000 (max 5000). If more remain, return `next_page_cursor` (opaque, ≤ 1000 chars, e.g. base64 of an
  offset or of the last log id). Eno’niva calls again with the same `sequence`/`since_sequence` plus `page_cursor`.
- The cursor must be valid for that fixed window; if it can't be honored any more → error `cursor_expired` (Eno’niva
  restarts the call from page 1).

## Errors

Return a normal result with `isError: true`:

```json
{ "isError": true, "content": [{ "type": "text", "text": "No linked user" }],
  "structuredContent": { "error": { "code": "unknown_user", "message": "No linked user" } } }
```

Codes: `unknown_user`, `not_authorized` (revoked), `resync_required`, `cursor_expired`, `rate_limited` (+ `retry_after`
seconds), `unsupported_protocol`, `bad_request`, `internal`. Use HTTP 401 only for signature / API key failures.

## Security checklist

- Signature verified on the raw body, constant-time, ±5 minutes; API key hashed at rest; signing secret encrypted at rest.
- Never log request bodies (they contain the user's phone/email) or secrets; log connector id + sequence numbers.
- Only users who opted in; only contacts they may see; revocation takes effect immediately.
- Rate-limit the endpoint per API key (Eno’niva calls about once a day per user, plus manual updates every ≥ 10 min).
- The endpoint is https in production and doesn't redirect.

## Tests to write (in this project's test framework)

1. `initialize` → result with `capabilities.tools`; `notifications/initialized` → 202; `tools/list` lists the tool.
2. Bad signature, old timestamp (6 min), modified body, wrong API key → 401.
3. Unverified identity (no `phone_verified`) or a user who didn't opt in → `unknown_user`; another user's phone never
   returns someone else's contacts.
4. `sequence 1, since 0` with more contacts than one page → pages concatenate to every contact exactly once.
5. Repeat `sequence 1, since 0` after changing data → identical result.
6. Update one, delete one, add one → `sequence 2, since 1` returns exactly those three (the deleted one as `delete`).
7. Unknown `since_sequence` → `resync_required`.
8. Output validates against https://enoniva.com/connectors/schema.json (`$defs.output`); phones E.164.

## Smoke test with curl

```bash
URL=http://localhost:8787/mcp; SECRET=ensec_...; KEY=your-api-key
sign() { t=$(date +%s); printf 't=%s,v1=%s' "$t" "$(printf '%s' "$t.$1" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"; }
post() { curl -s -D /tmp/h "$URL" -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -H "authorization: Bearer $KEY" -H 'x-enoniva-connector-id: dc_test' -H "x-enoniva-signature: $(sign "$1")" \
  ${SID:+-H "mcp-session-id: $SID"} -d "$1"; echo; }
post '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
SID=$(grep -i '^mcp-session-id:' /tmp/h | cut -d' ' -f2 | tr -d '\r')
post '{"jsonrpc":"2.0","method":"notifications/initialized"}'
post '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
post '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"eno_get_contact_changes","arguments":{"protocol":"enoniva.contacts/1","user":{"email":"you@example.com","email_verified":true},"sequence":1,"since_sequence":0}}}'
post '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"eno_get_contact_changes","arguments":{"protocol":"enoniva.contacts/1","user":{"email":"you@example.com","email_verified":true},"sequence":2,"since_sequence":1}}}'
```

## Going live

1. Deploy behind https. 2. In this system, turn on "Connect to Eno’niva" for a test user and copy the API key.
3. In Eno’niva: Settings → Data connectors → Add data connector (name, the MCP URL, the API key). Copy the signing secret
it shows into this system's setting. 4. Click **Test connection**, then **Update now**, and check the run log counts.

## Directory service (`eno_connect`)

Register the service at https://enoniva.com/connectors/register.html (a person does this, signed in to Eno’niva). They
get a **service id** (`svc_…`) and **one signing secret** (`ensec_…`) for the whole service. Every call, for every
Eno’niva member, is signed with that secret and carries `X-Enoniva-Connector-Id: svc_…`. No API key is sent.
After Eno’niva approves the listing, members find it under Connectors.

What changes compared to a custom connector:

1. **Settings / secrets**: no per-user toggle or API key. Store the service signing secret encrypted (env var or a
   secrets table); support rotation (accept a new value without a deploy if you can).
2. **A link table**: `eno_links(enoniva_user text primary key, account_id, linked_at, revoked_at)`. `enoniva_user` is
   an opaque id (`enu_` + 32 chars), stable for one Eno’niva member and your service. It is not an email and not an
   Eno’niva database id.
3. **`tools/list`** lists three tools: `eno_get_contact_changes` (as above), `eno_connect` (inputSchema
   `$defs.connect_input`, outputSchema `$defs.connect_output` of schema.json) and optionally `eno_disconnect`
   (`$defs.disconnect_input`).
4. **`eno_connect`** input:

   ```json
   { "protocol": "enoniva.contacts/1", "enoniva_user": "enu_…", "emails": ["dana@example.com"], "phones": ["+15551234567"], "user_name": "Dana Levi" }
   ```

   Every email / phone in it was **verified by Eno’niva**; unverified ones are never sent. `user_name` is display only.
   Answer (as `structuredContent`, plus the same JSON in a text block) exactly one of:

   - `{"status": "not_registered"}`: no account here matches any of the identifiers.
   - `{"status": "consent_required", "consent_url": "https://…"}`: the matching account must approve first. Build a page
     in this product (the user must be signed in here) that shows "Eno’niva wants to import your contacts" with
     Approve / Deny, and stores the pending request (enoniva_user, account, created_at, approved_at). Eno’niva opens the
     URL for the member and calls `eno_connect` again (every ~10 s for a few minutes and on "I've confirmed") with the
     same `enoniva_user`: keep answering `consent_required` until approved, then link and answer `connected`.
   - `{"status": "connected", "account_label": "Dana @ Acme team", "total_contacts": 874}`: you stored
     `enoniva_user → account`. `total_contacts` is what the first full export will return (for the progress bar).

   Rules: verify the signature first (401 otherwise; never act on an unsigned call). Match only the identifiers in the
   call; never link a different account than the one that matched. **Decide whether a match alone is enough**: for
   anything sensitive (team inboxes, customer data, regulated data) answer `consent_required`. Recommended default:
   `consent_required`. Several matching accounts → `consent_required` and let the owner choose. Don't log the
   identifiers in full; log `svc_…`, `enoniva_user` and the result.
5. **`eno_get_contact_changes`** for directory members sends only `"user": {"enoniva_user": "enu_…"}`. Resolve it through
   the link table; unknown or revoked → error `not_authorized` (Eno’niva shows "Not linked" and the member can connect
   again). Sequence numbers, checkpoints and paging are exactly as above, keyed by (`enoniva_user`, sequence).
6. **`eno_disconnect`** (optional) `{protocol, enoniva_user}` → `{"ok": true}`: delete the link (and its checkpoints).
   Also let your users revoke Eno’niva in your settings (then answer `not_authorized`).

Extra tests for the directory flavor:

1. Unsigned / badly signed `eno_connect` → 401 and nothing linked.
2. Identifiers that match nobody → `not_registered`; an identifier that matches one account → `consent_required` (or
   `connected` if you chose match-only), never another account's data.
3. Consent flow: `consent_required` → approve on the consent page → next `eno_connect` → `connected`; deny → stays
   unlinked.
4. After `connected`, `eno_get_contact_changes` with `{enoniva_user}` returns that account's contacts; with an unknown
   `enoniva_user` → `not_authorized`.
5. `eno_disconnect` → later calls with that `enoniva_user` → `not_authorized`.

The reference implementation (connector.js) implements `eno_connect`, the consent flow (in memory) and `eno_disconnect`.
