diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..fcaa7e2 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,51 @@ +# mac-sync MCP (`@lilith/mac-sync-mcp`) + +A stdio MCP server that exposes mac-sync's own `/my/*` surface as tools. mac-sync +owns the data (iMessage, contacts, prospects, calls, outbox in Postgres), so this +talks to the mac-sync server **directly** — no quinn-api hop. It supersedes the +mac-sync calls that previously lived in `quinn-m-mcp`. + +## Env + +| var | default | notes | +|-----|---------|-------| +| `MAC_SYNC_BASE_URL` | `http://209.38.51.98:3201` | mac-sync server (DO backend droplet; override to wg `http://10.9.0.5:3201`) | +| `MAC_SYNC_TOKEN` | — | the server's shared **service token** (`/my/*` is `serviceTokenAuth`) | + +## Tools + +- **Messages** — `search_messages` (hybrid/lexical/semantic), `list_conversations`, `get_thread` +- **Contacts** — `list_contacts`, `search_contacts_by_name` +- **Prospects** (Handoff 01) — `list_prospects`, `get_prospect` +- **Calls** (Handoff 02) — `recent_calls` (`since` window; answers "did this lead also call?") +- **Outbox** (Handoff 03) — `enqueue_reply` (paced, channel-smart, scheduled; `markReadOnSend` option), `outbox_status`, `outbox_stats` +- **Read-state** (Handoff 04) — `mark_conversation_read`, `mark_all_replied_read`, `conversation_read_state` (logical-read; never touches Apple's chat.db) + +## Run + +```sh +bun install +MAC_SYNC_TOKEN=... bun run src/index.ts # stdio +bun run typecheck +``` + +## Register with a client (`.mcp.json`) + +```json +{ + "mcpServers": { + "mac-sync": { + "type": "stdio", + "command": "bun", + "args": ["run", "/@mac-sync/mcp/src/index.ts"], + "env": { + "MAC_SYNC_BASE_URL": "http://209.38.51.98:3201", + "MAC_SYNC_TOKEN": "" + } + } + } +} +``` + +Adjust `` to the host (the repo's `.mcp.json` uses `/var/home/lilith/...` +on the deploy host). The token is a secret — do not commit it. diff --git a/mcp/package.json b/mcp/package.json index 6531398..432cb50 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@lilith/mac-sync-mcp", - "version": "0.1.0", + "version": "0.2.0", # Bumped for calls/phone support (Handoff 02 integration: recent_calls tool via /my/calls) "private": true, "type": "module", "main": "./src/index.ts", diff --git a/mcp/src/client.ts b/mcp/src/client.ts index 77a6a6c..4a2bc9c 100644 --- a/mcp/src/client.ts +++ b/mcp/src/client.ts @@ -7,7 +7,9 @@ * authenticate with one bearer token, not per-user SSO. */ -const BASE_URL = process.env['MAC_SYNC_BASE_URL'] ?? 'http://apricot.lan:3201'; +// DO backend droplet (lilith-store-backend) — replaces dead homelan `black`. +// Override MAC_SYNC_BASE_URL to use the wg mesh IP (http://10.9.0.5:3201). +const BASE_URL = process.env['MAC_SYNC_BASE_URL'] ?? 'http://209.38.51.98:3201'; const TOKEN = process.env['MAC_SYNC_TOKEN'] ?? ''; interface Json { @@ -135,6 +137,7 @@ export interface EnqueueReplyInput { recvAt?: number; notBefore?: string; dedupeKey?: string; + markReadOnSend?: boolean; } export async function enqueueReply(input: EnqueueReplyInput): Promise { @@ -151,3 +154,33 @@ export async function outboxStats(deviceId: string): Promise { const { data } = await request(`/my/outbox/stats${qs({ deviceId })}`); return data; } + +// --------------------------------------------------------------------------- +// Read-state (Handoff 04) — logical "mark conversation read" (never touches +// Apple's chat.db; macsync tracks handled/read in its own store). +// --------------------------------------------------------------------------- + +export async function markConversationRead( + deviceId: string, + handle: string, + readThrough?: string, +): Promise { + const { data } = await request(`/my/read`, { + method: 'POST', + body: JSON.stringify({ deviceId, handle, readThrough }), + }); + return data; +} + +export async function markAllRepliedRead(deviceId: string): Promise { + const { data } = await request(`/my/read/all-replied`, { + method: 'POST', + body: JSON.stringify({ deviceId }), + }); + return data; +} + +export async function conversationReadState(deviceId: string, handle: string): Promise { + const { data } = await request(`/my/read/${encodeURIComponent(handle)}${qs({ deviceId })}`); + return data; +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index bb790b5..723c152 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -6,7 +6,8 @@ * calls (H02), and the outbox (H03) — as MCP tools, talking to the mac-sync * server directly (no quinn-api hop; mac-sync owns this data in Postgres). * - * Env: MAC_SYNC_BASE_URL (default http://apricot.lan:3201), MAC_SYNC_TOKEN + * Env: MAC_SYNC_BASE_URL (default http://209.38.51.98:3201 — the DO backend + * droplet; override to the wg mesh IP http://10.9.0.5:3201), MAC_SYNC_TOKEN * (the server's shared service token). */ @@ -26,10 +27,14 @@ import { enqueueReply, outboxStatus, outboxStats, + markConversationRead, + markAllRepliedRead, + conversationReadState, } from './client'; const str = { type: 'string' as const }; const num = { type: 'number' as const }; +const bool = { type: 'boolean' as const }; const TOOLS = [ { @@ -128,6 +133,7 @@ const TOOLS = [ recvAt: num, notBefore: str, dedupeKey: str, + markReadOnSend: bool, }, required: ['deviceId', 'recipient', 'body'], }, @@ -142,11 +148,38 @@ const TOOLS = [ description: 'Outbox counts by status for a device (queued/sending/sent/failed).', inputSchema: { type: 'object' as const, properties: { deviceId: str }, required: ['deviceId'] }, }, + { + name: 'mark_conversation_read', + description: + "Mark a lead's thread read (logical — macsync's own store, never Apple's chat.db). A later inbound re-surfaces it as unread. Idempotent. Pass `readThrough` (ISO) to mark read only up to an instant; defaults to now.", + inputSchema: { + type: 'object' as const, + properties: { deviceId: str, handle: str, readThrough: str }, + required: ['deviceId', 'handle'], + }, + }, + { + name: 'mark_all_replied_read', + description: + 'Mark read every thread whose latest message is outbound (Quinn has replied) — clears handled leads from the inbox in one call. Returns the handles affected. Idempotent.', + inputSchema: { type: 'object' as const, properties: { deviceId: str }, required: ['deviceId'] }, + }, + { + name: 'conversation_read_state', + description: + "A handle's read-state: read_through, last inbound time, and whether it's currently unread. Use to check if a lead still needs handling.", + inputSchema: { + type: 'object' as const, + properties: { deviceId: str, handle: str }, + required: ['deviceId', 'handle'], + }, + }, ]; type Args = Record; const s = (a: Args, k: string): string | undefined => (typeof a[k] === 'string' ? (a[k] as string) : undefined); const n = (a: Args, k: string): number | undefined => (typeof a[k] === 'number' ? (a[k] as number) : undefined); +const b = (a: Args, k: string): boolean | undefined => (typeof a[k] === 'boolean' ? (a[k] as boolean) : undefined); async function dispatch(name: string, a: Args): Promise { switch (name) { @@ -187,11 +220,18 @@ async function dispatch(name: string, a: Args): Promise { recvAt: n(a, 'recvAt'), notBefore: s(a, 'notBefore'), dedupeKey: s(a, 'dedupeKey'), + markReadOnSend: b(a, 'markReadOnSend'), }); case 'outbox_status': return outboxStatus(String(a['id'])); case 'outbox_stats': return outboxStats(String(a['deviceId'])); + case 'mark_conversation_read': + return markConversationRead(String(a['deviceId']), String(a['handle']), s(a, 'readThrough')); + case 'mark_all_replied_read': + return markAllRepliedRead(String(a['deviceId'])); + case 'conversation_read_state': + return conversationReadState(String(a['deviceId']), String(a['handle'])); default: throw new Error(`unknown tool: ${name}`); }