actionmcp 0.1.2 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/action_mcp/client.rb +7 -0
- data/lib/action_mcp/configuration.rb +6 -1
- data/lib/action_mcp/content/text.rb +1 -1
- data/lib/action_mcp/content.rb +7 -2
- data/lib/action_mcp/integer_array.rb +11 -0
- data/lib/action_mcp/json_rpc/json_rpc_error.rb +1 -1
- data/lib/action_mcp/json_rpc/notification.rb +5 -6
- data/lib/action_mcp/json_rpc/request.rb +12 -2
- data/lib/action_mcp/json_rpc/response.rb +13 -33
- data/lib/action_mcp/json_rpc.rb +0 -1
- data/lib/action_mcp/prompt.rb +4 -0
- data/lib/action_mcp/prompts_registry.rb +19 -0
- data/lib/action_mcp/registry_base.rb +41 -28
- data/lib/action_mcp/renderable.rb +28 -0
- data/lib/action_mcp/resource.rb +2 -2
- data/lib/action_mcp/resources_bank.rb +0 -2
- data/lib/action_mcp/server.rb +7 -0
- data/lib/action_mcp/string_array.rb +9 -0
- data/lib/action_mcp/tool.rb +114 -67
- data/lib/action_mcp/tools_registry.rb +14 -2
- data/lib/action_mcp/transport.rb +79 -82
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +10 -0
- data/lib/generators/action_mcp/install/install_generator.rb +2 -0
- metadata +8 -3
- data/lib/action_mcp/json_rpc/base.rb +0 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3e01fe9b15e57ab4450f9712dd2a38dbf355cbdc50d196a8026cbb205d3d8a07
|
4
|
+
data.tar.gz: 8ce8ffd799f5b9487c0bb3b78c7d7242cff31742e8cc1a79a76116e50f6cb896
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b044250b38fc6680fc5afa90f33dfc1082cb4ca7ec5916339e8e2a1f0b5ff64a32229968c24eeec19ec706a4341c413cc0d3546f7edee8dd9dd4ffd807427056
|
7
|
+
data.tar.gz: 8b98f315be3dd31d80f77d85eeddd66ab434af8077d04358928c7be3793e7ad0565d37c9042666a93810694688a45e7147b9a540ae0505b061b34b77a58cf991
|
@@ -3,13 +3,18 @@
|
|
3
3
|
module ActionMCP
|
4
4
|
# Configuration class to hold settings for the ActionMCP server.
|
5
5
|
class Configuration
|
6
|
-
attr_accessor :name, :version, :logging_enabled
|
6
|
+
attr_accessor :name, :version, :logging_enabled,
|
7
|
+
# Right now, if enabled, the server will send a listChanged notification for tools, prompts, and resources.
|
8
|
+
# We can make it more granular in the future, but for now, it's a simple boolean.
|
9
|
+
:list_changed,
|
10
|
+
:resources_subscribe
|
7
11
|
|
8
12
|
def initialize
|
9
13
|
# Use Rails.application values if available, or fallback to defaults.
|
10
14
|
@name = defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:name) ? Rails.application.name : "ActionMCP"
|
11
15
|
@version = defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:version) ? Rails.application.version.to_s.presence : "0.0.1"
|
12
16
|
@logging_enabled = true
|
17
|
+
@list_changed = false
|
13
18
|
end
|
14
19
|
end
|
15
20
|
end
|
data/lib/action_mcp/content.rb
CHANGED
@@ -15,9 +15,14 @@ module ActionMCP
|
|
15
15
|
{ type: @type }
|
16
16
|
end
|
17
17
|
|
18
|
-
def to_json(*
|
19
|
-
|
18
|
+
def to_json(*)
|
19
|
+
MultiJson.dump(to_h, *)
|
20
20
|
end
|
21
21
|
end
|
22
|
+
|
23
|
+
autoload :Image
|
24
|
+
autoload :Text
|
25
|
+
autoload :Audio
|
26
|
+
autoload :Resource
|
22
27
|
end
|
23
28
|
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
# This temporary naming extracted from MCPangea
|
5
|
+
# If there is a better name, please suggest it or part of ActiveModel, open a PR
|
6
|
+
class IntegerArray < ActiveModel::Type::Value
|
7
|
+
def cast(value)
|
8
|
+
Array(value).map(&:to_i) # Ensure all elements are integers
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -4,16 +4,15 @@ module ActionMCP
|
|
4
4
|
module JsonRpc
|
5
5
|
Notification = Data.define(:method, :params) do
|
6
6
|
def initialize(method:, params: nil)
|
7
|
-
super
|
7
|
+
super
|
8
8
|
end
|
9
9
|
|
10
10
|
def to_h
|
11
|
-
|
11
|
+
{
|
12
12
|
jsonrpc: "2.0",
|
13
|
-
method: method
|
14
|
-
|
15
|
-
|
16
|
-
hash
|
13
|
+
method: method,
|
14
|
+
params: params
|
15
|
+
}.compact
|
17
16
|
end
|
18
17
|
end
|
19
18
|
end
|
@@ -2,10 +2,10 @@
|
|
2
2
|
|
3
3
|
module ActionMCP
|
4
4
|
module JsonRpc
|
5
|
-
Request = Data.define(:
|
5
|
+
Request = Data.define(:id, :method, :params) do
|
6
6
|
def initialize(id:, method:, params: nil)
|
7
7
|
validate_id(id)
|
8
|
-
super
|
8
|
+
super
|
9
9
|
end
|
10
10
|
|
11
11
|
def to_h
|
@@ -17,6 +17,16 @@ module ActionMCP
|
|
17
17
|
hash[:params] = params if params
|
18
18
|
hash
|
19
19
|
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def validate_id(id)
|
24
|
+
unless id.is_a?(String) || id.is_a?(Numeric)
|
25
|
+
raise JsonRpcError.new(:invalid_params,
|
26
|
+
message: "ID must be a string or number")
|
27
|
+
end
|
28
|
+
raise JsonRpcError.new(:invalid_params, message: "ID must not be null") if id.nil?
|
29
|
+
end
|
20
30
|
end
|
21
31
|
end
|
22
32
|
end
|
@@ -4,49 +4,29 @@ module ActionMCP
|
|
4
4
|
module JsonRpc
|
5
5
|
Response = Data.define(:id, :result, :error) do
|
6
6
|
def initialize(id:, result: nil, error: nil)
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
super
|
7
|
+
validate_presence_of_result_or_error!(result, error)
|
8
|
+
validate_absence_of_both_result_and_error!(result, error)
|
9
|
+
|
10
|
+
super
|
11
11
|
end
|
12
12
|
|
13
13
|
def to_h
|
14
|
-
|
14
|
+
{
|
15
15
|
jsonrpc: "2.0",
|
16
|
-
id: id
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
code: error[:code],
|
21
|
-
message: error[:message]
|
22
|
-
}
|
23
|
-
hash[:error][:data] = error[:data] if error[:data]
|
24
|
-
else
|
25
|
-
hash[:result] = result
|
26
|
-
end
|
27
|
-
hash
|
16
|
+
id: id,
|
17
|
+
result: result,
|
18
|
+
error: error
|
19
|
+
}.compact
|
28
20
|
end
|
29
21
|
|
30
22
|
private
|
31
23
|
|
32
|
-
def
|
33
|
-
|
34
|
-
when Symbol
|
35
|
-
ErrorCodes[error]
|
36
|
-
when Hash
|
37
|
-
validate_error!(error)
|
38
|
-
error
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
def validate_error!(error)
|
43
|
-
raise Error, "Error code must be an integer" unless error[:code].is_a?(Integer)
|
44
|
-
raise Error, "Error message is required" unless error[:message].is_a?(String)
|
24
|
+
def validate_presence_of_result_or_error!(result, error)
|
25
|
+
raise ArgumentError, "Either result or error must be provided." if result.nil? && error.nil?
|
45
26
|
end
|
46
27
|
|
47
|
-
def
|
48
|
-
raise
|
49
|
-
raise Error, "Cannot set both result and error" if result && error
|
28
|
+
def validate_absence_of_both_result_and_error!(result, error)
|
29
|
+
raise ArgumentError, "Both result and error cannot be provided simultaneously." if result && error
|
50
30
|
end
|
51
31
|
end
|
52
32
|
end
|
data/lib/action_mcp/json_rpc.rb
CHANGED
data/lib/action_mcp/prompt.rb
CHANGED
@@ -6,6 +6,7 @@ module ActionMCP
|
|
6
6
|
class Prompt
|
7
7
|
include ActiveModel::Model
|
8
8
|
include ActiveModel::Attributes
|
9
|
+
include Renderable
|
9
10
|
|
10
11
|
class_attribute :_prompt_name, instance_accessor: false
|
11
12
|
class_attribute :_description, instance_accessor: false, default: ""
|
@@ -72,6 +73,9 @@ module ActionMCP
|
|
72
73
|
|
73
74
|
# Register the attribute so it's recognized by ActiveModel
|
74
75
|
attribute arg_name, :string, default: default
|
76
|
+
return unless required
|
77
|
+
|
78
|
+
validates arg_name, presence: true
|
75
79
|
end
|
76
80
|
|
77
81
|
def self.arguments
|
@@ -5,6 +5,25 @@ module ActionMCP
|
|
5
5
|
class << self
|
6
6
|
alias prompts items
|
7
7
|
alias available_prompts enabled
|
8
|
+
|
9
|
+
def prompt_call(prompt_name, arguments)
|
10
|
+
prompt = find(prompt_name)
|
11
|
+
prompt = prompt.new(arguments)
|
12
|
+
prompt.valid?
|
13
|
+
if prompt.valid?
|
14
|
+
{
|
15
|
+
messages: [ {
|
16
|
+
role: "user",
|
17
|
+
content: prompt.call
|
18
|
+
} ]
|
19
|
+
}
|
20
|
+
else
|
21
|
+
{
|
22
|
+
content: prompt.errors.full_messages.map { |msg| Content::Text.new(msg) },
|
23
|
+
isError: true
|
24
|
+
}
|
25
|
+
end
|
26
|
+
end
|
8
27
|
end
|
9
28
|
end
|
10
29
|
end
|
@@ -2,26 +2,30 @@
|
|
2
2
|
|
3
3
|
module ActionMCP
|
4
4
|
class RegistryBase
|
5
|
+
class NotFound < StandardError; end
|
6
|
+
|
5
7
|
class << self
|
6
8
|
def items
|
7
9
|
@items ||= {}
|
8
10
|
end
|
9
11
|
|
10
|
-
# Register an item by unique name
|
11
|
-
def register(name,
|
12
|
+
# Register an item by unique name.
|
13
|
+
def register(name, klass)
|
12
14
|
raise ArgumentError, "Name can't be blank" if name.blank?
|
13
15
|
raise ArgumentError, "Name '#{name}' is already registered." if items.key?(name)
|
14
16
|
|
15
|
-
items[name] = {
|
17
|
+
items[name] = { klass: klass, enabled: true }
|
16
18
|
end
|
17
19
|
|
18
|
-
#
|
19
|
-
|
20
|
-
|
21
|
-
|
20
|
+
# Retrieve an item’s metadata by name.
|
21
|
+
def find(name)
|
22
|
+
item = items[name]
|
23
|
+
raise NotFound, "Item '#{name}' not found." if item.nil?
|
24
|
+
|
25
|
+
item[:klass]
|
22
26
|
end
|
23
27
|
|
24
|
-
#
|
28
|
+
# Return the number of registered items, ignoring abstract ones.
|
25
29
|
def size
|
26
30
|
items.values.reject { |item| abstract_item?(item) }.size
|
27
31
|
end
|
@@ -34,37 +38,46 @@ module ActionMCP
|
|
34
38
|
items.clear
|
35
39
|
end
|
36
40
|
|
37
|
-
#
|
41
|
+
# Chainable scope: returns only enabled, non-abstract items.
|
38
42
|
def enabled
|
39
|
-
items
|
40
|
-
.reject { |_name, item| item[:class].abstract? }
|
41
|
-
.select { |_name, item| item[:enabled] }
|
43
|
+
RegistryScope.new(items)
|
42
44
|
end
|
43
45
|
|
44
|
-
|
45
|
-
|
46
|
+
private
|
47
|
+
|
48
|
+
# Helper to determine if an item is abstract.
|
49
|
+
def abstract_item?(item)
|
50
|
+
klass = item[:klass]
|
51
|
+
klass.respond_to?(:abstract?) && klass.abstract?
|
46
52
|
end
|
53
|
+
end
|
47
54
|
|
48
|
-
|
49
|
-
|
50
|
-
|
55
|
+
# Query object for chainable registry scopes.
|
56
|
+
class RegistryScope
|
57
|
+
include Enumerable
|
51
58
|
|
52
|
-
|
53
|
-
|
59
|
+
# Using a Data type for items.
|
60
|
+
Item = Data.define(:name, :klass)
|
54
61
|
|
55
|
-
|
56
|
-
|
57
|
-
|
62
|
+
def initialize(items)
|
63
|
+
@items = items.reject do |_name, item|
|
64
|
+
RegistryBase.send(:abstract_item?, item) || !item[:enabled]
|
65
|
+
end.map { |name, item| Item.new(name, item[:klass]) }
|
66
|
+
end
|
58
67
|
|
59
|
-
|
68
|
+
def each(&)
|
69
|
+
@items.each(&)
|
60
70
|
end
|
61
71
|
|
62
|
-
|
72
|
+
# Returns the names (keys) of all enabled items.
|
73
|
+
def keys
|
74
|
+
@items.map(&:name)
|
75
|
+
end
|
63
76
|
|
64
|
-
#
|
65
|
-
def
|
66
|
-
|
67
|
-
klass
|
77
|
+
# Chainable finder for available tools by name.
|
78
|
+
def find_available_tool(name)
|
79
|
+
item = @items.find { |i| i.name == name }
|
80
|
+
item&.klass
|
68
81
|
end
|
69
82
|
end
|
70
83
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Renderable
|
5
|
+
def render_text(text)
|
6
|
+
Content::Text.new(text)
|
7
|
+
end
|
8
|
+
|
9
|
+
def render_audio(data, mime_type)
|
10
|
+
Content::Audio.new(data, mime_type)
|
11
|
+
end
|
12
|
+
|
13
|
+
def render_image(data, mime_type)
|
14
|
+
Content::Image.new(data, mime_type)
|
15
|
+
end
|
16
|
+
|
17
|
+
def render_resource(uri, mime_type, text: nil, blob: nil)
|
18
|
+
Content::Resource.new(uri, mime_type, text: text, blob: blob)
|
19
|
+
end
|
20
|
+
|
21
|
+
def render_error(errors)
|
22
|
+
{
|
23
|
+
isError: true,
|
24
|
+
content: errors.map { |error| render_text(error) }
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/action_mcp/resource.rb
CHANGED
data/lib/action_mcp/tool.rb
CHANGED
@@ -1,18 +1,30 @@
|
|
1
|
-
# lib/action_mcp/tool.rb
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module ActionMCP
|
4
|
+
# Base class for defining tools.
|
5
|
+
#
|
6
|
+
# Provides a DSL for specifying metadata, properties, and nested collection schemas.
|
7
|
+
# Tools are registered automatically in the ToolsRegistry unless marked as abstract.
|
5
8
|
class Tool
|
6
9
|
include ActiveModel::Model
|
7
10
|
include ActiveModel::Attributes
|
11
|
+
include Renderable
|
8
12
|
|
13
|
+
# --------------------------------------------------------------------------
|
14
|
+
# Class Attributes for Tool Metadata and Schema
|
15
|
+
# --------------------------------------------------------------------------
|
9
16
|
class_attribute :_tool_name, instance_accessor: false
|
10
17
|
class_attribute :_description, instance_accessor: false, default: ""
|
11
18
|
class_attribute :_schema_properties, instance_accessor: false, default: {}
|
12
19
|
class_attribute :_required_properties, instance_accessor: false, default: []
|
13
20
|
class_attribute :abstract_tool, instance_accessor: false, default: false
|
14
21
|
|
15
|
-
#
|
22
|
+
# --------------------------------------------------------------------------
|
23
|
+
# Subclass Registration
|
24
|
+
# --------------------------------------------------------------------------
|
25
|
+
# Automatically registers non-abstract subclasses in the ToolsRegistry.
|
26
|
+
#
|
27
|
+
# @param subclass [Class] the subclass inheriting from Tool.
|
16
28
|
def self.inherited(subclass)
|
17
29
|
super
|
18
30
|
return if subclass == Tool
|
@@ -23,19 +35,27 @@ module ActionMCP
|
|
23
35
|
ToolsRegistry.register(subclass.tool_name, subclass)
|
24
36
|
end
|
25
37
|
|
26
|
-
#
|
38
|
+
# Marks this tool as abstract so that it won’t be available for use.
|
39
|
+
# If the tool is registered in ToolsRegistry, it is unregistered.
|
27
40
|
def self.abstract!
|
28
41
|
self.abstract_tool = true
|
29
42
|
ToolsRegistry.unregister(tool_name) if ToolsRegistry.items.key?(tool_name)
|
30
43
|
end
|
31
44
|
|
45
|
+
# Returns whether this tool is abstract.
|
46
|
+
#
|
47
|
+
# @return [Boolean] true if abstract, false otherwise.
|
32
48
|
def self.abstract?
|
33
49
|
abstract_tool
|
34
50
|
end
|
35
51
|
|
36
|
-
#
|
37
|
-
# Tool Name
|
38
|
-
#
|
52
|
+
# --------------------------------------------------------------------------
|
53
|
+
# Tool Name and Description DSL
|
54
|
+
# --------------------------------------------------------------------------
|
55
|
+
# Sets or retrieves the tool's name.
|
56
|
+
#
|
57
|
+
# @param name [String, nil] Optional. The name to set for the tool.
|
58
|
+
# @return [String] The current tool name.
|
39
59
|
def self.tool_name(name = nil)
|
40
60
|
if name
|
41
61
|
self._tool_name = name
|
@@ -44,10 +64,17 @@ module ActionMCP
|
|
44
64
|
end
|
45
65
|
end
|
46
66
|
|
67
|
+
# Returns a default tool name based on the class name.
|
68
|
+
#
|
69
|
+
# @return [String] The default tool name.
|
47
70
|
def self.default_tool_name
|
48
71
|
name.demodulize.underscore.dasherize.sub(/-tool$/, "")
|
49
72
|
end
|
50
73
|
|
74
|
+
# Sets or retrieves the tool's description.
|
75
|
+
#
|
76
|
+
# @param text [String, nil] Optional. The description text to set.
|
77
|
+
# @return [String] The current description.
|
51
78
|
def self.description(text = nil)
|
52
79
|
if text
|
53
80
|
self._description = text
|
@@ -56,86 +83,71 @@ module ActionMCP
|
|
56
83
|
end
|
57
84
|
end
|
58
85
|
|
59
|
-
#
|
86
|
+
# --------------------------------------------------------------------------
|
60
87
|
# Property DSL (Direct Declaration)
|
61
|
-
#
|
88
|
+
# --------------------------------------------------------------------------
|
89
|
+
# Defines a property for the tool.
|
90
|
+
#
|
91
|
+
# This method builds a JSON Schema definition for the property, registers it
|
92
|
+
# in the tool's schema, and creates an ActiveModel attribute for it.
|
93
|
+
#
|
94
|
+
# @param prop_name [Symbol, String] The property name.
|
95
|
+
# @param type [String] The JSON Schema type (default: "string").
|
96
|
+
# @param description [String, nil] Optional description for the property.
|
97
|
+
# @param required [Boolean] Whether the property is required (default: false).
|
98
|
+
# @param default [Object, nil] The default value for the property.
|
99
|
+
# @param opts [Hash] Additional options for the JSON Schema.
|
62
100
|
def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
|
63
|
-
# Build JSON Schema definition
|
101
|
+
# Build the JSON Schema definition.
|
64
102
|
prop_definition = { type: type }
|
65
103
|
prop_definition[:description] = description if description && !description.empty?
|
66
104
|
prop_definition.merge!(opts) if opts.any?
|
67
105
|
|
68
106
|
self._schema_properties = _schema_properties.merge(prop_name.to_s => prop_definition)
|
69
|
-
self._required_properties = _required_properties.dup
|
70
|
-
|
71
|
-
|
72
|
-
# Map our DSL type to an ActiveModel attribute type.
|
73
|
-
am_type = case type.to_s
|
74
|
-
when "number" then :float
|
75
|
-
when "integer" then :integer
|
76
|
-
when "array" then :string
|
77
|
-
else
|
78
|
-
:string
|
107
|
+
self._required_properties = _required_properties.dup.tap do |req|
|
108
|
+
req << prop_name.to_s if required
|
79
109
|
end
|
80
|
-
|
110
|
+
|
111
|
+
# Map the JSON Schema type to an ActiveModel attribute type.
|
112
|
+
attribute prop_name, map_json_type_to_active_model_type(type), default: default
|
113
|
+
validates prop_name, presence: true, if: -> { required }
|
114
|
+
|
115
|
+
return unless %w[number integer].include?(type)
|
116
|
+
|
117
|
+
validates prop_name, numericality: true
|
81
118
|
end
|
82
119
|
|
83
|
-
#
|
120
|
+
# --------------------------------------------------------------------------
|
84
121
|
# Collection DSL
|
85
|
-
#
|
86
|
-
#
|
87
|
-
#
|
88
|
-
# 1. Without a block:
|
89
|
-
# collection :args, type: "string", description: "Command arguments"
|
122
|
+
# --------------------------------------------------------------------------
|
123
|
+
# Defines a collection property for the tool.
|
90
124
|
#
|
91
|
-
#
|
92
|
-
#
|
93
|
-
#
|
94
|
-
#
|
95
|
-
#
|
96
|
-
def self.collection(prop_name, type
|
97
|
-
if
|
98
|
-
# Build nested schema for an object.
|
99
|
-
nested_schema = { type: "object", properties: {}, required: [] }
|
100
|
-
dsl = CollectionDSL.new(nested_schema)
|
101
|
-
dsl.instance_eval(&block)
|
102
|
-
collection_definition = { type: "array", description: description, items: nested_schema }
|
103
|
-
else
|
104
|
-
raise ArgumentError, "Type is required for a collection without a block" if type.nil?
|
125
|
+
# @param prop_name [Symbol, String] The collection property name.
|
126
|
+
# @param type [String] The type for collection items.
|
127
|
+
# @param description [String, nil] Optional description for the collection.
|
128
|
+
# @param required [Boolean] Whether the collection is required (default: false).
|
129
|
+
# @param default [Array, nil] The default value for the collection.
|
130
|
+
def self.collection(prop_name, type:, description: nil, required: false, default: [])
|
131
|
+
raise ArgumentError, "Type is required for a collection" if type.nil?
|
105
132
|
|
106
|
-
|
107
|
-
end
|
133
|
+
collection_definition = { type: "array", description: description, items: { type: type } }
|
108
134
|
|
109
135
|
self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
|
110
|
-
self._required_properties = _required_properties.dup
|
111
|
-
|
112
|
-
|
113
|
-
# Register the property as an attribute.
|
114
|
-
# (Mapping for a collection can be customized; here we use :string to mimic previous behavior.)
|
115
|
-
attribute prop_name, :string, default: default
|
116
|
-
end
|
117
|
-
|
118
|
-
# DSL for building a nested schema within a collection block.
|
119
|
-
class CollectionDSL
|
120
|
-
attr_reader :schema
|
121
|
-
|
122
|
-
def initialize(schema)
|
123
|
-
@schema = schema
|
136
|
+
self._required_properties = _required_properties.dup.tap do |req|
|
137
|
+
req << prop_name.to_s if required
|
124
138
|
end
|
125
139
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
prop_definition.merge!(opts) if opts.any?
|
130
|
-
|
131
|
-
@schema[:properties][prop_name.to_s] = prop_definition
|
132
|
-
@schema[:required] << prop_name.to_s if required
|
133
|
-
end
|
140
|
+
type = map_json_type_to_active_model_type("array_#{type}")
|
141
|
+
attribute prop_name, type, default: default
|
142
|
+
validates prop_name, presence: true, if: -> { required }
|
134
143
|
end
|
135
144
|
|
136
|
-
#
|
137
|
-
#
|
138
|
-
#
|
145
|
+
# --------------------------------------------------------------------------
|
146
|
+
# Tool Definition Serialization
|
147
|
+
# --------------------------------------------------------------------------
|
148
|
+
# Returns a hash representation of the tool definition including its JSON Schema.
|
149
|
+
#
|
150
|
+
# @return [Hash] The tool definition.
|
139
151
|
def self.to_h
|
140
152
|
schema = { type: "object", properties: _schema_properties }
|
141
153
|
schema[:required] = _required_properties if _required_properties.any?
|
@@ -145,5 +157,40 @@ module ActionMCP
|
|
145
157
|
inputSchema: schema
|
146
158
|
}.compact
|
147
159
|
end
|
160
|
+
|
161
|
+
# --------------------------------------------------------------------------
|
162
|
+
# Instance Methods
|
163
|
+
# --------------------------------------------------------------------------
|
164
|
+
# Abstract method to perform the tool's action.
|
165
|
+
#
|
166
|
+
# Subclasses must implement this method.
|
167
|
+
#
|
168
|
+
# @raise [NotImplementedError] Always raised if not implemented in a subclass.
|
169
|
+
def call
|
170
|
+
raise NotImplementedError, "Subclasses must implement the call method"
|
171
|
+
# Default implementation (no-op)
|
172
|
+
# In a real subclass, you might do:
|
173
|
+
# def call
|
174
|
+
# # Perform logic, e.g. analyze code, etc.
|
175
|
+
# # Array of Content objects is expected as return value
|
176
|
+
# end
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
# Maps a JSON Schema type to an ActiveModel attribute type.
|
182
|
+
#
|
183
|
+
# @param type [String] The JSON Schema type.
|
184
|
+
# @return [Symbol] The corresponding ActiveModel attribute type.
|
185
|
+
def self.map_json_type_to_active_model_type(type)
|
186
|
+
case type.to_s
|
187
|
+
when "number" then :float # JSON Schema "number" is a float in Ruby, the spec doesn't have an integer type yet.
|
188
|
+
when "array_number" then :integer_array
|
189
|
+
when "array_integer" then :string_array
|
190
|
+
when "array_string" then :string_array
|
191
|
+
else :string
|
192
|
+
end
|
193
|
+
end
|
194
|
+
private_class_method :map_json_type_to_active_model_type
|
148
195
|
end
|
149
196
|
end
|
@@ -1,12 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# frozen_string_literal: true
|
4
|
-
|
5
3
|
module ActionMCP
|
6
4
|
class ToolsRegistry < RegistryBase
|
7
5
|
class << self
|
8
6
|
alias tools items
|
9
7
|
alias available_tools enabled
|
8
|
+
|
9
|
+
def tool_call(tool_name, arguments, _metadata = {})
|
10
|
+
tool = find(tool_name)
|
11
|
+
tool = tool.new(arguments)
|
12
|
+
tool.validate
|
13
|
+
if tool.valid?
|
14
|
+
{ content: [ tool.call ] }
|
15
|
+
else
|
16
|
+
{
|
17
|
+
content: tool.errors.full_messages.map { |msg| Content::Text.new(msg) },
|
18
|
+
isError: true
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
10
22
|
end
|
11
23
|
end
|
12
24
|
end
|
data/lib/action_mcp/transport.rb
CHANGED
@@ -3,27 +3,38 @@
|
|
3
3
|
module ActionMCP
|
4
4
|
class Transport
|
5
5
|
HEARTBEAT_INTERVAL = 15 # seconds
|
6
|
+
attr_reader :initialized
|
6
7
|
|
8
|
+
# Initializes a new Transport.
|
9
|
+
#
|
10
|
+
# @param output_io [IO] An IO-like object where events will be written.
|
7
11
|
def initialize(output_io)
|
8
12
|
# output_io can be any IO-like object where we write events.
|
9
13
|
@output = output_io
|
10
14
|
@output.sync = true
|
15
|
+
@initialized = false
|
16
|
+
@client_capabilities = {}
|
17
|
+
@client_info = {}
|
18
|
+
@protocol_version = ""
|
11
19
|
end
|
12
20
|
|
13
21
|
# Sends the capabilities JSON-RPC notification.
|
14
22
|
#
|
15
23
|
# @param request_id [String, Integer] The request identifier.
|
16
|
-
def send_capabilities(request_id)
|
24
|
+
def send_capabilities(request_id, params = {})
|
25
|
+
@protocol_version = params["protocolVersion"]
|
26
|
+
@client_info = params["clientInfo"]
|
27
|
+
@client_capabilities = params["capabilities"]
|
28
|
+
Rails.logger.info("Client capabilities stored: #{@client_capabilities}")
|
17
29
|
capabilities = {}
|
18
30
|
|
19
31
|
# Only include each capability if the corresponding registry is non-empty.
|
20
|
-
capabilities[:tools] = { listChanged:
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
# Add logging capability only if enabled by configuration.
|
32
|
+
capabilities[:tools] = { listChanged: ActionMCP.configuration.list_changed } if ToolsRegistry.available_tools.any?
|
33
|
+
if PromptsRegistry.available_prompts.any?
|
34
|
+
capabilities[:prompts] =
|
35
|
+
{ listChanged: ActionMCP.configuration.list_changed }
|
36
|
+
end
|
37
|
+
capabilities[:resources] = { subscribe: ActionMCP.configuration.list_changed } if ResourcesBank.all_resources.any?
|
27
38
|
capabilities[:logging] = {} if ActionMCP.configuration.logging_enabled
|
28
39
|
|
29
40
|
payload = {
|
@@ -37,25 +48,22 @@ module ActionMCP
|
|
37
48
|
send_jsonrpc_response(request_id, result: payload)
|
38
49
|
end
|
39
50
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
def send_tools_list(request_id)
|
44
|
-
tools = format_registry_items(ActionMCP::ToolsRegistry.available_tools)
|
45
|
-
send_jsonrpc_response(request_id, result: { tools: tools })
|
51
|
+
def initialized!
|
52
|
+
@initialized = true
|
53
|
+
Rails.logger.info("Transport initialized.")
|
46
54
|
end
|
47
55
|
|
48
56
|
# Sends the resources list JSON-RPC response.
|
49
57
|
#
|
50
58
|
# @param request_id [String, Integer] The request identifier.
|
51
59
|
def send_resources_list(request_id)
|
52
|
-
resources =
|
60
|
+
resources = ResourcesBank.all_resources # fetch all resources
|
53
61
|
result_data = { "resources" => resources }
|
54
62
|
send_jsonrpc_response(request_id, result: result_data)
|
55
63
|
Rails.logger.info("resources/list: Returned #{resources.size} resources.")
|
56
64
|
rescue StandardError => e
|
57
65
|
Rails.logger.error("resources/list failed: #{e.message}")
|
58
|
-
error_obj = JsonRpcError.new(
|
66
|
+
error_obj = JsonRpc::JsonRpcError.new(
|
59
67
|
:internal_error,
|
60
68
|
message: "Failed to list resources: #{e.message}"
|
61
69
|
).as_json
|
@@ -66,13 +74,13 @@ module ActionMCP
|
|
66
74
|
#
|
67
75
|
# @param request_id [String, Integer] The request identifier.
|
68
76
|
def send_resource_templates_list(request_id)
|
69
|
-
templates =
|
77
|
+
templates = ResourcesBank.all_templates # get all resource templates
|
70
78
|
result_data = { "resourceTemplates" => templates }
|
71
79
|
send_jsonrpc_response(request_id, result: result_data)
|
72
80
|
Rails.logger.info("resources/templates/list: Returned #{templates.size} resource templates.")
|
73
81
|
rescue StandardError => e
|
74
82
|
Rails.logger.error("resources/templates/list failed: #{e.message}")
|
75
|
-
error_obj = JsonRpcError.new(
|
83
|
+
error_obj = JsonRpc::JsonRpcError.new(
|
76
84
|
:internal_error,
|
77
85
|
message: "Failed to list resource templates: #{e.message}"
|
78
86
|
).as_json
|
@@ -87,7 +95,7 @@ module ActionMCP
|
|
87
95
|
uri = params&.fetch("uri", nil)
|
88
96
|
if uri.nil? || uri.empty?
|
89
97
|
Rails.logger.error("resources/read: 'uri' parameter is missing")
|
90
|
-
error_obj = JsonRpcError.new(
|
98
|
+
error_obj = JsonRpc::JsonRpcError.new(
|
91
99
|
:invalid_params,
|
92
100
|
message: "Missing 'uri' parameter for resources/read"
|
93
101
|
).as_json
|
@@ -95,10 +103,10 @@ module ActionMCP
|
|
95
103
|
end
|
96
104
|
|
97
105
|
begin
|
98
|
-
content =
|
106
|
+
content = ResourcesBank.read(uri) # Expecting an instance of an ActionMCP::Content subclass
|
99
107
|
if content.nil?
|
100
108
|
Rails.logger.error("resources/read: Resource not found for URI #{uri}")
|
101
|
-
error_obj = JsonRpcError.new(
|
109
|
+
error_obj = JsonRpc::JsonRpcError.new(
|
102
110
|
:invalid_params,
|
103
111
|
message: "Resource not found: #{uri}"
|
104
112
|
).as_json
|
@@ -114,7 +122,7 @@ module ActionMCP
|
|
114
122
|
Rails.logger.info(log_msg)
|
115
123
|
rescue StandardError => e
|
116
124
|
Rails.logger.error("resources/read: Error reading #{uri} - #{e.message}")
|
117
|
-
error_obj = JsonRpcError.new(
|
125
|
+
error_obj = JsonRpc::JsonRpcError.new(
|
118
126
|
:internal_error,
|
119
127
|
message: "Failed to read resource: #{e.message}"
|
120
128
|
).as_json
|
@@ -122,73 +130,47 @@ module ActionMCP
|
|
122
130
|
end
|
123
131
|
end
|
124
132
|
|
125
|
-
# Sends
|
133
|
+
# Sends the tools list JSON-RPC notification.
|
134
|
+
#
|
135
|
+
# @param request_id [String, Integer] The request identifier.
|
136
|
+
def send_tools_list(request_id)
|
137
|
+
tools = format_registry_items(ToolsRegistry.available_tools)
|
138
|
+
send_jsonrpc_response(request_id, result: { tools: tools })
|
139
|
+
end
|
140
|
+
|
141
|
+
# Sends a call to a tool.
|
126
142
|
#
|
127
143
|
# @param request_id [String, Integer] The request identifier.
|
128
144
|
# @param tool_name [String] The name of the tool.
|
129
|
-
# @param
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
rescue
|
135
|
-
|
136
|
-
|
137
|
-
:internal_error,
|
138
|
-
message: "Failed to call tool #{tool_name}: #{e.message}"
|
139
|
-
).as_json
|
140
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
145
|
+
# @param arguments [Hash] The arguments for the tool.
|
146
|
+
# @param _meta [Hash] Additional metadata.
|
147
|
+
def send_tools_call(request_id, tool_name, arguments, _meta = {})
|
148
|
+
result = ToolsRegistry.tool_call(tool_name, arguments, _meta)
|
149
|
+
send_jsonrpc_response(request_id, result:)
|
150
|
+
rescue RegistryBase::NotFound
|
151
|
+
send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(:method_not_found,
|
152
|
+
message: "Tool not found: #{tool_name}").as_json)
|
141
153
|
end
|
142
154
|
|
143
155
|
# Sends the prompts list JSON-RPC notification.
|
144
156
|
#
|
145
157
|
# @param request_id [String, Integer] The request identifier.
|
146
158
|
def send_prompts_list(request_id)
|
147
|
-
prompts = format_registry_items(
|
159
|
+
prompts = format_registry_items(PromptsRegistry.available_prompts)
|
148
160
|
send_jsonrpc_response(request_id, result: { prompts: prompts })
|
149
|
-
rescue StandardError => e
|
150
|
-
Rails.logger.error("prompts/list failed: #{e.message}")
|
151
|
-
error_obj = JsonRpcError.new(
|
152
|
-
:internal_error,
|
153
|
-
message: "Failed to list prompts: #{e.message}"
|
154
|
-
).as_json
|
155
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
156
161
|
end
|
157
162
|
|
158
|
-
def send_prompts_get(request_id, params)
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
message: "Missing 'name' parameter for prompts/get"
|
165
|
-
).as_json
|
166
|
-
return send_jsonrpc_response(request_id, error: error_obj)
|
167
|
-
end
|
168
|
-
|
169
|
-
begin
|
170
|
-
# Assume a method similar to fetch_available_tool exists for prompts.
|
171
|
-
prompt = ActionMCP::PromptsRegistry.fetch_available_prompt(prompt_name.to_s)
|
172
|
-
if prompt.nil?
|
173
|
-
Rails.logger.error("prompts/get: Prompt not found for name #{prompt_name}")
|
174
|
-
error_obj = JsonRpcError.new(
|
175
|
-
:invalid_params,
|
176
|
-
message: "Prompt not found: #{prompt_name}"
|
177
|
-
).as_json
|
178
|
-
return send_jsonrpc_response(request_id, error: error_obj)
|
179
|
-
end
|
163
|
+
def send_prompts_get(request_id, prompt_name, params)
|
164
|
+
send_jsonrpc_response(request_id, result: PromptsRegistry.prompt_call(prompt_name.to_s, params))
|
165
|
+
rescue RegistryBase::NotFound
|
166
|
+
send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(:method_not_found,
|
167
|
+
message: "Prompt not found: #{prompt_name}").as_json)
|
168
|
+
end
|
180
169
|
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
Rails.logger.error("prompts/get: Error retrieving prompt #{prompt_name} - #{e.message}")
|
186
|
-
error_obj = JsonRpcError.new(
|
187
|
-
:internal_error,
|
188
|
-
message: "Failed to get prompt: #{e.message}"
|
189
|
-
).as_json
|
190
|
-
send_jsonrpc_response(request_id, error: error_obj)
|
191
|
-
end
|
170
|
+
# Sends the roots list JSON-RPC request.
|
171
|
+
# TODO: test it
|
172
|
+
def send_roots_list
|
173
|
+
send_jsonrpc_request("roots/list")
|
192
174
|
end
|
193
175
|
|
194
176
|
# Sends a JSON-RPC pong response.
|
@@ -199,6 +181,19 @@ module ActionMCP
|
|
199
181
|
send_jsonrpc_response(request_id, result: {})
|
200
182
|
end
|
201
183
|
|
184
|
+
def send_ping
|
185
|
+
send_jsonrpc_request("ping")
|
186
|
+
end
|
187
|
+
|
188
|
+
# Sends a JSON-RPC request.
|
189
|
+
# @param method [String] The JSON-RPC method.
|
190
|
+
# @param params [Hash] The parameters for the method.
|
191
|
+
# @param id [String] The request identifier.
|
192
|
+
def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
|
193
|
+
request = JsonRpc::Request.new(id: id, method: method, params: params)
|
194
|
+
write_message(request.to_json)
|
195
|
+
end
|
196
|
+
|
202
197
|
# Sends a JSON-RPC response.
|
203
198
|
#
|
204
199
|
# @param request_id [String, Integer] The request identifier.
|
@@ -213,7 +208,7 @@ module ActionMCP
|
|
213
208
|
#
|
214
209
|
# @param method [String] The JSON-RPC method.
|
215
210
|
# @param params [Hash] The parameters for the method.
|
216
|
-
def send_jsonrpc_notification(method, params =
|
211
|
+
def send_jsonrpc_notification(method, params = nil)
|
217
212
|
notification = JsonRpc::Notification.new(method: method, params: params)
|
218
213
|
write_message(notification.to_json)
|
219
214
|
end
|
@@ -225,17 +220,19 @@ module ActionMCP
|
|
225
220
|
# @param registry [Hash] The registry containing tool or prompt definitions.
|
226
221
|
# @return [Array<Hash>] The formatted registry items.
|
227
222
|
def format_registry_items(registry)
|
228
|
-
registry.map { |
|
223
|
+
registry.map { |item| item.klass.to_h }
|
229
224
|
end
|
230
225
|
|
231
226
|
# Writes a message to the output IO.
|
232
227
|
#
|
233
228
|
# @param data [String] The data to write.
|
234
229
|
def write_message(data)
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
230
|
+
Timeout.timeout(5) do # 5 second timeout
|
231
|
+
@output.write("#{data}\n")
|
232
|
+
end
|
233
|
+
rescue Timeout::Error
|
234
|
+
Rails.logger.error("Write operation timed out")
|
235
|
+
# Handle timeout appropriately
|
239
236
|
end
|
240
237
|
end
|
241
238
|
end
|
data/lib/action_mcp/version.rb
CHANGED
data/lib/action_mcp.rb
CHANGED
@@ -4,7 +4,10 @@ require "rails"
|
|
4
4
|
require "active_support"
|
5
5
|
require "active_model"
|
6
6
|
require "action_mcp/version"
|
7
|
+
require "multi_json"
|
7
8
|
require "action_mcp/railtie" if defined?(Rails)
|
9
|
+
require_relative "action_mcp/integer_array"
|
10
|
+
require_relative "action_mcp/string_array"
|
8
11
|
|
9
12
|
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
10
13
|
inflect.acronym "MCP"
|
@@ -20,6 +23,10 @@ module ActionMCP
|
|
20
23
|
autoload :Tool
|
21
24
|
autoload :Prompt
|
22
25
|
autoload :JsonRpc
|
26
|
+
autoload :Transport
|
27
|
+
autoload :Content
|
28
|
+
autoload :Renderable
|
29
|
+
|
23
30
|
eager_autoload do
|
24
31
|
autoload :Configuration
|
25
32
|
end
|
@@ -50,4 +57,7 @@ module ActionMCP
|
|
50
57
|
def available_prompts
|
51
58
|
PromptsRegistry.available_prompts
|
52
59
|
end
|
60
|
+
|
61
|
+
ActiveModel::Type.register(:string_array, StringArray)
|
62
|
+
ActiveModel::Type.register(:integer_array, IntegerArray)
|
53
63
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: actionmcp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Abdelkader Boudih
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-02-
|
10
|
+
date: 2025-02-15 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: activemodel
|
@@ -65,6 +65,7 @@ files:
|
|
65
65
|
- Rakefile
|
66
66
|
- exe/action_mcp_stdio
|
67
67
|
- lib/action_mcp.rb
|
68
|
+
- lib/action_mcp/client.rb
|
68
69
|
- lib/action_mcp/configuration.rb
|
69
70
|
- lib/action_mcp/content.rb
|
70
71
|
- lib/action_mcp/content/audio.rb
|
@@ -72,8 +73,8 @@ files:
|
|
72
73
|
- lib/action_mcp/content/resource.rb
|
73
74
|
- lib/action_mcp/content/text.rb
|
74
75
|
- lib/action_mcp/gem_version.rb
|
76
|
+
- lib/action_mcp/integer_array.rb
|
75
77
|
- lib/action_mcp/json_rpc.rb
|
76
|
-
- lib/action_mcp/json_rpc/base.rb
|
77
78
|
- lib/action_mcp/json_rpc/json_rpc_error.rb
|
78
79
|
- lib/action_mcp/json_rpc/notification.rb
|
79
80
|
- lib/action_mcp/json_rpc/request.rb
|
@@ -82,8 +83,11 @@ files:
|
|
82
83
|
- lib/action_mcp/prompts_registry.rb
|
83
84
|
- lib/action_mcp/railtie.rb
|
84
85
|
- lib/action_mcp/registry_base.rb
|
86
|
+
- lib/action_mcp/renderable.rb
|
85
87
|
- lib/action_mcp/resource.rb
|
86
88
|
- lib/action_mcp/resources_bank.rb
|
89
|
+
- lib/action_mcp/server.rb
|
90
|
+
- lib/action_mcp/string_array.rb
|
87
91
|
- lib/action_mcp/tool.rb
|
88
92
|
- lib/action_mcp/tools_registry.rb
|
89
93
|
- lib/action_mcp/transport.rb
|
@@ -103,6 +107,7 @@ licenses:
|
|
103
107
|
metadata:
|
104
108
|
homepage_uri: https://github.com/seuros/action_mcp
|
105
109
|
source_code_uri: https://github.com/seuros/action_mcp
|
110
|
+
rubygems_mfa_required: 'true'
|
106
111
|
rdoc_options: []
|
107
112
|
require_paths:
|
108
113
|
- lib
|
@@ -1,12 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
module JsonRpc
|
5
|
-
private
|
6
|
-
|
7
|
-
def validate_id(id)
|
8
|
-
raise Error, "ID must be a string or number" unless id.is_a?(String) || id.is_a?(Numeric)
|
9
|
-
raise Error, "ID must not be null" if id.nil?
|
10
|
-
end
|
11
|
-
end
|
12
|
-
end
|