actionmodelcontext 2026.11.02.161253
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +307 -0
- data/lib/action_model_context/error.rb +9 -0
- data/lib/action_model_context/error_reporter.rb +30 -0
- data/lib/action_model_context/naming.rb +13 -0
- data/lib/action_model_context/primitive.rb +113 -0
- data/lib/action_model_context/prompt.rb +122 -0
- data/lib/action_model_context/protected_resource_metadata_middleware.rb +28 -0
- data/lib/action_model_context/railtie.rb +35 -0
- data/lib/action_model_context/resource.rb +133 -0
- data/lib/action_model_context/server.rb +166 -0
- data/lib/action_model_context/test_helper.rb +61 -0
- data/lib/action_model_context/tool.rb +196 -0
- data/lib/action_model_context/translation.rb +30 -0
- data/lib/action_model_context/version.rb +3 -0
- data/lib/action_model_context.rb +14 -0
- data/lib/actionmodelcontext.rb +1 -0
- data/lib/generators/action_model_context/argument.rb +57 -0
- data/lib/generators/action_model_context/install/install_generator.rb +31 -0
- data/lib/generators/action_model_context/install/templates/application_prompt.rb.tt +2 -0
- data/lib/generators/action_model_context/install/templates/application_resource.rb.tt +2 -0
- data/lib/generators/action_model_context/install/templates/application_tool.rb.tt +2 -0
- data/lib/generators/action_model_context/install/templates/mcp_controller.rb.tt +8 -0
- data/lib/generators/action_model_context/prompt/prompt_generator.rb +47 -0
- data/lib/generators/action_model_context/prompt/templates/prompt.rb.tt +10 -0
- data/lib/generators/action_model_context/resource/resource_generator.rb +47 -0
- data/lib/generators/action_model_context/resource/templates/resource.rb.tt +8 -0
- data/lib/generators/action_model_context/tool/templates/tool.rb.tt +10 -0
- data/lib/generators/action_model_context/tool/tool_generator.rb +47 -0
- data/lib/generators/rspec/mcp_prompt/mcp_prompt_generator.rb +20 -0
- data/lib/generators/rspec/mcp_prompt/templates/prompt_spec.rb.tt +11 -0
- data/lib/generators/rspec/mcp_resource/mcp_resource_generator.rb +17 -0
- data/lib/generators/rspec/mcp_resource/templates/resource_spec.rb.tt +11 -0
- data/lib/generators/rspec/mcp_tool/mcp_tool_generator.rb +27 -0
- data/lib/generators/rspec/mcp_tool/templates/tool_spec.rb.tt +14 -0
- data/lib/generators/test_unit/mcp_prompt/mcp_prompt_generator.rb +20 -0
- data/lib/generators/test_unit/mcp_prompt/templates/prompt_test.rb.tt +11 -0
- data/lib/generators/test_unit/mcp_resource/mcp_resource_generator.rb +17 -0
- data/lib/generators/test_unit/mcp_resource/templates/resource_test.rb.tt +11 -0
- data/lib/generators/test_unit/mcp_tool/mcp_tool_generator.rb +27 -0
- data/lib/generators/test_unit/mcp_tool/templates/tool_test.rb.tt +14 -0
- metadata +130 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module ActionModelContext
|
|
2
|
+
# Autoloads `app/mcp`, reports failures through {ErrorReporter} and sets up `config.action_model_context.oauth`.
|
|
3
|
+
#
|
|
4
|
+
# @api private
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
config.action_model_context = ActiveSupport::OrderedOptions.new
|
|
7
|
+
config.action_model_context.oauth = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
9
|
+
initializer 'action_model_context.autoload' do |app|
|
|
10
|
+
Rails.autoloaders.main.collapse(*%w[tools resources prompts].map { |dir| app.root.join('app/mcp', dir).to_s })
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer 'action_model_context.exception_reporter' do
|
|
14
|
+
MCP.configure { |config| config.exception_reporter = ErrorReporter }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer 'action_model_context.oauth', after: :load_config_initializers do |app|
|
|
18
|
+
oauth = app.config.action_model_context.oauth
|
|
19
|
+
raise ArgumentError, 'config.action_model_context.oauth.token_verifier requires oauth.resource' if oauth.token_verifier && !oauth.resource
|
|
20
|
+
next unless oauth.resource
|
|
21
|
+
|
|
22
|
+
oauth.metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new(
|
|
23
|
+
resource: oauth.resource, authorization_servers: oauth.authorization_servers, scopes_supported: oauth.scopes,
|
|
24
|
+
resource_name: oauth.resource_name, resource_documentation: oauth.resource_documentation, extra: oauth.extra || {},
|
|
25
|
+
)
|
|
26
|
+
app.middleware.use ProtectedResourceMetadataMiddleware, oauth.metadata
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The verifier may name an app class, which cannot autoload while initializers run: DoorkeeperTokenVerifier in app/mcp.
|
|
30
|
+
config.to_prepare do
|
|
31
|
+
oauth = Rails.application.config.action_model_context.oauth
|
|
32
|
+
oauth.verifier = oauth.token_verifier&.call(oauth.metadata)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require 'active_support/core_ext/class/attribute'
|
|
2
|
+
|
|
3
|
+
module ActionModelContext
|
|
4
|
+
# Base class of the resources. An application subclasses it through `ApplicationResource`, under `app/mcp/resources`.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# class UserResource < ApplicationResource
|
|
8
|
+
# uri 'users://{id}'
|
|
9
|
+
#
|
|
10
|
+
# def read(id:) = { id:, viewer: context[:user] }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
class Resource
|
|
15
|
+
include Primitive
|
|
16
|
+
|
|
17
|
+
primitive kind: :resources, suffix: 'Resource'
|
|
18
|
+
|
|
19
|
+
# @api private
|
|
20
|
+
URI_VARIABLE = /\{([A-Za-z_]\w*)\}/
|
|
21
|
+
|
|
22
|
+
class_attribute :declared_mime_type, instance_accessor: false, instance_predicate: false
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Declares the URI, or reads it. A `{variable}` makes the resource a template, and subclasses do not inherit it.
|
|
26
|
+
#
|
|
27
|
+
# @param value [String, nil] the URI to declare
|
|
28
|
+
# @return [String, nil] nil when the class declares none
|
|
29
|
+
def uri(value = nil)
|
|
30
|
+
return @declared_uri = value if value
|
|
31
|
+
|
|
32
|
+
@declared_uri
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Declares the MIME type, or reads it. Subclasses inherit it, and it overrides the one derived from {#read}.
|
|
36
|
+
#
|
|
37
|
+
# @param value [String, nil] the MIME type to declare
|
|
38
|
+
# @return [String, nil] nil when none is declared
|
|
39
|
+
def mime_type(value = nil)
|
|
40
|
+
return self.declared_mime_type = value if value
|
|
41
|
+
|
|
42
|
+
declared_mime_type
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Declares the name, or reads it.
|
|
46
|
+
#
|
|
47
|
+
# @param name [String, nil] the name to declare
|
|
48
|
+
# @return [String, nil] the declared name, else the underscored class name without its `Resource` suffix
|
|
49
|
+
def resource_name(name = nil) = primitive_name(name)
|
|
50
|
+
|
|
51
|
+
# Tells whether the resource is exposed, which takes its own {uri} and a `read` below this class.
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
# @api private
|
|
55
|
+
def concrete? = !uri.nil? && instance_method(:read).owner != Resource
|
|
56
|
+
|
|
57
|
+
# Tells whether the URI holds variables.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
# @api private
|
|
61
|
+
def template? = uri.include?('{')
|
|
62
|
+
|
|
63
|
+
# Lists the variables of the URI.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Symbol>]
|
|
66
|
+
# @api private
|
|
67
|
+
def uri_variables = uri.scan(URI_VARIABLE).flatten.map(&:to_sym)
|
|
68
|
+
|
|
69
|
+
# Builds the SDK resource or resource template for a request.
|
|
70
|
+
#
|
|
71
|
+
# @param context [Object] the context of the request
|
|
72
|
+
# @return [Class<MCP::Resource>, Class<MCP::ResourceTemplate>]
|
|
73
|
+
# @api private
|
|
74
|
+
def to_mcp(context:)
|
|
75
|
+
resource = self
|
|
76
|
+
if template?
|
|
77
|
+
MCP::ResourceTemplate.define(uri_template: uri, **definition) do |**params|
|
|
78
|
+
resource.new(context:).read_mcp(params.except(:server_context))
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
MCP::Resource.define(uri:, **definition) { |**| resource.new(context:).read_mcp({}) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Lists the fields common to a resource and a resource template.
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash]
|
|
90
|
+
# @api private
|
|
91
|
+
def definition = { name: resource_name, title:, description:, mime_type: }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reads the resource. A subclass defines it, with one keyword per variable of the URI.
|
|
95
|
+
#
|
|
96
|
+
# @return [String, Hash, Array] the contents: a `String` goes out as `text/plain`, the others as `application/json`
|
|
97
|
+
# @raise [Error] to answer an `invalid_params` JSON-RPC error
|
|
98
|
+
# @raise [NotImplementedError] when no subclass defines it
|
|
99
|
+
def read(**) = raise(NotImplementedError, "#{self.class.name}#read is not implemented")
|
|
100
|
+
|
|
101
|
+
# Reads the resource for the SDK.
|
|
102
|
+
#
|
|
103
|
+
# @param params [Hash] the variables of the requested URI
|
|
104
|
+
# @return [MCP::Resource::TextContents]
|
|
105
|
+
# @raise [MCP::Server::RequestHandlerError] when the resource raises {Error}
|
|
106
|
+
# @api private
|
|
107
|
+
def read_mcp(params)
|
|
108
|
+
params = params.slice(*self.class.uri_variables)
|
|
109
|
+
to_contents(with_rescue { read(**params) }, params)
|
|
110
|
+
rescue Error => e
|
|
111
|
+
raise_invalid_params(e)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Converts the value of {#read}.
|
|
117
|
+
#
|
|
118
|
+
# @param result [String, Hash, Array] that value
|
|
119
|
+
# @param params [Hash] the variables that expand the URI
|
|
120
|
+
# @return [MCP::Resource::TextContents]
|
|
121
|
+
# @raise [TypeError] when the value is of another class
|
|
122
|
+
# @api private
|
|
123
|
+
def to_contents(result, params)
|
|
124
|
+
text, default_mime = case result
|
|
125
|
+
when String then [ result, 'text/plain' ]
|
|
126
|
+
when Hash, Array then [ result.to_json, 'application/json' ]
|
|
127
|
+
else raise TypeError, "#{self.class.name}#read or its rescue_from handler must return a String, Hash or Array, got #{result.class}"
|
|
128
|
+
end
|
|
129
|
+
uri = self.class.uri.gsub(URI_VARIABLE) { params.fetch(Regexp.last_match(1).to_sym) }
|
|
130
|
+
MCP::Resource::TextContents.new(text:, uri:, mime_type: self.class.mime_type || default_mime)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require 'active_support/concern'
|
|
2
|
+
|
|
3
|
+
module ActionModelContext
|
|
4
|
+
# Controller concern that serves MCP over the SDK's Streamable HTTP transport, stateless with JSON responses.
|
|
5
|
+
# It discovers the primitives through `ApplicationTool`, `ApplicationResource` and `ApplicationPrompt`.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# class McpController < ApplicationController
|
|
9
|
+
# include ActionModelContext::Server
|
|
10
|
+
# before_action :authenticate_user!
|
|
11
|
+
#
|
|
12
|
+
# private
|
|
13
|
+
#
|
|
14
|
+
# def mcp_context = { user: current_user }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
18
|
+
module Server
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
skip_forgery_protection only: %i[show create destroy] if respond_to?(:skip_forgery_protection)
|
|
23
|
+
before_action :verify_mcp_access_token, only: %i[show create destroy], if: -> { mcp_oauth.verifier }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Loads a family of primitives and lists its concrete classes.
|
|
27
|
+
#
|
|
28
|
+
# @param base_name [String] the name of the family's base class
|
|
29
|
+
# @param dir [String] its directory under `app/mcp`
|
|
30
|
+
# @return [Array<Class>] empty when the base class does not exist
|
|
31
|
+
# @api private
|
|
32
|
+
def self.discover(base_name, dir)
|
|
33
|
+
path = Rails.root.join('app/mcp', dir)
|
|
34
|
+
Rails.autoloaders.main.eager_load_dir(path) if path.directory?
|
|
35
|
+
base_name.safe_constantize&.descendants&.select(&:concrete?) || []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Serves an MCP `GET`.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @api private
|
|
42
|
+
def show = serve_mcp
|
|
43
|
+
# Serves an MCP `POST`.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
# @api private
|
|
47
|
+
def create = serve_mcp
|
|
48
|
+
# Serves an MCP `DELETE`.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @api private
|
|
52
|
+
def destroy = serve_mcp
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Gives every primitive its `context` for the request: override this private method in the controller.
|
|
57
|
+
#
|
|
58
|
+
# @!visibility public
|
|
59
|
+
# @return [Object] an empty hash by default
|
|
60
|
+
def mcp_context = {}
|
|
61
|
+
# Gives the keyword arguments of `MCP::Server.new`: override this private method in the controller.
|
|
62
|
+
#
|
|
63
|
+
# @!visibility public
|
|
64
|
+
# @return [Hash] the underscored module name of the app as `name:` by default
|
|
65
|
+
def mcp_server_options = { name: Rails.application.class.module_parent_name.underscore }
|
|
66
|
+
# Gives the keyword arguments of `MCP::Server::Transports::StreamableHTTPTransport.new`: override this private
|
|
67
|
+
# method in the controller. `stateless`, `enable_json_response` and `serve_subscriptions_listen` stay pinned.
|
|
68
|
+
#
|
|
69
|
+
# @!visibility public
|
|
70
|
+
# @return [Hash] empty by default
|
|
71
|
+
def mcp_transport_options = {}
|
|
72
|
+
|
|
73
|
+
# Answers the SDK's 401 challenge. Call this private method when the app itself rejects a request.
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# before_action { request_mcp_authentication unless user_signed_in? }
|
|
77
|
+
#
|
|
78
|
+
# @!visibility public
|
|
79
|
+
# @return [void]
|
|
80
|
+
def request_mcp_authentication
|
|
81
|
+
render_mcp_challenge(MCP::Server::OAuth::Challenge.missing_token_response(scope: mcp_oauth.scopes&.join(' '), resource_metadata: mcp_resource_metadata_url))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Gives the token verified through `config.action_model_context.oauth.token_verifier`.
|
|
85
|
+
#
|
|
86
|
+
# @!visibility public
|
|
87
|
+
# @return [MCP::Server::OAuth::AccessToken, nil] nil when no token was verified
|
|
88
|
+
def mcp_access_token = request.env[MCP::Server::OAuth::ENV_KEY]
|
|
89
|
+
|
|
90
|
+
# Checks the bearer token, and answers the SDK's challenge when it is rejected.
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
# @api private
|
|
94
|
+
def verify_mcp_access_token
|
|
95
|
+
authenticator = MCP::Server::OAuth::Authenticator.new(
|
|
96
|
+
token_verifier: mcp_oauth.verifier, required_scopes: mcp_oauth.scopes,
|
|
97
|
+
resource_metadata: mcp_oauth.metadata, resource_metadata_url: mcp_resource_metadata_url,
|
|
98
|
+
)
|
|
99
|
+
request.env[MCP::Server::OAuth::ENV_KEY] = authenticator.authenticate(request.env)
|
|
100
|
+
rescue MCP::Server::OAuth::Error => e
|
|
101
|
+
render_mcp_challenge(authenticator.challenge_response(e))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reads `config.action_model_context.oauth`.
|
|
105
|
+
#
|
|
106
|
+
# @return [ActiveSupport::OrderedOptions]
|
|
107
|
+
# @api private
|
|
108
|
+
def mcp_oauth = Rails.configuration.action_model_context.oauth
|
|
109
|
+
|
|
110
|
+
# A proxy can strip the path prefix and pass on only X-Forwarded-Host, leaving SCRIPT_NAME empty:
|
|
111
|
+
# the public URL declared in resource is then the one place that still holds the prefix.
|
|
112
|
+
#
|
|
113
|
+
# @return [String] the URL of the metadata document
|
|
114
|
+
# @api private
|
|
115
|
+
def mcp_resource_metadata_url
|
|
116
|
+
resource = mcp_oauth.resource
|
|
117
|
+
return "#{resource.delete_suffix(request.path_info)}#{ProtectedResourceMetadataMiddleware::PATH}" if resource&.end_with?(request.path_info)
|
|
118
|
+
|
|
119
|
+
"#{request.base_url}#{request.script_name}#{ProtectedResourceMetadataMiddleware::PATH}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Answers with a challenge built by the SDK.
|
|
123
|
+
#
|
|
124
|
+
# @param challenge [Array(Integer, Hash, Array)] the Rack response
|
|
125
|
+
# @return [void]
|
|
126
|
+
# @api private
|
|
127
|
+
def render_mcp_challenge(challenge)
|
|
128
|
+
self.status, challenge_headers, self.response_body = challenge
|
|
129
|
+
headers.merge!(challenge_headers)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# handle_request, not call(env): Rails already read the body into RAW_POST_DATA to log params, and only
|
|
133
|
+
# ActionDispatch::Request#body replays it when rack.input cannot rewind (optional since Rack 3).
|
|
134
|
+
#
|
|
135
|
+
# @return [void]
|
|
136
|
+
# @api private
|
|
137
|
+
def serve_mcp
|
|
138
|
+
self.status, transport_headers, self.response_body = mcp_transport.handle_request(request)
|
|
139
|
+
headers.merge!(transport_headers)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Builds the server and its transport for the request.
|
|
143
|
+
#
|
|
144
|
+
# @return [MCP::Server::Transports::StreamableHTTPTransport]
|
|
145
|
+
# @api private
|
|
146
|
+
def mcp_transport
|
|
147
|
+
context = mcp_context
|
|
148
|
+
templates, resources = Server.discover('ApplicationResource', 'resources').partition(&:template?)
|
|
149
|
+
server = MCP::Server.new(
|
|
150
|
+
**mcp_server_options,
|
|
151
|
+
tools: Server.discover('ApplicationTool', 'tools').map { |tool| tool.to_mcp(context:) },
|
|
152
|
+
resources: resources.map { |resource| resource.to_mcp(context:) },
|
|
153
|
+
resource_templates: templates.map { |template| template.to_mcp(context:) },
|
|
154
|
+
prompts: Server.discover('ApplicationPrompt', 'prompts').map { |prompt| prompt.to_mcp(context:) },
|
|
155
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
156
|
+
)
|
|
157
|
+
# Host checking is left to Rails' config.hosts: the transport's own check rejects every Host but 127.0.0.1,
|
|
158
|
+
# ::1 and localhost. The structural options come last because stateless: false starts one session reaper
|
|
159
|
+
# thread per request that nothing ever closes.
|
|
160
|
+
MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
161
|
+
server, dns_rebinding_protection: false, **mcp_transport_options,
|
|
162
|
+
stateless: true, enable_json_response: true, serve_subscriptions_listen: false,
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require 'action_model_context'
|
|
2
|
+
|
|
3
|
+
module ActionModelContext
|
|
4
|
+
# Runs a tool in a test through a real `MCP::Server`, so argument validation and error handling behave as in
|
|
5
|
+
# production.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# include ActionModelContext::TestHelper
|
|
9
|
+
#
|
|
10
|
+
# result = call_tool(EchoTool, message: 'hi', times: 2)
|
|
11
|
+
# result.text #=> 'hihi'
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
module TestHelper
|
|
15
|
+
# What {TestHelper#call_tool} returns.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] text
|
|
18
|
+
# The text parts of the result, joined.
|
|
19
|
+
# @return [String]
|
|
20
|
+
# @api public
|
|
21
|
+
# @!attribute [r] structured_content
|
|
22
|
+
# The structured content of the result.
|
|
23
|
+
# @return [Hash, nil]
|
|
24
|
+
# @api public
|
|
25
|
+
# @!attribute [r] error
|
|
26
|
+
# Whether the tool answered an error result.
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
# @api public
|
|
29
|
+
Result = Data.define(:text, :structured_content, :error) do
|
|
30
|
+
# Tells whether the tool answered an error result.
|
|
31
|
+
#
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def error? = error
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Calls a tool. An unexpected exception raised by the tool is raised again here.
|
|
37
|
+
#
|
|
38
|
+
# @param tool_class [Class] a subclass of {Tool}
|
|
39
|
+
# @param context [Object] the context given to the tool
|
|
40
|
+
# @param arguments [Hash] the arguments of the tool
|
|
41
|
+
# @return [Result]
|
|
42
|
+
def call_tool(tool_class, context: {}, **arguments)
|
|
43
|
+
raised = []
|
|
44
|
+
server = MCP::Server.new(
|
|
45
|
+
tools: [ tool_class.to_mcp(context:) ],
|
|
46
|
+
configuration: MCP::Configuration.new(exception_reporter: ->(exception, _) { raised << exception }),
|
|
47
|
+
)
|
|
48
|
+
response = server.handle(
|
|
49
|
+
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: tool_class.tool_name, arguments: } },
|
|
50
|
+
)
|
|
51
|
+
raise raised.first if response[:error] && raised.any?
|
|
52
|
+
|
|
53
|
+
result = response.fetch(:result)
|
|
54
|
+
Result.new(
|
|
55
|
+
text: result[:content].filter_map { |part| part[:text] if part[:type] == 'text' }.join,
|
|
56
|
+
structured_content: result[:structuredContent],
|
|
57
|
+
error: result[:isError] == true,
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
require 'active_support/core_ext/class/attribute'
|
|
2
|
+
require 'active_support/rescuable'
|
|
3
|
+
require 'active_support/core_ext/string/inflections'
|
|
4
|
+
require 'active_support/core_ext/enumerable'
|
|
5
|
+
require 'active_support/core_ext/object/blank'
|
|
6
|
+
|
|
7
|
+
module ActionModelContext
|
|
8
|
+
# Base class of the tools. An application subclasses it through `ApplicationTool`, under `app/mcp/tools`.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class EchoTool < ApplicationTool
|
|
12
|
+
# argument :message, :string, required: true
|
|
13
|
+
# argument :times, :integer
|
|
14
|
+
#
|
|
15
|
+
# def call(message:, times: 1) = message * times
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @api public
|
|
19
|
+
class Tool
|
|
20
|
+
include Primitive
|
|
21
|
+
|
|
22
|
+
primitive kind: :tools, suffix: 'Tool'
|
|
23
|
+
|
|
24
|
+
# The types {argument} accepts.
|
|
25
|
+
ARGUMENT_TYPES = %i[string integer number boolean array object].freeze
|
|
26
|
+
# @api private
|
|
27
|
+
ARGUMENT_OPTIONS = %i[required null].freeze
|
|
28
|
+
# JSON Schema 2020-12, the dialect MCP mandates (SEP-1613), minus type and required that argument sets itself.
|
|
29
|
+
SCHEMA_KEYWORDS = %w[
|
|
30
|
+
$id $schema $ref $anchor $dynamicRef $dynamicAnchor $vocabulary $comment $defs
|
|
31
|
+
prefixItems items contains additionalProperties properties patternProperties dependentSchemas propertyNames
|
|
32
|
+
if then else allOf anyOf oneOf not unevaluatedItems unevaluatedProperties
|
|
33
|
+
const enum multipleOf maximum exclusiveMaximum minimum exclusiveMinimum maxLength minLength pattern
|
|
34
|
+
maxItems minItems uniqueItems maxContains minContains maxProperties minProperties dependentRequired
|
|
35
|
+
title description default deprecated readOnly writeOnly examples format contentEncoding contentMediaType contentSchema
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
class_attribute :declared_annotations, instance_accessor: false, instance_predicate: false
|
|
39
|
+
class_attribute :arguments, instance_accessor: false, instance_predicate: false, default: {}.freeze
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Declares the name, or reads it.
|
|
43
|
+
#
|
|
44
|
+
# @param name [String, nil] the name to declare
|
|
45
|
+
# @return [String, nil] the declared name, else the underscored class name without its `Tool` suffix
|
|
46
|
+
def tool_name(name = nil) = primitive_name(name)
|
|
47
|
+
|
|
48
|
+
# Declares an argument of the input schema.
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# argument :ids, :array, required: true, items: { type: 'integer' }, max_items: 50
|
|
52
|
+
#
|
|
53
|
+
# @param name [Symbol, String] the argument's name
|
|
54
|
+
# @param type [Symbol] one of {ARGUMENT_TYPES}
|
|
55
|
+
# @param keywords [Hash] any JSON Schema 2020-12 keyword, in snake_case or camelCase
|
|
56
|
+
# @option keywords [Boolean] :required (false) whether the client must send it
|
|
57
|
+
# @option keywords [Boolean] :null (false) whether it also accepts `null`
|
|
58
|
+
# @return [void]
|
|
59
|
+
# @raise [ArgumentError] when the type or a keyword is unknown
|
|
60
|
+
def argument(name, type, **keywords)
|
|
61
|
+
raise ArgumentError, "unknown argument type #{type.inspect}" unless ARGUMENT_TYPES.include?(type)
|
|
62
|
+
|
|
63
|
+
unknown = keywords.keys.reject { |key| ARGUMENT_OPTIONS.include?(key) || SCHEMA_KEYWORDS.include?(key.to_s.camelize(:lower)) }
|
|
64
|
+
raise ArgumentError, "unknown JSON Schema keyword #{unknown.first.inspect} for argument #{name.inspect}" if unknown.any?
|
|
65
|
+
|
|
66
|
+
self.arguments = arguments.merge(name.to_sym => keywords.merge(type:)).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Declares one argument per entry of a JSON Schema `properties` hash, `type: [..., 'null']` becoming `null: true`.
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# arguments_from({ title: { type: 'string' }, due_on: { type: %w[string null] } }, required: %i[title])
|
|
73
|
+
#
|
|
74
|
+
# @param properties [Hash{Symbol, String => Hash}] the schema of each argument
|
|
75
|
+
# @param required [Array<Symbol, String>] the names of the required arguments
|
|
76
|
+
# @return [void]
|
|
77
|
+
# @raise [ArgumentError] when a property has no single type, lists its own `required` or takes an unknown keyword
|
|
78
|
+
def arguments_from(properties, required: [])
|
|
79
|
+
required = required.map(&:to_s)
|
|
80
|
+
properties.each do |name, schema|
|
|
81
|
+
schema = schema.symbolize_keys
|
|
82
|
+
raise ArgumentError, "argument #{name.to_sym.inspect} lists its own required properties, which argument cannot express" if schema.key?(:required)
|
|
83
|
+
|
|
84
|
+
types = Array(schema[:type]).map(&:to_s)
|
|
85
|
+
null = types.delete('null').present?
|
|
86
|
+
raise ArgumentError, "argument #{name.to_sym.inspect} needs a single type, got #{schema[:type].inspect}" unless types.one?
|
|
87
|
+
|
|
88
|
+
argument(name, types.first.to_sym, required: required.include?(name.to_s), null:, **schema.except(:type))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Lists the declared arguments.
|
|
93
|
+
#
|
|
94
|
+
# @return [Array<Symbol>]
|
|
95
|
+
# @api private
|
|
96
|
+
def argument_names = arguments.keys
|
|
97
|
+
|
|
98
|
+
# Tells whether the tool is exposed, which takes a `call` below this class.
|
|
99
|
+
#
|
|
100
|
+
# @return [Boolean]
|
|
101
|
+
# @api private
|
|
102
|
+
def concrete? = instance_method(:call).owner != Tool
|
|
103
|
+
|
|
104
|
+
# Declares the annotations, or reads them.
|
|
105
|
+
#
|
|
106
|
+
# @param hints [Hash] the keywords of `MCP::Tool::Annotations`, such as `read_only_hint: true`
|
|
107
|
+
# @return [Hash, nil] nil when none are declared
|
|
108
|
+
def annotations(**hints)
|
|
109
|
+
return declared_annotations if hints.empty?
|
|
110
|
+
|
|
111
|
+
self.declared_annotations = hints
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Builds the SDK tool for a request.
|
|
115
|
+
#
|
|
116
|
+
# @param context [Object] the context of the request
|
|
117
|
+
# @return [Class<MCP::Tool>]
|
|
118
|
+
# @api private
|
|
119
|
+
def to_mcp(context:)
|
|
120
|
+
tool = self
|
|
121
|
+
MCP::Tool.define(
|
|
122
|
+
name: tool_name, title:, description:, input_schema:, annotations: declared_annotations,
|
|
123
|
+
) { |**args| tool.new(context:).call_mcp(args.except(:server_context)) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Builds the JSON Schema of the arguments.
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash]
|
|
131
|
+
# @api private
|
|
132
|
+
def input_schema
|
|
133
|
+
properties = arguments.to_h { |name, spec| [ name, property_schema(name, spec) ] }
|
|
134
|
+
required = arguments.select { |_, spec| spec[:required] }.keys.map(&:to_s)
|
|
135
|
+
{ type: 'object', properties:, required: }.compact_blank
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Builds the JSON Schema of an argument, its description falling back on I18n.
|
|
139
|
+
#
|
|
140
|
+
# @param name [Symbol] the argument's name
|
|
141
|
+
# @param spec [Hash] its declaration
|
|
142
|
+
# @return [Hash]
|
|
143
|
+
# @api private
|
|
144
|
+
def property_schema(name, spec)
|
|
145
|
+
type = spec[:null] ? [ spec[:type].to_s, 'null' ] : spec[:type].to_s
|
|
146
|
+
keywords = spec.except(*ARGUMENT_OPTIONS).transform_keys { |key| key.to_s.camelize(:lower).to_sym }
|
|
147
|
+
description = keywords[:description] || Translation.argument(i18n_kind, tool_name, name, :description)
|
|
148
|
+
{ **keywords, type:, description: }.compact
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Runs the tool. A subclass defines it, with one keyword per argument it takes.
|
|
153
|
+
#
|
|
154
|
+
# @return [String, Hash, Array, MCP::Tool::Response] the result, a `Hash` also going out as structured content
|
|
155
|
+
# @raise [Error] to answer an error result with its message
|
|
156
|
+
# @raise [NotImplementedError] when no subclass defines it
|
|
157
|
+
def call(**) = raise(NotImplementedError, "#{self.class.name}#call is not implemented")
|
|
158
|
+
|
|
159
|
+
# Runs the tool for the SDK.
|
|
160
|
+
#
|
|
161
|
+
# @param arguments [Hash] the arguments the client sent
|
|
162
|
+
# @return [MCP::Tool::Response]
|
|
163
|
+
# @api private
|
|
164
|
+
def call_mcp(arguments)
|
|
165
|
+
to_response(with_rescue { invoke(arguments) })
|
|
166
|
+
rescue Error => e
|
|
167
|
+
MCP::Tool::Response.new([ { type: 'text', text: e.message } ], error: true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Wraps every call of the tool: override this private method and call `super`, as `process_action` in a
|
|
173
|
+
# controller. `rescue_from` covers it.
|
|
174
|
+
#
|
|
175
|
+
# @!visibility public
|
|
176
|
+
# @param arguments [Hash] the arguments as the client sent them, before they are sliced to the declared ones
|
|
177
|
+
# @return [Object] the value of {#call}, before its conversion
|
|
178
|
+
def invoke(arguments) = call(**arguments.slice(*self.class.argument_names))
|
|
179
|
+
|
|
180
|
+
# Converts the value of {#call}.
|
|
181
|
+
#
|
|
182
|
+
# @param result [String, Hash, Array, MCP::Tool::Response] that value
|
|
183
|
+
# @return [MCP::Tool::Response]
|
|
184
|
+
# @raise [TypeError] when the value is of another class
|
|
185
|
+
# @api private
|
|
186
|
+
def to_response(result)
|
|
187
|
+
case result
|
|
188
|
+
when MCP::Tool::Response then result
|
|
189
|
+
when String then MCP::Tool::Response.new([ { type: 'text', text: result } ])
|
|
190
|
+
when Hash then MCP::Tool::Response.new([ { type: 'text', text: result.to_json } ], structured_content: result)
|
|
191
|
+
when Array then MCP::Tool::Response.new([ { type: 'text', text: result.to_json } ])
|
|
192
|
+
else raise TypeError, "#{self.class.name}#call or its rescue_from handler must return a String, Hash, Array or MCP::Tool::Response, got #{result.class}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module ActionModelContext
|
|
2
|
+
# Reads the titles and descriptions of primitives from I18n.
|
|
3
|
+
#
|
|
4
|
+
# @api private
|
|
5
|
+
module Translation
|
|
6
|
+
# Looks up a key under `action_model_context.<kind>.<name>`.
|
|
7
|
+
#
|
|
8
|
+
# @param kind [Symbol] `:tools`, `:resources` or `:prompts`
|
|
9
|
+
# @param name [String, nil] the primitive name
|
|
10
|
+
# @param path [Array<Symbol>] the path of the key
|
|
11
|
+
# @return [String, Hash, nil] nil when the key is missing or the primitive has no name
|
|
12
|
+
def self.lookup(kind, name, *path)
|
|
13
|
+
return unless name
|
|
14
|
+
|
|
15
|
+
I18n.t(path.last, scope: [ :action_model_context, kind, name, *path[0...-1] ], default: nil)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Reads an argument's title or description: a plain string under `arguments.<argument>` is its description.
|
|
19
|
+
#
|
|
20
|
+
# @param kind [Symbol] `:tools`, `:resources` or `:prompts`
|
|
21
|
+
# @param name [String, nil] the primitive name
|
|
22
|
+
# @param argument [Symbol] the argument name
|
|
23
|
+
# @param key [Symbol] `:title` or `:description`
|
|
24
|
+
# @return [String, nil]
|
|
25
|
+
def self.argument(kind, name, argument, key)
|
|
26
|
+
value = lookup(kind, name, :arguments, argument)
|
|
27
|
+
value.is_a?(Hash) ? value[key] : (value if key == :description)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'mcp'
|
|
2
|
+
require 'active_support'
|
|
3
|
+
require 'action_model_context/version'
|
|
4
|
+
require 'action_model_context/error'
|
|
5
|
+
require 'action_model_context/naming'
|
|
6
|
+
require 'action_model_context/translation'
|
|
7
|
+
require 'action_model_context/primitive'
|
|
8
|
+
require 'action_model_context/tool'
|
|
9
|
+
require 'action_model_context/resource'
|
|
10
|
+
require 'action_model_context/prompt'
|
|
11
|
+
require 'action_model_context/error_reporter'
|
|
12
|
+
require 'action_model_context/protected_resource_metadata_middleware'
|
|
13
|
+
require 'action_model_context/server'
|
|
14
|
+
require 'action_model_context/railtie' if defined?(Rails::Railtie)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require 'action_model_context'
|