mcp 0.2.0 → 0.4.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.
data/lib/mcp/prompt.rb CHANGED
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -6,22 +5,32 @@ module MCP
6
5
  class << self
7
6
  NOT_SET = Object.new
8
7
 
8
+ attr_reader :title_value
9
9
  attr_reader :description_value
10
10
  attr_reader :arguments_value
11
+ attr_reader :meta_value
11
12
 
12
13
  def template(args, server_context: nil)
13
14
  raise NotImplementedError, "Subclasses must implement template"
14
15
  end
15
16
 
16
17
  def to_h
17
- { name: name_value, description: description_value, arguments: arguments_value.map(&:to_h) }.compact
18
+ {
19
+ name: name_value,
20
+ title: title_value,
21
+ description: description_value,
22
+ arguments: arguments_value&.map(&:to_h),
23
+ _meta: meta_value,
24
+ }.compact
18
25
  end
19
26
 
20
27
  def inherited(subclass)
21
28
  super
22
29
  subclass.instance_variable_set(:@name_value, nil)
30
+ subclass.instance_variable_set(:@title_value, nil)
23
31
  subclass.instance_variable_set(:@description_value, nil)
24
32
  subclass.instance_variable_set(:@arguments_value, nil)
33
+ subclass.instance_variable_set(:@meta_value, nil)
25
34
  end
26
35
 
27
36
  def prompt_name(value = NOT_SET)
@@ -36,6 +45,14 @@ module MCP
36
45
  @name_value || StringUtils.handle_from_class_name(name)
37
46
  end
38
47
 
48
+ def title(value = NOT_SET)
49
+ if value == NOT_SET
50
+ @title_value
51
+ else
52
+ @title_value = value
53
+ end
54
+ end
55
+
39
56
  def description(value = NOT_SET)
40
57
  if value == NOT_SET
41
58
  @description_value
@@ -52,14 +69,24 @@ module MCP
52
69
  end
53
70
  end
54
71
 
55
- def define(name: nil, description: nil, arguments: [], &block)
72
+ def meta(value = NOT_SET)
73
+ if value == NOT_SET
74
+ @meta_value
75
+ else
76
+ @meta_value = value
77
+ end
78
+ end
79
+
80
+ def define(name: nil, title: nil, description: nil, arguments: [], meta: nil, &block)
56
81
  Class.new(self) do
57
82
  prompt_name name
83
+ title title
58
84
  description description
59
85
  arguments arguments
60
86
  define_singleton_method(:template) do |args, server_context: nil|
61
87
  instance_exec(args, server_context:, &block)
62
88
  end
89
+ meta meta
63
90
  end
64
91
  end
65
92
 
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
data/lib/mcp/resource.rb CHANGED
@@ -1,23 +1,24 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
5
4
  class Resource
6
- attr_reader :uri, :name, :description, :mime_type
5
+ attr_reader :uri, :name, :title, :description, :mime_type
7
6
 
8
- def initialize(uri:, name:, description: nil, mime_type: nil)
7
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil)
9
8
  @uri = uri
10
9
  @name = name
10
+ @title = title
11
11
  @description = description
12
12
  @mime_type = mime_type
13
13
  end
14
14
 
15
15
  def to_h
16
16
  {
17
- uri: @uri,
18
- name: @name,
19
- description: @description,
20
- mimeType: @mime_type,
17
+ uri: uri,
18
+ name: name,
19
+ title: title,
20
+ description: description,
21
+ mimeType: mime_type,
21
22
  }.compact
22
23
  end
23
24
  end
@@ -1,23 +1,24 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
5
4
  class ResourceTemplate
6
- attr_reader :uri_template, :name, :description, :mime_type
5
+ attr_reader :uri_template, :name, :title, :description, :mime_type
7
6
 
8
- def initialize(uri_template:, name:, description: nil, mime_type: nil)
7
+ def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil)
9
8
  @uri_template = uri_template
10
9
  @name = name
10
+ @title = title
11
11
  @description = description
12
12
  @mime_type = mime_type
13
13
  end
14
14
 
15
15
  def to_h
