mcp_toolkit 0.3.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +43 -0
  3. data/.rubocop.yml +98 -0
  4. data/CHANGELOG.md +123 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +301 -0
  7. data/Rakefile +21 -0
  8. data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
  9. data/config/routes.rb +18 -0
  10. data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
  11. data/lib/mcp_toolkit/auth/authority.rb +75 -0
  12. data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
  13. data/lib/mcp_toolkit/auth/introspection.rb +162 -0
  14. data/lib/mcp_toolkit/configuration.rb +209 -0
  15. data/lib/mcp_toolkit/engine.rb +21 -0
  16. data/lib/mcp_toolkit/errors/base.rb +10 -0
  17. data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
  18. data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
  19. data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
  20. data/lib/mcp_toolkit/field_selection.rb +93 -0
  21. data/lib/mcp_toolkit/filtering.rb +152 -0
  22. data/lib/mcp_toolkit/get_executor.rb +32 -0
  23. data/lib/mcp_toolkit/list_executor.rb +137 -0
  24. data/lib/mcp_toolkit/registry.rb +67 -0
  25. data/lib/mcp_toolkit/resource.rb +129 -0
  26. data/lib/mcp_toolkit/resource_schema.rb +163 -0
  27. data/lib/mcp_toolkit/serialization.rb +62 -0
  28. data/lib/mcp_toolkit/serializer/base.rb +285 -0
  29. data/lib/mcp_toolkit/server.rb +44 -0
  30. data/lib/mcp_toolkit/session.rb +46 -0
  31. data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
  32. data/lib/mcp_toolkit/token_kinds.rb +14 -0
  33. data/lib/mcp_toolkit/tools/base.rb +100 -0
  34. data/lib/mcp_toolkit/tools/get.rb +57 -0
  35. data/lib/mcp_toolkit/tools/list.rb +83 -0
  36. data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
  37. data/lib/mcp_toolkit/tools/resources.rb +27 -0
  38. data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
  39. data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
  40. data/lib/mcp_toolkit/version.rb +5 -0
  41. data/lib/mcp_toolkit.rb +118 -0
  42. data/sig/mcp_toolkit.rbs +4 -0
  43. metadata +147 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f6ccc255fce267fe8c0caafe02f29e8a5db48e9b410494ef463bf9520047cbe2
