cuboid 0.3.6 → 0.4

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,346 @@
1
+ require 'spec_helper'
2
+ require "#{Cuboid::Options.paths.lib}/mcp/server"
3
+
4
+ describe Cuboid::MCP::Server do
5
+ include Rack::Test::Methods
6
+
7
+ # Each test gets a fresh anonymous Application subclass so service /
8
+ # validator registrations don't leak between examples.
9
+ let(:fake_application) { Class.new(Cuboid::Application) }
10
+
11
+ before(:each) do
12
+ @prev_application = Cuboid::Application.application
13
+ Cuboid::Application.application = fake_application
14
+ end
15
+
16
+ after(:each) do
17
+ Cuboid::Application.application = @prev_application
18
+ # Reset the shared instances map so per-example fixtures don't
19
+ # bleed into the next example.
20
+ Cuboid::Server::InstanceHelpers.instances.clear
21
+ end
22
+
23
+ # Rack::Test's `app` hook — built per-test from current options/state.
24
+ # Default to `stateless: true` so tests can hit individual JSON-RPC
25
+ # methods without an explicit `initialize` handshake first.
26
+ def app
27
+ described_class.rack_app({ stateless: true }.merge(@app_options || {}))
28
+ end
29
+
30
+ def jsonrpc(method, params = {}, id: 1)
31
+ {
32
+ jsonrpc: '2.0',
33
+ id: id,
34
+ method: method,
35
+ params: params
36
+ }.to_json
37
+ end
38
+
39
+ def post_jsonrpc(path, method, params = {}, headers: {})
40
+ post path,
41
+ jsonrpc(method, params),
42
+ {
43
+ 'CONTENT_TYPE' => 'application/json',
44
+ 'HTTP_ACCEPT' => 'application/json, text/event-stream'
45
+ }.merge(headers)
46
+ end
47
+
48
+ INITIALIZE_PARAMS = {
49
+ protocolVersion: '2025-06-18',
50
+ capabilities: {},
51
+ clientInfo: { name: 'spec', version: '0' }
52
+ }.freeze
53
+
54
+ # Stub for an MCP-tool handler module/class — what an application
55
+ # gem supplies via `mcp_service_for`.
56
+ def build_handler_with_tools(*tools)
57
+ h = Module.new
58
+ h.define_singleton_method(:tools) { tools }
59
+ h
60
+ end
61
+
62
+ # Plant an entry in the shared instance map so the dispatcher can
63
+ # resolve `:instance` to something. The value is whatever an RPC
64
+ # client would normally be — for tests we just use a plain
65
+ # double / OpenStruct that the tools may interact with.
66
+ def stub_instance(id, instance_obj = Object.new)
67
+ Cuboid::Server::InstanceHelpers.instances[id] = instance_obj
68
+ instance_obj
69
+ end
70
+
71
+ context 'when the path is unrecognised' do
72
+ before do
73
+ fake_application.mcp_service_for(:my_service, build_handler_with_tools)
74
+ stub_instance('inst-1')
75
+ end
76
+
77
+ it '404s an unrelated path' do
78
+ post_jsonrpc '/random/path', 'initialize', INITIALIZE_PARAMS
79
+ last_response.status.should == 404
80
+ end
81
+ end
82
+
83
+ context 'core tools at /mcp (framework-level)' do
84
+ # Stub double for kill_instance: needs `.shutdown` and `.close`.
85
+ let(:killable) do
86
+ obj = Object.new
87
+ obj.define_singleton_method(:shutdown) { nil }
88
+ obj.define_singleton_method(:close) { nil }
89
+ obj
90
+ end
91
+
92
+ it 'serves an initialize handshake' do
93
+ post_jsonrpc '/mcp', 'initialize', INITIALIZE_PARAMS
94
+
95
+ last_response.status.should == 200
96
+ JSON.parse(last_response.body)['result']['protocolVersion']
97
+ .should == '2025-06-18'
98
+ end
99
+
100
+ it 'lists the framework tools (list/spawn/kill instance)' do
101
+ post_jsonrpc '/mcp', 'tools/list'
102
+
103
+ names = JSON.parse(last_response.body)['result']['tools'].map { |t| t['name'] }
104
+ names.sort.should == %w[kill_instance list_instances spawn_instance]
105
+ end
106
+
107
+ it 'list_instances returns currently-registered ids' do
108
+ stub_instance('inst-a')
109
+ stub_instance('inst-b')
110
+
111
+ post_jsonrpc '/mcp', 'tools/call', { name: 'list_instances', arguments: {} }
112
+
113
+ structured = JSON.parse(last_response.body)['result']['structuredContent']
114
+ structured['instances'].keys.sort.should == %w[inst-a inst-b]
115
+ end
116
+
117
+ it 'kill_instance removes the instance from the shared map' do
118
+ stub_instance('inst-x', killable)
119
+
120
+ post_jsonrpc '/mcp', 'tools/call',
121
+ { name: 'kill_instance', arguments: { instance_id: 'inst-x' } }
122
+
123
+ structured = JSON.parse(last_response.body)['result']['structuredContent']
124
+ structured['killed'].should == 'inst-x'
125
+ Cuboid::Server::InstanceHelpers.instances.key?('inst-x').should == false
126
+ end
127
+
128
+ it 'kill_instance returns an error response when the id is unknown' do
129
+ post_jsonrpc '/mcp', 'tools/call',
130
+ { name: 'kill_instance', arguments: { instance_id: 'nope' } }
131
+
132
+ body = JSON.parse(last_response.body)
133
+ content = body['result']['content'].first
134
+ body['result']['isError'].should == true
135
+ content['text'].should include('unknown instance')
136
+ end
137
+ end
138
+
139
+ context 'with a service registered' do
140
+ let(:tool_class) do
141
+ klass = Class.new(MCP::Tool)
142
+ klass.instance_eval do
143
+ tool_name 'echo'
144
+ description 'Returns "<instance_id>: <message>".'
145
+ input_schema(
146
+ properties: { message: { type: 'string' } },
147
+ required: ['message']
148
+ )
149
+
150
+ def self.call(message:, server_context:)
151
+ text = "#{server_context[:instance_id]}: #{message}"
152
+ MCP::Tool::Response.new([{ type: 'text', text: text }])
153
+ end
154
+ end
155
+ klass
156
+ end
157
+
158
+ let(:handler) { build_handler_with_tools(tool_class) }
159
+
160
+ before do
161
+ fake_application.mcp_service_for(:my_service, handler)
162
+ stub_instance('inst-1')
163
+ end
164
+
165
+ it 'lists the wrapped tool under its <service>_<tool> name with instance_id required' do
166
+ post_jsonrpc '/mcp', 'tools/list'
167
+
168
+ tools = JSON.parse(last_response.body)['result']['tools']
169
+ wrapped = tools.find { |t| t['name'] == 'my_service_echo' }
170
+ wrapped.should_not be_nil
171
+ wrapped['inputSchema']['required'].should include('instance_id')
172
+ wrapped['inputSchema']['properties'].keys.map(&:to_s)
173
+ .should include('instance_id', 'message')
174
+ end
175
+
176
+ it 'resolves the instance_id arg into server_context for the wrapped tool' do
177
+ post_jsonrpc '/mcp', 'tools/call',
178
+ { name: 'my_service_echo',
179
+ arguments: { instance_id: 'inst-1', message: 'hi' } }
180
+
181
+ content = JSON.parse(last_response.body)['result']['content']
182
+ content.first['text'].should == 'inst-1: hi'
183
+ end
184
+
185
+ it 'returns an MCP tool error (not a routing 404) when instance_id is unknown' do
186
+ post_jsonrpc '/mcp', 'tools/call',
187
+ { name: 'my_service_echo',
188
+ arguments: { instance_id: 'missing', message: 'hi' } }
189
+
190
+ body = JSON.parse(last_response.body)
191
+ body['result']['isError'].should == true
192
+ body['result']['content'].first['text'].should include('unknown instance')
193
+ end
194
+
195
+ it 'isolates state across instance_ids passed as arguments' do
196
+ stub_instance('inst-2')
197
+
198
+ post_jsonrpc '/mcp', 'tools/call',
199
+ { name: 'my_service_echo',
200
+ arguments: { instance_id: 'inst-1', message: 'hi' } }
201
+ JSON.parse(last_response.body)['result']['content']
202
+ .first['text'].should == 'inst-1: hi'
203
+
204
+ post_jsonrpc '/mcp', 'tools/call',
205
+ { name: 'my_service_echo',
206
+ arguments: { instance_id: 'inst-2', message: 'hi' } }
207
+ JSON.parse(last_response.body)['result']['content']
208
+ .first['text'].should == 'inst-2: hi'
209
+ end
210
+ end
211
+
212
+ context 'when an auth validator is registered' do
213
+ before do
214
+ fake_application.mcp_service_for(:my_service, build_handler_with_tools)
215
+ stub_instance('inst-1')
216
+ fake_application.mcp_authenticate_with do |token|
217
+ token == 'good' ? :ok : nil
218
+ end
219
+ end
220
+
221
+ it '401s a request with no Authorization header' do
222
+ post_jsonrpc '/mcp', 'initialize', INITIALIZE_PARAMS
223
+ last_response.status.should == 401
224
+ end
225
+
226
+ it 'allows requests with a valid bearer token' do
227
+ post_jsonrpc '/mcp', 'initialize', INITIALIZE_PARAMS,
228
+ headers: { 'HTTP_AUTHORIZATION' => 'Bearer good' }
229
+ last_response.status.should == 200
230
+ end
231
+ end
232
+
233
+ context 'with custom name / version' do
234
+ before do
235
+ fake_application.mcp_service_for(:my_service, build_handler_with_tools)
236
+ stub_instance('inst-1')
237
+ @app_options = { name: 'custom-mcp', version: '7.7.7' }
238
+ end
239
+
240
+ it 'advertises them in serverInfo' do
241
+ post_jsonrpc '/mcp', 'initialize', INITIALIZE_PARAMS
242
+
243
+ info = JSON.parse(last_response.body)['result']['serverInfo']
244
+ info['name'].should == 'custom-mcp'
245
+ info['version'].should == '7.7.7'
246
+ end
247
+ end
248
+
249
+ context 'when the application class lives under a branded top-level namespace' do
250
+ # Synthesize a real-looking namespace: a `shortname` method
251
+ # (the brand the user wants advertised) and a `version`
252
+ # method (preferred over the VERSION constant when both are
253
+ # present). The dispatcher should pick the branded methods
254
+ # over the raw module name.
255
+ before do
256
+ stub_const('BrandFake', Module.new)
257
+ BrandFake.define_singleton_method(:shortname) { :foo }
258
+ BrandFake.define_singleton_method(:version) { '9.9.9' }
259
+ BrandFake.const_set(
260
+ :Application,
261
+ Class.new(Cuboid::Application) { def self.name; 'BrandFake::Application'; end }
262
+ )
263
+
264
+ Cuboid::Application.application = BrandFake::Application
265
+ BrandFake::Application.mcp_service_for(:my_service, build_handler_with_tools)
266
+ stub_instance('inst-1')
267
+ end
268
+
269
+ it 'advertises the brand shortname + version at /mcp' do
270
+ post_jsonrpc '/mcp', 'initialize', INITIALIZE_PARAMS
271
+
272
+ info = JSON.parse(last_response.body)['result']['serverInfo']
273
+ info['name'].should == 'foo'
274
+ info['version'].should == '9.9.9'
275
+ end
276
+
277
+ end
278
+
279
+ context 'live route (`/mcp/live/<token>`)' do
280
+ # The route is loopback-only; Rack::Test sets REMOTE_ADDR to
281
+ # '127.0.0.1' by default which lets the loopback gate pass.
282
+
283
+ before(:each) do
284
+ # Reset Live registry between examples so tokens don't bleed.
285
+ Cuboid::MCP::Live.instance_variable_set(:@by_token, {})
286
+ Cuboid::MCP::Live.instance_variable_set(:@by_instance_id, {})
287
+ Cuboid::MCP::Live.transport = nil
288
+ end
289
+
290
+ it '410s when the token isn\'t registered' do
291
+ post '/mcp/live/no-such-token', '{}',
292
+ { 'CONTENT_TYPE' => 'application/json' }
293
+
294
+ last_response.status.should == 410
295
+ JSON.parse(last_response.body)['error']
296
+ .should include('live token unknown')
297
+ end
298
+
299
+ it 'rejects pushes from non-loopback with 404' do
300
+ post '/mcp/live/whatever', '{}',
301
+ { 'CONTENT_TYPE' => 'application/json',
302
+ 'REMOTE_ADDR' => '203.0.113.5' }
303
+
304
+ last_response.status.should == 404
305
+ JSON.parse(last_response.body)['error']['message']
306
+ .should include('loopback')
307
+ end
308
+
309
+ it '400s on an undecodable body for the declared content type' do
310
+ # Register a token so we get past the 410 path.
311
+ Cuboid::MCP::Live.instance_variable_get(:@by_token)['tok'] = {
312
+ session_id: 'sess-1', instance_id: 'inst-x'
313
+ }
314
+
315
+ post '/mcp/live/tok', "\xff\xff\xff not json".b,
316
+ { 'CONTENT_TYPE' => 'application/json' }
317
+
318
+ last_response.status.should == 400
319
+ JSON.parse(last_response.body)['error']
320
+ .should include('could not decode')
321
+ end
322
+ end
323
+
324
+ context 'when the namespace exposes only a VERSION constant (no branded methods)' do
325
+ before do
326
+ stub_const('PlainFake', Module.new)
327
+ PlainFake.const_set(:VERSION, '2.0.0')
328
+ PlainFake.const_set(
329
+ :Application,
330
+ Class.new(Cuboid::Application) { def self.name; 'PlainFake::Application'; end }
331
+ )
332
+
333
+ Cuboid::Application.application = PlainFake::Application
334
+ PlainFake::Application.mcp_service_for(:my_service, build_handler_with_tools)
335
+ stub_instance('inst-1')
336
+ end
337
+
338
+ it 'falls back to the namespace name + VERSION' do
339
+ post_jsonrpc '/mcp', 'initialize', INITIALIZE_PARAMS
340
+
341
+ info = JSON.parse(last_response.body)['result']['serverInfo']
342
+ info['name'].should == 'PlainFake'
343
+ info['version'].should == '2.0.0'
344
+ end
345
+ end
346
+ end
@@ -479,7 +479,7 @@ describe Cuboid::Rest::Server do
479
479
  end
