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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e6356095ff67fc46ca4127750f66ed7f38cd60309560183ad09c020090672564
4
+ data.tar.gz: 2a4fe362afbbbc120026894b342ea2e53ea9bd5d01dccf6c41b2c0d360789568
5
+ SHA512:
6
+ metadata.gz: fac819ee5b494d815e6f0967131ff02923666ee0dca5eb9254b531905bf0ec7f1885bd3627c94396ac0da7a479eac4aea977ed3dd29a330e3c504375c1aa981f
7
+ data.tar.gz: 17e869c7ab78fbc2803ff9b38c5d1baffd45f25e95d733dda5541dda9faed4f4cb935abe93937a2d0d081f5feaaa7660196c07a6653c1d877fef5b36a388e40c
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Fluence
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # actionmodelcontext
2
+
3
+ A Rails-flavoured MCP server layer on top of the official [`mcp`](https://github.com/modelcontextprotocol/ruby-sdk) SDK. Tools, resources and prompts are plain Ruby classes under `app/mcp/`, discovered by Zeitwerk, and served by a controller concern over the Streamable HTTP transport (stateless, JSON responses).
4
+
5
+ Requires Ruby 3.3+, Rails 7.2+ and `mcp` ~> 1.8.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ bundle add actionmodelcontext
11
+ bin/rails generate action_model_context:install
12
+ ```
13
+
14
+ The install generator creates `ApplicationTool`, `ApplicationResource` and `ApplicationPrompt` under `app/mcp/`, an `McpController`, and this route:
15
+
16
+ ```ruby
17
+ resource :mcp, controller: :mcp, only: %i[show create destroy]
18
+ ```
19
+
20
+ Files under `app/mcp/tools`, `app/mcp/resources` and `app/mcp/prompts` are autoloaded with that first directory level collapsed: `app/mcp/tools/billing/refund_tool.rb` defines `Billing::RefundTool`. Other directories under `app/mcp` keep theirs, so the code the primitives share can live next to them: `app/mcp/billing/tax_rate.rb` defines `Billing::TaxRate`. The controller discovers primitives through `ApplicationTool`, `ApplicationResource` and `ApplicationPrompt` and their `descendants`.
21
+
22
+ Only concrete classes are exposed. A tool or prompt is concrete when it, or a parent below the gem's base class, defines `call`. A resource is concrete when it declares its own `uri` and defines `read` (`uri` is not inherited). An intermediate base such as `Admin::BaseTool < ApplicationTool` that only adds `rescue_from` handlers or arguments is therefore not listed, while its subclasses that define `call` are. A base that defines `call` itself is exposed as a tool.
23
+
24
+ > `ApplicationResource` collides with Graphiti and JSONAPI::Resources, which also define a top-level `ApplicationResource` (in `app/resources/`). The controller looks up MCP resources through that constant on every request, so when it names the other library's class, every MCP request fails with a 500, `tools/list` and `tools/call` included, not just resources. An app using one of these libraries has to resolve the clash before installing this gem.
25
+
26
+ ## Tools
27
+
28
+ ```ruby
29
+ class EchoTool < ApplicationTool
30
+ argument :message, :string, required: true
31
+ argument :times, :integer
32
+
33
+ def call(message:, times: 1) = message * times
34
+ end
35
+ ```
36
+
37
+ `argument(name, type, required: false, null: false, **keywords)` where `type` is one of `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`. `null: true` also accepts `null` (`"type": ["string", "null"]`). The other keywords are those of JSON Schema 2020-12, the dialect MCP uses (`description:`, `enum:`, `items:`, `minimum:`…), their names camelized: `argument :ids, :array, items: { type: 'integer' }, max_items: 50` emits `maxItems`. A hash such as `items:` goes through as is, so write its keys in JSON (`maxLength`). A keyword outside it, a typo such as `max_itmes:` for instance, raises `ArgumentError` when the class loads. The SDK validates the arguments against the schema before `call`, so `minimum`, `maxItems` and the like are enforced; `format` is not. The tool name defaults to the underscored class name without the `Tool` suffix (`echo`); override it with `tool_name 'other'`. `title`, `description` and `annotations(**hints)` are class-level macros.
38
+
39
+ `arguments_from(properties, required: [])` declares one argument per entry of a JSON Schema `properties` hash, the names listed in `required` being required, so one set of fields can serve a create tool and an update tool:
40
+
41
+ ```ruby
42
+ TASK_FIELDS = { title: { type: 'string' }, due_on: { type: %w[string null], format: 'date' } }.freeze
43
+
44
+ class CreateTaskTool < ApplicationTool
45
+ arguments_from TASK_FIELDS, required: %i[title]
46
+ end
47
+ ```
48
+
49
+ Keys may be symbols or strings, as in a schema loaded from JSON, and `type: [..., 'null']` becomes `null: true`. A property without a single type, or with its own `required` list, raises `ArgumentError`; a `required` nested in `items` or `properties` goes through as is.
50
+
51
+ `call` may return a `String`, a `Hash` (sent as text and as structured content), an `Array`, or an `MCP::Tool::Response`. Raise `ActionModelContext::Error` to return an error result to the model with your message; any other exception is reported through `Rails.error` (`severity: :error`, `source: 'actionmodelcontext'`), with the JSON-RPC method and the primitive's name or URI as `mcp_method` and `mcp_name` in the context, never the arguments. `rescue_from` works inside a primitive:
52
+
53
+ ```ruby
54
+ class WhoAmITool < ApplicationTool
55
+ rescue_from(KeyError) { raise ActionModelContext::Error, 'Unknown user' }
56
+
57
+ def call = { user: context.fetch(:user) }
58
+ end
59
+ ```
60
+
61
+ A handler that returns normally has handled the exception, as in a Rails controller: its return value becomes the result, converted like the value of `call` (or of `read` in a resource). To produce an error result instead, raise `ActionModelContext::Error` from the handler, as above. When no handler matches the exception, its `cause` is tried next, as in Rails.
62
+
63
+ To wrap every call of a tool base, override the private `invoke(arguments)` and call `super`, as `process_action` in a controller. It receives the arguments as the client sent them, before they are sliced to the declared ones, and returns the value of `call` before its conversion. `rescue_from` covers it:
64
+
65
+ ```ruby
66
+ class ApplicationTool < ActionModelContext::Tool
67
+ private
68
+
69
+ def invoke(arguments)
70
+ raise ActionModelContext::Error, 'Archived records are read-only' if arguments.key?(:archived)
71
+
72
+ Current.set(user: context[:user]) { super }
73
+ end
74
+ end
75
+ ```
76
+
77
+ ## Resources
78
+
79
+ ```ruby
80
+ class ReadmeResource < ApplicationResource
81
+ uri 'app://readme'
82
+
83
+ def read = 'Hello'
84
+ end
85
+
86
+ class UserResource < ApplicationResource
87
+ uri 'users://{id}'
88
+
89
+ def read(id:)
90
+ raise ActionModelContext::Error, 'Unknown user' if id == '0'
91
+
92
+ { id:, viewer: context[:user] }
93
+ end
94
+ end
95
+ ```
96
+
97
+ A URI containing `{variable}` is registered as a resource template, and the variables are passed to `read` as keyword arguments. `read` may return a `String` (`text/plain`), or a `Hash` / `Array` (`application/json`); `mime_type '...'` overrides this and is inherited by subclasses, unlike `uri`. An `ActionModelContext::Error` becomes an `invalid_params` JSON-RPC error.
98
+
99
+ ## Prompts
100
+
101
+ ```ruby
102
+ class SummarizePrompt < ApplicationPrompt
103
+ argument :topic, required: true
104
+
105
+ def call(topic:) = "Summarize #{topic} in three points"
106
+ end
107
+ ```
108
+
109
+ `argument(name, required: false, title: nil, description: nil)`: MCP gives a prompt argument no type, only these fields. `call` returns a `String` (one user message) or an `Array` of `{ role:, content: }` hashes.
110
+
111
+ ## Generators
112
+
113
+ ```sh
114
+ bin/rails generate action_model_context:tool Billing::Refund amount:integer:required reason
115
+ bin/rails generate action_model_context:resource User --uri "users://{id}"
116
+ bin/rails generate action_model_context:prompt Summarize topic:required
117
+ ```
118
+
119
+ Each generator drops its own suffix (`Tool`, `Resource`, `Prompt`) from the name, so `action_model_context:tool FindUserTool` generates `FindUserTool`, not `FindUserToolTool`. Each one also generates a spec (RSpec) or test (Minitest) following your `test_framework`. Until you implement it, the generated `call` or `read` raises `ActionModelContext::Error`, so a fresh stub answers an error result (`isError` for a tool, a JSON-RPC `-32602` error for a resource or prompt) instead of a 500.
120
+
121
+ ## Authentication and context
122
+
123
+ The controller is a normal Rails controller, so authenticate with a `before_action`, and expose what tools need through `mcp_context`. Whatever it returns is available as `context` in every tool, resource and prompt:
124
+
125
+ ```ruby
126
+ class McpController < ApplicationController
127
+ include ActionModelContext::Server
128
+ before_action :authenticate_user!
129
+
130
+ private
131
+
132
+ def mcp_context = { user: current_user }
133
+ end
134
+ ```
135
+
136
+ The gem imposes no authentication unless `config.action_model_context.oauth.token_verifier` is set (see [OAuth](#oauth)). Three hooks are available, all private and overridable:
137
+
138
+ | Hook | Default | Purpose |
139
+ | --- | --- | --- |
140
+ | `mcp_context` | `{}` | The `context` given to every primitive |
141
+ | `mcp_server_options` | `{ name: <app module name, underscored> }` | Keyword arguments for `MCP::Server.new` (for example `name:`, `version:`, `instructions:`) |
142
+ | `mcp_transport_options` | `{}` | Keyword arguments for `MCP::Server::Transports::StreamableHTTPTransport.new` |
143
+
144
+ `stateless`, `enable_json_response` and `serve_subscriptions_listen` are pinned and cannot be overridden through `mcp_transport_options`: the transport is built per request, so a stateful transport would leak a session reaper thread on every call.
145
+
146
+ ### OAuth
147
+
148
+ The MCP server is an OAuth 2.1 resource server: clients call it with bearer tokens issued by an authorization server (Doorkeeper in the same app, or an external identity provider), and it never issues them. Everything is set under `config.action_model_context.oauth`, in `config/application.rb` or per environment:
149
+
150
+ ```ruby
151
+ config.action_model_context.oauth.resource = 'https://example.com/mcp'
152
+ config.action_model_context.oauth.authorization_servers = [ 'https://auth.example.com' ]
153
+ config.action_model_context.oauth.scopes = %w[mcp:tools]
154
+ ```
155
+
156
+ `resource` is the public URL of the MCP endpoint, prefix included. With it, the gem publishes the RFC 9728 Protected Resource Metadata document, through which clients discover the authorization server, using the SDK's middleware: at the RFC 9728 path when requests for it reach the app, and at `/.well-known/oauth-protected-resource` relative to the app, the path an app mounted under a prefix receives. Without `resource`, the gem publishes nothing. `scopes` are advertised in the document and in the challenges. The optional `resource_name` and `resource_documentation` go into the document too, and `extra` adds the other RFC 9728 fields (`extra = { resource_policy_uri: '...' }`); `bearer_methods_supported` stays `["header"]`, the only place the SDK reads a token from.
157
+
158
+ Every 401 points at that document. Its URL is `resource` with the path of the MCP endpoint replaced by `/.well-known/oauth-protected-resource` (`https://example.com/backend/app/.well-known/oauth-protected-resource` for `https://example.com/backend/app/mcp`), so it keeps a prefix that a proxy strips before the request reaches the app, without relying on forwarded headers.
159
+
160
+ Then either the gem checks the token, or the app decides who calls.
161
+
162
+ #### The gem checks the token
163
+
164
+ `token_verifier` is a lambda that receives the document and returns the SDK verifier. It runs once the app has loaded, and again after each code reload in development. The concern then checks the bearer token before the MCP actions: a missing or invalid token gets a 401, a token without all of `scopes` a 403 `insufficient_scope`, both with the SDK's challenge. `mcp_access_token` returns the verified `MCP::Server::OAuth::AccessToken`:
165
+
166
+ ```ruby
167
+ config.action_model_context.oauth.token_verifier = ->(metadata) {
168
+ MCP::Server::OAuth::JWTVerifier.new(resource_metadata: metadata, jwks_uri: 'https://auth.example.com/.well-known/jwks.json')
169
+ }
170
+ ```
171
+
172
+ ```ruby
173
+ class McpController < ApplicationController
174
+ include ActionModelContext::Server
175
+
176
+ private
177
+
178
+ def mcp_context = { user: User.find_by(id: mcp_access_token.subject) }
179
+ end
180
+ ```
181
+
182
+ `JWTVerifier` (which needs the `jwt` gem) checks the signature, and the issuer and audience against the document, and caches the signing keys. `IntrospectionVerifier` asks the authorization server instead, and any object that responds to `verify(token)` and returns an `MCP::Server::OAuth::AccessToken` works too (see `MCP::Server::OAuth::TokenVerifier`).
183
+
184
+ With Doorkeeper in the same app, `authorization_servers` names the app itself and the verifier looks the token up in Doorkeeper's table:
185
+
186
+ ```ruby
187
+ config.action_model_context.oauth.token_verifier = ->(_metadata) { DoorkeeperTokenVerifier.new }
188
+ ```
189
+
190
+ ```ruby
191
+ # app/mcp/doorkeeper_token_verifier.rb
192
+ class DoorkeeperTokenVerifier
193
+ def verify(token)
194
+ access_token = Doorkeeper::AccessToken.by_token(token)
195
+ raise MCP::Server::OAuth::InvalidTokenError unless access_token&.accessible?
196
+
197
+ MCP::Server::OAuth::AccessToken.new(
198
+ token:, scopes: access_token.scopes.to_a, subject: access_token.resource_owner_id.to_s,
199
+ expires_at: access_token.expires_at&.to_i, client_id: access_token.application&.uid,
200
+ )
201
+ end
202
+ end
203
+ ```
204
+
205
+ Doorkeeper serves `/oauth/authorize` and `/oauth/token` with PKCE. MCP clients need two more things that Doorkeeper 5.9 does not provide:
206
+
207
+ - the authorization server metadata (RFC 8414) at `/.well-known/oauth-authorization-server`, whose `issuer` must equal the URL in `authorization_servers` and whose `code_challenge_methods_supported` must include `S256`;
208
+ - a way to obtain a `client_id`: a Doorkeeper application registered beforehand, support for Client ID Metadata Documents, or Dynamic Client Registration (RFC 7591).
209
+
210
+ Doorkeeper tokens are not bound to the MCP endpoint: any token of the app that carries the required scope is accepted.
211
+
212
+ #### The app decides who calls
213
+
214
+ Without `token_verifier`, the app authenticates the request itself, or a proxy in front of it does, as in [Authentication and context](#authentication-and-context). When it rejects a request, `request_mcp_authentication` answers the SDK's 401 challenge:
215
+
216
+ ```ruby
217
+ class McpController < ApplicationController
218
+ include ActionModelContext::Server
219
+ before_action { request_mcp_authentication unless user_signed_in? }
220
+ end
221
+ ```
222
+
223
+ ### Host and Origin checks (DNS rebinding)
224
+
225
+ The concern builds the transport with `dns_rebinding_protection: false`. The transport's own check accepts only `127.0.0.1`, `::1` and `localhost` as Host, which would reject every real deployment. Host checking is left to Rails' `config.hosts` (`ActionDispatch::HostAuthorization`).
226
+
227
+ - In development, Rails allows local hosts by default. In production, `config.hosts` is empty, which means **no Host check at all** unless your app configures one. Set `config.hosts` in production.
228
+ - The same flag also disables the transport's Origin check.
229
+ - To turn the protection back on, override the option:
230
+
231
+ ```ruby
232
+ def mcp_transport_options = { dns_rebinding_protection: true, allowed_hosts: %w[mcp.example.com] }
233
+ ```
234
+
235
+ ## I18n
236
+
237
+ Titles, descriptions, and argument titles and descriptions are looked up per request, in the current locale:
238
+
239
+ ```yaml
240
+ en:
241
+ action_model_context:
242
+ tools:
243
+ billing_refund:
244
+ title: Refund
245
+ description: Refund an invoice
246
+ arguments:
247
+ amount: Amount in cents
248
+ resources:
249
+ user:
250
+ description: A user
251
+ prompts:
252
+ summarize:
253
+ description: Summarize a topic
254
+ arguments:
255
+ topic:
256
+ title: Topic
257
+ description: What to summarize
258
+ ```
259
+
260
+ The keys are `action_model_context.{tools,resources,prompts}.<name>.{title,description,arguments.<arg>}`, where `<name>` is the primitive name (`billing_refund` for `Billing::RefundTool`). `arguments.<arg>` holds the argument's description, or a hash with its `title` and `description`; a tool argument reads only the description. A value declared in the class (`description '...'`, `argument ..., description: '...'`) wins over I18n. I18n fallbacks apply only when `config.i18n.fallbacks` is enabled, which the `config/environments/production.rb` generated by Rails does. A missing key omits the field: "Translation missing" never reaches the client.
261
+
262
+ Switch the locale per request in your controller:
263
+
264
+ ```ruby
265
+ class McpController < ApplicationController
266
+ include ActionModelContext::Server
267
+
268
+ around_action :switch_locale
269
+
270
+ private
271
+
272
+ def switch_locale(&) = I18n.with_locale(request.headers['Accept-Language'].presence_in(%w[en fr]) || :en, &)
273
+ end
274
+ ```
275
+
276
+ This sample only matches a header that is exactly `en` or `fr`. A real `Accept-Language` header such as `fr-FR,fr;q=0.9,en;q=0.8` has to be parsed first.
277
+
278
+ > Do not mix languages across tools in one server. Descriptions in several languages degrade tool selection by the model (see [modelcontextprotocol issue #1395](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1395)). Serve one consistent locale per client.
279
+
280
+ ## Testing
281
+
282
+ `ActionModelContext::TestHelper` runs a tool through a real `MCP::Server`, so argument validation and error handling behave as in production:
283
+
284
+ ```ruby
285
+ require 'action_model_context/test_helper'
286
+
287
+ RSpec.describe EchoTool do
288
+ include ActionModelContext::TestHelper
289
+
290
+ it 'repeats the message' do
291
+ result = call_tool(EchoTool, message: 'hi', times: 2)
292
+
293
+ expect(result.text).to eq('hihi')
294
+ expect(result).not_to be_error
295
+ end
296
+
297
+ it 'passes the context' do
298
+ expect(call_tool(WhoAmITool, context: { user: 'ada' }).structured_content).to eq(user: 'ada')
299
+ end
300
+ end
301
+ ```
302
+
303
+ `call_tool(tool_class, context: {}, **arguments)` returns a result with `text`, `structured_content` and `error?`. An unexpected exception raised by the tool is re-raised in the test.
304
+
305
+ ## License
306
+
307
+ MIT.
@@ -0,0 +1,9 @@
1
+ # Rails layer over the official `mcp` SDK: tools, resources and prompts are Ruby classes under `app/mcp`,
2
+ # served by a controller that includes {Server}.
3
+ module ActionModelContext
4
+ # Raised by a primitive to report a failure to the client: a tool answers an error result with the message,
5
+ # a resource or a prompt an `invalid_params` JSON-RPC error.
6
+ #
7
+ # @api public
8
+ class Error < StandardError; end
9
+ end
@@ -0,0 +1,30 @@
1
+ module ActionModelContext
2
+ # Reports the failures of MCP requests through `Rails.error`.
3
+ #
4
+ # @api private
5
+ module ErrorReporter
6
+ # The SDK reports an internal failure as its original exception (mcp server.rb:779), so a bare
7
+ # RequestHandlerError or OAuth::Error is the client's fault: "Tool not found", a missing scope.
8
+ #
9
+ # @param exception [Exception] the failure the SDK reports
10
+ # @param server_context [Hash] the SDK's context, with the request under `:request`
11
+ # @return [void]
12
+ def self.call(exception, server_context)
13
+ return if exception.is_a?(MCP::Server::RequestHandlerError) || exception.is_a?(MCP::Server::OAuth::Error)
14
+
15
+ Rails.error.report(exception, handled: true, severity: :error, source: 'actionmodelcontext', context: request_context(server_context[:request]))
16
+ end
17
+
18
+ # The legacy lifecycle hands over the raw JSON body, a batch included. The arguments stay out: they may carry personal data.
19
+ #
20
+ # @param request [String, Hash, nil] the request, as JSON in the legacy lifecycle
21
+ # @return [Hash] the JSON-RPC method and the primitive's name or URI, empty when the request has no params
22
+ private_class_method def self.request_context(request)
23
+ request = JSON.parse(request, symbolize_names: true) if request.is_a?(String)
24
+ params = request[:params] if request.is_a?(Hash)
25
+ return {} unless params.is_a?(Hash)
26
+
27
+ { mcp_method: request[:method], mcp_name: params[:name] || params[:uri] }.compact
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ module ActionModelContext
2
+ # Derives the default name of a primitive.
3
+ #
4
+ # @api private
5
+ module Naming
6
+ # Builds the name from the class: `Billing::RefundTool` gives `billing_refund`.
7
+ #
8
+ # @param klass [Class] the primitive class
9
+ # @param suffix [String] the class name suffix to drop
10
+ # @return [String, nil] nil for an anonymous class
11
+ def self.default_name(klass, suffix:) = klass.name&.delete_suffix(suffix)&.underscore&.tr('/', '_')
12
+ end
13
+ end
@@ -0,0 +1,113 @@
1
+ require 'active_support/concern'
2
+ require 'active_support/rescuable'
3
+
4
+ module ActionModelContext
5
+ # Shared by {Tool}, {Resource} and {Prompt}: the class macros that name and describe a primitive,
6
+ # and `rescue_from`, which works as in a controller.
7
+ #
8
+ # @api public
9
+ module Primitive
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ include ActiveSupport::Rescuable
14
+
15
+ class_attribute :i18n_kind, :name_suffix, instance_accessor: false, instance_predicate: false
16
+ end
17
+
18
+ # @!method primitive(kind:, suffix:)
19
+ # @!scope class
20
+ # Declares the I18n scope and the class name suffix of a family of primitives.
21
+ # @param kind [Symbol] the I18n scope
22
+ # @param suffix [String] the class name suffix to drop from the default name
23
+ # @return [void]
24
+ # @api private
25
+ #
26
+ # @!method description(text = nil)
27
+ # @!scope class
28
+ # Declares the description, or reads it: a declared value wins over I18n.
29
+ # @param text [String, nil] the description to declare
30
+ # @return [String, nil] nil when neither the class nor I18n has one
31
+ # @api public
32
+ #
33
+ # @!method title(text = nil)
34
+ # @!scope class
35
+ # Declares the title, or reads it: a declared value wins over I18n.
36
+ # @param text [String, nil] the title to declare
37
+ # @return [String, nil] nil when neither the class nor I18n has one
38
+ # @api public
39
+ #
40
+ # @!method primitive_name(name = nil)
41
+ # @!scope class
42
+ # Declares the name, or reads it.
43
+ # @param name [String, nil] the name to declare
44
+ # @return [String, nil] the declared name, else one derived from the class name, nil for an anonymous class
45
+ # @api private
46
+
47
+ class_methods do
48
+ def primitive(kind:, suffix:)
49
+ self.i18n_kind = kind
50
+ self.name_suffix = suffix
51
+ end
52
+
53
+ def description(text = nil)
54
+ return @declared_description = text if text
55
+
56
+ @declared_description || Translation.lookup(i18n_kind, primitive_name, :description)
57
+ end
58
+
59
+ def title(text = nil)
60
+ return @declared_title = text if text
61
+
62
+ @declared_title || Translation.lookup(i18n_kind, primitive_name, :title)
63
+ end
64
+
65
+ def primitive_name(name = nil)
66
+ return @declared_name = name if name
67
+
68
+ @declared_name || Naming.default_name(self, suffix: name_suffix)
69
+ end
70
+ end
71
+
72
+ # The context of the request: what the controller's `mcp_context` returned.
73
+ #
74
+ # @return [Object]
75
+ attr_reader :context
76
+
77
+ # Creates the primitive for a request.
78
+ #
79
+ # @param context [Object] what the controller's `mcp_context` returned
80
+ # @return [void]
81
+ # @api private
82
+ def initialize(context:)
83
+ @context = context
84
+ end
85
+
86
+ private
87
+
88
+ # rescue_with_handler returns the exception, not the handler's value, so its walk up the causes is redone here.
89
+ #
90
+ # @yieldreturn [Object] the value of the primitive's method
91
+ # @return [Object] that value, or the return value of the `rescue_from` handler
92
+ # @api private
93
+ def with_rescue
94
+ yield
95
+ rescue StandardError => e
96
+ handled = e
97
+ handled = handled.cause until handled.nil? || (handler = handler_for_rescue(handled))
98
+ handler ? handler.call(handled) : raise
99
+ end
100
+
101
+ # Turns an {Error} into an `invalid_params` JSON-RPC error.
102
+ #
103
+ # @param error [Error] the error to report
104
+ # @return [void]
105
+ # @raise [MCP::Server::RequestHandlerError] always
106
+ # @api private
107
+ def raise_invalid_params(error)
108
+ raise MCP::Server::RequestHandlerError.new(
109
+ error.message, nil, error_type: :invalid_params, error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
110
+ )
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,122 @@
1
+ require 'active_support/core_ext/class/attribute'
2
+
3
+ module ActionModelContext
4
+ # Base class of the prompts. An application subclasses it through `ApplicationPrompt`, under `app/mcp/prompts`.
5
+ #
6
+ # @example
7
+ # class SummarizePrompt < ApplicationPrompt
8
+ # argument :topic, required: true
9
+ #
10
+ # def call(topic:) = "Summarize #{topic} in three points"
11
+ # end
12
+ #
13
+ # @api public
14
+ class Prompt
15
+ include Primitive
16
+
17
+ primitive kind: :prompts, suffix: 'Prompt'
18
+
19
+ class_attribute :arguments, instance_accessor: false, instance_predicate: false, default: {}.freeze
20
+
21
+ class << self
22
+ # Declares the name, or reads it.
23
+ #
24
+ # @param name [String, nil] the name to declare
25
+ # @return [String, nil] the declared name, else the underscored class name without its `Prompt` suffix
26
+ def prompt_name(name = nil) = primitive_name(name)
27
+
28
+ # Declares an argument. MCP gives a prompt argument no type, only these fields.
29
+ #
30
+ # @param name [Symbol, String] the argument's name
31
+ # @param required [Boolean] whether the client must send it
32
+ # @param title [String, nil] its title, falling back on I18n
33
+ # @param description [String, nil] its description, falling back on I18n
34
+ # @return [void]
35
+ def argument(name, required: false, title: nil, description: nil)
36
+ self.arguments = arguments.merge(name.to_sym => { required:, title:, description: }).freeze
37
+ end
38
+
39
+ # Lists the declared arguments.
40
+ #
41
+ # @return [Array<Symbol>]
42
+ # @api private
43
+ def argument_names = arguments.keys
44
+
45
+ # Tells whether the prompt is exposed, which takes a `call` below this class.
46
+ #
47
+ # @return [Boolean]
48
+ # @api private
49
+ def concrete? = instance_method(:call).owner != Prompt
50
+
51
+ # Builds the SDK prompt for a request.
52
+ #
53
+ # @param context [Object] the context of the request
54
+ # @return [Class<MCP::Prompt>]
55
+ # @api private
56
+ def to_mcp(context:)
57
+ prompt = self
58
+ MCP::Prompt.define(name: prompt_name, title:, description:, arguments: mcp_arguments) do |args, **|
59
+ prompt.new(context:).call_mcp(args || {})
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Builds the SDK arguments, their title and description falling back on I18n.
66
+ #
67
+ # @return [Array<MCP::Prompt::Argument>]
68
+ # @api private
69
+ def mcp_arguments
70
+ arguments.map do |name, spec|
71
+ title = spec[:title] || Translation.argument(i18n_kind, prompt_name, name, :title)
72
+ description = spec[:description] || Translation.argument(i18n_kind, prompt_name, name, :description)
73
+ MCP::Prompt::Argument.new(name: name.to_s, title:, description:, required: spec[:required])
74
+ end
75
+ end
76
+ end
77
+
78
+ # Builds the prompt. A subclass defines it, with one keyword per argument it takes.
79
+ #
80
+ # @return [String, Array<Hash>] one user message, or `{ role:, content: }` hashes
81
+ # @raise [Error] to answer an `invalid_params` JSON-RPC error
82
+ # @raise [NotImplementedError] when no subclass defines it
83
+ def call(**) = raise(NotImplementedError, "#{self.class.name}#call is not implemented")
84
+
85
+ # Builds the prompt for the SDK.
86
+ #
87
+ # @param arguments [Hash] the arguments the client sent
88
+ # @return [MCP::Prompt::Result]
89
+ # @raise [MCP::Server::RequestHandlerError] when the prompt raises {Error}
90
+ # @api private
91
+ def call_mcp(arguments)
92
+ to_result(with_rescue { call(**arguments.slice(*self.class.argument_names)) })
93
+ rescue Error => e
94
+ raise_invalid_params(e)
95
+ end
96
+
97
+ private
98
+
99
+ # Converts the value of {#call}.
100
+ #
101
+ # @param result [String, Array<Hash>] that value
102
+ # @return [MCP::Prompt::Result]
103
+ # @raise [TypeError] when the value is of another class
104
+ # @api private
105
+ def to_result(result)
106
+ messages = case result
107
+ when String then [ message('user', result) ]
108
+ when Array then result.map { |item| message(item.fetch(:role), item.fetch(:content)) }
109
+ else raise TypeError, "#{self.class.name}#call or its rescue_from handler must return a String or Array, got #{result.class}"
110
+ end
111
+ MCP::Prompt::Result.new(messages:)
112
+ end
113
+
114
+ # Builds a text message.
115
+ #
116
+ # @param role [String] the role of the message
117
+ # @param text [String] its text
118
+ # @return [MCP::Prompt::Message]
119
+ # @api private
120
+ def message(role, text) = MCP::Prompt::Message.new(role:, content: MCP::Content::Text.new(text))
121
+ end
122
+ end
@@ -0,0 +1,28 @@
1
+ module ActionModelContext
2
+ # The SDK answers only when PATH_INFO is the RFC 9728 path built from the public resource URL, prefix included
3
+ # (/.well-known/oauth-protected-resource/<prefix>/mcp), which an app mounted under that prefix never receives.
4
+ #
5
+ # @api private
6
+ class ProtectedResourceMetadataMiddleware < MCP::Server::OAuth::ProtectedResourceMetadataMiddleware
7
+ # The path of the metadata document, relative to the app.
8
+ PATH = '/.well-known/oauth-protected-resource'
9
+
10
+ # Keeps the path the SDK expects.
11
+ #
12
+ # @param app [#call] the next Rack application
13
+ # @param metadata [MCP::Server::OAuth::ProtectedResourceMetadata] the document to serve
14
+ def initialize(app, metadata)
15
+ super
16
+ @sdk_path = metadata.well_known_path
17
+ end
18
+
19
+ # Serves the document at {PATH} too.
20
+ #
21
+ # @param env [Hash] the Rack environment
22
+ # @return [Array(Integer, Hash, #each)] the Rack response
23
+ def call(env)
24
+ env = env.merge('PATH_INFO' => @sdk_path) if env['PATH_INFO'] == PATH
25
+ super
26
+ end
27
+ end
28
+ end