muxr 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/muxr-mcp ADDED
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # muxr-mcp — Model Context Protocol bridge for muxr.
5
+ #
6
+ # Speaks MCP JSON-RPC over stdio (one JSON object per line) and translates
7
+ # tool calls into NDJSON requests on the muxr control socket at
8
+ # ~/.muxr/sockets/<name>.ctrl.sock. Connection target is determined by:
9
+ #
10
+ # 1. $MUXR_CONTROL_SOCKET (absolute path) — exact override
11
+ # 2. $MUXR_SESSION (session name) — resolved to standard path
12
+ # 3. otherwise: error
13
+ #
14
+ # Both env vars are injected automatically when claude is launched from the
15
+ # muxr Claude-drawer (Ctrl-a C), so end users almost never set them by hand.
16
+ #
17
+ # Typical claude-code mcp config:
18
+ #
19
+ # {
20
+ # "mcpServers": {
21
+ # "muxr": { "command": "/abs/path/to/bin/muxr-mcp" }
22
+ # }
23
+ # }
24
+
25
+ require "json"
26
+ require "socket"
27
+
28
+ class MuxrMcpBridge
29
+ PROTOCOL_VERSION = "2024-11-05" # MCP protocol revision the bridge speaks.
30
+ SERVER_NAME = "muxr-mcp"
31
+ SERVER_VERSION = "0.1.0"
32
+
33
+ # Map each exposed MCP tool name to the underlying muxr control method.
34
+ # The tool surface is intentionally a flat one-to-one — every method shipped
35
+ # by the control server gets a tool. Names are snake_case and prefixed with
36
+ # `muxr_` so they sit cleanly in Claude's tool list alongside other servers.
37
+ TOOL_TO_MUXR_METHOD = {
38
+ "muxr_ping" => "ping",
39
+ "muxr_session_get" => "session.get",
40
+ "muxr_session_save" => "session.save",
41
+ "muxr_panes_list" => "panes.list",
42
+ "muxr_pane_read" => "pane.read",
43
+ "muxr_pane_send_input" => "pane.send_input",
44
+ "muxr_pane_focus" => "pane.focus",
45
+ "muxr_pane_new" => "pane.new",
46
+ "muxr_pane_kill" => "pane.kill",
47
+ "muxr_pane_promote" => "pane.promote",
48
+ "muxr_pane_run" => "pane.run",
49
+ "muxr_layout_set" => "layout.set",
50
+ "muxr_layout_cycle" => "layout.cycle",
51
+ "muxr_drawer_toggle" => "drawer.toggle",
52
+ "muxr_drawer_show" => "drawer.show",
53
+ "muxr_drawer_hide" => "drawer.hide",
54
+ "muxr_drawer_reset" => "drawer.reset",
55
+ "muxr_drawer_send_input"=> "drawer.send_input",
56
+ "muxr_drawer_read" => "drawer.read"
57
+ }.freeze
58
+
59
+ # Reusable JSON schema fragment: pane identifier. We expose only the
60
+ # string-id form via MCP (slot-as-integer would have required `oneOf`,
61
+ # which the Anthropic tool-use API doesn't accept). The id-only contract
62
+ # matches the skill's "always use ids" guidance anyway. Callers get the
63
+ # ids from muxr_panes_list.
64
+ PANE_REF = {
65
+ "type" => "string",
66
+ "description" => "Stable 6-hex pane id from muxr_panes_list (e.g. \"a3f9b2\"). Ids survive splits, kills, and promote_to_master — always prefer them over the positional slot numbers shown in the status bar."
67
+ }.freeze
68
+
69
+ TOOL_SCHEMAS = [
70
+ {
71
+ "name" => "muxr_session_get",
72
+ "description" => "Get a summary of the muxr session: name, layout, focused pane, drawer visibility, dimensions, pane count. Call this first to ground yourself in the current state.",
73
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
74
+ },
75
+ {
76
+ "name" => "muxr_panes_list",
77
+ "description" => "List every pane with its stable id, 1-based slot number, focused/master flags, cwd, and grid dimensions. Always reference panes by id in subsequent calls (slots shift on split/kill).",
78
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
79
+ },
80
+ {
81
+ "name" => "muxr_pane_read",
82
+ "description" => "Read the currently-visible terminal contents of a pane as plain text (one row per line, trailing whitespace trimmed). Cheap and idempotent.",
83
+ "inputSchema" => {
84
+ "type" => "object",
85
+ "properties" => { "pane" => PANE_REF },
86
+ "required" => ["pane"],
87
+ "additionalProperties" => false
88
+ }
89
+ },
90
+ {
91
+ "name" => "muxr_pane_send_input",
92
+ "description" => "Send raw bytes to a pane's PTY without waiting. For full commands prefer muxr_pane_run, which sends + waits for output to settle. Set bracketed=true when sending multi-line text into an editor or REPL. When the sequence needs any special key (Escape, Enter-as-terminator, arrows, Ctrl-*), use `keys` instead of `data` — sending \"\\n\" or \"\\e\" through `data` is a footgun that easily leaves vim in insert mode.",
93
+ "inputSchema" => {
94
+ "type" => "object",
95
+ "properties" => {
96
+ "pane" => PANE_REF,
97
+ "data" => { "type" => "string", "description" => "Literal bytes to send, sent exactly as-is (no \\r appended). Lower-level than `keys` — use `keys` whenever any named special key is involved. Mutually exclusive with `keys`." },
98
+ "keys" => {
99
+ "type" => "array",
100
+ "items" => { "type" => "string" },
101
+ "description" => "Mixed array of literal text and vim-style named keys. Each element is either literal text or a single `<name>`. Supported names (case-insensitive): <esc>, <enter>/<cr>, <tab>, <s-tab>, <bs>, <space>, <up>/<down>/<left>/<right>, <home>, <end>, <pageup>, <pagedown>, <c-a>..<c-z>, <f1>..<f12>. Mutually exclusive with `data`. Example for vim open-paste-save: [\"G\", \"o\", \"hello world\", \"<esc>\", \":w\", \"<enter>\"]."
102
+ },
103
+ "bracketed" => { "type" => "boolean", "description" => "Wrap literal text in bracketed-paste markers (\\e[200~ … \\e[201~). Named keys in `keys` are never bracketed." }
104
+ },
105
+ "required" => ["pane"],
106
+ "additionalProperties" => false
107
+ }
108
+ },
109
+ {
110
+ "name" => "muxr_pane_run",
111
+ "description" => "Send input to a pane and wait for the PTY to go idle before returning. The killer tool for driving shells: returns the pane's full visible text after the command settles, plus a timed_out flag. By default appends \\r and waits 500ms of idle. Bump idle_ms to ~800 for bursty commands (test runners) or timeout_ms for long builds. When the sequence needs any special key (Escape, arrows, Ctrl-*), use `keys` instead of `input`.",
112
+ "inputSchema" => {
113
+ "type" => "object",
114
+ "properties" => {
115
+ "pane" => PANE_REF,
116
+ "input" => { "type" => "string", "description" => "Command text. Omit/empty to just wait. Use `keys` instead when any named special key is needed. Mutually exclusive with `keys`." },
117
+ "keys" => {
118
+ "type" => "array",
119
+ "items" => { "type" => "string" },
120
+ "description" => "Mixed array of literal text and vim-style named keys (see muxr_pane_send_input for the full list). Preferred over `input` whenever Escape/arrows/Ctrl-* are involved. Mutually exclusive with `input`."
121
+ },
122
+ "append_enter"=> { "type" => "boolean", "description" => "Append \\r after input/keys (default true)." },
123
+ "bracketed" => { "type" => "boolean", "description" => "Wrap literal text in bracketed-paste markers (default false). Named keys in `keys` are never bracketed." },
124
+ "idle_ms" => { "type" => "integer", "minimum" => 50, "maximum" => 60000, "description" => "Idle window before considering the command settled. Default 500." },
125
+ "timeout_ms" => { "type" => "integer", "minimum" => 100, "maximum" => 300000, "description" => "Absolute timeout for the wait. Default 30000." }
126
+ },
127
+ "required" => ["pane"],
128
+ "additionalProperties" => false
129
+ }
130
+ },
131
+ {
132
+ "name" => "muxr_pane_focus",
133
+ "description" => "Focus a pane (moves the human's cursor to it). Mostly used to set up state before the human takes over.",
134
+ "inputSchema" => {
135
+ "type" => "object",
136
+ "properties" => { "pane" => PANE_REF },
137
+ "required" => ["pane"],
138
+ "additionalProperties" => false
139
+ }
140
+ },
141
+ {
142
+ "name" => "muxr_pane_new",
143
+ "description" => "Create a new pane, optionally in a specific cwd. Returns the new pane's id and slot. Use sparingly — the human usually controls layout.",
144
+ "inputSchema" => {
145
+ "type" => "object",
146
+ "properties" => {
147
+ "cwd" => { "type" => "string", "description" => "Working directory for the new shell. Defaults to the focused pane's cwd." }
148
+ },
149
+ "additionalProperties" => false
150
+ }
151
+ },
152
+ {
153
+ "name" => "muxr_pane_kill",
154
+ "description" => "Close a pane. Destructive — only call when the user has named the specific pane to kill.",
155
+ "inputSchema" => {
156
+ "type" => "object",
157
+ "properties" => { "pane" => PANE_REF },
158
+ "required" => ["pane"],
159
+ "additionalProperties" => false
160
+ }
161
+ },
162
+ {
163
+ "name" => "muxr_pane_promote",
164
+ "description" => "Promote a pane to the master slot (position 0). Useful for setting up a tall-layout focal pane.",
165
+ "inputSchema" => {
166
+ "type" => "object",
167
+ "properties" => { "pane" => PANE_REF },
168
+ "required" => ["pane"],
169
+ "additionalProperties" => false
170
+ }
171
+ },
172
+ {
173
+ "name" => "muxr_layout_set",
174
+ "description" => "Switch to a specific layout: tall, grid, or monocle.",
175
+ "inputSchema" => {
176
+ "type" => "object",
177
+ "properties" => { "layout" => { "type" => "string", "enum" => ["tall", "grid", "monocle"] } },
178
+ "required" => ["layout"],
179
+ "additionalProperties" => false
180
+ }
181
+ },
182
+ {
183
+ "name" => "muxr_layout_cycle",
184
+ "description" => "Cycle to the next layout (tall → grid → monocle → tall).",
185
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
186
+ },
187
+ {
188
+ "name" => "muxr_drawer_toggle",
189
+ "description" => "Toggle the persistent Quake-style drawer overlay.",
190
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
191
+ },
192
+ {
193
+ "name" => "muxr_drawer_show",
194
+ "description" => "Show the drawer if it's hidden.",
195
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
196
+ },
197
+ {
198
+ "name" => "muxr_drawer_hide",
199
+ "description" => "Hide the drawer (its shell keeps running).",
200
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
201
+ },
202
+ {
203
+ "name" => "muxr_drawer_reset",
204
+ "description" => "Tear down the drawer's shell process. Next show recreates it fresh.",
205
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
206
+ },
207
+ {
208
+ "name" => "muxr_drawer_read",
209
+ "description" => "Read the drawer pane's current text (works even when hidden — the PTY survives).",
210
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
211
+ },
212
+ {
213
+ "name" => "muxr_drawer_send_input",
214
+ "description" => "Send bytes to the drawer pane. When the sequence needs any special key (Escape, Enter-as-terminator, arrows, Ctrl-*), use `keys` instead of `data`.",
215
+ "inputSchema" => {
216
+ "type" => "object",
217
+ "properties" => {
218
+ "data" => { "type" => "string", "description" => "Literal bytes. Mutually exclusive with `keys`." },
219
+ "keys" => {
220
+ "type" => "array",
221
+ "items" => { "type" => "string" },
222
+ "description" => "Mixed literal text and vim-style named keys (see muxr_pane_send_input for the full list). Mutually exclusive with `data`."
223
+ },
224
+ "bracketed" => { "type" => "boolean", "description" => "Wrap literal text in bracketed-paste markers. Named keys in `keys` are never bracketed." }
225
+ },
226
+ "additionalProperties" => false
227
+ }
228
+ },
229
+ {
230
+ "name" => "muxr_session_save",
231
+ "description" => "Persist the structural session (layout, indices, cwds, drawer state) to ~/.muxr/sessions/<name>.json. Shell history is not saved.",
232
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
233
+ },
234
+ {
235
+ "name" => "muxr_ping",
236
+ "description" => "Health check — returns {pong: true} if the muxr server is responsive.",
237
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
238
+ }
239
+ ].freeze
240
+
241
+ def initialize
242
+ @socket = nil
243
+ @next_request_id = 0
244
+ @read_buffer = +""
245
+ @in_drawer = ENV["MUXR_DRAWER_SELF"] == "1"
246
+ end
247
+
248
+ def run
249
+ connect_to_muxr
250
+ loop do
251
+ line = $stdin.gets
252
+ break if line.nil?
253
+ handle_message(line.chomp)
254
+ end
255
+ rescue Interrupt
256
+ # graceful exit
257
+ ensure
258
+ @socket&.close
259
+ end
260
+
261
+ private
262
+
263
+ def handle_message(line)
264
+ return if line.empty?
265
+ msg = JSON.parse(line)
266
+ rescue JSON::ParserError
267
+ return # silently drop unparsable input — stdio is best-effort
268
+ else
269
+ id = msg["id"]
270
+ method = msg["method"]
271
+ params = msg["params"] || {}
272
+
273
+ case method
274
+ when "initialize"
275
+ respond(id, {
276
+ "protocolVersion" => PROTOCOL_VERSION,
277
+ "capabilities" => { "tools" => {} },
278
+ "serverInfo" => { "name" => SERVER_NAME, "version" => SERVER_VERSION }
279
+ })
280
+ when "initialized", "notifications/initialized"
281
+ # notification, no reply
282
+ when "tools/list"
283
+ respond(id, { "tools" => TOOL_SCHEMAS })
284
+ when "tools/call"
285
+ handle_tool_call(id, params)
286
+ when "ping"
287
+ respond(id, {})
288
+ when nil
289
+ # bare notification or invalid — ignore
290
+ else
291
+ respond_error(id, -32601, "Method not found: #{method}") if id
292
+ end
293
+ end
294
+
295
+ def handle_tool_call(id, params)
296
+ tool_name = params["name"].to_s
297
+ args = params["arguments"] || {}
298
+
299
+ muxr_method = TOOL_TO_MUXR_METHOD[tool_name]
300
+ if muxr_method.nil?
301
+ respond_tool_error(id, "Unknown tool: #{tool_name}")
302
+ return
303
+ end
304
+
305
+ # When the bridge is running inside the muxr drawer itself, calling any
306
+ # drawer.* method would either recurse into our own pty or yank the
307
+ # drawer out from under us. Refuse those instead.
308
+ if @in_drawer && muxr_method.start_with?("drawer.")
309
+ respond_tool_error(id, "drawer.* methods are unavailable from inside the drawer (MUXR_DRAWER_SELF=1).")
310
+ return
311
+ end
312
+
313
+ response = send_muxr_request(muxr_method, args)
314
+ if response.nil?
315
+ respond_tool_error(id, "muxr server closed the connection")
316
+ elsif response["error"]
317
+ err = response["error"]
318
+ respond_tool_error(id, "muxr error #{err["code"]}: #{err["message"]}")
319
+ else
320
+ result = response["result"]
321
+ respond(id, {
322
+ "content" => [{ "type" => "text", "text" => JSON.pretty_generate(result) }]
323
+ })
324
+ end
325
+ rescue StandardError => e
326
+ respond_tool_error(id, "#{e.class}: #{e.message}")
327
+ end
328
+
329
+ # Send one NDJSON request to the muxr control socket and block until the
330
+ # response with the matching id arrives. Server-pushed events (no id, or
331
+ # id mismatch) are silently dropped — the v1 bridge doesn't forward them.
332
+ def send_muxr_request(method, params)
333
+ @next_request_id += 1
334
+ rid = @next_request_id
335
+ payload = JSON.generate({ "id" => rid, "method" => method, "params" => params }) + "\n"
336
+ @socket.write(payload)
337
+
338
+ loop do
339
+ line = read_muxr_line
340
+ return nil unless line # EOF
341
+ msg = JSON.parse(line)
342
+ next unless msg["id"] == rid
343
+ return msg
344
+ end
345
+ end
346
+
347
+ # Line-oriented read on the muxr socket. Reads in chunks and emits one
348
+ # JSON line at a time as they accumulate in @read_buffer.
349
+ def read_muxr_line
350
+ loop do
351
+ nl = @read_buffer.index("\n")
352
+ if nl
353
+ line = @read_buffer.slice!(0..nl)
354
+ return line.chomp
355
+ end
356
+ chunk = @socket.readpartial(64 * 1024)
357
+ @read_buffer << chunk
358
+ end
359
+ rescue EOFError, IOError
360
+ nil
361
+ end
362
+
363
+ def connect_to_muxr
364
+ path = ENV["MUXR_CONTROL_SOCKET"] || derive_socket_from_session
365
+ if path.nil? || path.empty?
366
+ die("set MUXR_CONTROL_SOCKET to the path of the muxr control socket, " \
367
+ "or MUXR_SESSION to the session name (the drawer-launched claude sets these for you).")
368
+ end
369
+ unless File.exist?(path)
370
+ die("muxr control socket not found at #{path} — is the server running? " \
371
+ "Start it with `muxr <session>`.")
372
+ end
373
+ @socket = UNIXSocket.new(path)
374
+ end
375
+
376
+ def derive_socket_from_session
377
+ name = ENV["MUXR_SESSION"]
378
+ return nil unless name && !name.empty?
379
+ File.join(Dir.home, ".muxr", "sockets", "#{name}.ctrl.sock")
380
+ end
381
+
382
+ # ---- JSON-RPC plumbing ----
383
+
384
+ def respond(id, result)
385
+ return if id.nil?
386
+ write_message({ "jsonrpc" => "2.0", "id" => id, "result" => result })
387
+ end
388
+
389
+ def respond_error(id, code, message)
390
+ return if id.nil?
391
+ write_message({ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } })
392
+ end
393
+
394
+ def respond_tool_error(id, text)
395
+ respond(id, {
396
+ "content" => [{ "type" => "text", "text" => text }],
397
+ "isError" => true
398
+ })
399
+ end
400
+
401
+ def write_message(obj)
402
+ $stdout.write(JSON.generate(obj) + "\n")
403
+ $stdout.flush
404
+ end
405
+
406
+ def die(msg)
407
+ $stderr.puts "muxr-mcp: #{msg}"
408
+ exit 1
409
+ end
410
+ end
411
+
412
+ MuxrMcpBridge.new.run if $PROGRAM_NAME == __FILE__