מפרט מחבר הנתונים

פרוטוקול enoniva.contacts/1 · עודכן לאחרונה ב־12 באוקטובר 2026

Eno’niva היא רשת קשרים פרטית. מחבר נתונים מאפשר לחבר (או לסביבת עבודה של חברה) להביא את אנשי הקשר שלו מהמערכת שלך: CRM, כלי לזימון תורים או לניהול קליניקה, מערכת גיוס, או האפליקציה שלך. בונים שרת MCP קטן עם כלי אחד, eno_get_contact_changes. החבר מוסיף את כתובת ה־URL שלו ב־Eno’niva, ו־Eno’niva קוראת לו פעם ביום (וכשלוחצים "עדכון עכשיו") כדי לקבל את אנשי הקשר שנוספו, עודכנו או נמחקו מאז הקריאה האחרונה.

הורדת CLAUDE.md JSON Schema שרת לדוגמה (Node) רישום השירות שלך בספרייה

שתי דרכים להתחבר. מחבר מותאם: חבר מדביק ב־Eno’niva את כתובת ה־MCP שלך (ומפתח API). שירות בספרייה: רושמים את השירות פעם אחת, אנחנו מאשרים אותו, וחברים מוצאים אותו תחת מחברים ולוחצים חיבור; אז Eno’niva קוראת ל־eno_connect עם כתובות האימייל והטלפון המאומתים שלהם (§17). בשתי הדרכים משתמשים באותו כלי, eno_get_contact_changes, לאנשי הקשר.

הדרך המהירה: שמים את CLAUDE.md במאגר הקוד ומבקשים מ־Claude Code "implement the Eno’niva data connector described in CLAUDE.md". השרת לדוגמה (connector.js, כ־100 שורות, בלי תלויות) הוא מה שחבילת הבדיקות של Eno’niva עצמה רצה מולו.

המפרט הטכני המלא, מכאן והלאה, זמין באנגלית בלבד, כדי ששמות השדות, הדוגמאות והקוד נשארים מדויקים.

Contents

  1. How it works
  2. Transport (MCP)
  3. Authentication and consent
  4. The tool
  5. Sequence numbers
  6. Paging and full exports
  7. Deletes
  8. Contact record
  9. Errors
  10. Personal and company connectors
  11. Schedule and rate limits
  12. Security and privacy
  13. Versioning
  14. Signature verification code
  15. Testing checklist
  16. Why it’s designed this way
  17. Directory services: eno_connect

1. How it works

  1. In your product, the user turns on “Connect to Eno’niva”. You give them your MCP URL and, ideally, an API key that identifies their account.
  2. In Eno’niva (Settings → Data connectors, or Workspace → Connectors for companies) they add a connector: a name, your MCP URL and the optional API key. Eno’niva shows a signing secret once. The user pastes it into your product, which stores it.
  3. They click Test connection: Eno’niva calls initialize and tools/list, which must include eno_get_contact_changes. Until a test passes, Eno’niva never calls the tool.
  4. Eno’niva calls the tool with the user’s verified phone and/or email, a sequence number and since_sequence. The first call has since_sequence: 0: send everything (paged). After that you send only what changed.

2. Transport (MCP Streamable HTTP)

Your server is an MCP server over Streamable HTTP (MCP spec 2025-06-18): JSON-RPC 2.0 messages sent with POST to one endpoint, for example https://api.example.com/mcp. Any MCP SDK works (TypeScript, Python, Go, …), or handle the four messages by hand as the reference server does.

3. Authentication and consent

A phone number or email alone doesn’t prove that a request is allowed to read someone’s contacts. Every request carries:

