# Connect an MCP Client to Your iMessage Account

Canonical: https://figment.so/blog/connect-imessage-mcp-client

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.

## What you need

- **A Figment Apple Account connection with iMessage approved.** The iMessage service state must be `ready`; check status at [/apple-connect/account](https://figment.so/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.
- **Node.js 20 or higher.** `@modelcontextprotocol/server` v2.4.0 requires Node ≥ 20.
- **An MCP-compatible client.** The examples use the `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](https://support.apple.com/en-us/102545)). Existing carrier-message history already on the Mac remains readable even when the iPhone is offline.

## Get a Figment access token

Register your app at [/apple-connect/developers](https://figment.so/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:

```bash
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.

```bash
export FIGMENT_ACCESS_TOKEN=ACCESS_TOKEN
export FIGMENT_CONNECTION_ID=con_...
```

Your app never receives the Apple password. Manage grants at [/apple-connect/account](https://figment.so/apple-connect/account). The developer account is billed **$29/month** per active live connection; sandbox is free and access starts after setup.

## Build the local adapter

Create the project directory and install dependencies.

```bash
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`:

```json
{
  "type": "module",
  "scripts": {
    "build": "tsc"
  }
}
```

Create `tsconfig.json` at the project root (matches the [official MCP TypeScript quickstart](https://modelcontextprotocol.io/quickstart/server)):

```json
{
  "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`:

```typescript
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:

```bash
npm run build
node build/index.js   # emits adapter-running line to stderr, then waits
```

SDK reference: [github.com/modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk).

## Configure your MCP client

Locate the Claude Desktop configuration file:

- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux:** `~/.config/Claude/claude_desktop_config.json`

Add the adapter entry. Replace every placeholder with your actual values before saving:

```json
{
  "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](https://modelcontextprotocol.io/docs/develop/connect-local-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`.

## Read-only first-tool checklist

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.
- [ ] iMessage service state is `ready`. Confirm with:

```bash
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](https://figment.so/apple-connect/account). A `syncing` state means the Mac is still building local history; wait and recheck before reading.

- [ ] MCP client fully quit and relaunched after saving the config.

**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.

## Adding send capability

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`.

## When the connection stalls

If the connection endpoint returns a non-2xx status, or the iMessage service state is not `ready`, open [/apple-connect/account](https://figment.so/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.

---

[**Connect Apple Account →**](https://figment.so/apple-connect/developers)
