// Eno’niva data connector: reference implementation of the protocol enoniva.contacts/1 (no dependencies). // createConnector() returns handle(rawBody, headers) -> {status, headers, body}; server.js mounts it on Express. // In-memory users, contacts and change log. Replace the store with your database. Spec: https://enoniva.com/connectors/spec.html // Directory services (listed in Eno’niva's Connectors menu) also implement eno_connect (link a member by verified // emails / phones, or ask for consent) and optionally eno_disconnect; later eno_get_contact_changes calls carry only // user.enoniva_user, the opaque id linked in eno_connect. import crypto from 'node:crypto'; export const PROTOCOL = 'enoniva.contacts/1'; export const TOOL = 'eno_get_contact_changes'; export const CONNECT_TOOL = 'eno_connect'; export const DISCONNECT_TOOL = 'eno_disconnect'; const PAGE_SIZE = 500; const inputSchema = { type: 'object', additionalProperties: false, required: ['protocol', 'user', 'sequence', 'since_sequence'], properties: { protocol: { const: PROTOCOL }, user: { type: 'object', properties: { phone: { type: 'string' }, email: { type: 'string' }, phone_verified: { type: 'boolean' }, email_verified: { type: 'boolean' }, workspace_domain: { type: 'string' }, enoniva_user: { type: 'string' } } }, sequence: { type: 'integer', minimum: 1 }, since_sequence: { type: 'integer', minimum: 0 }, page_cursor: { type: 'string' }, }, }; const outputSchema = { type: 'object', required: ['sequence', 'changes'], properties: { sequence: { type: 'integer' }, next_page_cursor: { type: 'string' }, changes: { type: 'array' } } }; const connectInput = { type: 'object', additionalProperties: false, required: ['protocol', 'enoniva_user', 'emails', 'phones'], properties: { protocol: { const: PROTOCOL }, enoniva_user: { type: 'string' }, emails: { type: 'array', items: { type: 'string' } }, phones: { type: 'array', items: { type: 'string' } }, user_name: { type: 'string' } }, }; const connectOutput = { type: 'object', required: ['status'], properties: { status: { enum: ['not_registered', 'consent_required', 'connected'] }, consent_url: { type: 'string' }, account_label: { type: 'string' }, total_contacts: { type: 'integer' } } }; const disconnectInput = { type: 'object', required: ['protocol', 'enoniva_user'], properties: { protocol: { const: PROTOCOL }, enoniva_user: { type: 'string' } } }; /** X-Enoniva-Signature: t=,v1=, timestamp within ±5 minutes. */ export function verifySignature(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')); } export function createStore() { // log: {pos, userId, op, external_id, updated_at, contact}; links: enoniva_user -> user id (eno_connect); // consents: token -> {enoniva_user, userId, approved} (the page at consent_url sets approved after the user says yes) const s = { users: [], log: [], pos: 0, checkpoints: new Map(), links: new Map(), consents: new Map() }; s.addUser = (u) => { s.users.push({ authorized: true, phones: [], emails: [], requireConsent: false, ...u }); }; s.upsert = (userId, external_id, contact) => s.log.push({ pos: ++s.pos, userId, op: 'upsert', external_id, updated_at: new Date().toISOString(), contact }); s.remove = (userId, external_id) => s.log.push({ pos: ++s.pos, userId, op: 'delete', external_id, updated_at: new Date().toISOString() }); // only VERIFIED identities may match, and only users who authorized Eno’niva in this system s.findUser = (u = {}) => { if (u.enoniva_user) { const id = s.links.get(u.enoniva_user); return s.users.find((x) => x.id === id); } // the link is the authorization return s.users.find((x) => x.authorized && ((u.phone && u.phone_verified === true && x.phones.includes(u.phone)) || (u.email && u.email_verified === true && x.emails.includes(String(u.email).toLowerCase())))); }; // eno_connect: the identifiers are verified by Eno’niva. Match any of them; your product may require more (consent). s.matchConnect = (emails = [], phones = []) => s.users.find((x) => emails.some((e) => x.emails.includes(String(e).toLowerCase())) || phones.some((p) => x.phones.includes(p))); s.approveConsent = (token) => { const c = s.consents.get(token); if (c) c.approved = true; return !!c; }; s.contactCount = (userId) => { const live = new Map(); for (const e of s.log) if (e.userId === userId) live.set(e.external_id, e.op); return [...live.values()].filter((op) => op === 'upsert').length; }; return s; } export function createConnector({ getSecret, apiKey = null, store = createStore(), hooks = {}, consentBase = 'https://app.example.com/enoniva/consent' }) { const sessions = new Set(); const reply = (status, body, headers = {}) => ({ status, headers: { 'content-type': 'application/json', ...headers }, body: body == null ? '' : JSON.stringify(body) }); const ok = (id, result, headers) => reply(200, { jsonrpc: '2.0', id, result }, headers); const rpcErr = (id, code, message) => reply(200, { jsonrpc: '2.0', id, error: { code, message } }); const toolError = (id, code, message, extra = {}) => ok(id, { isError: true, content: [{ type: 'text', text: message }], structuredContent: { error: { code, message, ...extra } } }); function changes(user, sequence, since, cursor) { const cps = store.checkpoints.get(user.id) || new Map(); store.checkpoints.set(user.id, cps); if (since !== 0 && !cps.has(since)) return { error: ['resync_required', 'Unknown since_sequence; send since_sequence 0 for a full export.'] }; // checkpoint for this sequence: fixed on the first attempt, so a retry returns exactly the same window if (!cps.has(sequence)) { if ([...cps.keys()].some((k) => k > sequence)) return { error: ['bad_request', 'sequence went backwards'] }; cps.set(sequence, store.pos); } for (const k of cps.keys()) if (k < since) cps.delete(k); // older checkpoints are never needed again const from = since === 0 ? 0 : cps.get(since), to = cps.get(sequence); const latest = new Map(); // last change per external_id inside (from, to] for (const e of store.log) if (e.userId === user.id && e.pos > from && e.pos <= to) latest.set(e.external_id, e); let list = [...latest.values()].sort((a, b) => a.pos - b.pos); if (since === 0) list = list.filter((e) => e.op === 'upsert'); // a full export lists what exists now const offset = cursor ? Number(Buffer.from(cursor, 'base64url').toString()) || 0 : 0; const page = list.slice(offset, offset + PAGE_SIZE); const next = offset + PAGE_SIZE < list.length ? Buffer.from(String(offset + PAGE_SIZE)).toString('base64url') : undefined; return { result: { sequence, ...(next ? { next_page_cursor: next } : {}), changes: page.map((e) => ({ op: e.op, external_id: e.external_id, updated_at: e.updated_at, ...(e.op === 'upsert' ? { contact: e.contact } : {}) })) } }; } // eno_connect: not_registered | consent_required (+consent_url) | connected (+account_label, total_contacts) function connect(a) { const user = store.matchConnect(Array.isArray(a.emails) ? a.emails : [], Array.isArray(a.phones) ? a.phones : []); if (!user) return { status: 'not_registered' }; if (store.links.get(a.enoniva_user) === user.id) return connected(user); if (user.requireConsent || !user.authorized) { // reuse the pending request for this enoniva_user; link only after the user approved it in your product let [token, c] = [...store.consents.entries()].find(([, x]) => x.enoniva_user === a.enoniva_user && x.userId === user.id) || []; if (!token) { token = crypto.randomBytes(16).toString('base64url'); c = { enoniva_user: a.enoniva_user, userId: user.id, approved: false }; store.consents.set(token, c); } if (!c.approved) return { status: 'consent_required', consent_url: `${consentBase}/${token}` }; store.consents.delete(token); } store.links.set(a.enoniva_user, user.id); return connected(user); } const connected = (user) => ({ status: 'connected', account_label: user.label || user.emails[0] || user.phones[0] || user.id, total_contacts: store.contactCount(user.id) }); const structured = (id, result) => ok(id, { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }); return { store, async handle(rawBody, headers = {}) { const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v])); if (apiKey && h.authorization !== `Bearer ${apiKey}`) return reply(401, { error: 'unauthorized' }); if (!verifySignature(await getSecret(h['x-enoniva-connector-id']), rawBody, h['x-enoniva-signature'])) return reply(401, { error: 'bad_signature' }); let msg; try { msg = JSON.parse(rawBody); } catch { return reply(400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); } hooks.onMessage?.(msg, h); if (msg.id === undefined) return reply(202, null); // notification (notifications/initialized) if (msg.method === 'initialize') { const sid = crypto.randomUUID(); sessions.add(sid); return ok(msg.id, { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'example-data-connector', version: '1.0.0' } }, { 'mcp-session-id': sid }); } if (!sessions.has(h['mcp-session-id'])) return reply(404, { error: 'unknown session' }); if (msg.method === 'tools/list') { return ok(msg.id, { tools: [ { name: TOOL, description: 'Contacts added, updated or deleted since since_sequence (Eno’niva data connector, enoniva.contacts/1).', inputSchema, outputSchema, annotations: { readOnlyHint: true } }, { name: CONNECT_TOOL, description: 'Link an Eno’niva member to their account here by verified emails / phones (Eno’niva connector directory).', inputSchema: connectInput, outputSchema: connectOutput }, { name: DISCONNECT_TOOL, description: 'The member disconnected this service in Eno’niva: forget the link.', inputSchema: disconnectInput }, ] }); } if (msg.method !== 'tools/call') return rpcErr(msg.id, -32601, 'Method not found'); if (![TOOL, CONNECT_TOOL, DISCONNECT_TOOL].includes(msg.params?.name)) return rpcErr(msg.id, -32602, 'Unknown tool'); const a = msg.params.arguments || {}; if (a.protocol !== PROTOCOL) return toolError(msg.id, 'unsupported_protocol', `Only ${PROTOCOL} is supported.`); if (msg.params.name !== TOOL && (typeof a.enoniva_user !== 'string' || !a.enoniva_user)) return toolError(msg.id, 'bad_request', 'enoniva_user is required.'); if (msg.params.name === CONNECT_TOOL) { const f = hooks.beforeConnect?.(a); return f ? toolError(msg.id, f.code, f.message) : structured(msg.id, connect(a)); } if (msg.params.name === DISCONNECT_TOOL) { store.links.delete(a.enoniva_user); return structured(msg.id, { ok: true }); } if (!Number.isInteger(a.sequence) || !Number.isInteger(a.since_sequence) || a.sequence <= a.since_sequence || a.since_sequence < 0) return toolError(msg.id, 'bad_request', 'Bad sequence numbers.'); const user = store.findUser(a.user); if (!user) return toolError(msg.id, 'unknown_user', 'No user here has linked Eno’niva with that verified phone or email.'); const fail = hooks.beforeChanges?.(a); if (fail) return toolError(msg.id, fail.code, fail.message); const r = changes(user, a.sequence, a.since_sequence, a.page_cursor); if (r.error) return toolError(msg.id, r.error[0], r.error[1]); return structured(msg.id, r.result); }, }; }