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,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "async"
5
+ require "async/notification"
6
+ require "libtmux/errors"
7
+
8
+ module LibTmux
9
+ module MCP
10
+ # Owns Async tasks, while the application retains its streams and scheduler.
11
+ class StdioTransport < ::MCP::Transport
12
+ RequestExpired = Class.new(Exception)
13
+ WriteExpired = Class.new(Exception)
14
+ Ticket = Struct.new(:request, :bytes, :deadline, :task, :active, :cancellation, :callback, :state, keyword_init: true)
15
+ Frame = Struct.new(:bytes, :state, keyword_init: true)
16
+ module CleanupDetails
17
+ attr_reader :mcp_cleanup_errors
18
+ end
19
+ private_constant :RequestExpired, :WriteExpired, :Ticket, :Frame, :CleanupDetails
20
+
21
+ class Session < ::MCP::ServerSession
22
+ def initialize(adapter:, **options)
23
+ @adapter = adapter
24
+ super(**options)
25
+ end
26
+
27
+ def register_in_flight(id)
28
+ super.tap { |token| @adapter.__send__(:bind_cancellation, id, token) if token }
29
+ end
30
+ end
31
+
32
+ # Modern envelopes have request-local capabilities and logging state.
33
+ class ModernSession < ::MCP::ServerSession
34
+ def initialize(connection:, **options)
35
+ @connection = connection
36
+ super(**options)
37
+ end
38
+
39
+ def register_in_flight(id)
40
+ @connection.register_in_flight(id)
41
+ end
42
+
43
+ def unregister_in_flight(id, cancellation: nil)
44
+ @connection.unregister_in_flight(id, cancellation: cancellation)
45
+ end
46
+
47
+ def lookup_in_flight(id)
48
+ @connection.lookup_in_flight(id)
49
+ end
50
+ end
51
+ private_constant :Session, :ModernSession
52
+
53
+ def initialize(server:, parent:, input:, output:, concurrency: 4, max_requests: 32,
54
+ max_frame_bytes: 1 << 20, max_request_bytes: 1 << 22, max_output_bytes: 1 << 22,
55
+ request_timeout: 30, write_timeout: 0.5, cleanup_timeout: 0.5)
56
+ unless parent.is_a?(::Async::Task) && !parent.finished? && parent.root.equal?(Fiber.scheduler)
57
+ raise ArgumentError, "parent must be a live task on the current Async scheduler"
58
+ end
59
+ raise ArgumentError, "server must be an MCP SDK Server" unless server.is_a?(::MCP::Server)
60
+ unless [input, output].all? { |io| io.is_a?(IO) && !io.closed? }
61
+ raise ArgumentError, "input and output must be open IO streams"
62
+ end
63
+ [concurrency, max_requests, max_frame_bytes, max_request_bytes, max_output_bytes].each do |value|
64
+ raise ArgumentError, "transport limits must be positive Integers" unless value.is_a?(Integer) && value.positive?
65
+ end
66
+ [request_timeout, write_timeout, cleanup_timeout].each do |value|
67
+ raise ArgumentError, "transport deadlines must be positive and finite" unless value.is_a?(Numeric) && value.finite? && value.positive?
68
+ end
69
+ @parent, @input, @output = parent, input, output
70
+ @thread, @pid, @scheduler = Thread.current, Process.pid, Fiber.scheduler
71
+ @concurrency, @max_requests = concurrency, max_requests
72
+ @max_frame, @max_request, @max_output = max_frame_bytes, max_request_bytes, max_output_bytes
73
+ @request_timeout, @write_timeout, @cleanup_timeout = request_timeout, write_timeout, cleanup_timeout
74
+ @tickets, @waiting, @by_id, @by_task, @frames = [], [], {}, {}, []
75
+ @active = @request_bytes = @output_bytes = 0
76
+ @changed, @writable = ::Async::Notification.new, ::Async::Notification.new
77
+ @previous_transport = server.transport
78
+ @session = Session.new(server: server, transport: self, adapter: self)
79
+ super(server)
80
+ end
81
+
82
+ def run
83
+ ensure_owner
84
+ raise ClosedError.new("transport cannot be restarted", phase: :admission) if @runner || @closed
85
+
86
+ @runner = ::Async::Task.current
87
+ begin
88
+ @writer = child_task { write_loop }
89
+ @writer.run
90
+ @reader = child_task { read_loop }
91
+ @reader.run
92
+ @changed.wait until @input_done || @stopping || @failure
93
+ rescue Exception => error
94
+ @failure ||= error
95
+ ensure
96
+ retire
97
+ end
98
+ raise @failure, cause: nil if @failure
99
+
100
+ nil
101
+ end
102
+
103
+ def close
104
+ ensure_owner
105
+ return nil if @closed
106
+ if @by_task.key?(::Async::Task.current?)
107
+ raise ClosedError.new("cannot close transport from its own request", phase: :retire)
108
+ end
109
+
110
+ @stopping = true
111
+ @changed.signal
112
+ if @runner&.finished?
113
+ retire
114
+ raise @failure, cause: nil unless @closed
115
+ elsif @runner && !@runner.current?
116
+ @runner.wait(timeout: @cleanup_timeout * 2)
117
+ elsif !@runner
118
+ @closed = true
119
+ restore_transport
120
+ end
121
+ nil
122
+ end
123
+
124
+ def closed?
125
+ !!@closed
126
+ end
127
+
128
+ def send_response(message)
129
+ ensure_owner
130
+ raise ClosedError.new("transport output is closed", phase: :write) if @closed || @writer_done
131
+
132
+ ticket = @by_task[::Async::Task.current?]
133
+ raise RequestExpired if ticket && !ticket.state[:expired] && clock >= ticket.deadline
134
+
135
+ bytes = encode(message)
136
+ raise RequestExpired if ticket && !ticket.state[:expired] && clock >= ticket.deadline
137
+
138
+ if @output_bytes + bytes.bytesize > @max_output || @frames.length >= @max_requests * 4
139
+ raise CapacityError.new("MCP output queue limit reached", phase: :write, delivery: :possibly_sent)
140
+ end
141
+ state = ticket&.state
142
+ return nil if state && state[:cancelled]
143
+
144
+ @frames << Frame.new(bytes: bytes, state: state)
145
+ @output_bytes += bytes.bytesize
146
+ @writable.signal
147
+ nil
148
+ rescue CapacityError, ProtocolError => error
149
+ fail_transport(error)
150
+ raise
151
+ end
152
+
153
+ def send_notification(method, params = nil, related_request_id: nil, **)
154
+ return false if related_request_id && @by_id[related_request_id]&.state&.fetch(:cancelled)
155
+
156
+ send_response({jsonrpc: "2.0", method: method, params: params}.compact)
157
+ true
158
+ end
159
+
160
+ def send_request(*)
161
+ raise UnsupportedFeatureError.new("server-initiated MCP requests are not supported by this transport", phase: :admission)
162
+ end
163
+
164
+ private
165
+
166
+ def clock
167
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ end
169
+
170
+ def ensure_owner
171
+ unless Process.pid == @pid && Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@scheduler)
172
+ raise ClosedError.new("MCP transport belongs to another scheduler, thread or process", phase: :admission)
173
+ end
174
+ end
175
+
176
+ def child_task(&block)
177
+ task = ::Async::Task.new(@parent) do
178
+ block.call
179
+ rescue ::Async::Cancel
180
+ nil
181
+ rescue Exception => error
182
+ fail_transport(error)
183
+ ensure
184
+ @changed.signal
185
+ end
186
+ task
187
+ end
188
+
189
+ def fail_transport(error)
190
+ @failure ||= error
191
+ @stopping = true
192
+ @changed.signal
193
+ end
194
+
195
+ def read_loop
196
+ buffer = +"".b
197
+ loop do
198
+ break if @stopping
199
+
200
+ chunk = @input.read_nonblock([16_384, @max_frame + 1 - buffer.bytesize].min, exception: false)
201
+ if chunk == :wait_readable
202
+ @scheduler.io_wait(@input, IO::READABLE)
203
+ next
204
+ end
205
+ break unless chunk
206
+
207
+ buffer << chunk.b
208
+ while (ending = buffer.index("\n"))
209
+ frame = buffer.slice!(0, ending + 1)
210
+ raise CapacityError.new("MCP input frame limit reached", phase: :read) if frame.bytesize > @max_frame
211
+
212
+ receive(frame)
213
+ break if @stopping
214
+ end
215
+ raise CapacityError.new("MCP input frame limit reached", phase: :read) if buffer.bytesize >= @max_frame
216
+ ::Async::Task.current.yield
217
+ end
218
+ receive(buffer) unless buffer.empty? || @stopping
219
+ rescue IOError, SystemCallError => error
220
+ raise TransportError.new("MCP input failed (#{error.class})", phase: :read), cause: nil
221
+ ensure
222
+ @input_done = true
223
+ @changed.signal
224
+ end
225
+
226
+ def receive(frame)
227
+ parsed = JSON.parse(frame, symbolize_names: true, max_nesting: 32)
228
+ unless parsed.is_a?(Hash)
229
+ send_response(@session.handle(nil))
230
+ return
231
+ end
232
+ if parsed[:jsonrpc] == "2.0" && !parsed.key?(:id) && parsed[:method] == ::MCP::Methods::NOTIFICATIONS_CANCELLED
233
+ @session.handle(parsed)
234
+ id = parsed[:params][:requestId] if parsed[:params].is_a?(Hash)
235
+ ticket = @by_id[id]
236
+ stop_ticket(ticket) if ticket && ticket.request[:method] != ::MCP::Methods::INITIALIZE
237
+ return
238
+ end
239
+ id = parsed[:id]
240
+ if id && @by_id.key?(id)
241
+ send_response(error_response(id, -32600, "Request ID is already in flight"))
242
+ return
243
+ end
244
+ if @tickets.length >= @max_requests || @request_bytes + frame.bytesize > @max_request
245
+ send_response(error_response(id, -32000, "MCP request capacity reached")) if id
246
+ return
247
+ end
248
+ ticket = Ticket.new(request: parsed, bytes: frame.bytesize, deadline: clock + @request_timeout, state: {cancelled: false})
249
+ ticket.task = ::Async::Task.new(@parent) { dispatch(ticket) }
250
+ @tickets << ticket
251
+ @waiting << ticket
252
+ @by_id[id] = ticket if id
253
+ @request_bytes += ticket.bytes
254
+ @by_task[ticket.task] = ticket
255
+ ticket.task.run
256
+ rescue JSON::ParserError
257
+ send_response(@session.handle_json("{"))
258
+ end
259
+
260
+ def dispatch(ticket)
261
+ task = ::Async::Task.current
262
+ task.with_timeout([ticket.deadline - clock, 0].max, RequestExpired) do
263
+ loop do
264
+ capacity = @session.era ? @concurrency : 1
265
+ break if @waiting.first.equal?(ticket) && @active < capacity
266
+
267
+ @changed.wait
268
+ end
269
+ @waiting.shift
270
+ @active += 1
271
+ ticket.active = true
272
+ @changed.signal
273
+ raise RequestExpired if clock >= ticket.deadline
274
+
275
+ response = request_session(ticket.request).handle(ticket.request)
276
+ raise RequestExpired if clock >= ticket.deadline
277
+ if !@session.era && response.is_a?(Hash) && !response.key?(:error) &&
278
+ (ticket.request[:method] == ::MCP::Methods::SERVER_DISCOVER || ::MCP::RequestEnvelope.modern?(ticket.request[:params]))
279
+ @session.lock_era!(:modern)
280
+ end
281
+ send_response(response) if response && !ticket.state[:cancelled]
282
+ end
283
+ rescue RequestExpired
284
+ ticket.state[:expired] = true
285
+ if ticket.request[:id] && !ticket.state[:cancelled] && !@stopping
286
+ send_response(error_response(ticket.request[:id], -32000, "MCP request deadline elapsed"))
287
+ end
288
+ rescue ::Async::Cancel
289
+ nil
290
+ rescue Exception => error
291
+ fail_transport(error)
292
+ ensure
293
+ ticket.cancellation&.off_cancel(ticket.callback)
294
+ release_ticket(ticket)
295
+ end
296
+
297
+ def release_ticket(ticket)
298
+ return unless @tickets.delete(ticket)
299
+
300
+ @active -= 1 if ticket.active
301
+ @waiting.delete(ticket)
302
+ @by_id.delete(ticket.request[:id]) if @by_id[ticket.request[:id]].equal?(ticket)
303
+ @by_task.delete(ticket.task)
304
+ @request_bytes -= ticket.bytes
305
+ @changed.signal
306
+ end
307
+
308
+ def request_session(request)
309
+ if @session.era == :modern || request[:method] == ::MCP::Methods::SERVER_DISCOVER || ::MCP::RequestEnvelope.modern?(request[:params])
310
+ ModernSession.new(server: @server, transport: self, connection: @session, era: @session.era)
311
+ else
312
+ @session
313
+ end
314
+ end
315
+
316
+ def bind_cancellation(id, token)
317
+ ticket = @by_id[id]
318
+ return unless ticket
319
+
320
+ ticket.cancellation = token
321
+ ticket.callback = token.on_cancel { stop_ticket(ticket) }
322
+ end
323
+
324
+ def stop_ticket(ticket, retry_cancel: false)
325
+ was_cancelled = ticket.state[:cancelled]
326
+ return if was_cancelled && !retry_cancel
327
+
328
+ ticket.state[:cancelled] = true
329
+ ticket.cancellation.cancel(reason: "Request stopped") if !was_cancelled && ticket.cancellation && !ticket.cancellation.cancelled?
330
+ ticket.task.cancel if ticket.task && !ticket.task.finished?
331
+ end
332
+
333
+ def error_response(id, code, message)
334
+ JsonRpcHandler.error_response(id: id, id_validation_pattern: JsonRpcHandler::DEFAULT_ALLOWED_ID_CHARACTERS,
335
+ error: {code: code, message: message})
336
+ end
337
+
338
+ def encode(message)
339
+ if message.is_a?(String)
340
+ raise CapacityError.new("MCP output frame limit reached", phase: :write) if message.bytesize >= @max_output
341
+
342
+ message = JSON.parse(message, max_nesting: 32)
343
+ end
344
+ measure(message)
345
+ bytes = JSON.generate(message).b
346
+ if bytes.bytesize + 1 > @max_output || bytes.include?("\n")
347
+ raise CapacityError.new("MCP output frame limit reached", phase: :write)
348
+ end
349
+ (bytes + "\n").freeze
350
+ rescue JSON::GeneratorError, JSON::ParserError, EncodingError
351
+ raise ProtocolError.new("MCP response cannot be encoded", phase: :write), cause: nil
352
+ end
353
+
354
+ def measure(message)
355
+ bytes, nodes = 1, 0 # Include the framing newline before allocating JSON.
356
+ spend = lambda do |amount|
357
+ bytes += amount
358
+ raise CapacityError.new("MCP output frame limit reached", phase: :write) if bytes > @max_output
359
+ end
360
+ string = lambda do |value|
361
+ unless value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
362
+ raise ProtocolError.new("MCP response text must be UTF-8", phase: :write)
363
+ end
364
+ spend.call(2)
365
+ value.each_byte do |byte|
366
+ spend.call(case byte
367
+ when 34, 92, 8, 9, 10, 12, 13 then 2
368
+ when 0...32 then 6
369
+ else 1
370
+ end)
371
+ end
372
+ end
373
+ visit = lambda do |value, depth|
374
+ nodes += 1
375
+ if depth > 32 || nodes > 65_536
376
+ raise CapacityError.new("MCP response structure limit reached", phase: :write)
377
+ end
378
+ case value
379
+ when String then string.call(value)
380
+ when Symbol then string.call(value.to_s)
381
+ when Integer then spend.call([1, value.bit_length].max + (value.negative? ? 1 : 0))
382
+ when nil, true then spend.call(4)
383
+ when false then spend.call(5)
384
+ when Float
385
+ raise ProtocolError.new("MCP response numbers must be finite", phase: :write) unless value.finite?
386
+ spend.call(32)
387
+ when Array
388
+ spend.call(2 + [0, value.length - 1].max)
389
+ value.each { |item| visit.call(item, depth + 1) }
390
+ when Hash
391
+ spend.call(2 + [0, value.length - 1].max + value.length)
392
+ value.each do |key, item|
393
+ unless key.is_a?(String) || key.is_a?(Symbol)
394
+ raise ProtocolError.new("MCP response keys must be text", phase: :write)
395
+ end
396
+ visit.call(key, depth + 1)
397
+ visit.call(item, depth + 1)
398
+ end
399
+ else raise ProtocolError.new("MCP response contains unsupported data", phase: :write)
400
+ end
401
+ end
402
+ visit.call(message, 0)
403
+ end
404
+
405
+ def write_loop
406
+ loop do
407
+ if @frames.empty?
408
+ break if @writer_done
409
+ @writable.wait
410
+ next
411
+ end
412
+ frame = @frames.shift
413
+ begin
414
+ next if frame.state && frame.state[:cancelled]
415
+
416
+ deadline = clock + @write_timeout
417
+ ::Async::Task.current.with_timeout(@write_timeout, WriteExpired) do
418
+ offset = 0
419
+ while offset < frame.bytes.bytesize
420
+ raise WriteExpired if clock >= deadline
421
+
422
+ written = @output.write_nonblock(frame.bytes.byteslice(offset, 16_384), exception: false)
423
+ if written == :wait_writable
424
+ @scheduler.io_wait(@output, IO::WRITABLE)
425
+ else
426
+ offset += written
427
+ end
428
+ end
429
+ end
430
+ ensure
431
+ @output_bytes -= frame.bytes.bytesize
432
+ end
433
+ end
434
+ rescue WriteExpired
435
+ raise DeadlineExceeded.new("MCP output consumer exceeded its deadline", phase: :write, delivery: :possibly_sent)
436
+ rescue IOError, SystemCallError => error
437
+ raise TransportError.new("MCP output failed (#{error.class})", phase: :write, delivery: :possibly_sent), cause: nil
438
+ end
439
+
440
+ def retire
441
+ deadline = clock + @cleanup_timeout
442
+ errors = []
443
+ @stopping = true
444
+ cleanup_action(deadline, errors) { @reader.cancel if @reader && !@reader.finished? }
445
+ @tickets.dup.each { |ticket| cleanup_action(deadline, errors) { stop_ticket(ticket, retry_cancel: true) } }
446
+ @tickets.dup.each do |ticket|
447
+ join_owned(ticket.task, deadline, errors)
448
+ release_ticket(ticket) if ticket.task.finished?
449
+ end
450
+ @writer_done = true
451
+ @writable.signal
452
+ join_owned(@reader, deadline, errors)
453
+ join_owned(@writer, deadline, errors)
454
+ @closed = [@reader, @writer].compact.all?(&:finished?) && @tickets.empty?
455
+ if @closed
456
+ @frames.clear
457
+ @output_bytes = 0
458
+ restore_transport
459
+ else
460
+ errors << "MCP owned task retirement remains pending"
461
+ end
462
+ unless errors.empty?
463
+ @failure ||= TransportError.new("MCP owned task did not retire", phase: :retire)
464
+ attach_cleanup(@failure, errors)
465
+ end
466
+ end
467
+
468
+ def attach_cleanup(error, details)
469
+ if error.is_a?(Error)
470
+ error.__send__(:attach_cleanup_errors, details)
471
+ else
472
+ error.extend(CleanupDetails)
473
+ error.instance_variable_set(:@mcp_cleanup_errors, ((error.mcp_cleanup_errors || []) + details).freeze)
474
+ end
475
+ rescue FrozenError, TypeError
476
+ nil
477
+ end
478
+
479
+ def cleanup_action(deadline, errors)
480
+ yield
481
+ rescue ::Async::Cancel => error
482
+ @failure ||= error
483
+ retry if clock < deadline
484
+ errors << "MCP cleanup cancellation remains pending"
485
+ rescue Exception => error
486
+ errors << "MCP cleanup failed (#{error.class})"
487
+ end
488
+
489
+ def join_owned(task, deadline, errors)
490
+ return if !task || task.finished?
491
+
492
+ cleanup_action(deadline, errors) { task.wait(timeout: [deadline - clock, 0].max) }
493
+ unless task.finished?
494
+ cleanup_action(deadline, errors) { task.cancel unless task.finished? }
495
+ cleanup_action(deadline, errors) { task.wait(timeout: [deadline - clock, 0].max) unless task.finished? }
496
+ end
497
+ end
498
+
499
+ def restore_transport
500
+ @server.transport = @previous_transport if @server.transport.equal?(self)
501
+ end
502
+ end
503
+ end
504
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ module MCP
5
+ VERSION = "0.1.0.alpha.1"
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux/async"
4
+ require "mcp"
5
+ require "libtmux/mcp/version"
6
+ require "libtmux/mcp/stdio_transport"
7
+ require "libtmux/mcp/application"
@@ -0,0 +1,26 @@
1
+ module LibTmux
2
+ module MCP
3
+ VERSION: String
4
+ interface _ShellInvitation
5
+ def reference: () -> LibTmux::EntityRef
6
+ def shell_arguments: () -> Array[String]
7
+ def expires_at: () -> Float
8
+ def inspect: () -> String
9
+ end
10
+ class CLI
11
+ private def self.new: (*untyped, **untyped) -> instance
12
+ def run: () -> Integer
13
+ def self.run: (Array[String], ?input: IO, ?out: untyped, ?err: untyped) -> Integer
14
+ end
15
+ class Application
16
+ attr_reader tools: Array[untyped]
17
+ def initialize: (server: LibTmux::Async::Server, endpoint_name: String, ?enabled_tools: Array[String], ?max_captures: Integer, ?max_capture_bytes: Integer, ?capture_ttl: Numeric, ?request_timeout: Numeric, ?max_response_bytes: Integer) -> void
18
+ def sdk_server: () -> untyped
19
+ def call: (String, ?Hash[untyped, untyped], ?cancellation: untyped) -> untyped
20
+ def invite_shell: (LibTmux::EntityRef, ?timeout: Numeric?, ?expires_in: Numeric, ?cancellation: untyped) -> _ShellInvitation
21
+ def accept_shell: (_ShellInvitation, ?timeout: Numeric?, ?cancellation: untyped) -> LibTmux::EntityRef
22
+ def close: () -> nil
23
+ def inspect: () -> String
24
+ end
25
+ end
26
+ end
data/sig/transport.rbs ADDED
@@ -0,0 +1,16 @@
1
+ module LibTmux
2
+ module MCP
3
+ class StdioTransport
4
+ def initialize: (server: untyped, parent: untyped, input: IO, output: IO,
5
+ ?concurrency: Integer, ?max_requests: Integer, ?max_frame_bytes: Integer,
6
+ ?max_request_bytes: Integer, ?max_output_bytes: Integer,
7
+ ?request_timeout: Numeric, ?write_timeout: Numeric, ?cleanup_timeout: Numeric) -> void
8
+ def run: () -> nil
9
+ def close: () -> nil
10
+ def closed?: () -> bool
11
+ def send_response: (untyped) -> nil
12
+ def send_notification: (String, ?untyped, ?related_request_id: untyped, **untyped) -> bool
13
+ def send_request: (*untyped) -> bot
14
+ end
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: libtmux-mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.alpha.1
5
+ platform: ruby
6
+ authors:
7
+ - libtmux contributors
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: libtmux
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0.alpha.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0.alpha.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: libtmux-async
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0.alpha.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0.alpha.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: mcp
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 1.5.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 1.5.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: digest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 3.2.1
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 3.2.1
68
+ - !ruby/object:Gem::Dependency
69
+ name: optparse
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0.4'
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '1'
78
+ type: :runtime
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0.4'
85
+ - - "<"
86
+ - !ruby/object:Gem::Version
87
+ version: '1'
88
+ description: Expose tmux snapshots, captures and explicitly enabled mutations through
89
+ an MCP stdio server.
90
+ executables:
91
+ - libtmux-mcp
92
+ extensions: []
93
+ extra_rdoc_files: []
94
+ files:
95
+ - LICENSE
96
+ - README.md
97
+ - exe/libtmux-mcp
98
+ - lib/libtmux/mcp.rb
99
+ - lib/libtmux/mcp/application.rb
100
+ - lib/libtmux/mcp/catalog.rb
101
+ - lib/libtmux/mcp/catalog_tool.rb
102
+ - lib/libtmux/mcp/cli.rb
103
+ - lib/libtmux/mcp/enrollment.rb
104
+ - lib/libtmux/mcp/mutations.rb
105
+ - lib/libtmux/mcp/observation.rb
106
+ - lib/libtmux/mcp/process_identity.rb
107
+ - lib/libtmux/mcp/resources.rb
108
+ - lib/libtmux/mcp/shell/integration.zsh
109
+ - lib/libtmux/mcp/shell/prepare.rb
110
+ - lib/libtmux/mcp/stdio_transport.rb
111
+ - lib/libtmux/mcp/version.rb
112
+ - sig/libtmux-mcp.rbs
113
+ - sig/transport.rbs
114
+ homepage: https://github.com/libtmux/libtmux-ruby
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ source_code_uri: https://github.com/libtmux/libtmux-ruby
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '3.3'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 4.0.20
134
+ specification_version: 4
135
+ summary: MCP consumer package for libtmux
136
+ test_files: []