gainst local services.
Step 1: Local MCP Server Initialization
The server uses the official @modelcontextprotocol/sdk to register tools. Instead of stdio transport (which requires local process attachment), we configure an HTTP server with SSE support. This allows remote clients to maintain a persistent, stateful connection over the SSH tunnel.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import { MailService } from "./services/mail.js";
import { CalendarService } from "./services/calendar.js";
import { MessageRepository } from "./repositories/messages.js";
const app = express();
const server = new McpServer({ name: "macOSContextBridge", version: "1.0.0" });
// Register service tools
const mailSvc = new MailService();
const calSvc = new CalendarService();
const msgRepo = new MessageRepository();
server.tool("read_recent_emails", "Fetch latest emails from specified mailbox", async () => {
return await mailSvc.getRecentInbox();
});
server.tool("list_upcoming_events", "Retrieve calendar events for the next 7 days", async () => {
return await calSvc.getUpcomingEvents();
});
server.tool("fetch_chat_history", "Query iMessage history by conversation identifier", async (params: { chatId: string; limit: number }) => {
return await msgRepo.getHistory(params.chatId, params.limit);
});
// SSE Transport Setup
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/message", res);
await server.connect(transport);
});
app.post("/message", async (req, res) => {
// Handle incoming MCP requests
res.status(200).end();
});
const PORT = process.env.MCP_PORT || 3100;
app.listen(PORT, "127.0.0.1", () => {
console.log(`MCP SSE bridge listening on localhost:${PORT}`);
});
Step 2: Service Integration Layer
Mail and Calendar operations use compiled AppleScript executed via child_process. This avoids third-party dependencies and maintains compatibility with Apple's scripting bridge. iMessage history bypasses AppleScript entirely. The MessageRepository class queries the local SQLite database directly, extracting structured data from the relational schema.
import Database from "better-sqlite3";
import path from "path";
import os from "os";
export class MessageRepository {
private db: Database.Database;
constructor() {
const dbPath = path.join(os.homedir(), "Library/Messages/chat.db");
this.db = new Database(dbPath, { readonly: true });
this.db.pragma("journal_mode = WAL");
}
getHistory(chatIdentifier: string, limit: number) {
const query = `
SELECT
m.text AS content,
m.is_from_me AS isOutbound,
h.uncanonicalized_id AS sender,
datetime(m.date + 978307200, 'unixepoch') AS timestamp
FROM message AS m
INNER JOIN chat_message_join AS cmj ON cmj.message_id = m.ROWID
INNER JOIN chat AS c ON c.ROWID = cmj.chat_id
INNER JOIN handle AS h ON h.ROWID = m.handle_id
WHERE c.chat_identifier = @targetChat
ORDER BY m.date DESC
LIMIT @rowLimit
`;
const stmt = this.db.prepare(query);
return stmt.all({ targetChat: chatIdentifier, rowLimit: limit });
}
}
Step 3: Secure Transport & Process Supervision
The SSH reverse tunnel maps the remote host's localhost port to the Mac's local MCP port. launchd manages both the Node.js server and the SSH client, enforcing automatic restart policies and session context requirements. This ensures the bridge survives network interruptions, system sleep cycles, and unexpected crashes.
Architecture Rationale:
- SSE over stdio: Remote AI clients cannot attach to local standard streams. SSE provides a persistent, HTTP-compatible channel that works seamlessly over SSH tunnels.
- Direct SQLite Access: iMessage lacks a read API. Querying
chat.db directly eliminates the AppleScript bridge overhead and provides deterministic query performance.
- Localhost Binding: The MCP server binds strictly to
127.0.0.1. The SSH tunnel is the only entry point, eliminating exposure to the local network.
launchd Supervision: Native macOS process management handles session creation, crash recovery, and environment variable injection without external dependencies.
Pitfall Guide
1. macOS Full Disk Access (FDA) Enforcement
Explanation: Direct SQLite reads against ~/Library/Messages/chat.db fail silently if the executing process lacks Full Disk Access. macOS Transparency, Consent, and Control (TCC) blocks database file reads even when POSIX permissions allow them.
Fix: Grant FDA via System Settings > Privacy & Security > Full Disk Access. For automated deployments, use tccutil commands or MDM configuration profiles. Validate permissions on startup by attempting a lightweight PRAGMA query and throwing a clear error if access is denied.
2. WindowServer Session Binding
Explanation: osascript requires an active GUI session and WindowServer context. Running the MCP server under root, via cron, or in a headless SSH session causes AppleScript calls to hang or return empty payloads.
Fix: Execute the bridge under the logged-in user context. Configure launchd with SessionCreate enabled. Verify the launchctl environment includes GUI session variables. Never daemonize AppleScript-dependent services under system-level accounts.
3. SSH Tunnel Heartbeat & Reconnection
Explanation: Reverse tunnels terminate on network changes, sleep cycles, or firewall timeouts. Without keep-alive mechanisms, the AI client loses connectivity silently.
Fix: Configure ~/.ssh/config with ServerAliveInterval 30, ServerAliveCountMax 3, and ExitOnForwardFailure yes. Pair with launchd KeepAlive and ThrottleInterval to ensure automatic reconnection. Implement SSE keep-alive pings in the MCP server to prevent intermediate proxy timeouts.
4. SQLite Schema Volatility
Explanation: Apple modifies the chat.db schema between major macOS releases. Hardcoded ROWID assumptions or table structures break after OS updates.
Fix: Implement schema version detection using PRAGMA user_version;. Wrap database calls in try/catch blocks with fallback strategies. Maintain a versioned query registry that maps macOS versions to compatible SQL statements. Log schema mismatches for automated alerting.
5. Transport Protocol Mismatch
Explanation: Using stdio transport for remote AI clients breaks connectivity. Remote hosts cannot attach to local standard streams, and stdio lacks the persistent connection semantics required for MCP tool discovery.
Fix: Always expose SSE (text/event-stream) for remote MCP clients. Ensure proper Content-Type headers, implement chunked transfer encoding, and configure HTTP keep-alive timeouts. Validate transport compatibility during the MCP initialize handshake.
6. Lateral Movement Risk
Explanation: Exposing local services via SSH tunnel risks lateral movement if the remote AI host is compromised. Attackers could pivot through the tunnel to access local macOS services.
Fix: Bind the MCP server strictly to 127.0.0.1. Use SSH key-only authentication with restricted RemoteForward directives. Implement MCP-level authentication, rate limiting, and tool-specific permission scopes. Never expose the tunnel to 0.0.0.0 or configure GatewayPorts yes.
Explanation: Registering dozens of granular tools increases MCP initialization time and complicates agent routing. Large tool schemas consume context window tokens unnecessarily.
Fix: Group related operations under composite tools with clear parameter schemas. Use MCP annotations to indicate idempotency and destructive potential. Implement dynamic tool loading based on user permissions. Keep schemas minimal and rely on descriptive names rather than verbose descriptions.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single developer testing locally | MCP + SSH Reverse Tunnel | Zero infrastructure setup, preserves local context, encrypted by default | Minimal (SSH bandwidth only) |
| Enterprise multi-user deployment | MCP + SSH Reverse Tunnel + MDM | Centralized FDA management, consistent session handling, auditable access | Low (MDM licensing) |
| High-throughput message analytics | Direct SQLite + Batch Processing | Bypasses MCP overhead, enables bulk extraction, reduces API calls | Medium (Compute for batch jobs) |
| Cross-platform AI integration | Custom REST Wrapper + Cloud Sync | Standardized API, platform-agnostic, easier CI/CD pipeline integration | High (Infrastructure + Dev overhead) |
Configuration Template
SSH Client Configuration (~/.ssh/config)
Host macos-bridge
HostName <REMOTE_AI_HOST>
User <SSH_USER>
IdentityFile ~/.ssh/id_ed25519
RemoteForward 3100 127.0.0.1:3100
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yes
StrictHostKeyChecking yes
launchd Service Definition (com.dev.macos-bridge.plist)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dev.macos-bridge</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/opt/macos-bridge/dist/index.js</string>
</array>
<key>SessionCreate</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>5</integer>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_PORT</key>
<string>3100</string>
<key>LOG_LEVEL</key>
<string>info</string>
</dict>
<key>StandardOutPath</key>
<string>/var/log/macos-bridge.out.log</string>
<key>StandardErrorPath</key>
<string>/var/log/macos-bridge.err.log</string>
</dict>
</plist>
Quick Start Guide
- Install Dependencies: Run
npm install @modelcontextprotocol/sdk express better-sqlite3 in your project directory.
- Grant Permissions: Open System Settings > Privacy & Security > Full Disk Access and add your terminal/Node.js executable.
- Configure SSH: Add the
RemoteForward directive to ~/.ssh/config and test connectivity with ssh macos-bridge.
- Launch Service: Load the
launchd plist using launchctl load ~/Library/LaunchAgents/com.dev.macos-bridge.plist.
- Connect AI Client: Point your remote MCP client to
http://localhost:3100/sse and verify tool discovery via the initialization handshake.