libtmux-mcp 0.1.0.alpha.1
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/exe/libtmux-mcp +6 -0
- data/lib/libtmux/mcp/application.rb +519 -0
- data/lib/libtmux/mcp/catalog.rb +258 -0
- data/lib/libtmux/mcp/catalog_tool.rb +33 -0
- data/lib/libtmux/mcp/cli.rb +239 -0
- data/lib/libtmux/mcp/enrollment.rb +670 -0
- data/lib/libtmux/mcp/mutations.rb +120 -0
- data/lib/libtmux/mcp/observation.rb +474 -0
- data/lib/libtmux/mcp/process_identity.rb +271 -0
- data/lib/libtmux/mcp/resources.rb +101 -0
- data/lib/libtmux/mcp/shell/integration.zsh +50 -0
- data/lib/libtmux/mcp/shell/prepare.rb +137 -0
- data/lib/libtmux/mcp/stdio_transport.rb +504 -0
- data/lib/libtmux/mcp/version.rb +7 -0
- data/lib/libtmux/mcp.rb +7 -0
- data/sig/libtmux-mcp.rbs +26 -0
- data/sig/transport.rbs +16 -0
- metadata +136 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
module MCP
|
|
5
|
+
module Catalog
|
|
6
|
+
READ_ONLY = %w[tmux_capabilities tmux_snapshot].freeze
|
|
7
|
+
MUTATIONS = %w[tmux_send tmux_create tmux_close tmux_run].freeze
|
|
8
|
+
OBSERVATIONS = %w[tmux_capture tmux_wait].freeze
|
|
9
|
+
NAMES = (READ_ONLY + MUTATIONS + OBSERVATIONS).freeze
|
|
10
|
+
MUTATION_BYTES = 1 << 16
|
|
11
|
+
MIN_MUTATION_RESPONSE_BYTES = 4096
|
|
12
|
+
|
|
13
|
+
def self.object(properties, required = properties.keys)
|
|
14
|
+
{"type" => "object", "properties" => properties, "required" => required, "additionalProperties" => false}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.text
|
|
18
|
+
{"type" => "string"}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.input(name)
|
|
22
|
+
return object({}) if name == "tmux_capabilities"
|
|
23
|
+
if name == "tmux_run"
|
|
24
|
+
return object({"target" => reference("pane"), "script" => mutation_text,
|
|
25
|
+
"stdout_limit" => {"type" => "integer", "minimum" => 0, "maximum" => 262144},
|
|
26
|
+
"stderr_limit" => {"type" => "integer", "minimum" => 0, "maximum" => 262144}}, %w[target script])
|
|
27
|
+
end
|
|
28
|
+
return mutation_input(name) if MUTATIONS.include?(name)
|
|
29
|
+
return observation_input(name) if OBSERVATIONS.include?(name)
|
|
30
|
+
|
|
31
|
+
criteria = FilterExpr.json_schema
|
|
32
|
+
object({"entity" => {"enum" => Internal::Catalog.kinds.map(&:to_s)},
|
|
33
|
+
"criteria" => {"oneOf" => criteria.fetch("oneOf")},
|
|
34
|
+
"limit" => {"type" => "integer", "minimum" => 1, "maximum" => 200},
|
|
35
|
+
"cursor" => {"type" => "string", "pattern" => "^[a-f0-9]{32}:[0-9]{1,10}$", "maxLength" => 43}}, []).merge(
|
|
36
|
+
"$defs" => criteria.fetch("$defs"), "oneOf" => [
|
|
37
|
+
{"required" => ["entity"], "not" => {"required" => ["cursor"]}},
|
|
38
|
+
{"required" => ["cursor"], "not" => {"anyOf" => %w[entity criteria limit].map { |key| {"required" => [key]} }}}
|
|
39
|
+
])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.identity
|
|
43
|
+
object({"generation" => text, "pid" => {"type" => "integer"},
|
|
44
|
+
"start_time" => {"type" => "integer"}, "tmux_version" => text})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.capture_limits
|
|
48
|
+
{"max_lines" => {"type" => "integer", "minimum" => 1, "maximum" => 1000},
|
|
49
|
+
"max_bytes" => {"type" => "integer", "minimum" => 1, "maximum" => 262144},
|
|
50
|
+
"history_lines" => {"type" => "integer", "minimum" => 0, "maximum" => 1000}}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.observation_input(name)
|
|
54
|
+
if name == "tmux_capture"
|
|
55
|
+
{"oneOf" => [object(capture_limits.merge("target" => reference("pane"), "track" => {"type" => "boolean"}), ["target"]),
|
|
56
|
+
object({"target" => reference("pane"), "cursor" => text.merge("pattern" => "^[a-f0-9]{32}$", "maxLength" => 32)})]}
|
|
57
|
+
else
|
|
58
|
+
{"oneOf" => [object(capture_limits.merge("target" => reference("pane"),
|
|
59
|
+
"timeout" => {"type" => "number", "exclusiveMinimum" => 0, "maximum" => 60},
|
|
60
|
+
"condition" => {"oneOf" => [object({"type" => {"const" => "screen_contains"},
|
|
61
|
+
"text" => text.merge("minLength" => 1, "maxLength" => 4096)}), object({"type" => {"const" => "process_exit"}})]}), %w[target condition])]}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.capture_output
|
|
66
|
+
common = {"target" => reference("pane"), "capture_id" => text,
|
|
67
|
+
"process_generation" => {"type" => ["string", "null"]},
|
|
68
|
+
"encoding" => {"enum" => %w[utf-8 base64]}, "row_count" => {"type" => "integer", "minimum" => 0, "maximum" => 1000},
|
|
69
|
+
"bytes" => {"type" => "integer", "minimum" => 0, "maximum" => 262144},
|
|
70
|
+
"truncated" => {"type" => "boolean"}, "history_continuity" => {"const" => "unknown"},
|
|
71
|
+
"interval" => object({"clock" => {"const" => "monotonic_seconds"}, "started" => {"type" => "number"}, "finished" => {"type" => "number"}}),
|
|
72
|
+
"scope" => object(capture_limits), "next_cursor" => text}
|
|
73
|
+
rows = {"type" => "array", "maxItems" => 1000, "items" => text}
|
|
74
|
+
required = common.keys - ["next_cursor"]
|
|
75
|
+
{"oneOf" => [object(common.merge("mode" => {"const" => "snapshot"}, "rows" => rows), required + %w[mode rows]),
|
|
76
|
+
object(common.merge("mode" => {"const" => "delta"}, "base_capture_id" => text, "reset" => {"type" => "boolean"},
|
|
77
|
+
"splice" => object({"start" => {"type" => "integer", "minimum" => 0}, "delete" => {"type" => "integer", "minimum" => 0}, "rows" => rows})),
|
|
78
|
+
required + %w[mode base_capture_id reset splice next_cursor])]}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.wait_output
|
|
82
|
+
{"oneOf" => [object({"target" => reference("pane"), "condition" => {"const" => "screen_contains"}, "capture" => capture_output}),
|
|
83
|
+
object({"target" => reference("pane"), "condition" => {"const" => "process_exit"}, "process_generation" => text,
|
|
84
|
+
"observed_at" => {"type" => "number"}, "exit_status" => {"const" => "unobserved"}})]}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.reference(kind = nil)
|
|
88
|
+
kinds = kind ? [kind] : %w[session window pane window_link]
|
|
89
|
+
{"oneOf" => kinds.map do |entity|
|
|
90
|
+
prefix = {"session" => "\\$", "window" => "@", "pane" => "%", "window_link" => "@"}.fetch(entity)
|
|
91
|
+
properties = {"generation" => text.merge("minLength" => 1, "maxLength" => 128), "kind" => {"const" => entity},
|
|
92
|
+
"id" => text.merge("pattern" => "^#{prefix}[0-9]+$", "maxLength" => 32)}
|
|
93
|
+
if entity == "window_link"
|
|
94
|
+
properties.merge!("session_id" => text.merge("pattern" => "^\\$[0-9]+$", "maxLength" => 32),
|
|
95
|
+
"index" => {"type" => "integer", "minimum" => 0})
|
|
96
|
+
end
|
|
97
|
+
object(properties)
|
|
98
|
+
end}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.mutation_text
|
|
102
|
+
text.merge("maxLength" => MUTATION_BYTES, "pattern" => "^[^\\u0000]*$")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.mutation_input(name)
|
|
106
|
+
case name
|
|
107
|
+
when "tmux_send"
|
|
108
|
+
{"oneOf" => [object({"target" => reference("pane"), "input" => {"oneOf" => [
|
|
109
|
+
object({"type" => {"const" => "text"}, "text" => mutation_text}),
|
|
110
|
+
object({"type" => {"const" => "keys"}, "keys" => {"type" => "array", "minItems" => 1, "maxItems" => 256,
|
|
111
|
+
"items" => mutation_text.merge("minLength" => 1)}})
|
|
112
|
+
]}})]}
|
|
113
|
+
when "tmux_close"
|
|
114
|
+
{"oneOf" => [object({"target" => {"oneOf" => %w[session window pane].flat_map { |kind| reference(kind).fetch("oneOf") }}})]}
|
|
115
|
+
when "tmux_create"
|
|
116
|
+
common = {"argv" => {"type" => "array", "minItems" => 1, "maxItems" => 256,
|
|
117
|
+
"prefixItems" => [mutation_text.merge("minLength" => 1)], "items" => mutation_text},
|
|
118
|
+
"cwd" => mutation_text.merge("minLength" => 1),
|
|
119
|
+
"environment" => {"type" => "object", "maxProperties" => 128,
|
|
120
|
+
"propertyNames" => {"pattern" => "^[A-Za-z_][A-Za-z0-9_]*$"}, "additionalProperties" => mutation_text}}
|
|
121
|
+
positive = {"type" => "integer", "minimum" => 1, "maximum" => (1 << 31) - 1}
|
|
122
|
+
{"oneOf" => [
|
|
123
|
+
object(common.merge("kind" => {"const" => "session"}, "name" => mutation_text.merge("minLength" => 1),
|
|
124
|
+
"window_name" => mutation_text.merge("minLength" => 1), "width" => positive, "height" => positive), %w[kind name argv]),
|
|
125
|
+
object(common.merge("kind" => {"const" => "window"}, "parent" => reference("session"),
|
|
126
|
+
"name" => mutation_text.merge("minLength" => 1), "index" => {"type" => "integer", "minimum" => 0, "maximum" => (1 << 31) - 1},
|
|
127
|
+
"focus" => {"type" => "boolean"}), %w[kind parent name argv]),
|
|
128
|
+
object(common.merge("kind" => {"const" => "pane"}, "parent" => reference("pane"),
|
|
129
|
+
"direction" => {"enum" => %w[horizontal vertical]}, "size" => {"oneOf" => [positive,
|
|
130
|
+
{"type" => "string", "pattern" => "^(?:[1-9][0-9]?|100)%$"}]},
|
|
131
|
+
"focus" => {"type" => "boolean"}), %w[kind parent direction argv])
|
|
132
|
+
]}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.record
|
|
137
|
+
{"oneOf" => Internal::Catalog.kinds.map do |kind|
|
|
138
|
+
fields = Internal::Catalog.entity(kind).fields.values.to_h do |field|
|
|
139
|
+
type = {text: "string", integer: "integer", boolean: "boolean"}.fetch(field.type)
|
|
140
|
+
schema = {"type" => field.nullable ? [type, "null"] : type}
|
|
141
|
+
schema["minimum"] = field.min if field.min
|
|
142
|
+
schema["maximum"] = field.max if field.max
|
|
143
|
+
[field.wire_name, schema]
|
|
144
|
+
end
|
|
145
|
+
object({"kind" => {"const" => kind.to_s}, "fields" => object(fields),
|
|
146
|
+
"ref" => kind == :client ? {"type" => "null"} : reference(kind.to_s)})
|
|
147
|
+
end}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.output(name)
|
|
151
|
+
payload = if name == "tmux_run"
|
|
152
|
+
object({"target" => reference("pane"), "authorization" => authorization,
|
|
153
|
+
"completion" => run_completion, "stdout" => run_bytes, "stderr" => run_bytes})
|
|
154
|
+
elsif MUTATIONS.include?(name)
|
|
155
|
+
mutation_output(name)
|
|
156
|
+
elsif name == "tmux_capture"
|
|
157
|
+
capture_output
|
|
158
|
+
elsif name == "tmux_wait"
|
|
159
|
+
wait_output
|
|
160
|
+
elsif name == "tmux_capabilities"
|
|
161
|
+
object({"endpoint" => text, "server_identity" => identity,
|
|
162
|
+
"enabled_tools" => {"type" => "array", "items" => text},
|
|
163
|
+
"criteria_schema" => {"type" => "object"},
|
|
164
|
+
"limits" => {"type" => "object", "additionalProperties" => {"type" => "number"}},
|
|
165
|
+
"owns_daemon" => {"const" => false}, "resource_subscriptions" => {"const" => false},
|
|
166
|
+
"authored_run" => object({"availability" => {"enum" => %w[conditional unsupported]},
|
|
167
|
+
"shell_profile" => {"const" => "zsh-5.9-zle"}, "enrollment" => {"const" => "explicit_source"},
|
|
168
|
+
"authorization" => {"const" => "exact_generation_at_queue_grant"},
|
|
169
|
+
"stdin" => {"const" => "closed"}, "persistent_shell_changes" => {"const" => false},
|
|
170
|
+
"descendant_termination" => {"const" => "unobserved"},
|
|
171
|
+
"requirements" => {"type" => "array", "items" => text}}),
|
|
172
|
+
"observation" => object({"screen" => {"const" => "bounded_rows"}, "history_continuity" => {"const" => "unknown"},
|
|
173
|
+
"process_cursor" => {"enum" => %w[conditional unsupported]}, "requirements" => {"type" => "array", "items" => text},
|
|
174
|
+
"wait_conditions" => {"type" => "array", "items" => {"enum" => %w[screen_contains process_exit]}}})})
|
|
175
|
+
else
|
|
176
|
+
object({"capture_id" => text, "server_identity" => identity,
|
|
177
|
+
"entity" => {"enum" => Internal::Catalog.kinds.map(&:to_s)},
|
|
178
|
+
"items" => {"type" => "array", "items" => record, "maxItems" => 200},
|
|
179
|
+
"coverage" => {"type" => "object", "additionalProperties" => {"enum" => %w[complete unloaded]}},
|
|
180
|
+
"interval" => object({"clock" => {"const" => "monotonic_seconds"},
|
|
181
|
+
"started" => {"type" => "number"}, "finished" => {"type" => "number"},
|
|
182
|
+
"reads" => {"type" => "integer", "minimum" => 0}}),
|
|
183
|
+
"truncated" => {"type" => "boolean"}, "next_cursor" => text},
|
|
184
|
+
%w[capture_id server_identity entity items coverage interval truncated])
|
|
185
|
+
end
|
|
186
|
+
{"oneOf" => [object({"ok" => {"const" => true}, "data" => payload}),
|
|
187
|
+
object({"ok" => {"const" => false}, "error" => object({"code" => text,
|
|
188
|
+
"message" => text, "delivery" => {"enum" => %w[not_sent possibly_sent observed]},
|
|
189
|
+
"effects" => name == "tmux_run" ? run_effects : effects}, %w[code message delivery])})]}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.authorization
|
|
193
|
+
token = text.merge("pattern" => "^[a-f0-9]{32}$", "maxLength" => 32)
|
|
194
|
+
object({"state" => {"const" => "authorized"}, "run_id" => token,
|
|
195
|
+
"script_digest" => text.merge("pattern" => "^[a-f0-9]{64}$", "maxLength" => 64),
|
|
196
|
+
"server_generation" => text.merge("minLength" => 1, "maxLength" => 128),
|
|
197
|
+
"pane_id" => text.merge("pattern" => "^%[0-9]+$", "maxLength" => 32),
|
|
198
|
+
"enrollment_generation" => token, "process_generation" => token})
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.run_completion
|
|
202
|
+
{"oneOf" => [object({"state" => {"const" => "exited"},
|
|
203
|
+
"exit_status" => {"type" => "integer", "minimum" => 0, "maximum" => 255}, "signal" => {"type" => "null"}}),
|
|
204
|
+
object({"state" => {"const" => "signaled"}, "exit_status" => {"type" => "null"},
|
|
205
|
+
"signal" => {"type" => "integer", "minimum" => 1, "maximum" => 255}})]}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.run_bytes
|
|
209
|
+
object({"encoding" => {"enum" => %w[utf-8 base64]}, "data" => text.merge("maxLength" => 349528),
|
|
210
|
+
"bytes" => {"type" => "integer", "minimum" => 0, "maximum" => 262144}, "truncated" => {"const" => false}})
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.run_effects
|
|
214
|
+
object({"state" => {"enum" => %w[none known unknown]},
|
|
215
|
+
"authorization" => {"oneOf" => [authorization, {"type" => "null"}]},
|
|
216
|
+
"completion" => {"oneOf" => [object({"state" => {"const" => "unobserved"}}), *run_completion.fetch("oneOf")]}})
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.effects
|
|
220
|
+
object({"state" => {"enum" => %w[none known unknown]},
|
|
221
|
+
"created" => {"type" => "array", "maxItems" => 3, "items" => reference}})
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def self.mutation_output(name)
|
|
225
|
+
if name == "tmux_create"
|
|
226
|
+
object({"entity" => reference, "created" => {"type" => "array", "minItems" => 1, "maxItems" => 3, "items" => reference},
|
|
227
|
+
"delivery" => {"const" => "observed"}, "program_completion" => {"const" => "unobserved"}})
|
|
228
|
+
else
|
|
229
|
+
properties = {"target" => reference, "delivery" => {"const" => "observed"},
|
|
230
|
+
"client_exit_status" => {"const" => 0}}
|
|
231
|
+
properties["completion"] = {"const" => "dispatch_only"} if name == "tmux_send"
|
|
232
|
+
object(properties)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.description(name)
|
|
237
|
+
if name == "tmux_run"
|
|
238
|
+
"Run an authored POSIX script in an explicitly enrolled idle, empty zsh 5.9 editor. Opt-in; no terminal injection. Exact queue authorization binds the existing shell generation; execution may follow a later pane replacement without retargeting it. Inherits cwd/exported environment, closed stdin, separate bounded UTF-8/base64 outputs. Defaults: 65536 bytes per output, 65536 script bytes; overflow refuses completion. Reports native exit/signal only from the helper; cancellation never proves descendants stopped."
|
|
239
|
+
elsif name == "tmux_capture"
|
|
240
|
+
"Read exact-pane screen/history rows preserving LF. Defaults: 200 lines, 65536 bytes, zero history lines, track=false. Limits: 1000 lines/history, 262144 bytes. UTF-8 or base64; truncation is separate from unknown history continuity. Tracked process cursors require tmux>=3.3 and a native process identity backend: Linux peer pidfds with matching PID namespaces, or Darwin kqueue process observation. Continuation returns an exact state row splice, not a live-output journal. Refuses capture after-hooks."
|
|
241
|
+
elsif name == "tmux_wait"
|
|
242
|
+
"Wait for observed literal screen text or the initial pane process exit. Deadline in seconds is capped by the application limit. Uses control events/process descriptors; no polling. Requires tmux>=3.3 and Linux peer pidfds with matching PID namespaces or Darwin kqueue process observation. Reports observed evidence, never remote termination caused by cancellation or an inferred process exit status."
|
|
243
|
+
elsif name == "tmux_send"
|
|
244
|
+
"Send literal UTF-8 text or named keys to one exact pane. Opt-in mutation; 64 KiB total text bytes, at most 256 keys. Reports tmux client completion and input dispatch only; never shell completion."
|
|
245
|
+
elsif name == "tmux_create"
|
|
246
|
+
"Create a session, window in an exact session, or split an exact pane using argv. Opt-in mutation; 64 KiB total string bytes and at most 256 argv entries. Window/pane focus defaults false. Cwd/environment are optional; size is cells or percent. Assigned refs prove creation, not program readiness or completion."
|
|
247
|
+
elsif name == "tmux_close"
|
|
248
|
+
"Destroy one exact session, window or pane. Opt-in destructive operation; closing a session or window also removes its contained topology. Never selects by name or closes the daemon directly. Returns the final tmux client outcome."
|
|
249
|
+
elsif name == "tmux_capabilities"
|
|
250
|
+
"Discover the fixed endpoint, server binding, enabled tools, criteria schema and limits. Acquires metadata; starts no daemon."
|
|
251
|
+
else
|
|
252
|
+
"Capture ordered metadata and evaluate Ruby criteria locally. Pages retain one immutable capture; expired cursors require a new query. Limit defaults to 50 records, maximum 200."
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
private_constant :Catalog
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
module MCP
|
|
5
|
+
# Handshake metadata does not need schemas. Validate each through the SDK
|
|
6
|
+
# before exposing it; Application validates both before dispatching effects.
|
|
7
|
+
class CatalogTool < ::MCP::Tool
|
|
8
|
+
class << self
|
|
9
|
+
def define(input_schema:, output_schema:, **metadata, &block)
|
|
10
|
+
super(**metadata, &block).tap do |tool|
|
|
11
|
+
tool.instance_variable_set(:@input_definition, input_schema)
|
|
12
|
+
tool.instance_variable_set(:@output_definition, output_schema)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def input_schema_value
|
|
17
|
+
@input_schema_value ||= ::MCP::Tool::InputSchema.new(@input_definition)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def output_schema_value
|
|
21
|
+
@output_schema_value ||= ::MCP::Tool::OutputSchema.new(@output_definition)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
input_schema_value
|
|
26
|
+
output_schema_value
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
private_constant :CatalogTool
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/mcp"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
module MCP
|
|
8
|
+
class CLI
|
|
9
|
+
def self.run(arguments, input: $stdin, out: $stdout, err: $stderr)
|
|
10
|
+
new(arguments, input, out, err).run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(arguments, input, output, error)
|
|
14
|
+
@arguments, @input, @output, @error = arguments.dup, input, output, error
|
|
15
|
+
@options = {endpoint_name: "local", executable: "tmux", tools: Catalog::READ_ONLY.dup,
|
|
16
|
+
timeout: 5.0, concurrency: 4, max_requests: 32, max_frame_bytes: 1 << 20,
|
|
17
|
+
max_output_bytes: 1 << 22, max_response_bytes: 1 << 20,
|
|
18
|
+
enrollments: [], enrollment_timeout: 60.0}
|
|
19
|
+
@setup_files, @enrollment_tasks = [], []
|
|
20
|
+
end
|
|
21
|
+
private_class_method :new
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
parser.parse!(@arguments)
|
|
25
|
+
if @options[:help] || @options[:version]
|
|
26
|
+
@output.puts(@options[:help] ? parser : VERSION)
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
validate_arguments
|
|
30
|
+
endpoint = Endpoint.new(socket_path: @options[:socket_path], socket_name: @options[:socket_name],
|
|
31
|
+
executable: @options.fetch(:executable))
|
|
32
|
+
Async do |task|
|
|
33
|
+
begin
|
|
34
|
+
serve(endpoint, task)
|
|
35
|
+
0
|
|
36
|
+
rescue Interrupt, LibTmux::Cancelled => error
|
|
37
|
+
report("MCP interrupted; dispatched effects may remain.", 130, failure: error)
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
report("MCP execution failed (#{error.class}); dispatched effects may remain.", 1, failure: error)
|
|
40
|
+
end
|
|
41
|
+
end.wait
|
|
42
|
+
rescue OptionParser::ParseError, ArgumentError => error
|
|
43
|
+
report("Invalid arguments; use --help for supported options.", 2, failure: error)
|
|
44
|
+
rescue Interrupt, LibTmux::Cancelled => error
|
|
45
|
+
report("MCP interrupted; dispatched effects may remain.", 130, failure: error)
|
|
46
|
+
rescue StandardError => error
|
|
47
|
+
report("MCP startup failed (#{error.class}).", 1, failure: error)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def serve(endpoint, task)
|
|
53
|
+
Server.open(endpoint: endpoint) do |server|
|
|
54
|
+
LibTmux::Async.open(server: server, parent: task) do |scope|
|
|
55
|
+
app = Application.new(server: scope.server, endpoint_name: @options.fetch(:endpoint_name),
|
|
56
|
+
enabled_tools: @options.fetch(:tools).uniq, request_timeout: @options.fetch(:timeout),
|
|
57
|
+
max_response_bytes: @options.fetch(:max_response_bytes))
|
|
58
|
+
failure = transport = nil
|
|
59
|
+
begin
|
|
60
|
+
prepare_enrollments(app, scope.server, task)
|
|
61
|
+
transport = StdioTransport.new(server: app.sdk_server, parent: task, input: @input, output: @output,
|
|
62
|
+
concurrency: @options.fetch(:concurrency), max_requests: @options.fetch(:max_requests),
|
|
63
|
+
max_frame_bytes: @options.fetch(:max_frame_bytes), max_output_bytes: @options.fetch(:max_output_bytes),
|
|
64
|
+
request_timeout: @options.fetch(:timeout))
|
|
65
|
+
transport.run
|
|
66
|
+
rescue Exception => error
|
|
67
|
+
failure = error
|
|
68
|
+
ensure
|
|
69
|
+
[-> { transport&.close }, -> { close_enrollments }, -> { app.close }].each do |cleanup|
|
|
70
|
+
begin
|
|
71
|
+
cleanup.call
|
|
72
|
+
rescue Exception => error
|
|
73
|
+
if failure
|
|
74
|
+
ProcessIdentity.attach_cleanup(failure, ["MCP cleanup failed (#{error.class})"])
|
|
75
|
+
diagnostic("MCP cleanup also failed (#{error.class}).", failure: failure)
|
|
76
|
+
end
|
|
77
|
+
failure ||= error
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
raise failure if failure
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def prepare_enrollments(app, server, task)
|
|
87
|
+
return if @options.fetch(:enrollments).empty?
|
|
88
|
+
|
|
89
|
+
panes = server.list_panes(timeout: @options.fetch(:timeout)).to_h { |pane| [pane.id, pane.ref] }
|
|
90
|
+
@options.fetch(:enrollments).each do |id, path|
|
|
91
|
+
reference = panes[id]
|
|
92
|
+
raise TargetNotFoundError.new("enrollment pane does not exist", phase: :admission) unless reference
|
|
93
|
+
|
|
94
|
+
invitation = app.invite_shell(reference, timeout: @options.fetch(:timeout),
|
|
95
|
+
expires_in: @options.fetch(:enrollment_timeout))
|
|
96
|
+
arguments = invitation.shell_arguments.map do |value|
|
|
97
|
+
raise ArgumentError if value.include?("\0") || value.include?("\n") || value.include?("\r")
|
|
98
|
+
|
|
99
|
+
"'#{value.gsub("'") { %q('\'') }}'"
|
|
100
|
+
end
|
|
101
|
+
owned = publish_setup_file(path, "source #{arguments.join(' ')}\n")
|
|
102
|
+
child = ::Async::Task.new(task) do
|
|
103
|
+
begin
|
|
104
|
+
app.accept_shell(invitation)
|
|
105
|
+
rescue LibTmux::Cancelled, ::Async::Cancel => error
|
|
106
|
+
diagnostic("Shell enrollment was cancelled.", failure: error) unless @enrollment_stopping
|
|
107
|
+
rescue StandardError => error
|
|
108
|
+
diagnostic("Shell enrollment failed (#{error.class}).", failure: error) unless @enrollment_stopping
|
|
109
|
+
ensure
|
|
110
|
+
remove_setup_file(owned)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
@enrollment_tasks << child
|
|
114
|
+
child.run
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def publish_setup_file(path, command)
|
|
119
|
+
temporary = File.join(File.dirname(path), ".libtmux-ruby-shell-#{SecureRandom.hex(16)}")
|
|
120
|
+
owned = staging = nil
|
|
121
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
122
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
123
|
+
staging = [temporary, file.stat]
|
|
124
|
+
@setup_files << staging
|
|
125
|
+
file.chmod(0o600)
|
|
126
|
+
file.write(command)
|
|
127
|
+
end
|
|
128
|
+
# A hard link publishes the complete file and refuses existing names.
|
|
129
|
+
File.link(temporary, path)
|
|
130
|
+
owned = [path, staging.last]
|
|
131
|
+
@setup_files << owned
|
|
132
|
+
end
|
|
133
|
+
remove_setup_file(staging)
|
|
134
|
+
owned
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def remove_setup_file(owned)
|
|
138
|
+
path, identity = owned
|
|
139
|
+
begin
|
|
140
|
+
current = File.lstat(path)
|
|
141
|
+
File.unlink(path) if [current.dev, current.ino] == [identity.dev, identity.ino]
|
|
142
|
+
rescue Errno::ENOENT
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
@setup_files.delete(owned)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close_enrollments
|
|
149
|
+
@enrollment_stopping = true
|
|
150
|
+
errors = []
|
|
151
|
+
interrupted = nil
|
|
152
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
|
|
153
|
+
actions = @enrollment_tasks.map do |child|
|
|
154
|
+
lambda do
|
|
155
|
+
child.cancel unless child.finished?
|
|
156
|
+
child.wait(timeout: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
actions.concat(@setup_files.dup.map { |owned| -> { remove_setup_file(owned) } })
|
|
160
|
+
actions.each do |action|
|
|
161
|
+
begin
|
|
162
|
+
action.call
|
|
163
|
+
rescue ::Async::Cancel => error
|
|
164
|
+
interrupted ||= error
|
|
165
|
+
retry if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
166
|
+
errors << "shell setup retirement was interrupted"
|
|
167
|
+
rescue Exception => error
|
|
168
|
+
errors << "shell setup retirement failed (#{error.class})"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
@enrollment_tasks.reject!(&:finished?)
|
|
172
|
+
errors << "shell enrollment tasks remain active" unless @enrollment_tasks.empty?
|
|
173
|
+
if interrupted
|
|
174
|
+
ProcessIdentity.attach_cleanup(interrupted, errors) unless errors.empty?
|
|
175
|
+
raise interrupted
|
|
176
|
+
end
|
|
177
|
+
raise TransportError.new("shell setup cleanup remains pending", phase: :retire, cleanup_errors: errors) unless errors.empty?
|
|
178
|
+
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def parser
|
|
183
|
+
@parser ||= OptionParser.new do |options|
|
|
184
|
+
options.banner = "Usage: libtmux-mcp (--socket PATH | --socket-name NAME) [options]"
|
|
185
|
+
options.on("--socket PATH", "Borrow this existing tmux Unix socket") { |value| @options[:socket_path] = value }
|
|
186
|
+
options.on("--socket-name NAME", "Borrow this explicit tmux socket name") { |value| @options[:socket_name] = value }
|
|
187
|
+
options.on("--endpoint NAME", "Public endpoint alias (default: local)") { |value| @options[:endpoint_name] = value }
|
|
188
|
+
options.on("--tmux EXECUTABLE", "Resolve and pin this tmux executable at startup") { |value| @options[:executable] = value }
|
|
189
|
+
options.on("--enable-tool NAME", "Enable one additional tool; repeat as needed") { |value| @options.fetch(:tools) << value }
|
|
190
|
+
options.on("--enroll-pane %ID=FILE", "Write a private zsh setup file for this exact pane; requires tmux_run") do |value|
|
|
191
|
+
id, path = value.split("=", 2)
|
|
192
|
+
raise ArgumentError unless id && /\A%\d+\z/.match?(id) && path && !path.empty? && path.bytesize <= 4096 && !/[\0\r\n]/.match?(path)
|
|
193
|
+
|
|
194
|
+
@options.fetch(:enrollments) << [id.freeze, File.expand_path(path).freeze].freeze
|
|
195
|
+
end
|
|
196
|
+
options.on("--enrollment-timeout SECONDS", Float, "Setup file lifetime, at most 300 seconds (default: 60)") { |value| @options[:enrollment_timeout] = value }
|
|
197
|
+
options.on("--timeout SECONDS", Float, "Total request deadline (default: 5)") { |value| @options[:timeout] = value }
|
|
198
|
+
options.on("--concurrency COUNT", Integer, "Active requests (default: 4)") { |value| @options[:concurrency] = value }
|
|
199
|
+
options.on("--max-requests COUNT", Integer, "Active plus waiting requests (default: 32)") { |value| @options[:max_requests] = value }
|
|
200
|
+
options.on("--max-frame-bytes BYTES", Integer, "Input frame limit (default: 1048576)") { |value| @options[:max_frame_bytes] = value }
|
|
201
|
+
options.on("--max-output-bytes BYTES", Integer, "Queued protocol output limit (default: 4194304)") { |value| @options[:max_output_bytes] = value }
|
|
202
|
+
options.on("--max-response-bytes BYTES", Integer, "Structured tool response limit (default: 1048576)") { |value| @options[:max_response_bytes] = value }
|
|
203
|
+
options.on("--version", "Print the gem version") { @options[:version] = true }
|
|
204
|
+
options.on("-h", "--help", "Show supported options") { @options[:help] = true }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def validate_arguments
|
|
209
|
+
raise ArgumentError unless @arguments.empty?
|
|
210
|
+
raise ArgumentError unless [@options[:socket_path], @options[:socket_name]].compact.length == 1
|
|
211
|
+
raise ArgumentError unless @options.fetch(:tools).all? { |name| Catalog::NAMES.include?(name) }
|
|
212
|
+
raise ArgumentError unless /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\z/.match?(@options.fetch(:endpoint_name))
|
|
213
|
+
raise ArgumentError unless @options.fetch(:timeout).finite? && @options.fetch(:timeout).positive?
|
|
214
|
+
lifetime = @options.fetch(:enrollment_timeout)
|
|
215
|
+
raise ArgumentError unless lifetime.finite? && lifetime.positive? && lifetime <= 300
|
|
216
|
+
enrollments = @options.fetch(:enrollments)
|
|
217
|
+
raise ArgumentError unless enrollments.length <= 8
|
|
218
|
+
raise ArgumentError unless enrollments.map(&:first).uniq.length == enrollments.length && enrollments.map(&:last).uniq.length == enrollments.length
|
|
219
|
+
raise ArgumentError if !enrollments.empty? && !@options.fetch(:tools).include?("tmux_run")
|
|
220
|
+
%i[concurrency max_requests max_frame_bytes max_output_bytes max_response_bytes].each do |key|
|
|
221
|
+
raise ArgumentError unless @options.fetch(key).positive?
|
|
222
|
+
end
|
|
223
|
+
raise ArgumentError unless [@input, @output].all? { |io| io.is_a?(IO) && !io.closed? }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def report(message, status, failure: nil)
|
|
227
|
+
diagnostic(message, failure: failure)
|
|
228
|
+
status
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def diagnostic(message, failure: nil)
|
|
232
|
+
@error.puts(message)
|
|
233
|
+
rescue Exception => error
|
|
234
|
+
ProcessIdentity.attach_cleanup(failure, ["MCP diagnostic failed (#{error.class})"]) if failure
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|