16
16
  {
17
- uriTemplate: @uri_template,
18
- name: @name,
19
- description: @description,
20
- mimeType: @mime_type,
17
+ uriTemplate: uri_template,
18
+ name: name,
19
+ title: title,
20
+ description: description,
21
+ mimeType: mime_type,
21
22
  }.compact
22
23
  end
23
24
  end
@@ -34,7 +34,13 @@ module MCP
34
34
  end
35
35
  end
36
36
 
37
- def send_notification(notification, session_id: nil)
37
+ def send_notification(method, params = nil, session_id: nil)
38
+ notification = {
39
+ jsonrpc: "2.0",
40
+ method:,
41
+ }
42
+ notification[:params] = params if params
43
+
38
44
  @mutex.synchronize do
39
45
  if session_id
40
46
  # Send to specific session
@@ -102,6 +108,8 @@ module MCP
102
108
 
103
109
  if body["method"] == "initialize"
104
110
  handle_initialization(body_string, body)
111
+ elsif notification?(body) || response?(body)
112
+ handle_accepted
105
113
  else
106
114
  handle_regular_request(body_string, session_id)
107
115
  end
@@ -160,6 +168,14 @@ module MCP
160
168
  [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
161
169
  end
162
170
 
171
+ def notification?(body)
172
+ !body["id"] && !!body["method"]
173
+ end
174
+
175
+ def response?(body)
176
+ !!body["id"] && !body["method"]
177
+ end
178
+
163
179
  def handle_initialization(body_string, body)
164
180
  session_id = SecureRandom.uuid
165
181
 
@@ -179,6 +195,10 @@ module MCP
179
195
  [200, headers, [response]]
180
196
  end
181
197
 
198
+ def handle_accepted
199
+ [202, {}, []]
200
+ end
201
+
182
202
  def handle_regular_request(body_string, session_id)
183
203
  # If session ID is provided, but not in the sessions hash, return an error
184
204
  if session_id && !@sessions.key?(session_id)
data/lib/mcp/server.rb CHANGED
@@ -31,11 +31,13 @@ module MCP
31
31
 
32
32
  include Instrumentation
33
33
 
34
- attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
34
+ attr_accessor :name, :title, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
35
35
 
36
36
  def initialize(
37
37
  name: "model_context_protocol",
38
+ title: nil,
38
39
  version: DEFAULT_VERSION,
40
+ instructions: nil,
39
41
  tools: [],
40
42
  prompts: [],
41
43
  resources: [],
@@ -46,7 +48,9 @@ module MCP
46
48
  transport: nil
47
49
  )
48
50
  @name = name
51
+ @title = title
49
52
  @version = version
53
+ @instructions = instructions
50
54
  @tools = tools.to_h { |t| [t.name_value, t] }
51
55
  @prompts = prompts.to_h { |p| [p.name_value, p] }
52
56
  @resources = resources
@@ -54,6 +58,9 @@ module MCP
54
58
  @resource_index = index_resources_by_uri(resources)
55
59
  @server_context = server_context
56
60
  @configuration = MCP.configuration.merge(configuration)
61
+
62
+ validate!
63
+
57
64
  @capabilities = capabilities || default_capabilities
58
65
 
59
66
  @handlers = {
@@ -66,6 +73,7 @@ module MCP
66
73
  Methods::PROMPTS_GET => method(:get_prompt),
67
74
  Methods::INITIALIZE => method(:init),
68
75
  Methods::PING => ->(_) { {} },
76
+ Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
69
77
 
70
78
  # No op handlers for currently unsupported methods
71
79
  Methods::RESOURCES_SUBSCRIBE => ->(_) {},
@@ -88,13 +96,15 @@ module MCP
88
96
  end
89
97
  end
90
98
 
91
- def define_tool(name: nil, description: nil, input_schema: nil, annotations: nil, &block)
92
- tool = Tool.define(name:, description:, input_schema:, annotations:, &block)
99
+ def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block)
100
+ tool = Tool.define(name:, title:, description:, input_schema:, annotations:, meta:, &block)
93
101
  @tools[tool.name_value] = tool
102
+
103
+ validate!
94
104
  end
95
105
 
96
- def define_prompt(name: nil, description: nil, arguments: [], &block)
97
- prompt = Prompt.define(name:, description:, arguments:, &block)
106
+ def define_prompt(name: nil, title: nil, description: nil, arguments: [], &block)
107
+ prompt = Prompt.define(name:, title:, description:, arguments:, &block)
98
108
  @prompts[prompt.name_value] = prompt
99
109
  end
100
110
 
@@ -160,6 +170,25 @@ module MCP
160
170
 
161
171
  private
162
172
 
173
+ def validate!
174
+ if @configuration.protocol_version == "2024-11-05"
175
+ if @instructions
176
+ message = "`instructions` supported by protocol version 2025-03-26 or higher"
177
+ raise ArgumentError, message
178
+ end
179
+
180
+ error_tool_names = @tools.each_with_object([]) do |(tool_name, tool), error_tool_names|
181
+ if tool.annotations
182
+ error_tool_names << tool_name
183
+ end
184
+ end
185
+ unless error_tool_names.empty?
186
+ message = "Error occurred in #{error_tool_names.join(", ")}. `annotations` are supported by protocol version 2025-03-26 or higher"
187
+ raise ArgumentError, message
188
+ end
189
+ end
190
+ end
191
+
163
192
  def handle_request(request, method)
164
193
  handler = @handlers[method]
165
194
  unless handler
@@ -209,8 +238,9 @@ module MCP
209
238
  def server_info
210
239
  @server_info ||= {
211
240
  name:,
241
+ title:,
212
242
  version:,
213
- }
243
+ }.compact
214
244
  end
