Run AI agents in Zulip as @mentionable bots or connect Zulip to any MCP client, with real-time listening, session management and file handling.

التثبيت

خطوات التثبيت موجودة في التوثيق أدناه. التوثيق

التوثيق

مأخوذ من ملف README للمشروع على GitHub ويُحدَّث أسبوعيًا. تفتح الروابط على GitHub.

المحتويات
  1. zulipmcp
  2. Quickstart
  3. Requirements
  4. Entry Points
  5. Library Usage
  6. Hermes Gateway
  7. Direct launch (no listener)
  8. Listener
  9. Key Design Details
  10. Environment Variables
  11. License

zulipmcp

Run AI agents in Zulip as @mentionable bots — or wire into any MCP client. Also works as a Python library.

Quickstart

  1. Install the package:
     uv add git+https://github.com/windborne/zulipmcp.git
  2. Add a .zuliprc file to your project root with your Zulip bot credentials. See Add a bot or integration for instructions on making a bot. The bot type must be "generic."
  3. Add the MCP server to your .mcp.json:
     {
       "mcpServers": {
         "zulip": {
           "command": "uv",
           "args": ["run", "python", "-m", "zulipmcp"]
         }
       }
     }
  4. Restart your MCP client. The Zulip tools should now be available.
Read as yourself, post as a bot

A bot can't read private channels it isn't in or DMs it isn't part of. To let an agent read everything you can see while it still posts as the bot, run two servers: one on the bot's zuliprc, and a read-only one on a zuliprc holding your personal API key. Installing the zulipmcp command once keeps launches from re-resolving dependencies:

uv tool install git+https://github.com/windborne/zulipmcp.git   # update later with: uv tool upgrade zulipmcp

uv tool install ignores the lockfile; append @<tag-or-commit> to the URL to pin a version.

Desktop MCP clients often don't inherit your shell's PATH; if zulipmcp isn't found, use the absolute path under uv tool dir --bin.

{
  "mcpServers": {
    "zulip": {
      "command": "zulipmcp",
      "env": {"ZULIP_RC_PATH": "/path/to/bot.zuliprc"}
    },
    "zulip-personal": {
      "command": "zulipmcp",
      "env": {
        "ZULIP_RC_PATH": "/path/to/personal.zuliprc",
        "ZULIPMCP_READ_ONLY": "1",
        "BOT_ALLOWED_PRIVATE_STREAMS": "__ALL__"
      }
    }
  }
}

__ALL__ lets that server read DMs and search everything the account received. Read-only mode constrains this server, not an agent that can read the personal zuliprc file itself.

Requirements

  • Python >=3.10, managed with uv
  • Zulip Server 9.0+ (before date bounds need 12.0+)
  • A .zuliprc file for Zulip API auth (see Quickstart)
  • For listener mode: the selected backend CLI installed and authenticated (claude by default, codex with --backend codex, or opencode with --backend opencode)

Entry Points

Entry PointDescription
uv run python -m zulipmcp (or zulipmcp after uv tool install)MCP server for Claude Code, Codex, and other MCP clients
uv run python -m zulipmcp --transport sseMCP server over SSE (for remote/web clients)
uv run python -m zulipmcp.launch_agentLaunch one MCP-backed coding session without a listener
uv run python -m zulipmcp.listenerListener: watches for @mentions, spawns agent sessions

Library Usage

zulipmcp can also be imported directly as a Python library:

import zulipmcp

# Fetch and format the latest page of a channel
page = zulipmcp.fetch_narrow(zulipmcp.build_narrow(stream="engineering"))
print(zulipmcp.format_messages(page.messages))

# Send a message
zulipmcp.send_message("engineering", "general", "Hello from Python!")

# Configure MCP hooks before starting the server
zulipmcp.configure(
    message_prefix=lambda: "[bot] ",
    on_session_end=lambda session: print(f"Session ended in #{session.stream}"),
    auto_typing_start=False,  # another process drives the typing indicator
)

Hermes Gateway