480
480
  end
481
481
 
482
- context 'when the instance is from the Scheduler' do
482
+ context 'when the instance is from the Scheduler'do
483
483
  before do
484
484
  put '/scheduler/url', scheduler.url
485
485
  end
@@ -581,7 +581,6 @@ describe Cuboid::Rest::Server do
581
581
  delete url
582
582
 
583
583
  get "/instances/#{id}"
584
- ap response
585
584
  expect(response_code).to eq 404
586
585
  end
587
586
 
@@ -606,9 +605,9 @@ describe Cuboid::Rest::Server do
606
605
  delete url
607
606
  expect(response_code).to eq 200
608
607
 
609
- sleep 0.1 while scheduler.failed.empty?
608
+ sleep 1 while !scheduler.running.empty?
610
609
 
611
- expect(scheduler.failed).to include @id
610
+ expect(scheduler.completed).to include @id
612
611
  end
613
612
 
614
613
  context 'when the instance completes' do
@@ -7,9 +7,19 @@ shared_examples_for 'option_group' do
7
7
  it 'converts self to a serializable hash' do
8
8
  expect(data).to be_kind_of Hash
9
9
 
10
+ # MessagePack — the on-wire serializer behind
11
+ # `Cuboid::RPC::Serializer` — has no Symbol type, so any
12
+ # Symbol value in `data` round-trips as a String. Compare
13
+ # against the stringified-Symbol form rather than `data`
14
+ # itself; the round-trip equality the spec is asserting
15
+ # holds modulo that one well-known coercion.
16
+ expected = data.each_with_object({}) do |(k, v), h|
17
+ h[k] = v.is_a?(Symbol) ? v.to_s : v
18
+ end
19
+
10
20
  expect(Cuboid::RPC::Serializer.load(
11
21
  Cuboid::RPC::Serializer.dump( data )
12
- )).to eq(data)
22
+ )).to eq(expected)
13
23
  end
