mcp 0.4.0 → 0.5.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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JsonRpcHandler
6
+ class Version
7
+ V1_0 = "1.0"
8
+ V2_0 = "2.0"
9
+ end
10
+
11
+ class ErrorCode
12
+ INVALID_REQUEST = -32600
13
+ METHOD_NOT_FOUND = -32601
14
+ INVALID_PARAMS = -32602
15
+ INTERNAL_ERROR = -32603
16
+ PARSE_ERROR = -32700
17
+ end
18
+
19
+ DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/
20
+
21
+ extend self
22
+
23
+ def handle(request, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
24
+ if request.is_a?(Array)
25
+ return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
26
+ code: ErrorCode::INVALID_REQUEST,
27
+ message: "Invalid Request",
28
+ data: "Request is an empty array",
29
+ }) if request.empty?
30
+
31
+ # Handle batch requests
32
+ responses = request.map { |req| process_request(req, id_validation_pattern: id_validation_pattern, &method_finder) }.compact
33
+
34
+ # A single item is hoisted out of the array
35
+ return responses.first if responses.one?
36
+
37
+ # An empty array yields nil
38
+ responses if responses.any?
39
+ elsif request.is_a?(Hash)
40
+ # Handle single request
41
+ process_request(request, id_validation_pattern: id_validation_pattern, &method_finder)
42
+ else
43
+ error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
44
+ code: ErrorCode::INVALID_REQUEST,
45
+ message: "Invalid Request",
46
+ data: "Request must be an array or a hash",
47
+ })
48
+ end
49
+ end
50
+
51
+ def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
52
+ begin
53
+ request = JSON.parse(request_json, symbolize_names: true)
54
+ response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
55
+ rescue JSON::ParserError
56
+ response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
57
+ code: ErrorCode::PARSE_ERROR,
58
+ message: "Parse error",
59
+ data: "Invalid JSON",
60
+ })
61
+ end
62
+
63
+ response&.to_json
64
+ end
65
+
66
+ def process_request(request, id_validation_pattern:, &method_finder)
67
+ id = request[:id]
68
+
69
+ error = if !valid_version?(request[:jsonrpc])
70
+ "JSON-RPC version must be 2.0"
71
+ elsif !valid_id?(request[:id], id_validation_pattern)
72
+ "Request ID must match validation pattern, or be an integer or null"
73
+ elsif !valid_method_name?(request[:method])
74
+ 'Method name must be a string and not start with "rpc."'
75
+ end
76
+
77
+ return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
78
+ code: ErrorCode::INVALID_REQUEST,
79
+ message: "Invalid Request",
80
+ data: error,
81
+ }) if error
82
+
83
+ method_name = request[:method]
84
+ params = request[:params]
85
+
86
+ unless valid_params?(params)
87
+ return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
88
+ code: ErrorCode::INVALID_PARAMS,
89
+ message: "Invalid params",
90
+ data: "Method parameters must be an array or an object or null",
91
+ })
92
+ end
93
+
94
+ begin
95
+ method = method_finder.call(method_name)
96
+
97
+ if method.nil?
98
+ return error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
99
+ code: ErrorCode::METHOD_NOT_FOUND,
100
+ message: "Method not found",
101
+ data: method_name,
102
+ })
103
+ end
104
+
105
+ result = method.call(params)
106
+
107
+ success_response(id: id, result: result)
108
+ rescue StandardError => e
109
+ error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
110
+ code: ErrorCode::INTERNAL_ERROR,
111
+ message: "Internal error",
112
+ data: e.message,
113
+ })
114
+ end
115
+ end
116
+
117
+ def valid_version?(version)
118
+ version == Version::V2_0
119
+ end
120
+
121
+ def valid_id?(id, pattern = nil)
122
+ return true if id.nil? || id.is_a?(Integer)
123
+ return false unless id.is_a?(String)
124
+
125
+ pattern ? id.match?(pattern) : true
126
+ end
127
+
128
+ def valid_method_name?(method)
129
+ method.is_a?(String) && !method.start_with?("rpc.")
130
+ end
131
+
132
+ def valid_params?(params)
133
+ params.nil? || params.is_a?(Array) || params.is_a?(Hash)
134
+ end
135
+
136
+ def success_response(id:, result:)
137
+ {
138
+ jsonrpc: Version::V2_0,
139
+ id: id,
140
+ result: result,
141
+ } unless id.nil?
142
+ end
143
+
144
+ def error_response(id:, id_validation_pattern:, error:)
145
+ {
146
+ jsonrpc: Version::V2_0,
147
+ id: valid_id?(id, id_validation_pattern) ? id : nil,
148
+ error: error.compact,
149
+ } unless id.nil?
150
+ end
151
+ end
@@ -3,6 +3,8 @@
3
3
  module MCP