Hermes uses the native hermes_plugin/zulip gateway adapter instead of the subprocess listener. Each stream/topic maps to its own persistent Hermes session, while zulipmcp remains available as the explicit Zulip API tool layer.

  • Mention the bot once to activate a topic; follow-ups do not need a mention.
  • Each user message is a fresh Hermes turn with a fresh iteration budget.
  • Typing, normal response delivery, status reactions, approval reactions, and :stop_sign: interruption are handled by the adapter.
  • Only same-topic history is added on first activation.
  • After a gateway restart, mention once to reactivate the topic; Hermes resumes the same stored session.

See the plugin README for installation, configuration, access controls, and verification.

Direct launch (no listener)

An existing coordinator can start one Claude Code or Codex session directly. The coding agent uses its configured zulip MCP server to initialize the target topic, reply there, and wait for follow-ups:

python -m zulipmcp.launch_agent \
  --backend claude \
  --agent-command /path/to/claude \
  --stream engineering \
  --topic "large coding task" \
  --working-dir /path/to/project

python -m zulipmcp.launch_agent \
  --backend codex \
  --agent-command /path/to/codex \
  --stream engineering \
  --topic "large coding task" \
  --working-dir /path/to/project

The launcher does not watch Zulip or spawn on mentions. It starts exactly one foreground coding-agent process and reuses the bundled session instructions. Run it as a background process when the coordinator should continue working. By default the agent listens in renewable two-hour intervals and does not exit merely because an interval timed out. Use --trigger-message-id and --session-user-email when that metadata is available. Pass --zuliprc or --mcp-config only when the agent should not use its existing global MCP configuration. An explicit Claude MCP config is loaded strictly so a project-local server with the same name cannot shadow it.

Listener

The optional zulipmcp.listener module watches Zulip for @mentions and spawns one headless agent session per (stream, topic). It supports Claude Code by default, Codex with --backend codex, and OpenCode with --backend opencode. It's the glue between Zulip events and the agent backend -- the MCP server handles all the Zulip tools, the listener just handles lifecycle.

# Minimal -- uses ./.zuliprc, ./.mcp.json (if present), and the bundled default prompt
uv run python -m zulipmcp.listener

# Full -- override MCP config and system prompt
uv run python -m zulipmcp.listener \
    --mcp-config .mcp.json \
    --system-prompt agent.md \
    --log-dir ./logs

# Recommended: Claude Code with Opus 4.6
uv run python -m zulipmcp.listener -- --model claude-opus-4-6

# Recommended: Codex with GPT-5.5 and medium reasoning
uv run python -m zulipmcp.listener --backend codex -- \
    --model gpt-5.5 \
    -c 'model_reasoning_effort="medium"'

# Recommended: OpenCode with any provider (Qwen, Llama, Gemini, etc.)
uv run python -m zulipmcp.listener --backend opencode \
    --opencode-model anthropic/claude-sonnet-4-5

uv run python -m zulipmcp.listener --backend opencode \
    --opencode-model ollama/qwen3:235b

# Pass additional backend-specific flags after --
uv run python -m zulipmcp.listener -- --strict-mcp-config --effort medium
uv run python -m zulipmcp.listener --backend codex -- -c 'model_verbosity="low"'
uv run python -m zulipmcp.listener --backend opencode -- --verbose

Flags:

FlagDefaultDescription
--zuliprc./.zuliprcPath to .zuliprc (resolved relative to current working directory)
--backendclaudeAgent backend to launch: claude, codex, or opencode
--agent-commandbackend nameBackend CLI binary name or path
--mcp-config./.mcp.jsonPath to .mcp.json for agent sessions (used only if the file exists). Codex translates supported command and url servers into one-run -c mcp_servers... overrides. OpenCode embeds the translated config in OPENCODE_CONFIG_CONTENT.
--system-promptzulipmcp/default_system_prompt.mdSystem prompt file. Claude receives it as an appended system prompt; Codex receives it as developer instructions; OpenCode receives it via the instructions config field.
--working-dir.Working directory for spawned sessions
--log-dir./logsDirectory for session log files
--codex-permission-modeparityCodex-only permission preset. parity uses --yolo for full bypass like the Claude default and assumes external sandboxing; workspace-write and read-only use noninteractive sandboxed modes; none adds no permission flags.
--opencode-model(none)OpenCode model in provider/model format (e.g. anthropic/claude-sonnet-4-5, ollama/qwen3:235b). When omitted, OpenCode uses its own default.
--opencode-agent(none)OpenCode agent name (passed as --agent). When omitted, OpenCode uses its default agent.
-- ...(none)Everything after -- is forwarded to the selected backend as-is. For Codex, known top-level-only flags are placed before exec automatically.

