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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -2
  3. data/README.md +174 -978
  4. data/lib/model_context_protocol/rspec/helpers.rb +54 -0
  5. data/lib/model_context_protocol/rspec/matchers/be_mcp_error_response.rb +123 -0
  6. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_class.rb +103 -0
  7. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_prompt_response.rb +126 -0
  8. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_resource_response.rb +121 -0
  9. data/lib/model_context_protocol/rspec/matchers/be_valid_mcp_tool_response.rb +135 -0
  10. data/lib/model_context_protocol/rspec/matchers/have_audio_content.rb +109 -0
  11. data/lib/model_context_protocol/rspec/matchers/have_embedded_resource_content.rb +150 -0
  12. data/lib/model_context_protocol/rspec/matchers/have_image_content.rb +109 -0
  13. data/lib/model_context_protocol/rspec/matchers/have_message_count.rb +87 -0
  14. data/lib/model_context_protocol/rspec/matchers/have_message_with_role.rb +152 -0
  15. data/lib/model_context_protocol/rspec/matchers/have_resource_annotations.rb +135 -0
  16. data/lib/model_context_protocol/rspec/matchers/have_resource_blob.rb +108 -0
  17. data/lib/model_context_protocol/rspec/matchers/have_resource_link_content.rb +138 -0
  18. data/lib/model_context_protocol/rspec/matchers/have_resource_mime_type.rb +103 -0
  19. data/lib/model_context_protocol/rspec/matchers/have_resource_text.rb +112 -0
  20. data/lib/model_context_protocol/rspec/matchers/have_structured_content.rb +88 -0
  21. data/lib/model_context_protocol/rspec/matchers/have_text_content.rb +113 -0
  22. data/lib/model_context_protocol/rspec/matchers.rb +31 -0
  23. data/lib/model_context_protocol/rspec.rb +23 -0
  24. data/lib/model_context_protocol/server/client_logger.rb +1 -1
  25. data/lib/model_context_protocol/server/configuration.rb +195 -91
  26. data/lib/model_context_protocol/server/content_helpers.rb +1 -1
  27. data/lib/model_context_protocol/server/prompt.rb +0 -14
  28. data/lib/model_context_protocol/server/redis_client_proxy.rb +2 -14
  29. data/lib/model_context_protocol/server/redis_config.rb +5 -7
  30. data/lib/model_context_protocol/server/redis_pool_manager.rb +10 -13
  31. data/lib/model_context_protocol/server/registry.rb +8 -0
  32. data/lib/model_context_protocol/server/router.rb +279 -4
  33. data/lib/model_context_protocol/server/server_logger.rb +5 -2
  34. data/lib/model_context_protocol/server/stdio_configuration.rb +114 -0
  35. data/lib/model_context_protocol/server/stdio_transport/request_store.rb +0 -41
  36. data/lib/model_context_protocol/server/streamable_http_configuration.rb +218 -0
  37. data/lib/model_context_protocol/server/streamable_http_transport/event_counter.rb +0 -13
  38. data/lib/model_context_protocol/server/streamable_http_transport/notification_queue.rb +0 -41
  39. data/lib/model_context_protocol/server/streamable_http_transport/request_store.rb +0 -103
  40. data/lib/model_context_protocol/server/streamable_http_transport/server_request_store.rb +0 -64
  41. data/lib/model_context_protocol/server/streamable_http_transport/session_message_queue.rb +0 -58
  42. data/lib/model_context_protocol/server/streamable_http_transport/session_store.rb +17 -31
  43. data/lib/model_context_protocol/server/streamable_http_transport/stream_registry.rb +0 -34
  44. data/lib/model_context_protocol/server/streamable_http_transport.rb +192 -56
  45. data/lib/model_context_protocol/server/tool.rb +67 -1
  46. data/lib/model_context_protocol/server.rb +203 -262
  47. data/lib/model_context_protocol/version.rb +1 -1
  48. data/lib/model_context_protocol.rb +4 -1
  49. data/lib/puma/plugin/mcp.rb +39 -0
  50. data/tasks/mcp.rake +26 -0
  51. data/tasks/templates/dev-http-puma.erb +251 -0
  52. data/tasks/templates/dev-http.erb +166 -184
  53. data/tasks/templates/dev.erb +29 -7
  54. 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