4
4
  class Client
5
5
  class HTTP
6
+ ACCEPT_HEADER = "application/json, text/event-stream"
7
+
6
8
  attr_reader :url
7
9
 
8
10
  def initialize(url:, headers: {})
@@ -14,46 +16,48 @@ module MCP
14
16
  method = request[:method] || request["method"]
15
17
  params = request[:params] || request["params"]
16
18
 
17
- client.post("", request).body
19
+ response = client.post("", request)
20
+ validate_response_content_type!(response, method, params)
21
+ response.body
18
22
  rescue Faraday::BadRequestError => e
19
23
  raise RequestHandlerError.new(
20
24
  "The #{method} request is invalid",
21
- { method:, params: },
25
+ { method: method, params: params },
22
26
  error_type: :bad_request,
23
27
  original_error: e,
24
28
  )
25
29
  rescue Faraday::UnauthorizedError => e
26
30
  raise RequestHandlerError.new(
27
31
  "You are unauthorized to make #{method} requests",
28
- { method:, params: },
32
+ { method: method, params: params },
29
33
  error_type: :unauthorized,
30
34
  original_error: e,
31
35
  )
32
36
  rescue Faraday::ForbiddenError => e
33
37
  raise RequestHandlerError.new(
34
38
  "You are forbidden to make #{method} requests",
35
- { method:, params: },
39
+ { method: method, params: params },
36
40
  error_type: :forbidden,
37
41
  original_error: e,
38
42
  )
39
43
  rescue Faraday::ResourceNotFound => e
40
44
  raise RequestHandlerError.new(
41
45
  "The #{method} request is not found",
42
- { method:, params: },
46
+ { method: method, params: params },
43
47
  error_type: :not_found,
44
48
  original_error: e,
45
49
  )
46
50
  rescue Faraday::UnprocessableEntityError => e
47
51
  raise RequestHandlerError.new(
48
52
  "The #{method} request is unprocessable",
49
- { method:, params: },
53
+ { method: method, params: params },
50
54
  error_type: :unprocessable_entity,
51
55
  original_error: e,
52
56
  )
53
57
  rescue Faraday::Error => e # Catch-all
54
58
  raise RequestHandlerError.new(
55
59
  "Internal error handling #{method} request",
56
- { method:, params: },
60
+ { method: method, params: params },
57
61
  error_type: :internal_error,
58
62
  original_error: e,
59
63
  )
@@ -70,6 +74,7 @@ module MCP
70
74
  faraday.response(:json)
71
75
  faraday.response(:raise_error)
72
76
 
77
+ faraday.headers["Accept"] = ACCEPT_HEADER
73
78
  headers.each do |key, value|
74
79
  faraday.headers[key] = value
75
80
  end
@@ -83,6 +88,17 @@ module MCP
83
88
  "Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
84
89
  "See https://rubygems.org/gems/faraday for more details."
85
90
  end
91
+
92
+ def validate_response_content_type!(response, method, params)
93
+ content_type = response.headers["Content-Type"]
94
+ return if content_type&.include?("application/json")
95
+
96
+ raise RequestHandlerError.new(
97
+ "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.",
98
+ { method: method, params: params },
99
+ error_type: :unsupported_media_type,
100
+ )
101
+ end
86
102
  end
87
103
  end
88
104
  end
data/lib/mcp/client.rb CHANGED
@@ -58,6 +58,20 @@ module MCP
58
58
  response.dig("result", "resources") || []
59
59
  end
60
60
 
61
+ # Returns the list of prompts available from the server.
62
+ # Each call will make a new request – the result is not cached.
63
+ #
64
+ # @return [Array<Hash>] An array of available prompts.
65
+ def prompts
66
+ response = transport.send_request(request: {
67
+ jsonrpc: JsonRpcHandler::Version::V2_0,
68
+ id: request_id,
69
+ method: "prompts/list",
70
+ })
71
+
72
+ response.dig("result", "prompts") || []
73
+ end
74
+
61
75
  # Calls a tool via the transport layer and returns the full response from the server.
62
76
  #
63
77
  # @param tool [MCP::Client::Tool] The tool to be called.
@@ -96,6 +110,21 @@ module MCP
96
110
  response.dig("result", "contents") || []
97
111
  end
98
112
 
113
+ # Gets a prompt from the server by name and returns its details.
114
+ #
115
+ # @param name [String] The name of the prompt to get.
116
+ # @return [Hash] A hash containing the prompt details.
117
+ def get_prompt(name:)
118
+ response = transport.send_request(request: {
119
+ jsonrpc: JsonRpcHandler::Version::V2_0,
120
+ id: request_id,
121
+ method: "prompts/get",
122
+ params: { name: name },
123
+ })
124
+
125
+ response.fetch("result", {})
126
+ end
127
+
99
128
  private
