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.
@@ -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