Set [oauth].callback_base in config.toml so acp-host can complete browser OAuth.
Public remotes work without it; authenticated gateways need the callback (or /mcp code paste).
Tokens are never written to the repo. They live under:
$state_dir/mcp-oauth/by-repo/<repoKey>/<id>.json # mode 0600
$state_dir/mcp-oauth/pending/ # PKCE in flight
Default state_dir is ~/.local/share/acpbot/state (see Configuration).
Shared state dir
/mcp auth runs in the Telegram worker and writes pending PKCE.
GET /oauth/callback and session ensure run on acp-host.
Both processes must use the same config file (or the same absolute state_dir). Boot logs print the resolved path on both processes.
Setup
Recommended: Tailscale MagicDNS HTTPS
Listener always uses port 8788. Selecting MagicDNS only switches the scheme to https and loads Tailscale certs — it does not bind :443.
[oauth]
callback_base = "https://your-node.ts.net:8788" # phone on the same tailnet
# listen_port = 8788 # default (same for http and https)
# tls_cert / tls_key optional — auto-detected from ~/.local/share/tailscale-certs/
Issue a cert once (macOS and Linux — same paths):
mkdir -p ~/.local/share/tailscale-certs
cd ~/.local/share/tailscale-certs
tailscale cert your-node.ts.net
| File | Path |
|---|---|
| Certificate | ~/.local/share/tailscale-certs/<MagicDNS>.crt |
| Private key | ~/.local/share/tailscale-certs/<MagicDNS>.key |
acpbot auto-detects those files from callback_base (or tailscale status).
You can also set them explicitly:
[oauth]
callback_base = "https://your-node.ts.net:8788"
tls_cert = "~/.local/share/tailscale-certs/your-node.ts.net.crt"
tls_key = "~/.local/share/tailscale-certs/your-node.ts.net.key"
Plain HTTP fallback
[oauth]
callback_base = "http://100.x.y.z:8788" # Tailscale IP or LAN — no cert needed
# listen_host = "0.0.0.0"
# listen_port = 8788
Guided setup detection
acpbot setup offers a picker for callback_base. It detects hosts when possible
(via tailscale status --json and local network interfaces) and always allows a custom URL:
| Option | Source | Example |
|---|---|---|
| Tailscale HTTPS | Self.DNSName (MagicDNS) + local certs | https://your-node.ts.net:8788 |
| Tailscale IP | Tailscale 100.x IPv4 | http://100.64.1.2:8788 |
| LAN IP | Private interface addrs (10.x, 172.16–31.x, 192.168.x) | http://192.168.1.10:8788 |
| Custom URL… | Manual entry | tunnel / Serve / Funnel |
| Skip / clear | Unset | use /mcp code paste fallback |
- All options use port 8788. MagicDNS is
https://…:8788(TLS); IP options stayhttp://…:8788. - If certs are missing, setup prints the
tailscale certcommands (table of.crt/.keypaths). - Prefer Tailscale HTTPS when the phone is on the same tailnet. LAN IPs only work on the same Wi‑Fi/Ethernet.
- Detection + cert helpers:
src/setup/oauth-callback-detect.ts(tests intest/oauth-callback-detect.test.ts).
Run:
acpbot host # serves GET /oauth/callback when callback_base is set
acpbot worker # same config.toml / state_dir
# from source: acpbot host · acpbot worker
If bind fails (port in use), acp-host exits with a clear error when callback_base is set. Free the port or use the paste fallback below.
Operator flow
- In a session topic:
/mcp add <id> <url>
→ acpbot attaches a per-topicmcp-proxyimmediately (empty tool list until auth). /mcp auth <id>- Open the tappable authorize URL in Telegram (host does not open a browser)
- On callback, PKCE completes; token is stored under
state_dir(not the repo) - The live proxy re-reads the store, connects upstream, and advertises tools — no agent restart
- Pending PKCE expires after 15 minutes
- Access tokens auto-refresh when stale (stored
refresh_token+ token endpoint). On 401 the proxy force-refreshes. If refresh fails (invalid_grant, no refresh token), run/mcp auth <id>again — still no agent restart.
Remotes are always served via acpbot mcp-proxy (stdio, per session slot). Details: MCP.
Before auth the agent still sees the MCP server name, but with zero tools. After auth (or reauth), tools appear without respawning the agent.
Paste fallback
If the redirect cannot reach the host:
- Prefer
/mcp code <full-callback-url>(includescode+state) - Last resort:
/mcp code <code> <id>
Discovery (no env client_id / auth URL)
On /mcp auth, acpbot:
- Probes the MCP URL for
WWW-Authenticateresource_metadata(RFC 9728), else fetches/.well-known/oauth-protected-resource… - Loads authorization-server metadata (RFC 8414)
- Dynamically registers a public PKCE client (
registration_endpoint, RFC 7591) - Opens authorize with the registered
client_id+resourceindicator
The gateway must publish AS metadata with a registration endpoint. There are no per-gateway CLIENT_ID / AUTH_URL config keys.
Security model
| Piece | Note |
|---|---|
| Listen address | Default 0.0.0.0 so phone redirects work |
| Who can hit the port | Anyone who can reach the host may attempt a callback |
| Real protection | High-entropy state + PKCE (code_verifier never leaves the host) |
| Network preference | Tailscale Serve / tailnet over public Funnel/IP |
Implementation map
| Area | Path |
|---|---|
| Discovery | src/mcp/oauth-discovery.ts |
| PKCE / flow | src/mcp/oauth-pkce.ts, oauth-flow.ts |
| Token store | src/mcp/oauth-store.ts |
| Per-slot stdio proxy (official MCP TS SDK) | src/mcp/proxy.ts, proxy-rewrite.ts |
| HTTP callback | src/acp-host/oauth-http.ts |
| Tests | test/mcp-oauth.test.ts, test/mcp-proxy-rewrite.test.ts |
Env overrides (ACPBOT_OAUTH_CALLBACK_BASE, ACPBOT_OAUTH_*) work when set; prefer TOML for day-to-day use.