4
+ data.tar.gz: feb8b4e46f7e7054abef0bf99ad8d633ebb30d1fb36624b350313d35e83824f9
5
+ SHA512:
6
+ metadata.gz: 50cc58bd25162c2ecf70b97b90b1de329a8b3858494dc719699d8cf609607d3c6f8abacef5c1ed1f0e0eea8b7dc9760c0a4752a816433e65ee661147404a53d8
7
+ data.tar.gz: 72474820f84e31bf3601d5ae26e6e153069accf7f51d9c3bc4e11428be2a068139f1ecc9656b5e4fc95bb40275f999a35e24e60bae5c0462dcdf03098e40a030
@@ -0,0 +1,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ name: RSpec (Ruby ${{ matrix.ruby }})
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ ruby: ["3.2", "3.3", "3.4"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: ${{ matrix.ruby }}
20
+ bundler-cache: true
21
+ - run: bundle exec rspec
22
+
23
+ rubocop:
24
+ name: RuboCop
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: "3.4"
31
+ bundler-cache: true
32
+ - run: bundle exec rubocop
33
+
34
+ brakeman:
35
+ name: Brakeman
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: ruby/setup-ruby@v1
40
+ with:
41
+ ruby-version: "3.4"
42
+ bundler-cache: true
43
+ - run: bundle exec brakeman --force --no-progress --quiet --no-pager
data/.rubocop.yml ADDED
@@ -0,0 +1,98 @@
1
+ plugins:
2
+ - rubocop-performance
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 3.2
6
+ NewCops: enable
7
+ SuggestExtensions: false
8
+ Exclude:
9
+ - "bin/**/*"
10
+ - "vendor/**/*"
11
+ - "tmp/**/*"
12
+ # Specs follow manual conventions (named subjects, in-example test doubles)
13
+ # that don't map cleanly to the library cops; they are not linted.
14
+ - "spec/**/*"
15
+
16
+ # --- Layout -----------------------------------------------------------------
17
+
18
+ Layout/LineLength:
19
+ Max: 120
20
+
21
+ # --- Style ------------------------------------------------------------------
22
+
23
+ # Prefer FLAT (compact) leaf declarations — `class McpToolkit::Auth::Authenticator`
24
+ # — over the `module ... module ... class` pyramid. The only exception is the gem
25
+ # entry point, which must open `module McpToolkit` as a block to host the Zeitwerk
26
+ # loader setup, the top-level configuration methods, and the `MCPToolkit` alias;
27
+ # compact is not expressible there. Files that genuinely defined several constants
28
+ # under one namespace (the errors hierarchy) are split one-constant-per-file under
29
+ # lib/mcp_toolkit/errors/ so each can be declared compactly.
30
+ Style/ClassAndModuleChildren:
31
+ EnforcedStyle: compact
32
+ Exclude:
33
+ - "lib/mcp_toolkit.rb"
34
+
35
+ # Define class methods as `def self.x`, never `class << self` (so the singleton
36
+ # style stays consistent and greppable across the gem).
37
+ Style/ClassMethodsDefinitions:
38
+ EnforcedStyle: def_self
39
+
40
+ # The gem (gemspec, Gemfile, lib) is written with double-quoted strings; match
41
+ # that convention instead of churning every literal.
42
+ Style/StringLiterals:
43
+ EnforcedStyle: double_quotes
44
+
45
+ Style/StringLiteralsInInterpolation:
46
+ EnforcedStyle: double_quotes
47
+
48
+ # This is a library; per-class/module YARD comments are not enforced.
49
+ Style/Documentation:
50
+ Enabled: false
51
+
52
+ # --- Naming -----------------------------------------------------------------
53
+
54
+ # `has_one` / `has_many` are association-DSL names mirroring AMS / ActiveRecord;
55
+ # they are not boolean predicates.
56
+ Naming/PredicatePrefix:
57
+ AllowedMethods:
58
+ - has_one
59
+ - has_many
60
+
61
+ # --- Metrics ----------------------------------------------------------------
62
+ # The extracted framework intentionally carries some larger units (the transport
63
+ # controller, the server wiring, the registry/executor dispatch). Bump the
64
+ # metrics cops to fit the code as written rather than contorting it; keep them
65
+ # enabled so genuinely runaway methods still get flagged.
66
+
67
+ Metrics/MethodLength:
68
+ Max: 30
69
+
70
+ Metrics/AbcSize:
71
+ Max: 30
72
+
73
+ Metrics/CyclomaticComplexity:
74
+ Max: 12
75
+
76
+ Metrics/PerceivedComplexity:
77
+ Max: 12
78
+
79
+ Metrics/ClassLength:
80
+ Max: 250
81
+
82
+ Metrics/ModuleLength:
83
+ Max: 250
84
+
85
+ Metrics/BlockLength:
86
+ Max: 50
87
+ Exclude:
88
+ - "*.gemspec"
89
+ - "Gemfile"
90
+
91
+ Metrics/ParameterLists:
92
+ Max: 6
93
+
94
+ # `id` is a domain-meaningful record identifier.
95
+ Naming/MethodParameterName:
96
+ AllowedNames:
97
+ - id
98
+ - n
data/CHANGELOG.md ADDED
@@ -0,0 +1,123 @@
1
+ ## [0.3.0] - 2026-07-03
2
+
3
+ ### Added
4
+
5
+ - **Resource discovery DX** improvements, closing gaps hit when an MCP agent had to guess a
6
+ related resource's name rather than discover it:
7
+ - The `resource_schema` tool now names, for each relationship, the `target_resource` it
8
+ resolves to (callable via `list` / `get`). A `scheduled_notifications.notification` link is
9
+ thus discoverably the `notifications` resource instead of a name to guess. Additive and
10
+ backward compatible: it is omitted when the target can't be resolved (e.g. a polymorphic
11
+ link), so existing relationship consumers are unaffected.
12
+ - An unknown resource name now raises a "did you mean" message — the closest registered
13
+ name(s) via Ruby's stdlib `DidYouMean::SpellChecker` (with a dependency-free edit-distance
14
+ fallback), plus the full list when the catalog is short. It flows unchanged to the caller as
15
+ a clean `InvalidParams` tool error via the existing `resolve_descriptor` path.
16
+
17
+ ### Changed
18
+
19
+ - The transport controller now logs a WARN when a POST arrives with no matching session
20
+ (`mcp_render_session_not_found`): a greppable, id/token-free line recording only whether a
21
+ session-id header was present, so a non-shared `cache_store` misconfiguration surfaces in a
22
+ satellite's own logs instead of only as a client-side 404. The logger defaults to
23
+ `Rails.logger` and is overridable via the new `mcp_logger` controller hook.
24
+
25
+ ## [0.2.0] - 2026-07-02
26
+
27
+ ### Added
28
+
29
+ - **Sparse fieldsets** (JSON:API `fields[type]`) on the `get` and `list` tools. Pass
30
+ `fields` — an array of names or a comma-separated string — to return only the named
31
+ attributes and/or relationships, shrinking the response (a token win for MCP clients).
32
+ Attribute and relationship names share one flat namespace; `resource_schema` advertises
33
+ the valid values. Unknown names are rejected with `InvalidParams` (consistent with
34
+ unknown filter keys), so a typo is actionable rather than silently dropped.
35
+ - `McpToolkit::Serializer::Base` honors the selection NATIVELY: `serialize_one` /
36
+ `serialize_collection` / `serializable_hash` take an optional `fields:` keyword, and
37
+ unselected `has_many` relationships are never loaded (a query win, not just a payload
38
+ win). Under a selection the `links` block is omitted entirely when no relationship is
39
+ selected.
40
+ - `McpToolkit::FieldSelection` parses + validates the request; `McpToolkit::Serialization`
41
+ bridges the executors to the serializer.
42
+ - **Backward compatible.** Omitting `fields` returns the full shape exactly as before. An
43
+ injected serializer that does NOT declare a `fields:` keyword still supports sparse
44
+ fieldsets — the toolkit prunes its output to the requested `fields` — so the serializer
45
+ injection contract is unchanged.
46
+
47
+ ## [0.1.0] - 2026-06-28
48
+
49
+ Initial extraction from two independently-grown internal MCP servers into a single
50
+ shared gem. Standardizes on the official `mcp` gem (`~> 0.18`) as the wrapped
51
+ JSON-RPC core. Per-tool scope is declared explicitly via `required_permissions_scope`,
52
+ and a mountable `McpToolkit::Engine` gives satellites their MCP routes without the
53
+ hand-rolled controller wiring.
54
+
55
+ ### Added
56
+
57
+ - `McpToolkit.configure` / `config` / `registry` / `reset_config!` — a single
58
+ injectable `Configuration` object (`MCPToolkit` alias provided).
59
+ - `McpToolkit::Server.build` — wraps `MCP::Server` (the official gem) with the
60
+ generic toolset registered and the per-request `server_context`.
61
+ - Generic, registry-driven tools subclassing `MCP::Tool`: `resources`,
62
+ `resource_schema`, `get`, `list`.
63
+ - `McpToolkit::Registry` + `Resource` DSL (`model` / `serializer` / `scope` /
64
+ `description` / `filterable`) + `ListExecutor` / `GetExecutor` /
65
+ `ResourceSchema`. The serializer is injectable per resource.
66
+ - `McpToolkit::Serializer::Base` — the default serializer DSL (`attributes`,
67
+ `has_one`, `has_many`, `translates`), with a documented `serialize_one` /
68
+ `serialize_collection` contract so an app's existing serializers slot in
69
+ unchanged.
70
+ - Dual-role authentication: satellite (`Auth::Introspection` +
71
+ `Auth::Authenticator`, with short-TTL caching) and authority (`Auth::Authority`
72
+ — local token authentication + introspection payload responder). All
73
+ config-driven. The introspection HTTP call is owned by
74
+ `Auth::AuthorityServerClient`.
75
+ - OAuth-style **scope** enforcement, declared EXPLICITLY per resource. Tokens
76
+ carry `scopes` of the form `<app>__<action>` (e.g. `notifications__read`). A
77
+ resource declares the scope it requires via `required_permissions_scope`, or a
78
+ satellite declares one default for all resources via
79
+ `Registry#default_required_permissions_scope`. A resource's effective scope is
80
+ its own declaration, else the registry default, else none. A tool is allowed
81
+ iff the token carries the required scope; a resource (and default) with no
82
+ declared scope is reachable by any valid token. Whether ANY scope is required
83
+ is decided per tool — there is NO app-wide permission setting.
84
+ - `Resource#required_permissions_scope` / `#effective_required_permissions_scope`
85
+ and `Registry#default_required_permissions_scope` / `#required_scope_for` — the
86
+ explicit scope DSL + resolution. `reset!` preserves the registry default (it's
87
+ declared in `configure`, not per-reload).
88
+ - `Tools::Base.with_account` / `.with_authentication` take an explicit
89
+ `required_scope:` keyword (resolved by the caller from the resource); the `list`
90
+ / `get` / `resource_schema` tools resolve the resource FIRST, then enforce its
91
+ effective scope. The discovery tools (`resources`, `resource_schema`) require
92
+ the registry default scope.
93
+ - A **mountable Rails engine**, `McpToolkit::Engine`, plus the gem-provided
94
+ `McpToolkit::ServerController`: a satellite writes `mount McpToolkit::Engine =>
95
+ "/mcp"` instead of four hand-declared routes + a controller. The controller's
96
+ parent class is configurable via `Configuration#parent_controller` (default
97
+ `"ActionController::Base"`; set `"ApplicationController"` for `helper_method`
98
+ compat). The engine is ADDITIVE — `Transport::ControllerMethods` remains a
99
+ standalone concern. Both the engine and the gem's `app/controllers` are loaded
100
+ only when Rails is present; the gem's non-Rails consumers and unit suite never
101
+ reference them. The four routes are drawn in the engine's `config/routes.rb`
102
+ (NOT a class-body `routes.draw`), so they survive Rails' routes_reloader — a
103
+ class-body draw is wiped on the first route reload, leaving the engine
104
+ route-less and every `/mcp` 404'ing. A regression spec boots a real
105
+ `Rails::Application` in an isolated subprocess and asserts the engine route set
106
+ survives a `reload_routes!`.
107
+ - `Auth::Introspection::Result#authorized_for_scope?(required_scope)` — exact-scope
108
+ check used by the tool layer.
109
+ - `scopes` field in the introspection contract: parsed by the satellite
110
+ (`Auth::Introspection`) and emitted by the authority
111
+ (`Auth::Authority#introspection_payload`).
112
+ - Injectable `Configuration#sql_sanitizer` (defaulting to the ActiveRecord-backed
113
+ `McpToolkit::SqlSanitizer`) used to escape LIKE wildcards in `matches` /
114
+ `does_not_match` filters, so a non-Rails host can supply its own.
115
+ - `McpToolkit::Session` — cache-backed, sliding-TTL `Mcp-Session-Id` sessions.
116
+ - `McpToolkit::Transport::ControllerMethods` — an includable Streamable-HTTP
117
+ controller concern (POST/GET/DELETE/health, SSE-on-`Accept`, 202-for-notifications).
118
+ - `get` accepts a string/UUID record id as well as an integer one.
119
+
120
+ ### Notes
121
+
122
+ - The gateway / upstream-aggregation layer (`Mcp::Upstreams*`) is intentionally
123
+ out of scope (core-only).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Karol Galanciak
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,301 @@
1
+ # mcp_toolkit
2
+
3
+ An opinionated toolkit for building **account-scoped, read-only MCP servers** on
4
+ top of the official [`mcp`](https://rubygems.org/gems/mcp) gem.
5
+
6
+ It extracts the shared MCP-server framework that several apps grew independently
7
+ into one versioned, standalone library, so a new app can add an MCP server in
8
+ ~20 lines. It ships:
9
+
10
+ - a **Streamable-HTTP transport** (POST/GET/DELETE/health, SSE-on-`Accept`,
11
+ `202`-for-notifications) as an includable controller concern;
12
+ - **cache-backed sessions** (`Mcp-Session-Id`, sliding TTL) that survive across
13
+ Puma workers;
14
+ - **central-app token introspection** in two roles — be the **authority**
15
+ (authenticate local tokens + answer introspection) or a **satellite**
16
+ (validate forwarded tokens against the central app);
17
+ - a registry-driven **"generic tools over N resources"** dispatcher
18
+ (`list` / `get` / `resources` / `resource_schema`) wrapping the official `mcp`
19
+ gem's JSON-RPC core;
20
+ - an **injectable serializer DSL** (the default base, or your own — e.g. an
21
+ existing app serializer).
22
+
23
+ The JSON-RPC protocol, version negotiation, and error envelopes are delegated to
24
+ the official `mcp` gem; this toolkit owns everything around it.
25
+
26
+ ## Installation
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ gem "mcp_toolkit"
31
+ ```
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ ## Concepts
38
+
39
+ A typical topology is **one central app** responsible for auth and **N
40
+ satellites** that expose their own resources and validate forwarded tokens by
41
+ introspecting against the central app. `mcp_toolkit` makes both roles trivial.
42
+
43
+ Everything is driven by a single config object:
44
+
45
+ ```ruby
46
+ McpToolkit.configure do |c|
47
+ # ...
48
+ end
49
+ ```
50
+
51
+ (`MCPToolkit` is an alias — `MCPToolkit.configure { ... }` works identically.)
52
+
53
+ ---
54
+
55
+ ## Quickstart 1 — a satellite MCP server (~20 lines)
56
+
57
+ A satellite exposes read-only resources and trusts no token locally: it
58
+ introspects each forwarded bearer token against the central app.
59
+
60
+ **1. Configure** (`config/initializers/mcp_toolkit.rb`):
61
+
62
+ ```ruby
63
+ McpToolkit.configure do |c|
64
+ c.server_name = "bsa-notifications-mcp"
65
+ c.server_instructions = "Read-only access to this account's notifications domain."
66
+
67
+ # --- satellite auth ---
68
+ c.auth_role = :satellite
69
+ c.central_app_url = ENV.fetch("MCP_CENTRAL_APP_URL") # POSTs <url>/mcp/tokens/introspect
70
+
71
+ # The scope every tool requires, declared ONCE for all resources. A resource
72
+ # can override it per-resource (see below). Omit entirely for "no scope
73
+ # required". Whether a scope is required is PER TOOL — there is no app-wide
74
+ # permission flag.
75
+ c.registry.default_required_permissions_scope "notifications__read"
76
+
77
+ # Map the central account id to this app's LOCAL scope root (an Account here).
78
+ c.account_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_id) }
79
+
80
+ # Share sessions/introspection across workers.
81
+ c.cache_store = Rails.cache
82
+
83
+ # The engine's controller inherits ActionController::Base by default; point it
84
+ # at ApplicationController if your stack needs helper_method (e.g. logstasher).
85
+ c.parent_controller = "ApplicationController"
86
+ end
87
+ ```
88
+
89
+ **2. Register resources** (same initializer, wrapped in `to_prepare` so they
90
+ refresh on reload). Every `scope` block MUST return a relation already rooted on
91
+ the resolved scope root — this is the single tenancy chokepoint:
92
+
93
+ ```ruby
94
+ Rails.application.config.to_prepare do
95
+ McpToolkit.registry.reset!
96
+
97
+ McpToolkit.registry.register(:notifications) do
98
+ model Notification
99
+ serializer Mcp::NotificationSerializer # your serializer (see below)
100
+ description "Email notification templates + their scheduling rules."
101
+ scope(&:notifications) # account.notifications
102
+ end
103
+
104
+ McpToolkit.registry.register(:scheduled_notifications) do
105
+ model ScheduledNotification
106
+ serializer Mcp::ScheduledNotificationSerializer
107
+ description "Scheduled mailings."
108
+ # Expose a public filter key that maps to a synced storage column:
109
+ filterable booking_id: :synced_booking_id
110
+ # Override the registry default scope for just this resource (optional):
111
+ required_permissions_scope "notifications__read"
112
+ scope { |account| ScheduledNotification.where(synced_account_id: account.synced_id) }
113
+ end
114
+ end
115
+ ```
116
+
117
+ Each resource's effective required scope is its own `required_permissions_scope`
118
+ if declared, else the registry's `default_required_permissions_scope`, else none.
119
+
120
+ **3. Mount the transport** — one line. The gem ships the engine *and* the
121
+ controller, so a satellite writes no routes and no controller of its own:
122
+
123
+ ```ruby
124
+ # config/routes.rb
125
+ mount McpToolkit::Engine => "/mcp"
126
+ ```
127
+
128
+ That yields `POST/GET/DELETE /mcp` + `GET /mcp/health` exactly as a hand-rolled
129
+ satellite did. The four generic tools (`resources`, `resource_schema`, `get`,
130
+ `list`) are now live over Streamable-HTTP, each call authenticated by
131
+ introspecting the forwarded token and scoped to the resolved account.
132
+
133
+ > Prefer to keep your own controller? The transport is also a standalone concern
134
+ > — `include McpToolkit::Transport::ControllerMethods` in a controller and route
135
+ > the four endpoints yourself. The engine is purely additive.
136
+
137
+ ---
138
+
139
+ ## Quickstart 2 — make your app the auth authority
140
+
141
+ The authority authenticates plaintext tokens locally and answers the
142
+ introspection requests satellites send.
143
+
144
+ **1. Configure** the local token lookup (your `McpToken.authenticate` equivalent):
145
+
146
+ ```ruby
147
+ McpToolkit.configure do |c|
148
+ c.auth_role = :authority
149
+ c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) }
150
+ c.cache_store = Rails.cache
151
+ end
152
+ ```
153
+
154
+ The token object your authenticator returns must respond to:
155
+ `kind` (`:accounts_user` | `:user`), `account_id`, `account_ids`, `expires_at`
156
+ (an `#iso8601`-able time or nil), and `scopes` (an array of `<app>__<action>`
157
+ scopes; `[]` = no scopes). Optionally `touch_last_used!`. A typical app token
158
+ model (e.g. `McpToken`) fits.
159
+
160
+ **2. Expose the introspection endpoint** the satellites call:
161
+
162
+ ```ruby
163
+ class Mcp::TokensController < ActionController::API
164
+ def introspect
165
+ token = McpToolkit::Auth::Authority.authenticate(extract_token)
166
+ return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) unless token
167
+
168
+ render json: McpToolkit::Auth::Authority.introspection_payload(token)
169
+ end
170
+
171
+ private
172
+
173
+ def extract_token
174
+ auth = request.headers["Authorization"]
175
+ return auth.sub("Bearer ", "") if auth&.start_with?("Bearer ")
176
+
177
+ request.headers["X-MCP-Token"].presence || params[:token].presence
178
+ end
179
+ end
180
+ ```
181
+
182
+ ```ruby
183
+ # config/routes.rb
184
+ post "mcp/tokens/introspect", to: "mcp/tokens#introspect"
185
+ ```
186
+
187
+ The payload `introspection_payload` emits is exactly the contract the satellite's
188
+ `McpToolkit::Auth::Introspection` parses — the two roles interoperate out of the
189
+ box. (An app can be **both**: a central app that also exposes its own tools just
190
+ sets the authority config and registers resources + the transport controller.)
191
+
192
+ ---
193
+
194
+ ## Serializer injection (e.g. an existing app serializer)
195
+
196
+ The registry takes a **serializer class per resource**. The gem ships a default
197
+ DSL base (`McpToolkit::Serializer::Base`), but the only thing the executors
198
+ require is that a serializer responds to two class methods:
199
+
200
+ ```ruby
201
+ serializer.serialize_one(record, scope:)
202
+ # => Hash (a single record's shape), or nil for a nil record
203
+
204
+ serializer.serialize_collection(records, scope:, total_count:, limit:, offset:)
205
+ # => { <root_key> => [ <record_hash>, ... ], meta: { total_count:, limit:, offset: } }
206
+ ```
207
+
208
+ Any class satisfying that contract slots in — including an app's existing
209
+ serializers. Register it directly:
210
+
211
+ > **Sparse fieldsets.** Both methods also accept an optional `fields:` keyword (an
212
+ > array of attribute/relationship names) so `get` / `list` can return a subset of
213
+ > a record's shape. Honoring it natively — the bundled base does — skips computing
214
+ > the unselected members; a serializer that ignores it still works, because the
215
+ > toolkit prunes its output to the requested `fields` instead. Omitting `fields:`
216
+ > (the default) returns the full shape, so this is fully backward-compatible.
217
+
218
+ ```ruby
219
+ McpToolkit.registry.register(:bookings) do
220
+ model Booking
221
+ serializer BookingSerializer # your existing serializer
222
+ scope { |account| account.bookings }
223
+ end
224
+ ```
225
+
226
+ ### Using the bundled base
227
+
228
+ ```ruby
229
+ class Mcp::NotificationSerializer < McpToolkit::Serializer::Base
230
+ attributes :id, :name, :active, :created_at, :updated_at
231
+ translates :subject, :template_html # Globalize-backed { locale => value }
232
+
233
+ has_one :account, foreign_key: :synced_account_id
234
+ has_one :mail_layout
235
+ has_many :scheduled_notifications
236
+ end
237
+ ```
238
+
239
+ It emits declared attributes as symbol keys (in declaration order), a sorted
240
+ string-keyed `"links"` hash (ids / `{id:,type:}` for polymorphic / sorted arrays
241
+ for `has_many`), and `iso8601(6)` timestamps. To power the `resource_schema`
242
+ discovery tool, a custom serializer may also expose `declared_attributes` /
243
+ `declared_associations`; this is optional (the base provides them).
244
+
245
+ ---
246
+
247
+ ## Configuration reference
248
+
249
+ | Setting | Default | Purpose |
250
+ |---|---|---|
251
+ | `server_name` / `server_version` / `server_instructions` | `"mcp-server"` / `"1.0.0"` / `nil` | advertised on `initialize` |
252
+ | `serializer_base` | `McpToolkit::Serializer::Base` | the default base to subclass |
253
+ | `auth_role` | `:satellite` | `:satellite` or `:authority` |
254
+ | `central_app_url` | `nil` | satellite: base URL of the auth authority |
255
+ | `introspect_path` | `"/mcp/tokens/introspect"` | satellite: appended to `central_app_url` |
256
+ | `introspection_cache_ttl` | `45` | seconds to cache introspection results |
257
+ | `introspection_timeout` | `10` | HTTP timeout (s) for the introspection call |
258
+ | `account_resolver` | identity | maps the central account id → local scope root |
259
+ | `token_authenticator` | `nil` | authority: `->(plaintext) { token_or_nil }` |
260
+ | `cache_store` | `MemoryStore` | sessions + introspection cache (set to `Rails.cache`) |
261
+ | `session_ttl` | `3600` | session sliding TTL (s) |
262
+ | `protocol_version` | `nil` (negotiate) | pin an MCP protocol version |
263
+ | `parent_controller` | `"ActionController::Base"` | superclass of the engine's `ServerController` (set to `"ApplicationController"` for `helper_method` compat) |
264
+ | `account_meta_key` | `"mcp-toolkit/account-id"` | `_meta` key a superuser uses to pin the account |
265
+ | `account_id_header` | `"X-MCP-Account-ID"` | header fallback for the account selector |
266
+
267
+ ## Public API surface
268
+
269
+ - `McpToolkit.configure { |c| ... }`, `McpToolkit.config`, `McpToolkit.registry`,
270
+ `McpToolkit.reset_config!`
271
+ - `McpToolkit::Registry#register(name) { ... }` (DSL: `model`, `serializer`,
272
+ `scope`, `description`, `filterable`, `required_permissions_scope`) +
273
+ `#default_required_permissions_scope`
274
+ - `McpToolkit::Serializer::Base` (DSL: `attributes`, `has_one`, `has_many`,
275
+ `translates`)
276
+ - `McpToolkit::Server.build(server_context:, config:, extra_tools:)`
277
+ - `McpToolkit::Engine` (mountable; `mount McpToolkit::Engine => "/mcp"`) +
278
+ `McpToolkit::ServerController` (its controller; parent via `parent_controller`)
279
+ - `McpToolkit::Transport::ControllerMethods` (standalone controller concern;
280
+ override `mcp_config` / `mcp_extra_tools`)
281
+ - `McpToolkit::Session`
282
+ - `McpToolkit::Auth::Introspection` / `Authenticator` (satellite),
283
+ `McpToolkit::Auth::Authority` (authority)
284
+ - `McpToolkit::Errors::{InvalidParams, Unauthorized, ConfigurationError}`
285
+
286
+ ## Scope (what's intentionally NOT here)
287
+
288
+ The gateway / upstream-aggregation layer (a central app's `Mcp::Upstreams*`) is
289
+ **out of scope** — it's core-only and ships to no satellite. This toolkit is for
290
+ servers that expose tools, not for aggregating other servers.
291
+
292
+ ## Development
293
+
294
+ ```bash
295
+ bin/setup
296
+ bundle exec rspec
297
+ ```
298
+
299
+ ## License
300
+
301
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ # Run brakeman with the same flags CI uses, so a local `rake` catches what the
13
+ # pipeline would.
14
+ desc "Run Brakeman security scanner"
15
+ task :brakeman do
16
+ sh "bundle exec brakeman --force --no-progress --quiet --no-pager"
17
+ end
18
+
19
+ # Convenience "everything" task for local runs — mirrors CI (which keeps these as
20
+ # separate jobs: test / rubocop / brakeman) so a green `rake` means a green build.
21
+ task default: %i[spec rubocop brakeman]
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The MCP transport controller, provided BY the gem so a satellite mounting
4
+ # McpToolkit::Engine writes no controller of its own. It is the standalone
5
+ # McpToolkit::Transport::ControllerMethods concern wired into a controller.
6
+ #
7
+ # Its parent class is configurable (Doorkeeper-style) via
8
+ # `McpToolkit.config.parent_controller` (default "ActionController::Base"), so a
9
+ # satellite can keep ActionController::Base (NOT ::API) for its logstasher
10
+ # `helper_method` hook:
11
+ #
12
+ # c.parent_controller = "ApplicationController"
13
+ #
14
+ # Lives under the gem's app/controllers (an engine path), so it's loaded by Rails'
15
+ # autoloader via the engine — never by the gem's own Zeitwerk loader, which only
16
+ # manages lib/. Non-Rails consumers never see it.
17
+ class McpToolkit::ServerController < McpToolkit.config.parent_controller.constantize
18
+ include McpToolkit::Transport::ControllerMethods
19
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Engine routes, drawn through Rails' routes_reloader so they survive route
4
+ # reloads (a class-body `routes.draw` is wiped when the app re-draws the engine's
5
+ # route set on boot/reload). Mounted by a satellite via:
6
+ #
7
+ # mount McpToolkit::Engine => "/mcp"
8
+ #
9
+ # POST /mcp -> create (JSON-RPC requests/responses)
10
+ # GET /mcp -> stream (405; no server-initiated SSE)
11
+ # DELETE /mcp -> destroy (terminate the session)
12
+ # GET /mcp/health -> health (unauthenticated probe)
13
+ McpToolkit::Engine.routes.draw do
14
+ post "/", to: "server#create"
15
+ get "/", to: "server#stream"
16
+ delete "/", to: "server#destroy"
17
+ get "health", to: "server#health"
18
+ end