
Three things make an AI agent aware of your iMessage conversations: a Figment connection with the iMessage service in a ready state, a Figment access token stored in an environment variable, and a local stdio adapter that wraps the Figment REST API and speaks the MCP protocol. This guide builds all three, then gives you a checklist to verify the read path before anything can send.
Custom adapter design: No published Figment MCP server package exists. You author a local adapter using
@modelcontextprotocol/server. The tool names below—list_chats,list_messages,search_messages—are adapter design names; rename them freely in your implementation.
ready; check status at /apple-connect/account before reading any messages.messages:read scope. This single scope covers both conversation history and search. messages:send is a separate permission; start with read-only until the read path is verified.@modelcontextprotocol/server v2.4.0 requires Node ≥ 20.mcpServers configuration format that Claude Desktop and compatible clients share.When Messages in iCloud is enabled, iMessage, SMS, MMS, and RCS history keeps in sync across devices. Your iPhone must be on, online, and signed into the same Apple Account for carrier messages to remain current (Apple support, 102545). Existing carrier-message history already on the Mac remains readable even when the iPhone is offline.
Register your app at /apple-connect/developers. Complete the PKCE authorization flow in a browser or a short CLI script before starting the agent session—the access token is stored as an environment variable, not fetched inside the conversation. Build the authorization URL with a fresh PKCE verifier and S256 challenge for each authorization attempt—a verifier and challenge are generated once per authorization flow, not regenerated for every new agent session that reuses an existing token:
https://figment.so/apple-connect/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=YOUR_CALLBACK_URL_ENCODED
&response_type=code
&scope=messages%3Aread
&state=RANDOM_STATE
&code_challenge=YOUR_CHALLENGE
&code_challenge_method=S256
After the user approves and the redirect arrives, validate the returned state exactly against the value you generated, then exchange the code:
curl -X POST https://figment.so/api/apple-connect/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "client_id=YOUR_CLIENT_ID" \
--data-urlencode "redirect_uri=YOUR_CALLBACK_URL" \
--data-urlencode "code=AUTH_CODE" \
--data-urlencode "code_verifier=YOUR_VERIFIER"
The response includes access_token, refresh_token, connection_id, scope, and expires_in. Access tokens have a short expiry (up to 900 seconds). Refresh tokens rotate: use the refresh token to renew before starting a new session, serialize refresh calls per grant so that concurrent requests do not invalidate each other, and always persist the replacement refresh token returned in the response. The adapter reads two values at startup; a third is stored separately:
FIGMENT_ACCESS_TOKEN and FIGMENT_CONNECTION_ID go into the adapter env (shown below).refresh_token is stored in persistent storage per grant by the app that runs the OAuth flow—not in the adapter env.export FIGMENT_ACCESS_TOKEN=ACCESS_TOKEN
export FIGMENT_CONNECTION_ID=con_...
Your app never receives the Apple password. Manage grants at /apple-connect/account. The developer account is billed $29/month per active live connection; sandbox is free and access starts after setup.
Create the project directory and install dependencies.
mkdir figment-imessage && cd figment-imessage
npm init -y
npm install @modelcontextprotocol/server@2.4.0 zod@4
npm install -D typescript @types/node
mkdir src && touch src/index.ts
Add "type" and "scripts" to package.json:
{
"type": "module",
"scripts": {
"build": "tsc"
}
}
Create tsconfig.json at the project root (matches the official MCP TypeScript quickstart):
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Stdio servers must write all logging to stderr. Writing to stdout corrupts the JSON-RPC message stream; use console.error for every diagnostic message.
src/index.ts:
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const BASE = "https://figment.so/api/apple-connect";
const TOKEN = process.env.FIGMENT_ACCESS_TOKEN ?? "";
const CID = process.env.FIGMENT_CONNECTION_ID ?? "";
if (!TOKEN || !CID) {
console.error(
"Missing required env vars: FIGMENT_ACCESS_TOKEN and FIGMENT_CONNECTION_ID must both be set."
);
process.exit(1);
}
const cidPath = encodeURIComponent(CID);
async function apiFetch(
path: string,
params: Record<string, string>
): Promise<unknown> {
const controller = new AbortController();
const tid = setTimeout(() => controller.abort(), 10_000);
try {
const url = `${BASE}${path}?${new URLSearchParams(params)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status} – ${res.statusText}`);
return await res.json();
} finally {
clearTimeout(tid);
}
}
function createServer() {
const server = new McpServer({ name: "figment-imessage", version: "0.1.0" });
// Adapter design tool names — rename freely
server.registerTool("list_chats", {
description: "List recent iMessage chats on the connected Mac.",
inputSchema: z.object({
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).max(10000).default(0),
}),
}, async ({ limit, offset }) => {
const data = await apiFetch(
`/v1/connections/${cidPath}/chats`,
{ limit: String(limit), offset: String(offset) }
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
});
server.registerTool("list_messages", {
description: "List messages in a chat. chatGuid must come from list_chats.",
inputSchema: z.object({
chatGuid: z.string().describe("Chat GUID from list_chats"),
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).max(10000).default(0),
}),
}, async ({ chatGuid, limit, offset }) => {
const data = await apiFetch(
`/v1/connections/${cidPath}/messages`,
{ chatGuid, limit: String(limit), offset: String(offset) }
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
});
server.registerTool("search_messages", {
description:
"Search locally available message history within a specific chat. chatGuid is required (safe default for selected-chat grants). Results are bounded by what is locally available on the Mac.",
inputSchema: z.object({
query: z.string().describe("Search text"),
chatGuid: z.string().describe("Chat GUID to search within (from list_chats)"),
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).max(10000).default(0),
}),
}, async ({ query, chatGuid, limit, offset }) => {
const data = await apiFetch(
`/v1/connections/${cidPath}/messages/search`,
{ query, chatGuid, limit: String(limit), offset: String(offset) }
);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
});
return server;
}
serveStdio(createServer, {
onerror: (err) => console.error("Adapter error:", err),
});
console.error("figment-imessage adapter running");
Build and verify the binary starts without errors before wiring it to a client:
npm run build
node build/index.js # emits adapter-running line to stderr, then waits
SDK reference: github.com/modelcontextprotocol/typescript-sdk.
Locate the Claude Desktop configuration file:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.json~/.config/Claude/claude_desktop_config.jsonAdd the adapter entry. Replace every placeholder with your actual values before saving:
{
"mcpServers": {
"figment-imessage": {
"command": "node",
"args": ["/absolute/path/to/figment-imessage/build/index.js"],
"env": {
"FIGMENT_ACCESS_TOKEN": "paste-your-access-token-here",
"FIGMENT_CONNECTION_ID": "paste-your-connection-id-here"
}
}
}
}
Treat this file as a secret. It contains a live access token; never commit it to version control. For stronger protection, inject FIGMENT_ACCESS_TOKEN and FIGMENT_CONNECTION_ID via your OS keychain or a secrets manager at the shell level rather than hardcoding them in this file—no specific client feature for that injection is implied here. The official guide to connecting local MCP servers covers the full stdio workflow.
Fully quit and relaunch your client after saving—most MCP clients read configuration only at startup. If the server does not appear, check logs at ~/Library/Logs/Claude/mcp*.log (macOS) or %APPDATA%\Claude\logs\mcp*.log (Windows). Common causes are a JSON syntax error or a relative path in args.
Work through this checklist before adding any send capability.
Before the session
FIGMENT_ACCESS_TOKEN and FIGMENT_CONNECTION_ID are set in the adapter env and the token is fresh.ready. Confirm with:curl -fsS \
-H "Authorization: Bearer $FIGMENT_ACCESS_TOKEN" \
"https://figment.so/api/apple-connect/v1/connections/$FIGMENT_CONNECTION_ID" \
| jq '.connection.services.messages.state'
A non-2xx response means the connection needs attention—check status at /apple-connect/account. A syncing state means the Mac is still building local history; wait and recheck before reading.
Adapter tool smoke tests — start in sandbox
Run these against a sandbox connection_id with synthetic data first. Sandbox is free and avoids touching live account data until the adapter is confirmed working.
list_chats — Confirm a JSON response with a data array. An empty array is a valid result if no chats are locally available. Note a guid from any returned item to use as chatGuid in the next steps.list_messages — Pass a chatGuid from the previous step. Message items include text, isFromMe, and chatGuid. Use hasMore and nextOffset to page through results. The adapter reads only what is locally available on the Mac.search_messages — Run a query you expect to match a recent thread and pass its chatGuid. An empty data array is a valid result if the term does not appear in locally available history. Search is bounded, not a complete index.All three responding without error confirms the read path end-to-end.
Sending requires a separate messages:send scope grant and a conversation already included in that grant. Supply an Idempotency-Key header on every send request—not in the request body. Generate and persist one key per intended action before the first attempt, then reuse it only for polling that same action's status. A failed receipt under the same key is the recorded result, not a retry invitation. An unknown receipt must only be polled; never issue a new key for an action whose outcome is still unknown. In sandbox, the receipt state for a completed send is simulated.
If the connection endpoint returns a non-2xx status, or the iMessage service state is not ready, open /apple-connect/account. The account screen shows the current service state and the action needed to resolve it. Common situations include an incomplete approval step, the Mac being offline or unreachable, and a grant that has been revoked and must be re-issued. Reconnect from the account screen to restore a session that is no longer accepted.