100
129
 
101
130
  def request_id
@@ -2,8 +2,10 @@
2
2
 
3
3
  module MCP
4
4
  class Configuration
5
- DEFAULT_PROTOCOL_VERSION = "2025-06-18"
6
- SUPPORTED_PROTOCOL_VERSIONS = [DEFAULT_PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]
5
+ LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25"
6
+ SUPPORTED_STABLE_PROTOCOL_VERSIONS = [
7
+ LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
8
+ ]
7
9
 
8
10
  attr_writer :exception_reporter, :instrumentation_callback
9
11
 
@@ -33,7 +35,7 @@ module MCP
33
35
  end
34
36
 
35
37
  def protocol_version
36
- @protocol_version || DEFAULT_PROTOCOL_VERSION
38
+ @protocol_version || LATEST_STABLE_PROTOCOL_VERSION
37
39
  end
38
40
 
39
41
  def protocol_version?
@@ -83,18 +85,18 @@ module MCP
83
85
  validate_tool_call_arguments = other.validate_tool_call_arguments
84
86
 
85
87
  Configuration.new(
86
- exception_reporter:,
87
- instrumentation_callback:,
88
- protocol_version:,
89
- validate_tool_call_arguments:,
88
+ exception_reporter: exception_reporter,
89
+ instrumentation_callback: instrumentation_callback,
90
+ protocol_version: protocol_version,
91
+ validate_tool_call_arguments: validate_tool_call_arguments,
90
92
  )
91
93
  end
92
94
 
93
95
  private
94
96
 
95
97
  def validate_protocol_version!(protocol_version)
96
- unless SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version)
97
- message = "protocol_version must be #{SUPPORTED_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_PROTOCOL_VERSIONS[-1]}"
98
+ unless SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
99
+ message = "protocol_version must be #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_STABLE_PROTOCOL_VERSIONS[-1]}"
98
100
  raise ArgumentError, message
99
101
  end
100
102
  end
data/lib/mcp/content.rb CHANGED
@@ -11,7 +11,7 @@ module MCP
11
11
  end
12
12
 
13
13
  def to_h
14
- { text:, annotations:, type: "text" }.compact
14
+ { text: text, annotations: annotations, type: "text" }.compact
15
15
  end
16
16
  end
17
17
 
@@ -25,7 +25,7 @@ module MCP
25
25
  end
26
26
 
27
27
  def to_h
28
- { data:, mime_type:, annotations:, type: "image" }.compact
28
+ { data: data, mime_type: mime_type, annotations: annotations, type: "image" }.compact
29
29
  end
30
30
  end
31
31
  end
data/lib/mcp/icon.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Icon
5
+ SUPPORTED_THEMES = ["light", "dark"]
6
+
7
+ attr_reader :mime_type, :sizes, :src, :theme
8
+
9
+ def initialize(mime_type: nil, sizes: nil, src: nil, theme: nil)
10
+ raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme)
11
+
12
+ @mime_type = mime_type
13
+ @sizes = sizes
14
+ @src = src
15
+ @theme = theme
16
+ end
17
+
18
+ def to_h
19
+ { mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact
20
+ end
21
+ end
22
+ end
@@ -6,7 +6,7 @@ module MCP
6
6
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
7
  begin
8
8
  @instrumentation_data = {}
9
- add_instrumentation_data(method:)
9
+ add_instrumentation_data(method: method)
10
10
 
11
11
  result = yield block
12
12
 
data/lib/mcp/methods.rb CHANGED
@@ -21,6 +21,7 @@ module MCP
21
21
 
22
22
  ROOTS_LIST = "roots/list"
23
23
  SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
24
+ ELICITATION_CREATE = "elicitation/create"
24
25
 
25
26
  # Notification methods
26
27
  NOTIFICATIONS_INITIALIZED = "notifications/initialized"
@@ -76,6 +77,8 @@ module MCP
76
77
  require_capability!(method, capabilities, :roots, :listChanged)
77
78
  when SAMPLING_CREATE_MESSAGE
78
79
  require_capability!(method, capabilities, :sampling)
80
+ when ELICITATION_CREATE
81
+ require_capability!(method, capabilities, :elicitation)
79
82
  when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
80
83
  # No specific capability required for initialize, ping, progress or cancelled
81
84
  end
@@ -11,7 +11,7 @@ module MCP
11
11
  end
12
12
 
13
13
  def to_h
14
- { role:, content: content.to_h }.compact
14
+ { role: role, content: content.to_h }.compact
15
15
  end
16
16
  end
17
17
  end
@@ -11,7 +11,7 @@ module MCP
11
11
  end
12
12
 
13
13
  def to_h
14
- { description:, messages: messages.map(&:to_h) }.compact
14
+ { description: description, messages: messages.map(&:to_h) }.compact
15
15
  end
16
16
  end
17
17
  end
data/lib/mcp/prompt.rb CHANGED
@@ -7,6 +7,7 @@ module MCP
7
7
 
8
8
  attr_reader :title_value
9
9
  attr_reader :description_value
10
+ attr_reader :icons_value
10
11
  attr_reader :arguments_value
11
12
  attr_reader :meta_value
12
13
 
@@ -19,6 +20,7 @@ module MCP
19
20
  name: name_value,
20
21
  title: title_value,
21
22
  description: description_value,
23
+ icons: icons&.map(&:to_h),
22
24
  arguments: arguments_value&.map(&:to_h),
23
25
  _meta: meta_value,
24
26
  }.compact
