model-context-protocol-rb 0.6.0 → 0.7.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/CHANGELOG.md +26 -2
- data/README.md +174 -978
- data/lib/model_context_protocol/rspec/helpers.rb +54 -0
- data/lib/model_context_protocol/rspec/matchers/be_mcp_error_response.rb +123 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_class.rb +103 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_prompt_response.rb +126 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_resource_response.rb +121 -0
- data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_tool_response.rb +135 -0
- data/lib/model_context_protocol/rspec/matchers/have_audio_content.rb +109 -0
- data/lib/model_context_protocol/rspec/matchers/have_embedded_resource_content.rb +150 -0
- data/lib/model_context_protocol/rspec/matchers/have_image_content.rb +109 -0
- data/lib/model_context_protocol/rspec/matchers/have_message_count.rb +87 -0
- data/lib/model_context_protocol/rspec/matchers/have_message_with_role.rb +152 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_annotations.rb +135 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_blob.rb +108 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_link_content.rb +138 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_mime_type.rb +103 -0
- data/lib/model_context_protocol/rspec/matchers/have_resource_text.rb +112 -0
- data/lib/model_context_protocol/rspec/matchers/have_structured_content.rb +88 -0
- data/lib/model_context_protocol/rspec/matchers/have_text_content.rb +113 -0
- data/lib/model_context_protocol/rspec/matchers.rb +31 -0
- data/lib/model_context_protocol/rspec.rb +23 -0
- data/lib/model_context_protocol/server/client_logger.rb +1 -1
- data/lib/model_context_protocol/server/configuration.rb +195 -91
- data/lib/model_context_protocol/server/content_helpers.rb +1 -1
- data/lib/model_context_protocol/server/prompt.rb +0 -14
- data/lib/model_context_protocol/server/redis_client_proxy.rb +2 -14
- data/lib/model_context_protocol/server/redis_config.rb +5 -7
- data/lib/model_context_protocol/server/redis_pool_manager.rb +10 -13
- data/lib/model_context_protocol/server/registry.rb +8 -0
- data/lib/model_context_protocol/server/router.rb +279 -4
- data/lib/model_context_protocol/server/server_logger.rb +5 -2
- data/lib/model_context_protocol/server/stdio_configuration.rb +114 -0
- data/lib/model_context_protocol/server/stdio_transport/request_store.rb +0 -41
- data/lib/model_context_protocol/server/streamable_http_configuration.rb +218 -0
- data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +0 -13
- data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +0 -41
- data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +0 -103
- data/lib/model_context_protocol/server/streamable_http_transport/server_request_store.rb +0 -64
- data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +0 -58
- data/lib/model_context_protocol/server/streamable_http_transport/session_store.rb +17 -31
- data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +0 -34
- data/lib/model_context_protocol/server/streamable_http_transport.rb +192 -56
- data/lib/model_context_protocol/server/tool.rb +67 -1
- data/lib/model_context_protocol/server.rb +203 -262
- data/lib/model_context_protocol/version.rb +1 -1
- data/lib/model_context_protocol.rb +4 -1
- data/lib/puma/plugin/mcp.rb +39 -0
- data/tasks/mcp.rake +26 -0
- data/tasks/templates/dev-http-puma.erb +251 -0
- data/tasks/templates/dev-http.erb +166 -184
- data/tasks/templates/dev.erb +29 -7
- metadata +26 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelContextProtocol
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher that validates a tool response contains audio content.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(response).to have_audio_content
|
|
10
|
+
#
|
|
11
|
+
# @example With mime type constraint
|
|
12
|
+
# expect(response).to have_audio_content(mime_type: "audio/mp3")
|
|
13
|
+
#
|
|
14
|
+
def have_audio_content(mime_type: nil)
|
|
15
|
+
HaveAudioContent.new(mime_type: mime_type)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class HaveAudioContent
|
|
19
|
+
def initialize(mime_type: nil)
|
|
20
|
+
@expected_mime_type = mime_type
|
|
21
|
+
@failure_reasons = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def matches?(actual)
|
|
25
|
+
@actual = actual
|
|
26
|
+
@failure_reasons = []
|
|
27
|
+
@serialized = serialize_response(actual)
|
|
28
|
+
|
|
29
|
+
return false if @serialized.nil?
|
|
30
|
+
|
|
31
|
+
validate_has_content &&
|
|
32
|
+
validate_has_audio_content
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failure_message
|
|
36
|
+
constraint = @expected_mime_type ? " with mime type '#{@expected_mime_type}'" : ""
|
|
37
|
+
"expected response to have audio content#{constraint}, but:\n" +
|
|
38
|
+
@failure_reasons.map { |reason| " - #{reason}" }.join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failure_message_when_negated
|
|
42
|
+
constraint = @expected_mime_type ? " with mime type '#{@expected_mime_type}'" : ""
|
|
43
|
+
"expected response not to have audio content#{constraint}, but it did"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def description
|
|
47
|
+
constraint = @expected_mime_type ? " with mime type '#{@expected_mime_type}'" : ""
|
|
48
|
+
"have audio content#{constraint}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def serialize_response(response)
|
|
54
|
+
if response.respond_to?(:serialized)
|
|
55
|
+
response.serialized
|
|
56
|
+
elsif response.is_a?(Hash)
|
|
57
|
+
response
|
|
58
|
+
else
|
|
59
|
+
@failure_reasons << "response must respond to :serialized or be a Hash"
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_has_content
|
|
65
|
+
@content = @serialized[:content] || @serialized["content"]
|
|
66
|
+
|
|
67
|
+
unless @content
|
|
68
|
+
@failure_reasons << "response does not have :content key"
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless @content.is_a?(Array)
|
|
73
|
+
@failure_reasons << "content must be an Array"
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_has_audio_content
|
|
81
|
+
audio_items = @content.select do |item|
|
|
82
|
+
type = item[:type] || item["type"]
|
|
83
|
+
type == "audio"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if audio_items.empty?
|
|
87
|
+
@failure_reasons << "no audio content found in response"
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if @expected_mime_type
|
|
92
|
+
matching_item = audio_items.find do |item|
|
|
93
|
+
mime_type = item[:mimeType] || item["mimeType"]
|
|
94
|
+
mime_type == @expected_mime_type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unless matching_item
|
|
98
|
+
actual_types = audio_items.map { |item| item[:mimeType] || item["mimeType"] }
|
|
99
|
+
@failure_reasons << "no audio content with mime type '#{@expected_mime_type}' found, found: #{actual_types.inspect}"
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelContextProtocol
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher that validates a tool response contains embedded resource content.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(response).to have_embedded_resource_content
|
|
10
|
+
#
|
|
11
|
+
# @example With URI constraint
|
|
12
|
+
# expect(response).to have_embedded_resource_content(uri: "file:///path/to/file.txt")
|
|
13
|
+
#
|
|
14
|
+
def have_embedded_resource_content(uri: nil, mime_type: nil)
|
|
15
|
+
HaveEmbeddedResourceContent.new(uri: uri, mime_type: mime_type)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class HaveEmbeddedResourceContent
|
|
19
|
+
def initialize(uri: nil, mime_type: nil)
|
|
20
|
+
@expected_uri = uri
|
|
21
|
+
@expected_mime_type = mime_type
|
|
22
|
+
@failure_reasons = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def matches?(actual)
|
|
26
|
+
@actual = actual
|
|
27
|
+
@failure_reasons = []
|
|
28
|
+
@serialized = serialize_response(actual)
|
|
29
|
+
|
|
30
|
+
return false if @serialized.nil?
|
|
31
|
+
|
|
32
|
+
validate_has_content &&
|
|
33
|
+
validate_has_embedded_resource_content
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def failure_message
|
|
37
|
+
constraints = build_constraint_message
|
|
38
|
+
"expected response to have embedded resource content#{constraints}, but:\n" +
|
|
39
|
+
@failure_reasons.map { |reason| " - #{reason}" }.join("\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def failure_message_when_negated
|
|
43
|
+
constraints = build_constraint_message
|
|
44
|
+
"expected response not to have embedded resource content#{constraints}, but it did"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def description
|
|
48
|
+
constraints = build_constraint_message
|
|
49
|
+
"have embedded resource content#{constraints}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_constraint_message
|
|
55
|
+
parts = []
|
|
56
|
+
parts << "uri: '#{@expected_uri}'" if @expected_uri
|
|
57
|
+
parts << "mime_type: '#{@expected_mime_type}'" if @expected_mime_type
|
|
58
|
+
parts.empty? ? "" : " with #{parts.join(", ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def serialize_response(response)
|
|
62
|
+
if response.respond_to?(:serialized)
|
|
63
|
+
response.serialized
|
|
64
|
+
elsif response.is_a?(Hash)
|
|
65
|
+
response
|
|
66
|
+
else
|
|
67
|
+
@failure_reasons << "response must respond to :serialized or be a Hash"
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_has_content
|
|
73
|
+
@content = @serialized[:content] || @serialized["content"]
|
|
74
|
+
|
|
75
|
+
unless @content
|
|
76
|
+
@failure_reasons << "response does not have :content key"
|
|
77
|
+
return false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
unless @content.is_a?(Array)
|
|
81
|
+
@failure_reasons << "content must be an Array"
|
|
82
|
+
return false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_has_embedded_resource_content
|
|
89
|
+
resource_items = @content.select do |item|
|
|
90
|
+
type = item[:type] || item["type"]
|
|
91
|
+
type == "resource"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if resource_items.empty?
|
|
95
|
+
@failure_reasons << "no embedded resource content found in response"
|
|
96
|
+
return false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
matching_items = resource_items.dup
|
|
100
|
+
|
|
101
|
+
if @expected_uri
|
|
102
|
+
matching_items = matching_items.select do |item|
|
|
103
|
+
resource = item[:resource] || item["resource"] || {}
|
|
104
|
+
# Resource can be directly embedded (uri at top level) or have contents array
|
|
105
|
+
uri = resource[:uri] || resource["uri"]
|
|
106
|
+
if uri
|
|
107
|
+
uri == @expected_uri
|
|
108
|
+
else
|
|
109
|
+
contents = resource[:contents] || resource["contents"] || []
|
|
110
|
+
contents.any? do |content|
|
|
111
|
+
content_uri = content[:uri] || content["uri"]
|
|
112
|
+
content_uri == @expected_uri
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if matching_items.empty?
|
|
118
|
+
@failure_reasons << "no embedded resource with uri '#{@expected_uri}' found"
|
|
119
|
+
return false
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if @expected_mime_type
|
|
124
|
+
matching_items = matching_items.select do |item|
|
|
125
|
+
resource = item[:resource] || item["resource"] || {}
|
|
126
|
+
# Resource can be directly embedded (mimeType at top level) or have contents array
|
|
127
|
+
mime_type = resource[:mimeType] || resource["mimeType"]
|
|
128
|
+
if mime_type
|
|
129
|
+
mime_type == @expected_mime_type
|
|
130
|
+
else
|
|
131
|
+
contents = resource[:contents] || resource["contents"] || []
|
|
132
|
+
contents.any? do |content|
|
|
133
|
+
content_mime_type = content[:mimeType] || content["mimeType"]
|
|
134
|
+
content_mime_type == @expected_mime_type
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if matching_items.empty?
|
|
140
|
+
@failure_reasons << "no embedded resource with mime type '#{@expected_mime_type}' found"
|
|
141
|
+
return false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelContextProtocol
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher that validates a tool response contains image content.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(response).to have_image_content
|
|
10
|
+
#
|
|
11
|
+
# @example With mime type constraint
|
|
12
|
+
# expect(response).to have_image_content(mime_type: "image/png")
|
|
13
|
+
#
|
|
14
|
+
def have_image_content(mime_type: nil)
|
|
15
|
+
HaveImageContent.new(mime_type: mime_type)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class HaveImageContent
|
|
19
|
+
def initialize(mime_type: nil)
|
|
20
|
+
@expected_mime_type = mime_type
|
|
21
|
+
@failure_reasons = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def matches?(actual)
|
|
25
|
+
@actual = actual
|
|
26
|
+
@failure_reasons = []
|
|
27
|
+
@serialized = serialize_response(actual)
|
|
28
|
+
|
|
29
|
+
return false if @serialized.nil?
|
|
30
|
+
|
|
31
|
+
validate_has_content &&
|
|
32
|
+
validate_has_image_content
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failure_message
|
|
36
|
+
constraint = @expected_mime_type ? " with mime type '#{@expected_mime_type}'" : ""
|
|
37
|
+
"expected response to have image content#{constraint}, but:\n" +
|
|
38
|
+
@failure_reasons.map { |reason| " - #{reason}" }.join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failure_message_when_negated
|
|
42
|
+
constraint = @expected_mime_type ? " with mime type '#{@expected_mime_type}'" : ""
|
|
43
|
+
"expected response not to have image content#{constraint}, but it did"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def description
|
|
47
|
+
constraint = @expected_mime_type ? " with mime type '#{@expected_mime_type}'" : ""
|
|
48
|
+
"have image content#{constraint}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def serialize_response(response)
|
|
54
|
+
if response.respond_to?(:serialized)
|
|
55
|
+
response.serialized
|
|
56
|
+
elsif response.is_a?(Hash)
|
|
57
|
+
response
|
|
58
|
+
else
|
|
59
|
+
@failure_reasons << "response must respond to :serialized or be a Hash"
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_has_content
|
|
65
|
+
@content = @serialized[:content] || @serialized["content"]
|
|
66
|
+
|
|
67
|
+
unless @content
|
|
68
|
+
@failure_reasons << "response does not have :content key"
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless @content.is_a?(Array)
|
|
73
|
+
@failure_reasons << "content must be an Array"
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_has_image_content
|
|
81
|
+
image_items = @content.select do |item|
|
|
82
|
+
type = item[:type] || item["type"]
|
|
83
|
+
type == "image"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if image_items.empty?
|
|
87
|
+
@failure_reasons << "no image content found in response"
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if @expected_mime_type
|
|
92
|
+
matching_item = image_items.find do |item|
|
|
93
|
+
mime_type = item[:mimeType] || item["mimeType"]
|
|
94
|
+
mime_type == @expected_mime_type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unless matching_item
|
|
98
|
+
actual_types = image_items.map { |item| item[:mimeType] || item["mimeType"] }
|
|
99
|
+
@failure_reasons << "no image content with mime type '#{@expected_mime_type}' found, found: #{actual_types.inspect}"
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelContextProtocol
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher that validates a prompt response contains a specific number of messages.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(response).to have_message_count(3)
|
|
10
|
+
#
|
|
11
|
+
def have_message_count(expected_count)
|
|
12
|
+
HaveMessageCount.new(expected_count)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class HaveMessageCount
|
|
16
|
+
def initialize(expected_count)
|
|
17
|
+
@expected_count = expected_count
|
|
18
|
+
@failure_reasons = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def matches?(actual)
|
|
22
|
+
@actual = actual
|
|
23
|
+
@failure_reasons = []
|
|
24
|
+
@serialized = serialize_response(actual)
|
|
25
|
+
|
|
26
|
+
return false if @serialized.nil?
|
|
27
|
+
|
|
28
|
+
validate_has_messages &&
|
|
29
|
+
validate_count
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failure_message
|
|
33
|
+
"expected response to have #{@expected_count} message(s), but:\n" +
|
|
34
|
+
@failure_reasons.map { |reason| " - #{reason}" }.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def failure_message_when_negated
|
|
38
|
+
"expected response not to have #{@expected_count} message(s), but it did"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def description
|
|
42
|
+
"have #{@expected_count} message(s)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def serialize_response(response)
|
|
48
|
+
if response.respond_to?(:serialized)
|
|
49
|
+
response.serialized
|
|
50
|
+
elsif response.is_a?(Hash)
|
|
51
|
+
response
|
|
52
|
+
else
|
|
53
|
+
@failure_reasons << "response must respond to :serialized or be a Hash"
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_has_messages
|
|
59
|
+
@messages = @serialized[:messages] || @serialized["messages"]
|
|
60
|
+
|
|
61
|
+
unless @messages
|
|
62
|
+
@failure_reasons << "response does not have :messages key"
|
|
63
|
+
return false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless @messages.is_a?(Array)
|
|
67
|
+
@failure_reasons << "messages must be an Array"
|
|
68
|
+
return false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_count
|
|
75
|
+
actual_count = @messages.size
|
|
76
|
+
|
|
77
|
+
if actual_count != @expected_count
|
|
78
|
+
@failure_reasons << "expected #{@expected_count} message(s), got #{actual_count}"
|
|
79
|
+
return false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ModelContextProtocol
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher that validates a prompt response contains a message with a specific role.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(response).to have_message_with_role("user")
|
|
10
|
+
#
|
|
11
|
+
# @example With content constraint
|
|
12
|
+
# expect(response).to have_message_with_role("assistant").containing("How can I help?")
|
|
13
|
+
#
|
|
14
|
+
# @example With regex content constraint
|
|
15
|
+
# expect(response).to have_message_with_role("user").containing(/generate.*excuses/i)
|
|
16
|
+
#
|
|
17
|
+
def have_message_with_role(role)
|
|
18
|
+
HaveMessageWithRole.new(role)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class HaveMessageWithRole
|
|
22
|
+
def initialize(role)
|
|
23
|
+
@expected_role = role.to_s
|
|
24
|
+
@expected_content = nil
|
|
25
|
+
@failure_reasons = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def containing(content)
|
|
29
|
+
@expected_content = content
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def matches?(actual)
|
|
34
|
+
@actual = actual
|
|
35
|
+
@failure_reasons = []
|
|
36
|
+
@serialized = serialize_response(actual)
|
|
37
|
+
|
|
38
|
+
return false if @serialized.nil?
|
|
39
|
+
|
|
40
|
+
validate_has_messages &&
|
|
41
|
+
validate_has_role &&
|
|
42
|
+
validate_content_match
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failure_message
|
|
46
|
+
constraint = @expected_content ? " containing #{@expected_content.inspect}" : ""
|
|
47
|
+
"expected response to have message with role '#{@expected_role}'#{constraint}, but:\n" +
|
|
48
|
+
@failure_reasons.map { |reason| " - #{reason}" }.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def failure_message_when_negated
|
|
52
|
+
constraint = @expected_content ? " containing #{@expected_content.inspect}" : ""
|
|
53
|
+
"expected response not to have message with role '#{@expected_role}'#{constraint}, but it did"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def description
|
|
57
|
+
constraint = @expected_content ? " containing #{@expected_content.inspect}" : ""
|
|
58
|
+
"have message with role '#{@expected_role}'#{constraint}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def serialize_response(response)
|
|
64
|
+
if response.respond_to?(:serialized)
|
|
65
|
+
response.serialized
|
|
66
|
+
elsif response.is_a?(Hash)
|
|
67
|
+
response
|
|
68
|
+
else
|
|
69
|
+
@failure_reasons << "response must respond to :serialized or be a Hash"
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_has_messages
|
|
75
|
+
@messages = @serialized[:messages] || @serialized["messages"]
|
|
76
|
+
|
|
77
|
+
unless @messages
|
|
78
|
+
@failure_reasons << "response does not have :messages key"
|
|
79
|
+
return false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
unless @messages.is_a?(Array)
|
|
83
|
+
@failure_reasons << "messages must be an Array"
|
|
84
|
+
return false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_has_role
|
|
91
|
+
@messages_with_role = @messages.select do |message|
|
|
92
|
+
role = message[:role] || message["role"]
|
|
93
|
+
role == @expected_role
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if @messages_with_role.empty?
|
|
97
|
+
actual_roles = @messages.map { |m| m[:role] || m["role"] }.uniq
|
|
98
|
+
@failure_reasons << "no message with role '#{@expected_role}' found, found roles: #{actual_roles.inspect}"
|
|
99
|
+
return false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_content_match
|
|
106
|
+
return true unless @expected_content
|
|
107
|
+
|
|
108
|
+
matching_message = @messages_with_role.find do |message|
|
|
109
|
+
content = message[:content] || message["content"]
|
|
110
|
+
content_matches?(content)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unless matching_message
|
|
114
|
+
@failure_reasons << "no '#{@expected_role}' message contains content matching #{@expected_content.inspect}"
|
|
115
|
+
return false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def content_matches?(content)
|
|
122
|
+
return false unless content
|
|
123
|
+
|
|
124
|
+
# Content can be a hash with type/text or an array of content blocks
|
|
125
|
+
texts = extract_texts(content)
|
|
126
|
+
texts.any? { |text| text_matches?(text) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def extract_texts(content)
|
|
130
|
+
case content
|
|
131
|
+
when Array
|
|
132
|
+
content.flat_map { |item| extract_texts(item) }
|
|
133
|
+
when Hash
|
|
134
|
+
text = content[:text] || content["text"]
|
|
135
|
+
text ? [text] : []
|
|
136
|
+
else
|
|
137
|
+
[]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def text_matches?(text)
|
|
142
|
+
case @expected_content
|
|
143
|
+
when Regexp
|
|
144
|
+
@expected_content.match?(text)
|
|
145
|
+
else
|
|
146
|
+
text.to_s.include?(@expected_content.to_s)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|