Each session gets TRIGGER_MESSAGE_ID and SESSION_USER_EMAIL set automatically so set_context() anchors to the @mention and hooks can identify the requester.

The listener intentionally does not set model or reasoning defaults. Backend CLIs and model aliases move over time, so use the backend's user config or pass flags after --. For reproducible production behavior, pin exact backend model IDs in your deployment config instead of relying on aliases.

Custom --system-prompt files are backend instructions, not the initial task. The listener still sends a short per-session bootstrap prompt with the target stream/topic and the Zulip lifecycle contract: initialize context, send visible text through reply(), then call listen() when yielding for follow-ups.

Codex sessions launch with web search enabled to match Claude Code's default web-fetch capability. For Codex, the .mcp.json adapter whitelists inherited environment variable names for translated stdio MCP servers, mirroring Claude-style subprocess inheritance without putting env values in argv. It also forwards Zulip's direct auto-init SESSION_STREAM/SESSION_TOPIC pair when present and sets tool_timeout_sec to at least 3 hours so the long-running listen() tool can wait for follow-ups. The adapter is intentionally conservative: Claude SSE config is rejected, only command and streamable HTTP url servers are translated, and environment placeholders are supported only in env/header values that can stay out of process argv.

OpenCode sessions receive the full config via OPENCODE_CONFIG_CONTENT (inline JSON). The .mcp.json adapter translates command-based servers to OpenCode's local type (merging command+args into a single array) and url-based servers to remote type, renaming env to environment. Each translated server gets a 3-hour MCP timeout so listen() can block for follow-ups; listen() sends MCP progress notifications during its long-poll loop, which OpenCode uses to reset its per-call timeout. The system prompt file path is passed via the instructions config field.

The listener is deliberately minimal. It omits concurrency caps, workspace isolation, staleness watchdogs, and dashboards -- add those when you need them.

Key Design Details

Listening for messages

The listen tool uses Zulip's real-time events API (long-polling) instead of repeated GET /messages calls. On entry it catches up on any messages since last_seen_message_id, subscribes the bot to the stream if needed, registers a narrowed event queue for the stream/topic, and then long-polls via GET /events. The server blocks until a message arrives or ~90 seconds elapse (heartbeat), making this ~30x more efficient than polling every 2 seconds. If the queue expires (BAD_EVENT_QUEUE_ID), it re-registers automatically. The queue is deleted in a finally block on exit.

An ear emoji is added to the last message as a visual indicator while listening and removed when listening stops. If the realm has a custom emoji named robot_ear, that is used instead. MCP keepalive pings are sent via ctx.info() after each long-poll cycle.

No missed messages on reply

When reply is called, it checks for new messages before sending. If anyone posted while the LLM was thinking, those messages are fetched and returned alongside the "message sent" confirmation. This way the LLM always sees what it missed and can react accordingly. The last_seen_message_id is updated to whichever is newest -- the missed messages or the sent message -- so nothing falls through the cracks.

Session dismissal

Users can dismiss a bot session by reacting with a configurable emoji (default: :stop_sign:) on any bot message. The dismiss check runs both during listen() (via reaction events) and before reply() (via REST API poll), covering the race condition where a user reacts while the bot is busy working. Customize with configure(dismiss_emoji={"stop_sign", "wave"}).

Typing indicator

The typing indicator starts on set_context() and after every reply(), and stops on listen() and end_session(), so users see the bot as "typing" while it works. If another process drives the indicator, configure(auto_typing_start=False) suppresses the automatic starts so they cannot overwrite it; the stops still run, since clearing the indicator on idle and exit is right whoever set it, and reply() sends a stop in place of its start so the status shown while composing does not outlive the message. The explicit typing() / stop_typing() tools are unaffected.

get_messages reads one conversation (stream+topic, dm_with, or up to a message_id); search_messages wraps Zulip's native keyword search over a stream, everything this account received (scope="received"), or all public channels (scope="public", the default without __ALL__). Both page backward with the before_message_id each response names and accept ISO 8601 after/before bounds (before needs Zulip 12.0+). Both filter with only (unread, mentioned, starred, plus dm on search; ANDed); search_messages adds dm_including and max_chars (clips each hit). Formatted messages are tagged mentioned="true" when they mention this account and, on human accounts, unread="true".

