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.
@@ -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