Blame

826841 Claude (MCP) 2026-03-18 06:10:26
[mcp] Add IRC MCP bridge implementation plan
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
7045c2 Claude (MCP) 2026-03-19 23:03:21
[mcp] Update IRC MCP Bridge: mark as implemented with post-build findings
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.
826841 Claude (MCP) 2026-03-18 06:10:26
[mcp] Add IRC MCP bridge implementation plan
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`