actionmcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1183f05df43cca3948c84aa105f6e38c5f7cd414c1b38619cfb6c9afa528d442
4
+ data.tar.gz: 02bdfed4f0a3b26d04203430c4b62183c615fb0a47a3b28d281b42f07928e5a8
5
+ SHA512:
6
+ metadata.gz: 77b9a85435b5e19a35268db9fbcc3139222c01abc7c67e5af61a44a460d55fe5ec6b9819209fa8bfd493a3d0c648ba7d7fa5c440f889871556880f5f961a1da0
7
+ data.tar.gz: e77783308752dbbe95498ca9d3548471b44bafbfe72a130f6e070c906220eeeecd48fb8d719f150d2f69a14a416d7f0cba19c3fd33b6479eab54f564633eed73
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Abdelkader Boudih 2024
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # ActionMCP
2
+
3
+ **ActionMCP** is a Ruby gem that provides essential tooling for building Model Context Protocol (MCP) capable servers.
4
+ It offers base classes and helpers for creating MCP applications, making it easier to integrate your Ruby/Rails application with the MCP standard.
5
+ With ActionMCP, you can focus on your app's logic while it handles the boilerplate for MCP compliance.
6
+
7
+ ## Introduction
8
+
9
+ **Model Context Protocol (MCP)** is an open protocol that standardizes how applications provide context to large language models (LLMs) ([Introduction - Model Context Protocol](https://modelcontextprotocol.io/introduction#:~:text=MCP%20is%20an%20open%20protocol,different%20data%20sources%20and%20tools)).
10
+
11
+ Think of it as a universal interface for connecting AI assistants to external data sources and tools.
12
+
13
+ MCP allows AI systems to plug into various resources in a consistent, secure way, enabling two-way integration between your data and AI-powered applications ([Introducing the Model Context Protocol \ Anthropic](https://www.anthropic.com/news/model-context-protocol#:~:text=The%20Model%20Context%20Protocol%20is,that%20connect%20to%20these%20servers)).
14
+
15
+ This means an AI (like an LLM) can request information or actions from your application through a well-defined protocol, and your app can provide context or perform tasks for the AI in return.
16
+
17
+ **ActionMCP** is targeted at developers building MCP-enabled applications.
18
+ It simplifies the process of integrating Ruby and Rails apps with the MCP standard by providing a set of base classes and an easy-to-use server interface.
19
+
20
+ Instead of implementing MCP support from scratch, you can subclass and configure the provided **Prompt**, **Tool**, and **Resource** classes to expose your app’s functionality to LLMs.
21
+
22
+ ActionMCP handles the underlying MCP message format and routing, so you can adhere to the open standard with minimal effort.
23
+
24
+ In short, ActionMCP helps you build an MCP server (the component that exposes capabilities to AI) more quickly and with fewer mistakes.
25
+
26
+ ## Installation
27
+
28
+ To start using ActionMCP, add it to your project:
29
+
30
+ - **Using Bundler (Rails or Ruby projects):** Add the gem to your Gemfile and run bundle install:
31
+
32
+ execute:
33
+ ```
34
+ $ bundle add actionmcp
35
+ ```
36
+
37
+ After installing, include the gem in your code by requiring it:
38
+
39
+ This will load the ActionMCP library so you can start defining MCP prompts, tools, and resources in your application.
40
+
41
+ ## Core Components
42
+
43
+ ActionMCP provides three core abstractions to streamline MCP server development: **Prompt**, **Tool**, and **Resource**.
44
+ These correspond to key MCP concepts and let you define what context or capabilities your server exposes to LLMs.
45
+ Below is an overview of each component and how you might use it:
46
+
47
+ ### ActionMCP::Prompt
48
+
49
+ Make Rails Say Sexy stuff
50
+
51
+ ### ActionMCP::Tool
52
+
53
+ Make Rails Do Sexy stuff and serve beer to Clients.
54
+
55
+ ### ActionMCP::Resource
56
+
57
+ I dont need this for now
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
6
+
7
+ load 'rails/tasks/statistics.rake'
8
+
9
+ require 'bundler/gem_tasks'
File without changes
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Content
5
+ # Audio content includes a base64-encoded audio clip and its MIME type.
6
+ class Audio < Base
7
+ attr_reader :data, :mime_type
8
+
9
+ def initialize(data, mime_type)
10
+ super("audio")
11
+ @data = data
12
+ @mime_type = mime_type
13
+ end
14
+
15
+ def to_h
16
+ super.merge(data: @data, mimeType: @mime_type)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Content
5
+ # Image content includes a base64-encoded image and its MIME type.
6
+ class Image < Base
7
+ attr_reader :data, :mime_type
8
+
9
+ def initialize(data, mime_type)
10
+ super("image")
11
+ @data = data
12
+ @mime_type = mime_type
13
+ end
14
+
15
+ def to_h
16
+ super.merge(data: @data, mimeType: @mime_type)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Content
5
+ # Resource content references a server-managed resource.
6
+ # It includes a URI, MIME type, and optionally text content or a base64-encoded blob.
7
+ class Resource < Base
8
+ attr_reader :uri, :mime_type, :text, :blob
9
+
10
+ def initialize(uri, mime_type, text: nil, blob: nil)
11
+ super("resource")
12
+ @uri = uri
13
+ @mime_type = mime_type
14
+ @text = text
15
+ @blob = blob
16
+ end
17
+
18
+ def to_h
19
+ resource_data = { uri: @uri, mimeType: @mime_type }
20
+ resource_data[:text] = @text if @text
21
+ resource_data[:blob] = @blob if @blob
22
+
23
+ super.merge(resource: resource_data)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Content
5
+ # Text content represents plain text messages.
6
+ class Text < Base
7
+ attr_reader :text
8
+
9
+ def initialize(text)
10
+ super("text")
11
+ @text = text
12
+ end
13
+
14
+ def to_h
15
+ super.merge(text: @text)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ module ActionMCP
2
+ module Content
3
+ extend ActiveSupport::Autoload
4
+ # Base class for MCP content items.
5
+ class Base
6
+ attr_reader :type
7
+
8
+ def initialize(type)
9
+ @type = type
10
+ end
11
+
12
+ def to_h
13
+ { type: @type }
14
+ end
15
+
16
+ def to_json(*args)
17
+ to_h.to_json(*args)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ module ActionMCP
2
+ # Returns the currently loaded version of Active MCP as a +Gem::Version+.
3
+ def self.gem_version
4
+ Gem::Version.new VERSION
5
+ end
6
+ end
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module JsonRpc
5
+ class JsonRpcError < StandardError
6
+ # Define the standard JSON-RPC 2.0 error codes
7
+ ERROR_CODES = {
8
+ parse_error: {
9
+ code: -32_700,
10
+ message: "Parse error"
11
+ },
12
+ invalid_request: {
13
+ code: -32_600,
14
+ message: "Invalid request"
15
+ },
16
+ method_not_found: {
17
+ code: -32_601,
18
+ message: "Method not found"
19
+ },
20
+ invalid_params: {
21
+ code: -32_602,
22
+ message: "Invalid params"
23
+ },
24
+ internal_error: {
25
+ code: -32_603,
26
+ message: "Internal error"
27
+ },
28
+ server_error: {
29
+ code: -32_000,
30
+ message: "Server error"
31
+ }
32
+ }.freeze
33
+
34
+ attr_reader :code, :data
35
+
36
+ # Retrieve error details by symbol.
37
+ def self.[](symbol)
38
+ ERROR_CODES[symbol] or raise ArgumentError, "Unknown error code: #{symbol}"
39
+ end
40
+
41
+ # Build an error hash, allowing custom message or data to override defaults.
42
+ def self.build(symbol, message: nil, data: nil)
43
+ error = self[symbol].dup
44
+ error[:message] = message if message
45
+ error[:data] = data if data
46
+ error
47
+ end
48
+
49
+ # Initialize the error using a symbol key, with optional custom message and data.
50
+ def initialize(symbol, message: nil, data: nil)
51
+ error_details = self.class.build(symbol, message: message, data: data)
52
+ @code = error_details[:code]
53
+ @data = error_details[:data]
54
+ super(error_details[:message])
55
+ end
56
+
57
+ # Returns a hash formatted for a JSON-RPC error response.
58
+ def as_json
59
+ hash = { code: code, message: message }
60
+ hash[:data] = data if data
61
+ hash
62
+ end
63
+
64
+ # Converts the error hash to a JSON string.
65
+ def to_json(*_args)
66
+ as_json.to_json
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module JsonRpc
5
+ Notification = Data.define(:method, :params) do
6
+ def initialize(method:, params: nil)
7
+ super(method: method, params: params)
8
+ end
9
+
10
+ def to_h
11
+ hash = {
12
+ jsonrpc: "2.0",
13
+ method: method
14
+ }
15
+ hash[:params] = params if params
16
+ hash
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module JsonRpc
5
+ Request = Data.define(:jsonrpc, :id, :method, :params) do
6
+ def initialize(id:, method:, params: nil)
7
+ validate_id(id)
8
+ super(id: id, method: method, params: params)
9
+ end
10
+
11
+ def to_h
12
+ hash = {
13
+ jsonrpc: "2.0",
14
+ id: id,
15
+ method: method
16
+ }
17
+ hash[:params] = params if params
18
+ hash
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module JsonRpc
5
+ Response = Data.define(:id, :result, :error) do
6
+ def initialize(id:, result: nil, error: nil)
7
+ processed_error = process_error(error)
8
+ processed_result = error ? nil : result
9
+ validate_result_error!(processed_result, processed_error)
10
+ super(id: id, result: processed_result, error: processed_error)
11
+ end
12
+
13
+ def to_h
14
+ hash = {
15
+ jsonrpc: "2.0",
16
+ id: id
17
+ }
18
+ if error
19
+ hash[:error] = {
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
28
+ end
29
+
30
+ private
31
+
32
+ def process_error(error)
33
+ case error
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)
45
+ end
46
+
47
+ def validate_result_error!(result, error)
48
+ raise Error, "Either result or error must be set" unless result || error
49
+ raise Error, "Cannot set both result and error" if result && error
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module JsonRpc
5
+ extend ActiveSupport::Autoload
6
+
7
+ autoload :Base
8
+ autoload :JsonRpcError
9
+ autoload :Notification
10
+ autoload :Request
11
+ autoload :Response
12
+ end
13
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Abstract base class for Prompts
5
+ # Defines: name, description, arguments, plus auto-registration.
6
+ class Prompt
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+
10
+ class_attribute :_prompt_name, instance_accessor: false
11
+ class_attribute :_description, instance_accessor: false, default: ""
12
+ class_attribute :_argument_definitions, instance_accessor: false, default: []
13
+ class_attribute :abstract_prompt, instance_accessor: false, default: false
14
+
15
+ def self.inherited(subclass)
16
+ super
17
+ return if subclass == Prompt
18
+ return if "ApplicationPrompt" == subclass.name
19
+ subclass.abstract_prompt = false
20
+
21
+ # Automatically register the subclass with the PromptsRegistry
22
+ PromptsRegistry.register(subclass.prompt_name, subclass)
23
+ end
24
+
25
+ def self.abstract!
26
+ self.abstract_prompt = true
27
+ # If already registered, you might want to unregister it here.
28
+ end
29
+
30
+ def self.abstract?
31
+ self.abstract_prompt
32
+ end
33
+
34
+ # ---------------------------------------------------
35
+ # Prompt Name
36
+ # ---------------------------------------------------
37
+ def self.prompt_name(name = nil)
38
+ if name
39
+ self._prompt_name = name
40
+ else
41
+ _prompt_name || default_prompt_name
42
+ end
43
+ end
44
+
45
+ def self.default_prompt_name
46
+ name.demodulize.underscore.dasherize.sub(/-prompt$/, "")
47
+ end
48
+
49
+ # ---------------------------------------------------
50
+ # Description
51
+ # ---------------------------------------------------
52
+ def self.description(text = nil)
53
+ if text
54
+ self._description = text
55
+ else
56
+ _description
57
+ end
58
+ end
59
+
60
+ # ---------------------------------------------------
61
+ # Argument DSL
62
+ # ---------------------------------------------------
63
+ def self.argument(arg_name, description: "", required: false, default: nil)
64
+ arg_def = {
65
+ name: arg_name.to_s,
66
+ description: description,
67
+ required: required,
68
+ default: default
69
+ }
70
+ self._argument_definitions += [ arg_def ]
71
+
72
+ # Register the attribute so it's recognized by ActiveModel
73
+ attribute arg_name, :string, default: default
74
+ end
75
+
76
+ def self.arguments
77
+ _argument_definitions
78
+ end
79
+
80
+ # ---------------------------------------------------
81
+ # Convert prompt definition to Hash
82
+ # ---------------------------------------------------
83
+ def self.to_h
84
+ {
85
+ name: prompt_name,
86
+ description: description.presence,
87
+ arguments: arguments.map { |arg| arg.slice(:name, :description, :required) }
88
+ }.compact
89
+ end
90
+
91
+ # ---------------------------------------------------
92
+ # Class-level call method
93
+ # ---------------------------------------------------
94
+ # Receives a Hash of params, initializes a prompt instance,
95
+ # validates it, and if valid, calls the instance call method.
96
+ # If invalid, raises a JsonRpcError with code :invalid_params.
97
+ #
98
+ # Usage:
99
+ # result = MyPromptClass.call(params)
100
+ #
101
+ # Raises:
102
+ # ActionMCP::JsonRpc::JsonRpcError(:invalid_params) if validation fails.
103
+ #
104
+ def self.call(params)
105
+ prompt = new(params) # Initialize an instance with provided params
106
+ unless prompt.valid?
107
+ # Collect all validation errors into a single string or array
108
+ errors_str = prompt.errors.full_messages.join(", ")
109
+
110
+ raise ActionMCP::JsonRpc::JsonRpcError.new(
111
+ :invalid_params,
112
+ message: "Prompt validation failed: #{errors_str}",
113
+ data: { errors: prompt.errors }
114
+ )
115
+ end
116
+
117
+ # If we reach here, the prompt is valid
118
+ prompt.call
119
+ end
120
+
121
+ # ---------------------------------------------------
122
+ # Instance call method
123
+ # ---------------------------------------------------
124
+ # By default, does nothing. Override in your subclasses to
125
+ # perform custom prompt processing. (Return a payload if needed)
126
+ #
127
+ # Usage: Called internally after validation in self.call
128
+ #
129
+ def call
130
+ raise NotImplementedError, "Subclasses must implement the call method"
131
+ # Default implementation (no-op)
132
+ # In a real subclass, you might do:
133
+ # def call
134
+ # # Perform logic, e.g. analyze code, etc.
135
+ # # Return something meaningful.
136
+ # end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class PromptsRegistry < RegistryBase
5
+ class << self
6
+ alias_method :prompts, :items
7
+ alias_method :available_prompts, :enabled
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ require "rails"
2
+ require "active_model/railtie"
3
+
4
+ module ActionMCP
5
+ class Railtie < Rails::Railtie # :nodoc:
6
+ # TODO: fix this to be a proper railtie if you going to to opensource it
7
+ initializer "action_mcp.clear_registry" do |app|
8
+ app.config.to_prepare do
9
+ ActionMCP::ToolsRegistry.clear!
10
+ ActionMCP::PromptsRegistry.clear!
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class RegistryBase
5
+ class << self
6
+ def items
7
+ @items ||= {}
8
+ end
9
+
10
+ # Register an item by unique name
11
+ def register(name, item_class)
12
+ raise ArgumentError, "Name can't be blank" if name.blank?
13
+ raise ArgumentError, "Name '#{name}' is already registered." if items.key?(name)
14
+
15
+ items[name] = { class: item_class, enabled: true }
16
+ end
17
+
18
+ # Fetch an item’s metadata
19
+ # Returns { class: <Class>, enabled: <Boolean> } or nil
20
+ def fetch(name)
21
+ items[name]
22
+ end
23
+
24
+ # Number of registered items, ignoring abstract ones.
25
+ def size
26
+ items.values.reject { |item| abstract_item?(item) }.size
27
+ end
28
+
29
+ def unregister(name)
30
+ items.delete(name)
31
+ end
32
+
33
+ def clear!
34
+ items.clear
35
+ end
36
+
37
+ # List of currently available items, excluding abstract ones.
38
+ def enabled
39
+ items
40
+ .reject { |_name, item| item[:class].abstract? }
41
+ .select { |_name, item| item[:enabled] }
42
+ end
43
+
44
+ def fetch_available_tool(name)
45
+ enabled[name]&.fetch(:class)
46
+ end
47
+
48
+ # Enable an item by name
49
+ def enable(name)
50
+ raise ArgumentError, "Name '#{name}' not found." unless items.key?(name)
51
+
52
+ items[name][:enabled] = true
53
+ end
54
+
55
+ # Disable an item by name
56
+ def disable(name)
57
+ raise ArgumentError, "Name '#{name}' not found." unless items.key?(name)
58
+
59
+ items[name][:enabled] = false
60
+ end
61
+
62
+ private
63
+
64
+ # Helper to determine if an item is abstract.
65
+ def abstract_item?(item)
66
+ klass = item[:class]
67
+ klass.respond_to?(:abstract?) && klass.abstract?
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ Resource = Data.define(:uri, :name, :description, :mime_type, :size) do
5
+ # Convert the resource to a hash with the keys expected by MCP.
6
+ # Note: The key for mime_type is converted to 'mimeType' as specified.
7
+ def to_h
8
+ hash = { uri: uri, name: name }
9
+ hash[:description] = description if description
10
+ hash[:mimeType] = mime_type if mime_type
11
+ hash[:size] = size if size
12
+ hash
13
+ end
14
+
15
+ # Convert the resource to a JSON string.
16
+ def to_json(*args)
17
+ to_h.to_json(*args)
18
+ end
19
+ end
20
+ end