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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +43 -0
- data/.rubocop.yml +98 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +21 -0
- data/app/controllers/mcp_toolkit/server_controller.rb +19 -0
- data/config/routes.rb +18 -0
- data/lib/mcp_toolkit/auth/authenticator.rb +99 -0
- data/lib/mcp_toolkit/auth/authority.rb +75 -0
- data/lib/mcp_toolkit/auth/authority_server_client.rb +50 -0
- data/lib/mcp_toolkit/auth/introspection.rb +162 -0
- data/lib/mcp_toolkit/configuration.rb +209 -0
- data/lib/mcp_toolkit/engine.rb +21 -0
- data/lib/mcp_toolkit/errors/base.rb +10 -0
- data/lib/mcp_toolkit/errors/configuration_error.rb +5 -0
- data/lib/mcp_toolkit/errors/invalid_params.rb +5 -0
- data/lib/mcp_toolkit/errors/unauthorized.rb +5 -0
- data/lib/mcp_toolkit/field_selection.rb +93 -0
- data/lib/mcp_toolkit/filtering.rb +152 -0
- data/lib/mcp_toolkit/get_executor.rb +32 -0
- data/lib/mcp_toolkit/list_executor.rb +137 -0
- data/lib/mcp_toolkit/registry.rb +67 -0
- data/lib/mcp_toolkit/resource.rb +129 -0
- data/lib/mcp_toolkit/resource_schema.rb +163 -0
- data/lib/mcp_toolkit/serialization.rb +62 -0
- data/lib/mcp_toolkit/serializer/base.rb +285 -0
- data/lib/mcp_toolkit/server.rb +44 -0
- data/lib/mcp_toolkit/session.rb +46 -0
- data/lib/mcp_toolkit/sql_sanitizer.rb +14 -0
- data/lib/mcp_toolkit/token_kinds.rb +14 -0
- data/lib/mcp_toolkit/tools/base.rb +100 -0
- data/lib/mcp_toolkit/tools/get.rb +57 -0
- data/lib/mcp_toolkit/tools/list.rb +83 -0
- data/lib/mcp_toolkit/tools/resource_schema.rb +40 -0
- data/lib/mcp_toolkit/tools/resources.rb +27 -0
- data/lib/mcp_toolkit/transport/controller_methods.rb +226 -0
- data/lib/mcp_toolkit/unknown_resource_message.rb +75 -0
- data/lib/mcp_toolkit/version.rb +5 -0
- data/lib/mcp_toolkit.rb +118 -0
- data/sig/mcp_toolkit.rbs +4 -0
- 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
|