mcpable 0.1.0
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 +21 -0
- data/README.md +776 -0
- data/app/controllers/mcpable/rails/tools_controller.rb +23 -0
- data/lib/mcpable/active_record/column_inference.rb +43 -0
- data/lib/mcpable/active_record/source.rb +108 -0
- data/lib/mcpable/active_record.rb +15 -0
- data/lib/mcpable/argument.rb +29 -0
- data/lib/mcpable/definition.rb +36 -0
- data/lib/mcpable/dsl/resource_builder.rb +264 -0
- data/lib/mcpable/dsl/tool_builder.rb +66 -0
- data/lib/mcpable/dsl.rb +18 -0
- data/lib/mcpable/naming.rb +28 -0
- data/lib/mcpable/pipeline.rb +47 -0
- data/lib/mcpable/ports/middleware.rb +18 -0
- data/lib/mcpable/ports/schema_strategy.rb +11 -0
- data/lib/mcpable/ports/source.rb +23 -0
- data/lib/mcpable/ports/transport.rb +27 -0
- data/lib/mcpable/pundit/authorize.rb +31 -0
- data/lib/mcpable/pundit.rb +4 -0
- data/lib/mcpable/rails/engine.rb +29 -0
- data/lib/mcpable/rails/loader.rb +57 -0
- data/lib/mcpable/rails.rb +5 -0
- data/lib/mcpable/registry.rb +35 -0
- data/lib/mcpable/resource.rb +21 -0
- data/lib/mcpable/result.rb +17 -0
- data/lib/mcpable/runtime.rb +118 -0
- data/lib/mcpable/schema_strategies/explicit_schema.rb +49 -0
- data/lib/mcpable/sources/enumerable_source.rb +99 -0
- data/lib/mcpable/tool.rb +29 -0
- data/lib/mcpable/tool_call.rb +18 -0
- data/lib/mcpable/transports/official_mcp.rb +116 -0
- data/lib/mcpable/version.rb +5 -0
- data/lib/mcpable.rb +76 -0
- metadata +124 -0
data/README.md
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
# mcpable
|
|
2
|
+
|
|
3
|
+
Expose Ruby objects and Rails models as MCP (Model Context Protocol) tools.
|
|
4
|
+
|
|
5
|
+
A tool is a `Mcpable::Definition`. Definitions are compiled by a DSL, stored in a
|
|
6
|
+
`Mcpable::Registry`, executed through a middleware `Mcpable::Pipeline`, and served by a
|
|
7
|
+
`Mcpable::Ports::Transport`. The core is plain Ruby — no Rails, no ActiveRecord.
|
|
8
|
+
|
|
9
|
+
## Concepts
|
|
10
|
+
|
|
11
|
+
Four ports are pluggable:
|
|
12
|
+
|
|
13
|
+
| Port | Contract |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `Ports::Source` | `fetch(filters:, scope:, page:, per_page:, order:) -> Page`, `find(id, scope:)` |
|
|
16
|
+
| `Ports::SchemaStrategy` | `input_schema(definition) -> JSON Schema Hash` |
|
|
17
|
+
| `Ports::Middleware` | Rack-style `initialize(app, *args)` / `call(ctx) -> Result` |
|
|
18
|
+
| `Ports::Transport` | `list_tools`, `handle(raw_request, context:)`, `serve_stdio(context:)` |
|
|
19
|
+
|
|
20
|
+
`Definition` carries `name`, `description`, `arguments`, `handler`, `annotations`
|
|
21
|
+
(`read_only:`, `destructive:`, `open_world:`), `profiles` and a free-form `metadata` hash
|
|
22
|
+
(`model:`, `action:`, `policy:`, `per_page:`, `paginated:`).
|
|
23
|
+
|
|
24
|
+
`Argument` carries `name`, `type` (`:string :integer :number :boolean :date :datetime`),
|
|
25
|
+
`required`, `description`, `enum` and an optional `filter` spec
|
|
26
|
+
`{ kind: :eq | :match | :range_from | :range_to | :scope, target: :column }`.
|
|
27
|
+
|
|
28
|
+
A handler receives a `ToolCall` (`#args`, `#context`, `#assigns`, `#user`, `#scope`,
|
|
29
|
+
`#definition`) and returns a `Result`:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
Mcpable::Result.ok(payload) # status :ok, payload set, error nil
|
|
33
|
+
Mcpable::Result.deny("...") # status :denied, payload nil, error set
|
|
34
|
+
Mcpable::Result.fail("...") # status :error, payload nil, error set
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`#ok?`, `#denied?` and `#error?` read the status.
|
|
38
|
+
|
|
39
|
+
`scope` is an opaque visibility constraint written into `ctx.assigns[:scope]` by auth
|
|
40
|
+
middleware. A source composes it and never widens it; a `nil` scope means the source's base.
|
|
41
|
+
|
|
42
|
+
The examples below all describe one small multi-tenant store application: a `Store` owns
|
|
43
|
+
`Category` and `Product` records, and a `User` belongs to one store.
|
|
44
|
+
|
|
45
|
+
## Rails quick start
|
|
46
|
+
|
|
47
|
+
Add the gem and require the Rails adapter, which pulls in the core, the `mcp` transport and
|
|
48
|
+
the engine:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# Gemfile
|
|
52
|
+
gem "mcpable", require: "mcpable/rails"
|
|
53
|
+
gem "pundit"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Declare tools on your models:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
class Product < ApplicationRecord
|
|
60
|
+
include Mcpable::Resource
|
|
61
|
+
|
|
62
|
+
belongs_to :store
|
|
63
|
+
belongs_to :category
|
|
64
|
+
|
|
65
|
+
enum :status, { active: 0, out_of_stock: 1, discontinued: 2 }
|
|
66
|
+
|
|
67
|
+
mcpable do
|
|
68
|
+
description "Products of the current user's store."
|
|
69
|
+
attributes :id, :store_id, :category_id, :name, :sku, :price_cents, :status
|
|
70
|
+
filter :category_id, description: "Category id."
|
|
71
|
+
filter :status, description: "Stock status."
|
|
72
|
+
filter :name, match: :partial, description: "Case-insensitive substring of the name."
|
|
73
|
+
policy ProductPolicy
|
|
74
|
+
actions :list, :show
|
|
75
|
+
default_page_size 25
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Write an authentication middleware and wire the pipeline:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# lib/mcp/authenticate_user.rb
|
|
84
|
+
module Mcp
|
|
85
|
+
class AuthenticateUser < Mcpable::Ports::Middleware
|
|
86
|
+
def call(ctx)
|
|
87
|
+
token = ctx.context[:api_token]
|
|
88
|
+
user = token && User.find_by(api_token: token)
|
|
89
|
+
return Mcpable::Result.deny("unauthenticated") if user.nil?
|
|
90
|
+
|
|
91
|
+
ctx.assigns[:user] = user
|
|
92
|
+
@app.call(ctx)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# config/initializers/mcpable.rb
|
|
100
|
+
require "mcpable/active_record"
|
|
101
|
+
require "mcpable/pundit"
|
|
102
|
+
|
|
103
|
+
require Rails.root.join("lib/mcp/authenticate_user")
|
|
104
|
+
require Rails.root.join("lib/mcp/audit_log")
|
|
105
|
+
|
|
106
|
+
Mcpable.configure do |config|
|
|
107
|
+
config.pipeline.use(Mcp::AuthenticateUser)
|
|
108
|
+
config.pipeline.use(Mcpable::Pundit::Authorize)
|
|
109
|
+
config.pipeline.use(Mcp::AuditLog)
|
|
110
|
+
|
|
111
|
+
config.schema_strategy = Mcpable::SchemaStrategies::ExplicitSchema.new
|
|
112
|
+
|
|
113
|
+
config.context_builder = ->(env) { { api_token: env["HTTP_X_API_TOKEN"] } }
|
|
114
|
+
|
|
115
|
+
config.error_mapper = lambda do |error|
|
|
116
|
+
Rails.logger.error("[mcp] #{error.class}: #{error.message}")
|
|
117
|
+
|
|
118
|
+
case error
|
|
119
|
+
when Mcpable::MissingScopeError
|
|
120
|
+
Mcpable::Result.fail("refusing to run: #{error.message}")
|
|
121
|
+
else
|
|
122
|
+
Mcpable::Result.fail("internal error")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`Mcpable::Configuration` exposes exactly four things: `pipeline` (read-only), plus the
|
|
129
|
+
writable `schema_strategy`, `error_mapper` and `context_builder`. The defaults are an empty
|
|
130
|
+
pipeline, `ExplicitSchema`, an error mapper returning `Result.fail("internal error")` and a
|
|
131
|
+
context builder returning `{}`.
|
|
132
|
+
|
|
133
|
+
You do not add a route. `mcpable/rails` appends one in an initializer:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
post "/mcp", to: "mcpable/rails/tools#create", as: :mcpable_root
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Middlewares belong in `lib/` and should be `require`d rather than autoloaded: they are
|
|
140
|
+
installed into a process-wide pipeline at boot, so Rails must not reload their constants. List
|
|
141
|
+
that directory in `config.autoload_lib(ignore:)`.
|
|
142
|
+
|
|
143
|
+
Then talk to it:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
curl -s -X POST http://localhost:3000/mcp \
|
|
147
|
+
-H 'Content-Type: application/json' \
|
|
148
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
149
|
+
|
|
150
|
+
curl -s -X POST http://localhost:3000/mcp \
|
|
151
|
+
-H 'Content-Type: application/json' \
|
|
152
|
+
-H 'X-Api-Token: acme-member-token' \
|
|
153
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"products_list","arguments":{"per_page":3}}}'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The endpoint reads `params[:profile]` (`POST /mcp?profile=admin`) to pick which tool set to
|
|
157
|
+
serve; it defaults to `:default`.
|
|
158
|
+
|
|
159
|
+
## Data-backed resources
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class Product
|
|
163
|
+
include Mcpable::Resource
|
|
164
|
+
|
|
165
|
+
mcpable do
|
|
166
|
+
description "Products"
|
|
167
|
+
attributes :id, :name, :sku, :price_cents, :store_id
|
|
168
|
+
attribute :cost_cents, if: :admin?
|
|
169
|
+
filter :store_id, type: :integer
|
|
170
|
+
filter :name, match: :partial, type: :string
|
|
171
|
+
filter :released_on, range: true, type: :date
|
|
172
|
+
source Mcpable::Sources::EnumerableSource.new(-> { Product.all_records })
|
|
173
|
+
policy ProductPolicy
|
|
174
|
+
actions :list, :show
|
|
175
|
+
default_page_size 25
|
|
176
|
+
profiles :default
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This compiles two definitions: `products_list` (paginated, filter arguments,
|
|
182
|
+
`metadata[:action] == :list`) and `products_show` (a required `id`).
|
|
183
|
+
`range: true` expands into `released_on_from` / `released_on_to`.
|
|
184
|
+
|
|
185
|
+
`attributes` is the serialization whitelist: a record is rendered as exactly those keys, read
|
|
186
|
+
by `public_send` and falling back to `[]`. A column left out of it is unreachable over MCP for
|
|
187
|
+
every caller.
|
|
188
|
+
|
|
189
|
+
`attribute` adds one key that is serialized only when its `if:` condition holds for the caller,
|
|
190
|
+
which is how a column is shown to some roles and withheld from others. A `Symbol` is sent to
|
|
191
|
+
`ctx.user` (a `nil` user, or one that does not respond to it, fails closed); a callable receives
|
|
192
|
+
the `ToolCall`, so it can read `ctx.user`, `ctx.scope` or `ctx.context`. The condition is
|
|
193
|
+
evaluated per call, on both `list` and `show`.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
attribute :cost_cents, if: :admin?
|
|
197
|
+
attribute :margin, if: ->(ctx) { ctx.user&.finance? }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Two leaks are closed for you. Conditional attributes are excluded from the default
|
|
201
|
+
`order_whitelist`, because ordering by a hidden column lets a caller infer its values from row
|
|
202
|
+
order. And declaring a `filter` on a conditionally visible attribute raises an `ArgumentError` at
|
|
203
|
+
compile time, for the same reason — filtering on a value you cannot read still reveals it.
|
|
204
|
+
|
|
205
|
+
`name` overrides the base name used for the tool names; it defaults to the underscored,
|
|
206
|
+
namespace-stripped class name, pluralized by `Mcpable::Naming.pluralize`, which handles the
|
|
207
|
+
`-s/-x/-z/-ch/-sh`, consonant-`y` and `-f/-fe` cases (`category` → `categories_list`). It is
|
|
208
|
+
five regex rules, not an inflector: irregular plurals are not covered (`person` → `persons`),
|
|
209
|
+
so name those resources explicitly.
|
|
210
|
+
|
|
211
|
+
`order_whitelist` defaults to the declared `attributes`, and is only consulted by
|
|
212
|
+
`ActiveRecord::Source` — see the note under [Sources](#sources) if you declare a `source`
|
|
213
|
+
yourself. `annotations(**values)` starts from `{ read_only: true }` for resources. `profiles`
|
|
214
|
+
defaults to `[:default]`. `actions` recognises only `:list` and `:show`.
|
|
215
|
+
|
|
216
|
+
In core, `filter` requires an explicit `type:` and `source` is mandatory. Two soft hooks relax
|
|
217
|
+
both. `require "mcpable/active_record"` installs a type inferrer
|
|
218
|
+
(`Mcpable::Dsl::ResourceBuilder.type_inferrer`) that reads `columns_hash` and `defined_enums`,
|
|
219
|
+
after which `type:` is optional for AR-backed classes, and a source factory
|
|
220
|
+
(`Mcpable::Dsl::ResourceBuilder.source_factory`) that wires an `Mcpable::ActiveRecord::Source`
|
|
221
|
+
for any AR model, after which `source` is optional too. An explicitly declared `source` always
|
|
222
|
+
wins.
|
|
223
|
+
|
|
224
|
+
## Filter kinds
|
|
225
|
+
|
|
226
|
+
Every `filter` line expands into one or two `Argument`s, each carrying a
|
|
227
|
+
`filter: { kind:, target: }` spec that the source reads back. `target` is always the declared
|
|
228
|
+
filter name; only `range: true` gives the arguments different names.
|
|
229
|
+
|
|
230
|
+
| DSL | Arguments generated | Compiled spec | `ActiveRecord::Source` emits |
|
|
231
|
+
| --- | --- | --- | --- |
|
|
232
|
+
| `filter :category_id` | `category_id` | `{ kind: :eq, target: :category_id }` | `where("category_id" => value)` |
|
|
233
|
+
| `filter :status` on an enum column | `status`, with `enum:` filled from `defined_enums` | `{ kind: :eq, target: :status }` | `where("status" => value)` |
|
|
234
|
+
| `filter :name, match: :partial` | `name` | `{ kind: :match, target: :name }` | `arel_table[:name].matches("%value%", nil, false)` — case-insensitive, `ILIKE` on PostgreSQL and `LIKE` elsewhere, with the value passed through `sanitize_sql_like` |
|
|
235
|
+
| `filter :released_on, range: true` | `released_on_from`, `released_on_to` | `{ kind: :range_from, target: :released_on }` and `{ kind: :range_to, ... }` | `arel_table[:released_on].gteq(value)` / `.lteq(value)` |
|
|
236
|
+
| `filter :recently_released, scope: true, type: :boolean` | `recently_released` | `{ kind: :scope, target: :recently_released }` | `relation.recently_released` when the value is truthy, the relation untouched when it is false |
|
|
237
|
+
|
|
238
|
+
A `nil` value is skipped, so an omitted filter never narrows anything. `match:` triggers partial
|
|
239
|
+
matching only for the literal `:partial`; any other value falls through to `:eq`. `required: true`
|
|
240
|
+
is honoured only on the plain `:eq` form; range and scope arguments are always optional. Passing
|
|
241
|
+
`type:` explicitly turns inference off for that filter, so an enum's values then have to come
|
|
242
|
+
from an explicit `enum:` — omit `type:` to let `defined_enums` fill it in. The
|
|
243
|
+
`:list` definition additionally accepts `page`, `per_page` and `order` because its metadata
|
|
244
|
+
carries `paginated: true`, and `ExplicitSchema` advertises those three properties.
|
|
245
|
+
|
|
246
|
+
`order` is a bare attribute name, optionally prefixed with `-` for descending, and is ignored
|
|
247
|
+
unless the column is in the `order_whitelist`.
|
|
248
|
+
|
|
249
|
+
## Command tools
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
class ProductReindex
|
|
253
|
+
include Mcpable::Tool
|
|
254
|
+
|
|
255
|
+
mcp_tool do
|
|
256
|
+
name "product_reindex"
|
|
257
|
+
description "Rebuilds the search index for one product."
|
|
258
|
+
argument :product_id, :integer, required: true, description: "Product id."
|
|
259
|
+
annotations read_only: false, destructive: true
|
|
260
|
+
profiles :default, :admin
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def call(product_id:)
|
|
264
|
+
Product.find(product_id).reindex!
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`argument`'s type is positional, not a keyword: `argument :product_id, :integer, required: true`.
|
|
270
|
+
|
|
271
|
+
`name` defaults to the underscored class name, namespace included (`Search::Reindex` →
|
|
272
|
+
`search_reindex`). Only declared arguments are forwarded to `#call`, as keywords — pagination
|
|
273
|
+
keys and anything else are stripped — so the class needs a zero-argument `new` and a keyword
|
|
274
|
+
`#call`. A non-`Result` return value is wrapped in `Result.ok`. `annotations` starts empty here,
|
|
275
|
+
and `read_only?` defaults to `true`, so a write tool must say `annotations read_only: false`.
|
|
276
|
+
Declaring `metadata model:, action:, policy:` lets the Pundit middleware authorize a command
|
|
277
|
+
tool the same way it authorizes a resource; the action may be any name, since the middleware
|
|
278
|
+
just calls `#<action>?`.
|
|
279
|
+
|
|
280
|
+
### Reaching the caller from inside a tool
|
|
281
|
+
|
|
282
|
+
Before `#call` runs, the compiled handler assigns the `ToolCall` to the instance if it responds
|
|
283
|
+
to `mcp_call=` — which `Mcpable::Tool` provides. That gives every tool three readers:
|
|
284
|
+
|
|
285
|
+
| Reader | Returns |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| `current_user` | `ctx.assigns[:user]`, whatever your auth middleware put there |
|
|
288
|
+
| `current_scope` | `ctx.assigns[:scope]`, set by `Pundit::Authorize` on `:list` and `:show` only |
|
|
289
|
+
| `current_context` | the transport's context hash, or `{}` |
|
|
290
|
+
|
|
291
|
+
A write tool should scope its own lookups, because `Pundit::Authorize` does **not** resolve a
|
|
292
|
+
`Policy::Scope` for a non-read action — `current_scope` is `nil` inside an `action: :update`
|
|
293
|
+
tool. Resolve the scopes yourself:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
class MoveProductTool
|
|
297
|
+
include Mcpable::Tool
|
|
298
|
+
|
|
299
|
+
mcp_tool do
|
|
300
|
+
name "move_product"
|
|
301
|
+
description "Moves a product to another category inside the current user's store."
|
|
302
|
+
argument :product_id, :integer, required: true, description: "Product id."
|
|
303
|
+
argument :category_id, :integer, required: true, description: "Target category id."
|
|
304
|
+
annotations read_only: false, destructive: false
|
|
305
|
+
metadata model: Product, action: :update, policy: ProductPolicy
|
|
306
|
+
profiles :default
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def call(product_id:, category_id:)
|
|
310
|
+
product = visible(Product, ProductPolicy).find_by(id: product_id)
|
|
311
|
+
return Mcpable::Result.fail("product not found") if product.nil?
|
|
312
|
+
|
|
313
|
+
category = visible(Category, CategoryPolicy).find_by(id: category_id)
|
|
314
|
+
return Mcpable::Result.fail("category not found") if category.nil?
|
|
315
|
+
|
|
316
|
+
product.update!(category: category)
|
|
317
|
+
product.slice(:id, :store_id, :category_id, :name, :sku, :status)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
def visible(model, policy)
|
|
323
|
+
policy::Scope.new(current_user, model.all).resolve
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`metadata action: :update` gets the admin-only `ProductPolicy#update?` check for free; the two
|
|
329
|
+
`Scope` resolutions are what stop an admin of one store from moving another store's product.
|
|
330
|
+
|
|
331
|
+
## Pipeline
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
Mcpable.configure do |config|
|
|
335
|
+
config.pipeline.use(MyApp::Mcp::AuthenticateUser)
|
|
336
|
+
config.pipeline.use(Mcpable::Pundit::Authorize)
|
|
337
|
+
config.pipeline.use(MyApp::Mcp::AuditLog)
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
The first `use` is the outermost middleware: the stack is folded from the inside out
|
|
342
|
+
(`middlewares.reverse.reduce(HANDLER)`), so `AuthenticateUser` wraps `Pundit::Authorize`, which
|
|
343
|
+
wraps `AuditLog`, which wraps the definition's handler. A middleware communicates only through
|
|
344
|
+
`ctx.assigns`. Any `StandardError` raised inside the pipeline goes through `error_mapper`, and
|
|
345
|
+
the pipeline always returns a `Result` — a handler or middleware that returns something else is
|
|
346
|
+
turned into `Result.fail("invalid result")`.
|
|
347
|
+
|
|
348
|
+
Execute a tool with `Mcpable.runtime.call_tool(name, args:, context:)`, which symbolizes keys,
|
|
349
|
+
rejects unknown arguments, rejects missing required ones, coerces each value to its declared
|
|
350
|
+
type (`Integer`, `Float`, `true/false/"true"/"1"/1/...`, `Date.iso8601`, `Time.iso8601`), then
|
|
351
|
+
runs the pipeline.
|
|
352
|
+
|
|
353
|
+
### Writing your own middleware
|
|
354
|
+
|
|
355
|
+
Subclass `Mcpable::Ports::Middleware`. It is constructed as `new(app, *args)` — the extra args
|
|
356
|
+
are whatever you passed to `pipeline.use` — and exposes the next link as `app`.
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
class AuthenticateUser < Mcpable::Ports::Middleware
|
|
360
|
+
def call(ctx)
|
|
361
|
+
token = ctx.context[:api_token]
|
|
362
|
+
user = token && User.find_by(api_token: token)
|
|
363
|
+
return Mcpable::Result.deny("unauthenticated") if user.nil?
|
|
364
|
+
|
|
365
|
+
ctx.assigns[:user] = user
|
|
366
|
+
@app.call(ctx)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Returning a `Result` without calling `@app` short-circuits the rest of the stack, including the
|
|
372
|
+
handler. Everything downstream reads what you wrote into `assigns`; `ctx.user` and `ctx.scope`
|
|
373
|
+
are just readers for `assigns[:user]` and `assigns[:scope]`.
|
|
374
|
+
|
|
375
|
+
A middleware can also run *after* the call by inspecting the returned `Result`:
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
class AuditLog < Mcpable::Ports::Middleware
|
|
379
|
+
def call(ctx)
|
|
380
|
+
result = @app.call(ctx)
|
|
381
|
+
Rails.logger.info(
|
|
382
|
+
"[mcp] tool=#{ctx.definition.name} user=#{ctx.user&.id.inspect} status=#{result.status}"
|
|
383
|
+
)
|
|
384
|
+
result
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`ctx` gives a middleware `definition`, `args` (already coerced), `context`, `assigns`, `user`
|
|
390
|
+
and `scope`. Configuration arguments are passed positionally:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
config.pipeline.use(RateLimit, 100, per: :minute) # RateLimit.new(app, 100, per: :minute)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Pundit integration
|
|
397
|
+
|
|
398
|
+
`require "mcpable/pundit"` gives you `Mcpable::Pundit::Authorize`. It is duck-typed and does
|
|
399
|
+
not need the pundit gem. For each call it reads `metadata[:policy]`, `metadata[:model]` and
|
|
400
|
+
`metadata[:action]`; with no `:policy` it passes straight through. It then builds
|
|
401
|
+
`policy_class.new(ctx.user, model)` and denies with `Result.deny("not authorized")` unless
|
|
402
|
+
`#<action>?` is true.
|
|
403
|
+
|
|
404
|
+
For the read actions `:list` and `:show` — **both** of them — it resolves `Policy::Scope` and
|
|
405
|
+
writes the relation into `ctx.assigns[:scope]`, which the source then merges. Scoping `:show`
|
|
406
|
+
too is what stops `<plural>_show` from reading a record the caller may not see.
|
|
407
|
+
|
|
408
|
+
A policy whose class does not define its own `Scope` constant raises
|
|
409
|
+
`Mcpable::MissingScopeError` ("policy Foo has no Scope class") rather than silently listing
|
|
410
|
+
every tenant. Note the `const_defined?(:Scope, false)` lookup: inheriting a `Scope` from a base
|
|
411
|
+
policy does not count, so every policy needs its own.
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
class ProductPolicy < ApplicationPolicy
|
|
415
|
+
def list? = user.present?
|
|
416
|
+
|
|
417
|
+
def show? = list?
|
|
418
|
+
|
|
419
|
+
def update? = user.present? && user.admin?
|
|
420
|
+
|
|
421
|
+
class Scope < ApplicationPolicy::Scope
|
|
422
|
+
def resolve
|
|
423
|
+
scope.where(store_id: user.store_id)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Because the scope is merged rather than replaced, a hostile `store_id` filter cannot widen it:
|
|
430
|
+
the caller's own scope and the filter are ANDed, and the result is empty.
|
|
431
|
+
|
|
432
|
+
## Sources
|
|
433
|
+
|
|
434
|
+
`Mcpable::ActiveRecord::Source` is installed automatically for AR models by
|
|
435
|
+
`require "mcpable/active_record"`. It maps filters as described in the table above, composes
|
|
436
|
+
visibility with `relation.merge(scope)`, counts before paginating, and restricts ordering to
|
|
437
|
+
the `order_whitelist:` given to its constructor.
|
|
438
|
+
|
|
439
|
+
`Mcpable::Sources::EnumerableSource` wraps any Enumerable or a callable returning one, and is
|
|
440
|
+
the right base for in-memory data. It intersects with `scope` (`records & scope.to_a`, and a
|
|
441
|
+
non-Enumerable scope raises) and implements `:eq`, `:match`, `:range_from` and `:range_to`;
|
|
442
|
+
`:scope`-kind filters are a no-op there, since there is no named scope to call, and its
|
|
443
|
+
ordering is unrestricted.
|
|
444
|
+
|
|
445
|
+
`order_whitelist` is only handed to the automatic source factory. If you declare `source`
|
|
446
|
+
yourself, pass the whitelist to your own constructor — the DSL's copy is never consulted.
|
|
447
|
+
|
|
448
|
+
### Implementing a custom source
|
|
449
|
+
|
|
450
|
+
Subclass `Mcpable::Ports::Source`. Its `initialize(base)` stores `base`, and you implement two
|
|
451
|
+
methods. `fetch` must return a `Mcpable::Ports::Source::Page`, a
|
|
452
|
+
`Data.define(:records, :total, :page, :per_page)`.
|
|
453
|
+
|
|
454
|
+
`filters` arrives as a Hash of `Argument => value`, with **every** declared filter argument
|
|
455
|
+
present and a `nil` value for the ones the caller omitted. Read the spec off the argument with
|
|
456
|
+
`#filter_kind` and `#filter_target`.
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
class CatalogApiSource < Mcpable::Ports::Source
|
|
460
|
+
def fetch(filters:, scope: nil, page: nil, per_page: nil, order: nil)
|
|
461
|
+
page = [page.to_i, 1].max
|
|
462
|
+
per_page = per_page.to_i.positive? ? per_page.to_i : 25
|
|
463
|
+
|
|
464
|
+
query = query_params(filters).merge(page: page, per_page: per_page)
|
|
465
|
+
query[:sort] = order if order
|
|
466
|
+
query[:store_id] = scope.fetch(:store_id) if scope
|
|
467
|
+
|
|
468
|
+
body = base.get("/products", query)
|
|
469
|
+
|
|
470
|
+
Page.new(
|
|
471
|
+
records: body.fetch("data"),
|
|
472
|
+
total: body.fetch("meta").fetch("total"),
|
|
473
|
+
page: page,
|
|
474
|
+
per_page: per_page
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def find(id, scope: nil)
|
|
479
|
+
record = base.get("/products/#{id}")
|
|
480
|
+
return nil if record.nil?
|
|
481
|
+
return nil if scope && record["store_id"] != scope.fetch(:store_id)
|
|
482
|
+
|
|
483
|
+
record
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
private
|
|
487
|
+
|
|
488
|
+
def query_params(filters)
|
|
489
|
+
(filters || {}).each_with_object({}) do |(argument, value), out|
|
|
490
|
+
next if value.nil?
|
|
491
|
+
|
|
492
|
+
target = argument.filter_target || argument.name
|
|
493
|
+
|
|
494
|
+
case argument.filter_kind
|
|
495
|
+
when :eq then out[target] = value
|
|
496
|
+
when :match then out[:"#{target}_contains"] = value
|
|
497
|
+
when :range_from then out[:"#{target}_gte"] = value
|
|
498
|
+
when :range_to then out[:"#{target}_lte"] = value
|
|
499
|
+
when :scope then out[target] = true if value
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Two rules the core relies on:
|
|
507
|
+
|
|
508
|
+
- `scope` is composed, never widened. Whatever your auth middleware put in
|
|
509
|
+
`ctx.assigns[:scope]` reaches the source as `scope:`; a source may narrow further but must
|
|
510
|
+
never ignore it. A `nil` scope means "no extra constraint", which is only correct when the
|
|
511
|
+
pipeline deliberately left it unset.
|
|
512
|
+
- `find` returns `nil` for a record outside the scope, not a raised error. The compiled `show`
|
|
513
|
+
handler turns `nil` into `Result.fail("not found")`, so a foreign record and a missing one
|
|
514
|
+
are indistinguishable to the caller.
|
|
515
|
+
|
|
516
|
+
Records may be anything the serializer can read — AR models, `Struct`s or plain hashes — since
|
|
517
|
+
`attributes` are read by `public_send` with an `[]` fallback.
|
|
518
|
+
|
|
519
|
+
Declare it with `source`, either as an instance or as a callable resolved per call:
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
source CatalogApiSource.new(CatalogClient.new)
|
|
523
|
+
source -> { CatalogApiSource.new(CatalogClient.current) }
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
## Profiles
|
|
527
|
+
|
|
528
|
+
Every definition declares `profiles` (default `[:default]`), and a transport serves exactly one
|
|
529
|
+
profile. That is how one set of definitions becomes several tool sets:
|
|
530
|
+
|
|
531
|
+
```ruby
|
|
532
|
+
class Product
|
|
533
|
+
include Mcpable::Resource
|
|
534
|
+
|
|
535
|
+
mcpable do
|
|
536
|
+
# ...
|
|
537
|
+
profiles :default, :admin
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
class PurgeStoreTool
|
|
542
|
+
include Mcpable::Tool
|
|
543
|
+
|
|
544
|
+
mcp_tool do
|
|
545
|
+
name "purge_store"
|
|
546
|
+
annotations read_only: false, destructive: true
|
|
547
|
+
profiles :admin
|
|
548
|
+
end
|
|
549
|
+
# ...
|
|
550
|
+
end
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
```ruby
|
|
554
|
+
Mcpable::Transports::OfficialMcp.new(profile: :default).list_tools # products_*
|
|
555
|
+
Mcpable::Transports::OfficialMcp.new(profile: :admin).list_tools # products_* and purge_store
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
`Mcpable.registry.for_profile(:admin)` is the underlying selection. Over HTTP the profile comes
|
|
559
|
+
from `?profile=`; over stdio you choose it when constructing the transport.
|
|
560
|
+
|
|
561
|
+
## Serving
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
require "mcpable/transports/official_mcp"
|
|
565
|
+
|
|
566
|
+
transport = Mcpable::Transports::OfficialMcp.new(profile: :default)
|
|
567
|
+
transport.list_tools
|
|
568
|
+
transport.handle(request_body, context: { api_token: "..." })
|
|
569
|
+
transport.serve_stdio(context: { api_token: ENV["MCP_API_TOKEN"] })
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
`OfficialMcp.new` also takes `registry:`, `runtime:`, `server_name:` (default `"mcpable"`) and
|
|
573
|
+
`server_version:` (default `Mcpable::VERSION`). `handle` returns a JSON String when given a
|
|
574
|
+
String and a Hash when given a Hash, and returns `nil` for a JSON-RPC notification such as
|
|
575
|
+
`notifications/initialized` — which is why the Rails controller answers those with `202` and an
|
|
576
|
+
empty body.
|
|
577
|
+
|
|
578
|
+
Both entry points take the same `context:` hash. Over HTTP a `context_builder` derives it from
|
|
579
|
+
the Rack env once per request; over stdio there is no request env, so the process supplies its
|
|
580
|
+
context once at startup and every tool call on that connection runs under it. One stdio process
|
|
581
|
+
therefore serves exactly one actor.
|
|
582
|
+
|
|
583
|
+
A stdio entry point must keep fd 1 pure: the JSON-RPC framing is the only thing allowed on
|
|
584
|
+
stdout, so application logging has to be moved to stderr *before* anything can write. Capture
|
|
585
|
+
the real stdout first, then redirect:
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
#!/usr/bin/env ruby
|
|
589
|
+
require_relative "../config/boot"
|
|
590
|
+
|
|
591
|
+
protocol_stream = $stdout.dup
|
|
592
|
+
protocol_stream.sync = true
|
|
593
|
+
$stdout.reopen($stderr)
|
|
594
|
+
|
|
595
|
+
require_relative "../config/environment"
|
|
596
|
+
|
|
597
|
+
Rails.logger = ActiveSupport::Logger.new($stderr)
|
|
598
|
+
ActiveRecord::Base.logger = Rails.logger
|
|
599
|
+
|
|
600
|
+
$stdout = protocol_stream
|
|
601
|
+
|
|
602
|
+
Mcpable::Transports::OfficialMcp
|
|
603
|
+
.new(profile: (ENV["MCPABLE_PROFILE"] || "default").to_sym)
|
|
604
|
+
.serve_stdio(context: { api_token: ENV["MCPABLE_API_TOKEN"] })
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Requiring `config/boot` before the redirection matters under Bundler, which may re-exec the
|
|
608
|
+
process and would otherwise inherit an already-swapped fd 1.
|
|
609
|
+
|
|
610
|
+
Built on the official [`mcp`](https://rubygems.org/gems/mcp) gem: tools are built with
|
|
611
|
+
`MCP::Tool.define`, requests go through `MCP::Server#handle_json` (String in, String out) or
|
|
612
|
+
`#handle` (Hash in, Hash out), and stdio uses `MCP::Server::Transports::StdioTransport`.
|
|
613
|
+
`Result.ok` becomes a text response carrying the JSON payload; `denied` and `error` become
|
|
614
|
+
`isError` responses whose text is `"#{status}: #{error}"`, e.g. `denied: not authorized`.
|
|
615
|
+
|
|
616
|
+
## Testing your tools
|
|
617
|
+
|
|
618
|
+
A definition is callable without any server. `Mcpable.runtime.call_tool` runs the full pipeline
|
|
619
|
+
and returns a `Result`, so assertions go straight on that. (The one exception is an unregistered
|
|
620
|
+
tool name, which raises `Mcpable::UnknownToolError` from outside the pipeline, so the
|
|
621
|
+
`error_mapper` never sees it.)
|
|
622
|
+
|
|
623
|
+
```ruby
|
|
624
|
+
result = Mcpable.runtime.call_tool(
|
|
625
|
+
"products_list",
|
|
626
|
+
args: { "category_id" => 3, "per_page" => 5 },
|
|
627
|
+
context: { api_token: "acme-member-token" }
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
result.ok? # => true
|
|
631
|
+
result.payload[:total] # => 2
|
|
632
|
+
result.payload[:records] # => [{ id: 4, name: "Anchor Bolt Set", ... }, ...]
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
```ruby
|
|
636
|
+
denied = Mcpable.runtime.call_tool(
|
|
637
|
+
"move_product",
|
|
638
|
+
args: { product_id: 4, category_id: 2 },
|
|
639
|
+
context: {}
|
|
640
|
+
)
|
|
641
|
+
denied.denied? # => true
|
|
642
|
+
denied.error # => "unauthenticated"
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Argument validation happens before the pipeline, so `call_tool` is also where you assert on
|
|
646
|
+
rejected input, and no middleware runs for those:
|
|
647
|
+
|
|
648
|
+
```ruby
|
|
649
|
+
r = Mcpable.runtime
|
|
650
|
+
r.call_tool("products_list", args: { cost_cents: 1 }, context: {}).error
|
|
651
|
+
# => "unknown arguments: cost_cents"
|
|
652
|
+
r.call_tool("move_product", args: { product_id: 4 }, context: {}).error
|
|
653
|
+
# => "missing required arguments: category_id"
|
|
654
|
+
r.call_tool("products_list", args: { category_id: "abc" }, context: {}).error
|
|
655
|
+
# => "category_id is not an integer"
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
`Mcpable.reset!` replaces the registry, configuration and runtime with fresh ones, which is the
|
|
659
|
+
cleanest way to isolate a test that registers throwaway definitions. Compiling a definition by
|
|
660
|
+
hand is useful for negative tests:
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
builder = Mcpable::Dsl::ResourceBuilder.new(Product)
|
|
664
|
+
builder.name "leak"
|
|
665
|
+
builder.attributes :id, :store_id, :name
|
|
666
|
+
builder.policy ScopelessProductPolicy
|
|
667
|
+
builder.actions :list
|
|
668
|
+
builder.profiles :leaky
|
|
669
|
+
builder.compile.each { |definition| Mcpable.registry.register(definition) }
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Isolating a test without touching globals
|
|
673
|
+
|
|
674
|
+
`Runtime` and `OfficialMcp` accept an injected `Registry` and `Configuration`, and both are
|
|
675
|
+
honoured all the way down — the `error_mapper` a pipeline reaches for and the `schema_strategy` a
|
|
676
|
+
transport builds schemas with come from the injected configuration, not from `Mcpable.config`.
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
config = Mcpable::Configuration.new
|
|
680
|
+
config.error_mapper = ->(e) { Mcpable::Result.fail("boom: #{e.message}") }
|
|
681
|
+
config.pipeline.use(MyApp::Mcp::AuthenticateUser)
|
|
682
|
+
|
|
683
|
+
registry = Mcpable::Registry.new
|
|
684
|
+
registry.register(definition)
|
|
685
|
+
|
|
686
|
+
runtime = Mcpable::Runtime.new(registry: registry, config: config)
|
|
687
|
+
runtime.call_tool("products_list", args: {}, context: {})
|
|
688
|
+
|
|
689
|
+
transport = Mcpable::Transports::OfficialMcp.new(registry: registry, runtime: runtime)
|
|
690
|
+
transport.list_tools
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
A transport given no `config:` of its own adopts the one carried by its runtime, and falls back to
|
|
694
|
+
`Mcpable.config` only when neither was injected.
|
|
695
|
+
|
|
696
|
+
Note that the DSL always registers into the **global** registry: `include Mcpable::Resource` and
|
|
697
|
+
`mcp_tool` call `Mcpable.registry.register` at class-definition time. An injected registry
|
|
698
|
+
therefore holds only definitions you compile and register by hand, which is what the negative-test
|
|
699
|
+
example above does.
|
|
700
|
+
|
|
701
|
+
## Adapter require paths
|
|
702
|
+
|
|
703
|
+
`require "mcpable"` loads the core only. Adapters are opt-in:
|
|
704
|
+
|
|
705
|
+
```ruby
|
|
706
|
+
require "mcpable/transports/official_mcp" # needs the mcp gem
|
|
707
|
+
require "mcpable/active_record" # needs ActiveRecord
|
|
708
|
+
require "mcpable/pundit" # duck-typed, does not need the pundit gem
|
|
709
|
+
require "mcpable/rails" # needs Rails; also loads the official mcp transport
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
`mcpable/rails` mounts `POST /mcp`, and on every `to_prepare` it resets the registry and
|
|
713
|
+
re-registers the definitions in `config.mcpable.eager_load_paths` (default
|
|
714
|
+
`%w[app/models app/tools]`) through the application's Zeitwerk loaders, so code reloading
|
|
715
|
+
neither drops tools nor raises `DuplicateToolError`. It skips registration while the database
|
|
716
|
+
schema is not yet loaded — logging a warning instead — so `db:create` and `db:migrate` still
|
|
717
|
+
work on a fresh checkout even though the DSL reads `columns_hash` at class-definition time.
|
|
718
|
+
|
|
719
|
+
## Not supported yet
|
|
720
|
+
|
|
721
|
+
Stated plainly, so you do not go looking:
|
|
722
|
+
|
|
723
|
+
- Tools only. There is no `resources/`, `prompts/`, sampling, progress or cancellation support.
|
|
724
|
+
- Resource actions are `:list` and `:show`. Writes are command tools you author yourself;
|
|
725
|
+
declaring `actions :update` (or any other write action) raises an `ArgumentError` at compile
|
|
726
|
+
time rather than registering nothing.
|
|
727
|
+
- Offset pagination only (`page` / `per_page`); no cursors, no `has_more`, no upper clamp on
|
|
728
|
+
`per_page`.
|
|
729
|
+
- Attribute visibility is decided per call, not per record: `attribute ... if:` receives the
|
|
730
|
+
`ToolCall`, never the record being serialized, so "this row's field is visible but that row's
|
|
731
|
+
is not" needs a source or a command tool of your own. There is also no caller-supplied field
|
|
732
|
+
selection — the whitelist is the whole contract.
|
|
733
|
+
- Filters address columns on the model's own table, and the five kinds in the table above are
|
|
734
|
+
all of them. There is no join, association, `IN`, negation, OR or free-form-search filter.
|
|
735
|
+
- `enum:` reaches the JSON Schema only; the runtime does not check membership itself.
|
|
736
|
+
- Resource tool names are always `<plural>_list` / `<plural>_show`; only the singular base is
|
|
737
|
+
configurable.
|
|
738
|
+
- No output schemas or structured content: a payload is serialized to one JSON text block, and
|
|
739
|
+
the only annotations are `readOnlyHint`, `destructiveHint` and `openWorldHint`.
|
|
740
|
+
- No streaming or SSE; the Rails engine appends `POST /mcp` only, at a path that is not
|
|
741
|
+
configurable, with no session handling.
|
|
742
|
+
- One global pipeline for every tool — no per-tool stacks, and `Pipeline` offers only `use` and
|
|
743
|
+
`clear`.
|
|
744
|
+
- No built-in authentication, rate limiting, instrumentation or per-tool timeouts — those are
|
|
745
|
+
middlewares you write.
|
|
746
|
+
- Nothing is async, and nothing guarantees thread safety; the registry is a plain Hash.
|
|
747
|
+
- No test matchers or fixtures ship with the gem.
|
|
748
|
+
|
|
749
|
+
## The sample application
|
|
750
|
+
|
|
751
|
+
A complete working example lives in the `mcpable-demo` repository: a multi-tenant Rails 8.1 app
|
|
752
|
+
on SQLite, containerized, with two stores sharing one database. It demonstrates the whole
|
|
753
|
+
surface described above — `mcpable` blocks on `Store`, `Category` and `Product`, a Pundit policy
|
|
754
|
+
with its own `Scope` per model, a `move_product` command tool, both middlewares, the HTTP
|
|
755
|
+
endpoint and the stdio entry point, and a checked-in `.mcp.json` that registers the server with
|
|
756
|
+
Claude Code. Its test suite asserts tenancy isolation, every filter kind, pagination, ordering,
|
|
757
|
+
command-tool authorization and `MissingScopeError` handling over real JSON-RPC requests, and
|
|
758
|
+
`bin/mcp_demo` prints the same walkthrough in readable form.
|
|
759
|
+
|
|
760
|
+
## Status
|
|
761
|
+
|
|
762
|
+
v0.1. Core, DSL, pipeline, `EnumerableSource`, `ExplicitSchema`, the Pundit middleware, the
|
|
763
|
+
Rails loader and the `mcp` transport are covered by this repository's specs.
|
|
764
|
+
|
|
765
|
+
`mcpable/rails` and `mcpable/active_record` cannot be unit-tested here — there is no Rails or
|
|
766
|
+
ActiveRecord in the development bundle — but they are exercised end to end by the
|
|
767
|
+
`mcpable-demo` application in the sibling directory, which has proven the Rails engine and its
|
|
768
|
+
appended route, the ActiveRecord source against a real database, and both transports, under
|
|
769
|
+
both code reloading and eager loading.
|
|
770
|
+
|
|
771
|
+
Splitting the adapters into separate gems is a later step; for now everything ships from one
|
|
772
|
+
gemspec whose only runtime dependency is `mcp`.
|
|
773
|
+
|
|
774
|
+
## License
|
|
775
|
+
|
|
776
|
+
MIT.
|