14
24
  end
15
25
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cuboid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: '0.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tasos Laskos
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-05 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: awesome_print
@@ -178,6 +178,20 @@ dependencies:
178
178
  - - ">="
179
179
  - !ruby/object:Gem::Version
180
180
  version: '4.0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: mcp
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0.15'
188
+ type: :runtime
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0.15'
181
195
  - !ruby/object:Gem::Dependency
182
196
  name: toq
183
197
  requirement: !ruby/object:Gem::Requirement
@@ -248,6 +262,10 @@ files:
248
262
  - lib/cuboid/data.rb
249
263
  - lib/cuboid/data/application.rb
250
264
  - lib/cuboid/error.rb
265
+ - lib/cuboid/mcp/auth.rb
266
+ - lib/cuboid/mcp/core_tools.rb
267
+ - lib/cuboid/mcp/live.rb
268
+ - lib/cuboid/mcp/server.rb
251
269
  - lib/cuboid/option_group.rb
252
270
  - lib/cuboid/option_groups.rb
253
271
  - lib/cuboid/option_groups/agent.rb
@@ -265,6 +283,7 @@ files:
265
283
  - lib/cuboid/processes/executables/agent.rb
266
284
  - lib/cuboid/processes/executables/base.rb
267
285
  - lib/cuboid/processes/executables/instance.rb
