cuboid 0.3.6 → 0.5
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 +4 -4
- data/README.md +195 -0
- data/cuboid.gemspec +4 -0
- data/lib/cuboid/application.rb +84 -3
- data/lib/cuboid/mcp/auth.rb +99 -0
- data/lib/cuboid/mcp/core_tools.rb +318 -0
- data/lib/cuboid/mcp/live.rb +166 -0
- data/lib/cuboid/mcp/server.rb +426 -0
- data/lib/cuboid/option_groups/paths.rb +40 -0
- data/lib/cuboid/processes/executables/base.rb +37 -0
- data/lib/cuboid/processes/executables/mcp.rb +20 -0
- data/lib/cuboid/processes/instances.rb +9 -1
- data/lib/cuboid/processes/manager.rb +22 -1
- data/lib/cuboid/rest/server/instance_helpers.rb +13 -79
- data/lib/cuboid/rest/server/routes/instances.rb +8 -13
- data/lib/cuboid/rest/server.rb +1 -1
- data/lib/cuboid/rpc/server/agent.rb +6 -1
- data/lib/cuboid/rpc/server/instance.rb +86 -66
- data/lib/cuboid/server/instance_helpers.rb +131 -0
- data/lib/version +1 -1
- data/spec/cuboid/mcp/auth_spec.rb +179 -0
- data/spec/cuboid/mcp/server_spec.rb +346 -0
- data/spec/cuboid/rest/server_spec.rb +3 -4
- data/spec/support/shared/option_group.rb +11 -1
- metadata +26 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require "#{Cuboid::Options.paths.lib}/mcp/auth"
|
|
3
|
+
|
|
4
|
+
describe Cuboid::MCP::Auth do
|
|
5
|
+
# Inner app: any time the middleware passes a request through, the
|
|
6
|
+
# inner app records the env it saw and replies 200 OK. Lets us
|
|
7
|
+
# check that env['cuboid.mcp.auth'] is populated AND that
|
|
8
|
+
# short-circuited (401) requests never reach it.
|
|
9
|
+
let(:inner_app) do
|
|
10
|
+
seen = []
|
|
11
|
+
app = ->(env) {
|
|
12
|
+
seen << env
|
|
13
|
+
[200, { 'content-type' => 'text/plain' }, ['ok']]
|
|
14
|
+
}
|
|
15
|
+
# Expose `seen` for assertions.
|
|
16
|
+
app.singleton_class.send(:define_method, :seen_envs) { seen }
|
|
17
|
+
app
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:middleware) { described_class.new(inner_app) }
|
|
21
|
+
|
|
22
|
+
# Each test installs a fresh anonymous Application subclass so we
|
|
23
|
+
# don't leak validators across examples.
|
|
24
|
+
let(:fake_application) { Class.new(Cuboid::Application) }
|
|
25
|
+
|
|
26
|
+
before(:each) do
|
|
27
|
+
@prev_application = Cuboid::Application.application
|
|
28
|
+
Cuboid::Application.application = fake_application
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
after(:each) do
|
|
32
|
+
Cuboid::Application.application = @prev_application
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def env(headers = {})
|
|
36
|
+
# Minimum env Rack expects; HTTP_AUTHORIZATION is the only
|
|
37
|
+
# header the middleware reads.
|
|
38
|
+
{
|
|
39
|
+
'REQUEST_METHOD' => 'POST',
|
|
40
|
+
'PATH_INFO' => '/mcp',
|
|
41
|
+
'rack.input' => StringIO.new('{}'),
|
|
42
|
+
'rack.errors' => StringIO.new
|
|
43
|
+
}.merge(headers)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
context 'when no validator is registered' do
|
|
47
|
+
it 'passes the request through unchanged' do
|
|
48
|
+
status, _, _ = middleware.call(env)
|
|
49
|
+
status.should == 200
|
|
50
|
+
inner_app.seen_envs.size.should == 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'does not populate cuboid.mcp.auth' do
|
|
54
|
+
middleware.call(env)
|
|
55
|
+
inner_app.seen_envs.first['cuboid.mcp.auth'].should be_nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context 'when a validator is registered' do
|
|
60
|
+
before do
|
|
61
|
+
fake_application.mcp_authenticate_with do |token|
|
|
62
|
+
token == 'good-token' ? { user: 'alice' } : nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'and the Authorization header is missing' do
|
|
67
|
+
it 'responds 401 with invalid_request' do
|
|
68
|
+
status, headers, body = middleware.call(env)
|
|
69
|
+
|
|
70
|
+
status.should == 401
|
|
71
|
+
headers['www-authenticate']
|
|
72
|
+
.should == 'Bearer realm="MCP", error="invalid_request"'
|
|
73
|
+
|
|
74
|
+
JSON.parse(body.first)['error']['message'].should == 'invalid_request'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'never reaches the inner app' do
|
|
78
|
+
middleware.call(env)
|
|
79
|
+
inner_app.seen_envs.should be_empty
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
context 'and the Authorization header is not a Bearer scheme' do
|
|
84
|
+
it 'responds 401 with invalid_request' do
|
|
85
|
+
status, _, _ = middleware.call(
|
|
86
|
+
env('HTTP_AUTHORIZATION' => 'Basic dXNlcjpwYXNz')
|
|
87
|
+
)
|
|
88
|
+
status.should == 401
|
|
89
|
+
inner_app.seen_envs.should be_empty
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context 'and the Bearer token is wrong' do
|
|
94
|
+
it 'responds 401 with invalid_token' do
|
|
95
|
+
status, headers, _ = middleware.call(
|
|
96
|
+
env('HTTP_AUTHORIZATION' => 'Bearer not-the-token')
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
status.should == 401
|
|
100
|
+
headers['www-authenticate']
|
|
101
|
+
.should == 'Bearer realm="MCP", error="invalid_token"'
|
|
102
|
+
|
|
103
|
+
inner_app.seen_envs.should be_empty
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
context 'and the Bearer token is correct' do
|
|
108
|
+
it 'passes the request through' do
|
|
109
|
+
status, _, _ = middleware.call(
|
|
110
|
+
env('HTTP_AUTHORIZATION' => 'Bearer good-token')
|
|
111
|
+
)
|
|
112
|
+
status.should == 200
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "stashes the validator's return value in env['cuboid.mcp.auth']" do
|
|
116
|
+
middleware.call(
|
|
117
|
+
env('HTTP_AUTHORIZATION' => 'Bearer good-token')
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
inner_app.seen_envs.first['cuboid.mcp.auth']
|
|
121
|
+
.should == { user: 'alice' }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'is case-insensitive on the Bearer keyword' do
|
|
125
|
+
status, _, _ = middleware.call(
|
|
126
|
+
env('HTTP_AUTHORIZATION' => 'bearer good-token')
|
|
127
|
+
)
|
|
128
|
+
status.should == 200
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'tolerates extra whitespace between Bearer and the token' do
|
|
132
|
+
status, _, _ = middleware.call(
|
|
133
|
+
env('HTTP_AUTHORIZATION' => "Bearer good-token")
|
|
134
|
+
)
|
|
135
|
+
status.should == 200
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
context 'and the validator raises' do
|
|
140
|
+
before do
|
|
141
|
+
fake_application.mcp_authenticate_with do |_token|
|
|
142
|
+
raise 'database is down'
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'responds 401 (not 500) so internals never leak' do
|
|
147
|
+
status, headers, _ = middleware.call(
|
|
148
|
+
env('HTTP_AUTHORIZATION' => 'Bearer whatever')
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
status.should == 401
|
|
152
|
+
headers['www-authenticate']
|
|
153
|
+
.should == 'Bearer realm="MCP", error="invalid_token"'
|
|
154
|
+
|
|
155
|
+
inner_app.seen_envs.should be_empty
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
context 'when the validator is replaced after the middleware was instantiated' do
|
|
161
|
+
# Important property: the middleware reads the validator at
|
|
162
|
+
# request time, not at construction time, so applications can
|
|
163
|
+
# swap implementations during a long-running process.
|
|
164
|
+
it 'picks up the new validator on the next request' do
|
|
165
|
+
mw = middleware
|
|
166
|
+
|
|
167
|
+
status, _, _ = mw.call(env('HTTP_AUTHORIZATION' => 'Bearer x'))
|
|
168
|
+
status.should == 200 # no validator yet → pass-through
|
|
169
|
+
|
|
170
|
+
fake_application.mcp_authenticate_with { |t| t == 'x' }
|
|
171
|
+
|
|
172
|
+
status, _, _ = mw.call(env('HTTP_AUTHORIZATION' => 'Bearer x'))
|
|
173
|
+
status.should == 200
|
|
174
|
+
|
|
175
|
+
status, _, _ = mw.call(env('HTTP_AUTHORIZATION' => 'Bearer y'))
|
|
176
|
+
status.should == 401
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -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'
|
|
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
|
|
608
|
+
sleep 1 while !scheduler.running.empty?
|
|
610
609
|
|
|
611
|
-
expect(scheduler.
|
|
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(
|
|
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.
|
|
4
|
+
version: '0.5'
|
|
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-
|
|
11
|
+
date: 2026-05-09 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
|