Bot visibility filtering

Topics containing /nobots or /nb are hidden from the bot entirely. Messages starting with /nobots or /nb are also filtered out. This lets humans have private conversations the bot won't see.

Outgoing markdown normalization

normalize_zulip_markdown() runs on every send/reply/edit before content hits the API, fixing two GFM habits Zulip's renderer mishandles: missing blank lines before tables, and bold combined with links ([**a**](url) renders as literal asterisks with an unclickable link; a bare URL touching ** breaks the autolink). Fenced and indented code blocks are always left untouched; inline code spans are additionally preserved for the bold/link fix. The Hermes gateway sends via the raw client and is not normalized. Disable entirely with ZULIPMCP_MARKDOWN_AUTOFIX=0.

Environment Variables

VariableDescription
ZULIP_RC_PATHAbsolute path to .zuliprc for direct MCP server or Hermes gateway use. Listener mode sets this for spawned sessions from --zuliprc; it does not read ambient ZULIP_RC_PATH as its own default.
ZULIPMCP_TIMEZONEIANA timezone used to display message timestamps and to interpret date-only and naive after/before values, such as Europe/London or Asia/Tokyo. Defaults to America/Los_Angeles.
ZULIP_MAX_MESSAGE_LENGTHChar limit above which send tools return an error instead of letting Zulip silently truncate. Defaults to 10000 (Zulip's default); set for realms with a custom cap.
TRIGGER_MESSAGE_IDMessage ID that triggered the session (e.g. the @mention). Sets the listen anchor so the agent doesn't miss messages after the trigger.
SESSION_USER_EMAILEmail of the human who triggered the session. Stored on SessionState for hooks.
SESSION_STREAMStream name for auto-initializing a session on server start (direct run_server() callers only -- the listener does not use these). Both SESSION_STREAM and SESSION_TOPIC must be set; the agent can then skip set_context().
SESSION_TOPICTopic for auto-init. Requires SESSION_STREAM.
BOT_ALLOWED_PRIVATE_STREAMSPrivate-stream read/send allowlist. Unset = no private-stream access. Accepts __ALL__, a JSON list, or comma-separated names. Without __ALL__, reads must name an allowed stream or public channels, and DMs are refused; with it, search_messages defaults to scope="received".
BOT_ALLOWED_WRITE_STREAMSStream send allowlist. Unset = writes allowed everywhere (backwards-compatible). Same formats as above.
ZULIPMCP_READ_ONLY1/true/yes/on: the client refuses every non-GET request, and write and session tools are hidden. Unset, empty or 0/false/no/off = off; anything else is a startup error. See Read as yourself, post as a bot.
ZULIPMCP_CACHE_DIROverride the disk cache base directory (defaults to system temp dir). Each site and account gets its own subdirectory, so servers running as different users never share cached data.
ZULIPMCP_LOG_DIRServer log directory (defaults to /tmp/zulipmcp_logs); written by run_server(), never on import.
ZULIPMCP_MARKDOWN_AUTOFIXDisables all outgoing markdown normalization (blank-line-before-table injection and bold/link rewrites) when set to 0 or false. Defaults to enabled.

License

Apache 2.0

الأسئلة الشائعة

ما هو Zulipmcp؟

Run AI agents in Zulip as @mentionable bots or connect Zulip to any MCP client, with real-time listening, session management and file handling.

كيف أثبّت Zulipmcp؟

اتبع خطوات التثبيت في توثيقه المعروض في هذه الصفحة وفي مستودعه.

هل Zulipmcp مفتوح المصدر؟

نعم. شيفرته متاحة على GitHub بترخيص Apache-2.0.

من يصون Zulipmcp؟

ينشره windborne. يدرجه AI.tn لكنه لا يصونه ولا يوصي به.

من القوائم المجتمعية awesome-mcp-servers ومستودع المشروع على GitHub. الإدراج ليس توصية: راجع شيفرة الخادم وصلاحياته قبل منحه الوصول إلى بياناتك.