@@ -29,6 +31,7 @@ module MCP
29
31
  subclass.instance_variable_set(:@name_value, nil)
30
32
  subclass.instance_variable_set(:@title_value, nil)
31
33
  subclass.instance_variable_set(:@description_value, nil)
34
+ subclass.instance_variable_set(:@icons_value, nil)
32
35
  subclass.instance_variable_set(:@arguments_value, nil)
33
36
  subclass.instance_variable_set(:@meta_value, nil)
34
37
  end
@@ -61,6 +64,14 @@ module MCP
61
64
  end
62
65
  end
63
66
 
67
+ def icons(value = NOT_SET)
68
+ if value == NOT_SET
69
+ @icons_value
70
+ else
71
+ @icons_value = value
72
+ end
73
+ end
74
+
64
75
  def arguments(value = NOT_SET)
65
76
  if value == NOT_SET
66
77
  @arguments_value
@@ -77,14 +88,15 @@ module MCP
77
88
  end
78
89
  end
79
90
 
80
- def define(name: nil, title: nil, description: nil, arguments: [], meta: nil, &block)
91
+ def define(name: nil, title: nil, description: nil, icons: [], arguments: [], meta: nil, &block)
81
92
  Class.new(self) do
82
93
  prompt_name name
83
94
  title title
84
95
  description description
96
+ icons icons
85
97
  arguments arguments
86
98
  define_singleton_method(:template) do |args, server_context: nil|
87
- instance_exec(args, server_context:, &block)
99
+ instance_exec(args, server_context: server_context, &block)
88
100
  end
89
101
  meta meta
90
102
  end
@@ -11,7 +11,7 @@ module MCP
11
11
  end
12
12
 
13
13
  def to_h
14
- { uri:, mime_type: }.compact
14
+ { uri: uri, mime_type: mime_type }.compact
15
15
  end
16
16
  end
17
17
 
@@ -10,7 +10,7 @@ module MCP
10
10
  end
11
11
 
12
12
  def to_h
13
- { resource: resource.to_h, annotations: }.compact
13
+ { resource: resource.to_h, annotations: annotations }.compact
14
14
  end
15
15
  end
16
16
  end
data/lib/mcp/resource.rb CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  module MCP
4
4
  class Resource
5
- attr_reader :uri, :name, :title, :description, :mime_type
5
+ attr_reader :uri, :name, :title, :description, :icons, :mime_type
6
6
 
7
- def initialize(uri:, name:, title: nil, description: nil, mime_type: nil)
7
+ def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil)
8
8
  @uri = uri
9
9
  @name = name
10
10
  @title = title
11
11
  @description = description
12
+ @icons = icons
12
13
  @mime_type = mime_type
13
14
  end
14
15
 
@@ -18,6 +19,7 @@ module MCP
18
19
  name: name,
19
20
  title: title,
20
21
  description: description,
22
+ icons: icons.map(&:to_h),
21
23
  mimeType: mime_type,
22
24
  }.compact
23
25
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module MCP
4
4
  class ResourceTemplate
5
- attr_reader :uri_template, :name, :title, :description, :mime_type
5
+ attr_reader :uri_template, :name, :title, :description, :icons, :mime_type
6
6
 
7
- def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil)
7
+ def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil)
8
8
  @uri_template = uri_template
9
9
  @name = name
10
10
  @title = title
11
11
  @description = description
12
+ @icons = icons
12
13
  @mime_type = mime_type
13
14
  end
14
15
 
@@ -18,6 +19,7 @@ module MCP
18
19
  name: name,
19
20
  title: title,
20
21
  description: description,
22
+ icons: icons.map(&:to_h),
21
23
  mimeType: mime_type,
22
24
  }.compact
23
25
  end