286
+ - lib/cuboid/processes/executables/mcp.rb
268
287
  - lib/cuboid/processes/executables/rest_service.rb
269
288
  - lib/cuboid/processes/executables/scheduler.rb
270
289
  - lib/cuboid/processes/helpers.rb
@@ -304,6 +323,7 @@ files:
304
323
  - lib/cuboid/ruby/array.rb
305
324
  - lib/cuboid/ruby/hash.rb
306
325
  - lib/cuboid/ruby/object.rb
326
+ - lib/cuboid/server/instance_helpers.rb
307
327
  - lib/cuboid/snapshot.rb
308
328
  - lib/cuboid/state.rb
309
329
  - lib/cuboid/state/application.rb
@@ -362,6 +382,8 @@ files:
362
382
  - spec/cuboid/data/application_spec.rb
363
383
  - spec/cuboid/data_spec.rb
364
384
  - spec/cuboid/error_spec.rb
385
+ - spec/cuboid/mcp/auth_spec.rb
386
+ - spec/cuboid/mcp/server_spec.rb
365
387
  - spec/cuboid/option_groups/agent_spec.rb
366
388
  - spec/cuboid/option_groups/datastore_spec.rb
367
389
  - spec/cuboid/option_groups/output_spec.rb
@@ -486,6 +508,8 @@ test_files:
486
508
  - spec/cuboid/data/application_spec.rb
487
509
  - spec/cuboid/data_spec.rb
488
510
  - spec/cuboid/error_spec.rb
511
+ - spec/cuboid/mcp/auth_spec.rb
512
+ - spec/cuboid/mcp/server_spec.rb
489
513
  - spec/cuboid/option_groups/agent_spec.rb
490
514
  - spec/cuboid/option_groups/datastore_spec.rb
491
515
  - spec/cuboid/option_groups/output_spec.rb