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
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
|