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 | The project is greenfield (no code yet). We use `uv` for dependency management, `pydle` 1.1 for async IRC, and `mcp[cli]` 1.26 for FastMCP. SSE transport only. |
|||||||
| 15 | ||||||||
| 16 | ## Why pydle over bottom |
|||||||
| 17 | ||||||||
| 18 | The original MVP plan specified `bottom`. After evaluating both against the installed source: |
|||||||
| 19 | ||||||||
| 20 | - **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. |
|||||||
| 21 | - **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. |
|||||||
| 22 | ||||||||
| 23 | The transport abstraction means we can swap later, but pydle is the better long-term choice for infrastructure code. |
|||||||
| 24 | ||||||||
| 25 | ## Scope |
|||||||
| 26 | ||||||||
| 27 | Bridge only — no supervisor, no agent lifecycle, no docker-compose. Files live under `bridge/`. |
|||||||
| 28 | ||||||||
| 29 | ## File Structure |
|||||||
| 30 | ||||||||
| 31 | ``` |
|||||||
| 32 | bridge/ |
|||||||
| 33 | ├── pyproject.toml |
|||||||
| 34 | ├── src/minsky_bridge/ |
|||||||
| 35 | │ ├── __init__.py |
|||||||
| 36 | │ ├── transport.py # Transport Protocol + Message dataclass |
|||||||
| 37 | │ ├── memory_transport.py # In-memory impl (testing) |
|||||||
| 38 | │ ├── irc_transport.py # IRC impl (pydle) |
|||||||
| 39 | │ ├── server.py # FastMCP server: 5 tools + create_app() |
|||||||
| 40 | │ └── __main__.py # Entry point |
|||||||
| 41 | └── tests/ |
|||||||
| 42 | ├── conftest.py # --run-irc flag for integration tests |
|||||||
| 43 | ├── test_memory_transport.py |
|||||||
| 44 | ├── test_irc_transport.py # Integration tests, skip by default |
|||||||
| 45 | └── test_server.py |
|||||||
| 46 | ``` |
|||||||
| 47 | ||||||||
| 48 | Also at project root: `.env.example` |
|||||||
| 49 | ||||||||
| 50 | ## Steps |
|||||||
| 51 | ||||||||
| 52 | ### 1. Project scaffolding |
|||||||
| 53 | ||||||||
| 54 | **`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"`. |
|||||||
| 55 | ||||||||
| 56 | **`bridge/src/minsky_bridge/__init__.py`** — empty. |
|||||||
| 57 | ||||||||
| 58 | **`.env.example`** — `TRANSPORT_TYPE`, `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `MCP_PORT`. |
|||||||
| 59 | ||||||||
| 60 | Init git repo. |
|||||||
| 61 | ||||||||
| 62 | ### 2. Transport Protocol + Message |
|||||||
| 63 | ||||||||
| 64 | **`bridge/src/minsky_bridge/transport.py`** |
|||||||
| 65 | ||||||||
| 66 | ```python |
|||||||
| 67 | @dataclass(frozen=True) |
|||||||
| 68 | class Message: |
|||||||
| 69 | channel: str |
|||||||
| 70 | sender: str |
|||||||
| 71 | text: str |
|||||||
| 72 | timestamp: datetime |
|||||||
| 73 | ||||||||
| 74 | class Transport(Protocol): |
|||||||
| 75 | async def send(self, channel: str, message: str, sender: str) -> None: ... |
|||||||
| 76 | async def read(self, channel: str, since: datetime | None = None, limit: int = 50) -> list[Message]: ... |
|||||||
| 77 | async def create_channel(self, name: str) -> None: ... |
|||||||
| 78 | async def list_channels(self) -> list[str]: ... |
|||||||
| 79 | async def get_members(self, channel: str) -> list[str]: ... |
|||||||
| 80 | ``` |
|||||||
| 81 | ||||||||
| 82 | ### 3. MemoryTransport + tests (TDD) |
|||||||
| 83 | ||||||||
| 84 | **`bridge/tests/test_memory_transport.py`** — write tests first: |
|||||||
| 85 | - `test_create_channel` / `test_send_and_read` / `test_read_returns_newest_first` |
|||||||
| 86 | - `test_read_since_filters_by_time` / `test_read_limit` / `test_read_empty_channel` |
|||||||
| 87 | - `test_get_members` / `test_send_auto_creates_channel` |
|||||||
| 88 | ||||||||
| 89 | **`bridge/src/minsky_bridge/memory_transport.py`** — implement to pass tests. Dict-based storage, `reversed()` for newest-first. |
|||||||
| 90 | ||||||||
| 91 | ### 3. FastMCP server + tests (TDD) |
|||||||
| 92 | ||||||||
| 93 | **`bridge/tests/test_server.py`** — write tests first using `create_app(MemoryTransport())` + `app.call_tool(name, args)`. Returns `Sequence[ContentBlock]`; check `result[0].text`. |
|||||||
| 94 | ||||||||
| 95 | **`bridge/src/minsky_bridge/server.py`** — `create_app(transport, **kwargs) -> FastMCP`. 5 tools as closures over transport: |
|||||||
| 96 | ||||||||
| 97 | | Tool | Params | Returns | |
|||||||
| 98 | |------|--------|---------| |
|||||||
| 99 | | `send_message` | `channel, text, sender` | Confirmation string | |
|||||||
| 100 | | `read_messages` | `channel, since?, limit?` | `[HH:MM:SS] <nick> text` lines, newest first | |
|||||||
| 101 | | `create_channel` | `name` | Confirmation string | |
|||||||
| 102 | | `list_channels` | — | Bulleted channel list | |
|||||||
| 103 | | `get_members` | `channel` | Bulleted member list | |
|||||||
| 104 | ||||||||
| 105 | `since` is ISO 8601 string, parsed to datetime internally. `**kwargs` forwarded to `FastMCP()` constructor for `port`, `lifespan`, etc. |
|||||||
| 106 | ||||||||
| 107 | ### 5. IrcTransport (pydle) |
|||||||
| 108 | ||||||||
| 109 | **`bridge/src/minsky_bridge/irc_transport.py`** — the real IRC backend. |
|||||||
| 110 | ||||||||
| 111 | pydle API (verified against installed 1.1.0): |
|||||||
| 112 | - Subclass `pydle.Client`, override `on_channel_message(self, target, by, message)` |
|||||||
| 113 | - `on_connect(self)` — auto-join channels |
|||||||
| 114 | - `self.channels` — built-in dict tracking joined channels + members |
|||||||
| 115 | - `await self.join(channel)`, `await self.message(target, text)` |
|||||||
| 116 | - `await self.connect(hostname, port, tls=False)` |
|||||||
| 117 | - PING/PONG handled automatically |
|||||||
| 118 | - `pydle.ClientPool` for managing observer + per-sender connections |
|||||||
| 119 | ||||||||
| 120 | Design: |
|||||||
| 121 | - **Observer client** (subclass of `pydle.Client`): joins all channels, overrides `on_channel_message` to buffer `Message` objects into `dict[str, list[Message]]` |
|||||||
| 122 | - **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. |
|||||||
| 123 | - **Member tracking**: pydle's built-in `self.channels[channel]['users']` set — no manual NAMES query needed |
|||||||
| 124 | - **Pool management**: `pydle.ClientPool` to run all clients in one event loop |
|||||||
| 125 | - **Lock**: `asyncio.Lock` on `_get_sender()` and `create_channel()` for concurrency |
|||||||
| 126 | ||||||||
| 127 | **`bridge/tests/conftest.py`** — `pytest_addoption` for `--run-irc`. |
|||||||
| 128 | ||||||||
| 129 | **`bridge/tests/test_irc_transport.py`** — integration tests, skipped without `--run-irc` flag. |
|||||||
| 130 | ||||||||
| 131 | ### 6. Entry point |
|||||||
| 132 | ||||||||
| 133 | **`bridge/src/minsky_bridge/__main__.py`** |
|||||||
| 134 | ||||||||
| 135 | ```python |
|||||||
| 136 | def main(): |
|||||||
| 137 | transport_type = os.environ.get("TRANSPORT_TYPE", "irc") |
|||||||
| 138 | port = int(os.environ.get("MCP_PORT", "8090")) |
|||||||
| 139 | ||||||||
| 140 | if transport_type == "memory": |
|||||||
| 141 | transport = MemoryTransport() |
|||||||
| 142 | app = create_app(transport, port=port) |
|||||||
| 143 | elif transport_type == "irc": |
|||||||
| 144 | transport = IrcTransport(server=..., port=..., observer_nick=...) |
|||||||
| 145 | ||||||||
| 146 | @asynccontextmanager |
|||||||
| 147 | async def lifespan(app): |
|||||||
| 148 | await transport.connect() |
|||||||
| 149 | try: |
|||||||
| 150 | yield {} |
|||||||
| 151 | finally: |
|||||||
| 152 | await transport.disconnect() |
|||||||
| 153 | ||||||||
| 154 | app = create_app(transport, port=port, lifespan=lifespan) |
|||||||
| 155 | ||||||||
| 156 | app.run(transport="sse") |
|||||||
| 157 | ``` |
|||||||
| 158 | ||||||||
| 159 | The lifespan pattern lets IRC connect/disconnect share FastMCP's event loop (FastMCP calls `anyio.run()` internally). |
|||||||
| 160 | ||||||||
| 161 | **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. |
|||||||
| 162 | ||||||||
| 163 | ## Not in scope |
|||||||
| 164 | ||||||||
| 165 | - Message chunking for `maxline` (add later when ergo is running) |
|||||||
| 166 | - TLS for IRC connection |
|||||||
| 167 | - Docker/Dockerfile |
|||||||
| 168 | - Supervisor, agent lifecycle, prompts |
|||||||
| 169 | - `names.txt`, `docker-compose.yml` |
|||||||
| 170 | - stdio MCP transport |
|||||||
| 171 | ||||||||
| 172 | ## Verification |
|||||||
| 173 | ||||||||
| 174 | 1. **Unit tests**: `cd bridge && uv run pytest tests/test_memory_transport.py tests/test_server.py -v` — all pass, no IRC needed |
|||||||
| 175 | 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 |
|||||||
| 176 | 3. **Integration test** (requires ergo): `uv run pytest tests/test_irc_transport.py -v --run-irc` |
|||||||
