actionmcp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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