diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 0e3dfb0..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: 2.1 -jobs: - build: - docker: - - image: ruby:3.4.9 - steps: - - checkout - - run: - name: Run the default task - command: | - gem install bundler -v 4.0.11 - bundle install - bundle exec rake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d97e47f --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index b04a8c8..885d8c2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ # rspec failure tracking .rspec_status + +# Library gem: don't commit the lockfile or built gems. +/Gemfile.lock +/*.gem diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..fe0f2ce --- /dev/null +++ b/.rubocop.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index de4be07..9b96fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `__` (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). diff --git a/Gemfile b/Gemfile index 4e14bd4..31ee7b3 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/README.md b/README.md index 06729d5..fe1281a 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,294 @@ -# McpToolkit +# mcp_toolkit -TODO: Delete this and the text below, and describe your gem +An opinionated toolkit for building **account-scoped, read-only MCP servers** on +top of the official [`mcp`](https://rubygems.org/gems/mcp) gem. -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/mcp_toolkit`. To experiment with that code, run `bin/console` for an interactive prompt. +It extracts the shared MCP-server framework that several apps grew independently +into one versioned, standalone library, so a new app can add an MCP server in +~20 lines. It ships: -## Installation +- a **Streamable-HTTP transport** (POST/GET/DELETE/health, SSE-on-`Accept`, + `202`-for-notifications) as an includable controller concern; +- **cache-backed sessions** (`Mcp-Session-Id`, sliding TTL) that survive across + Puma workers; +- **central-app token introspection** in two roles — be the **authority** + (authenticate local tokens + answer introspection) or a **satellite** + (validate forwarded tokens against the central app); +- a registry-driven **"generic tools over N resources"** dispatcher + (`list` / `get` / `resources` / `resource_schema`) wrapping the official `mcp` + gem's JSON-RPC core; +- an **injectable serializer DSL** (the default base, or your own — e.g. an + existing app serializer). -TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org. +The JSON-RPC protocol, version negotiation, and error envelopes are delegated to +the official `mcp` gem; this toolkit owns everything around it. -Install the gem and add to the application's Gemfile by executing: +## Installation + +```ruby +# Gemfile +gem "mcp_toolkit" +``` ```bash -bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +bundle install ``` -If bundler is not being used to manage dependencies, install the gem by executing: +## Concepts -```bash -gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG +A typical topology is **one central app** responsible for auth and **N +satellites** that expose their own resources and validate forwarded tokens by +introspecting against the central app. `mcp_toolkit` makes both roles trivial. + +Everything is driven by a single config object: + +```ruby +McpToolkit.configure do |c| + # ... +end ``` -## Usage +(`MCPToolkit` is an alias — `MCPToolkit.configure { ... }` works identically.) -TODO: Write usage instructions here +--- -## Development +## Quickstart 1 — a satellite MCP server (~20 lines) + +A satellite exposes read-only resources and trusts no token locally: it +introspects each forwarded bearer token against the central app. + +**1. Configure** (`config/initializers/mcp_toolkit.rb`): + +```ruby +McpToolkit.configure do |c| + c.server_name = "bsa-notifications-mcp" + c.server_instructions = "Read-only access to this account's notifications domain." + + # --- satellite auth --- + c.auth_role = :satellite + c.central_app_url = ENV.fetch("MCP_CENTRAL_APP_URL") # POSTs /mcp/tokens/introspect + + # The scope every tool requires, declared ONCE for all resources. A resource + # can override it per-resource (see below). Omit entirely for "no scope + # required". Whether a scope is required is PER TOOL — there is no app-wide + # permission flag. + c.registry.default_required_permissions_scope "notifications__read" + + # Map the central account id to this app's LOCAL scope root (an Account here). + c.account_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_id) } + + # Share sessions/introspection across workers. + c.cache_store = Rails.cache + + # The engine's controller inherits ActionController::Base by default; point it + # at ApplicationController if your stack needs helper_method (e.g. logstasher). + c.parent_controller = "ApplicationController" +end +``` + +**2. Register resources** (same initializer, wrapped in `to_prepare` so they +refresh on reload). Every `scope` block MUST return a relation already rooted on +the resolved scope root — this is the single tenancy chokepoint: + +```ruby +Rails.application.config.to_prepare do + McpToolkit.registry.reset! + + McpToolkit.registry.register(:notifications) do + model Notification + serializer Mcp::NotificationSerializer # your serializer (see below) + description "Email notification templates + their scheduling rules." + scope(&:notifications) # account.notifications + end + + McpToolkit.registry.register(:scheduled_notifications) do + model ScheduledNotification + serializer Mcp::ScheduledNotificationSerializer + description "Scheduled mailings." + # Expose a public filter key that maps to a synced storage column: + filterable booking_id: :synced_booking_id + # Override the registry default scope for just this resource (optional): + required_permissions_scope "notifications__read" + scope { |account| ScheduledNotification.where(synced_account_id: account.synced_id) } + end +end +``` + +Each resource's effective required scope is its own `required_permissions_scope` +if declared, else the registry's `default_required_permissions_scope`, else none. + +**3. Mount the transport** — one line. The gem ships the engine *and* the +controller, so a satellite writes no routes and no controller of its own: + +```ruby +# config/routes.rb +mount McpToolkit::Engine => "/mcp" +``` + +That yields `POST/GET/DELETE /mcp` + `GET /mcp/health` exactly as a hand-rolled +satellite did. The four generic tools (`resources`, `resource_schema`, `get`, +`list`) are now live over Streamable-HTTP, each call authenticated by +introspecting the forwarded token and scoped to the resolved account. + +> Prefer to keep your own controller? The transport is also a standalone concern +> — `include McpToolkit::Transport::ControllerMethods` in a controller and route +> the four endpoints yourself. The engine is purely additive. + +--- + +## Quickstart 2 — make your app the auth authority + +The authority authenticates plaintext tokens locally and answers the +introspection requests satellites send. + +**1. Configure** the local token lookup (your `McpToken.authenticate` equivalent): + +```ruby +McpToolkit.configure do |c| + c.auth_role = :authority + c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) } + c.cache_store = Rails.cache +end +``` -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +The token object your authenticator returns must respond to: +`kind` (`:accounts_user` | `:user`), `account_id`, `account_ids`, `expires_at` +(an `#iso8601`-able time or nil), and `scopes` (an array of `__` +scopes; `[]` = no scopes). Optionally `touch_last_used!`. A typical app token +model (e.g. `McpToken`) fits. -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +**2. Expose the introspection endpoint** the satellites call: -## Contributing +```ruby +class Mcp::TokensController < ActionController::API + def introspect + token = McpToolkit::Auth::Authority.authenticate(extract_token) + return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) unless token -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/mcp_toolkit. + render json: McpToolkit::Auth::Authority.introspection_payload(token) + end + + private + + def extract_token + auth = request.headers["Authorization"] + return auth.sub("Bearer ", "") if auth&.start_with?("Bearer ") + + request.headers["X-MCP-Token"].presence || params[:token].presence + end +end +``` + +```ruby +# config/routes.rb +post "mcp/tokens/introspect", to: "mcp/tokens#introspect" +``` + +The payload `introspection_payload` emits is exactly the contract the satellite's +`McpToolkit::Auth::Introspection` parses — the two roles interoperate out of the +box. (An app can be **both**: a central app that also exposes its own tools just +sets the authority config and registers resources + the transport controller.) + +--- + +## Serializer injection (e.g. an existing app serializer) + +The registry takes a **serializer class per resource**. The gem ships a default +DSL base (`McpToolkit::Serializer::Base`), but the only thing the executors +require is that a serializer responds to two class methods: + +```ruby +serializer.serialize_one(record, scope:) + # => Hash (a single record's shape), or nil for a nil record + +serializer.serialize_collection(records, scope:, total_count:, limit:, offset:) + # => { => [ , ... ], meta: { total_count:, limit:, offset: } } +``` + +Any class satisfying that contract slots in — including an app's existing +serializers. Register it directly: + +```ruby +McpToolkit.registry.register(:bookings) do + model Booking + serializer BookingSerializer # your existing serializer + scope { |account| account.bookings } +end +``` + +### Using the bundled base + +```ruby +class Mcp::NotificationSerializer < McpToolkit::Serializer::Base + attributes :id, :name, :active, :created_at, :updated_at + translates :subject, :template_html # Globalize-backed { locale => value } + + has_one :account, foreign_key: :synced_account_id + has_one :mail_layout + has_many :scheduled_notifications +end +``` + +It emits declared attributes as symbol keys (in declaration order), a sorted +string-keyed `"links"` hash (ids / `{id:,type:}` for polymorphic / sorted arrays +for `has_many`), and `iso8601(6)` timestamps. To power the `resource_schema` +discovery tool, a custom serializer may also expose `declared_attributes` / +`declared_associations`; this is optional (the base provides them). + +--- + +## Configuration reference + +| Setting | Default | Purpose | +|---|---|---| +| `server_name` / `server_version` / `server_instructions` | `"mcp-server"` / `"1.0.0"` / `nil` | advertised on `initialize` | +| `serializer_base` | `McpToolkit::Serializer::Base` | the default base to subclass | +| `auth_role` | `:satellite` | `:satellite` or `:authority` | +| `central_app_url` | `nil` | satellite: base URL of the auth authority | +| `introspect_path` | `"/mcp/tokens/introspect"` | satellite: appended to `central_app_url` | +| `introspection_cache_ttl` | `45` | seconds to cache introspection results | +| `introspection_timeout` | `10` | HTTP timeout (s) for the introspection call | +| `account_resolver` | identity | maps the central account id → local scope root | +| `token_authenticator` | `nil` | authority: `->(plaintext) { token_or_nil }` | +| `cache_store` | `MemoryStore` | sessions + introspection cache (set to `Rails.cache`) | +| `session_ttl` | `3600` | session sliding TTL (s) | +| `protocol_version` | `nil` (negotiate) | pin an MCP protocol version | +| `parent_controller` | `"ActionController::Base"` | superclass of the engine's `ServerController` (set to `"ApplicationController"` for `helper_method` compat) | +| `account_meta_key` | `"mcp-toolkit/account-id"` | `_meta` key a superuser uses to pin the account | +| `account_id_header` | `"X-MCP-Account-ID"` | header fallback for the account selector | + +## Public API surface + +- `McpToolkit.configure { |c| ... }`, `McpToolkit.config`, `McpToolkit.registry`, + `McpToolkit.reset_config!` +- `McpToolkit::Registry#register(name) { ... }` (DSL: `model`, `serializer`, + `scope`, `description`, `filterable`, `required_permissions_scope`) + + `#default_required_permissions_scope` +- `McpToolkit::Serializer::Base` (DSL: `attributes`, `has_one`, `has_many`, + `translates`) +- `McpToolkit::Server.build(server_context:, config:, extra_tools:)` +- `McpToolkit::Engine` (mountable; `mount McpToolkit::Engine => "/mcp"`) + + `McpToolkit::ServerController` (its controller; parent via `parent_controller`) +- `McpToolkit::Transport::ControllerMethods` (standalone controller concern; + override `mcp_config` / `mcp_extra_tools`) +- `McpToolkit::Session` +- `McpToolkit::Auth::Introspection` / `Authenticator` (satellite), + `McpToolkit::Auth::Authority` (authority) +- `McpToolkit::Errors::{InvalidParams, Unauthorized, ConfigurationError}` + +## Scope (what's intentionally NOT here) + +The gateway / upstream-aggregation layer (a central app's `Mcp::Upstreams*`) is +**out of scope** — it's core-only and ships to no satellite. This toolkit is for +servers that expose tools, not for aggregating other servers. + +## Development + +```bash +bin/setup +bundle exec rspec +``` ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile index b6ae734..cac4ef9 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,17 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) -task default: :spec +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +# Run brakeman with the same flags CI uses, so a local `rake` catches what the +# pipeline would. +desc "Run Brakeman security scanner" +task :brakeman do + sh "bundle exec brakeman --force --no-progress --quiet --no-pager" +end + +# Convenience "everything" task for local runs — mirrors CI (which keeps these as +# separate jobs: test / rubocop / brakeman) so a green `rake` means a green build. +task default: %i[spec rubocop brakeman] diff --git a/app/controllers/mcp_toolkit/server_controller.rb b/app/controllers/mcp_toolkit/server_controller.rb new file mode 100644 index 0000000..dc80cba --- /dev/null +++ b/app/controllers/mcp_toolkit/server_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# The MCP transport controller, provided BY the gem so a satellite mounting +# McpToolkit::Engine writes no controller of its own. It is the standalone +# McpToolkit::Transport::ControllerMethods concern wired into a controller. +# +# Its parent class is configurable (Doorkeeper-style) via +# `McpToolkit.config.parent_controller` (default "ActionController::Base"), so a +# satellite can keep ActionController::Base (NOT ::API) for its logstasher +# `helper_method` hook: +# +# c.parent_controller = "ApplicationController" +# +# Lives under the gem's app/controllers (an engine path), so it's loaded by Rails' +# autoloader via the engine — never by the gem's own Zeitwerk loader, which only +# manages lib/. Non-Rails consumers never see it. +class McpToolkit::ServerController < McpToolkit.config.parent_controller.constantize + include McpToolkit::Transport::ControllerMethods +end diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..0eb4805 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Engine routes, drawn through Rails' routes_reloader so they survive route +# reloads (a class-body `routes.draw` is wiped when the app re-draws the engine's +# route set on boot/reload). Mounted by a satellite via: +# +# mount McpToolkit::Engine => "/mcp" +# +# POST /mcp -> create (JSON-RPC requests/responses) +# GET /mcp -> stream (405; no server-initiated SSE) +# DELETE /mcp -> destroy (terminate the session) +# GET /mcp/health -> health (unauthenticated probe) +McpToolkit::Engine.routes.draw do + post "/", to: "server#create" + get "/", to: "server#stream" + delete "/", to: "server#destroy" + get "health", to: "server#health" +end diff --git a/lib/mcp_toolkit.rb b/lib/mcp_toolkit.rb index 4e055fe..f34cce7 100644 --- a/lib/mcp_toolkit.rb +++ b/lib/mcp_toolkit.rb @@ -1,8 +1,115 @@ # frozen_string_literal: true +require "zeitwerk" + +# Stdlib + external gems the toolkit's own code calls. Zeitwerk autoloads the +# gem's `lib/mcp_toolkit/**` tree, but NOT these third-party / stdlib constants, +# so they are required here once (centralized) rather than scattered across the +# subfiles that happen to touch them: +# +# json - JSON.parse / JSON.generate (introspection parse, tools, transport) +# digest - Digest::SHA256 (introspection cache key) +# time - Time.iso8601 / Time.parse (introspection expiry parsing) +# securerandom - SecureRandom.uuid (Session ids) +# mcp - the official MCP SDK (Server wraps it; Tools::Base subclasses MCP::Tool) +# active_support/concern - Transport::ControllerMethods is an includable concern +# active_support/cache - the default MemoryStore cache_store +# +# `faraday` is the one exception: it is required alongside its sole owner, +# Auth::AuthorityServerClient, which is the only object that builds an HTTP +# connection. +require "json" +require "digest" +require "time" +require "securerandom" +require "mcp" +require "active_support/concern" +require "active_support/cache" + +# External dependencies (NOT autoloaded by Zeitwerk — only the gem's own tree is). +# ActiveSupport's specific core extensions are required up front (rather than full +# Rails) so the gem works in any host. Each line below earns its place; they were +# audited against actual usage: +# +# object/blank - blank? / present? / presence (used throughout) +# hash/keys - deep_symbolize_keys (ListExecutor#initialize) +# array/wrap - Array.wrap (Transport::ControllerMethods) +# enumerable - compact_blank (ListExecutor#apply_ids) +# time/conversions - Time#iso8601 (Serializer::Base timestamps; +# Time.zone.parse in ListExecutor) +# date_time/conversions - DateTime#iso8601 (token expires_at may be a DateTime) +require "active_support" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/array/wrap" +require "active_support/core_ext/enumerable" +require "active_support/core_ext/time/conversions" +require "active_support/core_ext/date_time/conversions" + +# The version constant is needed eagerly by the gemspec (before the loader is set +# up), so it stays an explicit require rather than an autoload. require_relative "mcp_toolkit/version" +loader = Zeitwerk::Loader.for_gem +# `version.rb` is loaded manually above; let Zeitwerk ignore it so it doesn't try +# to manage the already-defined constant. +loader.ignore("#{__dir__}/mcp_toolkit/version.rb") +# `engine.rb` subclasses ::Rails::Engine, which is absent in a non-Rails host (and +# in the gem's own unit suite). Keep it out of the autoloadable set and require it +# explicitly below only when Rails is present. The gem-internal controller lives +# under `app/controllers` (outside this lib-rooted loader), so it needs no ignore; +# it is loaded by Rails' autoloader via the engine when mounted. +loader.ignore("#{__dir__}/mcp_toolkit/engine.rb") +loader.setup + +# The toolkit for building account-scoped, read-only MCP servers on top of the +# official `mcp` gem. See README.md for the satellite + authority quickstarts. +# +# Entry points: +# McpToolkit.configure { |c| ... } # set up the server +# McpToolkit.config # the active Configuration +# McpToolkit.registry # the active config's resource registry +# +# `MCPToolkit` is provided as an alias for the same module. module McpToolkit + # The toolkit's own base error (kept distinct from tool-level Errors::*). class Error < StandardError; end - # Your code goes here... + + # Yields the active configuration for mutation, returning it. + # + # McpToolkit.configure do |c| + # c.server_name = "my-app-mcp" + # c.central_app_url = ENV.fetch("MCP_CENTRAL_APP_URL") + # c.registry.default_required_permissions_scope "my_app__read" + # end + def self.configure + yield(config) if block_given? + config + end + + # The active Configuration (created on first access). + def self.config + @config ||= Configuration.new + end + + # The active config's resource registry. Register resources against this in a + # boot initializer. + def self.registry + config.registry + end + + # Replaces the active configuration with a fresh default. Primarily for tests. + def self.reset_config! + @config = Configuration.new + end end + +# Karol's requested entry-point spelling. `MCPToolkit.configure { ... }` and +# `MCPToolkit.config` work identically to `McpToolkit.*`. +MCPToolkit = McpToolkit unless defined?(MCPToolkit) + +# The mountable engine is ADDITIVE and Rails-only: a satellite can either mount +# `McpToolkit::Engine` (engine + gem controller) OR keep including +# `McpToolkit::Transport::ControllerMethods` in its own controller. Loaded only +# when Rails::Engine is present (it was ignored by the loader above). +require_relative "mcp_toolkit/engine" if defined?(Rails::Engine) diff --git a/lib/mcp_toolkit/auth/authenticator.rb b/lib/mcp_toolkit/auth/authenticator.rb new file mode 100644 index 0000000..d375d22 --- /dev/null +++ b/lib/mcp_toolkit/auth/authenticator.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# SATELLITE side. Resolves the authenticated, scoped context for a tool call: +# +# 1. Introspect the bearer token against the central app (cached). +# 2. Reject if invalid / expired. +# 3. Resolve the active central account id, enforcing tenancy: +# - accounts_user token => its single bound `account_id`. A supplied +# selector, if present, MUST match it. +# - user (superuser) token => the selector is REQUIRED and MUST be one of +# the token's `account_ids`. +# 4. Map that central account id to the LOCAL scope root via +# `config.account_resolver` (e.g. Account.find_by(synced_id:)) and return +# it as the tools' `scope_root`. +# +# The required scope (explicitly declared per resource via +# `required_permissions_scope`, or the registry default) is enforced separately +# by Tools::Base (#with_account / #with_authentication) via +# `authorized_for_scope?`; the authenticator only validates the token and +# resolves the tenant. +# +# The account selector mirrors what a gateway forwards: the resolved account id +# arrives as `_meta[config.account_meta_key]`. We also accept an `account_id` +# tool argument and the `config.account_id_header` header as fallbacks. +class McpToolkit::Auth::Authenticator + Context = Struct.new(:scope_root, :introspection, keyword_init: true) + + # @param token [String] the plaintext bearer the central app forwarded + # @param meta [Hash] the JSON-RPC `_meta` (string or symbol keys) + # @param arguments [Hash] the tool-call arguments (may carry `account_id`) + # @param header_account_id [Integer,String,nil] the account-id header value + # @param config [McpToolkit::Configuration] + def self.call(token:, meta: {}, arguments: {}, header_account_id: nil, config: McpToolkit.config) + new(token:, meta:, arguments:, header_account_id:, config:).call + end + + def initialize(token:, meta:, arguments:, header_account_id:, config: McpToolkit.config) + @token = token + @meta = (meta || {}).transform_keys(&:to_s) + @arguments = (arguments || {}).transform_keys(&:to_s) + @header_account_id = header_account_id + @config = config + end + + def call + introspection = McpToolkit::Auth::Introspection.call(token, config:) + raise McpToolkit::Errors::Unauthorized, "invalid or expired token" unless introspection.valid? + + central_account_id = resolve_account_id(introspection) + scope_root = config.account_resolver.call(central_account_id) + unless scope_root + raise McpToolkit::Errors::Unauthorized, "no local scope found for account_id=#{central_account_id}" + end + + Context.new(scope_root:, introspection:) + end + + private + + attr_reader :token, :meta, :arguments, :header_account_id, :config + + def resolve_account_id(introspection) + candidate = candidate_account_id + + if introspection.accounts_user? + bound = introspection.account_id + # Compare as STRINGS: ids may be numeric or string/UUID, and to_i would + # collapse every non-numeric id to 0 (letting "acct_B" match "acct_A"). + if candidate.present? && candidate.to_s != bound.to_s + raise McpToolkit::Errors::Unauthorized, + "account_id #{candidate} does not match this token's bound account" + end + + return bound.to_s + end + + # superuser / multi-account: selection is mandatory and must be authorized. + if candidate.blank? + raise McpToolkit::Errors::Unauthorized, + "this token spans multiple accounts; an account must be selected " \ + "via _meta[\"#{config.account_meta_key}\"] (or the account_id argument)" + end + + # String-normalized membership (see accounts_user branch): `candidate` may be + # an Integer (forwarded `_meta` JSON number) or a String (header/arg); to_s + # normalizes both and authorized_account_ids is likewise string-normalized. + unless introspection.authorized_account_ids.include?(candidate.to_s) + raise McpToolkit::Errors::Unauthorized, "account_id #{candidate} is not authorized for this token" + end + + candidate.to_s + end + + def candidate_account_id + meta[config.account_meta_key].presence || + arguments["account_id"].presence || + header_account_id.presence + end +end diff --git a/lib/mcp_toolkit/auth/authority.rb b/lib/mcp_toolkit/auth/authority.rb new file mode 100644 index 0000000..befef53 --- /dev/null +++ b/lib/mcp_toolkit/auth/authority.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# AUTHORITY side. The helpers the central app uses to (a) authenticate a +# plaintext bearer token against its local token store and (b) answer the +# introspection request satellites send. +# +# Both are thin and config-driven: the actual token lookup is the app's +# `config.token_authenticator` callable (its `McpToken.authenticate` +# equivalent), and the introspection payload is derived from the duck-typed +# token object that callable returns. +# +# ## Token object contract +# +# `config.token_authenticator.call(plaintext)` must return nil (no/invalid +# token) or an object responding to: +# +# #kind -> :accounts_user | :user (or string equivalents) +# #account_id -> the single bound account id for an accounts_user token, +# else nil +# #account_ids -> Array of authorized account ids +# #expires_at -> a Time/DateTime responding to #iso8601, or nil +# #scopes -> Array of OAuth-style `__` scopes ([] = no scopes). +# The sole authorization source on the satellite side. +# +# Optionally `#touch_last_used!` (called after a successful authenticate if +# present). A typical app token model (e.g. `McpToken`) satisfies this. +module McpToolkit::Auth::Authority + # Authenticate a plaintext bearer locally. Returns the token object or nil. + # Calls `touch_last_used!` on the token if it responds to it (throttled + # last-used tracking is the token model's concern, not ours). + def self.authenticate(plaintext, config: McpToolkit.config) + authenticator = config.token_authenticator + if authenticator.nil? + raise McpToolkit::Errors::ConfigurationError, + "token_authenticator is not configured; required for the :authority role" + end + + token = authenticator.call(plaintext) + return nil unless token + + token.touch_last_used! if token.respond_to?(:touch_last_used!) + token + end + + # Build the introspection response payload for a token object. This is the + # JSON the authority's `/mcp/tokens/introspect` endpoint renders, and the + # exact contract Auth::Introspection (the satellite) parses. + # + # @param token [#kind, #account_id, #account_ids, #expires_at, #scopes] + # @return [Hash] + def self.introspection_payload(token) + account_ids = Array(token.account_ids) + { + valid: true, + kind: token.kind.to_s, + account_id: account_id_for(token, account_ids), + account_ids:, + expires_at: token.expires_at&.iso8601, + scopes: Array(token.scopes) + } + end + + # The payload returned for a missing/invalid token. Render with HTTP 401. + def self.invalid_payload + { valid: false } + end + + # account_id is the single bound account for an accounts_user token, else nil + # (a superuser/multi-account token advertises its set via account_ids). + def self.account_id_for(token, account_ids) + return token.account_id unless token.account_id.nil? + + token.kind.to_s == McpToolkit::TokenKinds::ACCOUNTS_USER ? account_ids.first : nil + end +end diff --git a/lib/mcp_toolkit/auth/authority_server_client.rb b/lib/mcp_toolkit/auth/authority_server_client.rb new file mode 100644 index 0000000..e1063f4 --- /dev/null +++ b/lib/mcp_toolkit/auth/authority_server_client.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "faraday" + +# SATELLITE side. The HTTP client that talks to the central authority's +# introspection endpoint. Owns the Faraday connection (timeouts, JSON parsing, +# adapter) and the POST itself, so Auth::Introspection is left with the caching +# and result-parsing concerns only. +# +# POST {config.introspect_url} +# Authorization: Bearer +# Accept: application/json +# +# Returns the raw response body on a 200, or nil otherwise (a non-200 status or a +# transport-level Faraday error). Auth::Introspection turns that into an +# Introspection::Result. +class McpToolkit::Auth::AuthorityServerClient + def initialize(config) + @config = config + end + + # POSTs the token to the authority's introspection endpoint. Returns the + # response body on a 200; nil on any non-200 status or transport failure. + def introspect(token) + response = connection.post(config.introspect_url) do |request| + request.headers["Authorization"] = "Bearer #{token}" + request.headers["Accept"] = "application/json" + end + + return nil unless response.status == 200 + + response.body + rescue Faraday::Error + nil + end + + private + + attr_reader :config + + def connection + timeout = config.introspection_timeout + Faraday.new do |conn| + conn.options.timeout = timeout + conn.options.open_timeout = timeout + conn.response :json, content_type: /\bjson$/ + conn.adapter Faraday.default_adapter + end + end +end diff --git a/lib/mcp_toolkit/auth/introspection.rb b/lib/mcp_toolkit/auth/introspection.rb new file mode 100644 index 0000000..3d1c5ce --- /dev/null +++ b/lib/mcp_toolkit/auth/introspection.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# SATELLITE side. Authenticates the bearer token the central app forwards by +# calling the central app's introspection endpoint, with a short-TTL cache so a +# burst of tool calls in one session does not hammer the central app. +# +# POST {central_app_url}{introspect_path} +# Authorization: Bearer +# +# Response contract (the AUTHORITY emits this; see Auth::Authority): +# { valid: bool, +# kind: , +# account_id: , +# account_ids: [...], +# expires_at: , +# scopes: [...] } # OAuth-style `__` scopes a token carries +# +# Authorization is purely scope-based: a token reaches a tool when it carries the +# scope that tool explicitly requires (declared per resource via +# `required_permissions_scope`, or the registry default; enforced in +# Tools::Base#with_account / #with_authentication via `authorized_for_scope?`). +# +# The cache is keyed on a SHA-256 of the token (never the plaintext) so cached +# entries can't be reversed back to a usable credential from cache storage. The +# HTTP call itself is delegated to Auth::AuthorityServerClient. +class McpToolkit::Auth::Introspection + CACHE_PREFIX = "mcp_toolkit:introspection:" + + Result = Struct.new( + :valid, :kind, :account_id, :account_ids, :expires_at, :scopes, keyword_init: true + ) do + # Locally enforce the authority's `expires_at` (defense-in-depth): a token is + # only valid when the authority said so AND it has not lapsed. Without this, + # validity would rest solely on the authority's `valid: true` boolean, so a + # stale `valid: true` (e.g. a clock-skewed or buggy authority, or a cached + # Result) with a past `expires_at` would be accepted indefinitely. + def valid? + valid == true && !expired? + end + + # True when `expires_at` is present and now is at/after it. Blank/nil means + # the token has no expiry (=> not expired). An UNPARSEABLE `expires_at` is + # treated as expired (fail-closed) rather than silently accepted. Kept public + # for clarity/testability. + def expired? + raw = expires_at + return false if raw.nil? || raw.to_s.strip.empty? + + expiry = parse_expiry(raw) + return true if expiry.nil? # unparseable => fail closed + + expiry <= Time.now + end + + private + + def parse_expiry(raw) + return raw if raw.is_a?(Time) + + Time.iso8601(raw.to_s) + rescue ArgumentError, TypeError + begin + Time.parse(raw.to_s) + rescue ArgumentError, TypeError + nil + end + end + + public + + def accounts_user? + kind.to_s == McpToolkit::TokenKinds::ACCOUNTS_USER + end + + def superuser? + kind.to_s == McpToolkit::TokenKinds::USER + end + + # True when the token carries the EXACT `required_scope` (e.g. + # `notifications__read`). An empty required scope passes (a tool that + # requires no scope is reachable by any valid token). A non-empty required + # scope must be present in the token's `scopes`; empty token scopes are + # therefore unrestricted ONLY for tools that require no scope. + def authorized_for_scope?(required_scope) + return true if required_scope.to_s.empty? + + scope_list = Array(scopes).map(&:to_s) + scope_list.include?(required_scope.to_s) + end + + # Account ids are STRING-normalized for comparison: the contract allows + # integer OR string/UUID ids, so coercing to_i would collapse every + # non-numeric id to 0 and let unrelated accounts match. Strings compare + # safely and the resolver's `find_by(synced_id:)` coerces back per-column. + def authorized_account_ids + Array(account_ids).map(&:to_s) + end + end + + INVALID = Result.new(valid: false).freeze + + # Returns an Introspection::Result. Invalid/expired/unreachable => a result + # whose `valid?` is false. Caches positive AND negative results briefly. + def self.call(token, config: McpToolkit.config) + new(token, config:).call + end + + def initialize(token, config: McpToolkit.config) + @token = token + @config = config + end + + def call + return INVALID if token.to_s.empty? + + cached = cache.read(cache_key) + return cached if cached + + result = fetch + cache.write(cache_key, result, expires_in: config.introspection_cache_ttl) + result + end + + private + + attr_reader :token, :config + + def fetch + body = authority_server_client.introspect(token) + return INVALID if body.nil? + + parse(body) + end + + def parse(body) + payload = body.is_a?(Hash) ? body : JSON.parse(body) + return INVALID unless payload["valid"] == true + + Result.new( + valid: true, + kind: payload["kind"], + account_id: payload["account_id"], + account_ids: payload["account_ids"], + expires_at: payload["expires_at"], + scopes: payload["scopes"] + ) + rescue JSON::ParserError + INVALID + end + + def authority_server_client + McpToolkit::Auth::AuthorityServerClient.new(config) + end + + def cache + config.cache_store + end + + def cache_key + "#{CACHE_PREFIX}#{Digest::SHA256.hexdigest(token)}" + end +end diff --git a/lib/mcp_toolkit/configuration.rb b/lib/mcp_toolkit/configuration.rb new file mode 100644 index 0000000..f995428 --- /dev/null +++ b/lib/mcp_toolkit/configuration.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +# The single, injectable configuration object for an app's MCP server. +# +# Generic but OPINIONATED: every setting has a sensible, vendor-neutral default, +# so a satellite needs to override only a handful of values. The two things an +# app almost always sets are +# `server_name` and the auth wiring (`central_app_url` for a satellite, or +# `token_authenticator` for the authority). +# +# Whether ANY scope is required is decided PER TOOL, not per app: a resource +# declares `required_permissions_scope "notifications__read"` (or the registry +# declares `default_required_permissions_scope` once for all resources). There is +# no app-wide permission setting here. +# +# Accessed through `McpToolkit.config` (or `MCPToolkit.config`) and mutated in a +# `McpToolkit.configure { |c| ... }` block. +class McpToolkit::Configuration + # --- server identity ------------------------------------------------------- + + # @return [String] the MCP server name advertised in `initialize`. + attr_accessor :server_name + # @return [String] the MCP server version advertised in `initialize`. + attr_accessor :server_version + # @return [String, nil] human-readable `instructions` returned on `initialize`. + attr_accessor :server_instructions + + # --- serialization --------------------------------------------------------- + + # The DEFAULT serializer base class. A `Resource` registration that does not + # supply its own `serializer` inherits nothing here — serializers are picked + # per-resource — but this is the class the gem ships and documents as the base + # to subclass. Apps that want their own existing serializers simply register + # resources with those classes instead; the gem only requires that a serializer + # responds to `serialize_one` / `serialize_collection` (see + # McpToolkit::Serializer::Base for the contract). + # + # @return [Class] + # The reader is defined below as a lazily-defaulting method; only the writer + # comes from here. + attr_writer :serializer_base + + # --- auth: role ------------------------------------------------------------ + + # @return [Symbol] :satellite (introspect tokens against a central app) or + # :authority (be the introspection provider + authenticate local tokens). + # A single app MAY be both — set :authority and still configure a + # `central_app_url` if it also exposes its own tools as a satellite. + attr_accessor :auth_role + + # --- auth: satellite side -------------------------------------------------- + + # @return [String, nil] base URL of the central auth app. + # The satellite POSTs `/mcp/tokens/introspect`. + attr_accessor :central_app_url + # @return [String, nil] the introspect path appended to `central_app_url`. + attr_accessor :introspect_path + # @return [Integer] seconds to cache an introspection result (positive AND + # negative) so a burst of tool calls does not hammer the central app. + attr_accessor :introspection_cache_ttl + # @return [Integer] HTTP open/read timeout for the introspection call. + attr_accessor :introspection_timeout + + # Resolves the central account id to the satellite's LOCAL scope root. + # + # A satellite stores rows keyed by the central app's account id (synced via + # Kafka etc.). This callable receives the resolved central `account_id` and + # MUST return the object that `Resource#scope` blocks root on (typically the + # local `Account`). Return nil to signal "no local account" (=> Unauthorized). + # + # c.account_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_id) } + # + # Defaults to the identity function: the resolved central account id is used + # directly as the scope root (suitable for an app whose scope blocks key on the + # central id, or for the authority itself). + # + # @return [#call] + attr_accessor :account_resolver + + # --- auth: authority side -------------------------------------------------- + + # Looks up + verifies a plaintext bearer token locally, returning a token + # object (duck-typed, see below) or nil. This is the authority's + # `McpToken.authenticate(plaintext)` equivalent. Required for the :authority + # role; unused by a pure satellite. + # + # c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) } + # + # The returned token object must respond to the methods + # `McpToolkit::Auth::Authority#introspection_payload` reads (see that module for + # the contract): `kind`, `account_id`, `account_ids`, `expires_at`, `scopes`. A + # `touch_last_used!` method, if present, is called. + # + # @return [#call, nil] + attr_accessor :token_authenticator + + # --- caching --------------------------------------------------------------- + + # The cache store backing sessions and introspection results. Must satisfy the + # ActiveSupport::Cache::Store contract (`read`/`write`/`delete` with + # `expires_in:`). Defaults to an in-process MemoryStore; a real deployment + # should set this to `Rails.cache` (or any shared store) so sessions survive + # across Puma workers. + # + # @return [ActiveSupport::Cache::Store, #read] + attr_accessor :cache_store + + # @return [Integer] session sliding-TTL in seconds. + attr_accessor :session_ttl + + # --- filtering ------------------------------------------------------------- + + # Escapes LIKE wildcards in `matches` / `does_not_match` filter values so they + # match literally. Must respond to `sanitize_sql_like(string)`. Defaults to the + # ActiveRecord-backed McpToolkit::SqlSanitizer; a non-Rails host (or a test) can + # inject its own. + # + # @return [#sanitize_sql_like] + attr_accessor :sql_sanitizer + + # --- protocol / transport -------------------------------------------------- + + # @return [String, nil] protocol version to pin on the underlying MCP::Server. + # nil lets the gem negotiate (recommended). Set only to force an older spec. + attr_accessor :protocol_version + + # The parent class (as a String, resolved via `constantize`) of the + # gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts. + # Doorkeeper-style indirection so a satellite mounting the engine can keep + # ActionController::Base (NOT ::API) — e.g. for a logstasher `helper_method` + # hook — by setting `c.parent_controller = "ApplicationController"`. + # + # @return [String] + attr_accessor :parent_controller + + # Header / meta-key constants. Vendor-neutral defaults; an app on a specific + # central authority can rename them to match that authority's convention. + # These are the selectors a superuser/multi-account token uses to pin the + # active account. + # + # @return [String] + attr_accessor :account_meta_key + # @return [String] + attr_accessor :account_id_header + + # The resource registry for this configuration. Each config carries its own so + # tests (and, in principle, multiple mounted servers) don't collide. The + # process-wide convenience `McpToolkit.registry` delegates to the active + # config's registry. + # + # @return [McpToolkit::Registry] + attr_accessor :registry + + # Vendor-neutral defaults; apps override the auth wiring + identity as needed. + def initialize + @server_name = "mcp-server" + @server_version = "1.0.0" + @server_instructions = nil + + @serializer_base = nil # set lazily in #serializer_base to avoid load-order issues + + @auth_role = :satellite + @central_app_url = nil + @introspect_path = "/mcp/tokens/introspect" + @introspection_cache_ttl = 45 + @introspection_timeout = 10 + @account_resolver = ->(synced_account_id) { synced_account_id } + + @token_authenticator = nil + + @cache_store = ActiveSupport::Cache::MemoryStore.new + @session_ttl = 3600 # 1 hour + + @sql_sanitizer = McpToolkit::SqlSanitizer.new + + @protocol_version = nil + @parent_controller = "ActionController::Base" + @account_meta_key = "mcp-toolkit/account-id" + @account_id_header = "X-MCP-Account-ID" + + @registry = McpToolkit::Registry.new + end + + # The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so + # `McpToolkit::Serializer::Base` is referenced after it has been required, + # regardless of file load order. + def serializer_base + @serializer_base ||= McpToolkit::Serializer::Base + end + + # @return [Boolean] whether this app introspects tokens against a central app. + def satellite? + auth_role.to_sym == :satellite || central_app_url + end + + # @return [Boolean] whether this app authenticates tokens locally / answers + # introspection. + def authority? + auth_role.to_sym == :authority + end + + # Full introspection URL the satellite POSTs to. Raises a clear error if the + # central URL was never configured. + def introspect_url + raise McpToolkit::Errors::ConfigurationError, "central_app_url is not configured" if central_app_url.to_s.empty? + + "#{central_app_url.chomp("/")}#{introspect_path}" + end +end diff --git a/lib/mcp_toolkit/engine.rb b/lib/mcp_toolkit/engine.rb new file mode 100644 index 0000000..099a6a5 --- /dev/null +++ b/lib/mcp_toolkit/engine.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Mountable Rails engine that draws the four MCP transport routes (defined in the +# engine's config/routes.rb so they survive Rails' route reloads) against the +# gem-provided McpToolkit::ServerController. A satellite mounts it in one line: +# +# # config/routes.rb +# mount McpToolkit::Engine => "/mcp" +# +# yielding exactly the endpoints a hand-rolled satellite declared: +# +# POST /mcp -> create (JSON-RPC requests/responses) +# GET /mcp -> stream (405; no server-initiated SSE) +# DELETE /mcp -> destroy (terminate the session) +# GET /mcp/health -> health (unauthenticated probe) +# +# Loaded ONLY when Rails::Engine is available (see lib/mcp_toolkit.rb); the gem's +# non-Rails consumers and its own unit suite never reference it. +class McpToolkit::Engine < Rails::Engine + isolate_namespace McpToolkit +end diff --git a/lib/mcp_toolkit/errors/base.rb b/lib/mcp_toolkit/errors/base.rb new file mode 100644 index 0000000..308c84a --- /dev/null +++ b/lib/mcp_toolkit/errors/base.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Tool-level errors raised by executors / auth and caught at the tool boundary, +# where they are turned into an `isError: true` MCP tool result (NOT a JSON-RPC +# protocol error). This matches how MCP clients (and a gateway relaying the +# satellite's `result` verbatim) expect tool failures to surface: the call +# succeeds at the protocol level, the result carries the error. +# +# Base class for that family; subclasses below narrow the cause. +class McpToolkit::Errors::Base < StandardError; end diff --git a/lib/mcp_toolkit/errors/configuration_error.rb b/lib/mcp_toolkit/errors/configuration_error.rb new file mode 100644 index 0000000..cd5c8f1 --- /dev/null +++ b/lib/mcp_toolkit/errors/configuration_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# The toolkit was used before it was configured, or a required piece of +# configuration is missing for the operation being attempted. +class McpToolkit::Errors::ConfigurationError < McpToolkit::Errors::Base; end diff --git a/lib/mcp_toolkit/errors/invalid_params.rb b/lib/mcp_toolkit/errors/invalid_params.rb new file mode 100644 index 0000000..5a8e288 --- /dev/null +++ b/lib/mcp_toolkit/errors/invalid_params.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# The arguments to a tool were invalid (missing id, unknown resource, bad +# account selection, etc.). +class McpToolkit::Errors::InvalidParams < McpToolkit::Errors::Base; end diff --git a/lib/mcp_toolkit/errors/unauthorized.rb b/lib/mcp_toolkit/errors/unauthorized.rb new file mode 100644 index 0000000..d9d1e81 --- /dev/null +++ b/lib/mcp_toolkit/errors/unauthorized.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# The caller is not authenticated / authorized (token invalid, expired, lacks +# the required permissions scope, or no/invalid account context). +class McpToolkit::Errors::Unauthorized < McpToolkit::Errors::Base; end diff --git a/lib/mcp_toolkit/filtering.rb b/lib/mcp_toolkit/filtering.rb new file mode 100644 index 0000000..2ec80be --- /dev/null +++ b/lib/mcp_toolkit/filtering.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +# Applies the `list` tool's `filter` params to an already-scoped relation, with +# the following semantics: +# +# * a BARE value filters by equality: filter: { booking_id: 42 } +# (a comma-separated string becomes an IN lookup: filter: { status: "a,b" }) +# * an { op:, value: } HASH filters with an operator: filter: { price: { op: "gteq", value: 100 } } +# * an ARRAY of those hashes ANDs them (ranges): +# filter: { price: [{ op: "gteq", value: 100 }, { op: "lt", value: 200 }] } +# +# Supported operators (validated against the column's DB type): +# eq, not_eq, gt, gteq, lt, lteq — numeric / datetime columns +# eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE) +# eq, not_eq — boolean columns +# +# Allowlist-safe: only the resource's declared `filterable` keys may be filtered, +# each resolved to its backing column. Unknown keys are rejected upstream by the +# ListExecutor, and an operator unsupported for a column's type raises +# InvalidParams — there is no arbitrary column or SQL injection surface. +module McpToolkit::Filtering + OPERATORS_BY_TYPE = { + integer: %w[eq not_eq gt gteq lt lteq].freeze, + float: %w[eq not_eq gt gteq lt lteq].freeze, + decimal: %w[eq not_eq gt gteq lt lteq].freeze, + datetime: %w[eq not_eq gt gteq lt lteq].freeze, + date: %w[eq not_eq gt gteq lt lteq].freeze, + string: %w[eq not_eq in matches does_not_match].freeze, + text: %w[eq not_eq in matches does_not_match].freeze, + boolean: %w[eq not_eq].freeze + }.freeze + + # Operators that map straight onto an Arel predication method. + AREL_PREDICATIONS = %w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze + + NULL_TOKEN = "null" + + # @param relation the already account-scoped relation + # @param column [Symbol] the backing DB column (already resolved from the allowlist) + # @param value the filter value: a bare value, an { op:, value: } hash, or an + # array of such hashes + # @param config [McpToolkit::Configuration] supplies the SQL sanitizer used to + # escape LIKE wildcards + # @return the relation with the filter(s) applied + def self.apply(relation, column, value, config: McpToolkit.config) + if compound?(value) + apply_condition(relation, column, value, config:) + elsif collection?(value) + value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) } + else + # Bare value: equality. A comma-separated string becomes an IN lookup, + # matching the implicit `eq` semantics. + relation.where(column => equality_value(value)) + end + end + + # A single operator-based condition, e.g. { op: "gt", value: 1000 }. + def self.compound?(value) + condition_hash?(value) && (value.key?(:op) || value.key?("op")) + end + + # Several operator-based conditions on one attribute, ANDed (ranges). + def self.collection?(value) + value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) } + end + + def self.condition_hash?(value) + value.is_a?(Hash) + end + + def self.apply_condition(relation, column, condition, config:) + operator = fetch(condition, :op).to_s + raise McpToolkit::Errors::InvalidParams, "a filter operator is required" if operator.empty? + + type = column_type(relation, column) + validate_operator!(operator, type, column) + + raw = fetch(condition, :value) + relation.where(predicate_for(relation, column, operator, raw, config:)) + end + + # Reads a key regardless of symbol/string keys; checks presence (not + # truthiness) so a literal `false` / `nil` value survives. + def self.fetch(condition, key) + return condition[key] if condition.key?(key) + + condition[key.to_s] if condition.key?(key.to_s) + end + + def self.column_type(relation, column) + model = relation.respond_to?(:model) ? relation.model : nil + return nil unless model.respond_to?(:columns_hash) + + model.columns_hash[column.to_s]&.type + end + + def self.validate_operator!(operator, type, column) + allowed = OPERATORS_BY_TYPE[type] + if allowed.nil? + raise McpToolkit::Errors::InvalidParams, + "'#{column}' cannot be filtered with operators" + end + return if allowed.include?(operator) + + raise McpToolkit::Errors::InvalidParams, + "'#{operator}' operator is not supported for #{column} (#{type}). " \ + "Supported operators: #{allowed.join(", ")}." + end + + # Builds the predicate to hand to `relation.where`. For a real ActiveRecord + # relation we build an Arel node (so it composes with the scope safely and is + # immune to SQL injection); a relation without an `arel_table` (the in-memory + # test fake) receives a portable Predicate value object it knows how to apply. + def self.predicate_for(relation, column, operator, raw, config:) + value = normalize_value(operator, raw, config:) + arel_operator = operator == "eq" ? "in" : operator + + model = relation.respond_to?(:model) ? relation.model : nil + if model.respond_to?(:arel_table) + model.arel_table[column.to_sym].public_send(arel_operator, value) + else + Predicate.new(column.to_sym, arel_operator, value) + end + end + + # `eq` against a (possibly comma-separated) value becomes an IN set. `matches` + # / `does_not_match` wrap the value in `%...%` with LIKE wildcards escaped so + # they match literally. `null` => nil for every operator. + def self.normalize_value(operator, raw, config:) + return nil if raw.to_s == NULL_TOKEN + + case operator + when "eq", "in" + raw.to_s.split(",") + when "matches", "does_not_match" + "%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%" + else + raw + end + end + + def self.equality_value(value) + return nil if value.to_s == NULL_TOKEN + + str = value.to_s + str.include?(",") ? str.split(",") : value + end + + # Portable representation of an operator predicate, applied by the in-memory + # test relation. Production uses Arel nodes instead (see .predicate_for). + Predicate = Struct.new(:column, :operator, :value) +end diff --git a/lib/mcp_toolkit/get_executor.rb b/lib/mcp_toolkit/get_executor.rb new file mode 100644 index 0000000..03d044f --- /dev/null +++ b/lib/mcp_toolkit/get_executor.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Runs a read-only "show" query for a registered resource by id, rooted on the +# scoped relation so cross-scope ids are simply not found. Serializes via the +# resource's serializer. +class McpToolkit::GetExecutor + def self.call(resource:, scope_root:, id:) + new(resource:, scope_root:, id:).call + end + + def initialize(resource:, scope_root:, id:) + @resource = resource + @scope_root = scope_root + @id = id + end + + def call + raise McpToolkit::Errors::InvalidParams, "id is required" if id.blank? + + record = resource.resolve_relation(scope_root).find_by(id:) + raise McpToolkit::Errors::InvalidParams, "#{resource.name} not found for id=#{id}" unless record + + resource.serializer.serialize_one(record, scope: scope_root) + end + + private + + attr_reader :resource, :scope_root, :id +end diff --git a/lib/mcp_toolkit/list_executor.rb b/lib/mcp_toolkit/list_executor.rb new file mode 100644 index 0000000..871e68e --- /dev/null +++ b/lib/mcp_toolkit/list_executor.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Runs a read-only, paginated "list" query for a registered resource, rooted on +# the scoped relation. Supports the standard `ids`, `updated_since`, `limit`, +# `offset` filters plus the resource's declared per-attribute filters (equality +# AND operator-based — see McpToolkit::Filtering), and serializes via the +# resource's serializer, producing the `{ => [...], meta: {...} }` wrapper. +class McpToolkit::ListExecutor + DEFAULT_LIMIT = 25 + MAX_LIMIT = 100 + # Column types whose primary key sorts naturally by `id`. Anything else (e.g. a + # string/uuid PK) sorts by `created_at` instead. + NUMERIC_PK_TYPES = %i[integer bigint].freeze + + def self.call(resource:, scope_root:, params:) + new(resource:, scope_root:, params:).call + end + + def initialize(resource:, scope_root:, params:) + @resource = resource + @scope_root = scope_root + @params = (params || {}).deep_symbolize_keys + end + + def call + relation = build_relation + total_count = relation.count + rows = paginate(relation).to_a + + resource.serializer.serialize_collection( + rows, scope: scope_root, total_count:, limit:, offset: + ) + end + + private + + attr_reader :resource, :scope_root, :params + + def build_relation + relation = resource.resolve_relation(scope_root) + relation = apply_ids(relation) + relation = apply_updated_since(relation) + relation = apply_attribute_filters(relation) + apply_order(relation) + end + + # Order by `id` when the primary key is numeric; otherwise by `created_at` + # (a non-numeric PK does not sort meaningfully). + def apply_order(relation) + relation.order(numeric_primary_key? ? :id : :created_at) + end + + def numeric_primary_key? + model = resource.model + return true unless model.respond_to?(:columns_hash) && model.respond_to?(:primary_key) + + pk = model.primary_key + return true if pk.nil? + + type = model.columns_hash[pk.to_s]&.type + type.nil? || NUMERIC_PK_TYPES.include?(type) + end + + # Applies per-attribute filters (`filter: { key: value | { op:, value: } | [...] }`). + # Each request-facing key is resolved against the resource's declared allowlist + # (`Resource#filterable`) to its backing column, then added as WHERE clause(s) on + # TOP of the already scoped relation — filtering composes with scoping and can + # only ever NARROW it, never widen it. Equality and operator-based semantics are + # delegated to McpToolkit::Filtering. + # + # Unknown filter keys are rejected with InvalidParams, so the caller gets + # actionable feedback instead of a silently-ignored filter. + def apply_attribute_filters(relation) + filter = params[:filter] + return relation if filter.blank? + + mapping = resource.filterable_columns + validate_filter_keys!(filter, mapping) + + filter.each do |request_key, value| + next if value.nil? || value == "" + + column = mapping[request_key.to_sym] + relation = McpToolkit::Filtering.apply(relation, column, value) + end + relation + end + + def validate_filter_keys!(filter, mapping) + keys = filter.keys.map(&:to_sym) + unknown = keys - mapping.keys + return if unknown.empty? + + allowed = mapping.keys.sort.join(", ") + raise McpToolkit::Errors::InvalidParams, + "unknown filter attribute(s): #{unknown.join(", ")}. " \ + "Filterable attributes for this resource: #{allowed.presence || "(none)"}" + end + + def apply_ids(relation) + return relation if params[:ids].blank? + + ids = params[:ids].to_s.split(",").map(&:strip).compact_blank + ids.empty? ? relation : relation.where(id: ids) + end + + def apply_updated_since(relation) + return relation if params[:updated_since].blank? + + time = parse_time(params[:updated_since]) + relation.where("#{relation.table_name}.updated_at > ?", time) + end + + def parse_time(value) + Time.zone.parse(value.to_s) || raise(ArgumentError) + rescue ArgumentError, TypeError + raise McpToolkit::Errors::InvalidParams, "updated_since must be an ISO 8601 timestamp" + end + + def paginate(relation) + relation.offset(offset).limit(limit) + end + + def limit + raw = params[:limit] || DEFAULT_LIMIT + [[raw.to_i, 1].max, MAX_LIMIT].min # rubocop:disable Style/ComparableClamp + end + + def offset + [params[:offset].to_i, 0].max + end +end diff --git a/lib/mcp_toolkit/registry.rb b/lib/mcp_toolkit/registry.rb new file mode 100644 index 0000000..6e56725 --- /dev/null +++ b/lib/mcp_toolkit/registry.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Central registry of read-only resources exposed via the MCP server. Resources +# are registered at boot (in a `to_prepare` initializer) and consumed by the +# generic `resources` / `resource_schema` / `get` / `list` tools. +# +# Instances are addressable so tests (and, in principle, multiple mounted +# servers) don't collide; the app-facing convenience is `McpToolkit.registry`, +# which returns the process-wide instance. +class McpToolkit::Registry + class UnknownResource < StandardError; end + + def initialize + @resources = {} + @default_required_permissions_scope = nil + end + + # Registry-wide DEFAULT required scope, so a satellite declares its scope ONCE + # for every resource instead of repeating it per resource: + # + # McpToolkit.registry.default_required_permissions_scope "notifications__read" + # + # A resource's own `required_permissions_scope` overrides this. Default nil = no + # scope required unless a resource declares its own. Read with no arg. + # + # Declared in the satellite's `configure` block (NOT inside `to_prepare`), so it + # survives `reset!` and stays set across dev reloads. + def default_required_permissions_scope(scope = nil) + @default_required_permissions_scope = scope if scope + @default_required_permissions_scope + end + + def register(name, &) + resource = McpToolkit::Resource.new(name) + resource.instance_eval(&) + @resources[name.to_s] = resource + end + + # The scope a token must carry to reach `resource` via the generic tools: the + # resource's own declared scope, else the registry default, else nil (no check). + def required_scope_for(resource) + resource.effective_required_permissions_scope(@default_required_permissions_scope) + end + + def fetch(name) + find(name) or raise(UnknownResource, "unknown resource: #{name.inspect}") + end + + def find(name) + @resources[name.to_s] + end + + def resources + @resources.values + end + + def resource_names + @resources.keys + end + + # Clears registered resources for a dev reload (the satellite re-declares them + # in `to_prepare`). The `default_required_permissions_scope` is PRESERVED, since + # it's declared once in `configure` rather than per-reload. + def reset! + @resources = {} + end +end diff --git a/lib/mcp_toolkit/resource.rb b/lib/mcp_toolkit/resource.rb new file mode 100644 index 0000000..1275b25 --- /dev/null +++ b/lib/mcp_toolkit/resource.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# Descriptor for a single read-only resource exposed via the MCP server. Built +# via `McpToolkit.registry.register`; consumed by the List/Get executors and the +# resource_schema tool. +# +# The `scope_block` is the account-rooting relation: it receives the resolved +# local scope root (typically an `Account`) and MUST return a relation already +# scoped so that every row belongs to that root (directly via a foreign key, or +# transitively through an owning record). This is the single tenancy chokepoint — +# every `get`/`list` query roots on it. +# +# The `serializer` is INJECTABLE per resource: it may be a subclass of the gem's +# `McpToolkit::Serializer::Base`, or any class satisfying the serializer contract +# (`serialize_one` / `serialize_collection`) — e.g. an app's existing serializer. +class McpToolkit::Resource + class NotConfigured < StandardError; end + + attr_reader :name + + def initialize(name) + @name = name.to_s + @model = nil + @serializer = nil + @scope_block = nil + @description = nil + @filterable = {} + @required_permissions_scope = nil + end + + def model(klass = nil) + @model = klass if klass + @model + end + + def serializer(klass = nil) + @serializer = klass if klass + @serializer + end + + def scope(&block) + @scope_block = block if block + @scope_block + end + + def description(text = nil) + @description = text if text + @description + end + + # The OAuth-style scope a token MUST carry to reach this resource via the + # generic tools (e.g. "notifications__read"). Declared explicitly per resource: + # + # required_permissions_scope "notifications__read" + # + # Default nil = no scope required for this resource (unless the registry sets a + # default — see Registry#default_required_permissions_scope). Read with no arg. + def required_permissions_scope(scope = nil) + @required_permissions_scope = scope if scope + @required_permissions_scope + end + + # The scope actually enforced for this resource: its own declared scope if set, + # otherwise the registry-level `default` passed in. nil = no scope required. + def effective_required_permissions_scope(default = nil) + @required_permissions_scope || default + end + + # Declares the per-attribute filters this resource accepts on the `list` tool. + # Each entry maps a REQUEST-FACING filter key to the backing DATABASE COLUMN the + # WHERE is applied to. The mapping is what lets the consumer-facing key differ + # from the storage column (e.g. exposing a synced foreign key under its public + # name): + # + # filterable booking_id: :synced_booking_id + # + # A declared key accepts both a bare equality value AND operator-based + # conditions (`{ op:, value: }` or an array of them, ANDed) — see + # McpToolkit::Filtering for the supported operators per column type. + # + # Unmapped/unknown keys are rejected by the list executor, never silently + # dropped, so a typo surfaces as actionable feedback. + def filterable(mapping = nil) + return @filterable if mapping.nil? + + mapping.each do |request_key, column| + @filterable[request_key.to_sym] = column.to_sym + end + @filterable + end + + # Request-facing filter keys (symbols, sorted) this resource can be filtered + # by. Surfaced via the `resource_schema` tool. + def filterable_keys + @filterable.keys.sort + end + + # Request-facing filter key (symbol) => backing column (symbol). Consumed by + # the list executor to build the WHERE clause. + def filterable_columns + @filterable + end + + # The account-scoped relation for this resource. Raises if misconfigured so a + # registry mistake fails loudly rather than leaking an unscoped query. + def resolve_relation(scope_root) + raise NotConfigured, "resource #{name.inspect} has no scope block" unless scope + raise NotConfigured, "resource #{name.inspect} has no model" unless model + raise NotConfigured, "resource #{name.inspect} has no serializer" unless serializer + + scope.call(scope_root) + end + + # Serialized attribute names (the response shape), read off the serializer's + # declared attributes. Requires a serializer that exposes `declared_attributes` + # (the gem's base does); resource_schema degrades gracefully otherwise. + def attribute_names + return [] unless serializer.respond_to?(:declared_attributes) + + serializer.declared_attributes.map(&:to_sym) + end + + # Association descriptors (the `links` shape) read off the serializer. + def association_descriptors + return [] unless serializer.respond_to?(:declared_associations) + + serializer.declared_associations + end +end diff --git a/lib/mcp_toolkit/resource_schema.rb b/lib/mcp_toolkit/resource_schema.rb new file mode 100644 index 0000000..b7b6d51 --- /dev/null +++ b/lib/mcp_toolkit/resource_schema.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# Builds a machine-readable schema for a single registered resource: its +# serialized attributes (the response shape) with column types, and its +# relationships (the `links` shape). Powers the `resource_schema` discovery tool +# so an MCP client can learn a resource's shape without trial and error. +# +# Read-only with standard filters (ids/updated_since/limit/offset) plus the +# resource's declared equality filters. +class McpToolkit::ResourceSchema + TYPE_FORMATS = { + datetime: "ISO 8601", + date: "ISO 8601", + decimal: "number", + float: "number", + integer: "integer", + boolean: "true/false", + string: "string", + text: "string" + }.freeze + COMPUTED_TYPE = "computed" + STANDARD_FILTERS = %w[ids updated_since limit offset].freeze + + def self.call(resource) + new(resource).call + end + + def initialize(resource) + @resource = resource + @model = resource.model + end + + def call + { + name: resource.name, + description: resource.description, + attributes:, + relationships:, + standard_filters: STANDARD_FILTERS, + filters: + } + end + + private + + attr_reader :resource, :model + + def attributes + resource.attribute_names.map { |name| attribute_schema(name) } + end + + def attribute_schema(name) + type = column_type(name) + { + name:, + type: type ? type.to_s : COMPUTED_TYPE, + format: type ? TYPE_FORMATS[type] : nil, + filterable: filterable_column_for(name).present? + }.compact + end + + # Per-attribute equality filters this resource accepts on the `list` tool's + # `filter` argument. Each entry is the request-facing key, the backing column + # it matches against, and the column's type — self-describing so an MCP client + # can construct a valid filter without trial and error. + def filters + resource.filterable_columns.sort.map do |request_key, column| + type = column_type(column) + { + key: request_key, + column:, + type: type ? type.to_s : COMPUTED_TYPE, + format: type ? TYPE_FORMATS[type] : nil + }.compact + end + end + + # Backing column for a serialized attribute that is also a filter key, if any. + # A filter key may be a public alias (e.g. booking_id -> synced_booking_id) so + # we match on either the request key or the column. + def filterable_column_for(attribute_name) + resource.filterable_columns.find do |request_key, column| + request_key.to_s == attribute_name.to_s || column.to_s == attribute_name.to_s + end + end + + def relationships + resource.association_descriptors.map do |association| + { + name: association.links_key, + kind: association.type.to_s, + polymorphic: association.polymorphic || false + } + end + end + + # Reads an attribute's DB column type, tolerating models that don't expose + # `columns_hash` (returns nil => the attribute is reported as "computed"). + def column_type(name) + return nil unless model.respond_to?(:columns_hash) + + model.columns_hash[name.to_s]&.type + end +end diff --git a/lib/mcp_toolkit/serializer/base.rb b/lib/mcp_toolkit/serializer/base.rb new file mode 100644 index 0000000..df11ac8 --- /dev/null +++ b/lib/mcp_toolkit/serializer/base.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +# The DEFAULT serializer base shipped by the toolkit. A self-contained +# implementation of the subset of an AMS-style serializer the MCP wire format +# depends on, with NO dependency on `active_model_serializers` / `fast_jsonapi`. +# +# ## The injection contract +# +# The executors (`ListExecutor` / `GetExecutor`) only ever call two class +# methods on a resource's serializer: +# +# serializer.serialize_one(record, scope:) +# # => Hash (a single record's shape), or nil for a nil record +# +# serializer.serialize_collection(records, scope:, total_count:, limit:, offset:) +# # => { => [ , ... ], +# # meta: { total_count:, limit:, offset: } } +# +# ANY class implementing those two methods can be registered as a resource's +# serializer — that is the seam that lets an app's existing serializers slot in +# unchanged alongside this base. The `resource_schema` tool additionally reads +# `declared_attributes` and +# `declared_associations` off the serializer (for shape discovery); a custom +# serializer that wants to power `resource_schema` should expose those too, but +# they are not required for `get` / `list`. +# +# `scope` is whatever the serializer needs (typically the account); it may be +# nil for models without translations. +# +# ## Output shape +# +# A single record serializes to: +# +# { => , ..., "links" => { "" => } } +# +# * Declared `attributes` are emitted as symbol keys, in declaration order +# (an instance method named after the attribute overrides the column value). +# * `"links"` is a string key whose value is a Hash with string keys, one per +# declared association, sorted alphabetically. +# - has_one / belongs_to whose FK lives on the record => the raw id (or nil) +# - polymorphic has_one / belongs_to => { id: , type: } +# - has_many => a sorted Array of associated ids ([] when none) +# * created_at / updated_at, when present, are rendered as iso8601(6). +# +# A collection serializes to: +# +# { : [ , ... ], +# meta: { total_count:, limit:, offset: } } +class McpToolkit::Serializer::Base + TIMESTAMP_COLUMNS = %i[created_at updated_at].freeze + HIGH_PRECISION_FOR_TIMESTAMPS = 6 + + # ---- class-level DSL ------------------------------------------------- + + Association = Struct.new(:name, :type, :key, :serializer, :polymorphic, :foreign_key, keyword_init: true) do + # Public-facing key used inside the `links` hash. + def links_key + (key || name).to_s + end + end + + def self.attributes(*names) + names.each { |name| declared_attributes << name.to_sym } + end + + # belongs_to / has_one - single id (or {id:,type:} when polymorphic). + # + # `foreign_key:` overrides the FK method read for the id (defaults to + # `_id`). Use it when the model's FK column doesn't follow the + # `_id` convention - e.g. + # `has_one :account, foreign_key: :synced_account_id` so the link reports + # the central account id straight off the already-loaded column. + def self.has_one(name, key: nil, root: nil, serializer: nil, polymorphic: false, foreign_key: nil) + declared_associations << Association.new( + name: name.to_sym, type: :has_one, key: key || root, serializer:, polymorphic:, foreign_key: + ) + end + + # has_many / has_and_belongs_to_many - sorted array of ids. + def self.has_many(name, key: nil, root: nil, serializer: nil) + declared_associations << Association.new( + name: name.to_sym, type: :has_many, key: key || root, serializer:, polymorphic: false + ) + end + + # Declares attributes whose value is a `{ locale => translation }` hash. + # An instance method is defined for each attribute that delegates to + # `#translate`. Only meaningful for Globalize models; harmless otherwise + # (returns {}). + def self.translates(*names) + names.each do |name| + declared_attributes << name.to_sym unless declared_attributes.include?(name.to_sym) + define_method(name) { translate(name) } + end + end + + def self.declared_attributes + @declared_attributes ||= [] + end + + def self.declared_associations + @declared_associations ||= [] + end + + # ---- entry points used by the executors (the injection contract) ----- + + # Serialize a single record to its attributes+links hash. nil-safe. + def self.serialize_one(record, scope: nil) + return nil if record.nil? + + new(record, scope:).serializable_hash + end + + # Serialize an array of records to the index wrapper, keyed by the + # pluralized resource name, with a `meta` pagination block. + def self.serialize_collection(records, scope: nil, total_count: nil, limit: nil, offset: nil) + rows = Array(records).map { |record| new(record, scope:).serializable_hash } + { + root_key => rows, + meta: { total_count: total_count.nil? ? rows.size : total_count, limit:, offset: } + } + end + + # Pluralized resource name used as the collection root key, derived from + # the serialized model (`model.model_name.plural`). + def self.root_key + model_class.model_name.plural.to_sym + end + + # Infer the serialized model from the serializer class name by stripping a + # trailing "Serializer" and the host namespace, e.g. + # Mcp::NotificationSerializer -> Notification + # Mcp::PushNotifications::FilterSerializer -> PushNotifications::Filter + # Subclasses whose name doesn't follow the convention set `model_class`. + def self.model_class + @model_class ||= begin + without_suffix = name.delete_suffix("Serializer") + # Drop the leading serializer namespace segment (e.g. "Mcp::") so the + # remainder names the model. If there is no namespace, use as-is. + without_namespace = without_suffix.sub(/\A[^:]+::/, "") + (without_namespace.empty? ? without_suffix : without_namespace).constantize + end + end + + # Lets subclasses point at a model whose name doesn't follow the + # convention (e.g. namespacing differences). Written as an explicit class + # method (not `attr_writer`, which would define an instance writer) to set the + # class-level @model_class the convention-inference memoizes. + def self.model_class=(klass) # rubocop:disable Style/TrivialAccessors + @model_class = klass + end + + # ---- instance API ---------------------------------------------------- + + attr_reader :object, :scope + + def initialize(object, scope: nil) + @object = object + @scope = scope + end + + def serializable_hash + hash = {} + self.class.declared_attributes.each do |attr| + hash[attr] = read_attribute(attr) + end + apply_high_precision_timestamps(hash) + hash["links"] = links + hash + end + alias as_json serializable_hash + + private + + def read_attribute(attr) + # An instance method named after the attribute overrides the column value + # (AMS convention). Globalize `translates` uses exactly this hook. + if respond_to?(attr, true) && method(attr).owner != McpToolkit::Serializer::Base + public_send(attr) + else + object.public_send(attr) + end + end + + def apply_high_precision_timestamps(hash) + TIMESTAMP_COLUMNS.each do |column| + value = hash[column] + hash[column] = value.iso8601(HIGH_PRECISION_FOR_TIMESTAMPS) if value.present? && value.respond_to?(:iso8601) + end + end + + # Builds the `links` hash: association links_key => ids, sorted. + def links + pairs = self.class.declared_associations.map do |association| + [association.links_key, serialize_ids(association)] + end + pairs.sort_by(&:first).to_h + end + + # Serializes an association to its id(s): + # * FK present on the record -> the raw id (polymorphic -> {id:,type:}) + # * otherwise load the association -> sorted array of ids (has_many) + # or single id (has_one). + def serialize_ids(association) + fk_method = association.foreign_key || :"#{association.name}_id" + + if object.respond_to?(fk_method) + if association.polymorphic + { id: object.public_send(fk_method), type: object.public_send(:"#{association.name}_type") } + else + object.public_send(fk_method) + end + else + associated = object.public_send(association.name) + if associated.respond_to?(:to_ary) || associated.respond_to?(:pluck) + associated.pluck(:id).sort + elsif associated + associated.id + end + end + end + + # Globalize-backed translation: `{ locale => value }`, restricted to the + # account's selected locales when a scope account is present. Returns {} when + # the model is not translatable. + def translate(attribute) + return {} unless object.respond_to?(:"#{attribute}_translations") + + translations = object.public_send(:"#{attribute}_translations") || {} + locales = scope_locales + result = {} + translations.each do |locale, value| + locale = locale.to_sym + next if locales&.exclude?(locale) + next if value.blank? + + result[locale] = value + end + result + end + + # Locales to restrict translations to. nil means "no restriction" (emit all + # available translations). + def scope_locales + return nil if scope.nil? + return nil unless scope.respond_to?(:selected_locales) + + selected = scope.selected_locales + return nil if selected.blank? + + Array(selected).map(&:to_sym) + end +end diff --git a/lib/mcp_toolkit/server.rb b/lib/mcp_toolkit/server.rb new file mode 100644 index 0000000..bb04cc3 --- /dev/null +++ b/lib/mcp_toolkit/server.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Builds the official-SDK `MCP::Server` for this app: the JSON-RPC dispatcher +# with the toolkit's generic tools registered. The transport / session / HTTP +# layer lives in McpToolkit::Transport::ControllerMethods; this is purely the +# gem's dispatcher with tools + the per-request server_context. +# +# The official `mcp` gem is the JSON-RPC core (per the 2026-06-18 architecture +# decision: standardize on the gem, wrapped, rather than a hand-rolled protocol). +module McpToolkit::Server + # The generic, registry-driven toolset every server gets. Apps that want + # additional bespoke tools pass them via `extra_tools:`. + GENERIC_TOOLS = [ + McpToolkit::Tools::Resources, + McpToolkit::Tools::ResourceSchema, + McpToolkit::Tools::Get, + McpToolkit::Tools::List + ].freeze + + # @param server_context [Hash] per-request context threaded to tools: + # :bearer_token, :header_account_id, :mcp_config, and (merged in by the gem) + # :_meta. + # @param config [McpToolkit::Configuration] + # @param extra_tools [Array] additional MCP::Tool subclasses to expose. + # @return [MCP::Server] + def self.build(server_context:, config: McpToolkit.config, extra_tools: []) + context = server_context.dup + context[:mcp_config] ||= config + + kwargs = { + name: config.server_name, + version: config.server_version, + instructions: config.server_instructions, + tools: GENERIC_TOOLS + Array(extra_tools), + server_context: context + } + if config.protocol_version + kwargs[:configuration] = + MCP::Configuration.new(protocol_version: config.protocol_version) + end + + MCP::Server.new(**kwargs) + end +end diff --git a/lib/mcp_toolkit/session.rb b/lib/mcp_toolkit/session.rb new file mode 100644 index 0000000..ec6e582 --- /dev/null +++ b/lib/mcp_toolkit/session.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Server-side session for the MCP Streamable HTTP transport: created on +# `initialize`, identified by an opaque `Mcp-Session-Id` header the client echoes +# on every later request, stored in the configured cache with a sliding TTL. +# +# Cache-backed (rather than the gem's in-process StreamableHTTPTransport) so +# sessions survive across Puma workers and interoperate with a gateway's client. +# The cache store + TTL come from McpToolkit.config. +class McpToolkit::Session + CACHE_KEY_PREFIX = "mcp_toolkit:session:" + + def self.create!(config: McpToolkit.config) + id = SecureRandom.uuid + config.cache_store.write(cache_key(id), { created_at: Time.now.to_i }, expires_in: config.session_ttl) + new(id:) + end + + def self.find(id, config: McpToolkit.config) + return nil if id.to_s.empty? + + data = config.cache_store.read(cache_key(id)) + return nil unless data + + # Sliding expiry: bump TTL on every successful lookup. + config.cache_store.write(cache_key(id), data, expires_in: config.session_ttl) + new(id:) + end + + def self.delete(id, config: McpToolkit.config) + return false if id.to_s.empty? + + config.cache_store.delete(cache_key(id)) + end + + def self.cache_key(id) + "#{CACHE_KEY_PREFIX}#{id}" + end + private_class_method :cache_key + + attr_reader :id + + def initialize(id:) + @id = id + end +end diff --git a/lib/mcp_toolkit/sql_sanitizer.rb b/lib/mcp_toolkit/sql_sanitizer.rb new file mode 100644 index 0000000..ed0436b --- /dev/null +++ b/lib/mcp_toolkit/sql_sanitizer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Escapes LIKE wildcards (`%`, `_`, `\`) in a string so a `matches` / +# `does_not_match` filter value is matched literally rather than as a pattern. +# +# The default implementation delegates to ActiveRecord's +# `sanitize_sql_like` (production); it is injected via +# `Configuration#sql_sanitizer` so a non-Rails host (or a test) can supply its +# own. Filtering calls `config.sql_sanitizer.sanitize_sql_like(value)`. +class McpToolkit::SqlSanitizer + def sanitize_sql_like(string) + ActiveRecord::Base.sanitize_sql_like(string) + end +end diff --git a/lib/mcp_toolkit/token_kinds.rb b/lib/mcp_toolkit/token_kinds.rb new file mode 100644 index 0000000..80a3c3e --- /dev/null +++ b/lib/mcp_toolkit/token_kinds.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# The token "kind" domain values, shared by the collaborating auth objects so the +# string literals live in exactly one place. The authority emits these in the +# introspection payload (`kind`); the satellite reads them back +# (`Auth::Introspection::Result#accounts_user?` / `#superuser?`). +# +# ACCOUNTS_USER - a token bound to a single account. +# USER - a superuser / multi-account token (advertises its set via +# `account_ids`). +module McpToolkit::TokenKinds + ACCOUNTS_USER = "accounts_user" + USER = "user" +end diff --git a/lib/mcp_toolkit/tools/base.rb b/lib/mcp_toolkit/tools/base.rb new file mode 100644 index 0000000..feec6e0 --- /dev/null +++ b/lib/mcp_toolkit/tools/base.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Base class for the generic MCP tools. Subclasses an official-SDK `MCP::Tool`, +# so `name`/`description`/`input_schema` and the `call` contract are the gem's. +# This base adds the shared concern every tool needs: authenticating + +# scope-resolving the caller (via McpToolkit::Auth::Authenticator) before +# running, and turning tool-level errors into `isError: true` MCP results +# (rather than letting them become JSON-RPC protocol errors). +# +# The bearer token, JSON-RPC `_meta`, and the account-id header are threaded in +# through `server_context` (set per-request by the controller). The active +# McpToolkit config is also threaded in as `server_context[:mcp_config]` so a +# process can, in principle, host more than one configured server; it falls back +# to `McpToolkit.config`. +class McpToolkit::Tools::Base < MCP::Tool + # Runs `block` with an authenticated, scoped context, serializing any + # McpToolkit::Errors into a clean text tool error. + # + # The resolved `scope_root` is yielded — it is the tools' serializer `scope` + # AND the root every query is scoped through. + # + # `account_id` is the superuser account selector arriving as a tool + # argument (the gem passes tool args as kwargs, not via server_context), + # threaded here so it joins `_meta` / the header in the resolution order. + # + # `required_scope` is the explicitly-declared scope a token must carry (the + # caller resolves it from the resource — see Registry#required_scope_for). + # Empty/nil => no scope check (authorized_for_scope? treats "" as a pass). + def self.with_account(server_context, account_id: nil, required_scope: nil) + config = config_from(server_context) + context = McpToolkit::Auth::Authenticator.call( + token: server_context[:bearer_token], + meta: meta_from(server_context), + arguments: { "account_id" => account_id }.compact, + header_account_id: server_context[:header_account_id], + config: + ) + + unless context.introspection.authorized_for_scope?(required_scope) + return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope") + end + + text_response(yield(context.scope_root)) + rescue McpToolkit::Errors::Unauthorized => e + error_response("Unauthorized: #{e.message}") + rescue McpToolkit::Errors::InvalidParams => e + error_response("Invalid request: #{e.message}") + end + + # Authenticates the token (valid + the explicitly-declared `required_scope`) + # WITHOUT requiring an account selection. Used by the schema-discovery tools, + # which reveal shape, not tenant data, so a superuser shouldn't have to pin an + # account just to discover what exists. Empty/nil `required_scope` => no scope + # check. + def self.with_authentication(server_context, required_scope: nil) + config = config_from(server_context) + introspection = McpToolkit::Auth::Introspection.call(server_context[:bearer_token], config:) + return error_response("Unauthorized: invalid or expired token") unless introspection.valid? + + unless introspection.authorized_for_scope?(required_scope) + return error_response("Unauthorized: token lacks the #{required_scope.inspect} scope") + end + + text_response(yield) + rescue McpToolkit::Errors::InvalidParams => e + error_response("Invalid request: #{e.message}") + end + + def self.text_response(payload) + text = payload.is_a?(String) ? payload : JSON.generate(payload) + MCP::Tool::Response.new([{ type: "text", text: }]) + end + + def self.error_response(message) + MCP::Tool::Response.new([{ type: "text", text: message }], error: true) + end + + # The gem nests the request `_meta` under server_context[:_meta]. + def self.meta_from(server_context) + server_context[:_meta] || {} + end + + def self.config_from(server_context) + server_context[:mcp_config] || McpToolkit.config + end + + def self.lookup_resource(name, config) + config.registry.fetch(name) + rescue McpToolkit::Registry::UnknownResource => e + raise McpToolkit::Errors::InvalidParams, e.message + end + + # Validates the `resource` argument is present and resolves its descriptor, + # raising InvalidParams (=> clean tool error) for a blank or unknown resource. + def self.resolve_descriptor(name, config) + raise McpToolkit::Errors::InvalidParams, "resource is required" if name.to_s.strip.empty? + + lookup_resource(name, config) + end +end diff --git a/lib/mcp_toolkit/tools/get.rb b/lib/mcp_toolkit/tools/get.rb new file mode 100644 index 0000000..addd0a9 --- /dev/null +++ b/lib/mcp_toolkit/tools/get.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Fetches a single record by id from a registered resource, scoped to the +# resolved scope root. +class McpToolkit::Tools::Get < McpToolkit::Tools::Base + tool_name "get" + description <<~DESC.strip + Fetch a single record by id from a read-only resource. Pass the resource name as `resource` + and the record id as `id`. Use the `resources` tool to discover available resources. + + For tokens that span multiple accounts (superuser), pass `account_id` to pin the active + account; account-scoped tokens may omit it. The response mirrors the resource's record + shape (attributes + a `links` block). + DESC + + input_schema( + properties: { + resource: { + type: "string", + description: "Resource name (use the `resources` tool to discover valid values)" + }, + # The id type is left open so a string/UUID primary key works as well as an + # integer one; the record is looked up by the value as given, uncoerced. + id: { type: %w[string integer], description: "The record ID (integer or string/UUID)" }, + account_id: { + type: "integer", + description: "Account to operate on. Required for superuser tokens; ignored otherwise." + } + }, + required: %w[resource id] + ) + + def self.call(server_context:, resource: nil, id: nil, account_id: nil, **_args) + config = config_from(server_context) + # Resolve the resource FIRST so its effective required scope is known before + # the scope check (and so an unknown resource is a clean tool error). + descriptor = resolve_descriptor(resource, config) + required_scope = config.registry.required_scope_for(descriptor) + with_account(server_context, account_id:, required_scope:) do |scope_root| + McpToolkit::GetExecutor.call(resource: descriptor, scope_root:, id:) + end + rescue McpToolkit::Errors::InvalidParams => e + error_response("Invalid request: #{e.message}") + end +end diff --git a/lib/mcp_toolkit/tools/list.rb b/lib/mcp_toolkit/tools/list.rb new file mode 100644 index 0000000..c77e08b --- /dev/null +++ b/lib/mcp_toolkit/tools/list.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Fetches a paginated list of records from a registered resource, scoped to the +# resolved scope root. +class McpToolkit::Tools::List < McpToolkit::Tools::Base + tool_name "list" + description <<~DESC.strip + Fetch a paginated list of records from a read-only resource. Pass the resource name as + `resource`. Use the `resources` tool to discover resources and `resource_schema` to learn a + resource's shape. + + Standard filters: + - ids: comma-separated list of IDs to fetch + - updated_since: ISO 8601 timestamp; only records updated after this time + - limit: page size (default 25, max 100) + - offset: pagination offset (default 0) + + Per-attribute equality filters: + - filter: an object of { : } exact-match filters, applied ON TOP of the + account scope (they can only narrow, never widen). Each resource advertises its + available filter keys via `resource_schema` (the `filters` array). Unknown keys are + rejected. + + For tokens that span multiple accounts (superuser), pass `account_id` to pin the active + account; account-scoped tokens may omit it. The response shape is + { "": [...], "meta": { total_count, limit, offset } }. + DESC + + input_schema( + properties: { + resource: { + type: "string", + description: "Resource name (use the `resources` tool to discover valid values)" + }, + account_id: { + type: "integer", + description: "Account to operate on. Required for superuser tokens; ignored otherwise." + }, + ids: { type: "string", description: "Comma-separated list of IDs to fetch" }, + updated_since: { + type: "string", + description: "ISO 8601 timestamp; only records updated after this time" + }, + filter: { + type: "object", + description: "Per-attribute exact-match equality filters, e.g. { \"booking_id\": 42 }. " \ + "See a resource's `resource_schema` `filters` for the keys it accepts.", + additionalProperties: true + }, + limit: { type: "integer", description: "Page size (default 25, max 100)" }, + offset: { type: "integer", description: "Pagination offset (default 0)" } + }, + required: ["resource"] + ) + + def self.call(server_context:, resource: nil, account_id: nil, **params) + config = config_from(server_context) + # Resolve the resource FIRST so its effective required scope is known before + # the scope check (and so an unknown resource is a clean tool error). + descriptor = resolve_descriptor(resource, config) + required_scope = config.registry.required_scope_for(descriptor) + with_account(server_context, account_id:, required_scope:) do |scope_root| + McpToolkit::ListExecutor.call(resource: descriptor, scope_root:, params:) + end + rescue McpToolkit::Errors::InvalidParams => e + error_response("Invalid request: #{e.message}") + end +end diff --git a/lib/mcp_toolkit/tools/resource_schema.rb b/lib/mcp_toolkit/tools/resource_schema.rb new file mode 100644 index 0000000..16f16eb --- /dev/null +++ b/lib/mcp_toolkit/tools/resource_schema.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Discovery tool: the detailed schema (attributes + relationships) of one +# resource. +class McpToolkit::Tools::ResourceSchema < McpToolkit::Tools::Base + tool_name "resource_schema" + description <<~DESC.strip + Describe a single read-only resource in detail. Pass the resource name as `resource` (use + the `resources` tool to discover names). Returns: + - attributes: every field in the response, each with its `type` and a value `format` hint + - relationships: associated resources emitted in the record's `links` + - standard_filters: ids, updated_since, limit, offset (accepted by the `list` tool) + - filters: the per-attribute equality filter keys the `list` tool accepts + Call this before `list` to learn a resource's shape. + DESC + + input_schema( + properties: { + resource: { + type: "string", + description: "Resource name (use the `resources` tool to discover valid values)" + } + }, + required: ["resource"] + ) + + def self.call(server_context:, resource: nil, **_args) + config = config_from(server_context) + # Resolve the resource FIRST so its effective required scope gates discovery + # of THIS resource's shape (and an unknown resource is a clean tool error). + descriptor = resolve_descriptor(resource, config) + with_authentication(server_context, required_scope: config.registry.required_scope_for(descriptor)) do + McpToolkit::ResourceSchema.call(descriptor) + end + rescue McpToolkit::Errors::InvalidParams => e + error_response("Invalid request: #{e.message}") + end +end diff --git a/lib/mcp_toolkit/tools/resources.rb b/lib/mcp_toolkit/tools/resources.rb new file mode 100644 index 0000000..d45ae34 --- /dev/null +++ b/lib/mcp_toolkit/tools/resources.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Discovery tool: lists every read-only resource exposed by this server. +class McpToolkit::Tools::Resources < McpToolkit::Tools::Base + tool_name "resources" + description <<~DESC.strip + List all read-only resources available via the `list` and `get` tools. Returns each + resource's name and a short description. Call this once at the start of a session to learn + what exists, then use `resource_schema` for a specific resource's attributes and + relationships. + DESC + + input_schema(properties: {}) + + def self.call(server_context:, **_args) + config = config_from(server_context) + # Discovery requires the registry-level default scope (the satellite's + # app-wide scope); per-resource scopes are enforced by `get` / `list`. + with_authentication(server_context, required_scope: config.registry.default_required_permissions_scope) do + { + resources: config.registry.resources.map do |resource| + { name: resource.name, description: resource.description } + end + } + end + end +end diff --git a/lib/mcp_toolkit/transport/controller_methods.rb b/lib/mcp_toolkit/transport/controller_methods.rb new file mode 100644 index 0000000..3fdb958 --- /dev/null +++ b/lib/mcp_toolkit/transport/controller_methods.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +# The MCP Streamable-HTTP transport, provided as an includable concern. An +# app's controller includes this to get the full transport with no per-app code: +# +# class Mcp::ServerController < ApplicationController +# include McpToolkit::Transport::ControllerMethods +# end +# +# and routes the four endpoints at its actions: +# +# post "mcp", to: "mcp/server#create" +# get "mcp", to: "mcp/server#stream" +# delete "mcp", to: "mcp/server#destroy" +# get "mcp/health", to: "mcp/server#health" +# +# Endpoints +# POST /mcp - JSON-RPC requests/responses +# GET /mcp - server-initiated SSE stream (none emitted; 405) +# DELETE /mcp - terminate the current session +# GET /mcp/health - unauthenticated health probe +# +# Authentication +# The bearer token is NOT verified at the transport boundary; it is forwarded +# into each tool, which authenticates it against the central app's +# introspection endpoint (McpToolkit::Auth::Authenticator). The transport only +# requires that *a* token be present (so unauthenticated calls are refused +# before any work). Tools resolve the active account from `_meta` / +# `account_id` argument / the account-id header. +# +# Session lifecycle +# - First `initialize` POST: create a session, return its id in the +# `Mcp-Session-Id` response header. The client echoes it on later requests. +# - Subsequent POSTs: validate the session id; missing/expired => 404. +# - DELETE /mcp ends the session. +# +# Notifications (requests without an `id`) get a 202 with no body. +# +# Overridable hooks +# - `mcp_config` -> the McpToolkit::Configuration to use (default: McpToolkit.config) +# - `mcp_extra_tools` -> Array of additional MCP::Tool subclasses (default: []) +# +# CSRF: the concern disables forgery protection (this is a token-authenticated +# JSON API). Inherit from ActionController::Base (not ::API) if your app's +# controller stack needs helper_method, as bsa-notifications does. +module McpToolkit::Transport::ControllerMethods + extend ActiveSupport::Concern + + SESSION_HEADER = "Mcp-Session-Id" + + included do + protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery) + + before_action :mcp_require_token!, except: [:health] + before_action :mcp_resolve_session!, only: [:create] + end + + def create + request_body = mcp_parsed_body + server = McpToolkit::Server.build( + server_context: mcp_server_context, + config: mcp_config, + extra_tools: mcp_extra_tools + ) + response_json = server.handle_json(JSON.generate(request_body)) + + # handle_json returns nil for notifications (no id) -> 202 Accepted, no body. + return head :accepted if response_json.nil? + + mcp_render_response(response_json) + end + + # GET /mcp - no server-initiated SSE stream is emitted; 405 per MCP spec. + def stream + head :method_not_allowed + end + + # DELETE /mcp - terminate the current session. + def destroy + McpToolkit::Session.delete(request.headers[SESSION_HEADER], config: mcp_config) + head :no_content + end + + # GET /mcp/health - unauthenticated probe. + def health + render json: { + status: "ok", + server: mcp_config.server_name, + version: mcp_config.server_version + } + end + + private + + # ---- overridable hooks ---------------------------------------------- + + def mcp_config + McpToolkit.config + end + + def mcp_extra_tools + [] + end + + # ---- per-request context -------------------------------------------- + + # Per-request context threaded to the tools. The gem merges the request's + # `_meta` into this hash (as `:_meta`) at tool-call time. + def mcp_server_context + { + bearer_token: mcp_extract_token, + header_account_id: request.headers[mcp_config.account_id_header].presence, + mcp_config: mcp_config + } + end + + # Renders the JSON-RPC payload as application/json by default, or as a single + # SSE `message` frame when the client's Accept header asks for it. We never + # actually stream (one message + EOF) so a strict client interoperates either + # way. + def mcp_render_response(response_json) + if mcp_event_stream_requested? + response.headers["Cache-Control"] = "no-cache" + body = "event: message\ndata: #{response_json}\n\n" + render body:, content_type: Mime::Type.lookup("text/event-stream").to_s + else + render json: response_json + end + end + + def mcp_event_stream_requested? + request.headers["Accept"].to_s.include?("text/event-stream") + end + + # ---- auth (presence only; real auth is per-tool) -------------------- + + # A token must be present; its validity is enforced per-tool. Extraction + # order: Bearer header, then X-MCP-Token, then ?token=. + def mcp_require_token! + return if mcp_extract_token.present? + + mcp_render_unauthorized("Missing authorization token") + end + + def mcp_extract_token + auth_header = request.headers["Authorization"] + return auth_header.sub("Bearer ", "") if auth_header&.start_with?("Bearer ") + + request.headers["X-MCP-Token"].presence || params[:token].presence + end + + # ---- session lifecycle ---------------------------------------------- + + # POST: create a session on `initialize`, otherwise require an existing one. + def mcp_resolve_session! + methods = mcp_methods_from(mcp_parsed_body) + + if methods.include?("initialize") + @mcp_session = McpToolkit::Session.create!(config: mcp_config) + else + @mcp_session = McpToolkit::Session.find(request.headers[SESSION_HEADER], config: mcp_config) + return mcp_render_session_not_found if @mcp_session.nil? + end + + response.headers[SESSION_HEADER] = @mcp_session.id + end + + def mcp_methods_from(request_body) + # Array.wrap (not Kernel#Array): a single JSON-RPC Hash must wrap to + # [hash], whereas Kernel#Array(hash) would explode it into [[k, v], ...]. + Array.wrap(request_body).filter_map { |req| req.is_a?(Hash) ? req["method"] : nil } + end + + def mcp_parsed_body + return @mcp_parsed_body if defined?(@mcp_parsed_body) + + @mcp_parsed_body = JSON.parse(request.body.read) + rescue JSON::ParserError + @mcp_parsed_body = {} + end + + # ---- error renders --------------------------------------------------- + + def mcp_render_unauthorized(message) + render json: { + jsonrpc: "2.0", + id: nil, + error: { code: -32_000, message: "Unauthorized: #{message}" } + }, status: :unauthorized + end + + def mcp_render_session_not_found + render json: { + jsonrpc: "2.0", + id: nil, + error: { code: -32_001, message: "Session not found or expired" } + }, status: :not_found + end +end diff --git a/mcp_toolkit.gemspec b/mcp_toolkit.gemspec index 1d944ad..06b4dd4 100644 --- a/mcp_toolkit.gemspec +++ b/mcp_toolkit.gemspec @@ -8,21 +8,23 @@ Gem::Specification.new do |spec| spec.authors = ["Karol Galanciak"] spec.email = ["karol.galanciak@gmail.com"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Opinionated toolkit for building account-scoped, read-only MCP servers." + spec.description = <<~DESC + mcp_toolkit extracts the shared MCP-server framework that Smily's apps grew + independently: a Streamable-HTTP transport, cache-backed sessions, central-app + token introspection (satellite + authority roles), a registry-driven + "generic tools over N resources" dispatcher, and an injectable serializer DSL. + It wraps the official `mcp` gem as the JSON-RPC core so each app ships only its + serializers, resource registrations, and scope blocks. + DESC + spec.homepage = "https://github.com/BookingSync/mcp_toolkit" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." - # Uncomment the line below to require MFA for gem pushes. - # This helps protect your gem from supply chain attacks by ensuring - # no one can publish a new version without multi-factor authentication. - # See: https://guides.rubygems.org/mfa-requirement-opt-in/ - # spec.metadata["rubygems_mfa_required"] = "true" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -37,9 +39,16 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - # For more information and examples about making a new gem, check out our - # guide at: https://guides.rubygems.org/make-your-own-gem/ + # Zeitwerk autoloads the gem's lib tree, so there are no manual require + # statements for the gem's own files. + spec.add_dependency "zeitwerk", "~> 2.6" + # The official MCP SDK is the JSON-RPC dispatcher core this toolkit wraps. + spec.add_dependency "mcp", "~> 0.18" + # activesupport supplies the cache-store contract, time, and Hash/Array helpers + # the toolkit relies on (deep_symbolize_keys, Array.wrap, iso8601, ...). We + # depend on it, not on full Rails, so non-Rails hosts can consume the gem. + spec.add_dependency "activesupport", ">= 6.1" + # faraday is the HTTP client the satellite uses to introspect tokens against + # the central app. + spec.add_dependency "faraday", ">= 1.0" end diff --git a/spec/mcp_toolkit/auth_spec.rb b/spec/mcp_toolkit/auth_spec.rb new file mode 100644 index 0000000..5dce0b5 --- /dev/null +++ b/spec/mcp_toolkit/auth_spec.rb @@ -0,0 +1,428 @@ +# frozen_string_literal: true + +require "spec_helper" +# This spec raises Faraday::ConnectionFailed directly (the unreachable-central +# case). faraday is required lazily by Auth::AuthorityServerClient, which isn't +# guaranteed to have loaded before this file runs under a randomized order, so +# require it here where the constant is referenced. +require "faraday" + +RSpec.describe "Authentication" do + let(:central_url) { "https://central.example.com" } + let(:introspect_endpoint) { "#{central_url}/mcp/tokens/introspect" } + + def configure_satellite! + McpToolkit.configure do |c| + c.auth_role = :satellite + c.central_app_url = central_url + c.registry.default_required_permissions_scope "notifications__read" + c.introspection_cache_ttl = 0 # don't let cache mask repeated stubs in tests + # Map the central account id to a local "scope root" object. The resolver + # receives a STRING-normalized id (mirroring `find_by(synced_id:)`, which + # AR-coerces per column), so compare as strings here too. + c.account_resolver = ->(synced_id) { synced_id.to_s == "42" ? :local_account_42 : nil } + end + end + + def stub_introspect(token:, body:, status: 200) + stub_request(:post, introspect_endpoint) + .with(headers: { "Authorization" => "Bearer #{token}" }) + .to_return(status:, body: JSON.generate(body), headers: { "Content-Type" => "application/json" }) + end + + describe McpToolkit::Auth::Introspection do + # The canonical introspection of a valid accounts_user token; examples that + # need other shapes call described_class.call with their own token/stub. + subject(:introspection_result) { described_class.call("good") } + + before { configure_satellite! } + + it "parses a valid response into a Result" do + stub_introspect( + token: "good", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: nil, scopes: ["notifications__read"] } + ) + + expect(introspection_result).to be_valid + expect(introspection_result).to be_accounts_user + expect(introspection_result.account_id).to eq(42) + expect(introspection_result.scopes).to eq(["notifications__read"]) + expect(introspection_result.authorized_for_scope?("notifications__read")).to be(true) + end + + it "derives per-scope authorization purely from scopes" do + stub_introspect( + token: "scoped", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: nil, scopes: ["notifications__read"] } + ) + + result = described_class.call("scoped") + + # exact scope present + expect(result.authorized_for_scope?("notifications__read")).to be(true) + # exact scope absent (different action) + expect(result.authorized_for_scope?("notifications__write")).to be(false) + # exact scope absent (different app) + expect(result.authorized_for_scope?("billing__read")).to be(false) + end + + it "allows a tool that requires NO scope when token scopes are empty" do + stub_introspect( + token: "no_scopes", + body: { valid: true, kind: "user", account_id: nil, account_ids: [1], expires_at: nil, scopes: [] } + ) + + result = described_class.call("no_scopes") + + # A tool requiring no scope (empty required scope) is reachable by any valid token. + expect(result.authorized_for_scope?("")).to be(true) + expect(result.authorized_for_scope?(nil)).to be(true) + end + + it "does NOT allow a tool that requires SOME scope when token scopes are empty" do + stub_introspect( + token: "no_scopes", + body: { valid: true, kind: "user", account_id: nil, account_ids: [1], expires_at: nil, scopes: [] } + ) + + result = described_class.call("no_scopes") + + # Empty token scopes is unrestricted ONLY for tools that require no scope: + # a tool requiring a scope is NOT reachable. + expect(result.authorized_for_scope?("anything__read")).to be(false) + end + + it "allows iff the required scope is present in the token's scopes" do + stub_introspect( + token: "mixed", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: nil, scopes: %w[notifications__read billing__read] } + ) + + result = described_class.call("mixed") + + # required scope present -> allowed + expect(result.authorized_for_scope?("notifications__read")).to be(true) + expect(result.authorized_for_scope?("billing__read")).to be(true) + # required scope absent -> not allowed + expect(result.authorized_for_scope?("notifications__write")).to be(false) + expect(result.authorized_for_scope?("other__read")).to be(false) + end + + it "treats an empty required scope as authorized regardless of token scopes" do + result = McpToolkit::Auth::Introspection::Result.new(valid: true, scopes: ["notifications__read"]) + + expect(result.authorized_for_scope?("")).to be(true) + expect(result.authorized_for_scope?(nil)).to be(true) + end + + it "string-normalizes authorized_account_ids so non-numeric ids don't collapse" do + # to_i would map every non-numeric id to 0; to_s preserves them distinctly. + result = McpToolkit::Auth::Introspection::Result.new( + valid: true, account_ids: [42, "acct_A", "550e8400-e29b-41d4-a716-446655440000"] + ) + + expect(result.authorized_account_ids) + .to eq(["42", "acct_A", "550e8400-e29b-41d4-a716-446655440000"]) + end + + describe "local expiry enforcement (defense-in-depth)" do + it "rejects a valid:true token whose expires_at is in the past" do + stub_introspect( + token: "lapsed", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: (Time.now - 3600).utc.iso8601, scopes: ["notifications__read"] } + ) + + result = described_class.call("lapsed") + + expect(result).not_to be_valid + expect(result).to be_expired + end + + it "accepts a valid:true token whose expires_at is in the future" do + stub_introspect( + token: "fresh", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: (Time.now + 3600).utc.iso8601, scopes: ["notifications__read"] } + ) + + result = described_class.call("fresh") + + expect(result).to be_valid + expect(result).not_to be_expired + end + + it "treats a nil/absent expires_at as a token with no expiry (valid)" do + stub_introspect( + token: "no_expiry", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: nil, scopes: ["notifications__read"] } + ) + + result = described_class.call("no_expiry") + + expect(result).to be_valid + expect(result).not_to be_expired + end + + it "treats an unparseable expires_at as expired (fail-closed)" do + stub_introspect( + token: "garbage_expiry", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], + expires_at: "not-a-timestamp", scopes: ["notifications__read"] } + ) + + result = described_class.call("garbage_expiry") + + expect(result).to be_expired + expect(result).not_to be_valid + end + + it "expires a cached Result the moment it lapses (evaluated at check time)" do + result = McpToolkit::Auth::Introspection::Result.new( + valid: true, expires_at: (Time.now - 1).utc.iso8601, scopes: ["notifications__read"] + ) + + expect(result).to be_expired + expect(result).not_to be_valid + end + end + + it "returns INVALID on a 401 from the central app" do + stub_introspect(token: "bad", status: 401, body: { valid: false }) + + expect(described_class.call("bad")).not_to be_valid + end + + it "returns INVALID (never raises) when the central app is unreachable" do + stub_request(:post, introspect_endpoint).to_raise(Faraday::ConnectionFailed.new("boom")) + + expect(described_class.call("any")).not_to be_valid + end + + it "treats a blank token as invalid without hitting the network" do + expect(described_class.call("")).not_to be_valid + expect(a_request(:post, introspect_endpoint)).not_to have_been_made + end + + it "caches results so a burst of calls makes a single HTTP request" do + McpToolkit.config.introspection_cache_ttl = 60 + stub = stub_introspect( + token: "cached", + body: { valid: true, kind: "user", account_id: nil, account_ids: [1, 2], scopes: ["notifications__read"] } + ) + + 3.times { described_class.call("cached") } + + expect(stub).to have_been_made.once + end + end + + describe McpToolkit::Auth::Authenticator do + # The canonical resolution of an accounts_user token; examples that exercise + # superuser / mismatch paths call described_class.call with their own args. + subject(:authenticated_context) { described_class.call(token: "au") } + + before { configure_satellite! } + + it "resolves an accounts_user token to its bound, locally-mapped scope root" do + stub_introspect( + token: "au", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], scopes: ["notifications__read"] } + ) + + expect(authenticated_context.scope_root).to eq(:local_account_42) + end + + it "resolves any valid token regardless of scope (scope enforcement lives at the tool layer)" do + # The authenticator only validates the token + resolves the tenant; the exact + # `__` scope is enforced by Tools::Base#with_account. A token + # carrying a different app's scope still resolves here... + stub_introspect( + token: "other_app", + body: { valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], scopes: ["other__read"] } + ) + + context = described_class.call(token: "other_app") + + expect(context.scope_root).to eq(:local_account_42) + # ...but it does NOT carry the notifications scope a tool would require. + expect(context.introspection.authorized_for_scope?("notifications__read")).to be(false) + end + + it "requires a superuser token to select an authorized account" do + stub_introspect( + token: "su", + body: { valid: true, kind: "user", account_id: nil, account_ids: [42, 99], scopes: ["notifications__read"] } + ) + + # No selector -> rejected + expect { described_class.call(token: "su") } + .to raise_error(McpToolkit::Errors::Unauthorized, /multiple accounts/) + + # Selector for an authorized account -> resolves + context = described_class.call(token: "su", arguments: { "account_id" => 42 }) + expect(context.scope_root).to eq(:local_account_42) + + # Selector for an unauthorized account -> rejected + expect { described_class.call(token: "su", arguments: { "account_id" => 7 }) } + .to raise_error(McpToolkit::Errors::Unauthorized, /not authorized/) + end + + it "rejects when no local scope maps to the resolved account" do + stub_introspect( + token: "no_local", + body: { valid: true, kind: "accounts_user", account_id: 999, account_ids: [999], + scopes: ["notifications__read"] } + ) + + expect { described_class.call(token: "no_local") } + .to raise_error(McpToolkit::Errors::Unauthorized, /no local scope/) + end + + it "rejects a non-numeric selector that mismatches an accounts_user's bound account" do + # Regression: with to_i both "acct_A" and "acct_B" collapse to 0, so the + # mismatched selector slipped the guard. String comparison rejects it. + stub_introspect( + token: "au_str", + body: { valid: true, kind: "accounts_user", account_id: "acct_A", account_ids: ["acct_A"], + scopes: ["notifications__read"] } + ) + + expect { described_class.call(token: "au_str", arguments: { "account_id" => "acct_B" }) } + .to raise_error(McpToolkit::Errors::Unauthorized, /does not match this token's bound account/) + end + + it "resolves a superuser selecting a string/UUID account to that exact id (not 0)" do + # Regression: the superuser branch returned candidate.to_i, so a string id + # resolved as 0. It now passes the exact string id to the resolver. + uuid = "550e8400-e29b-41d4-a716-446655440000" + McpToolkit.config.account_resolver = ->(synced_id) { synced_id == uuid ? :local_uuid_account : nil } + stub_introspect( + token: "su_str", + body: { valid: true, kind: "user", account_id: nil, account_ids: [uuid, "acct_X"], + scopes: ["notifications__read"] } + ) + + context = described_class.call(token: "su_str", arguments: { "account_id" => uuid }) + + expect(context.scope_root).to eq(:local_uuid_account) + end + + it "resolves a numeric selector (Integer or String) consistently to the same scope root" do + # The numeric case must keep working: candidate may arrive as a JSON number + # (Integer) or a header/arg String; both normalize to the same scope root. + stub_introspect( + token: "su_num", + body: { valid: true, kind: "user", account_id: nil, account_ids: [42, 99], + scopes: ["notifications__read"] } + ) + + from_integer = described_class.call(token: "su_num", arguments: { "account_id" => 42 }) + from_string = described_class.call(token: "su_num", arguments: { "account_id" => "42" }) + + expect(from_integer.scope_root).to eq(:local_account_42) + expect(from_string.scope_root).to eq(:local_account_42) + end + end + + describe McpToolkit::Auth::Authority do + # The authority module under test (a collection of module-level helpers). + subject(:authority) { described_class } + + # A token object matching the documented duck-typed contract. + let(:token_class) do + Struct.new(:kind, :account_id, :account_ids, :expires_at, :scopes, keyword_init: true) do + def touch_last_used! + @touched = true + end + + def touched? + @touched == true + end + end + end + + it "authenticates a plaintext token via the configured authenticator and touches last-used" do + token = token_class.new(kind: :accounts_user, account_id: 42, account_ids: [42], + expires_at: nil, scopes: ["notifications__read"]) + McpToolkit.configure do |c| + c.auth_role = :authority + c.token_authenticator = ->(plaintext) { plaintext == "secret" ? token : nil } + end + + authenticated = authority.authenticate("secret") + + expect(authenticated).to eq(token) + expect(token).to be_touched + expect(authority.authenticate("nope")).to be_nil + end + + it "raises ConfigurationError if no token_authenticator is set" do + McpToolkit.config.auth_role = :authority + expect { authority.authenticate("x") } + .to raise_error(McpToolkit::Errors::ConfigurationError, /token_authenticator/) + end + + it "builds the exact introspection payload the satellite parses (accounts_user)" do + token = token_class.new(kind: :accounts_user, account_id: 42, account_ids: [42], + expires_at: Time.utc(2026, 12, 31), scopes: ["notifications__read"]) + + payload = authority.introspection_payload(token) + + expect(payload).to eq( + valid: true, + kind: "accounts_user", + account_id: 42, + account_ids: [42], + expires_at: "2026-12-31T00:00:00Z", + scopes: ["notifications__read"] + ) + end + + it "does not emit an applications field (authorization is scope-only)" do + token = token_class.new(kind: :accounts_user, account_id: 42, account_ids: [42], + expires_at: nil, scopes: ["notifications__read"]) + + expect(authority.introspection_payload(token)).not_to have_key(:applications) + end + + it "nulls account_id for a superuser/multi-account token" do + token = token_class.new(kind: :user, account_id: nil, account_ids: [1, 2], + expires_at: nil, scopes: []) + + payload = authority.introspection_payload(token) + + expect(payload[:kind]).to eq("user") + expect(payload[:account_id]).to be_nil + expect(payload[:account_ids]).to eq([1, 2]) + expect(payload[:scopes]).to eq([]) + end + + it "wraps token scopes with Array (nil scopes => [])" do + token = token_class.new(kind: :accounts_user, account_id: 42, account_ids: [42], + expires_at: nil, scopes: nil) + + expect(authority.introspection_payload(token)[:scopes]).to eq([]) + end + + it "round-trips: an authority payload is consumed by the satellite Introspection parser" do + token = token_class.new(kind: :accounts_user, account_id: 42, account_ids: [42], + expires_at: nil, scopes: ["notifications__read"]) + payload = authority.introspection_payload(token) + + configure_satellite! + stub_request(:post, introspect_endpoint).to_return( + status: 200, body: JSON.generate(payload), headers: { "Content-Type" => "application/json" } + ) + + result = McpToolkit::Auth::Introspection.call("forwarded") + expect(result).to be_valid + expect(result.account_id).to eq(42) + expect(result.authorized_for_scope?("notifications__read")).to be(true) + end + end +end diff --git a/spec/mcp_toolkit/eager_load_spec.rb b/spec/mcp_toolkit/eager_load_spec.rb new file mode 100644 index 0000000..61fb6e1 --- /dev/null +++ b/spec/mcp_toolkit/eager_load_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Compact (`class McpToolkit::Foo::Bar`) declarations only autoload cleanly when +# every parent namespace is itself reachable through Zeitwerk. Eager-loading the +# whole tree surfaces any namespace that isn't (it would raise +# `NameError: uninitialized constant`), so this guards the flat-declaration + +# split-file layout against regressions. +RSpec.describe "Zeitwerk eager loading" do + subject(:eager_load) { -> { Zeitwerk::Registry.loaders.each(&:eager_load) } } + + it "loads the entire gem tree without error" do + expect(&eager_load).not_to raise_error + end +end diff --git a/spec/mcp_toolkit/engine_route_reload_spec.rb b/spec/mcp_toolkit/engine_route_reload_spec.rb new file mode 100644 index 0000000..31a5c14 --- /dev/null +++ b/spec/mcp_toolkit/engine_route_reload_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Regression spec for the route-reloader wipe. +# +# The engine draws its four MCP routes in the engine's `config/routes.rb` rather +# than in a class-body `McpToolkit::Engine.routes.draw` block. The reason is +# Rails' routes_reloader: a host application builds its route set lazily and runs +# the reloader on boot (and again on every reload). The reloader re-evaluates each +# mounted engine's `config/routes.rb`, but it never replays a `routes.draw` that +# ran once at class-body load time — under a lazily-loaded app that class-body +# draw is dropped, leaving the engine with ZERO routes and every `/mcp` request +# 404'ing. Moving the draw into config/routes.rb is what makes the routes +# materialize and survive subsequent reloads. +# +# The sibling engine_spec.rb stubs Rails and asserts the four routes are *drawn*, +# but a stub cannot reproduce the reloader: only a REAL Rails::Application driving +# its routes_reloader does, which is why the original bug slipped through. +# +# Booting a real Rails::Application IN-PROCESS is not viable here: it irreversibly +# mutates global state (Rails.application, the Zeitwerk loader set that the +# eager_load_spec inspects, the engine's lazy route set) and contaminates the rest +# of this Rails-absent suite across random orderings. So the boot runs in an +# ISOLATED CHILD Ruby process: it boots a minimal app, mounts the engine, forces a +# route reload, and prints the engine's route set as JSON before and after the +# reload. This parent process asserts on that JSON, fully insulated from the boot. +# +# Rails-only: railties/actionpack are a TEST dependency (see Gemfile), not a gem +# runtime dependency, so the group is skipped when Rails is unavailable, keeping +# the non-Rails unit suite clean. +rails_available = + begin + require "rails/version" + true + rescue LoadError + false + end + +RSpec.describe "Engine survives a route reload", if: rails_available do + # The four endpoints as [verb, engine-relative path, controller#action]. Paths + # are relative to the engine's own route set (the `/mcp` mount supplies the + # prefix), so the roots are "/" and the probe is "/health". `mcp_toolkit/server` + # is the isolated-namespace path of the gem-provided McpToolkit::ServerController. + EXPECTED_ENGINE_ROUTES = [ + ["POST", "/", "mcp_toolkit/server#create"], + ["GET", "/", "mcp_toolkit/server#stream"], + ["DELETE", "/", "mcp_toolkit/server#destroy"], + ["GET", "/health", "mcp_toolkit/server#health"] + ].freeze + + # The driver script: boots a real, minimal Rails app in a child process, mounts + # the engine, captures the engine route set, forces a route reload, captures it + # again, and prints both as a single JSON line for the parent to assert on. Any + # failure inside the boot surfaces as a non-zero exit + captured stderr. + DRIVER = <<~'RUBY' + # Load THIS bundle in the child (BUNDLE_GEMFILE is inherited from the parent + # ENV) so rails + mcp_toolkit resolve to the same locked gems even though the + # child interpreter is invoked directly via Gem.ruby (not `bundle exec`). + require "bundler/setup" + require "json" + require "tmpdir" + require "logger" + require "mcp_toolkit" + require "rails" + require "action_controller/railtie" + + # Default parent_controller; ActionController::Base (not ::API) keeps the + # gem-provided controller (which includes the transport concern) loadable. + McpToolkit.config.parent_controller = "ActionController::Base" + + # The gem requires engine.rb only when Rails::Engine is already defined at the + # moment mcp_toolkit loads; here mcp_toolkit is required before Rails, so load + # the (Zeitwerk-ignored) engine explicitly by path. + unless McpToolkit.const_defined?(:Engine, false) + load File.expand_path("lib/mcp_toolkit/engine.rb", Dir.pwd) + end + + app = Class.new(Rails::Application) do + config.eager_load = false + config.consider_all_requests_local = true + config.secret_key_base = "regression-spec-secret-key-base" + config.logger = Logger.new(IO::NULL) + config.root = Dir.mktmpdir("mcp_toolkit_engine_spec") + end + + app.initialize! + app.routes.draw { mount McpToolkit::Engine => "/mcp" } + + extract = lambda do + McpToolkit::Engine.routes.routes.map do |route| + path = route.path.spec.to_s.sub(/\(\.:format\)\z/, "") + [route.verb, path, "#{route.defaults[:controller]}##{route.defaults[:action]}"] + end + end + + # Capture the mount BEFORE reloading: the parent app's mount was drawn + # imperatively (app.routes.draw { ... }), not from a routes file, so the + # reload wipes the PARENT's route set too — the engine routes survive only + # because they live in the engine's file-based config/routes.rb. That asymmetry + # is the whole point, so the mount assertion is taken pre-reload. + mount = app.routes.routes.find { |r| r.app.app == McpToolkit::Engine } + mount_path = mount && mount.path.spec.to_s + + before_reload = extract.call + app.reload_routes! # the exact trigger that wiped a class-body draw + after_reload = extract.call + + puts JSON.generate( + "mount_path" => mount_path, + "before_reload" => before_reload, + "after_reload" => after_reload + ) + RUBY + + # Boot once for the group; the child process is the expensive part. + before(:all) do + require "json" + require "open3" + + gem_root = File.expand_path("../..", __dir__) + # Invoke the RUNNING interpreter directly via Gem.ruby (full path). A bare + # "bundle","exec","ruby" can resolve a SYSTEM ruby under a Bundler-managed + # parent; Gem.ruby is the portable way to re-enter the same interpreter. The + # child still loads the same locked gems because the driver does + # `require "bundler/setup"` and inherits BUNDLE_GEMFILE from this ENV. + stdout, stderr, status = Open3.capture3( + Gem.ruby, "-e", DRIVER, chdir: gem_root + ) + raise "engine route-reload driver failed (#{status.exitstatus}):\n#{stderr}" unless status.success? + + @result = JSON.parse(stdout.lines.last) + end + + it "mounts the engine in the host app under /mcp" do + expect(@result.fetch("mount_path")).to eq("/mcp") + end + + it "draws the four MCP endpoints in the engine route set once booted" do + # JSON parses each route back to a [verb, path, action] array, matching the + # shape of EXPECTED_ENGINE_ROUTES. + expect(@result.fetch("before_reload")).to contain_exactly(*EXPECTED_ENGINE_ROUTES) + end + + it "still holds them after the app reloads its routes (the regression)" do + after_reload = @result.fetch("after_reload") + + # NON-EMPTY (the old class-body form left it empty after the reload)... + expect(after_reload).not_to be_empty + # ...and still mapping every endpoint to the right controller action. + expect(after_reload).to contain_exactly(*EXPECTED_ENGINE_ROUTES) + end +end diff --git a/spec/mcp_toolkit/engine_spec.rb b/spec/mcp_toolkit/engine_spec.rb new file mode 100644 index 0000000..df412fb --- /dev/null +++ b/spec/mcp_toolkit/engine_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "spec_helper" + +# The mountable engine + gem-provided controller are Rails-only and live outside +# the gem's lib-rooted Zeitwerk loader (engine.rb is ignored; the controller is an +# engine `app/controllers` path). The gem's suite runs WITHOUT Rails, so these +# examples stub the minimal surface each file touches rather than booting Rails: +# +# * the engine subclasses ::Rails::Engine and calls `isolate_namespace` + +# `routes.draw` — we stub a recorder to capture the drawn routes; +# * the controller subclasses `config.parent_controller.constantize` and +# includes the transport concern — we point parent_controller at a fake +# ActionController-like base providing the class methods the concern calls. +# +# Constants are defined in `before` and torn down in `after` so they don't leak +# into the rest of the suite (which asserts Rails-absent behavior). +RSpec.describe "Mountable engine + gem controller" do + describe "McpToolkit::ServerController (engine controller)" do + # A fake ActionController::Base: provides only the class-level hooks the + # transport concern's `included do` block invokes. + before do + stub_const("FakeActionControllerBase", Class.new do + def self.before_action(*); end + # The concern guards protect_from_forgery behind respond_to?; omit it so + # that branch is exercised (no CSRF machinery needed in a unit test). + end) + McpToolkit.config.parent_controller = "FakeActionControllerBase" + + # Load the gem-provided controller against the configured parent. It lives + # under app/controllers (outside the lib loader), so require it by path. + path = File.expand_path("../../app/controllers/mcp_toolkit/server_controller.rb", __dir__) + load path + end + + after { McpToolkit.send(:remove_const, :ServerController) if McpToolkit.const_defined?(:ServerController, false) } + + it "inherits the configured parent_controller" do + expect(McpToolkit::ServerController.superclass).to eq(FakeActionControllerBase) + end + + it "includes the standalone transport concern (the engine path is additive)" do + expect(McpToolkit::ServerController.include?(McpToolkit::Transport::ControllerMethods)).to be(true) + end + + it "exposes the transport actions wired by the concern" do + expect(McpToolkit::ServerController.instance_methods).to include(:create, :stream, :destroy, :health) + end + end + + describe "McpToolkit::Engine routes" do + # A recorder standing in for the engine's route set: captures each verb call + # as [verb, path, to] so we can assert the four endpoints are drawn. The routes + # live in the engine's config/routes.rb (drawn through the routes_reloader so + # they survive route reloads), so we load THAT file against the recorder. + let(:route_recorder) do + Class.new do + attr_reader :drawn + + def initialize + @drawn = [] + end + + def draw(&block) + instance_eval(&block) + end + + %i[post get delete].each do |verb| + define_method(verb) { |path, to:| @drawn << [verb, path, to] } + end + end.new + end + + before do + recorder = route_recorder + stub_const("Rails", Module.new) + engine_base = Class.new do + define_singleton_method(:isolate_namespace) { |_mod| } + end + stub_const("Rails::Engine", engine_base) + + load File.expand_path("../../lib/mcp_toolkit/engine.rb", __dir__) + # The engine class itself no longer draws routes; config/routes.rb does, via + # McpToolkit::Engine.routes.draw — point that at the recorder. + McpToolkit::Engine.define_singleton_method(:routes) { recorder } + load File.expand_path("../../config/routes.rb", __dir__) + end + + after { McpToolkit.send(:remove_const, :Engine) if McpToolkit.const_defined?(:Engine, false) } + + it "draws the four MCP endpoints mapping to the server controller actions" do + expect(route_recorder.drawn).to contain_exactly( + [:post, "/", "server#create"], + [:get, "/", "server#stream"], + [:delete, "/", "server#destroy"], + [:get, "health", "server#health"] + ) + end + + it "subclasses ::Rails::Engine" do + expect(McpToolkit::Engine.superclass).to eq(Rails::Engine) + end + end +end diff --git a/spec/mcp_toolkit/registry_and_executors_spec.rb b/spec/mcp_toolkit/registry_and_executors_spec.rb new file mode 100644 index 0000000..a215e02 --- /dev/null +++ b/spec/mcp_toolkit/registry_and_executors_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Exercises the core data path: a registered resource -> List/Get executors -> +# the resource's serializer, including filterable-attribute validation and the +# tenancy scope block. Uses the in-memory fakes (no database). +RSpec.describe "Registry + executors + serializer (data path)" do + # A serializer for the fake "widget" model. Subclasses the gem's base, but + # overrides model_class / root_key so we don't need a real constant. + let(:widget_serializer) do + model = widget_model + Class.new(McpToolkit::Serializer::Base) do + attributes :id, :name, :booking_id + self.model_class = model + + def self.name + "WidgetSerializer" + end + end + end + + # A fake model exposing the column metadata resource_schema reads. + let(:widget_model) do + Class.new do + def self.columns_hash + { + "id" => FakeRelation::Column.new(:integer), + "name" => FakeRelation::Column.new(:string), + "booking_id" => FakeRelation::Column.new(:integer), + "price" => FakeRelation::Column.new(:integer) + } + end + + def self.primary_key + "id" + end + + def self.model_name + FakeModelName.new("widgets") + end + end + end + + let(:account) { :account_root } + + let(:rows) do + [ + FakeRecord.new(id: 1, name: "alpha", booking_id: 10, price: 100), + FakeRecord.new(id: 2, name: "beta", booking_id: 20, price: 200), + FakeRecord.new(id: 3, name: "gamma", booking_id: 10, price: 300) + ] + end + + let(:relation) { FakeRelation.new(rows, table_name: "widgets", model: widget_model) } + + # The registered widgets resource is the shared fixture every executor example + # drives; named so each `described_class.call(resource:, ...)` reads off it. + subject(:resource) { McpToolkit.registry.fetch("widgets") } + + before do + serializer = widget_serializer + model = widget_model + rel = relation + McpToolkit.configure do |c| + # No ActiveRecord in the gem's own suite — inject a sanitizer that escapes + # LIKE wildcards the same way ActiveRecord's `sanitize_sql_like` would. + c.sql_sanitizer = FakeSqlSanitizer.new + c.registry.register(:widgets) do + model model + serializer serializer + description "Test widgets." + filterable booking_id: :booking_id, name: :name, price: :price + scope { |_root| rel } + end + end + end + + describe McpToolkit::ListExecutor do + it "returns the collection wrapper keyed by the plural root with pagination meta" do + result = described_class.call(resource:, scope_root: account, params: {}) + + expect(result[:widgets].map { |w| w[:id] }).to eq([1, 2, 3]) + # ListExecutor passes its effective (defaulted) limit/offset into the meta. + expect(result[:meta]).to eq(total_count: 3, limit: 25, offset: 0) + expect(result[:widgets].first).to include(name: "alpha", "links" => {}) + end + + it "applies declared per-attribute equality filters by mapping request key -> column" do + result = described_class.call(resource:, scope_root: account, params: { filter: { booking_id: 10 } }) + + expect(result[:widgets].map { |w| w[:id] }).to eq([1, 3]) + end + + it "treats a comma-separated equality value as an IN set (API v3 parity)" do + result = described_class.call(resource:, scope_root: account, params: { filter: { booking_id: "10,20" } }) + + expect(result[:widgets].map { |w| w[:id] }).to eq([1, 2, 3]) + end + + it "rejects unknown filter keys with InvalidParams" do + expect do + described_class.call(resource:, scope_root: account, params: { filter: { bogus: 1 } }) + end.to raise_error(McpToolkit::Errors::InvalidParams, /unknown filter attribute/) + end + + it "filters by ids" do + result = described_class.call(resource:, scope_root: account, params: { ids: "1,3" }) + + expect(result[:widgets].map { |w| w[:id] }).to eq([1, 3]) + end + + describe "operator-based (complex hash) filtering — API v3 parity" do + it "applies a single { op:, value: } comparison condition" do + result = described_class.call( + resource:, scope_root: account, params: { filter: { price: { op: "gteq", value: 200 } } } + ) + + expect(result[:widgets].map { |w| w[:id] }).to eq([2, 3]) + end + + it "ANDs an array of conditions into a range" do + result = described_class.call( + resource:, scope_root: account, + params: { filter: { price: [{ op: "gteq", value: 150 }, { op: "lt", value: 300 }] } } + ) + + expect(result[:widgets].map { |w| w[:id] }).to eq([2]) + end + + it "supports not_eq" do + result = described_class.call( + resource:, scope_root: account, params: { filter: { booking_id: { op: "not_eq", value: 10 } } } + ) + + expect(result[:widgets].map { |w| w[:id] }).to eq([2]) + end + + it "supports case-insensitive substring matching on string columns" do + result = described_class.call( + resource:, scope_root: account, params: { filter: { name: { op: "matches", value: "ET" } } } + ) + + expect(result[:widgets].map { |w| w[:id] }).to eq([2]) + end + + it "rejects an operator unsupported for the column's type" do + expect do + described_class.call( + resource:, scope_root: account, params: { filter: { name: { op: "gt", value: "a" } } } + ) + end.to raise_error(McpToolkit::Errors::InvalidParams, /not supported/) + end + + it "rejects a condition with a blank operator" do + expect do + described_class.call( + resource:, scope_root: account, params: { filter: { price: { op: "", value: 10 } } } + ) + end.to raise_error(McpToolkit::Errors::InvalidParams, /operator is required/) + end + end + + describe "ordering by numeric vs non-numeric primary key (API v3 parity)" do + # A relation that records which column it was last ordered by. + let(:ordering_relation_class) do + Class.new(FakeRelation) do + attr_reader :ordered_by + + def order(column) + @ordered_by = column + super + end + end + end + + def register_resource_with_pk_type(pk_type) + model = Class.new do + define_singleton_method(:columns_hash) do + { "id" => FakeRelation::Column.new(pk_type), "created_at" => FakeRelation::Column.new(:datetime) } + end + def self.primary_key = "id" + def self.model_name = FakeModelName.new("things") + end + serializer = Class.new(McpToolkit::Serializer::Base) do + attributes :id + self.model_class = model + def self.name = "ThingSerializer" + end + rel = ordering_relation_class.new([FakeRecord.new(id: 1, created_at: nil)], table_name: "things", model:) + McpToolkit.configure do |c| + c.registry.register(:things) do + model model + serializer serializer + scope { |_root| rel } + end + end + rel + end + + it "orders by :id when the primary key is numeric" do + rel = register_resource_with_pk_type(:integer) + + described_class.call(resource: McpToolkit.registry.fetch("things"), scope_root: account, params: {}) + + expect(rel.ordered_by).to eq(:id) + end + + it "orders by :created_at when the primary key is non-numeric" do + rel = register_resource_with_pk_type(:uuid) + + described_class.call(resource: McpToolkit.registry.fetch("things"), scope_root: account, params: {}) + + expect(rel.ordered_by).to eq(:created_at) + end + end + end + + describe McpToolkit::GetExecutor do + it "fetches a single record by id, scoped through the relation" do + result = described_class.call(resource:, scope_root: account, id: 2) + + expect(result).to include(id: 2, name: "beta") + end + + it "raises InvalidParams when the id is missing from the scoped relation" do + expect do + described_class.call(resource:, scope_root: account, id: 999) + end.to raise_error(McpToolkit::Errors::InvalidParams, /not found/) + end + + it "requires an id" do + expect do + described_class.call(resource:, scope_root: account, id: nil) + end.to raise_error(McpToolkit::Errors::InvalidParams, /id is required/) + end + end + + describe McpToolkit::ResourceSchema do + it "describes attributes (with column types) and the declared filters" do + schema = described_class.call(resource) + + expect(schema[:name]).to eq("widgets") + expect(schema[:description]).to eq("Test widgets.") + booking = schema[:attributes].find { |a| a[:name] == :booking_id } + expect(booking).to include(type: "integer", filterable: true) + expect(schema[:standard_filters]).to eq(%w[ids updated_since limit offset]) + expect(schema[:filters]).to eq( + [ + { key: :booking_id, column: :booking_id, type: "integer", format: "integer" }, + { key: :name, column: :name, type: "string", format: "string" }, + { key: :price, column: :price, type: "integer", format: "integer" } + ] + ) + end + end + + describe McpToolkit::Registry do + it "raises UnknownResource for an unregistered name" do + expect { McpToolkit.registry.fetch("nope") }.to raise_error(McpToolkit::Registry::UnknownResource) + end + + describe "required scope resolution" do + subject(:registry) { McpToolkit::Registry.new } + + def register_resource(name, &) + registry.register(name, &) + registry.fetch(name.to_s) + end + + it "uses a resource's own required_permissions_scope when declared" do + resource = register_resource(:scoped) { required_permissions_scope "widgets__read" } + + expect(registry.required_scope_for(resource)).to eq("widgets__read") + end + + it "falls back to the registry default for a resource without its own scope" do + registry.default_required_permissions_scope "app__read" + resource = register_resource(:unscoped) { description "no scope of its own" } + + expect(registry.required_scope_for(resource)).to eq("app__read") + end + + it "prefers a resource's own scope over the registry default" do + registry.default_required_permissions_scope "app__read" + resource = register_resource(:own) { required_permissions_scope "own__read" } + + expect(registry.required_scope_for(resource)).to eq("own__read") + end + + it "returns nil (no scope required) with neither a default nor a resource scope" do + resource = register_resource(:open) { description "open" } + + expect(registry.required_scope_for(resource)).to be_nil + end + + it "preserves the default scope across reset! (declared in configure, not to_prepare)" do + registry.default_required_permissions_scope "app__read" + registry.reset! + + expect(registry.default_required_permissions_scope).to eq("app__read") + expect(registry.resources).to be_empty + end + end + + it "fails loudly when a resource is missing its serializer" do + McpToolkit.registry.register(:broken) do + model Object + scope { |_| [] } + end + expect do + McpToolkit.registry.fetch("broken").resolve_relation(:root) + end.to raise_error(McpToolkit::Resource::NotConfigured, /no serializer/) + end + end +end diff --git a/spec/mcp_toolkit/serializer/base_spec.rb b/spec/mcp_toolkit/serializer/base_spec.rb new file mode 100644 index 0000000..0d3a57f --- /dev/null +++ b/spec/mcp_toolkit/serializer/base_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe McpToolkit::Serializer::Base do + subject(:serializer) do + model = order_model + Class.new(described_class) do + attributes :id, :total, :created_at + has_one :account, foreign_key: :synced_account_id + has_one :owner, polymorphic: true + has_many :line_items + self.model_class = model + + def self.name + "OrderSerializer" + end + end + end + + let(:order_model) do + Class.new do + def self.model_name + FakeModelName.new("orders") + end + end + end + + describe ".serialize_one" do + it "is nil-safe" do + expect(serializer.serialize_one(nil)).to be_nil + end + + it "emits declared attributes (symbol keys) plus a sorted string-keyed links hash" do + record = FakeRecord.new( + id: 7, + total: 100, + created_at: Time.utc(2026, 6, 18, 12, 0, 0), + synced_account_id: 42, + owner_id: 5, + owner_type: "User", + line_items: FakeRelation.new([FakeRecord.new(id: 3), FakeRecord.new(id: 1)]) + ) + + hash = serializer.serialize_one(record) + + # attributes preserved, timestamps rendered iso8601(6) + expect(hash[:id]).to eq(7) + expect(hash[:total]).to eq(100) + expect(hash[:created_at]).to eq("2026-06-18T12:00:00.000000Z") + + # links: string key, alphabetically sorted associations + links = hash["links"] + expect(links.keys).to eq(%w[account line_items owner]) + expect(links["account"]).to eq(42) # foreign_key override + expect(links["owner"]).to eq(id: 5, type: "User") # polymorphic shape + expect(links["line_items"]).to eq([1, 3]) # has_many -> sorted ids + end + end + + describe ".serialize_collection" do + it "wraps rows under the plural root with a meta block" do + records = [FakeRecord.new( + id: 1, total: 10, created_at: nil, + synced_account_id: 1, owner_id: nil, owner_type: nil, + line_items: FakeRelation.new([]) + )] + + result = serializer.serialize_collection(records, total_count: 50, limit: 25, offset: 0) + + expect(result.keys).to contain_exactly(:orders, :meta) + expect(result[:orders].size).to eq(1) + expect(result[:meta]).to eq(total_count: 50, limit: 25, offset: 0) + end + end + + describe "injection: a custom serializer satisfying only the contract" do + # A serializer that is NOT a subclass of Base — it only implements the two + # contract methods. Stands in for an app's existing serializer. The executor + # must work with it unchanged. + subject(:custom_serializer) do + Class.new do + def self.serialize_one(record, scope: nil) + { custom: true, id: record.id, scope: scope } + end + + def self.serialize_collection(records, scope: nil, total_count: nil, limit: nil, offset: nil) + { rows: records.map(&:id), meta: { total_count:, limit:, offset:, scope: } } + end + end + end + + it "is driven by GetExecutor exactly like the base serializer" do + custom = custom_serializer + McpToolkit.configure do |c| + c.registry.register(:things) do + model Object + serializer custom + scope { |_root| FakeRelation.new([FakeRecord.new(id: 9)]) } + end + end + + result = McpToolkit::GetExecutor.call( + resource: McpToolkit.registry.fetch("things"), scope_root: :acct, id: 9 + ) + + expect(result).to eq(custom: true, id: 9, scope: :acct) + end + end +end diff --git a/spec/mcp_toolkit/server_end_to_end_spec.rb b/spec/mcp_toolkit/server_end_to_end_spec.rb new file mode 100644 index 0000000..15278c5 --- /dev/null +++ b/spec/mcp_toolkit/server_end_to_end_spec.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +require "spec_helper" + +# End-to-end through the wrapped official MCP::Server: a JSON-RPC `tools/call` for +# the generic `list` tool, exercising server -> Tools::Base -> Authenticator -> +# Introspection (stubbed central) -> ListExecutor -> serializer -> the MCP tool +# response envelope. +RSpec.describe "Server end-to-end (tools/call)" do + let(:central_url) { "https://central.example.com" } + let(:introspect_endpoint) { "#{central_url}/mcp/tokens/introspect" } + + let(:widget_serializer) do + model = widget_model + Class.new(McpToolkit::Serializer::Base) do + attributes :id, :name + self.model_class = model + + def self.name + "WidgetEndToEndSerializer" + end + end + end + + let(:widget_model) do + Class.new do + def self.model_name + FakeModelName.new("widgets") + end + end + end + + let(:rows) { [FakeRecord.new(id: 1, name: "alpha"), FakeRecord.new(id: 2, name: "beta")] } + + before do + serializer = widget_serializer + model = widget_model + relation = FakeRelation.new(rows, table_name: "widgets") + + McpToolkit.configure do |c| + c.server_name = "e2e-mcp" + c.central_app_url = central_url + # The resolver receives a STRING-normalized id (mirroring find_by(synced_id:), + # which AR-coerces per column), so compare as strings. + c.account_resolver = ->(synced_id) { synced_id.to_s == "42" ? :account_42 : nil } + # The registry default scope applies to every resource that doesn't declare + # its own (and to the discovery tools). + c.registry.default_required_permissions_scope "widgets_app__read" + c.registry.register(:widgets) do + model model + serializer serializer + description "Widgets." + scope { |_root| relation } + end + end + + stub_request(:post, introspect_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], scopes: ["widgets_app__read"] + ) + ) + end + + # The canonical happy-path call: a `list` for the widgets resource with the + # default before-stub (token carries `widgets_app__read`). + subject(:list_response) { call_tool({ "resource" => "widgets" }) } + + def call_tool(arguments, bearer: "forwarded-token") + server = McpToolkit::Server.build(server_context: { bearer_token: bearer, header_account_id: nil }) + request = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "list", arguments: } + } + JSON.parse(server.handle_json(JSON.generate(request))) + end + + it "returns the serialized collection as a text tool result" do + expect(list_response.dig("result", "isError")).to be_falsey + text = list_response.dig("result", "content", 0, "text") + payload = JSON.parse(text) + expect(payload["widgets"].map { |w| w["id"] }).to eq([1, 2]) + expect(payload["meta"]).to include("total_count" => 2) + end + + it "allows a list call when the token carries the required __read scope" do + # the default before-stub already carries scopes: ["widgets_app__read"] + expect(list_response.dig("result", "isError")).to be_falsey + payload = JSON.parse(list_response.dig("result", "content", 0, "text")) + expect(payload["widgets"].map { |w| w["id"] }).to eq([1, 2]) + end + + it "rejects a list call when the token lacks the required __read scope" do + stub_request(:post, introspect_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + # reaches the app (has a widgets_app_* scope) but not the read action + valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], scopes: ["widgets_app__write"] + ) + ) + + response = call_tool({ "resource" => "widgets" }) + + expect(response).not_to have_key("error") # protocol-level OK + expect(response.dig("result", "isError")).to be(true) + text = response.dig("result", "content", 0, "text") + expect(text).to include("Unauthorized") + expect(text).to include("widgets_app__read") + end + + it "surfaces an unknown resource as an isError tool result, not a protocol error" do + response = call_tool({ "resource" => "nonexistent" }) + + expect(response).not_to have_key("error") # protocol-level OK + expect(response.dig("result", "isError")).to be(true) + expect(response.dig("result", "content", 0, "text")).to match(/unknown resource/i) + end + + it "surfaces an invalid token as an Unauthorized isError result" do + stub_request(:post, introspect_endpoint).to_return(status: 401, body: JSON.generate(valid: false)) + + response = call_tool({ "resource" => "widgets" }) + + expect(response.dig("result", "isError")).to be(true) + expect(response.dig("result", "content", 0, "text")).to include("Unauthorized") + end + + it "lists the four generic tools" do + server = McpToolkit::Server.build(server_context: { bearer_token: "x" }) + response = JSON.parse(server.handle_json(JSON.generate(jsonrpc: "2.0", id: 9, method: "tools/list", params: {}))) + + names = response.dig("result", "tools").map { |t| t["name"] } + expect(names).to contain_exactly("get", "list", "resources", "resource_schema") + end + + describe "explicit per-resource required_permissions_scope" do + # A second resource declaring its OWN scope, overriding the registry default, + # registered alongside the default-scoped `widgets`. + before do + serializer = widget_serializer + model = widget_model + relation = FakeRelation.new(rows, table_name: "gadgets") + McpToolkit.registry.register(:gadgets) do + model model + serializer serializer + description "Gadgets." + required_permissions_scope "gadgets_app__read" + scope { |_root| relation } + end + end + + def stub_scopes(scopes) + stub_request(:post, introspect_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], scopes: + ) + ) + end + + it "rejects a resource-scoped tool when the token lacks the resource's own scope" do + stub_scopes(["widgets_app__read"]) # has the default, not the gadgets scope + + response = call_tool({ "resource" => "gadgets" }) + + expect(response).not_to have_key("error") + expect(response.dig("result", "isError")).to be(true) + text = response.dig("result", "content", 0, "text") + expect(text).to include("Unauthorized") + expect(text).to include("gadgets_app__read") + end + + it "accepts a resource-scoped tool when the token carries the resource's own scope" do + stub_scopes(["gadgets_app__read"]) + + response = call_tool({ "resource" => "gadgets" }) + + expect(response.dig("result", "isError")).to be_falsey + # The collection root key derives from the (shared) serializer's model name, + # "widgets"; the assertion that matters here is that the call was authorized. + payload = JSON.parse(response.dig("result", "content", 0, "text")) + expect(payload["widgets"].map { |w| w["id"] }).to eq([1, 2]) + end + end + + describe "the registry default scope" do + it "applies to a resource that declares no scope of its own" do + # `widgets` declares no scope; the registry default `widgets_app__read` is + # what the default before-stub carries, so the call is allowed. + expect(list_response.dig("result", "isError")).to be_falsey + end + end + + describe "a resource (and discovery) with NO scope required at all" do + # A registry with neither a default scope nor a per-resource scope: any valid + # token reaches the tools. + before do + serializer = widget_serializer + model = widget_model + relation = FakeRelation.new(rows, table_name: "widgets") + # Start from a pristine config (the outer before set a registry default + # scope; this scenario needs none). + McpToolkit.reset_config! + McpToolkit.configure do |c| + c.server_name = "open-mcp" + c.central_app_url = central_url + # The resolver receives a STRING-normalized id (mirroring find_by(synced_id:), + # which AR-coerces per column), so compare as strings. + c.account_resolver = ->(synced_id) { synced_id.to_s == "42" ? :account_42 : nil } + c.registry.register(:widgets) do + model model + serializer serializer + description "Widgets." + scope { |_root| relation } + end + end + + stub_request(:post, introspect_endpoint).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + # token carries NO scopes at all + body: JSON.generate(valid: true, kind: "accounts_user", account_id: 42, account_ids: [42], scopes: []) + ) + end + + it "is reachable by any valid token (list)" do + response = call_tool({ "resource" => "widgets" }) + + expect(response.dig("result", "isError")).to be_falsey + payload = JSON.parse(response.dig("result", "content", 0, "text")) + expect(payload["widgets"].map { |w| w["id"] }).to eq([1, 2]) + end + + it "is reachable by any valid token (discovery: resources)" do + server = McpToolkit::Server.build(server_context: { bearer_token: "forwarded-token", header_account_id: nil }) + request = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "resources", arguments: {} } } + response = JSON.parse(server.handle_json(JSON.generate(request))) + + expect(response.dig("result", "isError")).to be_falsey + end + end +end diff --git a/spec/mcp_toolkit/session_spec.rb b/spec/mcp_toolkit/session_spec.rb new file mode 100644 index 0000000..44369aa --- /dev/null +++ b/spec/mcp_toolkit/session_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe McpToolkit::Session do + # The default config ships an ActiveSupport::Cache::MemoryStore, which is enough + # to exercise create/find/delete + sliding TTL behavior. + subject(:session) { described_class.create! } + + describe ".create!" do + it "mints an opaque id and persists the session in the cache" do + expect(session.id).to match(/\A[0-9a-f-]{36}\z/) + expect(described_class.find(session.id)).to be_a(described_class) + end + end + + describe ".find" do + it "returns nil for a blank or unknown id" do + expect(described_class.find(nil)).to be_nil + expect(described_class.find("")).to be_nil + expect(described_class.find("does-not-exist")).to be_nil + end + + it "returns a session for a known id" do + id = session.id + + expect(described_class.find(id).id).to eq(id) + end + + it "slides the TTL on every successful lookup" do + McpToolkit.config.session_ttl = 100 + + expect(McpToolkit.config.cache_store).to receive(:write).with( + "#{described_class::CACHE_KEY_PREFIX}#{session.id}", anything, expires_in: 100 + ).and_call_original + + described_class.find(session.id) + end + end + + describe ".delete" do + it "removes the session" do + id = session.id + + described_class.delete(id) + + expect(described_class.find(id)).to be_nil + end + + it "is a no-op for a blank id" do + expect(described_class.delete(nil)).to be(false) + end + end +end diff --git a/spec/mcp_toolkit_spec.rb b/spec/mcp_toolkit_spec.rb index e2b14e5..e34a9bf 100644 --- a/spec/mcp_toolkit_spec.rb +++ b/spec/mcp_toolkit_spec.rb @@ -1,11 +1,55 @@ # frozen_string_literal: true +require "spec_helper" + RSpec.describe McpToolkit do + subject(:mcp_toolkit) { described_class } + it "has a version number" do - expect(McpToolkit::VERSION).not_to be nil + expect(McpToolkit::VERSION).not_to be_nil + end + + it "exposes MCPToolkit as an alias of McpToolkit" do + expect(MCPToolkit).to equal(mcp_toolkit) + end + + describe ".configure" do + it "yields the active configuration and returns it" do + returned = mcp_toolkit.configure do |c| + c.server_name = "configured-mcp" + c.registry.default_required_permissions_scope "thing__read" + end + + expect(returned).to be(mcp_toolkit.config) + expect(mcp_toolkit.config.server_name).to eq("configured-mcp") + expect(mcp_toolkit.config.registry.default_required_permissions_scope).to eq("thing__read") + end + end + + describe ".config defaults" do + subject(:config) { described_class.config } + + it "ships opinionated, vendor-neutral defaults" do + expect(config.introspect_path).to eq("/mcp/tokens/introspect") + expect(config.account_meta_key).to eq("mcp-toolkit/account-id") + expect(config.account_id_header).to eq("X-MCP-Account-ID") + expect(config.session_ttl).to eq(3600) + expect(config.serializer_base).to eq(McpToolkit::Serializer::Base) + end + + it "defaults the account_resolver to identity" do + expect(config.account_resolver.call(42)).to eq(42) + end + + it "raises a clear error when introspect_url is needed but central_app_url is unset" do + expect { config.introspect_url } + .to raise_error(McpToolkit::Errors::ConfigurationError, /central_app_url/) + end end - it "does something useful" do - expect(false).to eq(true) + describe ".registry" do + it "delegates to the active config's registry" do + expect(mcp_toolkit.registry).to be(mcp_toolkit.config.registry) + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2dd15d1..0795935 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true require "mcp_toolkit" +require "webmock/rspec" + +# Block all real HTTP; auth specs stub the central app's introspection endpoint. +WebMock.disable_net_connect! + +Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f } RSpec.configure do |config| # Enable flags like --only-failures and --next-failure @@ -12,4 +18,9 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + # Each example starts from a pristine, default configuration. + config.before do + McpToolkit.reset_config! + end end diff --git a/spec/support/fake_active_record.rb b/spec/support/fake_active_record.rb new file mode 100644 index 0000000..3e7fc98 --- /dev/null +++ b/spec/support/fake_active_record.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# Minimal in-memory stand-ins that quack like the slice of ActiveRecord the +# executors and serializer touch — enough to exercise the registry -> executor -> +# serializer path without a database. NOT a general AR fake; it implements only +# the methods the toolkit calls (`where`, `order`, `offset`, `limit`, `count`, +# `to_a`, `find_by`, `table_name`, `model`, and `columns_hash` / `model_name` / +# `primary_key` on the class). `where` understands both a `{ column => value }` +# hash and a McpToolkit::Filtering::Predicate (operator-based filtering). + +# A struct-like record with arbitrary attributes and an `id`. +class FakeRecord + def initialize(attrs) + @attrs = attrs + end + + def id + @attrs.fetch(:id) + end + + def respond_to_missing?(name, _include_private = false) + @attrs.key?(name.to_sym) || super + end + + def method_missing(name, *args) + return @attrs[name] if @attrs.key?(name) + + super + end + + def [](key) + @attrs[key.to_sym] + end +end + +# A composable, immutable in-memory relation. Each filtering method returns a new +# relation; `to_a` / `count` materialize. +class FakeRelation + Column = Struct.new(:type) + + attr_reader :table_name, :model + + def initialize(rows, table_name: "records", model: nil) + @rows = rows + @table_name = table_name + @model = model + end + + # Accepts either a `{ column => value }` equality hash or a + # McpToolkit::Filtering::Predicate (operator-based filtering). + def where(conditions) + if conditions.is_a?(McpToolkit::Filtering::Predicate) + with_rows(@rows.select { |row| predicate_match?(row, conditions) }) + else + filtered = @rows.select do |row| + conditions.all? { |column, value| equality_match?(row[column], value) } + end + with_rows(filtered) + end + end + + def order(column) + with_rows(@rows.sort_by { |row| row[column] }) + end + + def offset(n) + with_rows(@rows.drop(n.to_i)) + end + + def limit(n) + with_rows(@rows.take(n.to_i)) + end + + def count + @rows.size + end + + def to_a + @rows + end + + def find_by(conditions) + where(conditions).to_a.first + end + + # has_many serialization reads `pluck(:id)` off the relation. + def pluck(column) + @rows.map { |row| row[column] } + end + + private + + def equality_match?(actual, value) + if value.is_a?(Array) + value.map(&:to_s).include?(actual.to_s) + elsif value.nil? + actual.nil? + else + actual.to_s == value.to_s + end + end + + # Applies the operators McpToolkit::Filtering emits (mirroring the Arel + # predications it builds for real ActiveRecord). + def predicate_match?(row, predicate) + actual = row[predicate.column] + value = predicate.value + + case predicate.operator + when "in" then Array(value).map(&:to_s).include?(actual.to_s) + when "not_eq" then actual.to_s != value.to_s + when "gt" then actual > value + when "gteq" then actual >= value + when "lt" then actual < value + when "lteq" then actual <= value + when "matches" then like_match?(actual, value) + when "does_not_match" then !like_match?(actual, value) + else actual.to_s == value.to_s # "eq" fallback + end + end + + # Translates a `%escaped%` LIKE pattern into a case-insensitive substring test. + def like_match?(actual, pattern) + inner = pattern.to_s.gsub(/\A%|%\z/, "").gsub(/\\([\\%_])/, '\1') + actual.to_s.downcase.include?(inner.downcase) + end + + def with_rows(rows) + self.class.new(rows, table_name: @table_name, model: @model) + end +end + +# A fake model class: holds a relation factory + column metadata + a model_name. +class FakeModelName + def initialize(plural) + @plural = plural + end + + attr_reader :plural +end diff --git a/spec/support/fake_sql_sanitizer.rb b/spec/support/fake_sql_sanitizer.rb new file mode 100644 index 0000000..fa2882d --- /dev/null +++ b/spec/support/fake_sql_sanitizer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# A DB-free stand-in for the ActiveRecord-backed McpToolkit::SqlSanitizer, used +# by the specs that exercise `matches` / `does_not_match` filtering without a +# Rails/ActiveRecord dependency. Escapes the LIKE wildcards (`\`, `%`, `_`) the +# same way `ActiveRecord::Base.sanitize_sql_like` would. +class FakeSqlSanitizer + def sanitize_sql_like(string) + string.gsub(/([\\%_])/, '\\\\\1') + end +end