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