actionmcp 0.1.2 → 0.2.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.
- 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
|