Self-hosted MCP server. Save a Claude conversation on your iPhone, pick it up on your laptop in Claude Code — and the other way around. Claude does the transfer; you keep the data.
You run Relay on a server you control. You register it as a Custom Connector in Claude on both your phone and your desktop. Then conversation feels like this:
On the iPhone
"Save this conversation to Relay."
Claude transcribes the chat, generates a title, calls Relay's append (for summaries) or append_log (for verbatim turns). Done.
On the desktop, in Claude Code
"Pick up the iPhone conversation about the OAuth refactor."
Claude calls Relay's search or read_topic, finds the entry, reads it back into context. Continue working.
The reverse direction works the same way. Same tools, same data, opposite endpoints.
| Approach | Bidirectional | Claude does the transfer | Searchable later | Self-hosted |
|---|---|---|---|---|
| Copy-paste / screenshot | ✓ | ✗ (you do it) | ✗ | n/a |
| Email yourself | ✓ | ✗ | weak | ✗ |
| Notes app sync (iCloud, Google Keep, …) | ✓ | ✗ | weak | ✗ |
| Custom Connector → cloud SaaS (Notion, etc.) | ✓ | ✓ | ✓ | ✗ |
| Relay | ✓ | ✓ | ✓ (SQLite FTS5) | ✓ |
The point of Relay isn't "yet another notes app". It's a thin shared notebook that both Claudes know how to read and write. The intelligence lives in the Claude on either side; Relay just stores.
flowchart LR
subgraph iOS["iPhone"]
A[Claude app]
end
subgraph Server["Your server"]
R{{Relay MCP}}
DB[(SQLite + FTS5)]
R --- DB
end
subgraph Desktop["Your PC"]
B[Claude Code]
end
A <-- "OAuth + MCP" --> R
B <-- "OAuth + MCP" --> R
- Transport: Streamable HTTP, per the MCP spec
- Authentication: OAuth 2.1 with Dynamic Client Registration, PKCE, and refresh-token rotation with reuse detection
- Storage: SQLite + FTS5 with the
trigramtokenizer (so Japanese, English, and mixed-language queries all match cleanly), append-only. Tokens and authorization codes are stored asSHA-256(secret); the wire-format secret never touches disk - Identity model: every entry carries three independent axes
source— which device wrote it (derived from OAuthclient_id)title— a human-meaningful label the writing Claude generatesid— server-issued UUID v7
| Tool | Purpose |
|---|---|
append |
Write a conversation snippet as freeform text (title + content). For summaries / notes |
append_log |
Write a verbatim conversation log as a structured turn array. Each text is the original utterance — the structure itself closes the summarization loophole that a single string leaves open |
list_topics |
Browse titles, optionally by source / since |
read_topic |
Fetch entries under a title, newest first |
search |
Full-text search across content + title (FTS5) |
read_recent |
Time-ordered view across everything |
read_by_id |
Fetch one entry |
list_sources |
List registered Connectors |
append_log stores content as a natural-text join (user: ...\n\nassistant: ...) so existing read tools display it cleanly with no JSON noise. The structured turn array is preserved under meta.turns for callers that need it.
Limits: there is no per-turn or per-call turn-count cap on either tool — the only ceiling is the HTTP request body limit of 10 MB (src/index.ts express.json({ limit: '10mb' })). For longer conversations, split into multiple append_log calls under the same title; read_topic returns them in chronological order. The practical bottleneck is the writing-side LLM's context window, not Relay.
There is intentionally no edit or delete tool. Entries are append-only. To withdraw a wrong entry the supported pattern is a retraction append; for hard removal (e.g. compliance) edit the SQLite file directly.
When a call fails for a domain-level reason the response carries isError: true and a structured payload:
{ "error": "FTS_INVALID_QUERY", "message": "...", "data": { "query": "..." } }| Code | When |
|---|---|
NOT_FOUND |
read_by_id with an unknown id |
BEFORE_ID_NOT_FOUND |
read_topic paginating past a missing anchor |
FTS_INVALID_QUERY |
search with a malformed FTS5 query |
Transport-level failures (auth, missing session, rate limits) come back as JSON-RPC errors / HTTP status codes per the MCP spec.
sequenceDiagram
autonumber
participant C as Claude
participant RS as Relay (resource)
participant AS as Relay (auth)
C->>RS: POST /mcp (no token)
RS-->>C: 401 + WWW-Authenticate (resource_metadata)
C->>RS: GET /.well-known/oauth-protected-resource
RS-->>C: { authorization_servers: [...] }
C->>AS: GET /.well-known/oauth-authorization-server
AS-->>C: register / authorize / token endpoints
C->>AS: POST /register (DCR)
AS-->>C: client_id
C->>AS: GET /authorize (PKCE challenge)
AS-->>C: consent page → operator types passcode → redirect with ?code
C->>AS: POST /token (code + PKCE verifier)
AS-->>C: access_token (4h) + refresh_token (90d)
C->>RS: POST /mcp (Bearer)
RS-->>C: tool result
Refresh tokens are rotated on every use; the old token is revoked. If a revoked refresh token is presented again the server treats it as theft and revokes every refresh token for that client.
git clone https://github.com/kitepon-rgb/Relay.git
cd Relay
cp .env.example .env
# Fill in:
# RELAY_PUBLIC_MCP_URL full URL where the MCP endpoint will be reachable
# RELAY_PUBLIC_AUTH_URL base URL for the OAuth server (must share origin)
# RELAY_OAUTH_SIGNING_KEY openssl rand -base64 64
# RELAY_ADMIN_PASSCODE the passcode you'll type on the consent page
docker compose up -d --buildPut it behind a reverse proxy that terminates TLS (Caddy, nginx, Traefik). Then in the Claude app on either device:
- Open Custom Connector
- Remote MCP server URL: the value of
RELAY_PUBLIC_MCP_URL - OAuth Client ID / Secret: leave empty (Claude registers itself dynamically)
- Approve the consent page once with the passcode you set
Done. Subsequent calls run silently for ~3 months until the refresh token expires.
All configuration is environment variables. See .env.example for the full list. The server fails fast on startup if a required variable is missing or invalid — it does not fall back to defaults.
| Var | Required | Notes |
|---|---|---|
RELAY_PORT |
yes | Internal listening port |
RELAY_PUBLIC_MCP_URL |
yes | Full public URL of the MCP endpoint |
RELAY_PUBLIC_AUTH_URL |
yes | Public base URL of the OAuth server (same origin, different path) |
RELAY_OAUTH_SIGNING_KEY |
yes | ≥32 chars; signs JWT access tokens (HS256) |
RELAY_ADMIN_PASSCODE |
yes | ≥8 chars; gate on the consent page |
RELAY_DB_PATH |
yes | SQLite file path (mount a volume in Docker) |
LOG_LEVEL |
yes | debug / info / warn / error |
Reverse-proxy layouts (subdomain vs. shared-hostname path prefix)
You have two clean choices.
1. Dedicated subdomain (recommended) — every path lives at the root and there is no ambiguity:
relay.example.com {
reverse_proxy 127.0.0.1:18804 {
flush_interval -1
}
}Set in .env:
RELAY_PUBLIC_MCP_URL=https://relay.example.com/mcp
RELAY_PUBLIC_AUTH_URL=https://relay.example.com
2. Shared hostname under a path prefix — useful when you cannot add DNS records or want to coexist with another service that already occupies the bare /mcp, /authorize, etc:
See caddy.snippet for the full set of reverse_proxy lines. Set in .env:
RELAY_PUBLIC_MCP_URL=https://example.com/relay/mcp
RELAY_PUBLIC_AUTH_URL=https://example.com/relay/auth
The OAuth metadata documents are then served at /.well-known/oauth-authorization-server/relay/auth and /.well-known/oauth-protected-resource/relay/mcp (path-suffix form per RFC 8414 / RFC 9728).
Hairpin-NAT note (LAN ↔ public hostname)
If your home router does not loop traffic from the LAN back through the public IP, devices on the same LAN cannot reach https://relay.example.com/.... Add an entry to your machine's hosts file pointing the public hostname to the server's LAN IP:
192.168.x.x relay.example.com
The TLS certificate served by Caddy will still validate because SNI matches the public hostname.
- Backup: copy the SQLite file at
RELAY_DB_PATH. It contains the entries, registered OAuth clients, and the refresh-token hash table. Raw refresh tokens are not stored — onlySHA-256(token)— so a stolen DB does not yield usable tokens. - Revoke a connector:
UPDATE oauth_refresh_tokens SET revoked = 1 WHERE client_id = '<client_id>';then on the next refresh the connector is forced through the consent flow again. - Rotate the signing key: change
RELAY_OAUTH_SIGNING_KEYand restart. All existing access tokens become invalid; refresh tokens still work and produce new access tokens signed with the new key. - Change the consent passcode: change
RELAY_ADMIN_PASSCODEand restart. Existing refresh tokens are unaffected; only future consent prompts are gated by the new value.
The store is append-only by design — once written, content stays in the index and in any backup. To withdraw an entry you regret writing, append a new entry whose meta references the wrong id:
{
"title": "<original title>",
"content": "Retracts the previous entry: <reason>.",
"meta": { "retracts": "019de284-ea45-7249-b65a-68dfcc664d2e" }
}The reading Claude on either side is expected to honor retractions: when both the original and a later retraction are visible, treat the original as withdrawn.
This is a forward-only correction, not a delete. For physical removal (compliance, accidental secrets) edit the SQLite file directly and rebuild the FTS index with INSERT INTO entries_fts(entries_fts) VALUES('rebuild');.
- No fallbacks. If something fails, the server returns an error. Retries are the caller's responsibility (Claude already retries).
- Append-only storage. No edits, no deletes, no merge conflicts.
- Independent read paths. Browsing by topic, full-text search, and time-ordered reads are separate tools — not one fuzzy-matching read tool that tries to be clever.
- Tokens are hashed, never stored raw. Authorization codes and refresh tokens are persisted as SHA-256 hashes; the wire-format secret never touches disk.
MIT.