215
245
 
216
246
  def init(request)
@@ -218,11 +248,12 @@ module MCP
218
248
  protocolVersion: configuration.protocol_version,
219
249
  capabilities: capabilities,
220
250
  serverInfo: server_info,
221
- }
251
+ instructions: instructions,
252
+ }.compact
222
253
  end
223
254
 
224
255
  def list_tools(request)
225
- @tools.map { |_, tool| tool.to_h }
256
+ @tools.values.map(&:to_h)
226
257
  end
227
258
 
228
259
  def call_tool(request)
@@ -233,7 +264,7 @@ module MCP
233
264
  raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found)
234
265
  end
235
266
 
236
- arguments = request[:arguments]
267
+ arguments = request[:arguments] || {}
237
268
  add_instrumentation_data(tool_name:)
238
269
 
239
270
  if tool.input_schema&.missing_required_arguments?(arguments)
@@ -262,7 +293,7 @@ module MCP
262
293
  end
263
294
 
264
295
  def list_prompts(request)
265
- @prompts.map { |_, prompt| prompt.to_h }
296
+ @prompts.values.map(&:to_h)
266
297
  end
267
298
 
268
299
  def get_prompt(request)
@@ -307,14 +338,12 @@ module MCP
307
338
 
308
339
  def accepts_server_context?(method_object)
309
340
  parameters = method_object.parameters
310
- accepts_server_context = parameters.any? { |_type, name| name == :server_context }
311
- has_kwargs = parameters.any? { |type, _| type == :keyrest }
312
341
 
313
- accepts_server_context || has_kwargs
342
+ parameters.any? { |type, name| type == :keyrest || name == :server_context }
314
343
  end
315
344
 
316
345
  def call_tool_with_args(tool, arguments)
317
- args = arguments.transform_keys(&:to_sym)
346
+ args = arguments&.transform_keys(&:to_sym) || {}
318
347
 
319
348
  if accepts_server_context?(tool.method(:call))
320
349
  tool.call(**args, server_context: server_context).to_h
@@ -1,4 +1,3 @@
1
- # typed: strict
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module MCP
@@ -3,9 +3,9 @@
3
3
  module MCP
4
4
  class Tool
5
5
  class Annotations
6
- attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint
6
+ attr_reader :destructive_hint, :idempotent_hint, :open_world_hint, :read_only_hint, :title
7
7
 
8
- def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil)
8
+ def initialize(destructive_hint: true, idempotent_hint: false, open_world_hint: true, read_only_hint: false, title: nil)
9
9
  @title = title
10
10
  @read_only_hint = read_only_hint
11
11
  @destructive_hint = destructive_hint
@@ -15,11 +15,11 @@ module MCP
15
15
 
16
16
  def to_h
