feat(mcp): outbox/read client methods + tools + README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1de0ccdfd6
commit
5347a8d7e3
4 changed files with 127 additions and 3 deletions
51
mcp/README.md
Normal file
51
mcp/README.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue