Blame
|
1 | --- |
||||||
| 2 | category: plan |
|||||||
| 3 | tags: [minsky, irc, mcp, bridge, implementation] |
|||||||
| 4 | last_updated: 2026-03-17 |
|||||||
| 5 | confidence: high |
|||||||
| 6 | --- |
|||||||
| 7 | ||||||||
| 8 | # IRC MCP Bridge Implementation Plan |
|||||||
| 9 | ||||||||
| 10 | ## Context |
|||||||
| 11 | ||||||||
| 12 | The minsky project needs an IRC MCP bridge — a FastMCP server that wraps IRC as MCP tools so that Claude Code SDK agents can communicate over IRC channels. This is the standalone communication layer from the [[Design/Agent_IRC_Architecture]] spec. The bridge has independent utility: any MCP client can use it to interact with IRC. |
|||||||
| 13 | ||||||||
|
14 | **Status: implemented** (branch `feat/irc-mcp-bridge`, 2026-03-19). 40 tests passing (26 unit + 14 integration against ergo). Tested end-to-end with a Sonnet subagent chatting over IRC via MCP tools. |
||||||
| 15 | ||||||||
| 16 | We use `uv` for dependency management, `pydle` 1.1 for async IRC, and `mcp[cli]` 1.26 for FastMCP. SSE transport only. |
|||||||
| 17 | ||||||||
| 18 | ### Implementation notes (post-build) |
|||||||
| 19 | ||||||||
| 20 | - **`pydle.ClientPool` cannot be used** — `handle_forever()` calls `asyncio.run()` which conflicts with FastMCP's already-running anyio event loop. Uses `asyncio.create_task()` + `asyncio.gather()` instead. |
|||||||
| 21 | - **pydle ISUPPORT bug with ergo** — ergo sends `MODES` without a value; pydle's `on_isupport_modes` crashes with `TypeError`. Fixed via `_ErgoCompatMixin` that guards `int(None)`. |
|||||||
| 22 | - **Channel/nick validation** added at the server layer to prevent IRC protocol injection. |
|||||||
| 23 | - **`MCP_HOST` env var** added (default `127.0.0.1`) for binding the SSE server to non-loopback interfaces. |
|||||||
|
24 | |||||||
| 25 | ## Why pydle over bottom |
|||||||
| 26 | ||||||||
| 27 | The original MVP plan specified `bottom`. After evaluating both against the installed source: |
|||||||
| 28 | ||||||||
| 29 | - **pydle** auto-handles PING/PONG, NICK registration, NAMES/member tracking, and IRCv3 CAP negotiation. `on_channel_message(target, by, message)` is a clean override. `ClientPool` manages multiple connections. Less manual wiring = fewer bugs over time. |
|||||||
| 30 | - **bottom** 3.0 requires manual PING handling, manual NAMES parsing, manual event handler lifecycle management, and all `send()` calls are async with quirky kwargs. Every protocol detail is DIY. |
|||||||
| 31 | ||||||||
| 32 | The transport abstraction means we can swap later, but pydle is the better long-term choice for infrastructure code. |
|||||||
| 33 | ||||||||
| 34 | ## Scope |
|||||||
| 35 | ||||||||
| 36 | Bridge only — no supervisor, no agent lifecycle, no docker-compose. Files live under `bridge/`. |
|||||||
| 37 | ||||||||
| 38 | ## File Structure |
|||||||
| 39 | ||||||||
| 40 | ``` |
|||||||
| 41 | bridge/ |
|||||||
| 42 | ├── pyproject.toml |
|||||||
| 43 | ├── src/minsky_bridge/ |
|||||||
| 44 | │ ├── __init__.py |
|||||||
| 45 | │ ├── transport.py # Transport Protocol + Message dataclass |
|||||||
| 46 | │ ├── memory_transport.py # In-memory impl (testing) |
|||||||
| 47 | │ ├── irc_transport.py # IRC impl (pydle) |
|||||||
| 48 | │ ├── server.py # FastMCP server: 5 tools + create_app() |
|||||||
| 49 | │ └── __main__.py # Entry point |
|||||||
| 50 | └── tests/ |
|||||||
| 51 | ├── conftest.py # --run-irc flag for integration tests |
|||||||
| 52 | ├── test_memory_transport.py |
|||||||
| 53 | ├── test_irc_transport.py # Integration tests, skip by default |
|||||||
| 54 | └── test_server.py |
|||||||
| 55 | ``` |
|||||||
| 56 | ||||||||
| 57 | Also at project root: `.env.example` |
|||||||
| 58 | ||||||||
| 59 | ## Steps |
|||||||
| 60 | ||||||||
| 61 | ### 1. Project scaffolding |
|||||||
| 62 | ||||||||
| 63 | **`bridge/pyproject.toml`** — hatchling build, Python 3.12+, deps: `mcp[cli]>=1.0`, `pydle>=1.0`. Dev deps: `pytest>=8.0`, `pytest-asyncio>=0.23`. `asyncio_mode = "auto"`. Script entry: `minsky-bridge = "minsky_bridge.__main__:main"`. |
|||||||
| 64 | ||||||||
| 65 | **`bridge/src/minsky_bridge/__init__.py`** — empty. |
|||||||
| 66 | ||||||||
| 67 | **`.env.example`** — `TRANSPORT_TYPE`, `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `MCP_PORT`. |
|||||||
| 68 | ||||||||
| 69 | Init git repo. |
|||||||
| 70 | ||||||||
| 71 | ### 2. Transport Protocol + Message |
|||||||
| 72 | ||||||||
| 73 | **`bridge/src/minsky_bridge/transport.py`** |
|||||||
| 74 | ||||||||
| 75 | ```python |
|||||||
| 76 | @dataclass(frozen=True) |
|||||||
| 77 | class Message: |
|||||||
| 78 | channel: str |
|||||||
| 79 | sender: str |
|||||||
| 80 | text: str |
|||||||
| 81 | timestamp: datetime |
|||||||
| 82 | ||||||||
| 83 | class Transport(Protocol): |
|||||||
| 84 | async def send(self, channel: str, message: str, sender: str) -> None: ... |
|||||||
| 85 | async def read(self, channel: str, since: datetime | None = None, limit: int = 50) -> list[Message]: ... |
|||||||
| 86 | async def create_channel(self, name: str) -> None: ... |
|||||||
| 87 | async def list_channels(self) -> list[str]: ... |
|||||||
| 88 | async def get_members(self, channel: str) -> list[str]: ... |
|||||||
| 89 | ``` |
|||||||
| 90 | ||||||||
| 91 | ### 3. MemoryTransport + tests (TDD) |
|||||||
| 92 | ||||||||
| 93 | **`bridge/tests/test_memory_transport.py`** — write tests first: |
|||||||
| 94 | - `test_create_channel` / `test_send_and_read` / `test_read_returns_newest_first` |
|||||||
| 95 | - `test_read_since_filters_by_time` / `test_read_limit` / `test_read_empty_channel` |
|||||||
| 96 | - `test_get_members` / `test_send_auto_creates_channel` |
|||||||
| 97 | ||||||||
| 98 | **`bridge/src/minsky_bridge/memory_transport.py`** — implement to pass tests. Dict-based storage, `reversed()` for newest-first. |
|||||||
| 99 | ||||||||
| 100 | ### 3. FastMCP server + tests (TDD) |
|||||||
| 101 | ||||||||
| 102 | **`bridge/tests/test_server.py`** — write tests first using `create_app(MemoryTransport())` + `app.call_tool(name, args)`. Returns `Sequence[ContentBlock]`; check `result[0].text`. |
|||||||
| 103 | ||||||||
| 104 | **`bridge/src/minsky_bridge/server.py`** — `create_app(transport, **kwargs) -> FastMCP`. 5 tools as closures over transport: |
|||||||
| 105 | ||||||||
| 106 | | Tool | Params | Returns | |
|||||||
| 107 | |------|--------|---------| |
|||||||
| 108 | | `send_message` | `channel, text, sender` | Confirmation string | |
|||||||
| 109 | | `read_messages` | `channel, since?, limit?` | `[HH:MM:SS] <nick> text` lines, newest first | |
|||||||
| 110 | | `create_channel` | `name` | Confirmation string | |
|||||||
| 111 | | `list_channels` | — | Bulleted channel list | |
|||||||
| 112 | | `get_members` | `channel` | Bulleted member list | |
|||||||
| 113 | ||||||||
| 114 | `since` is ISO 8601 string, parsed to datetime internally. `**kwargs` forwarded to `FastMCP()` constructor for `port`, `lifespan`, etc. |
|||||||
| 115 | ||||||||
| 116 | ### 5. IrcTransport (pydle) |
|||||||
| 117 | ||||||||
| 118 | **`bridge/src/minsky_bridge/irc_transport.py`** — the real IRC backend. |
|||||||
| 119 | ||||||||
| 120 | pydle API (verified against installed 1.1.0): |
|||||||
| 121 | - Subclass `pydle.Client`, override `on_channel_message(self, target, by, message)` |
|||||||
| 122 | - `on_connect(self)` — auto-join channels |
|||||||
| 123 | - `self.channels` — built-in dict tracking joined channels + members |
|||||||
| 124 | - `await self.join(channel)`, `await self.message(target, text)` |
|||||||
| 125 | - `await self.connect(hostname, port, tls=False)` |
|||||||
| 126 | - PING/PONG handled automatically |
|||||||
| 127 | - `pydle.ClientPool` for managing observer + per-sender connections |
|||||||
| 128 | ||||||||
| 129 | Design: |
|||||||
| 130 | - **Observer client** (subclass of `pydle.Client`): joins all channels, overrides `on_channel_message` to buffer `Message` objects into `dict[str, list[Message]]` |
|||||||
| 131 | - **Per-sender clients**: lazy-created, each a plain `pydle.Client` with its own nick. Join channels on demand. Used only for `send()` so agent messages have the right nick. |
|||||||
| 132 | - **Member tracking**: pydle's built-in `self.channels[channel]['users']` set — no manual NAMES query needed |
|||||||
| 133 | - **Pool management**: `pydle.ClientPool` to run all clients in one event loop |
|||||||
| 134 | - **Lock**: `asyncio.Lock` on `_get_sender()` and `create_channel()` for concurrency |
|||||||
| 135 | ||||||||
| 136 | **`bridge/tests/conftest.py`** — `pytest_addoption` for `--run-irc`. |
|||||||
| 137 | ||||||||
| 138 | **`bridge/tests/test_irc_transport.py`** — integration tests, skipped without `--run-irc` flag. |
|||||||
| 139 | ||||||||
| 140 | ### 6. Entry point |
|||||||
| 141 | ||||||||
| 142 | **`bridge/src/minsky_bridge/__main__.py`** |
|||||||
| 143 | ||||||||
| 144 | ```python |
|||||||
| 145 | def main(): |
|||||||
| 146 | transport_type = os.environ.get("TRANSPORT_TYPE", "irc") |
|||||||
| 147 | port = int(os.environ.get("MCP_PORT", "8090")) |
|||||||
| 148 | ||||||||
| 149 | if transport_type == "memory": |
|||||||
| 150 | transport = MemoryTransport() |
|||||||
| 151 | app = create_app(transport, port=port) |
|||||||
| 152 | elif transport_type == "irc": |
|||||||
| 153 | transport = IrcTransport(server=..., port=..., observer_nick=...) |
|||||||
| 154 | ||||||||
| 155 | @asynccontextmanager |
|||||||
| 156 | async def lifespan(app): |
|||||||
| 157 | await transport.connect() |
|||||||
| 158 | try: |
|||||||
| 159 | yield {} |
|||||||
| 160 | finally: |
|||||||
| 161 | await transport.disconnect() |
|||||||
| 162 | ||||||||
| 163 | app = create_app(transport, port=port, lifespan=lifespan) |
|||||||
| 164 | ||||||||
| 165 | app.run(transport="sse") |
|||||||
| 166 | ``` |
|||||||
| 167 | ||||||||
| 168 | The lifespan pattern lets IRC connect/disconnect share FastMCP's event loop (FastMCP calls `anyio.run()` internally). |
|||||||
| 169 | ||||||||
| 170 | **Event loop concern**: pydle uses `asyncio` internally. FastMCP uses `anyio` (asyncio backend). These are compatible — pydle's client pool needs to run inside the same loop. The `lifespan` context manager handles this: connect observer + start pool inside FastMCP's loop, tear down on shutdown. |
|||||||
| 171 | ||||||||
| 172 | ## Not in scope |
|||||||
| 173 | ||||||||
| 174 | - Message chunking for `maxline` (add later when ergo is running) |
|||||||
| 175 | - TLS for IRC connection |
|||||||
| 176 | - Docker/Dockerfile |
|||||||
| 177 | - Supervisor, agent lifecycle, prompts |
|||||||
| 178 | - `names.txt`, `docker-compose.yml` |
|||||||
| 179 | - stdio MCP transport |
|||||||
| 180 | ||||||||
| 181 | ## Verification |
|||||||
| 182 | ||||||||
| 183 | 1. **Unit tests**: `cd bridge && uv run pytest tests/test_memory_transport.py tests/test_server.py -v` — all pass, no IRC needed |
|||||||
| 184 | 2. **Smoke test with memory transport**: `TRANSPORT_TYPE=memory MCP_PORT=8090 uv run minsky-bridge` — starts SSE server on port 8090, verify with curl or MCP client |
|||||||
| 185 | 3. **Integration test** (requires ergo): `uv run pytest tests/test_irc_transport.py -v --run-irc` |
|||||||
