Use Claude to control your iMessages

Claude controlling iMessage
Claude controlling iMessage (I scrubbed phone numbers and replaced with names for privacy)
The result
The result

Model Context Protocol with iMessage

I wrote an MCP server to let Claude control my iMessages and I'll walk through the process in this post for anyone who wants to build own of their own.

Short explanation of MCP

Instead of fiddling with countless custom integrations, MCP provides a single, open standard that AI systems can speak. In short:

  • MCP servers expose data sources (like iMessage) in a consistent format.
  • AI assistants connect to these servers to retrieve and update data.

Setting Up Our iMessage MCP

We can use Anthropic's typescript MCP starter as a base.

Here is the overall structure of my MCP server, which we will implement gradually:

imessage-mcp
├── src
│   ├── index.ts         # Our main MCP server
│   ├── mac.ts           # AppleScript utilities
│   ├── db.ts            # Drizzle + better-sqlite3
│   ├── imessage.ts      # Reading and sending iMessages
│   └── schemas
│       ├── imessage.ts  # Drizzle-ORM models for iMessage DB
│       └── types.ts
├── package.json
└── tsconfig.json

Our MCP Entry Point: index.ts

Our index.ts file implements the main server. It:

  • Creates a new MCP server with the name "imessage".
  • Defines which “tools” or capabilities it provides:
    • get-recent-chat-messages
    • send-imessage
  • Wires everything up so that requests from the AI get routed to the appropriate function.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
 
import { getRecentChatMessages, sendIMessage } from "./imessage.js";
 
// Create server instance
const server = new Server(
  {
    name: "imessage",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
 
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get-recent-chat-messages",
        description: "Retrieve recent iMessage chat messages",
        inputSchema: {
          type: "object",
          properties: {
            phoneNumber: { type: "string", description: "Person's phone number" },
            limit: { type: "number", description: "Number of messages to fetch" },
          },
          required: ["phoneNumber", "limit"],
        },
      },
      {
        name: "send-imessage",
        description: "Send an iMessage to a phone number",
        inputSchema: {
          type: "object",
          properties: {
            phoneNumber: { type: "string", description: "Recipient's phone number" },
            message: { type: "string", description: "Message content" },
          },
          required: ["phoneNumber", "message"],
        },
      },
    ],
  };
});
 
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
 
  switch (name) {
    case "get-recent-chat-messages": {
      const { phoneNumber, limit } = args as { phoneNumber: string; limit: number };
      const messages = await getRecentChatMessages(phoneNumber, limit);
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(messages),
          },
        ],
      };
    }
 
    case "send-imessage": {
      const { phoneNumber, message } = args as {
        phoneNumber: string;
        message: string;
      };
      await sendIMessage(phoneNumber, message);
      return {
        content: [
          {
            type: "text",
            text: "Message sent successfully",
          },
        ],
      };
    }
 
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
});
 
// Start the server
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}
 
main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

This is pretty standard setting for any MCP server. Next we need to implement the tools. We'll break this part down into two sections that require distinct techniques:

  1. iMessage sending
  2. iMessage reading

Implementing iMessage Sending

One of the few ways I've found to send iMessages programmatically is to use AppleScript. So for this implementation, we'll need to write an Applescript script and run it in a terminal shell.

Let's set up a utility to run AppleScript:

// mac.ts
import { promisify } from "node:util";
import { execFile } from "node:child_process";
 
const execFileAsync = promisify(execFile);
 
export async function runAppleScript({
  script,
  humanReadableOutput = true,
}: {
  script: string;
  humanReadableOutput?: boolean;
}): Promise<string> {
  const outputArgs = humanReadableOutput ? [] : ["-ss"];
  const { stdout } = await execFileAsync("osascript", ["-e", script, ...outputArgs]);
  return stdout.trim();
}

Then next we'll write a function specifically for sending iMessages with AppleScript:

export async function sendIMessage(phoneNumber: string, content: string) {
  await runAppleScript({
    script: `
      tell application "Messages"
          set targetService to 1st service whose service type = iMessage
          set targetBuddy to buddy "${phoneNumber}" of targetService
          send "${content.replace(/"/g, '\\"')}" to targetBuddy
      end tell
    `,
  });
}

I'm not an AppleScript expert, but I pieced together this AppleScript by looking at StackOverflow posts. It seems Apple somewhat abandoned the scripting language, so I wouldn't invest too much time learning it.

But that's it for the iMessage sending side! It requires a little bit of finessing since there's no first-party API, but it's still generally easy to set up.

Implementing iMessage Reading

Reading iMessage data is trickier than sending because it requires a few hacks and knowledge of Apple's internals.

