mcp 0.1.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/server.rb ADDED
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_rpc_handler"
4
+ require_relative "instrumentation"
5
+ require_relative "methods"
6
+
7
+ module MCP
8
+ class Server
9
+ DEFAULT_VERSION = "0.1.0"
10
+
11
+ class RequestHandlerError < StandardError
12
+ attr_reader :error_type
13
+ attr_reader :original_error
14
+
15
+ def initialize(message, request, error_type: :internal_error, original_error: nil)
16
+ super(message)
17
+ @request = request
18
+ @error_type = error_type
19
+ @original_error = original_error
20
+ end
21
+ end
22
+
23
+ include Instrumentation
24
+
25
+ attr_writer :capabilities
26
+ attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration
27
+
28
+ def initialize(
29
+ name: "model_context_protocol",
30
+ version: DEFAULT_VERSION,
31
+ tools: [],
32
+ prompts: [],
33
+ resources: [],
34
+ resource_templates: [],
35
+ server_context: nil,
36
+ configuration: nil,
37
+ capabilities: nil
38
+ )
39
+ @name = name
40
+ @version = version
41
+ @tools = tools.to_h { |t| [t.name_value, t] }
42
+ @prompts = prompts.to_h { |p| [p.name_value, p] }
43
+ @resources = resources
44
+ @resource_templates = resource_templates
45
+ @resource_index = index_resources_by_uri(resources)
46
+ @server_context = server_context
47
+ @configuration = MCP.configuration.merge(configuration)
48
+
49
+ @handlers = {
50
+ Methods::RESOURCES_LIST => method(:list_resources),
51
+ Methods::RESOURCES_READ => method(:read_resource_no_content),
52
+ Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
53
+ Methods::TOOLS_LIST => method(:list_tools),
54
+ Methods::TOOLS_CALL => method(:call_tool),
55
+ Methods::PROMPTS_LIST => method(:list_prompts),
56
+ Methods::PROMPTS_GET => method(:get_prompt),
57
+ Methods::INITIALIZE => method(:init),
58
+ Methods::PING => ->(_) { {} },
59
+
60
+ # No op handlers for currently unsupported methods
61
+ Methods::RESOURCES_SUBSCRIBE => ->(_) {},
62
+ Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
63
+ Methods::COMPLETION_COMPLETE => ->(_) {},
64
+ Methods::LOGGING_SET_LEVEL => ->(_) {},
65
+ }
66
+ end
67
+
68
+ def capabilities
69
+ @capabilities ||= determine_capabilities
70
+ end
71
+
72
+ def handle(request)
73
+ JsonRpcHandler.handle(request) do |method|
74
+ handle_request(request, method)
75
+ end
76
+ end
77
+
78
+ def handle_json(request)
79
+ JsonRpcHandler.handle_json(request) do |method|
80
+ handle_request(request, method)
81
+ end
82
+ end
83
+
84
+ def define_tool(name: nil, description: nil, input_schema: nil, annotations: nil, &block)
85
+ tool = Tool.define(name:, description:, input_schema:, annotations:, &block)
86
+ @tools[tool.name_value] = tool
87
+ end
88
+
89
+ def define_prompt(name: nil, description: nil, arguments: [], &block)
90
+ prompt = Prompt.define(name:, description:, arguments:, &block)
91
+ @prompts[prompt.name_value] = prompt
92
+ end
93
+
94
+ def resources_list_handler(&block)
95
+ @handlers[Methods::RESOURCES_LIST] = block
96
+ end
97
+
98
+ def resources_read_handler(&block)
99
+ @handlers[Methods::RESOURCES_READ] = block
100
+ end
101
+
102
+ def resources_templates_list_handler(&block)
103
+ @handlers[Methods::RESOURCES_TEMPLATES_LIST] = block
104
+ end
105
+
106
+ def tools_list_handler(&block)
107
+ @handlers[Methods::TOOLS_LIST] = block
108
+ end
109
+
110
+ def tools_call_handler(&block)
111
+ @handlers[Methods::TOOLS_CALL] = block
112
+ end
113
+
114
+ def prompts_list_handler(&block)
115
+ @handlers[Methods::PROMPTS_LIST] = block
116
+ end
117
+
118
+ def prompts_get_handler(&block)
119
+ @handlers[Methods::PROMPTS_GET] = block
120
+ end
121
+
122
+ private
123
+
124
+ def handle_request(request, method)
125
+ handler = @handlers[method]
126
+ unless handler
127
+ instrument_call("unsupported_method") {}
128
+ return
129
+ end
130
+
131
+ Methods.ensure_capability!(method, capabilities)
132
+
133
+ ->(params) {
134
+ instrument_call(method) do
135
+ case method
136
+ when Methods::TOOLS_LIST
137
+ { tools: @handlers[Methods::TOOLS_LIST].call(params) }
138
+ when Methods::PROMPTS_LIST
139
+ { prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
140
+ when Methods::RESOURCES_LIST
141
+ { resources: @handlers[Methods::RESOURCES_LIST].call(params) }
142
+ when Methods::RESOURCES_READ
143
+ { contents: @handlers[Methods::RESOURCES_READ].call(params) }
144
+ when Methods::RESOURCES_TEMPLATES_LIST
145
+ { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
146
+ else
147
+ @handlers[method].call(params)
148
+ end
149
+ rescue => e
150
+ report_exception(e, { request: request })
151
+ if e.is_a?(RequestHandlerError)
152
+ add_instrumentation_data(error: e.error_type)
153
+ raise e
154
+ end
155
+
156
+ add_instrumentation_data(error: :internal_error)
157
+ raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
158
+ end
159
+ }
160
+ end
161
+
162
+ def determine_capabilities
163
+ defines_prompts = @prompts.any? || @handlers[Methods::PROMPTS_LIST] != method(:list_prompts)
164
+ defines_tools = @tools.any? || @handlers[Methods::TOOLS_LIST] != method(:list_tools)
165
+ defines_resources = @resources.any? || @handlers[Methods::RESOURCES_LIST] != method(:list_resources)
166
+ defines_resource_templates = @resource_templates.any? || @handlers[Methods::RESOURCES_TEMPLATES_LIST] != method(:list_resource_templates)
167
+ {
168
+ prompts: defines_prompts ? {} : nil,
169
+ resources: defines_resources || defines_resource_templates ? {} : nil,
170
+ tools: defines_tools ? {} : nil,
171
+ }.compact
172
+ end
173
+
174
+ def server_info
175
+ @server_info ||= {
176
+ name:,
177
+ version:,
178
+ }
179
+ end
180
+
181
+ def init(request)
182
+ add_instrumentation_data(method: Methods::INITIALIZE)
183
+ {
184
+ protocolVersion: configuration.protocol_version,
185
+ capabilities: capabilities,
186
+ serverInfo: server_info,
187
+ }
188
+ end
189
+
190
+ def list_tools(request)
191
+ add_instrumentation_data(method: Methods::TOOLS_LIST)
192
+ @tools.map { |_, tool| tool.to_h }
193
+ end
194
+
195
+ def call_tool(request)
196
+ add_instrumentation_data(method: Methods::TOOLS_CALL)
197
+ tool_name = request[:name]
198
+ tool = tools[tool_name]
199
+ unless tool
200
+ add_instrumentation_data(error: :tool_not_found)
201
+ raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found)
202
+ end
203
+
204
+ arguments = request[:arguments]
205
+ add_instrumentation_data(tool_name:)
206
+
207
+ if tool.input_schema&.missing_required_arguments?(arguments)
208
+ add_instrumentation_data(error: :missing_required_arguments)
209
+ raise RequestHandlerError.new(
210
+ "Missing required arguments: #{tool.input_schema.missing_required_arguments(arguments).join(", ")}",
211
+ request,
212
+ error_type: :missing_required_arguments,
213
+ )
214
+ end
215
+
216
+ begin
217
+ call_params = tool_call_parameters(tool)
218
+
219
+ if call_params.include?(:server_context)
220
+ tool.call(**arguments.transform_keys(&:to_sym), server_context:).to_h
221
+ else
222
+ tool.call(**arguments.transform_keys(&:to_sym)).to_h
223
+ end
224
+ rescue => e
225
+ raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
226
+ end
227
+ end
228
+
229
+ def list_prompts(request)
230
+ add_instrumentation_data(method: Methods::PROMPTS_LIST)
231
+ @prompts.map { |_, prompt| prompt.to_h }
232
+ end
233
+
234
+ def get_prompt(request)
235
+ add_instrumentation_data(method: Methods::PROMPTS_GET)
236
+ prompt_name = request[:name]
237
+ prompt = @prompts[prompt_name]
238
+ unless prompt
239
+ add_instrumentation_data(error: :prompt_not_found)
240
+ raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found)
241
+ end
242
+
243
+ add_instrumentation_data(prompt_name:)
244
+
245
+ prompt_args = request[:arguments]
246
+ prompt.validate_arguments!(prompt_args)
247
+
248
+ prompt.template(prompt_args, server_context:).to_h
249
+ end
250
+
251
+ def list_resources(request)
252
+ add_instrumentation_data(method: Methods::RESOURCES_LIST)
253
+
254
+ @resources.map(&:to_h)
255
+ end
256
+
257
+ # Server implementation should set `resources_read_handler` to override no-op default
258
+ def read_resource_no_content(request)
259
+ add_instrumentation_data(method: Methods::RESOURCES_READ)
260
+ add_instrumentation_data(resource_uri: request[:uri])
261
+ []
262
+ end
263
+
264
+ def list_resource_templates(request)
265
+ add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST)
266
+
267
+ @resource_templates.map(&:to_h)
268
+ end
269
+
270
+ def report_exception(exception, server_context = {})
271
+ configuration.exception_reporter.call(exception, server_context)
272
+ end
273
+
274
+ def index_resources_by_uri(resources)
275
+ resources.each_with_object({}) do |resource, hash|
276
+ hash[resource.uri] = resource
277
+ end
278
+ end
279
+
280
+ def tool_call_parameters(tool)
281
+ method_def = tool_call_method_def(tool)
282
+ method_def.parameters.flatten
283
+ end
284
+
285
+ def tool_call_method_def(tool)
286
+ method = tool.method(:call)
287
+
288
+ if defined?(T::Utils) && T::Utils.respond_to?(:signature_for_method)
289
+ sorbet_typed_method_definition = T::Utils.signature_for_method(method)&.method
290
+
291
+ # Return the Sorbet typed method definition if it exists, otherwise fallback to original method
292
+ # definition if Sorbet is defined but not used by this tool.
293
+ sorbet_typed_method_definition || method
294
+ else
295
+ method
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,26 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module MCP
5
+ module StringUtils
6
+ extend self
7
+
8
+ def handle_from_class_name(class_name)
9
+ underscore(demodulize(class_name))
10
+ end
11
+
12
+ private
13
+
14
+ def demodulize(path)
15
+ path.to_s.split("::").last || path.to_s
16
+ end
17
+
18
+ def underscore(camel_cased_word)
19
+ camel_cased_word.dup
20
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
21
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
22
+ .tr("-", "_")
23
+ .downcase
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Tool
5
+ class Annotations
6
+ attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint
7
+
8
+ def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil)
9
+ @title = title
10
+ @read_only_hint = read_only_hint
11
+ @destructive_hint = destructive_hint
12
+ @idempotent_hint = idempotent_hint
13
+ @open_world_hint = open_world_hint
14
+ end
15
+
16
+ def to_h
17
+ {
18
+ title:,
19
+ readOnlyHint: read_only_hint,
20
+ destructiveHint: destructive_hint,
21
+ idempotentHint: idempotent_hint,
22
+ openWorldHint: open_world_hint,
23
+ }.compact
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Tool
5
+ class InputSchema
6
+ attr_reader :properties, :required
7
+
8
+ def initialize(properties: {}, required: [])
9
+ @properties = properties
10
+ @required = required.map(&:to_sym)
11
+ end
12
+
13
+ def to_h
14
+ { type: "object", properties:, required: }
15
+ end
16
+
17
+ def missing_required_arguments?(arguments)
18
+ missing_required_arguments(arguments).any?
19
+ end
20
+
21
+ def missing_required_arguments(arguments)
22
+ (required - arguments.keys.map(&:to_sym))
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Tool
5
+ class Response
6
+ attr_reader :content, :is_error
7
+
8
+ def initialize(content, is_error = false)
9
+ @content = content
10
+ @is_error = is_error
11
+ end
12
+
13
+ def to_h
14
+ { content:, isError: is_error }.compact
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/mcp/tool.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Tool
5
+ class << self
6
+ NOT_SET = Object.new
7
+
8
+ attr_reader :description_value
9
+ attr_reader :input_schema_value
10
+ attr_reader :annotations_value
11
+
12
+ def call(*args, server_context:)
13
+ raise NotImplementedError, "Subclasses must implement call"
14
+ end
15
+
16
+ def to_h
17
+ result = {
18
+ name: name_value,
19
+ description: description_value,
20
+ inputSchema: input_schema_value.to_h,
21
+ }
22
+ result[:annotations] = annotations_value.to_h if annotations_value
23
+ result
24
+ end
25
+
26
+ def inherited(subclass)
27
+ super
28
+ subclass.instance_variable_set(:@name_value, nil)
29
+ subclass.instance_variable_set(:@description_value, nil)
30
+ subclass.instance_variable_set(:@input_schema_value, nil)
31
+ subclass.instance_variable_set(:@annotations_value, nil)
32
+ end
33
+
34
+ def tool_name(value = NOT_SET)
35
+ if value == NOT_SET
36
+ name_value
37
+ else
38
+ @name_value = value
39
+ end
40
+ end
41
+
42
+ def name_value
43
+ @name_value || StringUtils.handle_from_class_name(name)
44
+ end
45
+
46
+ def description(value = NOT_SET)
47
+ if value == NOT_SET
48
+ @description_value
49
+ else
50
+ @description_value = value
51
+ end
52
+ end
53
+
54
+ def input_schema(value = NOT_SET)
55
+ if value == NOT_SET
56
+ input_schema_value
57
+ elsif value.is_a?(Hash)
58
+ properties = value[:properties] || value["properties"] || {}
59
+ required = value[:required] || value["required"] || []
60
+ @input_schema_value = InputSchema.new(properties:, required:)
61
+ elsif value.is_a?(InputSchema)
62
+ @input_schema_value = value
63
+ end
64
+ end
65
+
66
+ def annotations(hash = NOT_SET)
67
+ if hash == NOT_SET
68
+ @annotations_value
69
+ else
70
+ @annotations_value = Annotations.new(**hash)
71
+ end
72
+ end
73
+
74
+ def define(name: nil, description: nil, input_schema: nil, annotations: nil, &block)
75
+ Class.new(self) do
76
+ tool_name name
77
+ description description
78
+ input_schema input_schema
79
+ self.annotations(annotations) if annotations
80
+ define_singleton_method(:call, &block) if block
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Transport
5
+ def initialize(server)
6
+ @server = server
7
+ end
8
+
9
+ def send_response(response)
10
+ raise NotImplementedError, "Subclasses must implement send_response"
11
+ end
12
+
13
+ def open
14
+ raise NotImplementedError, "Subclasses must implement open"
15
+ end
16
+
17
+ def close
18
+ raise NotImplementedError, "Subclasses must implement close"
19
+ end
20
+
21
+ private
22
+
23
+ def handle_request(request)
24
+ response = @server.handle(request)
25
+ send_response(response) if response
26
+ end
27
+
28
+ def handle_json_request(request)
29
+ response = @server.handle_json(request)
30
+ send_response(response) if response
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../transport"
4
+ require "json"
5
+
6
+ module MCP
7
+ module Transports
8
+ class StdioTransport < Transport
9
+ def initialize(server)
10
+ @server = server
11
+ @open = false
12
+ $stdin.set_encoding("UTF-8")
13
+ $stdout.set_encoding("UTF-8")
14
+ super
15
+ end
16
+
17
+ def open
18
+ @open = true
19
+ while @open && (line = $stdin.gets)
20
+ handle_json_request(line.strip)
21
+ end
22
+ end
23
+
24
+ def close
25
+ @open = false
26
+ end
27
+
28
+ def send_response(message)
29
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
30
+ $stdout.puts(json_message)
31
+ $stdout.flush
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mcp.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcp/server"
4
+ require_relative "mcp/string_utils"
5
+ require_relative "mcp/tool"
6
+ require_relative "mcp/tool/input_schema"
7
+ require_relative "mcp/tool/annotations"
8
+ require_relative "mcp/tool/response"
9
+ require_relative "mcp/content"
10
+ require_relative "mcp/resource"
11
+ require_relative "mcp/resource/contents"
12
+ require_relative "mcp/resource/embedded"
13
+ require_relative "mcp/resource_template"
14
+ require_relative "mcp/prompt"
15
+ require_relative "mcp/prompt/argument"
16
+ require_relative "mcp/prompt/message"
17
+ require_relative "mcp/prompt/result"
18
+ require_relative "mcp/version"
19
+ require_relative "mcp/configuration"
20
+ require_relative "mcp/methods"
21
+
22
+ module MCP
23
+ class << self
24
+ def configure
25
+ yield(configuration)
26
+ end
27
+
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+ end
32
+
33
+ class Annotations
34
+ attr_reader :audience, :priority
35
+
36
+ def initialize(audience: nil, priority: nil)
37
+ @audience = audience
38
+ @priority = priority
39
+ end
40
+ end
41
+ end
data/mcp.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/mcp/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mcp"
7
+ spec.version = MCP::VERSION
8
+ spec.authors = ["Model Context Protocol"]
9
+ spec.email = ["mcp-support@anthropic.com"]
10
+
11
+ spec.summary = "The official Ruby SDK for Model Context Protocol servers and clients"
12
+ spec.description = spec.summary
13
+ spec.homepage = "https://github.com/modelcontextprotocol/ruby-sdk"
14
+ spec.license = "MIT"
15
+
16
+ spec.required_ruby_version = ">= 3.2.0"
17
+
18
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+
22
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
23
+ %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
+ end
25
+
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_dependency("json_rpc_handler", "~> 0.1")
31
+ spec.add_development_dependency("activesupport")
32
+ spec.add_development_dependency("sorbet-static-and-runtime")
33
+ end