17
17
  {
18
- title:,
19
- readOnlyHint: read_only_hint,
20
18
  destructiveHint: destructive_hint,
21
19
  idempotentHint: idempotent_hint,
22
20
  openWorldHint: open_world_hint,
21
+ readOnlyHint: read_only_hint,
22
+ title:,
23
23
  }.compact
24
24
  end
25
25
  end
@@ -15,8 +15,13 @@ module MCP
15
15
  validate_schema!
16
16
  end
17
17
 
18
+ def ==(other)
19
+ other.is_a?(InputSchema) && properties == other.properties && required == other.required
20
+ end
21
+
18
22
  def to_h
19
- { type: "object", properties: }.tap do |hsh|
23
+ { type: "object" }.tap do |hsh|
24
+ hsh[:properties] = properties if properties.any?
20
25
  hsh[:required] = required if required.any?
21
26
  end
22
27
  end
@@ -45,7 +50,10 @@ module MCP
45
50
  accept_uri: false,
46
51
  accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
47
52
  )
48
- metaschema = JSON::Validator.validator_for_name("draft4").metaschema
53
+ metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
54
+ # Converts metaschema to a file URI for cross-platform compatibility
55
+ metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
56
+ metaschema = metaschema_uri.to_s
49
57
  errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
50
58
  if errors.any?
51
59
  raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json-schema"
4
+
5
+ module MCP
6
+ class Tool
7
+ class OutputSchema
8
+ class ValidationError < StandardError; end
9
+
10
+ attr_reader :schema
11
+
12
+ def initialize(schema = {})
13
+ @schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
14
+ @schema[:type] ||= "object"
15
+ validate_schema!
16
+ end
17
+
18
+ def ==(other)
19
+ other.is_a?(OutputSchema) && schema == other.schema
20
+ end
21
+
22
+ def to_h
23
+ @schema
24
+ end
25
+
26
+ def validate_result(result)
27
+ errors = JSON::Validator.fully_validate(to_h, result)
28
+ if errors.any?
29
+ raise ValidationError, "Invalid result: #{errors.join(", ")}"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def deep_transform_keys(schema, &block)
36
+ case schema
37
+ when Hash
38
+ schema.each_with_object({}) do |(key, value), result|
39
+ if key.casecmp?("$ref")
40
+ raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool output schemas"
41
+ end
42
+
43
+ result[yield(key)] = deep_transform_keys(value, &block)
44
+ end
45
+ when Array
46
+ schema.map { |e| deep_transform_keys(e, &block) }
47
+ else
48
+ schema
49
+ end
50
+ end
51
+
52
+ def validate_schema!
53
+ schema = to_h
54
+ schema_reader = JSON::Schema::Reader.new(
55
+ accept_uri: false,
56
+ accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
57
+ )
58
+ metaschema_path = Pathname.new(JSON::Validator.validator_for_name("draft4").metaschema)
59
+ # Converts metaschema to a file URI for cross-platform compatibility
60
+ metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
61
+ metaschema = metaschema_uri.to_s
62
+ errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
63
+ if errors.any?
64
+ raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -3,15 +3,27 @@
3
3
  module MCP
4
4
  class Tool
5
5
  class Response
6
- attr_reader :content, :is_error
6
+ NOT_GIVEN = Object.new.freeze
7
7
 
8
- def initialize(content, is_error = false)
9
- @content = content
10
- @is_error = is_error
8
+ attr_reader :content, :structured_content
9
+
10
+ def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil)
11
+ if deprecated_error != NOT_GIVEN
12
+ warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1)
13
+ error = deprecated_error
14
+ end
15
+
16
+ @content = content || []
17
+ @error = error
18
+ @structured_content = structured_content
19
+ end
20
+
21
+ def error?
22
+ !!@error
11
23
  end
12
24
 
13
25
  def to_h
14
- { content:, isError: is_error }.compact
26
+ { content:, isError: error?, structuredContent: @structured_content }.compact
15
27
  end
16
28
  end
17
29
  end
data/lib/mcp/tool.rb CHANGED
@@ -5,30 +5,36 @@ module MCP
5
5
  class << self
6
6
  NOT_SET = Object.new
