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,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
|
data/lib/libtmux/mcp.rb
ADDED
data/sig/libtmux-mcp.rbs
ADDED
|
@@ -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: []
|