mockserver-client 7.1.0 → 7.2.0
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 +113 -1
- data/lib/mockserver/binary_launcher.rb +31 -1
- data/lib/mockserver/client.rb +311 -7
- data/lib/mockserver/forward_chain_expectation.rb +16 -6
- data/lib/mockserver/llm.rb +855 -0
- data/lib/mockserver/mcp.rb +453 -0
- data/lib/mockserver/models.rb +427 -11
- data/lib/mockserver/rspec.rb +56 -0
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver-client.rb +2 -0
- metadata +5 -2
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module MockServer
|
|
6
|
+
# Fluent builder for mocking an MCP (Model Context Protocol) server.
|
|
7
|
+
#
|
|
8
|
+
# Mirrors the Java/Node/Python +McpMockBuilder+. It produces the same
|
|
9
|
+
# wire-level expectation JSON: a set of HTTP expectations that emulate a
|
|
10
|
+
# Streamable-HTTP MCP server speaking JSON-RPC 2.0. Each generated
|
|
11
|
+
# expectation matches a JSON-RPC method on +POST <path>+ and responds with a
|
|
12
|
+
# Velocity template that echoes back the incoming JSON-RPC id via
|
|
13
|
+
# +$!{request.jsonRpcRawId}+.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# MockServer::MCP.mcp_mock('/mcp')
|
|
17
|
+
# .with_tool('get_weather')
|
|
18
|
+
# .with_description('Get weather for a city')
|
|
19
|
+
# .with_input_schema('{"type":"object"}')
|
|
20
|
+
# .responding_with('72F and sunny')
|
|
21
|
+
# .and_then
|
|
22
|
+
# .apply_to(client)
|
|
23
|
+
module MCP
|
|
24
|
+
# JSON-escape a string the same way Jackson +writeValueAsString+ does, then
|
|
25
|
+
# strip the surrounding quotes — yielding only the escaped inner content.
|
|
26
|
+
# @api private
|
|
27
|
+
def self.escape_json(value)
|
|
28
|
+
return '' if value.nil?
|
|
29
|
+
|
|
30
|
+
quoted = JSON.generate(value.to_s)
|
|
31
|
+
quoted[1...-1]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Escape Velocity metacharacters so literal +$+ / +#+ survive rendering.
|
|
35
|
+
# @api private
|
|
36
|
+
def self.escape_velocity(value)
|
|
37
|
+
return value if value.nil?
|
|
38
|
+
|
|
39
|
+
value.to_s.gsub('$', '${esc.d}').gsub('#', '${esc.h}')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Escape single quotes for safe inclusion inside a JSONPath string literal.
|
|
43
|
+
# @api private
|
|
44
|
+
def self.escape_json_path(value)
|
|
45
|
+
return '' if value.nil?
|
|
46
|
+
|
|
47
|
+
value.to_s.gsub("'", "\\\\'")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse + re-serialize JSON (compact) to validate it. Raises on invalid JSON.
|
|
51
|
+
# @api private
|
|
52
|
+
def self.validate_and_serialize_json(raw)
|
|
53
|
+
JSON.generate(JSON.parse(raw))
|
|
54
|
+
rescue JSON::ParserError => e
|
|
55
|
+
raise ArgumentError, "Invalid JSON for inputSchema: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
def self.velocity_json_rpc_response(result_json)
|
|
60
|
+
'{"statusCode": 200, ' \
|
|
61
|
+
'"headers": [{"name": "Content-Type", "values": ["application/json"]}], ' \
|
|
62
|
+
'"body": {"jsonrpc": "2.0", "result": ' + result_json + ', "id": $!{request.jsonRpcRawId}}}'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
# Wraps a Hash so it responds to +to_h+, allowing it to be passed to
|
|
67
|
+
# +Client#upsert+.
|
|
68
|
+
class RawExpectation
|
|
69
|
+
def initialize(hash)
|
|
70
|
+
@hash = hash
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_h
|
|
74
|
+
@hash
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @api private
|
|
79
|
+
ToolDef = Struct.new(:name, :description, :input_schema, :response_content, :response_is_error)
|
|
80
|
+
# @api private
|
|
81
|
+
ResourceDef = Struct.new(:uri, :name, :description, :mime_type, :content)
|
|
82
|
+
# @api private
|
|
83
|
+
PromptArg = Struct.new(:name, :description, :required)
|
|
84
|
+
# @api private
|
|
85
|
+
PromptMessage = Struct.new(:role, :text)
|
|
86
|
+
# @api private
|
|
87
|
+
PromptDef = Struct.new(:name, :description, :arguments, :messages)
|
|
88
|
+
|
|
89
|
+
class McpMockBuilder
|
|
90
|
+
def initialize(path = '/mcp')
|
|
91
|
+
@path = path.is_a?(String) ? path : '/mcp'
|
|
92
|
+
@server_name = 'MockMCPServer'
|
|
93
|
+
@server_version = '1.0.0'
|
|
94
|
+
@protocol_version = '2025-03-26'
|
|
95
|
+
@tools_capability = false
|
|
96
|
+
@resources_capability = false
|
|
97
|
+
@prompts_capability = false
|
|
98
|
+
@tools = []
|
|
99
|
+
@resources = []
|
|
100
|
+
@prompts = []
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [self]
|
|
104
|
+
def with_server_name(name)
|
|
105
|
+
@server_name = name
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [self]
|
|
110
|
+
def with_server_version(version)
|
|
111
|
+
@server_version = version
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [self]
|
|
116
|
+
def with_protocol_version(version)
|
|
117
|
+
@protocol_version = version
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [self]
|
|
122
|
+
def with_tools_capability
|
|
123
|
+
@tools_capability = true
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [self]
|
|
128
|
+
def with_resources_capability
|
|
129
|
+
@resources_capability = true
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [self]
|
|
134
|
+
def with_prompts_capability
|
|
135
|
+
@prompts_capability = true
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @return [McpToolBuilder]
|
|
140
|
+
def with_tool(name)
|
|
141
|
+
McpToolBuilder.new(self, name)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [McpResourceBuilder]
|
|
145
|
+
def with_resource(uri)
|
|
146
|
+
McpResourceBuilder.new(self, uri)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [McpPromptBuilder]
|
|
150
|
+
def with_prompt(name)
|
|
151
|
+
McpPromptBuilder.new(self, name)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @api private
|
|
155
|
+
def add_tool(tool)
|
|
156
|
+
@tools << tool
|
|
157
|
+
@tools_capability = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @api private
|
|
161
|
+
def add_resource(resource)
|
|
162
|
+
@resources << resource
|
|
163
|
+
@resources_capability = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @api private
|
|
167
|
+
def add_prompt(prompt)
|
|
168
|
+
@prompts << prompt
|
|
169
|
+
@prompts_capability = true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @return [Array<Hash>] the ordered list of expectations
|
|
173
|
+
def build
|
|
174
|
+
expectations = [
|
|
175
|
+
build_initialize_expectation,
|
|
176
|
+
build_ping_expectation,
|
|
177
|
+
build_notifications_initialized_expectation
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
if @tools_capability || !@tools.empty?
|
|
181
|
+
expectations << build_tools_list_expectation
|
|
182
|
+
end
|
|
183
|
+
@tools.each { |tool| expectations << build_tools_call_expectation(tool) }
|
|
184
|
+
|
|
185
|
+
if @resources_capability || !@resources.empty?
|
|
186
|
+
expectations << build_resources_list_expectation
|
|
187
|
+
end
|
|
188
|
+
@resources.each { |resource| expectations << build_resources_read_expectation(resource) }
|
|
189
|
+
|
|
190
|
+
if @prompts_capability || !@prompts.empty?
|
|
191
|
+
expectations << build_prompts_list_expectation
|
|
192
|
+
end
|
|
193
|
+
@prompts.each { |prompt| expectations << build_prompts_get_expectation(prompt) }
|
|
194
|
+
|
|
195
|
+
expectations
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @return [Array<Expectation>]
|
|
199
|
+
def apply_to(client)
|
|
200
|
+
client.upsert(*build.map { |h| RawExpectation.new(h) })
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def json_rpc_request(method)
|
|
206
|
+
{ 'method' => 'POST', 'path' => @path, 'body' => { 'type' => 'JSON_RPC', 'method' => method } }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def json_path_request(json_path)
|
|
210
|
+
{ 'method' => 'POST', 'path' => @path, 'body' => { 'type' => 'JSON_PATH', 'jsonPath' => json_path } }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def velocity_template_response_expectation(http_request, result_json)
|
|
214
|
+
{
|
|
215
|
+
'httpRequest' => http_request,
|
|
216
|
+
'httpResponseTemplate' => {
|
|
217
|
+
'template' => MCP.velocity_json_rpc_response(result_json),
|
|
218
|
+
'templateType' => 'VELOCITY'
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_initialize_expectation
|
|
224
|
+
caps_parts = []
|
|
225
|
+
if @tools_capability || !@tools.empty?
|
|
226
|
+
caps_parts << '"tools": {"listChanged": false}'
|
|
227
|
+
end
|
|
228
|
+
if @resources_capability || !@resources.empty?
|
|
229
|
+
caps_parts << '"resources": {"subscribe": false, "listChanged": false}'
|
|
230
|
+
end
|
|
231
|
+
if @prompts_capability || !@prompts.empty?
|
|
232
|
+
caps_parts << '"prompts": {"listChanged": false}'
|
|
233
|
+
end
|
|
234
|
+
caps = '{' + caps_parts.join(', ') + '}'
|
|
235
|
+
|
|
236
|
+
result_json =
|
|
237
|
+
'{"protocolVersion": "' + esc(@protocol_version) + '", ' \
|
|
238
|
+
'"capabilities": ' + caps + ', ' \
|
|
239
|
+
'"serverInfo": {"name": "' + esc(@server_name) + '", "version": "' + esc(@server_version) + '"}}'
|
|
240
|
+
|
|
241
|
+
velocity_template_response_expectation(json_rpc_request('initialize'), result_json)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def build_ping_expectation
|
|
245
|
+
velocity_template_response_expectation(json_rpc_request('ping'), '{}')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_notifications_initialized_expectation
|
|
249
|
+
{
|
|
250
|
+
'httpRequest' => json_rpc_request('notifications/initialized'),
|
|
251
|
+
'httpResponse' => {
|
|
252
|
+
'statusCode' => 200,
|
|
253
|
+
'headers' => [{ 'name' => 'Content-Type', 'values' => ['application/json'] }],
|
|
254
|
+
'body' => '{}'
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def build_tools_list_expectation
|
|
260
|
+
items = @tools.map do |tool|
|
|
261
|
+
parts = ['{"name": "' + esc(tool.name) + '"']
|
|
262
|
+
parts << ', "description": "' + esc(tool.description) + '"' unless tool.description.nil?
|
|
263
|
+
unless tool.input_schema.nil?
|
|
264
|
+
parts << ', "inputSchema": ' + MCP.escape_velocity(MCP.validate_and_serialize_json(tool.input_schema))
|
|
265
|
+
end
|
|
266
|
+
parts << '}'
|
|
267
|
+
parts.join
|
|
268
|
+
end
|
|
269
|
+
tools_json = '[' + items.join(', ') + ']'
|
|
270
|
+
velocity_template_response_expectation(json_rpc_request('tools/list'), '{"tools": ' + tools_json + '}')
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def build_tools_call_expectation(tool)
|
|
274
|
+
json_path = "$[?(@.method == 'tools/call' && @.params.name == '" + MCP.escape_json_path(tool.name) + "')]"
|
|
275
|
+
content = tool.response_content.nil? ? '' : esc(tool.response_content)
|
|
276
|
+
is_error = tool.response_is_error ? 'true' : 'false'
|
|
277
|
+
result_json = '{"content": [{"type": "text", "text": "' + content + '"}], "isError": ' + is_error + '}'
|
|
278
|
+
velocity_template_response_expectation(json_path_request(json_path), result_json)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def build_resources_list_expectation
|
|
282
|
+
items = @resources.map do |resource|
|
|
283
|
+
parts = ['{"uri": "' + esc(resource.uri) + '"']
|
|
284
|
+
parts << ', "name": "' + esc(resource.name) + '"' unless resource.name.nil?
|
|
285
|
+
parts << ', "description": "' + esc(resource.description) + '"' unless resource.description.nil?
|
|
286
|
+
parts << ', "mimeType": "' + esc(resource.mime_type) + '"' unless resource.mime_type.nil?
|
|
287
|
+
parts << '}'
|
|
288
|
+
parts.join
|
|
289
|
+
end
|
|
290
|
+
resources_json = '[' + items.join(', ') + ']'
|
|
291
|
+
velocity_template_response_expectation(json_rpc_request('resources/list'), '{"resources": ' + resources_json + '}')
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def build_resources_read_expectation(resource)
|
|
295
|
+
json_path = "$[?(@.method == 'resources/read' && @.params.uri == '" + MCP.escape_json_path(resource.uri) + "')]"
|
|
296
|
+
content = resource.content.nil? ? '' : esc(resource.content)
|
|
297
|
+
mime_type = resource.mime_type.nil? ? 'application/json' : resource.mime_type
|
|
298
|
+
result_json =
|
|
299
|
+
'{"contents": [{"uri": "' + esc(resource.uri) + '", ' \
|
|
300
|
+
'"mimeType": "' + esc(mime_type) + '", ' \
|
|
301
|
+
'"text": "' + content + '"}]}'
|
|
302
|
+
velocity_template_response_expectation(json_path_request(json_path), result_json)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def build_prompts_list_expectation
|
|
306
|
+
items = @prompts.map do |prompt|
|
|
307
|
+
parts = ['{"name": "' + esc(prompt.name) + '"']
|
|
308
|
+
parts << ', "description": "' + esc(prompt.description) + '"' unless prompt.description.nil?
|
|
309
|
+
unless prompt.arguments.empty?
|
|
310
|
+
arg_items = prompt.arguments.map do |arg|
|
|
311
|
+
arg_parts = ['{"name": "' + esc(arg.name) + '"']
|
|
312
|
+
arg_parts << ', "description": "' + esc(arg.description) + '"' unless arg.description.nil?
|
|
313
|
+
arg_parts << ', "required": ' + (arg.required ? 'true' : 'false')
|
|
314
|
+
arg_parts << '}'
|
|
315
|
+
arg_parts.join
|
|
316
|
+
end
|
|
317
|
+
parts << ', "arguments": [' + arg_items.join(', ') + ']'
|
|
318
|
+
end
|
|
319
|
+
parts << '}'
|
|
320
|
+
parts.join
|
|
321
|
+
end
|
|
322
|
+
prompts_json = '[' + items.join(', ') + ']'
|
|
323
|
+
velocity_template_response_expectation(json_rpc_request('prompts/list'), '{"prompts": ' + prompts_json + '}')
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def build_prompts_get_expectation(prompt)
|
|
327
|
+
json_path = "$[?(@.method == 'prompts/get' && @.params.name == '" + MCP.escape_json_path(prompt.name) + "')]"
|
|
328
|
+
msg_items = prompt.messages.map do |msg|
|
|
329
|
+
'{"role": "' + esc(msg.role) + '", ' \
|
|
330
|
+
'"content": {"type": "text", "text": "' + esc(msg.text) + '"}}'
|
|
331
|
+
end
|
|
332
|
+
messages_json = '[' + msg_items.join(', ') + ']'
|
|
333
|
+
result_json = '{"messages": ' + messages_json + '}'
|
|
334
|
+
velocity_template_response_expectation(json_path_request(json_path), result_json)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Compose JSON-escape then Velocity-escape, matching the reference clients.
|
|
338
|
+
def esc(value)
|
|
339
|
+
MCP.escape_velocity(MCP.escape_json(value))
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
class McpToolBuilder
|
|
344
|
+
def initialize(parent, name)
|
|
345
|
+
@parent = parent
|
|
346
|
+
@tool = ToolDef.new(name, nil, nil, nil, false)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @return [self]
|
|
350
|
+
def with_description(description)
|
|
351
|
+
@tool.description = description
|
|
352
|
+
self
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# @return [self]
|
|
356
|
+
def with_input_schema(json_schema)
|
|
357
|
+
@tool.input_schema = json_schema
|
|
358
|
+
self
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @return [self]
|
|
362
|
+
def responding_with(text_content, is_error = false)
|
|
363
|
+
@tool.response_content = text_content
|
|
364
|
+
@tool.response_is_error = is_error
|
|
365
|
+
self
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Commit the tool and return to the root builder.
|
|
369
|
+
# @return [McpMockBuilder]
|
|
370
|
+
def and_then
|
|
371
|
+
@parent.add_tool(@tool)
|
|
372
|
+
@parent
|
|
373
|
+
end
|
|
374
|
+
alias and_ and_then
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
class McpResourceBuilder
|
|
378
|
+
def initialize(parent, uri)
|
|
379
|
+
@parent = parent
|
|
380
|
+
@resource = ResourceDef.new(uri, nil, nil, 'application/json', nil)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# @return [self]
|
|
384
|
+
def with_name(name)
|
|
385
|
+
@resource.name = name
|
|
386
|
+
self
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# @return [self]
|
|
390
|
+
def with_description(description)
|
|
391
|
+
@resource.description = description
|
|
392
|
+
self
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# @return [self]
|
|
396
|
+
def with_mime_type(mime_type)
|
|
397
|
+
@resource.mime_type = mime_type
|
|
398
|
+
self
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# @return [self]
|
|
402
|
+
def with_content(content)
|
|
403
|
+
@resource.content = content
|
|
404
|
+
self
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# @return [McpMockBuilder]
|
|
408
|
+
def and_then
|
|
409
|
+
@parent.add_resource(@resource)
|
|
410
|
+
@parent
|
|
411
|
+
end
|
|
412
|
+
alias and_ and_then
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
class McpPromptBuilder
|
|
416
|
+
def initialize(parent, name)
|
|
417
|
+
@parent = parent
|
|
418
|
+
@prompt = PromptDef.new(name, nil, [], [])
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# @return [self]
|
|
422
|
+
def with_description(description)
|
|
423
|
+
@prompt.description = description
|
|
424
|
+
self
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# @return [self]
|
|
428
|
+
def with_argument(name, description, required)
|
|
429
|
+
@prompt.arguments << PromptArg.new(name, description, required)
|
|
430
|
+
self
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# @return [self]
|
|
434
|
+
def responding_with(role, text_content)
|
|
435
|
+
@prompt.messages << PromptMessage.new(role, text_content)
|
|
436
|
+
self
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# @return [McpMockBuilder]
|
|
440
|
+
def and_then
|
|
441
|
+
@parent.add_prompt(@prompt)
|
|
442
|
+
@parent
|
|
443
|
+
end
|
|
444
|
+
alias and_ and_then
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Create a new MCP mock builder. +path+ defaults to +/mcp+.
|
|
448
|
+
# @return [McpMockBuilder]
|
|
449
|
+
def self.mcp_mock(path = '/mcp')
|
|
450
|
+
McpMockBuilder.new(path)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|