Apple stores MacOS iMessage data in a local SQLite database, often found in ~/Library/Messages/chat.db. The three main caveats here are:

  1. The sql file has some OS level protections that need to be disabled. For this to work, you'll need to go to System Preferences > Security & Privacy > "Allow applications to access all user files" > Enable for any applications running the code we write. (Do so at your own risk!)
  2. Ever since MacOS Ventura, message content is encoded, so we need to implement some decoding logic to get the actual message text.
  3. The sqlite integers stored in chat.db are larger than the max JavaScript integer, so we need to be careful when reading the data.

Modeling the iMessage Database with Drizzle (optional)

I used drizzle-orm to model the sqlite database, but it's not required. I typically use Prisma ORM, but due to the sqlite integer size issue mentioned above, Prisma breaks when reading large integers from the database, and to my knowledge there's no easy fix.

Anyhow, here are my Drizzle models:

// db.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
 
// Create or open a local SQLite DB file
const sqlite = new Database(process.env.DATABASE_URL);
 
// Drizzle ORM connection
export const db = drizzle(sqlite);
 
// schemas/imessage.ts
import {
  sqliteTable,
  text,
  integer,
  primaryKey,
  blob,
} from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
 
export const chat = sqliteTable("Chat", {
  ROWID: integer("ROWID").primaryKey(),
  guid: text("guid").notNull(),
  chatIdentifier: text("chat_identifier").notNull(),
  displayName: text("display_name"),
  lastReadMessageTimestamp: blob("last_read_message_timestamp"),
});
 
export const handle = sqliteTable("Handle", {
  ROWID: integer("ROWID").primaryKey(),
  id: text("id").notNull(),
});
 
export const message = sqliteTable("Message", {
  ROWID: integer("ROWID").primaryKey(),
  guid: text("guid").notNull(),
  text: text("text"),
  handleId: integer("handle_id").references(() => handle.ROWID),
  date: integer("date").notNull(),
  attributedBody: blob("attributedBody"),
  isFromMe: integer("is_from_me"),
});
 
export const chatMessageJoin = sqliteTable(
  "chat_message_join",
  {
    chatId: integer("chat_id").references(() => chat.ROWID),
    messageId: integer("message_id").references(() => message.ROWID),
  },
  (table) => ({
    pk: primaryKey(table.chatId, table.messageId),
  })
);

Querying the iMessage Database

Now we can write a function that queries the iMessage database using Drizzle:

// imessage.ts
export async function getRecentChatMessages(phoneNumber: string, limit: number) {
  const chatResult = await db
    .select()
    .from(chat)
    .innerJoin(chatMessageJoin, eq(chatMessageJoin.chatId, chat.ROWID))
    .innerJoin(message, eq(chatMessageJoin.messageId, message.ROWID))
    .where(eq(chat.chatIdentifier, phoneNumber))
    .orderBy(desc(message.date))
    .limit(limit);
 
  // Reverse to ascending order
  chatResult.reverse();
 
  return chatResult.map(({ Message }) => ({
    id: Message.ROWID,
    text: getContentFromIMessage(Message),
    isFromMe: Message.isFromMe === 1,
    timestamp: Message.date,
  }));
}

Notice the call to getContentFromIMessage below. This is where we need to decode the message content. Prior to MacOS Ventura, the content was stored in plain text on the text field, but with Ventura, the content is now encoded in the attributedBody field.

export function getContentFromIMessage(
  msg: InferSelectModel<typeof message>
): string | null {
  if (msg.text !== null) {
    return msg.text;
  }
 
  const attributedBody = msg.attributedBody as unknown as Buffer | null;
 
  if (!attributedBody) {
    return null;
  }
 
  return _parseAttributedBody(msg.attributedBody as unknown as Buffer);
}
 
function _parseAttributedBody(attributedBody: Buffer): string {
  const nsStringIndex = attributedBody.indexOf(`NSString`);
  if (nsStringIndex === -1) {
    throw new Error(`NSString not found in attributedBody`);
  }
 
  const content = attributedBody.subarray(
    nsStringIndex + `NSString`.length + 5
  );
  let length: number;
  let start: number;
 
  if (content[0] === 0x81) {
    length = content.readUInt16LE(1);
    start = 3;
  } else {
    length = content[0];
    start = 1;
  }
 
  return content.subarray(start, start + length).toString(`utf-8`);
}

Load the MCP server into Claude

And now our iMessage MCP server is ready! The last thing to do is register it with Claude.

On your Mac, there's a file ~/Library/Application Support/Claude/claude_desktop_config.json. Open it (or create it if it doesn't exist) and add the following entry:

  "mcpServers": {
        "imessage": {
            "command": "node",
            "args": [
                "PATH_TO_YOUR_PROJECT/build/index.js"
            ]
        }
    }

Now the next time you open Claude, it should have access to your iMessage MCP server!

To view the full source code, check it out on GitHub. And feel free to connect with me on Twitter if you want to chat.