MCP server for Matrix (incl. E2EE rooms) over OAuth 2.1 / MAS. Self-host.
  • Rust 99%
  • Python 0.6%
  • Dockerfile 0.4%
Find a file
2026-09-02 02:42:58 +00:00
.forgejo/workflows build: pin CI to 1.98.0 and write down why the tree cannot lint below it 2026-08-25 15:12:24 +08:00
docs fix(mcp): pad body_base64, which strict decoders were rejecting 2026-08-25 15:43:45 +08:00
examples ci: publish to forge only, drop the GHCR mirror 2026-08-17 21:07:17 +08:00
scripts chore: pre-public-release polish (license, README, docs, generalize Gruyere) 2026-05-19 20:27:48 +08:00
src refactor(crypto): lift the post-rebuild wait out of verify_or_heal 2026-08-30 00:18:21 +08:00
.gitignore chore(repo): publish-ready polish (#72) 2026-05-17 08:54:17 +08:00
AGENTS.md docs: what a merge to main does, and how to read a cancelled run 2026-09-02 10:37:31 +08:00
Cargo.lock feat(channel): relay permission prompts through Matrix 2026-08-25 18:58:19 +08:00
Cargo.toml feat(channel): relay permission prompts through Matrix 2026-08-25 18:58:19 +08:00
CHANGELOG.md feat(channel): relay permission prompts through Matrix 2026-08-25 18:58:19 +08:00
deny.toml chore: pre-public-release polish (license, README, docs, generalize Gruyere) 2026-05-19 20:27:48 +08:00
Dockerfile ci: mirror release images to GHCR, and annotate the image 2026-08-24 08:02:50 +08:00
LICENSE chore: pre-public-release polish (license, README, docs, generalize Gruyere) 2026-05-19 20:27:48 +08:00
README.md feat(channel): relay permission prompts through Matrix 2026-08-25 18:58:19 +08:00
rustfmt.toml feat: phase 0 — repo skeleton, design docs, CI 2026-05-14 11:51:00 +08:00
SECURITY.md docs: tighten README, add SECURITY/THREAT_MODEL/multi-user for OSS release (#63) 2026-05-16 23:14:26 +08:00
THREAT_MODEL.md feat(mcp): paginate encrypted history and harden fetches 2026-06-13 08:45:16 +08:00

matrix-mcp

A remote MCP server that lets claude.ai (or any other MCP client) read, search, and write in your Matrix account including in end-to-end-encrypted rooms using your existing cross-signing identity and Secret Storage recovery key.

It speaks OAuth 2.1 / MCP / RFC 9728 on one side and the Matrix Client-Server API on the other. matrix-rust-sdk handles olm/megolm/cross-signing encrypted history decrypts normally as long as the device is cross-signed, which /setup arranges with your recovery key.

What you get

Two mounts, sharing one deployment's auth, clients and encrypted stores:

Mount For Shape
/mcp claude.ai, Claude Code, any MCP client ~61 tools. You ask, it answers
/channel agents that must react while nobody is watching Pushes inbound Matrix messages into a running Claude Code session, plus a scoped eight-tool subset

See Push Matrix into a running agent for the second one.

Two surfaces, and ~61 tools carrying MCP annotations (read_only_hint, destructive_hint, idempotent_hint) so client UIs can auto-approve reads and warn before writes.

  • Reads: read_recent_messages, read_thread, search_messages, get_unread_summary, list_joined_rooms, list_recent_activity, room_info, room_members, list_threads_in_room, download_attachment, verify_status, whoami, users_get_profile, get_room_state, get_space_hierarchy, get_account_data, get_user_receipts, get_event_receipts, get_presence, list_ignored_users, invites_list
  • Writes: send_text_message, send_image_from_url, send_file, send_audio, send_voice_message, send_tts_voice_message, send_video, send_reaction, send_bulk, send_broadcast, mark_read, message_edit, message_forward, redact_message, upload_media_from_url, set_account_data, set_presence, set_room_name, set_room_topic, me_set_displayname, me_set_avatar, ignore_user, unignore_user, request_room_keys
  • Room management: room_create, room_create_dm, join_room, leave_room, invite_user, invites_accept, invites_reject, room_pin_message, room_unpin_message
  • Moderation / admin: room_kick, room_ban, room_unban, admin_get_power_levels, admin_set_power_level (constrained by the user's Matrix power level on the room)
  • Self-audit: set_audit_room designate a Matrix room where matrix-mcp posts an m.notice for every write it makes on your behalf

See docs/api-reference.md for arguments and return shapes.

Push Matrix into a running agent

A Claude Code channel is an MCP server that pushes events into a session that is already open, so the agent reacts to things happening while you are away from the terminal. /channel implements it for Matrix: message the account from your phone and it lands in the session's context; the agent answers in the room.

Point a session at it — the token is any Matrix access token for the account, so a bot registered however you like works:

{ "mcpServers": { "matrix": {
    "type": "http",
    "url": "https://matrix-mcp.your-domain.example/channel",
    "headers": { "Authorization": "Bearer ${MATRIX_TOKEN}" } } } }
export MATRIX_TOKEN=...            # from your secret manager, not a file
claude --dangerously-load-development-channels server:matrix

The flag is not a testing step. During the channels research preview --channels accepts only plugins on an Anthropic-maintained allowlist, and a private marketplace does not qualify, so any self-hosted channel uses the development flag permanently. It shows a full-screen confirmation at startup. Channels also need Anthropic authentication (claude.ai or a Console key) and are unavailable on Bedrock, Vertex and Foundry.

Nothing is pushed until you say who may push. The sender allowlist lives in the account's own Matrix account data, so each account configures itself:

// event type: app.matrix_mcp.channel
{
  "allowed_senders": ["@you:your-domain.example"],
  // optional: where permission prompts go. See below.
  "permission_room": "!your-dm-with-the-bot:your-domain.example"
}

Absent or empty means nothing is delivered. An ungated channel is a prompt-injection vector: message bodies reach a model holding live credentials with no human turn in front of them. Gating is on the sender, never the room, because those differ in a group. Bodies are wrapped and escaped by the same content sandbox the read tools use, and anything tripping the injection heuristic is tagged suspicious="true".

Messages that arrive while no session is listening are replayed when one attaches, watermarked by the account's own read receipt. The server never writes that receipt itself — it cannot observe delivery — so the agent acknowledges with mark_read and delivery is at-least-once.

Approving tool calls from Matrix. The channel mount also declares claude/channel/permission, so when a session on it hits a permission prompt the same prompt arrives as a Matrix message carrying a five-letter request id:

Claude wants to run Bash: Run shell command
{"command": "cargo test --all-features"}

Reply "yes qmzkd" or "no qmzkd"

Answer yes <id> or no <id> and the tool call runs or is rejected. The terminal dialog stays open the whole time; whichever answer arrives first wins. The id is not shown in the terminal, so that message is the only place to learn it. Relay covers tool-use approvals only — project trust and MCP consent dialogs never leave the terminal.

The reply goes through the same sender allowlist as everything else, and it must, because anyone who can reply can approve tool use in the session. A verdict from a sender who is not on the list is dropped without being applied and without being forwarded as chat.

Prompts go to permission_room when the account data sets one; otherwise to the room an allowlisted sender last wrote in. If neither is known the prompt is dropped with a log line rather than sent somewhere guessed — set permission_room if a session may need approval before anyone has messaged it.

Accounts with no human

A bot cannot run the browser /setup flow, so bootstrap_cross_signing creates its cross-signing identity directly and writes it to Secret Storage under a passphrase derived from your pepper. Recovery therefore survives losing the volume, with nothing to configure. It refuses to run where an identity already exists, so it can never reset one.

One matrix-sdk client per access token. Two clients on one device id both generate identity keys, only one set is advertised, and the loser decrypts nothing while looking perfectly healthy. Reissuing the token does not fix it. Give every consumer its own account.

Prerequisites

  • A Synapse homeserver (≥ 1.130) with Matrix Authentication Service / MSC3861. Legacy password-only homeservers won't work.
  • A Matrix account on that homeserver with cross-signing already set up (the standard Element "Set up secure backup" flow). You'll need the recovery key that flow produced.
  • An MCP client that speaks the streamable-HTTP transport (claude.ai Custom Connectors, etc.).

Quick start

docker run --rm -p 3000:3000 \
  -v matrix-mcp-store:/var/lib/matrix-mcp \
  -e MATRIX_MCP_RESOURCE_URL=https://matrix-mcp.your-domain.example \
  -e MATRIX_MCP_AUTHORIZATION_SERVER=https://your-mas.example \
  -e MATRIX_MCP_HOMESERVER_URL=https://matrix.your-domain.example \
  -e MATRIX_MCP_SERVER_NAME=your-domain.example \
  -e MATRIX_MCP_INTROSPECTION_CLIENT_ID=... \
  -e MATRIX_MCP_INTROSPECTION_CLIENT_SECRET=... \
  -e MATRIX_MCP_STORE_DIR=/var/lib/matrix-mcp \
  -e MATRIX_MCP_STORE_PEPPER="$(openssl rand -hex 32)" \
  forge.oddie.app/jlxq0/matrix-mcp:v0.9.0

The image is public on both registries — no account needed:

forge.oddie.app/jlxq0/matrix-mcp:v0.9.0     # canonical
ghcr.io/jlxq0/matrix-mcp:v0.9.0             # mirror, identical by digest

Then point a public hostname at it over HTTPS (claude.ai requires https://) and follow docs/onboarding.md to connect. For a Kubernetes deployment with cert-manager, External Secrets, and Traefik Gateway API, see docs/installation.md. For a single-host homelab setup with Caddy or Traefik, see examples/docker-compose.yml.

Multi-user

Every authenticated user gets their own encrypted SQLite store, their own matrix-sdk client, their own audit room (held in their Matrix account_data), and their own rate-limit bucket. Two users share zero state. The MAS introspection middleware accepts any active token, so by default every user on your homeserver can connect same surface as Element. Restrict with an upstream allowlist if you want fewer.

docs/multi-user.md walks through the isolation guarantees.

Security

matrix-mcp gives claude.ai access to your decrypted Matrix content that's the entire point. So:

  1. You extend trust to claude.ai (and Anthropic). If that's not OK, don't connect.
  2. You extend trust to whoever runs the matrix-mcp instance. Run your own.

SECURITY.md covers reporting; THREAT_MODEL.md covers what's in and out of scope.

Development

Rust 1.93+ with edition = "2024".

cargo run                                           # build + run
curl http://127.0.0.1:3000/health                   # health check
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --locked

PRs welcome. Run the three checks above before pushing; CI will reject otherwise. Conventional Commits for messages.

Documentation

File Contents
docs/onboarding.md Connecting from claude.ai end-to-end
docs/installation.md Deploy on a VPS or Kubernetes
docs/architecture.md Component diagrams, OAuth dance, sync loop, storage layout
docs/api-reference.md Arguments + return shapes for the most-used tools (full set returned by tools/list)
docs/operations.md Running in production, debug recipes, pepper rotation, recovery for accounts with no human
docs/security.md Implementation-level security notes
docs/multi-user.md Multi-user isolation guarantees
docs/cross-signing-recover-flow.md E2EE bootstrap, recovery, auto-rotating device id
docs/decisions.md Architecture decision records
SECURITY.md Reporting vulnerabilities
THREAT_MODEL.md What this defends against
CHANGELOG.md Version history

Licence

MIT.

Where this code lives

The canonical source repository is on Forgejo. github.com/jlxq0/matrix-mcp is a public push-mirror so the code is discoverable and forkable from GitHub. PRs opened on either side are fine; merges happen on the primary Forgejo remote and are mirrored.