7
7
 
8
+ attr_reader :title_value
8
9
  attr_reader :description_value
9
- attr_reader :input_schema_value
10
10
  attr_reader :annotations_value
11
+ attr_reader :meta_value
11
12
 
12
13
  def call(*args, server_context: nil)
13
14
  raise NotImplementedError, "Subclasses must implement call"
14
15
  end
15
16
 
16
17
  def to_h
17
- result = {
18
+ {
18
19
  name: name_value,
20
+ title: title_value,
19
21
  description: description_value,
20
22
  inputSchema: input_schema_value.to_h,
21
- }
22
- result[:annotations] = annotations_value.to_h if annotations_value
23
- result
23
+ outputSchema: @output_schema_value&.to_h,
24
+ annotations: annotations_value&.to_h,
25
+ _meta: meta_value,
26
+ }.compact
24
27
  end
25
28
 
26
29
  def inherited(subclass)
27
30
  super
28
31
  subclass.instance_variable_set(:@name_value, nil)
32
+ subclass.instance_variable_set(:@title_value, nil)
29
33
  subclass.instance_variable_set(:@description_value, nil)
30
34
  subclass.instance_variable_set(:@input_schema_value, nil)
35
+ subclass.instance_variable_set(:@output_schema_value, nil)
31
36
  subclass.instance_variable_set(:@annotations_value, nil)
37
+ subclass.instance_variable_set(:@meta_value, nil)
32
38
  end
33
39
 
34
40
  def tool_name(value = NOT_SET)
@@ -43,6 +49,20 @@ module MCP
43
49
  @name_value || StringUtils.handle_from_class_name(name)
44
50
  end
45
51
 
52
+ def input_schema_value
53
+ @input_schema_value || InputSchema.new
54
+ end
55
+
56
+ attr_reader :output_schema_value
57
+
58
+ def title(value = NOT_SET)
59
+ if value == NOT_SET
60
+ @title_value
61
+ else
62
+ @title_value = value
63
+ end
64
+ end
65
+
46
66
  def description(value = NOT_SET)
47
67
  if value == NOT_SET
48
68
  @description_value
@@ -63,6 +83,24 @@ module MCP
63
83
  end
64
84
  end
65
85
 
86
+ def output_schema(value = NOT_SET)
87
+ if value == NOT_SET
88
+ output_schema_value
89
+ elsif value.is_a?(Hash)
90
+ @output_schema_value = OutputSchema.new(value)
91
+ elsif value.is_a?(OutputSchema)
92
+ @output_schema_value = value
93
+ end
94
+ end
95
+
96
+ def meta(value = NOT_SET)
97
+ if value == NOT_SET
98
+ @meta_value
99
+ else
100
+ @meta_value = value
101
+ end
102
+ end
103
+
66
104
  def annotations(hash = NOT_SET)
67
105
  if hash == NOT_SET
68
106
  @annotations_value
@@ -71,11 +109,14 @@ module MCP
71
109
  end
72
110
  end
73
111
 
74
- def define(name: nil, description: nil, input_schema: nil, annotations: nil, &block)
112
+ def define(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, meta: nil, annotations: nil, &block)
75
113
  Class.new(self) do
76
114
  tool_name name
115
+ title title
77
116
  description description
78
117
  input_schema input_schema
118
+ meta meta
119
+ output_schema output_schema
79
120
  self.annotations(annotations) if annotations
80
121
  define_singleton_method(:call, &block) if block
81
122
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/mcp.rb CHANGED
@@ -18,10 +18,14 @@ require_relative "mcp/server/transports/stdio_transport"
18
18
  require_relative "mcp/string_utils"
19
19
  require_relative "mcp/tool"
20
20
  require_relative "mcp/tool/input_schema"
21
+ require_relative "mcp/tool/output_schema"
21
22
  require_relative "mcp/tool/response"
22
23
  require_relative "mcp/tool/annotations"
23
24
  require_relative "mcp/transport"
24
25
  require_relative "mcp/version"
26
+ require_relative "mcp/client"
27
+ require_relative "mcp/client/http"
28
+ require_relative "mcp/client/tool"
25
29
 
26
30
  module MCP
27
31
  class << self