mcp-rb 0.3.0 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 131c29522f8eb44f7f613f51d92db433370d99950dcfcd27340ab9917b306dd3
4
- data.tar.gz: a676110b09851f7018205bc012b09a6f22d1c7730555ba569b9b7ae475c06009
3
+ metadata.gz: 7c5b2677ddf71a4e2597cae1bb3f5806308e33beb5c5a28032741788a1c0b7b6
4
+ data.tar.gz: 63c873245f9af796df2b60072167bac6a4810f6233a3f872e9295e52e1d42b38
5
5
  SHA512:
6
- metadata.gz: ebe914ec0fa9ba0ef97650f83454d0fe5bf5c924717365d5cea3bdd73bfb06b5e0ede7d49c6da2caa8d38c5a337bae3c98c22b86d5f1c25740785c66ac195509
7
- data.tar.gz: 719f1f95950ca24c0695316af978866e1b8834b5112c962701324d8279faa9e5705151157d23a66e7f9e6572313df18f7c4acfc247037965d059f12bdcacb883
6
+ metadata.gz: 9672f55327ee861ba8777447b5b04724d4ffac344a268245a8dc53f9aa5715a048af67f226d8856e2066b28268aff98e7ed5b82fcb8dd59a97710f6f4e4e3ae6
7
+ data.tar.gz: 2d82cf39dd04173518e7b2365513d07536661dbc80a0db72a38b759401808651ff186f2bb0910c24ac73ecbc613a709db66c4c89c7d9422d71d93b7fd0478665
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.2] - 2025-03-07
9
+
10
+ ### Added
11
+ - Add support for Nested Arguments and Array Arguments: https://github.com/funwarioisii/mcp-rb/pull/6
12
+ - Add validation for nested arguments
13
+ - Support array type arguments
14
+
15
+ ## [0.3.1] - 2025-03-05
16
+
17
+ ### Added
18
+ - Add `resources/templates/list` method: https://github.com/funwarioisii/mcp-rb/pull/5
19
+
8
20
  ## [0.3.0] - 2025-02-19
9
21
 
10
22
  - Allow specifying the version via DSL keyword: https://github.com/funwarioisii/mcp-rb/pull/2
data/README.md CHANGED
@@ -19,6 +19,8 @@ require 'mcp'
19
19
 
20
20
  name "hello-world"
21
21
 
22
+ version "1.0.0"
23
+
22
24
  # Define a resource
23
25
  resource "hello://world" do
24
26
  name "Hello World"
@@ -26,6 +28,13 @@ resource "hello://world" do
26
28
  call { "Hello, World!" }
27
29
  end
28
30
 
31
+ # Define a resource template
32
+ resource_template "hello://{user_name}" do
33
+ name "Hello User"
34
+ description "A simple hello user message"
35
+ call { |args| "Hello, #{args[:user_name]}!" }
36
+ end
37
+
29
38
  # Define a tool
30
39
  tool "greet" do
31
40
  description "Greet someone by name"
@@ -34,6 +43,27 @@ tool "greet" do
34
43
  "Hello, #{args[:name]}!"
35
44
  end
36
45
  end
