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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +307 -0
  4. data/lib/action_model_context/error.rb +9 -0
  5. data/lib/action_model_context/error_reporter.rb +30 -0
  6. data/lib/action_model_context/naming.rb +13 -0
  7. data/lib/action_model_context/primitive.rb +113 -0
  8. data/lib/action_model_context/prompt.rb +122 -0
  9. data/lib/action_model_context/protected_resource_metadata_middleware.rb +28 -0
  10. data/lib/action_model_context/railtie.rb +35 -0
  11. data/lib/action_model_context/resource.rb +133 -0
  12. data/lib/action_model_context/server.rb +166 -0
  13. data/lib/action_model_context/test_helper.rb +61 -0
  14. data/lib/action_model_context/tool.rb +196 -0
  15. data/lib/action_model_context/translation.rb +30 -0
  16. data/lib/action_model_context/version.rb +3 -0
  17. data/lib/action_model_context.rb +14 -0
  18. data/lib/actionmodelcontext.rb +1 -0
  19. data/lib/generators/action_model_context/argument.rb +57 -0
  20. data/lib/generators/action_model_context/install/install_generator.rb +31 -0
  21. data/lib/generators/action_model_context/install/templates/application_prompt.rb.tt +2 -0
  22. data/lib/generators/action_model_context/install/templates/application_resource.rb.tt +2 -0
  23. data/lib/generators/action_model_context/install/templates/application_tool.rb.tt +2 -0
  24. data/lib/generators/action_model_context/install/templates/mcp_controller.rb.tt +8 -0
  25. data/lib/generators/action_model_context/prompt/prompt_generator.rb +47 -0
  26. data/lib/generators/action_model_context/prompt/templates/prompt.rb.tt +10 -0
  27. data/lib/generators/action_model_context/resource/resource_generator.rb +47 -0
  28. data/lib/generators/action_model_context/resource/templates/resource.rb.tt +8 -0
  29. data/lib/generators/action_model_context/tool/templates/tool.rb.tt +10 -0
  30. data/lib/generators/action_model_context/tool/tool_generator.rb +47 -0
  31. data/lib/generators/rspec/mcp_prompt/mcp_prompt_generator.rb +20 -0
  32. data/lib/generators/rspec/mcp_prompt/templates/prompt_spec.rb.tt +11 -0
  33. data/lib/generators/rspec/mcp_resource/mcp_resource_generator.rb +17 -0
  34. data/lib/generators/rspec/mcp_resource/templates/resource_spec.rb.tt +11 -0
  35. data/lib/generators/rspec/mcp_tool/mcp_tool_generator.rb +27 -0
  36. data/lib/generators/rspec/mcp_tool/templates/tool_spec.rb.tt +14 -0
  37. data/lib/generators/test_unit/mcp_prompt/mcp_prompt_generator.rb +20 -0
  38. data/lib/generators/test_unit/mcp_prompt/templates/prompt_test.rb.tt +11 -0
  39. data/lib/generators/test_unit/mcp_resource/mcp_resource_generator.rb +17 -0
  40. data/lib/generators/test_unit/mcp_resource/templates/resource_test.rb.tt +11 -0
  41. data/lib/generators/test_unit/mcp_tool/mcp_tool_generator.rb +27 -0
  42. data/lib/generators/test_unit/mcp_tool/templates/tool_test.rb.tt +14 -0
  43. 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,3 @@
1
+ module ActionModelContext
2
+ VERSION = '2026.11.02.161253'
3
+ 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'