imessage-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.3
2
+ Name: imessage-mcp
3
+ Version: 0.1.0
4
+ Summary: An MCP server exposing your local iMessage history (macOS, read-only).
5
+ Keywords: mcp,imessage,macos,claude,llm
6
+ Author: moritzhwnr
7
+ Author-email: moritzhwnr <moritz.hawener@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: MacOS :: MacOS X
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Communications :: Chat
14
+ Requires-Dist: mcp[cli]>=1.27.1
15
+ Requires-Dist: rich>=14.0.0
16
+ Requires-Dist: typer>=0.25.1
17
+ Requires-Dist: uvicorn>=0.47.0
18
+ Requires-Dist: starlette>=1.0.0
19
+ Requires-Python: >=3.13
20
+ Project-URL: Homepage, https://github.com/yourname/imessage-mcp
21
+ Project-URL: Repository, https://github.com/yourname/imessage-mcp
22
+ Project-URL: Issues, https://github.com/yourname/imessage-mcp/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # imessage-mcp
26
+
27
+ A read-only [MCP](https://modelcontextprotocol.io) server exposing your macOS iMessage history to any MCP-capable AI (Claude Desktop, Cursor, Poke, etc.).
28
+
29
+ **100% local.** Your messages never leave your Mac. This package has zero external dependencies — no signup, no hosted service. Just install, grant Full Disk Access, run.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv tool install imessage-mcp
35
+ ```
36
+
37
+ (Requires Python 3.13+ and `uv`. Install `uv` from <https://docs.astral.sh/uv/>.)
38
+
39
+ ## Quickstart
40
+
41
+ ```bash
42
+ imessage-mcp setup # opens the macOS Full Disk Access pane
43
+ imessage-mcp serve # MCP server on http://127.0.0.1:8765/mcp
44
+ ```
45
+
46
+ Or expose it over the internet via a [Cloudflare Quick Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/):
47
+
48
+ ```bash
49
+ brew install cloudflared
50
+ imessage-mcp serve --public # prints a *.trycloudflare.com URL + bearer token
51
+ ```
52
+
53
+ The bearer token lives at `~/.config/imessage-mcp/token` (chmod 600). Rotate with `imessage-mcp token --rotate`.
54
+
55
+ ## Tools exposed via MCP
56
+
57
+ | Tool | Purpose |
58
+ |---|---|
59
+ | `list_chats(limit)` | Most recent conversations |
60
+ | `read_messages(chat_id, limit)` | Messages in a chat, oldest first |
61
+ | `search_messages(query, limit)` | Substring search across all chats |
62
+
63
+ ## Want a stable broker URL across restarts?
64
+
65
+ Cloudflare Quick Tunnels get a new hostname on every launch. If you want a stable URL (and an account-style flow with signup/login), install [`imessage-bridge`](https://pypi.org/project/imessage-bridge/) — a separate package that adds those features on top of this one via a hosted broker.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,45 @@
1
+ # imessage-mcp
2
+
3
+ A read-only [MCP](https://modelcontextprotocol.io) server exposing your macOS iMessage history to any MCP-capable AI (Claude Desktop, Cursor, Poke, etc.).
4
+
5
+ **100% local.** Your messages never leave your Mac. This package has zero external dependencies — no signup, no hosted service. Just install, grant Full Disk Access, run.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ uv tool install imessage-mcp
11
+ ```
12
+
13
+ (Requires Python 3.13+ and `uv`. Install `uv` from <https://docs.astral.sh/uv/>.)
14
+
15
+ ## Quickstart
16
+
17
+ ```bash
18
+ imessage-mcp setup # opens the macOS Full Disk Access pane
19
+ imessage-mcp serve # MCP server on http://127.0.0.1:8765/mcp
20
+ ```
21
+
22
+ Or expose it over the internet via a [Cloudflare Quick Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/):
23
+
24
+ ```bash
25
+ brew install cloudflared
26
+ imessage-mcp serve --public # prints a *.trycloudflare.com URL + bearer token
27
+ ```
28
+
29
+ The bearer token lives at `~/.config/imessage-mcp/token` (chmod 600). Rotate with `imessage-mcp token --rotate`.
30
+
31
+ ## Tools exposed via MCP
32
+
33
+ | Tool | Purpose |
34
+ |---|---|
35
+ | `list_chats(limit)` | Most recent conversations |
36
+ | `read_messages(chat_id, limit)` | Messages in a chat, oldest first |
37
+ | `search_messages(query, limit)` | Substring search across all chats |
38
+
39
+ ## Want a stable broker URL across restarts?
40
+
41
+ Cloudflare Quick Tunnels get a new hostname on every launch. If you want a stable URL (and an account-style flow with signup/login), install [`imessage-bridge`](https://pypi.org/project/imessage-bridge/) — a separate package that adds those features on top of this one via a hosted broker.
42
+
43
+ ## License
44
+
45
+ MIT
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "imessage-mcp"
3
+ version = "0.1.0"
4
+ description = "An MCP server exposing your local iMessage history (macOS, read-only)."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "moritzhwnr", email = "moritz.hawener@gmail.com" },
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "mcp[cli]>=1.27.1",
13
+ "rich>=14.0.0",
14
+ "typer>=0.25.1",
15
+ "uvicorn>=0.47.0",
16
+ "starlette>=1.0.0",
17
+ ]
18
+ keywords = ["mcp", "imessage", "macos", "claude", "llm"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: MacOS :: MacOS X",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Communications :: Chat",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/yourname/imessage-mcp"
29
+ Repository = "https://github.com/yourname/imessage-mcp"
30
+ Issues = "https://github.com/yourname/imessage-mcp/issues"
31
+
32
+ [project.scripts]
33
+ imessage-mcp = "imessage_mcp.cli:main"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.2,<0.12.0"]
37
+ build-backend = "uv_build"
@@ -0,0 +1,28 @@
1
+ """imessage-mcp — MCP server exposing local iMessage queries.
2
+
3
+ Public API for downstream packages (like imessage-bridge) to import:
4
+ - `app` — the Typer CLI app, extendable with @app.command()
5
+ - `mcp` — the FastMCP instance with our tools registered
6
+ - `serve_mcp(...)` — start the HTTP server (lower level, used by serve cmd)
7
+ - `spawn_cloudflared(...)` — start a Quick Tunnel subprocess
8
+ - `print_config_panel(...)` — render the Claude-Desktop-style JSON
9
+ - `CONFIG_DIR`, `TOKEN_FILE`
10
+ - `load_or_create_token(...)`
11
+ """
12
+
13
+ from imessage_mcp.cli import app, main
14
+ from imessage_mcp.config import CONFIG_DIR, TOKEN_FILE, load_or_create_token
15
+ from imessage_mcp.server import mcp, serve_mcp
16
+ from imessage_mcp.tunnel import print_config_panel, spawn_cloudflared
17
+
18
+ __all__ = [
19
+ "app",
20
+ "main",
21
+ "mcp",
22
+ "serve_mcp",
23
+ "spawn_cloudflared",
24
+ "print_config_panel",
25
+ "CONFIG_DIR",
26
+ "TOKEN_FILE",
27
+ "load_or_create_token",
28
+ ]
@@ -0,0 +1,27 @@
1
+ """macOS iMessage database access. Read-only, internal to the package.
2
+
3
+ The chat.db file is a SQLite database macOS uses for Messages.app. We open
4
+ it read-only (uri=mode=ro) to be safe against accidental writes. Timestamps
5
+ in the DB are "Apple epoch" — nanoseconds since 2001-01-01 UTC.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+
14
+ CHAT_DB = Path.home() / "Library" / "Messages" / "chat.db"
15
+ APPLE_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
16
+
17
+
18
+ def open_db() -> sqlite3.Connection:
19
+ """Open chat.db read-only. Caller handles the FileNotFoundError / permission error."""
20
+ if not CHAT_DB.exists():
21
+ raise FileNotFoundError(f"chat.db not found at {CHAT_DB}")
22
+ return sqlite3.connect(f"file:{CHAT_DB}?mode=ro", uri=True)
23
+
24
+
25
+ def apple_ts_to_dt(ts: int) -> datetime:
26
+ """Apple's nanosecond timestamp → datetime."""
27
+ return APPLE_EPOCH + timedelta(seconds=ts / 1_000_000_000)
@@ -0,0 +1,111 @@
1
+ """Typer CLI for imessage-mcp — `serve`, `setup`, `token`.
2
+
3
+ This is the standalone open-core CLI. No broker, no signup. Power users who
4
+ don't want a third-party service install just this package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from typing import Annotated
11
+ from urllib.parse import urlparse
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+
17
+ from imessage_mcp._imessage import CHAT_DB
18
+ from imessage_mcp.config import TOKEN_FILE, load_or_create_token
19
+ from imessage_mcp.server import allow_tunnel_host, serve_mcp
20
+ from imessage_mcp.tunnel import print_config_panel, spawn_cloudflared
21
+
22
+ out = Console()
23
+ err = Console(stderr=True)
24
+
25
+ DEFAULT_HOST = "127.0.0.1"
26
+ DEFAULT_PORT = 8765
27
+
28
+ app = typer.Typer(
29
+ no_args_is_help=True,
30
+ help="MCP server exposing your local iMessage history (read-only).",
31
+ )
32
+
33
+
34
+ @app.command()
35
+ def serve(
36
+ host: Annotated[
37
+ str, typer.Option(help="Bind address. Keep 127.0.0.1 unless you know why.")
38
+ ] = DEFAULT_HOST,
39
+ port: Annotated[int, typer.Option(help="TCP port.")] = DEFAULT_PORT,
40
+ public: Annotated[
41
+ bool,
42
+ typer.Option("--public", help="Expose via a cloudflared Quick Tunnel."),
43
+ ] = False,
44
+ rotate_token: Annotated[
45
+ bool, typer.Option("--rotate-token", help="Generate a fresh bearer token first.")
46
+ ] = False,
47
+ ) -> None:
48
+ """Run the MCP server (locally, or publicly via cloudflared with --public)."""
49
+ if not CHAT_DB.exists():
50
+ err.print(f"[red]chat.db not found at {CHAT_DB}[/red]")
51
+ err.print("Run [cyan]imessage-mcp setup[/cyan] to grant Full Disk Access.")
52
+ raise typer.Exit(code=1)
53
+
54
+ token = load_or_create_token(rotate=rotate_token)
55
+ local_url = f"http://{host}:{port}"
56
+ print_config_panel(local_url, token, public=False)
57
+
58
+ tunnel_proc: subprocess.Popen | None = None
59
+ if public:
60
+ def on_url(url: str) -> None:
61
+ allow_tunnel_host(urlparse(url).netloc, url)
62
+ print_config_panel(url, token, public=True)
63
+
64
+ tunnel_proc = spawn_cloudflared(local_url, on_url=on_url)
65
+ out.print("[dim]Waiting for cloudflared to establish the tunnel...[/dim]")
66
+
67
+ try:
68
+ serve_mcp(host=host, port=port, token=token)
69
+ finally:
70
+ if tunnel_proc is not None and tunnel_proc.poll() is None:
71
+ tunnel_proc.terminate()
72
+ try:
73
+ tunnel_proc.wait(timeout=5)
74
+ except subprocess.TimeoutExpired:
75
+ tunnel_proc.kill()
76
+
77
+
78
+ @app.command()
79
+ def setup() -> None:
80
+ """Open System Settings to grant Full Disk Access to your terminal."""
81
+ pane = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
82
+ subprocess.run(["open", pane], check=True)
83
+ out.print(
84
+ Panel(
85
+ "[bold]Full Disk Access[/bold] pane opened.\n"
86
+ "1. Click [cyan]+[/cyan] and add your terminal app.\n"
87
+ "2. Toggle it [green]on[/green].\n"
88
+ "3. [bold]Fully quit[/bold] the terminal (Cmd+Q) and reopen it.\n"
89
+ "4. Run [cyan]imessage-mcp serve[/cyan] to verify.",
90
+ border_style="cyan",
91
+ )
92
+ )
93
+
94
+
95
+ @app.command()
96
+ def token(
97
+ rotate: Annotated[
98
+ bool, typer.Option("--rotate", help="Generate a new token, replacing the old.")
99
+ ] = False,
100
+ ) -> None:
101
+ """Print the local bearer token (or rotate it)."""
102
+ t = load_or_create_token(rotate=rotate)
103
+ out.print(t)
104
+ if rotate:
105
+ err.print(
106
+ "[yellow]Rotated — any connected clients will start failing 401.[/yellow]"
107
+ )
108
+
109
+
110
+ def main() -> None:
111
+ app()
@@ -0,0 +1,24 @@
1
+ """Config paths + local bearer token management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+ CONFIG_DIR = Path.home() / ".config" / "imessage-mcp"
9
+ TOKEN_FILE = CONFIG_DIR / "token"
10
+
11
+
12
+ def load_or_create_token(rotate: bool = False) -> str:
13
+ """Return the local bearer token, generating it on first run.
14
+
15
+ `secrets.token_urlsafe` gives 32 bytes of entropy as URL-safe base64 —
16
+ plenty for a personal API. chmod 600 = owner-only, ~/.ssh convention.
17
+ """
18
+ if TOKEN_FILE.exists() and not rotate:
19
+ return TOKEN_FILE.read_text().strip()
20
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
21
+ token = secrets.token_urlsafe(32)
22
+ TOKEN_FILE.write_text(token)
23
+ TOKEN_FILE.chmod(0o600)
24
+ return token
@@ -0,0 +1,180 @@
1
+ """FastMCP server + tools + bearer auth.
2
+
3
+ This is the actual MCP service. The `serve_mcp` helper is the lower-level
4
+ entry point that downstream packages (imessage-bridge) reuse — the Typer
5
+ `serve` command just calls it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from typing import Any
12
+
13
+ import typer
14
+ import uvicorn
15
+ from mcp.server.fastmcp import FastMCP
16
+ from rich.console import Console
17
+ from starlette.middleware.base import BaseHTTPMiddleware
18
+ from starlette.responses import JSONResponse
19
+
20
+ from imessage_mcp._imessage import apple_ts_to_dt, open_db
21
+
22
+ out = Console()
23
+ err = Console(stderr=True)
24
+
25
+ mcp = FastMCP("imessage")
26
+
27
+
28
+ # ---------- bearer auth middleware ----------
29
+
30
+
31
+ class BearerAuthMiddleware(BaseHTTPMiddleware):
32
+ """ASGI middleware that 401s anything without `Authorization: Bearer <token>`."""
33
+
34
+ def __init__(self, app, token: str) -> None:
35
+ super().__init__(app)
36
+ self._expected = f"Bearer {token}"
37
+
38
+ async def dispatch(self, request, call_next):
39
+ if request.headers.get("Authorization") != self._expected:
40
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
41
+ return await call_next(request)
42
+
43
+
44
+ # ---------- helper for tool error handling ----------
45
+
46
+
47
+ def _safe_open_db() -> sqlite3.Connection:
48
+ """Translate DB access errors into MCP-friendly errors."""
49
+ try:
50
+ return open_db()
51
+ except FileNotFoundError as e:
52
+ raise RuntimeError(str(e)) from e
53
+ except sqlite3.OperationalError as e:
54
+ raise RuntimeError(
55
+ f"Cannot open chat.db: {e}. The process running imessage-mcp "
56
+ "probably lacks Full Disk Access. Run `imessage-mcp setup`."
57
+ ) from e
58
+
59
+
60
+ # ---------- MCP tools ----------
61
+
62
+
63
+ @mcp.tool()
64
+ def list_chats(limit: int = 20) -> list[dict]:
65
+ """List the most recent iMessage conversations.
66
+
67
+ Args:
68
+ limit: How many chats to return (default 20).
69
+
70
+ Returns: list of {chat_id, who, last_message_at}, newest first.
71
+ """
72
+ conn = _safe_open_db()
73
+ rows = conn.execute(
74
+ """
75
+ SELECT c.ROWID, COALESCE(NULLIF(c.display_name, ''), h.id, '?'), MAX(m.date)
76
+ FROM chat c
77
+ JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID
78
+ JOIN message m ON m.ROWID = cmj.message_id
79
+ LEFT JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
80
+ LEFT JOIN handle h ON h.ROWID = chj.handle_id
81
+ GROUP BY c.ROWID ORDER BY MAX(m.date) DESC LIMIT ?
82
+ """,
83
+ (limit,),
84
+ ).fetchall()
85
+ return [
86
+ {
87
+ "chat_id": cid,
88
+ "who": who,
89
+ "last_message_at": apple_ts_to_dt(ts).astimezone().isoformat(),
90
+ }
91
+ for cid, who, ts in rows
92
+ ]
93
+
94
+
95
+ @mcp.tool()
96
+ def read_messages(chat_id: int, limit: int = 30) -> list[dict]:
97
+ """Read recent messages from a specific chat, oldest first.
98
+
99
+ Args:
100
+ chat_id: The chat ID (from list_chats).
101
+ limit: How many recent messages (default 30).
102
+ """
103
+ conn = _safe_open_db()
104
+ rows = conn.execute(
105
+ """
106
+ SELECT m.date, m.is_from_me, COALESCE(m.text, '')
107
+ FROM message m
108
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
109
+ WHERE cmj.chat_id = ?
110
+ ORDER BY m.date DESC LIMIT ?
111
+ """,
112
+ (chat_id, limit),
113
+ ).fetchall()
114
+ return [
115
+ {
116
+ "at": apple_ts_to_dt(ts).astimezone().isoformat(),
117
+ "from": "me" if is_me else "them",
118
+ "text": text,
119
+ }
120
+ for ts, is_me, text in reversed(rows)
121
+ ]
122
+
123
+
124
+ @mcp.tool()
125
+ def search_messages(query: str, limit: int = 30) -> list[dict]:
126
+ """Substring-search message text across all chats (case-insensitive).
127
+
128
+ Args:
129
+ query: The substring to search for.
130
+ limit: Max matches (default 30).
131
+ """
132
+ conn = _safe_open_db()
133
+ rows = conn.execute(
134
+ """
135
+ SELECT m.date, m.is_from_me, COALESCE(m.text, ''), cmj.chat_id,
136
+ COALESCE(NULLIF(c.display_name, ''), h.id, '?')
137
+ FROM message m
138
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
139
+ JOIN chat c ON c.ROWID = cmj.chat_id
140
+ LEFT JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
141
+ LEFT JOIN handle h ON h.ROWID = chj.handle_id
142
+ WHERE m.text LIKE ? COLLATE NOCASE
143
+ ORDER BY m.date DESC LIMIT ?
144
+ """,
145
+ (f"%{query}%", limit),
146
+ ).fetchall()
147
+ return [
148
+ {
149
+ "at": apple_ts_to_dt(ts).astimezone().isoformat(),
150
+ "from": "me" if is_me else "them",
151
+ "text": text,
152
+ "chat_id": cid,
153
+ "who": who,
154
+ }
155
+ for ts, is_me, text, cid, who in rows
156
+ ]
157
+
158
+
159
+ # ---------- server runner ----------
160
+
161
+
162
+ def serve_mcp(host: str, port: int, token: str) -> None:
163
+ """Start the FastMCP HTTP server with bearer auth (blocks until stopped).
164
+
165
+ Extracted so downstream packages can run the same server while wrapping
166
+ the orchestration around it (e.g. broker registration in imessage-bridge).
167
+ """
168
+ asgi_app = mcp.streamable_http_app()
169
+ asgi_app.add_middleware(BearerAuthMiddleware, token=token)
170
+ uvicorn.run(asgi_app, host=host, port=port, log_level="info")
171
+
172
+
173
+ def allow_tunnel_host(host: str, origin_url: str) -> None:
174
+ """Allow a runtime-discovered host through FastMCP's DNS rebinding check.
175
+
176
+ Used by the tunnel watcher when cloudflared comes up. The settings list
177
+ is read per-request, so adding to it at runtime takes effect immediately.
178
+ """
179
+ mcp.settings.transport_security.allowed_hosts.append(host)
180
+ mcp.settings.transport_security.allowed_origins.append(origin_url)
@@ -0,0 +1,100 @@
1
+ """cloudflared subprocess + the config-panel renderer.
2
+
3
+ Exported so imessage-bridge can reuse both: spawn the same tunnel, then
4
+ print its own broker-aware config panel instead of the local one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import threading
14
+ from typing import Callable
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+
20
+ out = Console()
21
+ err = Console(stderr=True)
22
+
23
+ # Cloudflare Quick Tunnel hostnames look like https://<slug>.trycloudflare.com .
24
+ TRYCLOUDFLARE_URL_RE = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
25
+
26
+
27
+ def spawn_cloudflared(
28
+ local_url: str, on_url: Callable[[str], None]
29
+ ) -> subprocess.Popen:
30
+ """Start `cloudflared tunnel` and call `on_url(public_url)` once detected.
31
+
32
+ Notes:
33
+ - `cloudflared` must be on PATH. shutil.which() is the portable way.
34
+ - cloudflared logs to stderr → merge with stdout so we read one stream.
35
+ - bufsize=1 = line-buffered (Python's 4KB default would delay 30s).
36
+ - The watcher is a daemon thread, so it dies with the process.
37
+ """
38
+ if shutil.which("cloudflared") is None:
39
+ err.print("[red]cloudflared not found on PATH.[/red]")
40
+ err.print("Install it: [cyan]brew install cloudflared[/cyan]")
41
+ raise typer.Exit(code=1)
42
+
43
+ proc = subprocess.Popen(
44
+ ["cloudflared", "tunnel", "--url", local_url, "--no-autoupdate"],
45
+ stdout=subprocess.PIPE,
46
+ stderr=subprocess.STDOUT,
47
+ text=True,
48
+ bufsize=1,
49
+ )
50
+
51
+ def watch() -> None:
52
+ seen = False
53
+ assert proc.stdout is not None
54
+ for line in iter(proc.stdout.readline, ""):
55
+ if not seen:
56
+ m = TRYCLOUDFLARE_URL_RE.search(line)
57
+ if m:
58
+ seen = True
59
+ on_url(m.group(0))
60
+
61
+ threading.Thread(target=watch, daemon=True).start()
62
+ return proc
63
+
64
+
65
+ def print_config_panel(public_url: str, token: str, *, public: bool) -> None:
66
+ """Render the copy-pasteable Claude Desktop config block.
67
+
68
+ `public=True` styles the panel as a public tunnel warning (yellow + a
69
+ "rotate if you leak it" reminder). `public=False` is cyan/local-only.
70
+ """
71
+ config_snippet = json.dumps(
72
+ {
73
+ "mcpServers": {
74
+ "imessage": {
75
+ "url": f"{public_url}/mcp",
76
+ "headers": {"Authorization": f"Bearer {token}"},
77
+ }
78
+ }
79
+ },
80
+ indent=2,
81
+ )
82
+ title = "imessage-mcp (PUBLIC via cloudflared)" if public else "imessage-mcp"
83
+ border = "yellow" if public else "cyan"
84
+ warning = (
85
+ "\n[yellow]⚠ Anyone with this URL + token can read your messages. "
86
+ "Rotate the token if you leak it.[/yellow]\n"
87
+ if public
88
+ else ""
89
+ )
90
+ out.print(
91
+ Panel(
92
+ f"[bold]{public_url}/mcp[/bold]\n"
93
+ f"[dim]Token saved to ~/.config/imessage-mcp/token[/dim]\n"
94
+ f"{warning}\n"
95
+ "Add to Claude Desktop's [cyan]claude_desktop_config.json[/cyan]:\n"
96
+ f"[green]{config_snippet}[/green]",
97
+ title=title,
98
+ border_style=border,
99
+ )
100
+ )