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.
- imessage_mcp-0.1.0/PKG-INFO +69 -0
- imessage_mcp-0.1.0/README.md +45 -0
- imessage_mcp-0.1.0/pyproject.toml +37 -0
- imessage_mcp-0.1.0/src/imessage_mcp/__init__.py +28 -0
- imessage_mcp-0.1.0/src/imessage_mcp/_imessage.py +27 -0
- imessage_mcp-0.1.0/src/imessage_mcp/cli.py +111 -0
- imessage_mcp-0.1.0/src/imessage_mcp/config.py +24 -0
- imessage_mcp-0.1.0/src/imessage_mcp/server.py +180 -0
- imessage_mcp-0.1.0/src/imessage_mcp/tunnel.py +100 -0
|
@@ -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
|
+
)
|