HeaderValue
AuthorizationBearer <API key>, when the user entered one. Recommended: issue a per-user (or per-company) key in your product and map it to the account.
X-Enoniva-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>. The secret is the signing secret shown once in Eno’niva (it starts with ensec_; use the whole string as the key, UTF-8).
X-Enoniva-Connector-IdThe connector’s public id (dc_…), or for a directory service your service id (svc_…, the same on every call). Use it to look up which stored secret to check against.
User-AgentEnoniva-DataConnector/1 (+https://enoniva.com/connectors/spec.html)

You must:

  1. Verify the signature over the exact raw bytes of the body (before JSON parsing), with a constant-time comparison, and reject timestamps more than 5 minutes from your clock. Answer 401 when it fails. (DELETE requests that end a session carry no body and no signature; just close the session.)
  2. Check the API key, if you issued one.
  3. Return data only for a user who authorized Eno’niva in your system (or whose identity they linked there), matched on the verified identity Eno’niva sends and, when you use keys, on the key’s account. Otherwise answer the unknown_user error. Never return contacts of a different user because a phone number or email happens to match.

OAuth. v1 uses an API key plus the signature. If your MCP server requires OAuth (the MCP authorization spec: 401 with WWW-Authenticate: Bearer resource_metadata="…"), Test connection detects it and tells the user that OAuth connectors arrive in v1.1, when Eno’niva will run the standard OAuth 2.1 + PKCE flow with discovery through /.well-known/oauth-protected-resource. Until then, please offer an API key.

4. The tool: eno_get_contact_changes

List it in tools/list with the input and output schemas from schema.json ($defs.input, $defs.output) and annotations.readOnlyHint: true.

Input

{
  "protocol": "enoniva.contacts/1",
  "user": { "phone": "+15551234567", "phone_verified": true,
            "email": "dana@example.com", "email_verified": true },
  "sequence": 8,          // this call: last acknowledged + 1
  "since_sequence": 7,    // send changes made after call 7 (0 = full export)
  "page_cursor": "…"      // only on page 2+ of the same call
}

user contains a phone, an email, or both, and only ones Eno’niva has verified (the *_verified flags are always true). Company connectors in workspace mode send {"workspace_domain": "acme.com"} instead (see §10).

Output (structuredContent)

{
  "sequence": 8,                       // echo
  "next_page_cursor": "eyJvIjo1MDB9",  // omit on the last page
  "changes": [
    { "op": "upsert", "external_id": "c-100", "updated_at": "2026-10-11T08:12:00Z",
      "contact": { "full_name": "Jim Halpert", "emails": ["jim@dunder.example"], "phones": ["+15705550100"],
                   "company": "Dunder Mifflin", "title": "Sales", "owner_relationship": "customer",
                   "last_interaction_at": "2026-10-09T15:00:00Z",
                   "interaction_counts": { "calls": 4, "emails": 9 },
                   "url": "https://crm.example.com/contacts/c-100" } },
    { "op": "delete", "external_id": "c-090", "updated_at": "2026-10-10T17:40:00Z" }
  ]
}

Also put the same JSON in a text content block for older clients (content: [{"type":"text","text":"…"}]); Eno’niva reads structuredContent first.

A full exchange

→ POST /mcp   {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},
               "clientInfo":{"name":"enoniva","title":"Eno’niva","version":"1.0.0"}}}
← 200 Mcp-Session-Id: 4f1c…
  {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},
   "serverInfo":{"name":"acme-crm-eno","version":"1.2.0"}}}

→ POST /mcp   {"jsonrpc":"2.0","method":"notifications/initialized"}
← 202

→ POST /mcp   {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"eno_get_contact_changes",
               "arguments":{"protocol":"enoniva.contacts/1","user":{"phone":"+15551234567","phone_verified":true},
               "sequence":8,"since_sequence":7}}}
← 200 {"jsonrpc":"2.0","id":2,"result":{
        "content":[{"type":"text","text":"{\"sequence\":8,\"changes\":[…]}"}],
        "structuredContent":{"sequence":8,"changes":[…]}}}

→ DELETE /mcp   (Mcp-Session-Id: 4f1c…)

5. Sequence numbers

Eno’niva keeps, per connector and user, the last acknowledged sequence A. Each call sends sequence = A + 1 and since_sequence = A. Eno’niva advances A only after it ingested every page of that call. If anything fails (network, timeout, a bad page, our own outage), the next attempt sends the same two numbers again.

What you implement: a checkpoint per user and sequence.

  1. The first time you see sequence = N for a user, record checkpoint[N] = your current change position (a change-log id, or a timestamp with a tiebreaker). If you already have checkpoint[N] (a retry), reuse it: never move it.
  2. Return the changes in the window (checkpoint[since_sequence], checkpoint[N]], collapsed to the latest change per external_id. Changes made after checkpoint[N] wait for call N + 1.
  3. since_sequence = 0 means “from the beginning”: a full export of contacts that exist at checkpoint[N] (no deletes needed).
  4. You can delete checkpoints older than since_sequence; that number is acknowledged and will never be asked for again. If you no longer have checkpoint[since_sequence] (history pruned, restored backup), answer resync_required and Eno’niva follows with a full export.

