Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .circleci/config.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
pull_request:

jobs:
test:
name: RSpec (Ruby ${{ matrix.ruby }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4"]
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rspec

rubocop:
name: RuboCop
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- run: bundle exec rubocop

brakeman:
name: Brakeman
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- run: bundle exec brakeman --force --no-progress --quiet --no-pager
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@

# rspec failure tracking
.rspec_status

# Library gem: don't commit the lockfile or built gems.
/Gemfile.lock
/*.gem
98 changes: 98 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
plugins:
- rubocop-performance

AllCops:
TargetRubyVersion: 3.2
NewCops: enable
SuggestExtensions: false
Exclude:
- "bin/**/*"
- "vendor/**/*"
- "tmp/**/*"
# Specs follow manual conventions (named subjects, in-example test doubles)
# that don't map cleanly to the library cops; they are not linted.
- "spec/**/*"

# --- Layout -----------------------------------------------------------------

Layout/LineLength:
Max: 120

# --- Style ------------------------------------------------------------------

# Prefer FLAT (compact) leaf declarations — `class McpToolkit::Auth::Authenticator`
# — over the `module ... module ... class` pyramid. The only exception is the gem
# entry point, which must open `module McpToolkit` as a block to host the Zeitwerk
# loader setup, the top-level configuration methods, and the `MCPToolkit` alias;
# compact is not expressible there. Files that genuinely defined several constants
# under one namespace (the errors hierarchy) are split one-constant-per-file under
# lib/mcp_toolkit/errors/ so each can be declared compactly.
Style/ClassAndModuleChildren:
EnforcedStyle: compact
Exclude:
- "lib/mcp_toolkit.rb"

# Define class methods as `def self.x`, never `class << self` (so the singleton
# style stays consistent and greppable across the gem).
Style/ClassMethodsDefinitions:
EnforcedStyle: def_self

# The gem (gemspec, Gemfile, lib) is written with double-quoted strings; match
# that convention instead of churning every literal.
Style/StringLiterals:
EnforcedStyle: double_quotes

Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes

# This is a library; per-class/module YARD comments are not enforced.
Style/Documentation:
Enabled: false

# --- Naming -----------------------------------------------------------------

# `has_one` / `has_many` are association-DSL names mirroring AMS / ActiveRecord;
# they are not boolean predicates.
Naming/PredicatePrefix:
AllowedMethods:
- has_one
- has_many

# --- Metrics ----------------------------------------------------------------
# The extracted framework intentionally carries some larger units (the transport
# controller, the server wiring, the registry/executor dispatch). Bump the
# metrics cops to fit the code as written rather than contorting it; keep them
# enabled so genuinely runaway methods still get flagged.

Metrics/MethodLength:
Max: 30

Metrics/AbcSize:
Max: 30

Metrics/CyclomaticComplexity:
Max: 12

Metrics/PerceivedComplexity:
Max: 12

Metrics/ClassLength:
Max: 250

Metrics/ModuleLength:
Max: 250

Metrics/BlockLength:
Max: 50
Exclude:
- "*.gemspec"
- "Gemfile"

Metrics/ParameterLists:
Max: 6

# `id` is a domain-meaningful record identifier.
Naming/MethodParameterName:
AllowedNames:
- id
- n
78 changes: 75 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,77 @@
## [Unreleased]
## [0.1.0] - 2026-06-28

## [0.1.0] - 2026-06-18
Initial extraction from two independently-grown internal MCP servers into a single
shared gem. Standardizes on the official `mcp` gem (`~> 0.18`) as the wrapped
JSON-RPC core. Per-tool scope is declared explicitly via `required_permissions_scope`,
and a mountable `McpToolkit::Engine` gives satellites their MCP routes without the
hand-rolled controller wiring.

- Initial release
### Added

- `McpToolkit.configure` / `config` / `registry` / `reset_config!` — a single
injectable `Configuration` object (`MCPToolkit` alias provided).
- `McpToolkit::Server.build` — wraps `MCP::Server` (the official gem) with the
generic toolset registered and the per-request `server_context`.
- Generic, registry-driven tools subclassing `MCP::Tool`: `resources`,
`resource_schema`, `get`, `list`.
- `McpToolkit::Registry` + `Resource` DSL (`model` / `serializer` / `scope` /
`description` / `filterable`) + `ListExecutor` / `GetExecutor` /
`ResourceSchema`. The serializer is injectable per resource.
- `McpToolkit::Serializer::Base` — the default serializer DSL (`attributes`,
`has_one`, `has_many`, `translates`), with a documented `serialize_one` /
`serialize_collection` contract so an app's existing serializers slot in
unchanged.
- Dual-role authentication: satellite (`Auth::Introspection` +
`Auth::Authenticator`, with short-TTL caching) and authority (`Auth::Authority`
— local token authentication + introspection payload responder). All
config-driven. The introspection HTTP call is owned by
`Auth::AuthorityServerClient`.
- OAuth-style **scope** enforcement, declared EXPLICITLY per resource. Tokens
carry `scopes` of the form `<app>__<action>` (e.g. `notifications__read`). A
resource declares the scope it requires via `required_permissions_scope`, or a
satellite declares one default for all resources via
`Registry#default_required_permissions_scope`. A resource's effective scope is
its own declaration, else the registry default, else none. A tool is allowed
iff the token carries the required scope; a resource (and default) with no
declared scope is reachable by any valid token. Whether ANY scope is required
is decided per tool — there is NO app-wide permission setting.
- `Resource#required_permissions_scope` / `#effective_required_permissions_scope`
and `Registry#default_required_permissions_scope` / `#required_scope_for` — the
explicit scope DSL + resolution. `reset!` preserves the registry default (it's
declared in `configure`, not per-reload).
- `Tools::Base.with_account` / `.with_authentication` take an explicit
`required_scope:` keyword (resolved by the caller from the resource); the `list`
/ `get` / `resource_schema` tools resolve the resource FIRST, then enforce its
effective scope. The discovery tools (`resources`, `resource_schema`) require
the registry default scope.
- A **mountable Rails engine**, `McpToolkit::Engine`, plus the gem-provided
`McpToolkit::ServerController`: a satellite writes `mount McpToolkit::Engine =>
"/mcp"` instead of four hand-declared routes + a controller. The controller's
parent class is configurable via `Configuration#parent_controller` (default
`"ActionController::Base"`; set `"ApplicationController"` for `helper_method`
compat). The engine is ADDITIVE — `Transport::ControllerMethods` remains a
standalone concern. Both the engine and the gem's `app/controllers` are loaded
only when Rails is present; the gem's non-Rails consumers and unit suite never
reference them. The four routes are drawn in the engine's `config/routes.rb`
(NOT a class-body `routes.draw`), so they survive Rails' routes_reloader — a
class-body draw is wiped on the first route reload, leaving the engine
route-less and every `/mcp` 404'ing. A regression spec boots a real
`Rails::Application` in an isolated subprocess and asserts the engine route set
survives a `reload_routes!`.
- `Auth::Introspection::Result#authorized_for_scope?(required_scope)` — exact-scope
check used by the tool layer.
- `scopes` field in the introspection contract: parsed by the satellite
(`Auth::Introspection`) and emitted by the authority
(`Auth::Authority#introspection_payload`).
- Injectable `Configuration#sql_sanitizer` (defaulting to the ActiveRecord-backed
`McpToolkit::SqlSanitizer`) used to escape LIKE wildcards in `matches` /
`does_not_match` filters, so a non-Rails host can supply its own.
- `McpToolkit::Session` — cache-backed, sliding-TTL `Mcp-Session-Id` sessions.
- `McpToolkit::Transport::ControllerMethods` — an includable Streamable-HTTP
controller concern (POST/GET/DELETE/health, SSE-on-`Accept`, 202-for-notifications).
- `get` accepts a string/UUID record id as well as an integer one.

### Notes

- The gateway / upstream-aggregation layer (`Mcp::Upstreams*`) is intentionally
out of scope (core-only).
17 changes: 17 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@ gem "irb"
gem "rake", "~> 13.0"

gem "rspec", "~> 3.0"

# Used by the auth specs to stub the central app's introspection endpoint.
gem "webmock", "~> 3.0"

# Rails is NOT a gem runtime dependency (the gem depends on activesupport only, so
# non-Rails hosts can consume it). It is pulled in here, for the test suite alone,
# so the engine regression spec can boot a REAL minimal Rails::Application and
# drive its routes_reloader — the only thing that reproduces the route-reload wipe
# the engine's config/routes.rb fixes. `require: false` keeps it out of the gem's
# own load path.
gem "rails", "~> 8.0", require: false

# Linting, static analysis, and security scanning. `require: false` so they load
# only via their CLIs / Rake tasks, never into the gem's runtime.
gem "brakeman", require: false
gem "rubocop", require: false
gem "rubocop-performance", require: false
Loading
Loading