feat(mcp): outbox/read client methods + tools + README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 11:35:13 -04:00
parent 1de0ccdfd6
commit 5347a8d7e3
4 changed files with 127 additions and 3 deletions

51
mcp/README.md Normal file
View file

@ -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", "<abs-path>/@mac-sync/mcp/src/index.ts"],
"env": {
"MAC_SYNC_BASE_URL": "http://209.38.51.98:3201",
"MAC_SYNC_TOKEN": "<service-token>"
}
}
}
}
```
Adjust `<abs-path>` 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.

View file

@ -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",

View file

@ -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<unknown> {
@ -151,3 +154,33 @@ export async function outboxStats(deviceId: string): Promise<unknown> {
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<unknown> {
const { data } = await request(`/my/read`, {
method: 'POST',
body: JSON.stringify({ deviceId, handle, readThrough }),
});
return data;
}
export async function markAllRepliedRead(deviceId: string): Promise<unknown> {
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<unknown> {
const { data } = await request(`/my/read/${encodeURIComponent(handle)}${qs({ deviceId })}`);
return data;
}

View file

@ -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<string, unknown>;
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<unknown> {
switch (name) {
@ -187,11 +220,18 @@ async function dispatch(name: string, a: Args): Promise<unknown> {
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}`);
}