A simple schema: contact_changes(user_id, pos bigserial, external_id, op, changed_at) written by your app (or a trigger), plus eno_checkpoints(connector_id, user_id, sequence, pos).

Because the window for a given sequence is fixed on first sight, a retry returns exactly the same data: nothing is lost (a failed call is never acknowledged) and nothing is doubled (Eno’niva applies upserts and deletes idempotently by external_id).

6. Paging and full exports

7. Deletes

Send {"op":"delete","external_id":"…","updated_at":"…"} when a contact is deleted in your system, the user loses access to it, or it no longer belongs to the user. A delete removes only your connector’s contribution: if the member also has that person from their phone, LinkedIn or another connector, the person stays. People who came only from you are deleted, unless the member rated them, pinned them or wrote a note. Deleting an id Eno’niva doesn’t know is fine (no-op).

An upsert always carries the full current record, not a diff. Use stable external_ids and never reuse one for a different person.

8. Contact record

Validation is strict: values that break a rule are dropped, unknown fields are ignored, and a change with no usable contact is skipped (counted as “skipped” in the member’s run log). A contact needs at least a name, a phone or an email.

FieldType and limitsUsed for
full_namestring ≤ 200Name on the card
phones≤ 20, E.164 (+15705550100); others droppedMatching and the member’s private contact details
emails≤ 20, valid addresses ≤ 254Matching and private contact details
company, titlestring ≤ 200 eachCard (fills gaps; never overwrites a member’s own card)
linkedin_urlhttps://…linkedin.com/in/… ≤ 300Matching and card
city, countrycity ≤ 100; country ISO 3166-1 alpha-2 (names accepted)Card, map
tags≤ 20 strings ≤ 50Stored privately for the owner
last_interaction_atISO 8601 (future dates clamp to now)Relationship recency
interaction_counts{calls, messages, emails, meetings}, integers ≥ 0, totals (not increments)Relationship strength (private)
owner_relationshipcustomer, client, prospect, lead, partner, vendor, supplier, investor, advisor, colleague, ex_colleague, friend, family, acquaintance, community, otherSuggested category (only while the member hasn’t set one)
urlhttps:// ≤ 500“Open in <connector>” link on the person page, for the owner only
notesstring ≤ 2,000Ignored in v1 (free text from another system is never imported)

9. Errors

Return a normal tool result with isError: true and structuredContent: {"error": {"code", "message", "retry_after?"}}. Use HTTP 401 only for a bad signature or API key, and JSON-RPC errors only for malformed requests.

CodeWhenWhat Eno’niva does
unknown_userNo user who authorized Eno’niva matchesMarks the member “not linked”, tries again tomorrow; not counted as a failure
not_authorizedThe user revoked access, or the key doesn’t belong to themSame as unknown_user
resync_requiredYou can’t compute changes since since_sequenceNext call (within a minute): since_sequence: 0 full export, then a sweep
cursor_expiredThe page cursor is no longer validRestarts the same call from page 1
rate_limitedSlow down; set retry_after (seconds)Retries after retry_after (default 1 h)
unsupported_protocolUnknown protocol valueCounted as a failure
bad_request, internalAnything elseCounted as a failure, retried with backoff

Failures (also timeouts, 5xx, invalid JSON, a wrong sequence echo) are retried with backoff (15 min, 1 h, 4 h, 12 h, 24 h). After 6 consecutive failures spanning more than a day, Eno’niva pauses the connector and emails its owner. Nothing is deleted when that happens.

10. Personal and company connectors

11. Schedule and rate limits

12. Security and privacy

What Eno’niva sends you: only the protocol fields, the user’s verified phone and/or email (or the workspace domain), the API key you issued and the signature. Never their name, their other contacts, ratings, notes or anything else from Eno’niva.

What Eno’niva does on its side: https only; DNS is resolved and every address checked before connecting (no private, loopback, link-local or reserved addresses, IPv4 and IPv6); no redirects; capped responses; the API key and signing secret are encrypted at rest; each run is logged (counts, duration, error) and visible to the connector’s owner.

What we expect from you:

13. Versioning

