使用说明
Zulip MCP Server
A Zulip server for AI agents, speaking plain MCP (Model Context Protocol) to any client and MCPL (MCP Live) to hosts that support it — live delivery of stream messages and DMs, host-managed channels, catch-up after downtime, a hot-reloadable filters plane, and a stateful tool surface for reading and writing Zulip.
Built on @animalabs/mcpl-core,
the same substrate as discord-mcpl
and slack-mcpl. Zulip only — the
Discord and Slack adapters that once lived here moved to those servers.
What you get
Plain MCP (Claude Code, Cursor, any MCP client)
- 29 tools: stream/topic history with natural dates or id cursors,
fetch_around, sending to streams and DMs, editing, deleting, reactions, user lookup, attachments, and a persistent read/unread monitor. - Resources:
zulip://unread/summary,zulip://monitoring/status,zulip://channel/{stream}/unread.
MCPL hosts (connectome-host and friends)
- Every stream and DM conversation the bot can see is a channel the host can open and close. Opening a channel subscribes the bot to the stream first (Zulip only delivers events to subscribers) — a stream the bot cannot subscribe to fails the open rather than opening onto silence — and can return backscroll atomically.
- Delivery model: messages on open channels arrive as
channels/incoming; mentions and DMs on closed channels arrive aspush/eventso they always reach the agent; ambient traffic on closed channels is dropped and counted (channel_missed). - Catch-up: a persisted per-channel watermark advances only when the host
has accepted a message (itemized
channels/incomingresults, acknowledgedpush/event), and never past a message the host was offered but did not accept — a refusal, a batch lost to a transport failure — which is replayed from history once the host answers again. On the next connection a<missed>block per channel delivers what arrived meanwhile (full backscroll for channels the host had open, mention ± 7 messages for the rest), paged up toZULIP_CATCHUP_LIMITand hard-capped in size. A Zulip event-queue expiry is healed the same way — open channels replayed, closed channels' mentions pushed — not merely reported. Live events that arrive before the sweep has run are held and released in order after it, so a live delivery can never jump the watermark over the offline gap. - RFC-001 tags on every message (
chat:mention,chat:dm,chat:ambient,chat:from-bot,chat:has-image,chat:reaction, …) for the host's wake policy. Images are inlined on live delivery, downsampled to model-max. - Reactions, opt-in per channel, carry only reaction tags so a tag-keyed wake policy ignores them; operator-owned suppression of reaction markers plus the host-injected baseline; rollback checkpoints minted by every messaging tool; acknowledge by reaction; typing indicators routed to the active topic.
- Edits, topic moves and deletions of messages the agent has seen arrive as
[edited]/[moved]/[deleted]lines carrying the message's own id (chat:edited/chat:deleted, pluschat:mentionwhen the edit now addresses the bot); history marks edited messages(edited).
Installation
npm install
npm run build
Requires Node 20+. npm test runs the suite (node --test, no network).
Configuration
Credentials, via environment or a zuliprc file:
export ZULIP_REALM=https://your-org.zulipchat.com
export ZULIP_EMAIL=your-bot@your-org.zulipchat.com
export ZULIP_API_KEY=your-api-key
# or
export ZULIP_RC_PATH=/path/to/zuliprc
Everything else is optional. .env.example lists every variable; the ones you
are likely to touch:
| Variable | Default | Meaning |
|---|---|---|
ZULIP_SESSION_ID |
bot email (from env or zuliprc) | Keys the persistent state files (monitoring, delivery, filters); set it when two sessions of one bot share a state dir |
ZULIP_STATE_DIR |
~/.zulip_mcp_state |
Where those files live |
ZULIP_SUBSCRIBE |
— | Streams to subscribe the bot to on startup |
ZULIP_FILTERS_FILE |
<state dir>/<session>.filters.json |
The filters plane file (hot-reloaded) |
ZULIP_STREAMS, ZULIP_DM_USERS, ZULIP_MUTED_STREAMS |
— | Seed for the filters file on first materialization |
ZULIP_SUPPRESSED_REACTIONS_BASELINE |
DISCORD_SUPPRESSED_REACTIONS_BASELINE |
Host-owned reaction markers withheld from the model; re-read every start, never persisted (connectome-host injects the DISCORD_ name into every MCPL child) |
ZULIP_CATCHUP_LIMIT |
3000 | Per-channel ceiling for catch-up and gap recovery (max 10000). For an always-open desk channel a few hundred is plenty |
ZULIP_MISSED_BLOCK_MAX_CHARS |
40000 | Size cap on one <missed> block; the oldest lines are elided with a fetch_history pointer |
ZULIP_ATTRIBUTE_DELIVERY |
true | Prefix live, pushed and recovered bodies with [time id=N] [#stream > topic] Author: ; false for a host that renders the structured fields itself (see What the model reads) |
ZULIP_BACKSCROLL_DEFAULT, ZULIP_BACKSCROLL_CHANNELS |
500 | History cap on channels/open, per stream as general:50,dev:200 |
ZULIP_INLINE_IMAGES, ZULIP_INLINE_IMAGES_MAX, ZULIP_ATTACHMENT_INLINE_MAX_BYTES |
true, 4, 5120 | Attachment inlining on live delivery |
ZULIP_UPLOAD_ROOTS |
— | Named directories local-file attachments may come from, notes=./notes,out=/srv/out. Unset: local files refused, base64 only |
ZULIP_UPLOAD_MAX_BYTES |
realm's cap, else 25 MiB | Per-file ceiling for outbound uploads; the realm's max_file_upload_size_mib is read at start. A message carries at most 10 files within 4× this |
AGENT_TIMEZONE, AGENT_TIMESTAMP_STYLE |
system, full |
Agent-visible time on every message line: delivery, <missed>, injected history, fetch_history |
MCPL_ENABLED |
true | false forces plain-MCP mode even for MCPL hosts |
Plain MCP client (Claude Code, Cursor)
{
"mcpServers": {
"zulip": {
"command": "node",
"args": ["/path/to/zulip-mcp/build/index.js"],
"env": {
"ZULIP_RC_PATH": "/path/to/zuliprc",
"ZULIP_SESSION_ID": "my-agent"
}
}
}
}
MCPL host
The server negotiates MCPL when the host advertises experimental.mcpl in
initialize. It stays inert until the host's featureSets/update Request
establishes the capability grant (SPEC 0.5 §5.3 — absence is denial), then
registers channels and runs the catch-up sweep. Stdio is the default
transport; --tcp <port> serves one connection at a time on localhost.
Feature sets: zulip.messaging (channels, push events, tools, rollback),
zulip.history (the read tools), zulip.context (recent history injected
before inference for open channels).
Every zulip.messaging tool result carries state.checkpoint (SPEC §8);
state/rollback to a checkpoint deletes what the bot sent after it —
tool sends and channels/publish alike. Disabling zulip.messaging stops
its delivery (incoming and push) and its tools at once.
Wake policy. Closed-channel mentions and DMs, new-DM announcements and
<missed> catch-up blocks arrive as push/event, not channels/incoming.
A host whose gate defaults to skip needs a policy on the mcpl:push-event
scope or they land in context without a turn — for connectome-host's gate:
{ "name": "addressed-push",
"match": { "scope": ["mcpl:push-event"], "tagsAny": ["chat:addressed", "zulip:missed"] },
"behavior": "always" }
(the gate matches on tagsAny / tagsAll / tagsNone; the host expands
chat:mention and chat:dm into chat:addressed). Conversely, reactions
on an open channel are ordinary channels/incoming messages carrying only
chat:reaction / chat:reaction-remove — a policy keyed on tags ignores
them, but an unconditional "always wake on this channel" policy wakes on
them too; add "tagsNone": ["chat:reaction", "chat:reaction-remove"] to it.
Edits, moves and deletions
A change to a message is as visible as the message was. An edit, a topic move or a deletion of a message the host has accepted (or been offered) on an open channel, or of the bot's own message, surfaces on that channel as one line in the shared shape, carrying the message's own id:
[edited] [10:42 id=77] [#general > deploys] Ann: ship it tomorrow
[moved] [10:43 id=77] [#general > deploys-2] Ann: topic changed from "deploys" (3 messages) [by user 12]
[deleted] [10:44 id=77] [#general > deploys-2] Ann: message deleted — was: "ship it tomorrow"
On a closed channel only an addressed change is pushed — the mention the
agent is about to answer was rewritten or deleted, or a DM it is reading
changed — the same rule as for messages. A change to a message the host was
never offered is dropped. Zulip re-renders (a link preview arriving) are
not edits and never surface; the bot's own edits and deletions (rollback,
delete_message) are its own doing and never surface either. A moved
message is reported on the channel it left, naming where it went.
The lines carry chat:edited (moves add zulip:moved) or chat:deleted,
plus the same addressing tag a message would: chat:mention when the
message mentions the bot as it now reads, chat:dm for a DM, else
chat:ambient — so a tag-keyed wake policy wakes on an edit exactly when
it would wake on the message, and a debounced ambient policy debounces
ambient edits. An unconditional per-channel policy wakes on every change;
add "tagsNone": ["chat:edited", "chat:deleted"] to it if that is unwanted.
metadata carries change, targetMessageId(s), previousContent,
previousTopic, movedToChannelId, actorId, mentioned and
previouslyMentioned (absent when only the post-change state could be
read). A change line never sets threadId and never moves this server's
reply routing: a moderator archiving an old message does not retarget the
reply the agent is composing. A move into a stream the bot cannot see
arrives from Zulip as a deletion and is reported as "no longer visible". Synthetic ids (edit:77:…) never advance the
delivery watermark; the message's own id in the line is what fetch_around
takes (a deleted id can no longer be fetched). History and backscroll
render (edited) and (moved) trailers with metadata.editedAt /
movedAt.
Channels
| Channel id | What it is |
|---|---|
zulip:<stream> |
A stream. Topics are threads: incoming messages carry the topic as threadId; publishes go to the topic of the newest of these there, else mcpl: a message queued for the host while the stream is open, even one the batch then withholds or the host refuses; while the stream is closed, a mention the host accepted as a push, or a <missed> block it accepted, from its newest mention or, in a full backscroll, its newest message; or the newest message of the history channels/open returned, unless that history was fetched before a given message. Streams visible at startup are registered then; one the bot joins later announces itself (see below). |
zulip:dm:<ids> |
A DM conversation, keyed by the other parties' sorted user ids (zulip:dm:42, zulip:dm:7+42). Discovered from recent DM history and announced on the fly (channels/changed) when someone new writes. |
Descriptors carry capabilities.history (maxMessages, supportsBeforeMessage,
supportsSinceLastSeen); channels/open may ask for history and gets it
before the lifecycle commits.
Channels that appear after startup. The bot does not have to be
restarted to use a stream it was added to later. Four things register one,
whichever happens first: the listen tool registers what it subscribes the
bot to; every message from the stream carries its descriptor, the way a DM
from a new conversation does, so the mention that wakes the agent also
makes the channel openable; refresh_channels re-enumerates everything
visible; and channels/open on an unknown exact channelId re-enumerates
once before it fails. Only a channel none of these can find answers
-32023 Unknown channel.
A channels/changed announcement the host never confirms is not a refusal:
the channel stays usable here and is re-announced on the next explicit
registration (refresh_channels, listen, a widened allowlist — never the
message path, where an announcement sits in front of a delivery).
refresh_channels reports it under pendingAnnouncement, and listen
under registration. The retry set is capped at 100 channels; past that the
oldest stops being retried and stays usable. One consequence worth knowing:
channels/list can name a channel the host never confirmed.
A host that opens or closes a channel has confirmed it, whatever became of
the announcement, and it leaves the retry set at that point. Against
agent-framework that is the only confirmation available for an announcement
made inside a tool call: it reconciles (a blocking channels/open or
channels/close per descriptor) before it answers, and this server serves
one request at a time, so the answer cannot arrive until the tool returns.
Without that rule the backlog would be retried on every refresh forever.
Silence is not refusal either. A host that itemizes only the descriptors it
changed leaves the rest unstated, and they are treated as pending, not
refused. An itemized accepted: false IS the host's answer: the channel is
unregistered and closed, it is not announced again, and refresh_channels
lists it under refused. Asking again is an agent decision — an explicit
refresh_channels retries refused channels.
An announcement waits 5 seconds, not the protocol default of 30. This
server answers one request at a time, and agent-framework's
channels/changed handler reconciles (a blocking channels/open per
descriptor) before it answers, while its channels/register handler
answers first. So an announcement made inside a tool call cannot be
answered until that call returns. Timing out quickly keeps the channel
usable, lets the host's own channels/open be served, and leaves the
re-announcement to the next refresh; descriptors are recorded before the
announcement goes out so that open succeeds when it lands.
Filters plane
One JSON file is the desired state for what reaches the agent. It always exists once the server has started (seeded from the environment), is authoritative from then on, and is hot-reloaded within seconds — no change here ever needs a restart.
{
"streams": ["general", "dev"],
"dmUsers": ["42", "ann@example.com"],
"mutedStreams": ["random"],
"reactionChannels": ["zulip:general"],
"suppressedReactionEmojis": ["biohazard"]
}
streams— allowlist (absent = every stream the bot can see). Gates discovery and delivery on every surface: live, catch-up, gap recovery, backscroll on open, context injection, reactions.dmUsers— who may DM the bot (absent = anyone), judged per sender on every surface. Empty means unrestricted, deliberately: unsetting a variable must not silently lose every DM.mutedStreams— nothing from these reaches the agent on any surface: live delivery, mentions, backscroll on open, context injection, reactions, catch-up and gap recovery. The pull tools (fetch_history, …) still work.reactionChannels— channels showing live reactions.suppressedReactionEmojis— reaction markers withheld from every model-visible surface. Operator-owned: the agent's tools cannot carry this key, andfilters_getreports it only as a count and digest. Entries are emoji names (biohazard) or glyphs (☣️); a glyph matches a Zulip reaction on its codepoints. The host's baseline (*_SUPPRESSED_REACTIONS_BASELINE, glyphs as connectome-host injects them) is added on top at every start and is never written into the file.
Every key is an authorization list: a wrong-typed value makes the file invalid rather than reading as "unrestricted". While running, an unparseable or vanished file keeps the last-known-good filters in force and marks the plane stale; updates from the tools are refused until it is repaired. At start there is no last-known-good, so a file that exists but cannot be parsed is a startup failure — as is one that cannot be created — not a run on the env seed (which, unset, means everything). Repair the file, or remove it to re-seed from the environment.
Tools
Reading
fetch_history (stream/topic or a DM conversation by channel id,
before/after id cursors, ids on every line),
fetch_around (window centred on a message, within its conversation),
get_channel_history (natural dates), get_unread_messages,
list_streams, get_stream_topics, list_users, find_user,
get_user_profile, fetch_attachment, list_emojis.
Writing
send_message, send_dm (by name, email, or id), upload_file,
edit_message, delete_message, add_reaction, remove_reaction.
Both send tools take an optional attachments array; each entry is a local
file ({ "file": "notes/report.pdf" }) or inline bytes
({ "data": "<base64>", "name": "chart.png" }), with an optional
mime_type (guessed from the extension otherwise; Zulip previews an image
only when its declared type is image/*). Files are uploaded to the realm
first and linked at the end of the message, the way the Zulip client
attaches them. content may be omitted when there are attachments.
upload_file does the upload alone and returns the /user_uploads/...
path, the URL, and the markdown link, for embedding in an edit_message or
anywhere in a body. On the MCPL publish path, image and audio blocks
with inline data are uploaded the same way. Uploads happen before the send;
a send that then fails leaves them unreferenced, and Zulip garbage-collects
unclaimed uploads after a week.
Local files are confined to upload roots. Tool input is influenced by
message content from untrusted senders, and this server runs with the
host's filesystem and environment, so a file is never an arbitrary path.
It is <root>/<path>, where <root> names a directory exported in
ZULIP_UPLOAD_ROOTS (notes=./notes,out=/srv/agent/out; relative to the
server's cwd, which under a host is the host's). The path is resolved
against that root, symlinks followed, and must land inside it; absolute
paths and other roots are refused with the available names in the error.
With no roots configured, local-file attachments are refused and only
base64 data works. A root that does not exist is a startup failure. To
let an agent attach what it writes in its workspace, mount the workspace
and name it as a root at the same path, e.g. ZULIP_UPLOAD_ROOTS=workspace=./workspace,
so the mount-prefixed path the agent already knows is the attachment path.
The check is bound to the file actually opened, not to its pathname (via
/proc/self/fd), so a directory swapped for a symlink mid-request is
caught too. That makes local-file attachments Linux-only: on other
platforms Node has no descriptor-relative resolution, a pathname re-check
would be the very race the guard exists for, and so file is refused
there with a pointer to base64 data. What the check cannot see is a hard
link created inside a root to a file outside it: that needs
a local writer in the root (and, with fs.protected_hardlinks=1, ownership
of the target), so export roots only writers you trust can reach.
Limits: one file up to the realm's advertised cap (or ZULIP_UPLOAD_MAX_BYTES),
at most 10 files per message, 4× the per-file cap in total. The per-file
ceiling is enforced on the bytes read, not only on stat; the aggregate
budget is checked on declared sizes before any read and again on the bytes
actually read; base64 is measured before it is decoded. Files are read and
uploaded one at a time, so peak memory is one file in three copies (the
bytes, the multipart body, and the copy Node's fetch makes of the request
body). On the MCPL publish path a media block with malformed base64 fails
the publish; blocks the server cannot upload (no uploader, or a URI) are
dropped as before.
Attention
listen / unlisten (Zulip stream subscription), start_monitoring /
stop_monitoring / get_monitored_channels (read cursors for the plain-MCP
unread tools), channel_missed, mute_channel / unmute_channel,
set_reaction_visibility, filters_get / filters_update, refresh_channels.
Message ids are realm-global and monotonic, which makes them cursors: every
history line, <missed> block, and incoming message leads with one so the
agent can fetch_around it.
What the model reads. A delivered message carries its author, topic and
id as structured fields, but agent-framework's context strategies render
only the content blocks, so the body itself leads with them. Every place the
model reads a Zulip message line uses one shape: live delivery
(channels/incoming, push/event), messages recovered onto an open channel
after a gap or a refused batch, the <missed> catch-up block, the recent
history injected before inference, and fetch_history / fetch_around:
[2026-09-14T11:42:52+03:00 id=17206924] [#qa > router] Mykhailo Buialo (mention): do you have the same issue
[2026-09-14T11:42:52+03:00 id=17206925] [DM] Bo: hey, got a minute?
The time follows AGENT_TIMEZONE / AGENT_TIMESTAMP_STYLE; full drops its
[Zone] suffix on these lines (the offset fixes the instant), and none
keeps the id. (mention) marks a stream message that mentions the bot; a
direct message says [DM] instead. The reply affordance on a new DM precedes
the line; the attachment note and inlined images follow it. Header fields are
folded onto one line but not escaped, so a topic containing ]: reads
ambiguously to a regex. The backscroll returned on channels/open is not
prefixed: the host hands it to the agent as the channel_open tool result,
JSON that already shows each message's author and topic. The legacy
get_channel_history / get_unread_messages formats are unchanged.
Hosts that render provenance themselves. A prefixed message is stamped
attributed: true with attributionHeader (the exact prefix added) in its
metadata, and in the push/event origin, which agent-framework stores as the
message metadata on that path. A host strategy that builds its own
provenance header can skip it when the stamp is present, and one that scans
message text can strip the prefix first. connectome-host's frontdesk
strategy renders such a header ([zulip · #stream · topic "t" · @Author · time · msg N]); a frontdesk that does not yet honour the stamp shows both.
A host that renders the fields itself and cannot read the stamp sets
ZULIP_ATTRIBUTE_DELIVERY=false for bare bodies, in the environment the
server actually starts with: under connectome-host that is the host's own
environment or the env of the server's entry in mcpl-servers.json. A
recipe's env reaches the server only when the recipe defines the server
itself (command or url); for a server defined in mcpl-servers.json it
is not merged.
Gate filters see the prefix. agent-framework's wake gate matches a
policy's match.filter (substring or regex) against the joined text of the
message, which now starts with the line head. A pattern anchored with ^ on
the body stops matching, and a keyword that also appears in a stream, topic
or author name matches every message there. Anchor on the body after the
head (\] [^:]*: pattern), or match on channel, scope or tags instead.
State on disk
Under ZULIP_STATE_DIR, keyed by session:
<session>.json— the plain-MCP monitor (streams, last-read ids)<session>.delivery.json— watermarks, missed tallies, last-open channels<session>.filters.json— the filters plane (unlessZULIP_FILTERS_FILE)
Put ZULIP_STATE_DIR on storage that survives a redeploy. In a container
the default ~/.zulip_mcp_state lives in the writable layer and goes with
the image: every rebuild wipes the watermarks (the next start anchors
catch-up at "now") and the filters file (mutes, reaction visibility and
allowlist edits made through the tools are gone; the file re-seeds from the
environment). Under connectome-host, point it into the data volume, e.g.
"ZULIP_STATE_DIR": "${DATA_DIR}/<agent>/zulip-state".
Notes for operators
- Subscription is not optional. Zulip delivers stream events only to
subscribers, even with
all_public_streamson the event queue. Opening a channel subscribes the bot (and fails if it cannot — a private stream the bot was not invited to answerssuccesswith the stream underunauthorized, which this server treats as a refusal);ZULIP_SUBSCRIBEandlistendo it explicitly.listenalone leaves the channel closed: ambient is tallied and mentions become push events — the host must open the channel to receive its traffic. - State from 2.x. The plain-MCP monitor cursors (
<session>.json) are read as before but are not migrated into delivery watermarks: the first 3.x start anchors catch-up at "now". - No per-call timeouts on the Zulip API yet: the serve loop handles one host request at a time, so a hung Zulip call stalls the requests behind it. The host's own timeout abandons its request but does not unstall the loop; a server in that state needs a restart.
- zulip-js quirks (in
platforms/zulip-events.ts): booleans in POST bodies must be strings, arrays must be raw arrays; API errors come back as values (result: 'error'), which this server turns into thrown errors. - Debugging delivery: run the server standalone with the env of the recipe and watch stderr — hosts do not always capture MCPL child stderr.
Development
npm run build # tsc → build/
npm test # node --test test/*.test.ts (via tsx)
npm run watch
test/server.test.ts drives the real McplConnection over an in-memory stream
pair through the handshake, the policy exchange, registration, delivery,
catch-up, and the tools — the fastest way to see the wire behaviour.
License
MIT
