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,519 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/mcp/catalog"
|
|
4
|
+
require "libtmux/mcp/catalog_tool"
|
|
5
|
+
require "libtmux/mcp/mutations"
|
|
6
|
+
require "libtmux/mcp/observation"
|
|
7
|
+
require "libtmux/mcp/resources"
|
|
8
|
+
require "libtmux/mcp/enrollment"
|
|
9
|
+
|
|
10
|
+
module LibTmux
|
|
11
|
+
module MCP
|
|
12
|
+
class Application
|
|
13
|
+
CursorError = Class.new(LibTmux::Error)
|
|
14
|
+
Capture = Data.define(:rows, :metadata, :limit, :expires, :bytes)
|
|
15
|
+
private_constant :CursorError, :Capture
|
|
16
|
+
|
|
17
|
+
class RequestRetirement
|
|
18
|
+
def initialize(token, cancellation, callback)
|
|
19
|
+
@token, @cancellation, @callback = token, cancellation, callback
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def close
|
|
23
|
+
failure = nil
|
|
24
|
+
if @callback
|
|
25
|
+
begin
|
|
26
|
+
@cancellation.off_cancel(@callback)
|
|
27
|
+
@callback = nil
|
|
28
|
+
rescue Exception => error
|
|
29
|
+
failure = error
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
if @token
|
|
33
|
+
begin
|
|
34
|
+
@token.close
|
|
35
|
+
@token = nil
|
|
36
|
+
rescue Exception => error
|
|
37
|
+
ProcessIdentity.attach_cleanup(failure, ["cancellation token retirement failed (#{error.class})"]) if failure
|
|
38
|
+
failure ||= error
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
raise failure if failure
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_constant :RequestRetirement
|
|
47
|
+
|
|
48
|
+
attr_reader :tools
|
|
49
|
+
|
|
50
|
+
def initialize(server:, endpoint_name:, enabled_tools: Catalog::READ_ONLY,
|
|
51
|
+
max_captures: 16, max_capture_bytes: 1 << 23, capture_ttl: 30,
|
|
52
|
+
request_timeout: 5, max_response_bytes: 1 << 20)
|
|
53
|
+
raise ArgumentError, "MCP requires an application-owned Async server facade" unless server.is_a?(LibTmux::Async::Server)
|
|
54
|
+
unless endpoint_name.is_a?(String) && /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\z/.match?(endpoint_name)
|
|
55
|
+
raise ArgumentError, "endpoint_name must be a public endpoint alias"
|
|
56
|
+
end
|
|
57
|
+
unless enabled_tools.is_a?(Array) && (enabled_tools - Catalog::NAMES).empty?
|
|
58
|
+
raise ArgumentError, "enabled_tools must name implemented catalog tools"
|
|
59
|
+
end
|
|
60
|
+
unless [max_captures, max_capture_bytes, max_response_bytes].all? { |n| n.is_a?(Integer) && n.positive? }
|
|
61
|
+
raise ArgumentError, "MCP capacity limits must be positive Integers"
|
|
62
|
+
end
|
|
63
|
+
if (enabled_tools & Catalog::MUTATIONS).any? && max_response_bytes < Catalog::MIN_MUTATION_RESPONSE_BYTES
|
|
64
|
+
raise ArgumentError, "MCP mutation response capacity must be at least 4096 bytes"
|
|
65
|
+
end
|
|
66
|
+
unless [capture_ttl, request_timeout].all? { |n| n.is_a?(Numeric) && n.finite? && n.positive? }
|
|
67
|
+
raise ArgumentError, "MCP deadlines must be positive and finite"
|
|
68
|
+
end
|
|
69
|
+
@server, @endpoint = server, endpoint_name.dup.freeze
|
|
70
|
+
@enabled = Catalog::NAMES.select { |name| enabled_tools.include?(name) }.freeze
|
|
71
|
+
@limits = {"max_captures" => max_captures, "max_capture_bytes" => max_capture_bytes,
|
|
72
|
+
"capture_ttl_seconds" => capture_ttl, "request_timeout_seconds" => request_timeout,
|
|
73
|
+
"max_response_bytes" => max_response_bytes, "max_mutation_bytes" => Catalog::MUTATION_BYTES,
|
|
74
|
+
"max_observers" => max_captures,
|
|
75
|
+
"max_enrollments" => 8, "default_enrollment_seconds" => 60, "max_enrollment_seconds" => 300,
|
|
76
|
+
"max_script_bytes" => 65_536, "default_run_output_bytes" => 65_536, "max_run_output_bytes" => 262_144,
|
|
77
|
+
"min_mutation_response_bytes" => Catalog::MIN_MUTATION_RESPONSE_BYTES}.freeze
|
|
78
|
+
@thread, @pid, @scheduler = Thread.current, Process.pid, Fiber.scheduler
|
|
79
|
+
@captures, @retained_bytes = {}, 0
|
|
80
|
+
@observers, @retiring = [], []
|
|
81
|
+
@calls, @calls_changed = {}, ::Async::Notification.new
|
|
82
|
+
@parent = ::Async::Task.current
|
|
83
|
+
application = self
|
|
84
|
+
@tools = @enabled.map do |name|
|
|
85
|
+
CatalogTool.define(name: name, description: Catalog.description(name),
|
|
86
|
+
input_schema: Catalog.input(name), output_schema: Catalog.output(name),
|
|
87
|
+
annotations: {read_only_hint: false, destructive_hint: Catalog::MUTATIONS.include?(name), idempotent_hint: false, open_world_hint: false}) do |server_context: nil, **arguments|
|
|
88
|
+
application.call(name, arguments, cancellation: server_context&.cancellation)
|
|
89
|
+
end
|
|
90
|
+
end.freeze
|
|
91
|
+
@by_name = @tools.to_h { |tool| [tool.name_value, tool] }.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def sdk_server
|
|
95
|
+
::MCP::Server.new(name: "libtmux", version: VERSION, tools: tools,
|
|
96
|
+
instructions: "Use discovery before operations. Captured metadata is interval evidence, not an atomic snapshot.",
|
|
97
|
+
configuration: ::MCP::Configuration.new(validate_tool_call_arguments: false, validate_tool_call_results: true),
|
|
98
|
+
capabilities: {tools: {listChanged: false}}, ttl_ms: 0, cache_scope: "private").tap do |sdk|
|
|
99
|
+
Resources.new(application: self, endpoint_name: @endpoint, enabled_tools: @enabled,
|
|
100
|
+
max_response_bytes: @limits.fetch("max_response_bytes")).install(sdk)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def invite_shell(reference, timeout: nil, expires_in: 60, cancellation: nil)
|
|
105
|
+
shell_request(cancellation) do |registry, token|
|
|
106
|
+
registry.invite(reference, timeout: timeout || @limits.fetch("request_timeout_seconds"),
|
|
107
|
+
expires_in: expires_in, cancel: token)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def accept_shell(invitation, timeout: nil, cancellation: nil)
|
|
112
|
+
shell_request(cancellation) do |registry, token|
|
|
113
|
+
registry.accept(invitation, timeout: timeout, cancel: token).reference
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def call(name, arguments = {}, cancellation: nil)
|
|
118
|
+
unless Process.pid == @pid && Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@scheduler)
|
|
119
|
+
raise ClosedError.new("MCP application belongs to another scheduler", phase: :admission)
|
|
120
|
+
end
|
|
121
|
+
return failure_response("policy_denied", "The configured policy denies this operation.") unless @enabled.include?(name)
|
|
122
|
+
return failure_response("closed", "The application is closed.") if @closed
|
|
123
|
+
return failure_response("capacity", "Request cleanup remains pending.") unless @retiring.empty?
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
tool = @by_name.fetch(name)
|
|
127
|
+
tool.input_schema_value
|
|
128
|
+
tool.output_schema_value
|
|
129
|
+
wire = JSON.generate(arguments)
|
|
130
|
+
raise CapacityError.new("MCP input exceeds its byte limit", phase: :admission) if wire.bytesize > 1 << 20
|
|
131
|
+
arguments = JSON.parse(wire, max_nesting: 68, allow_nan: false, allow_duplicate_key: false)
|
|
132
|
+
tool.input_schema_value.validate_arguments(arguments)
|
|
133
|
+
token = Internal::Cancellation.new
|
|
134
|
+
@calls[token] = ::Async::Task.current
|
|
135
|
+
callback = cancellation&.on_cancel { token.cancel }
|
|
136
|
+
raise Cancelled.new("MCP request was cancelled", phase: :admission) if token.cancelled?
|
|
137
|
+
|
|
138
|
+
result = case name
|
|
139
|
+
when "tmux_capabilities" then capabilities(token)
|
|
140
|
+
when "tmux_snapshot" then snapshot(arguments, token)
|
|
141
|
+
when "tmux_capture" then capture_screen(arguments, token)
|
|
142
|
+
when "tmux_wait" then wait_for_observation(arguments, token)
|
|
143
|
+
when "tmux_run" then run_script(arguments, token)
|
|
144
|
+
else
|
|
145
|
+
mutation = Mutation.new(server: @server, arguments: arguments, timeout: @limits.fetch("request_timeout_seconds"),
|
|
146
|
+
cancel: token, max_snapshot_bytes: [@limits.fetch("max_capture_bytes"), 1 << 20].min)
|
|
147
|
+
mutation.call(name)
|
|
148
|
+
end
|
|
149
|
+
structured = {"ok" => true, "data" => result}
|
|
150
|
+
validate_response_size(structured)
|
|
151
|
+
tool.output_schema_value.validate_result(structured)
|
|
152
|
+
::MCP::Tool::Response.new([{type: "text", text: "#{name} completed; structuredContent contains the result."}], structured_content: structured)
|
|
153
|
+
ensure
|
|
154
|
+
finish_request(token, cancellation, callback, primary: $!)
|
|
155
|
+
end
|
|
156
|
+
rescue ::MCP::Tool::InputSchema::ValidationError, JSON::JSONError, ArgumentError
|
|
157
|
+
failure_response("invalid_input", "Input does not match the operation schema.")
|
|
158
|
+
rescue CursorError, Observation::StaleCursor
|
|
159
|
+
failure_response("stale_cursor", "The cursor is unknown, expired, or outside its captured result.")
|
|
160
|
+
rescue LibTmux::Error, SystemCallError, IOError => error
|
|
161
|
+
code = {InvalidFilterError => "invalid_filter", FieldDecodeError => "decode_error",
|
|
162
|
+
IncompleteSnapshotError => "incomplete_snapshot", Cancelled => "cancelled",
|
|
163
|
+
DeadlineExceeded => "deadline", CapacityError => "capacity", TargetNotFoundError => "stale_target",
|
|
164
|
+
CommandError => "command_failed", UnsupportedFeatureError => "unsupported", ClosedError => "closed",
|
|
165
|
+
Observation::LostObservation => "observation_lost"}.fetch(error.class, "transport_error")
|
|
166
|
+
delivery = error.respond_to?(:delivery) ? (mutation ? mutation.delivery(error) : error.delivery) : :possibly_sent
|
|
167
|
+
delivery = :observed if result
|
|
168
|
+
effects = if name == "tmux_run"
|
|
169
|
+
receipt = result && result["authorization"] || (error.run_receipt if error.respond_to?(:run_receipt))
|
|
170
|
+
delivery = error.run_delivery || delivery if error.respond_to?(:run_delivery)
|
|
171
|
+
completion = result && result["completion"] || (error.run_completion if error.respond_to?(:run_completion))
|
|
172
|
+
{"state" => receipt ? "known" : delivery == :not_sent ? "none" : "unknown", "authorization" => receipt,
|
|
173
|
+
"completion" => completion || {"state" => "unobserved"}}
|
|
174
|
+
elsif Catalog::MUTATIONS.include?(name)
|
|
175
|
+
created = result && result["created"]
|
|
176
|
+
{"state" => created ? "known" : delivery == :not_sent ? "none" : "unknown", "created" => created || []}
|
|
177
|
+
end
|
|
178
|
+
failure_response(code, "The operation could not establish its requested result.", delivery.to_s, effects: effects)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def inspect
|
|
182
|
+
"#<#{self.class} enabled_tools=#{@enabled.length} retained_captures=#{@captures.length}>"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def close
|
|
186
|
+
unless Process.pid == @pid && Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@scheduler)
|
|
187
|
+
raise ClosedError.new("MCP application belongs to another scheduler", phase: :retire)
|
|
188
|
+
end
|
|
189
|
+
if @calls.value?(::Async::Task.current)
|
|
190
|
+
raise ClosedError.new("cannot close an MCP application from its active request", phase: :retire)
|
|
191
|
+
end
|
|
192
|
+
@closed = true
|
|
193
|
+
errors = []
|
|
194
|
+
interrupted = nil
|
|
195
|
+
deadline = clock + 0.5
|
|
196
|
+
@calls.keys.each do |token|
|
|
197
|
+
begin
|
|
198
|
+
token.cancel
|
|
199
|
+
rescue Exception => error
|
|
200
|
+
interrupted ||= error if error.is_a?(::Async::Cancel)
|
|
201
|
+
errors << "request cancellation failed (#{error.class})"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
until @calls.empty? || clock >= deadline
|
|
205
|
+
begin
|
|
206
|
+
::Async::Task.current.with_timeout(deadline - clock) { @calls_changed.wait }
|
|
207
|
+
rescue ::Async::Cancel => error
|
|
208
|
+
interrupted ||= error
|
|
209
|
+
rescue ::Async::TimeoutError
|
|
210
|
+
break
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
errors << "admitted requests remain active" unless @calls.empty?
|
|
214
|
+
if @shells
|
|
215
|
+
begin
|
|
216
|
+
@shells.close(timeout: [deadline - clock, 0].max)
|
|
217
|
+
rescue Exception => error
|
|
218
|
+
interrupted ||= error if error.is_a?(::Async::Cancel)
|
|
219
|
+
errors << "shell enrollment retirement failed (#{error.class})"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
@captures.keys.each do |key|
|
|
223
|
+
begin
|
|
224
|
+
evict(key)
|
|
225
|
+
rescue Exception => error
|
|
226
|
+
interrupted ||= error if error.is_a?(::Async::Cancel)
|
|
227
|
+
errors << "capture retirement failed (#{error.class})"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
@retiring.dup.each do |resource|
|
|
231
|
+
begin
|
|
232
|
+
resource.is_a?(Observation) ? resource.close(timeout: [deadline - clock, 0].max) : resource.close
|
|
233
|
+
@observers.delete(resource)
|
|
234
|
+
@retiring.delete(resource)
|
|
235
|
+
rescue Exception => error
|
|
236
|
+
interrupted ||= error if error.is_a?(::Async::Cancel)
|
|
237
|
+
errors << "observation retirement failed (#{error.class})"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
if interrupted
|
|
241
|
+
ProcessIdentity.attach_cleanup(interrupted, errors) unless errors.empty?
|
|
242
|
+
raise interrupted
|
|
243
|
+
end
|
|
244
|
+
raise TransportError.new("MCP application cleanup remains pending", phase: :retire, cleanup_errors: errors) unless errors.empty?
|
|
245
|
+
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def shell_request(cancellation)
|
|
252
|
+
unless Process.pid == @pid && Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@scheduler)
|
|
253
|
+
raise ClosedError.new("MCP application belongs to another scheduler", phase: :admission)
|
|
254
|
+
end
|
|
255
|
+
raise UnsupportedFeatureError.new("shell enrollment is disabled by policy", phase: :admission) unless @enabled.include?("tmux_run")
|
|
256
|
+
raise ClosedError.new("MCP application is closed", phase: :admission) if @closed
|
|
257
|
+
raise CapacityError.new("request cleanup remains pending", phase: :admission) unless @retiring.empty?
|
|
258
|
+
|
|
259
|
+
token = Internal::Cancellation.new
|
|
260
|
+
@calls[token] = ::Async::Task.current
|
|
261
|
+
callback = cancellation&.on_cancel { token.cancel }
|
|
262
|
+
raise Cancelled.new("MCP request was cancelled", phase: :admission) if token.cancelled?
|
|
263
|
+
|
|
264
|
+
yield shell_registry, token
|
|
265
|
+
ensure
|
|
266
|
+
finish_request(token, cancellation, callback, primary: $!)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def finish_request(token, cancellation, callback, primary:)
|
|
270
|
+
return unless token || callback
|
|
271
|
+
|
|
272
|
+
retirement = RequestRetirement.new(token, cancellation, callback)
|
|
273
|
+
failure = nil
|
|
274
|
+
begin
|
|
275
|
+
retirement.close
|
|
276
|
+
rescue Exception => error
|
|
277
|
+
@retiring << retirement
|
|
278
|
+
details = ["request retirement failed (#{error.class})"]
|
|
279
|
+
if primary
|
|
280
|
+
ProcessIdentity.attach_cleanup(primary, details)
|
|
281
|
+
elsif error.is_a?(::Async::Cancel)
|
|
282
|
+
failure = error
|
|
283
|
+
else
|
|
284
|
+
failure = TransportError.new("request cleanup remains pending", phase: :retire, cleanup_errors: details)
|
|
285
|
+
end
|
|
286
|
+
ensure
|
|
287
|
+
@calls.delete(token) if token
|
|
288
|
+
@calls_changed.signal if token
|
|
289
|
+
end
|
|
290
|
+
raise failure, cause: nil if failure
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def shell_registry
|
|
294
|
+
@shells ||= EnrollmentRegistry.new(server: @server, parent: @parent, max_enrollments: @limits.fetch("max_enrollments"))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def run_script(arguments, token)
|
|
298
|
+
stdout_limit, stderr_limit = arguments.fetch("stdout_limit", 65_536), arguments.fetch("stderr_limit", 65_536)
|
|
299
|
+
if 4096 + 6 * (stdout_limit + stderr_limit) > @limits.fetch("max_response_bytes")
|
|
300
|
+
raise CapacityError.new("authored output exceeds response reservation", phase: :admission)
|
|
301
|
+
end
|
|
302
|
+
target = arguments.fetch("target")
|
|
303
|
+
reference = @server.__send__(:with_bound_endpoint) do |_endpoint, pin|
|
|
304
|
+
unless target.fetch("generation") == pin.key
|
|
305
|
+
raise TargetNotFoundError.new("authored target belongs to another binding", phase: :admission)
|
|
306
|
+
end
|
|
307
|
+
EntityRef.__send__(:new, binding_key: pin.key, kind: :pane, id: target.fetch("id"))
|
|
308
|
+
end
|
|
309
|
+
result = shell_registry.run(reference, script: arguments.fetch("script"), timeout: @limits.fetch("request_timeout_seconds"),
|
|
310
|
+
cancel: token, stdout_limit: stdout_limit, stderr_limit: stderr_limit)
|
|
311
|
+
{"target" => target, "authorization" => result.receipt,
|
|
312
|
+
"completion" => {"state" => result.signal ? "signaled" : "exited", "exit_status" => result.exit_status, "signal" => result.signal},
|
|
313
|
+
"stdout" => run_bytes(result.stdout), "stderr" => run_bytes(result.stderr)}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def run_bytes(bytes)
|
|
317
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
318
|
+
valid = text.valid_encoding?
|
|
319
|
+
{"encoding" => valid ? "utf-8" : "base64", "data" => valid ? text : [bytes].pack("m0"),
|
|
320
|
+
"bytes" => bytes.bytesize, "truncated" => false}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def observation(arguments, cancel)
|
|
324
|
+
if !@retiring.empty? || @observers.length >= @limits.fetch("max_observers")
|
|
325
|
+
raise CapacityError.new("observation admission is full or retiring", phase: :admission)
|
|
326
|
+
end
|
|
327
|
+
observer = Observation.new(server: @server, arguments: arguments, timeout: @limits.fetch("request_timeout_seconds"),
|
|
328
|
+
cancel: cancel, max_snapshot_bytes: [@limits.fetch("max_capture_bytes"), 1 << 20].min)
|
|
329
|
+
@observers << observer
|
|
330
|
+
observer
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def capture_screen(arguments, cancel)
|
|
334
|
+
prune
|
|
335
|
+
previous = arguments["cursor"] && @captures[arguments.fetch("cursor")]
|
|
336
|
+
if arguments["cursor"] && !previous.is_a?(Observation::Capture)
|
|
337
|
+
raise CursorError, "screen cursor capture is unavailable"
|
|
338
|
+
end
|
|
339
|
+
observer = observation(arguments, cancel)
|
|
340
|
+
result, entry = observer.capture(previous: previous, expires: clock + @limits.fetch("capture_ttl_seconds"))
|
|
341
|
+
@retiring << entry if entry
|
|
342
|
+
validate_response_size({"ok" => true, "data" => result})
|
|
343
|
+
if entry
|
|
344
|
+
raise ClosedError.new("MCP application closed during capture", phase: :read) if @closed
|
|
345
|
+
raise CapacityError.new("screen capture exceeds retention bytes", delivery: :observed) if entry.bytes > @limits.fetch("max_capture_bytes")
|
|
346
|
+
|
|
347
|
+
while @captures.length >= @limits.fetch("max_captures") || @retained_bytes + entry.bytes > @limits.fetch("max_capture_bytes")
|
|
348
|
+
evict(@captures.keys.first)
|
|
349
|
+
end
|
|
350
|
+
@captures[entry.capture_id] = entry
|
|
351
|
+
@retained_bytes += entry.bytes
|
|
352
|
+
@retiring.delete(entry)
|
|
353
|
+
entry = nil
|
|
354
|
+
end
|
|
355
|
+
result
|
|
356
|
+
ensure
|
|
357
|
+
retire_observation(observer, entry)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def wait_for_observation(arguments, cancel)
|
|
361
|
+
observer = observation(arguments, cancel)
|
|
362
|
+
observer.wait
|
|
363
|
+
ensure
|
|
364
|
+
retire_observation(observer)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def retire_observation(observer, entry = nil)
|
|
368
|
+
primary = $!
|
|
369
|
+
errors = []
|
|
370
|
+
[observer, entry].compact.each do |resource|
|
|
371
|
+
begin
|
|
372
|
+
resource.is_a?(Observation) ? resource.close(timeout: resource.cleanup_remaining) : resource.close
|
|
373
|
+
@observers.delete(resource)
|
|
374
|
+
@retiring.delete(resource)
|
|
375
|
+
rescue Exception => error
|
|
376
|
+
errors << "observation cleanup failed (#{error.class})"
|
|
377
|
+
@retiring << resource unless @retiring.include?(resource)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
return if errors.empty?
|
|
381
|
+
|
|
382
|
+
if primary.is_a?(LibTmux::Error)
|
|
383
|
+
primary.__send__(:attach_cleanup_errors, errors)
|
|
384
|
+
elsif primary
|
|
385
|
+
ProcessIdentity.attach_cleanup(primary, errors)
|
|
386
|
+
elsif !primary
|
|
387
|
+
raise TransportError.new("observation cleanup remains pending", phase: :retire, cleanup_errors: errors)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def acquire(cancel)
|
|
392
|
+
@server.snapshot(timeout: @limits.fetch("request_timeout_seconds"), cancel: cancel,
|
|
393
|
+
clients: true, max_bytes: [@limits.fetch("max_capture_bytes"), 1 << 20].min, max_rows: 4096)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def identity(snapshot)
|
|
397
|
+
{"generation" => snapshot.binding_key, "pid" => snapshot.server_info.fetch(:pid),
|
|
398
|
+
"start_time" => snapshot.server_info.fetch(:start_time), "tmux_version" => snapshot.server_info.fetch(:version)}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def capabilities(cancel)
|
|
402
|
+
snapshot = acquire(cancel)
|
|
403
|
+
observation = Observation.capabilities(snapshot.server_info.fetch(:version))
|
|
404
|
+
{"endpoint" => @endpoint, "server_identity" => identity(snapshot), "enabled_tools" => @enabled,
|
|
405
|
+
"criteria_schema" => FilterExpr.json_schema, "limits" => @limits,
|
|
406
|
+
"owns_daemon" => false, "resource_subscriptions" => false,
|
|
407
|
+
"observation" => observation,
|
|
408
|
+
"authored_run" => {"availability" => observation.fetch("process_cursor"),
|
|
409
|
+
"shell_profile" => "zsh-5.9-zle", "enrollment" => "explicit_source",
|
|
410
|
+
"authorization" => "exact_generation_at_queue_grant", "stdin" => "closed",
|
|
411
|
+
"persistent_shell_changes" => false, "descendant_termination" => "unobserved",
|
|
412
|
+
"requirements" => observation.fetch("requirements") + ["explicit idle/empty ZLE enrollment", "installed Ruby/core helper dependencies"]}}
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def snapshot(arguments, cancel)
|
|
416
|
+
prune
|
|
417
|
+
if arguments["cursor"]
|
|
418
|
+
key, position = arguments.fetch("cursor").split(":", 2)
|
|
419
|
+
capture = @captures.fetch(key) { raise CursorError, "cursor capture is unavailable" }
|
|
420
|
+
raise CursorError, "cursor has another capture kind" unless capture.is_a?(Capture)
|
|
421
|
+
offset = Integer(position, 10)
|
|
422
|
+
raise CursorError, "cursor offset is outside the capture" unless offset.positive? && offset < capture.rows.length
|
|
423
|
+
return page(key, capture, offset)
|
|
424
|
+
end
|
|
425
|
+
entity = arguments.fetch("entity").to_sym
|
|
426
|
+
expression = if arguments["criteria"]
|
|
427
|
+
FilterExpr.from_json(JSON.generate(arguments.fetch("criteria")))
|
|
428
|
+
else
|
|
429
|
+
FilterExpr.build(entity)
|
|
430
|
+
end
|
|
431
|
+
raise InvalidFilterError, "criteria entity does not match acquisition" unless expression.entity == entity
|
|
432
|
+
|
|
433
|
+
snapshot = acquire(cancel)
|
|
434
|
+
selection = snapshot.public_send({session: :sessions, window: :windows, pane: :panes,
|
|
435
|
+
window_link: :window_links, client: :clients}.fetch(entity)).where(expression)
|
|
436
|
+
bytes = 0
|
|
437
|
+
rows = selection.map do |record|
|
|
438
|
+
fields = Internal::Catalog.entity(entity).fields.values.to_h { |field| [field.wire_name, record.public_send(field.name)] }
|
|
439
|
+
reference = if entity == :client
|
|
440
|
+
nil
|
|
441
|
+
else
|
|
442
|
+
ref = record.ref
|
|
443
|
+
{"generation" => ref.binding_key, "kind" => ref.kind.to_s, "id" => ref.id}.tap do |value|
|
|
444
|
+
if entity == :window_link
|
|
445
|
+
value["session_id"], value["index"] = ref.session_id, ref.index
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
row = {"kind" => entity.to_s, "fields" => fields, "ref" => reference}
|
|
450
|
+
bytes += JSON.generate(row).bytesize
|
|
451
|
+
raise CapacityError.new("captured records exceed retention bytes", delivery: :observed) if bytes > @limits.fetch("max_capture_bytes")
|
|
452
|
+
row
|
|
453
|
+
end
|
|
454
|
+
metadata = {"capture_id" => snapshot.capture_id, "server_identity" => identity(snapshot),
|
|
455
|
+
"entity" => entity.to_s, "coverage" => Internal::Catalog.kinds.to_h { |kind| [kind.to_s, "complete"] },
|
|
456
|
+
"interval" => {"clock" => "monotonic_seconds", "started" => snapshot.started_at,
|
|
457
|
+
"finished" => snapshot.finished_at, "reads" => snapshot.reads.length}}
|
|
458
|
+
bytes += JSON.generate(metadata).bytesize
|
|
459
|
+
raise CapacityError.new("capture exceeds retention bytes", delivery: :observed) if bytes > @limits.fetch("max_capture_bytes")
|
|
460
|
+
key = SecureRandom.hex(16)
|
|
461
|
+
capture = Capture.new(rows: freeze_tree(rows), metadata: freeze_tree(metadata), limit: arguments.fetch("limit", 50),
|
|
462
|
+
expires: clock + @limits.fetch("capture_ttl_seconds"), bytes: bytes)
|
|
463
|
+
first_page = page(key, capture, 0)
|
|
464
|
+
validate_response_size({"ok" => true, "data" => first_page})
|
|
465
|
+
raise ClosedError.new("MCP application closed during capture", phase: :read) if @closed
|
|
466
|
+
while @captures.length >= @limits.fetch("max_captures") || @retained_bytes + bytes > @limits.fetch("max_capture_bytes")
|
|
467
|
+
evict(@captures.keys.first)
|
|
468
|
+
end
|
|
469
|
+
@captures[key] = capture
|
|
470
|
+
@retained_bytes += bytes
|
|
471
|
+
first_page
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def validate_response_size(structured)
|
|
475
|
+
if JSON.generate(structured).bytesize > @limits.fetch("max_response_bytes")
|
|
476
|
+
raise CapacityError.new("MCP response exceeds its byte limit", phase: :read, delivery: :observed)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def page(key, capture, offset)
|
|
481
|
+
items = capture.rows.slice(offset, capture.limit) || []
|
|
482
|
+
next_position = offset + items.length
|
|
483
|
+
capture.metadata.merge("items" => items, "truncated" => next_position < capture.rows.length).tap do |result|
|
|
484
|
+
result["next_cursor"] = "#{key}:#{next_position}" if next_position < capture.rows.length
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def prune
|
|
489
|
+
@captures.keys.each { |key| evict(key) if @captures.fetch(key).expires <= clock }
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def evict(key)
|
|
493
|
+
entry = @captures.fetch(key)
|
|
494
|
+
entry.close if entry.respond_to?(:close)
|
|
495
|
+
@captures.delete(key)
|
|
496
|
+
@retained_bytes -= entry.bytes
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def freeze_tree(value)
|
|
500
|
+
case value
|
|
501
|
+
when Hash then value.each { |key, child| key.freeze; freeze_tree(child) }
|
|
502
|
+
when Array then value.each { |child| freeze_tree(child) }
|
|
503
|
+
end
|
|
504
|
+
value.freeze
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def failure_response(code, message, delivery = "not_sent", effects: nil)
|
|
508
|
+
details = {"code" => code, "message" => message, "delivery" => delivery}
|
|
509
|
+
details["effects"] = effects if effects
|
|
510
|
+
::MCP::Tool::Response.new([{type: "text", text: message}], error: true,
|
|
511
|
+
structured_content: {"ok" => false, "error" => details})
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def clock
|
|
515
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|