The protocol id is enoniva.contacts/1. Within version 1, Eno’niva only adds optional fields (to the input, the output or the contact) and new error codes; ignore what you don’t know. A breaking change gets a new id (enoniva.contacts/2), announced at least 6 months ahead; during the overlap, Eno’niva sends the newest version your server accepts (answer unsupported_protocol to the others). This page’s date changes with every edit.

14. Signature verification code

Node.js

import crypto from 'node:crypto';

// rawBody: the exact request bytes as a string (e.g. express.text({ type: '*/*' }))
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'));
}

app.post('/mcp', express.text({ type: '*/*', limit: '1mb' }), (req, res, next) => {
  const secret = secretFor(req.get('x-enoniva-connector-id'));   // your lookup
  if (!secret || !verifyEnoniva(secret, req.body, req.get('x-enoniva-signature'))) return res.status(401).json({ error: 'bad_signature' });
  req.rpc = JSON.parse(req.body);
  next();
});

Python

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)

# Flask: verify_enoniva(secret, request.get_data(), request.headers.get("X-Enoniva-Signature", ""))
# FastAPI: body = await request.body(); verify_enoniva(secret, body, request.headers.get("x-enoniva-signature", ""))

15. Testing checklist

  1. initialize answers with capabilities.tools; notifications/initialized gets 202; tools/list includes eno_get_contact_changes with both schemas.
  2. A wrong or missing signature, a timestamp 6 minutes old, or a changed body → 401.
  3. An unverified identity (phone_verified missing) or a user who didn’t authorize Eno’niva → unknown_user.
  4. sequence 1, since 0 returns everything, paged; concatenating the pages gives each external_id once.
  5. Calling sequence 1, since 0 again returns the same result, even after you changed data in between.
  6. Change one contact, delete one, add one → sequence 2, since 1 returns exactly those three.
  7. Asking for a since_sequence you don’t have → resync_required.
  8. All phones are E.164, all timestamps ISO 8601, each page ≤ 5,000 changes, each response < 8 MB and < 30 s.
  9. Then add the connector in Eno’niva, paste the secret into your product and click Test connection, then Update now; the run log shows the counts.

curl smoke test

URL=https://api.example.com/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}}}'

16. Why it’s designed this way

17. Directory services: eno_connect

A directory service is listed in Eno’niva’s Connectors menu. Members don’t paste URLs or keys: they click Connect, agree to share their verified details with you, and you tell Eno’niva whether they have an account with you.

Registering

  1. Sign in to Eno’niva and open /connectors/register.html: service name, website, a short description, logo (https PNG/JPEG/WebP/GIF), your MCP endpoint URL, a support email and your privacy policy URL.
  2. You get a service id (svc_…) and one signing secret (ensec_…, shown once) for your whole service. Every call for every member is signed with it and carries X-Enoniva-Connector-Id: svc_…. No API key is sent. You can rotate the secret any time on the same page; the new one is used at once.
  3. Click Test connection: initialize + tools/list must list eno_connect and eno_get_contact_changes (eno_disconnect is optional).
  4. Eno’niva reviews and approves the listing. Until then members don’t see it and Eno’niva never calls it. A suspended listing is hidden and not called; members’ imported data stays.

The connect handshake

→ tools/call eno_connect
  { "protocol": "enoniva.contacts/1",
    "enoniva_user": "enu_Qm9vS2l0dGVuX3Byb2ZpbGVfMDAwMDAx",   // opaque, stable for you, different for every service
    "emails": ["dana@example.com", "dana@acme.com"],           // only addresses Eno’niva verified
    "phones": ["+15551234567"],                                 // only a verified phone, E.164
    "user_name": "Dana Levi" }                                  // display only, never match on it

← { "status": "not_registered" }
← { "status": "consent_required", "consent_url": "https://app.example.com/enoniva/approve/7f3c…" }
← { "status": "connected", "account_label": "Dana @ Acme team", "total_contacts": 874 }

After connecting, every eno_get_contact_changes call for that member sends only "user": {"enoniva_user": "enu_…"}: no phone or email. Answer not_authorized if the account revoked the link on your side (the member sees “Not linked” and can connect again).

Optional eno_disconnect {protocol, enoniva_user} → {"ok": true}: called (if you list it) when the member disconnects your service in Eno’niva. Forget the link.

Rules for eno_connect

Limits

Questions or a connector to list? hello@enoniva.com