46
+
47
+ # Define a tool with nested arguments
48
+ tool "greet_full_name" do
49
+ description "Greet someone by their full name"
50
+ argument :person, required: true, description: "Person to greet" do
51
+ argument :first_name, String, required: false, description: "First name"
52
+ argument :last_name, String, required: false, description: "Last name"
53
+ end
54
+ call do |args|
55
+ "Hello, First: #{args[:person][:first_name]} Last: #{args[:person][:last_name]}!"
56
+ end
57
+ end
58
+
59
+ # Define a tool with an Array argument
60
+ tool "group_greeting" do
61
+ description "Greet multiple people at once"
62
+ argument :people, Array, required: true, items: String, description: "People to greet"
63
+ call do |args|
64
+ args[:people].map { |person| "Hello, #{person}!" }.join(", ")
65
+ end
66
+ end
37
67
  ```
38
68
 
39
69
  ## Supported specifications
@@ -47,6 +77,7 @@ Reference: [MCP 2024-11-05](https://spec.modelcontextprotocol.io/specification/2
47
77
  - Resources
48
78
  - resources/read
49
79
  - resources/list
80
+ - resources/templates/list
50
81
  - Tools
51
82
  - tools/list
52
83
  - tools/call
@@ -102,4 +133,3 @@ gem push mcp-rb-*.gem
102
133
  ## Changelog
103
134
 
104
135
  See [CHANGELOG.md](CHANGELOG.md)
105
-
@@ -69,11 +69,11 @@ module MCP
69
69
 
70
70
  if page_size.nil?
71
71
  paginated = values[start_index..]
72
- next_cursor = ""
72
+ next_cursor = nil
73
73
  else
74
74
  paginated = values[start_index, page_size]
75
75
  has_next = start_index + page_size < values.length
76
- next_cursor = has_next ? (start_index + page_size).to_s : ""
76
+ next_cursor = has_next ? (start_index + page_size).to_s : nil
77
77
  end
78
78
 
79
79
  {
@@ -84,6 +84,29 @@ module MCP
84
84
 
85
85
  def read_resource(uri)
86
86
  resource = resources[uri]
87
+
88
+ # If no direct match, check if it matches a template
89
+ if resource.nil? && respond_to?(:find_matching_template)
90
+ template, variable_values = find_matching_template(uri)
91
+
92
+ if template
93
+ begin
94
+ # Call the template handler with the extracted variables
95
+ content = template[:handler].call(variable_values)
96
+ return {
97
+ contents: [{
98
+ uri: uri,
99
+ mimeType: template[:mime_type],
100
+ text: content
101
+ }]
102
+ }
103
+ rescue => e
104
+ raise ArgumentError, "Error reading resource from template: #{e.message}"
105
+ end
106
+ end
107
+ end
108
+
109
+ # If we still don't have a resource, raise an error
87
110
  raise ArgumentError, "Resource not found: #{uri}" unless resource
88
111
 
89
112
  begin
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module MCP
6
+ class App
7
+ module ResourceTemplate
8
+ def resource_templates
9
+ @resource_templates ||= {}
10
+ end
11
+
12
+ class ResourceTemplateBuilder
13
+ attr_reader :uri_template, :name, :description, :mime_type, :handler
14
+
15
+ def initialize(uri_template)
16
+ raise ArgumentError, "Resource URI template cannot be nil or empty" if uri_template.nil? || uri_template.empty?
17
+ @uri_template = uri_template
18
+ @name = ""
19
+ @description = ""
20
+ @mime_type = "text/plain"
21
+ @handler = nil
22
+ @variables = extract_variables(uri_template)
23
+ end
24
+
25
+ # standard:disable Lint/DuplicateMethods,Style/TrivialAccessors
26
+ def name(value)
27
+ @name = value
28
+ end
29
+ # standard:enable Lint/DuplicateMethods,Style/TrivialAccessors
30
+
31
+ # standard:disable Lint/DuplicateMethods,Style/TrivialAccessors
32
+ def description(text)
33
+ @description = text
34
+ end
35
+ # standard:enable Lint/DuplicateMethods,Style/TrivialAccessors
36
+
37
+ # standard:disable Lint/DuplicateMethods,Style/TrivialAccessors
38
+ def mime_type(value)
39
+ @mime_type = value
40
+ end
41
+ # standard:enable Lint/DuplicateMethods,Style/TrivialAccessors
42
+
43
+ def call(&block)
44
+ @handler = block
45
+ end
46
+
47
+ def to_resource_template_hash
48
+ raise ArgumentError, "Name must be provided" if @name.empty?
49
+
50
+ {
51
+ uri_template: @uri_template,
52
+ name: @name,
53
+ mime_type: @mime_type,
54
+ description: @description,
55
+ handler: @handler,
56
+ variables: @variables
57
+ }
58
+ end
59
+
60
+ # Extract variables from a URI template
61
+ # e.g., "channels://{channel_id}" => ["channel_id"]
62
+ def extract_variables(uri_template)
63
+ variables = []
64
+ uri_template.scan(/\{([^}]+)\}/) do |match|
65
+ variables << match[0]&.to_sym
66
+ end
67
+ variables
68
+ end
69
+
70
+ # Creates a pattern for matching URIs against this template
71
+ def to_pattern
72
+ pattern_string = Regexp.escape(@uri_template).gsub(/\\\{[^}]+\\\}/) do |match|
73
+ "([^/]+)"
74
+ end
75
+ Regexp.new("^#{pattern_string}$")
76
+ end
77
+
78
+ # Extract variable values from a concrete URI based on the template
79
+ # e.g., template: "channels://{channel_id}", uri: "channels://123" => {"channel_id" => "123"}
80
+ def extract_variable_values(uri)
81
+ pattern = to_pattern
82
+ match = pattern.match(uri)
83
+ return {} unless match
84
+
85
+ result = {}
86
+ @variables.each_with_index do |var_name, index|
87
+ result[var_name] = match[index + 1]
88
+ end
89
+ result
90
+ end
91
+ end
92
+
93
+ def register_resource_template(uri_template, &block)
94
+ builder = ResourceTemplateBuilder.new(uri_template)
95
+ builder.instance_eval(&block)
96
+ template_hash = builder.to_resource_template_hash
97
+ resource_templates[uri_template] = template_hash
98
+ template_hash
99
+ end
100
+
101
+ # Find a template that matches the given URI and extract variable values
102
+ def find_matching_template(uri)
103
+ resource_templates.each do |template_uri, template|
104
+ builder = ResourceTemplateBuilder.new(template_uri)
105
+ variable_values = builder.extract_variable_values(uri)
106
+ return [template, variable_values] unless variable_values.empty?
107
+ end
108
+ [nil, {}]
109
+ end
110
+
111
+ def list_resource_templates(cursor: nil, page_size: nil)
112
+ start_index = cursor&.to_i || 0
113
+ values = resource_templates.values
114
+
115
+ if page_size.nil?
116
+ paginated = values[start_index..]
117
+ next_cursor = nil
118
+ else
119
+ paginated = values[start_index, page_size]
120
+ has_next = start_index + page_size < values.length
121
+ next_cursor = has_next ? (start_index + page_size).to_s : nil
122
+ end
123
+
124
+ {
125
+ resourceTemplates: paginated.map { |t| format_resource_template(t) },
126
+ nextCursor: next_cursor
127
+ }
128
+ end
129
+
130
+ private
131
+
132
+ def format_resource_template(template)
133
+ {
134
+ uriTemplate: template[:uri_template],
135
+ name: template[:name],
136
+ description: template[:description],
137
+ mimeType: template[:mime_type]
138
+ }
139
+ end
140
+ end
141
+ end
142
+ end
data/lib/mcp/app/tool.rb CHANGED
@@ -7,31 +7,83 @@ module MCP
7
7
  @tools ||= {}
8
8
  end
9
9
 
10
+ # Builds schemas for arguments, supporting simple types, nested objects, and arrays
11
+ class SchemaBuilder
12
+ def initialize
13
+ @schema = nil
14
+ @properties = {}
15
+ @required = []
16
+ end
17
+
18
+ def argument(name, type = nil, required: false, description: "", items: nil, &block)
19
+ if type == Array
20
+ if block_given?
21
+ sub_builder = SchemaBuilder.new
22
+ sub_builder.instance_eval(&block)
23
+ item_schema = sub_builder.to_schema
24
+ elsif items
25
+ item_schema = {type: ruby_type_to_schema_type(items)}
26
+ else
27
+ raise ArgumentError, "Must provide items or a block for array type"
28
+ end
29
+ @properties[name] = {type: :array, description: description, items: item_schema}
30
+ elsif block_given?
31
+ raise ArgumentError, "Type not allowed with block for objects" if type
32
+ sub_builder = SchemaBuilder.new
33
+ sub_builder.instance_eval(&block)
34
+ @properties[name] = sub_builder.to_schema.merge(description: description)
35
+ else
36
+ raise ArgumentError, "Type required for simple arguments" if type.nil?
37
+ @properties[name] = {type: ruby_type_to_schema_type(type), description: description}
38
+ end
39
+ @required << name if required
40
+ end
41
+
42
+ def type(t)
43
+ @schema = {type: ruby_type_to_schema_type(t)}
44
+ end
45
+
46
+ def to_schema
47
+ @schema || {type: :object, properties: @properties, required: @required}
48
+ end
49
+
50
+ private
51
+
52
+ def ruby_type_to_schema_type(type)
53
+ if type == String
54
+ :string
55
+ elsif type == Integer
56
+ :integer
57
+ elsif type == Float
58
+ :number
59
+ elsif type == TrueClass || type == FalseClass
60
+ :boolean
61
+ elsif type == Array
62
+ :array
63
+ else
64
+ raise ArgumentError, "Unsupported type: #{type}"
65
+ end
66
+ end
67
+ end
68
+
69
+ # Constructs tool definitions with enhanced schema support
10
70
  class ToolBuilder
11
- attr_reader :name, :description, :arguments, :handler
71
+ attr_reader :name, :arguments, :handler
12
72
 
13
73
  def initialize(name)
14
74
  raise ArgumentError, "Tool name cannot be nil or empty" if name.nil? || name.empty?
15
75
  @name = name
16
76
  @description = ""
17
- @arguments = {}
18
- @required_arguments = []
77
+ @schema_builder = SchemaBuilder.new
19
78
  @handler = nil
20
79
  end
21
80
 
22
- # standard:disable Lint/DuplicateMethods
23
81
  def description(text = nil)
24
- return @description if text.nil?
25
- @description = text
82
+ text ? @description = text : @description
26
83
  end
27
- # standard:enable Lint/DuplicateMethods
28
84
 
29
- def argument(name, type, required: false, description: "")
30
- @arguments[name] = {
31
- type: ruby_type_to_schema_type(type),
32
- description: description
33
- }
34
- @required_arguments << name if required
85
+ def argument(*args, **kwargs, &block)
86
+ @schema_builder.argument(*args, **kwargs, &block)
35
87
  end
36
88
 
37
89
  def call(&block)
@@ -43,95 +95,91 @@ module MCP
43
95
  {
44
96
  name: @name,
45
97
  description: @description,
46
- input_schema: {
47
- type: :object,
48
- properties: @arguments,
49
- required: @required_arguments
50
- },
98
+ input_schema: @schema_builder.to_schema,
51
99
  handler: @handler
52
100
  }
53
101
  end
54
-
55
- private
56
-
57
- def ruby_type_to_schema_type(type)
58
- case type.to_s
59
- when "String" then :string
60
- when "Integer" then :integer
61
- when "Float" then :number
62
- when "TrueClass", "FalseClass", "Boolean" then :boolean
63
- else :object
64
- end
65
- end
66
102
  end
67
103
 
104
+ # Registers a tool with the given name and block
68
105
  def register_tool(name, &block)
69
106
  builder = ToolBuilder.new(name)
70
107
  builder.instance_eval(&block)
71
- tool_hash = builder.to_tool_hash
72
- tools[name] = tool_hash
73
- tool_hash
108
+ tools[name] = builder.to_tool_hash
74
109
  end
75
110
 
111
+ # Lists tools with pagination
76
112
  def list_tools(cursor: nil, page_size: 10)
77
- tool_values = tools.values
78
- start_index = cursor ? cursor.to_i : 0
79
- paginated = tool_values[start_index, page_size]
80
- next_cursor = (start_index + page_size < tool_values.length) ? (start_index + page_size).to_s : ""
81
-
82
- {
83
- tools: paginated.map { |t| format_tool(t) },
84
- nextCursor: next_cursor
85
- }
113
+ start = cursor ? cursor.to_i : 0
114
+ paginated = tools.values[start, page_size]
115
+ next_cursor = (start + page_size < tools.length) ? (start + page_size).to_s : nil
116
+ {tools: paginated.map { |t| {name: t[:name], description: t[:description], inputSchema: t[:input_schema]} }, nextCursor: next_cursor}
86
117
  end
87
118
 
88
- def call_tool(name, **arguments)
119
+ # Calls a tool with the provided arguments
120
+ def call_tool(name, **args)
89
121
  tool = tools[name]
90
122
  raise ArgumentError, "Tool not found: #{name}" unless tool
91
123
 
92
- begin
93
- validate_arguments(tool[:input_schema], arguments)
94
- result = tool[:handler].call(arguments)
95
- {
96
- content: [
97
- {
98
- type: "text",
99
- text: result.to_s
100
- }
101
- ],
102
- isError: false
103
- }
104
- rescue => e
105
- {
106
- content: [
107
- {
108
- type: "text",
109
- text: "Error: #{e.message}"
110
- }
111
- ],
112
- isError: true
113
- }
114
- end
124
+ validate_arguments(tool[:input_schema], args)
125
+ {content: [{type: "text", text: tool[:handler].call(args).to_s}], isError: false}
126
+ rescue => e
127
+ {content: [{type: "text", text: "Error: #{e.message}"}], isError: true}
115
128
  end
116
129
 
117
130
  private
118
131
 
119
- def validate_arguments(schema, arguments)
120
- return unless schema[:required]
121
-
122
- schema[:required].each do |required_arg|
123
- unless arguments.key?(required_arg)
124
- raise ArgumentError, "missing keyword: :#{required_arg}"
132
+ def validate(schema, arg, path = "")
133
+ errors = []
134
+ type = schema[:type]
135
+
136
+ if type == :object
137
+ if !arg.is_a?(Hash)
138
+ errors << (path.empty? ? "Arguments must be a hash" : "Expected object for #{path}, got #{arg.class}")
139
+ else
140
+ schema[:required]&.each do |req|
141
+ unless arg.key?(req)
142
+ errors << (path.empty? ? "Missing required param :#{req}" : "Missing required param #{path}.#{req}")
143
+ end
144
+ end
145
+ schema[:properties].each do |key, subschema|
146
+ if arg.key?(key)
147
+ sub_path = path.empty? ? key : "#{path}.#{key}"
148
+ sub_errors = validate(subschema, arg[key], sub_path)
149
+ errors.concat(sub_errors)
150
+ end
151
+ end
152
+ end
153
+ elsif type == :array
154
+ if !arg.is_a?(Array)
155
+ errors << "Expected array for #{path}, got #{arg.class}"
156
+ else
157
+ arg.each_with_index do |item, index|
158
+ sub_path = "#{path}[#{index}]"
159
+ sub_errors = validate(schema[:items], item, sub_path)
160
+ errors.concat(sub_errors)
161
+ end
162
+ end
163
+ else
164
+ valid = case type
165
+ when :string then arg.is_a?(String)
166
+ when :integer then arg.is_a?(Integer)
167
+ when :number then arg.is_a?(Float)
168
+ when :boolean then arg.is_a?(TrueClass) || arg.is_a?(FalseClass)
169
+ else false
170
+ end
171
+ unless valid
172
+ errors << "Expected #{type} for #{path}, got #{arg.class}"
125
173
  end
126
174
  end
175
+ errors
127
176
  end
128
177
 
129
- def format_tool(tool)
130
- {
131
- name: tool[:name],
132
- description: tool[:description],
133
- inputSchema: tool[:input_schema]
134
- }
178
+ def validate_arguments(schema, args)
179
+ errors = validate(schema, args, "")
180
+ unless errors.empty?
181
+ raise ArgumentError, errors.join("\n").to_s
182
+ end
135
183
  end
136
184
  end
137
185
  end
data/lib/mcp/app.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "app/resource"
4
+ require_relative "app/resource_template"
4
5
  require_relative "app/tool"
5
6
 
6
7
  module MCP
7
8
  class App
8
9
  include Resource
10
+ include ResourceTemplate
9
11
  include Tool
10
12
  end
11
13
  end
data/lib/mcp/delegator.rb CHANGED
@@ -15,6 +15,6 @@ module MCP
15
15
  end
16
16
  end
17
17
 
18
- delegate :name, :version, :resource, :tool
18
+ delegate :name, :version, :resource, :resource_template, :tool
19
19
  end
20
20
  end
data/lib/mcp/server.rb CHANGED
@@ -28,6 +28,7 @@ module MCP
28
28
  return @version if value.nil?
29
29
 
30
30
  @version = value
31
+ @supported_protocol_versions << value
31
32
  end
32
33
 
33
34
  def tool(name, &block)
@@ -38,6 +39,10 @@ module MCP
38
39
  @app.register_resource(uri, &block)
39
40
  end
40
41
 
42
+ def resource_template(uri_template, &block)
43
+ @app.register_resource_template(uri_template, &block)
44
+ end
45
+
41
46
  def run
42
47
  while (input = $stdin.gets)
43
48
  process_input(input)
@@ -56,6 +61,10 @@ module MCP
56
61
  @app.list_resources[:resources]
57
62
  end
58
63
 
64
+ def list_resource_templates
65
+ @app.list_resource_templates[:resourceTemplates]
66
+ end
67
+
59
68
  def read_resource(uri)
60
69
  @app.read_resource(uri).dig(:contents, 0, :text)
61
70
  end
@@ -172,6 +181,12 @@ module MCP
172
181
  success_response(request[:id], result)
173
182
  end
174
183
 
184
+ def handle_list_resources_templates(request)
185
+ cursor = request.dig(:params, :cursor)
186
+ result = @app.list_resource_templates(cursor:)
187
+ success_response(request[:id], result)
188
+ end
189
+
175
190
  def handle_read_resource(request)
176
191
  uri = request.dig(:params, :uri)
177
192
  result = @app.read_resource(uri)
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.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - funwarioisii
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2025-02-19 00:00:00.000000000 Z
11
+ date: 2025-03-08 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: MCP-RB is a Ruby framework that provides a Sinatra-like DSL for implementing
13
14
  Model Context Protocol servers.
@@ -23,6 +24,7 @@ files:
23
24
  - lib/mcp.rb
24
25
  - lib/mcp/app.rb
25
26
  - lib/mcp/app/resource.rb
27
+ - lib/mcp/app/resource_template.rb
26
28
  - lib/mcp/app/tool.rb
27
29
  - lib/mcp/client.rb
28
30
  - lib/mcp/constants.rb
@@ -36,6 +38,7 @@ metadata:
36
38
  homepage_uri: https://github.com/funwarioisii/mcp-rb
37
39
  source_code_uri: https://github.com/funwarioisii/mcp-rb
38
40
  changelog_uri: https://github.com/funwarioisii/mcp-rb/blob/main/CHANGELOG.md
41
+ post_install_message:
39
42
  rdoc_options: []
40
43
  require_paths:
41
44
  - lib
@@ -50,7 +53,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
50
53
  - !ruby/object:Gem::Version
51
54
  version: '0'
52
55
  requirements: []
53
- rubygems_version: 3.6.3
56
+ rubygems_version: 3.4.1
57
+ signing_key:
54
58
  specification_version: 4
55
59
  summary: A lightweight Ruby framework for implementing MCP (Model Context Protocol)
56
60
  servers