diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7bd51e..bf603b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0.7" bundler-cache: true - name: Run specs @@ -45,3 +45,38 @@ jobs: with: name: activeadmin-mcp-mcpb path: activeadmin-mcp.mcpb + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.7" + bundler-cache: true + + # The generated host application is expensive to build (a full + # `bundle install` against rubygems.org) and its contents are decided + # by the builder source, so that is what the key hashes. Bump the v1 + # prefix by hand when a new Rails or ActiveAdmin release changes what + # the generators produce without the builder itself changing. + - name: Cache the generated host application + uses: actions/cache@v4 + with: + path: tmp/e2e_app + key: e2e-app-v1-${{ runner.os }}-${{ hashFiles('spec/e2e/support/app_builder.rb') }} + + - name: Run the end-to-end suite + run: bundle exec rake e2e + + - name: Upload the application log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-rails-log + path: tmp/e2e_app/log/e2e.log + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 121d3c8..4838af8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0.7" bundler-cache: true - name: Write version file diff --git a/.rspec b/.rspec index 5be63fc..c2a69fa 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,3 @@ --require spec_helper --format documentation +--exclude-pattern "spec/e2e/**/*_spec.rb" diff --git a/CHANGELOG.md b/CHANGELOG.md index 78dbb1c..6e04ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Breaking:** the minimum supported Ruby is now 4.0 and the minimum Rails is + 7.2, and ActiveAdmin is constrained to `~> 3.5`. Applications outside those + must stay on the previous release until they upgrade. Rails 7.2 is the oldest + release this gem's end-to-end suite runs on Ruby 4; the ActiveAdmin + constraint pins the gem to the 3.5 series it is tested against, and will need + raising deliberately for ActiveAdmin 4. CI and the release workflow now run + on Ruby 4.0.7. + ### Security - Enforce ActiveAdmin authorization on reads. `list_resources` and `query` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41b8b34 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +Guidance for AI assistants working in this repository. + +## What this is + +`activeadmin_mcp` is a Rails engine that exposes an application's existing +ActiveAdmin resources over the Model Context Protocol. It speaks JSON-RPC 2.0 +over HTTP and is mounted at `/mcp` by default. The tools it offers — +`list_resources`, `query`, `update` — are defined in +`lib/activeadmin_mcp/request_handler.rb`. + +## Testing MCP actions + +**Every MCP action must be covered by an end-to-end test, not only by unit +specs.** This applies to new tools, new arguments on an existing tool, and any +change to how an existing tool authorizes, filters, or writes data. + +The reason is specific to this gem: almost everything it does is a claim about +code it does not own. `permit_params` is resolved by instantiating the real +ActiveAdmin controller and reading its compiled permitted params; authorization +runs through whichever adapter the host application configured; `query` hands +its arguments to Ransack; the engine mounts itself into the host's route set. +A unit spec can only assert that we called a mock the way we expected to — it +cannot show that ActiveAdmin, Ransack, Devise and Rails actually behave that +way when wired together. Several defects in this repository's history were +invisible to the unit suite for exactly that reason. + +The e2e suite lives in `spec/e2e/`. It generates a real Rails + ActiveAdmin + +Devise application into `tmp/e2e_app`, installs this gem into it through the +gem's own generator, boots it under Puma, and drives the mounted endpoint over +HTTP with a real API token. + + bundle exec rake spec # unit specs, fast + bundle exec rake e2e # end-to-end suite + +`rake e2e` caches the generated application, so only the first run is slow. +`E2E_REBUILD=1` forces a rebuild. See the README's "Running the tests" section. + +The application it generates is not written from heredocs: the ActiveAdmin +registrations, models, migrations and seeds it uses are checked in under +`spec/e2e/fixture_app/` and copied into place, so you can read and edit the +thing the suite tests against. Any static file you need to add to that +application belongs there too, not in a heredoc inside the builder. + +When you add an e2e example, make sure it can actually fail. Edit the relevant +file under `spec/e2e/fixture_app/` so the behaviour under test is wrong, watch +the example fail, then restore it — editing a fixture invalidates the build +cache, so the next run picks it up. An end-to-end suite that passes regardless +of what the code does is worse than no suite, because it is believed. + +Every example starts from the seeded database: a `before` hook restores it from +a snapshot taken after migrating and seeding. So examples must not depend on +what another one left behind, and the suite runs in random order to keep that +honest. Write each one as though it runs alone, because it might. + +### Write e2e descriptions out in full + +Give e2e examples and their enclosing blocks descriptions verbose enough that +the `--format documentation` output reads as a specification of the MCP +interface on its own, without anyone opening the file. These descriptions are +the closest thing this project has to a written contract for how the server +behaves, and they are what someone debugging a CI failure sees first. + +State the behaviour and its condition, not the mechanics: + + # Too terse: names a method, not a behaviour. + it "filters" + + # Better: someone reading the output learns what the server guarantees. + it "filters records with Ransack syntax passed straight through to the model" + + # Too terse: gives no clue why refusing is correct. + it "refuses Author" + + # Better: the reason is the point of the example. + it "refuses to update a resource registered without the update action" + +Prefer a long description to a comment explaining a short one. Favour the +language of the README and the MCP tools — resources, attributes, permitted +params, authorization — over the language of the implementation. diff --git a/README.md b/README.md index 46b1301..94a02e9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ The server is a Rails engine mounted inside your application (by default at are the ones you have already configured. - **Queries use Ransack.** The `query` tool passes its arguments straight to [Ransack](https://activerecord-hackery.github.io/ransack/), the same search - library ActiveAdmin uses for filtering. + library ActiveAdmin uses for filtering. The tool calls `ransack` on the + model directly rather than going through an ActiveAdmin filter form, so on + Ransack 4 the model must allowlist the attributes it wants queryable via + `ransackable_attributes`; without that allowlist, `query` against that + resource will raise instead of returning an empty result. - **Reads go through ActiveAdmin too.** `list_resources` and `query` run through the same authorization adapter (CanCanCan, Pundit, etc.) as the authenticated MCP user: resources the user cannot read are hidden from the listing and @@ -37,9 +41,9 @@ The server is a Rails engine mounted inside your application (by default at ## Requirements -- Ruby >= 3.0 -- Rails >= 6.1 -- ActiveAdmin >= 2.0 +- Ruby >= 4.0 +- Rails >= 7.2 +- ActiveAdmin ~> 3.5 ## Installation @@ -344,6 +348,36 @@ bundle install bundle exec rspec ``` +### Running the tests + +`bundle exec rspec` (or `rake spec`, the default rake task) runs the unit +suite only — it's fast, and it's what CI's `rspec` job runs. It never touches +`spec/e2e`; that directory is excluded via `.rspec`. + +The end-to-end suite lives in `spec/e2e` and runs separately: + +```bash +bundle exec rake e2e +``` + +This generates a real Rails 7.2 + ActiveAdmin + Devise application under +`tmp/e2e_app`, installs the gem into it with `path:`, boots it under Puma, +and drives it over real HTTP with a minimal JSON-RPC client — the same way +Claude Code or any other MCP client would. It's how the gem is tested +against ActiveAdmin's and Ransack's actual behaviour rather than mocks. + +The generated application is expensive to build (a full `bundle install` +against rubygems.org, and a `gem install rails` if Rails 7.2.2.2 isn't +already on your system) so it's cached in `tmp/e2e_app` between runs, keyed +on the contents of `spec/e2e/support/app_builder.rb`. The first run costs a +few minutes and needs network access; edit that file and the next run +rebuilds from scratch, otherwise reruns are quick. Force a rebuild without +editing anything by setting `E2E_REBUILD=1`: + +```bash +E2E_REBUILD=1 bundle exec rake e2e +``` + ## Contributing Bug reports and pull requests are welcome on GitHub. diff --git a/Rakefile b/Rakefile index b6ae734..b22ff08 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,15 @@ -# frozen_string_literal: true - require "bundler/gem_tasks" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) +desc "Run the end-to-end suite against a generated ActiveAdmin application" +RSpec::Core::RakeTask.new(:e2e) do |t| + t.pattern = "spec/e2e/**/*_spec.rb" + # --options replaces the project .rspec wholesale rather than merging with + # it, so the project's --require spec_helper and --exclude-pattern never + # apply here: this suite loads only spec/e2e/e2e_helper.rb. + t.rspec_opts = "--options spec/e2e/e2e.opts" +end + task default: :spec diff --git a/activeadmin_mcp.gemspec b/activeadmin_mcp.gemspec index 0b34b05..be16aa5 100644 --- a/activeadmin_mcp.gemspec +++ b/activeadmin_mcp.gemspec @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require_relative "lib/activeadmin_mcp/version" Gem::Specification.new do |spec| @@ -12,7 +10,7 @@ Gem::Specification.new do |spec| spec.description = "Expose your ActiveAdmin resources to AI assistants via the Model Context Protocol (MCP)." spec.homepage = "https://github.com/OLIOEX/activeadmin_mcp" spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" + spec.required_ruby_version = ">= 4.0.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage @@ -24,8 +22,8 @@ Gem::Specification.new do |spec| end spec.require_paths = ["lib"] - spec.add_dependency "rails", ">= 6.1" - spec.add_dependency "activeadmin", ">= 2.0" + spec.add_dependency "rails", ">= 7.2" + spec.add_dependency "activeadmin", "~> 3.5" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" diff --git a/app/controllers/activeadmin_mcp/mcp_controller.rb b/app/controllers/activeadmin_mcp/mcp_controller.rb index 54d7120..2031b49 100644 --- a/app/controllers/activeadmin_mcp/mcp_controller.rb +++ b/app/controllers/activeadmin_mcp/mcp_controller.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class McpController < ActionController::API before_action :authenticate_mcp_token! diff --git a/app/models/activeadmin_mcp/api_token.rb b/app/models/activeadmin_mcp/api_token.rb index d27907a..c7e3ef2 100644 --- a/app/models/activeadmin_mcp/api_token.rb +++ b/app/models/activeadmin_mcp/api_token.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "digest" require "securerandom" diff --git a/config/routes.rb b/config/routes.rb index c23449a..8f71857 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - ActiveadminMcp::Engine.routes.draw do post "/", to: "mcp#call" end diff --git a/lib/activeadmin_mcp.rb b/lib/activeadmin_mcp.rb index e727866..502a1e4 100644 --- a/lib/activeadmin_mcp.rb +++ b/lib/activeadmin_mcp.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require_relative "activeadmin_mcp/version" require_relative "activeadmin_mcp/configuration" require_relative "activeadmin_mcp/authorization" diff --git a/lib/activeadmin_mcp/authorization.rb b/lib/activeadmin_mcp/authorization.rb index 685bd63..2742e7b 100644 --- a/lib/activeadmin_mcp/authorization.rb +++ b/lib/activeadmin_mcp/authorization.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp # Applies a resource's ActiveAdmin authorization adapter to MCP tool calls, so # reads, listings and writes obey the same rules as the admin UI. diff --git a/lib/activeadmin_mcp/configuration.rb b/lib/activeadmin_mcp/configuration.rb index a89be64..833e11c 100644 --- a/lib/activeadmin_mcp/configuration.rb +++ b/lib/activeadmin_mcp/configuration.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class Configuration MOUNT_STRATEGIES = %i[prepend append none].freeze diff --git a/lib/activeadmin_mcp/engine.rb b/lib/activeadmin_mcp/engine.rb index c510678..a771593 100644 --- a/lib/activeadmin_mcp/engine.rb +++ b/lib/activeadmin_mcp/engine.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class Engine < ::Rails::Engine isolate_namespace ActiveadminMcp diff --git a/lib/activeadmin_mcp/form_field_collector.rb b/lib/activeadmin_mcp/form_field_collector.rb index 6e8e1d6..b3e27de 100644 --- a/lib/activeadmin_mcp/form_field_collector.rb +++ b/lib/activeadmin_mcp/form_field_collector.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp # Records the field names declared by an ActiveAdmin `form do ... end` block. # diff --git a/lib/activeadmin_mcp/record_updater.rb b/lib/activeadmin_mcp/record_updater.rb index dcc02d6..0a59332 100644 --- a/lib/activeadmin_mcp/record_updater.rb +++ b/lib/activeadmin_mcp/record_updater.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp # Updates a single ActiveAdmin-managed record, enforcing the same three gates # the admin UI would: the resource must expose the update action, the current diff --git a/lib/activeadmin_mcp/request_handler.rb b/lib/activeadmin_mcp/request_handler.rb index f88dc74..a6fb0ca 100644 --- a/lib/activeadmin_mcp/request_handler.rb +++ b/lib/activeadmin_mcp/request_handler.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp class RequestHandler PROTOCOL_VERSION = "2025-06-18" diff --git a/lib/activeadmin_mcp/resource_registry.rb b/lib/activeadmin_mcp/resource_registry.rb index 5068651..3aaed78 100644 --- a/lib/activeadmin_mcp/resource_registry.rb +++ b/lib/activeadmin_mcp/resource_registry.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp module ResourceRegistry class << self diff --git a/lib/activeadmin_mcp/version.rb b/lib/activeadmin_mcp/version.rb index 3d1f9d0..9caeb55 100644 --- a/lib/activeadmin_mcp/version.rb +++ b/lib/activeadmin_mcp/version.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module ActiveadminMcp VERSION = "0.0.4" end diff --git a/lib/generators/activeadmin_mcp/install/install_generator.rb b/lib/generators/activeadmin_mcp/install/install_generator.rb index 9cee91f..6646075 100644 --- a/lib/generators/activeadmin_mcp/install/install_generator.rb +++ b/lib/generators/activeadmin_mcp/install/install_generator.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "rails/generators" require "rails/generators/active_record" diff --git a/lib/generators/activeadmin_mcp/install/templates/initializer.rb b/lib/generators/activeadmin_mcp/install/templates/initializer.rb index bb0abbd..40263c2 100644 --- a/lib/generators/activeadmin_mcp/install/templates/initializer.rb +++ b/lib/generators/activeadmin_mcp/install/templates/initializer.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - ActiveadminMcp.configure do |config| # Uncomment to enable API token authentication. # Requires running the auth migration first: diff --git a/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb b/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb index 170a3ea..b474c49 100644 --- a/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb +++ b/lib/generators/activeadmin_mcp/install/templates/mcp_api_tokens.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - ActiveAdmin.register_page "MCP API Tokens" do menu label: "MCP Tokens", parent: ActiveadminMcp.config.menu_parent, priority: 100 diff --git a/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb b/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb index b65c5bf..35d79db 100644 --- a/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb +++ b/lib/generators/activeadmin_mcp/install/templates/migration.rb.erb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - class CreateMcpApiTokens < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] def change return if table_exists?(:mcp_api_tokens) diff --git a/spec/activeadmin_mcp/api_token_spec.rb b/spec/activeadmin_mcp/api_token_spec.rb index 6468d4e..d308616 100644 --- a/spec/activeadmin_mcp/api_token_spec.rb +++ b/spec/activeadmin_mcp/api_token_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" require "support/active_record" diff --git a/spec/activeadmin_mcp/authorization_spec.rb b/spec/activeadmin_mcp/authorization_spec.rb index caac7d8..d9640cf 100644 --- a/spec/activeadmin_mcp/authorization_spec.rb +++ b/spec/activeadmin_mcp/authorization_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::Authorization do diff --git a/spec/activeadmin_mcp/configuration_spec.rb b/spec/activeadmin_mcp/configuration_spec.rb index 53f5403..6fe6467 100644 --- a/spec/activeadmin_mcp/configuration_spec.rb +++ b/spec/activeadmin_mcp/configuration_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::Configuration do diff --git a/spec/activeadmin_mcp/record_updater_spec.rb b/spec/activeadmin_mcp/record_updater_spec.rb index 0f8cc2a..988e17d 100644 --- a/spec/activeadmin_mcp/record_updater_spec.rb +++ b/spec/activeadmin_mcp/record_updater_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::RecordUpdater do diff --git a/spec/activeadmin_mcp/request_handler_spec.rb b/spec/activeadmin_mcp/request_handler_spec.rb index fae865d..116e439 100644 --- a/spec/activeadmin_mcp/request_handler_spec.rb +++ b/spec/activeadmin_mcp/request_handler_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::RequestHandler do diff --git a/spec/activeadmin_mcp/resource_registry_spec.rb b/spec/activeadmin_mcp/resource_registry_spec.rb index fbe7cf5..cbfbd91 100644 --- a/spec/activeadmin_mcp/resource_registry_spec.rb +++ b/spec/activeadmin_mcp/resource_registry_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp::ResourceRegistry do diff --git a/spec/activeadmin_mcp_spec.rb b/spec/activeadmin_mcp_spec.rb index 7a6956b..b009b56 100644 --- a/spec/activeadmin_mcp_spec.rb +++ b/spec/activeadmin_mcp_spec.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "spec_helper" RSpec.describe ActiveadminMcp do diff --git a/spec/e2e/app_install_spec.rb b/spec/e2e/app_install_spec.rb new file mode 100644 index 0000000..50fdb89 --- /dev/null +++ b/spec/e2e/app_install_spec.rb @@ -0,0 +1,34 @@ +RSpec.describe "the generated host application" do + let(:app_path) { E2E::AppBuilder::APP_PATH } + + it "has ActiveAdmin installed" do + expect(File).to exist(File.join(app_path, "config/initializers/active_admin.rb")) + expect(File).to exist(File.join(app_path, "app/admin/dashboard.rb")) + end + + # The user_class substitution isn't asserted here: AppBuilder#configure_mcp + # already raises BuildError at build time if it fails, so an assertion on + # its result here could never fail independently of the builder. The + # authentication_method line below is a genuine assertion on the + # installer's own `gsub_file` behaviour, so it stays. + it "has the gem's initializer, with devise_token authentication enabled" do + initializer = File.read(File.join(app_path, "config/initializers/activeadmin_mcp.rb")) + + expect(initializer).to include("config.authentication_method = :devise_token") + end + + it "has the token migration and the MCP Tokens admin page from the installer" do + migrations = Dir[File.join(app_path, "db/migrate/*_create_mcp_api_tokens.rb")] + + expect(migrations).not_to be_empty + expect(File).to exist(File.join(app_path, "app/admin/mcp_api_tokens.rb")) + end + + it "has migrated the fixture tables" do + schema = File.read(File.join(app_path, "db/schema.rb")) + + expect(schema).to include('create_table "posts"') + expect(schema).to include('create_table "authors"') + expect(schema).to include('create_table "mcp_api_tokens"') + end +end diff --git a/spec/e2e/e2e.opts b/spec/e2e/e2e.opts new file mode 100644 index 0000000..56d5410 --- /dev/null +++ b/spec/e2e/e2e.opts @@ -0,0 +1,2 @@ +--require ./spec/e2e/e2e_helper.rb +--format documentation diff --git a/spec/e2e/e2e_helper.rb b/spec/e2e/e2e_helper.rb new file mode 100644 index 0000000..c927ac3 --- /dev/null +++ b/spec/e2e/e2e_helper.rb @@ -0,0 +1,41 @@ +require "rspec" + +require_relative "../../lib/activeadmin_mcp/version" +require_relative "support/app_builder" +require_relative "support/app_server" +require_relative "support/mcp_client" + +module E2E + class << self + attr_accessor :token + end +end + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.disable_monkey_patching! + + # Every example starts from the seeded database, so no example depends on + # what another one left behind and the order is free to vary. + config.order = :random + Kernel.srand config.seed + + config.before(:suite) do + E2E::AppBuilder.build! + E2E::AppServer.start! + E2E.token = E2E::AppServer.mint_token! + end + + # Restoring the seeded rows is a transaction against the live database + # rather than a Rails boot, so paying it per example costs milliseconds. + config.before do + E2E::AppBuilder.reset_database! + end + + config.after(:suite) do + E2E::AppServer.stop! + end +end diff --git a/spec/e2e/fixture_app/Gemfile.deps b/spec/e2e/fixture_app/Gemfile.deps new file mode 100644 index 0000000..c1f3c6d --- /dev/null +++ b/spec/e2e/fixture_app/Gemfile.deps @@ -0,0 +1,14 @@ +# Appended to the generated application's Gemfile by AppBuilder#write_gemfile. +# GEM_PATH is substituted with this repository's root, so the application +# always loads the gem from the working tree rather than from RubyGems. + +gem "activeadmin", "~> 3.5" +gem "devise" +gem "sassc-rails" + +# Rails 7.2's ActiveSupport::JSON.decode still calls JSON.parse with the +# quirks_mode keyword, which the json gem dropped in 2.9.0. Any request with a +# JSON body 500s until this is pinned back below that line. +gem "json", "< 2.9" + +gem "activeadmin_mcp", path: "GEM_PATH" diff --git a/spec/e2e/fixture_app/README.md b/spec/e2e/fixture_app/README.md new file mode 100644 index 0000000..6e9f87b --- /dev/null +++ b/spec/e2e/fixture_app/README.md @@ -0,0 +1,46 @@ +# Fixture application + +These files are copied over the Rails application the e2e suite generates +into `tmp/e2e_app`, replacing what the Rails and ActiveAdmin generators +produce. They are checked in rather than written from heredocs in +`../support/app_builder.rb` so that you can read, diff and edit the thing the +suite actually tests against. + +The layout mirrors the generated application, so a file here lands at the same +path there. + +Each one exists to give a claim in the README something to bite on: + +- `app/admin/posts.rb` permits `title` and `body` but **not** `slug`, so the + suite can prove an unpermitted attribute is dropped rather than written. +- `app/admin/authors.rb` registers `actions :index, :show`, so the suite can + prove `update` refuses a resource the admin UI would not let you edit. +- `app/models/*.rb` allowlist `ransackable_attributes`, which Ransack 4 + requires before it will filter on an attribute at all. +- `db/migrate/*.rb` create the `authors` and `posts` tables. They are checked + in with fixed version numbers rather than produced by `rails generate model`, + so the schema under test is visible and does not change from run to run. +- `db/seeds.rb` is restorative: it resets existing rows rather than only + creating missing ones, because the suite's `update` examples mutate a post + and the generated application is cached between runs. It reads the admin + credentials from the environment so that they are defined in exactly one + place, `AppBuilder`. + +Two files here are not copied into the application: + +- `README.md`, this file. +- `Gemfile.deps`, which is appended to the Gemfile the Rails generator wrote + rather than replacing it. `GEM_PATH` in it is substituted with the + repository root. + +Editing anything here except this README changes the builder's cache +fingerprint, so the next `rake e2e` rebuilds the application rather than +silently reusing a stale one. That is what makes these files safe to +experiment with: break one deliberately, run the suite, and watch the example +that covers it fail. + +After migrating and seeding, the builder snapshots the SQLite database to +`storage/seeded.sqlite3` inside the generated application. Restoring from that +snapshot is a transaction against the live database rather than a Rails boot, +so the suite can afford to do it before every single example — which is why +examples here never have to undo their own writes. diff --git a/spec/e2e/fixture_app/app/admin/authors.rb b/spec/e2e/fixture_app/app/admin/authors.rb new file mode 100644 index 0000000..ced9403 --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/authors.rb @@ -0,0 +1,5 @@ +# Registered without the update action, so the e2e suite can prove the MCP +# `update` tool refuses a resource the admin UI would not let you edit either. +ActiveAdmin.register Author do + actions :index, :show +end diff --git a/spec/e2e/fixture_app/app/admin/posts.rb b/spec/e2e/fixture_app/app/admin/posts.rb new file mode 100644 index 0000000..cd6c28b --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/posts.rb @@ -0,0 +1,5 @@ +# Permits title and body but deliberately not slug, so the e2e suite can prove +# the MCP `update` tool drops attributes the admin form does not accept. +ActiveAdmin.register Post do + permit_params :title, :body +end diff --git a/spec/e2e/fixture_app/app/models/author.rb b/spec/e2e/fixture_app/app/models/author.rb new file mode 100644 index 0000000..54d81b5 --- /dev/null +++ b/spec/e2e/fixture_app/app/models/author.rb @@ -0,0 +1,6 @@ +class Author < ApplicationRecord + # See the note in post.rb: Ransack 4 requires an explicit allowlist. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/app/models/post.rb b/spec/e2e/fixture_app/app/models/post.rb new file mode 100644 index 0000000..6003a1f --- /dev/null +++ b/spec/e2e/fixture_app/app/models/post.rb @@ -0,0 +1,8 @@ +class Post < ApplicationRecord + # Ransack 4 refuses to filter on any attribute absent from this allowlist, + # and the MCP `query` tool calls `ransack` directly rather than going + # through an ActiveAdmin filter. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb b/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb new file mode 100644 index 0000000..bbe0ca7 --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000001_create_authors.rb @@ -0,0 +1,10 @@ +class CreateAuthors < ActiveRecord::Migration[7.2] + def change + create_table :authors do |t| + t.string :name + t.string :email + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb b/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb new file mode 100644 index 0000000..e092dc7 --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000002_create_posts.rb @@ -0,0 +1,11 @@ +class CreatePosts < ActiveRecord::Migration[7.2] + def change + create_table :posts do |t| + t.string :title + t.text :body + t.string :slug + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/seeds.rb b/spec/e2e/fixture_app/db/seeds.rb new file mode 100644 index 0000000..29bf6e9 --- /dev/null +++ b/spec/e2e/fixture_app/db/seeds.rb @@ -0,0 +1,35 @@ +# Restorative by design: the e2e suite's `update` examples rewrite a post's +# title, and the generated application is cached between runs, so seeding has +# to reset existing rows rather than only create missing ones. Records are +# keyed on stable natural keys (email, slug) that no example mutates. + +admin_email = ENV.fetch("E2E_ADMIN_EMAIL") +admin_password = ENV.fetch("E2E_ADMIN_PASSWORD") + +AdminUser.find_or_initialize_by(email: admin_email).tap do |user| + user.password = admin_password + user.password_confirmation = admin_password + user.save! +end + +{ + "ursula@example.com" => "Ursula", + "terry@example.com" => "Terry", +}.each do |email, name| + Author.find_or_initialize_by(email: email).tap do |author| + author.name = name + author.save! + end +end + +{ + "a-wizard-of-earthsea" => ["A Wizard of Earthsea", "The first."], + "the-tombs-of-atuan" => ["The Tombs of Atuan", "The second."], + "small-gods" => ["Small Gods", "Unrelated."], +}.each do |slug, (title, body)| + Post.find_or_initialize_by(slug: slug).tap do |post| + post.title = title + post.body = body + post.save! + end +end diff --git a/spec/e2e/mcp_server_spec.rb b/spec/e2e/mcp_server_spec.rb new file mode 100644 index 0000000..18377ec --- /dev/null +++ b/spec/e2e/mcp_server_spec.rb @@ -0,0 +1,41 @@ +RSpec.describe "the MCP server" do + let(:url) { E2E::AppServer.instance.mcp_url } + let(:client) { E2E::McpClient.new(url: url, token: E2E.token) } + + describe "the handshake" do + it "reports the protocol version and server info" do + result = client.initialize_session + + expect(result["protocolVersion"]).to eq("2025-06-18") + expect(result["serverInfo"]["name"]).to eq("activeadmin-mcp") + expect(result["serverInfo"]["version"]).to eq(ActiveadminMcp::VERSION) + expect(result["capabilities"]).to have_key("tools") + end + + it "advertises the three tools" do + names = client.tools_list["tools"].map { |tool| tool["name"] } + + expect(names).to contain_exactly("list_resources", "query", "update") + end + end + + describe "authentication" do + it "rejects a request with no token" do + response = E2E::McpClient.new(url: url).post("initialize") + + expect(response.code).to eq("401") + expect(JSON.parse(response.body).dig("error", "code")).to eq(-32_000) + end + + it "rejects a request with an unrecognised token" do + response = E2E::McpClient.new(url: url, token: "aamcp_not_a_real_token").post("initialize") + + expect(response.code).to eq("401") + expect(JSON.parse(response.body).dig("error", "code")).to eq(-32_000) + end + + it "accepts a request with the minted token" do + expect(client.post("initialize").code).to eq("200") + end + end +end diff --git a/spec/e2e/mcp_tools_spec.rb b/spec/e2e/mcp_tools_spec.rb new file mode 100644 index 0000000..fe49afd --- /dev/null +++ b/spec/e2e/mcp_tools_spec.rb @@ -0,0 +1,105 @@ +RSpec.describe "the MCP tools" do + let(:client) { E2E::McpClient.new(url: E2E::AppServer.instance.mcp_url, token: E2E.token) } + + describe "list_resources" do + it "lists the registered resources with their attributes" do + resources = client.call_tool("list_resources")["resources"] + + expect(resources.map { |resource| resource["name"] }).to include("Post", "Author") + + post = resources.find { |resource| resource["name"] == "Post" } + expect(post["table"]).to eq("posts") + expect(post["attributes"]).to include("title", "body", "slug") + end + + it "omits sensitive attributes from the admin user" do + resources = client.call_tool("list_resources")["resources"] + admin_user = resources.find { |resource| resource["name"] == "AdminUser" } + + expect(admin_user["attributes"]).not_to include("encrypted_password") + expect(admin_user["attributes"]).not_to include("reset_password_token") + end + end + + describe "query" do + it "filters with Ransack syntax" do + result = client.call_tool("query", resource: "Post", q: { title_cont: "Earthsea" }) + + expect(result["count"]).to eq(1) + expect(result["records"].first["title"]).to eq("A Wizard of Earthsea") + end + + it "returns every record when no query is given" do + result = client.call_tool("query", resource: "Post") + + expect(result["count"]).to eq(3) + end + + it "honours the limit" do + result = client.call_tool("query", resource: "Post", limit: 1) + + expect(result["count"]).to eq(1) + expect(result["records"].length).to eq(1) + end + + # No example asserts the documented 100-record cap (README: "capped at + # 100"). With only three Posts seeded, any assertion on the result of a + # limit above 100 (count, records.length, or absence of an error) would + # be identical whether the clamp fired or not, so it would pass whether + # or not the clamp exists - see the final-fix-report for the fuller + # reasoning. Demonstrating the clamp for real needs >100 seeded records, + # which is a real cost (seed script size, migrate/seed time on every + # run) we're not paying just for this. + + it "reports an unregistered resource rather than raising" do + result = client.call_tool("query", resource: "Nonexistent") + + expect(result["error"]).to eq("Resource not found: Nonexistent") + end + end + + describe "update" do + let(:post_id) do + client.call_tool("query", resource: "Post", q: { slug_eq: "small-gods" })["records"].first["id"] + end + + it "updates a permitted attribute and persists it" do + result = client.call_tool("update", resource: "Post", id: post_id, attributes: { title: "Pyramids" }) + + expect(result["error"]).to be_nil + expect(result["updated"]).to eq(["title"]) + + reread = client.call_tool("query", resource: "Post", q: { id_eq: post_id })["records"].first + expect(reread["title"]).to eq("Pyramids") + end + + it "drops an attribute the resource does not permit" do + result = client.call_tool( + "update", + resource: "Post", + id: post_id, + attributes: { title: "Hogfather", slug: "tampered" } + ) + + expect(result["updated"]).to eq(["title"]) + + reread = client.call_tool("query", resource: "Post", q: { id_eq: post_id })["records"].first + expect(reread["title"]).to eq("Hogfather") + expect(reread["slug"]).to eq("small-gods") + end + + it "refuses a resource that does not register the update action" do + author_id = client.call_tool("query", resource: "Author", q: { name_eq: "Terry" })["records"].first["id"] + + result = client.call_tool("update", resource: "Author", id: author_id, attributes: { name: "Terence" }) + + expect(result["error"]).to eq("Resource is not editable: Author") + end + + it "reports a missing record rather than raising" do + result = client.call_tool("update", resource: "Post", id: 999_999, attributes: { title: "Nope" }) + + expect(result["error"]).to eq("Record not found: Post#999999") + end + end +end diff --git a/spec/e2e/support/app_builder.rb b/spec/e2e/support/app_builder.rb new file mode 100644 index 0000000..2699fdb --- /dev/null +++ b/spec/e2e/support/app_builder.rb @@ -0,0 +1,286 @@ +require "bundler" +require "digest" +require "fileutils" +require "open3" +require "sqlite3" + +module E2E + # Generates a real Rails + ActiveAdmin application with the gem under test + # installed into it, and caches the result between runs. + # + # The application is cached on a fingerprint of this file and of everything + # under `../fixture_app`, so editing either the build recipe or a fixture + # forces a rebuild. Editing the gem's own library code does not, and must + # not: the application references the gem with `path:`, so it always loads + # the current working tree. + class AppBuilder + RAILS_VERSION = "7.2.2.2" + REPO_ROOT = File.expand_path("../../..", __dir__) + APP_PATH = File.join(REPO_ROOT, "tmp", "e2e_app") + FINGERPRINT_PATH = File.join(APP_PATH, ".e2e_fingerprint") + + # Checked-in files copied over the generated application. Kept as real + # files rather than heredocs so they can be read and edited directly. + FIXTURE_APP_PATH = File.expand_path("../fixture_app", __dir__) + + # Documentation, not part of the application. + FIXTURE_DOCS = "README.md" + + # Appended to the generated Gemfile rather than copied into place. + FIXTURE_GEMFILE = "Gemfile.deps" + + DATABASE_PATH = "storage/development.sqlite3" + + # A copy of the database taken immediately after migrating and seeding. + # Restoring from it costs milliseconds; re-seeding is a full Rails boot, + # which is what makes it affordable to reset between examples. + DATABASE_SNAPSHOT_PATH = "storage/seeded.sqlite3" + + # Tables holding state created after the snapshot was taken, which a reset + # must therefore leave alone. The API token is minted once the server is + # up, so restoring this table would revoke it and every subsequent request + # would come back 401. + SESSION_TABLES = %w[mcp_api_tokens].freeze + + ADMIN_EMAIL = "admin@example.com" + ADMIN_PASSWORD = "password" + + class BuildError < StandardError; end + + class << self + def build! + new.build! + end + + def snapshot_exists? + File.exist?(File.join(APP_PATH, DATABASE_SNAPSHOT_PATH)) + end + + # Puts the seeded rows back, table by table, through SQLite itself. + # + # Copying the snapshot file over the database would be simpler but is + # not safe here: the application server holds the database open, and + # swapping the file underneath its page cache invites it to read a + # mixture of the old and new images. Going through a transaction on the + # live connection takes SQLite's locks and leaves every reader + # consistent, which is what makes this usable between examples rather + # than only between runs. + def reset_database! + snapshot = File.join(APP_PATH, DATABASE_SNAPSHOT_PATH) + raise BuildError, "No database snapshot at #{snapshot}" unless File.exist?(snapshot) + + SQLite3::Database.new(File.join(APP_PATH, DATABASE_PATH)) do |db| + db.execute("ATTACH DATABASE ? AS seed", [snapshot]) + + begin + db.transaction { restore_tables(db) } + ensure + db.execute("DETACH DATABASE seed") + end + end + end + + # Runs a command with the gem's own bundler environment stripped out, so + # the generated application resolves against its own Gemfile. Raises with + # the combined output on failure: a silent build failure here surfaces + # much later as an inscrutable boot error. + def run!(command, chdir: APP_PATH, env: {}) + output = nil + status = nil + + Bundler.with_unbundled_env do + output, status = Open3.capture2e(env, *command, chdir: chdir) + end + + return output if status.success? + + raise BuildError, "Command failed: #{command.join(' ')}\n\n#{output}" + end + + private + + def restore_tables(db) + tables = db.execute( + "SELECT name FROM seed.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ).flatten - SESSION_TABLES + + tables.each do |table| + db.execute("DELETE FROM main.\"#{table}\"") + db.execute("INSERT INTO main.\"#{table}\" SELECT * FROM seed.\"#{table}\"") + end + end + end + + def build! + if cached? + # The bundle lives inside the cached directory (vendor/bundle), but a + # cache restore does not guarantee it is satisfied for this machine. + bundle_install + + # The previous run's `update` examples mutated the seed data, so the + # database has to be put back. Fall back to re-seeding when there is + # no snapshot, which is the case for a cache saved before snapshots + # existed. + self.class.snapshot_exists? ? self.class.reset_database! : seed + return APP_PATH + end + + FileUtils.rm_rf(APP_PATH) + FileUtils.mkdir_p(File.dirname(APP_PATH)) + + generate_app + write_gemfile + vendor_bundle_path + bundle_install + install_active_admin + copy_fixture_app + install_mcp + configure_mcp + migrate + seed + snapshot_database + + File.write(FINGERPRINT_PATH, fingerprint) + APP_PATH + end + + private + + def cached? + return false if ENV["E2E_REBUILD"] + return false unless File.exist?(FINGERPRINT_PATH) + + File.read(FINGERPRINT_PATH).strip == fingerprint + end + + def fingerprint + Digest::SHA256.hexdigest([RAILS_VERSION, File.read(__FILE__), fixture_digest].join("\n")) + end + + # Hashes every fixture's path and contents, so editing one invalidates the + # cache. Without this the fixtures would move out of this file's digest and + # an edited fixture would be silently ignored on the next run. + def fixture_digest + paths = Dir.glob(File.join(FIXTURE_APP_PATH, "**", "*")) + .select { |path| File.file?(path) } + .reject { |path| File.basename(path) == FIXTURE_DOCS } + + paths.sort.map { |path| "#{path.delete_prefix(FIXTURE_APP_PATH)}\n#{File.read(path)}" }.join("\n") + end + + def run!(command, chdir: APP_PATH, env: {}) + self.class.run!(command, chdir: chdir, env: env) + end + + def generate_app + ensure_rails_installed + + run!( + [ + "rails", "_#{RAILS_VERSION}_", "new", APP_PATH, + "--database=sqlite3", + "--asset-pipeline=sprockets", + "--skip-git", + "--skip-test", + "--skip-system-test", + "--skip-javascript", + "--skip-hotwire", + "--skip-action-cable", + "--skip-action-mailbox", + "--skip-action-text", + "--skip-active-storage", + "--skip-jbuilder", + "--skip-bootsnap", + ], + chdir: REPO_ROOT + ) + end + + def ensure_rails_installed + _, status = Bundler.with_unbundled_env do + Open3.capture2e("gem", "list", "-i", "rails", "-v", RAILS_VERSION) + end + return if status.success? + + self.class.run!(["gem", "install", "rails", "-v", RAILS_VERSION, "--no-document"], chdir: REPO_ROOT) + end + + def write_gemfile + additions = File.read(File.join(FIXTURE_APP_PATH, FIXTURE_GEMFILE)).gsub("GEM_PATH", REPO_ROOT) + + File.open(File.join(APP_PATH, "Gemfile"), "a") do |f| + f.puts + f.puts additions + end + end + + # Vendors the generated app's gems inside APP_PATH itself, rather than the + # system gem home, so that caching tmp/e2e_app (as CI does) actually + # caches a runnable app. Without this, Bundler.with_unbundled_env strips + # BUNDLE_* and the gems installed by `bundle_install` land outside + # whatever directory gets cached. + def vendor_bundle_path + run!(["bundle", "config", "set", "--local", "path", "vendor/bundle"]) + end + + def bundle_install + run!(["bundle", "install"]) + end + + def install_active_admin + run!(["bin/rails", "generate", "active_admin:install"]) + end + + # Copies the checked-in fixture application over what the generators + # produced: the ActiveAdmin registrations, the model allowlists, and the + # seeds. See spec/e2e/fixture_app/README.md for what each file proves. + def copy_fixture_app + fixture_entries.each do |entry| + FileUtils.cp_r(File.join(FIXTURE_APP_PATH, entry), APP_PATH) + end + end + + # Everything in the fixture directory that belongs in the application: + # not the README, and not the Gemfile fragment, which is appended to the + # generated Gemfile rather than copied over it. + def fixture_entries + (Dir.children(FIXTURE_APP_PATH) - [FIXTURE_DOCS, FIXTURE_GEMFILE]).sort + end + + def install_mcp + run!(["bin/rails", "generate", "activeadmin_mcp:install", "--auth", "devise_token"]) + end + + # ActiveAdmin's installer creates AdminUser, but the gem defaults + # user_class to "User"; without this the ApiToken association never + # resolves and every authenticated request fails. + def configure_mcp + path = File.join(APP_PATH, "config/initializers/activeadmin_mcp.rb") + contents = File.read(path).sub('# config.user_class = "User"', 'config.user_class = "AdminUser"') + + unless contents.include?('config.user_class = "AdminUser"') + raise BuildError, "Could not set user_class in #{path}" + end + + File.write(path, contents) + end + + def migrate + run!(["bin/rails", "db:migrate"]) + end + + # Safe to copy the database file directly: the seeding process has + # exited by this point, so SQLite has checkpointed its write-ahead log + # into the main file and removed it. + def snapshot_database + FileUtils.cp(File.join(APP_PATH, DATABASE_PATH), File.join(APP_PATH, DATABASE_SNAPSHOT_PATH)) + end + + def seed + run!( + ["bin/rails", "db:seed"], + env: { "E2E_ADMIN_EMAIL" => ADMIN_EMAIL, "E2E_ADMIN_PASSWORD" => ADMIN_PASSWORD } + ) + end + end +end diff --git a/spec/e2e/support/app_server.rb b/spec/e2e/support/app_server.rb new file mode 100644 index 0000000..6caa4d0 --- /dev/null +++ b/spec/e2e/support/app_server.rb @@ -0,0 +1,129 @@ +require "fileutils" +require "net/http" +require "socket" +require "timeout" + +module E2E + # Boots the generated application under Puma and tears it down again. + class AppServer + BOOT_TIMEOUT = 60 + LOG_PATH = File.join(AppBuilder::APP_PATH, "log", "e2e.log") + + MINT_SCRIPT = <<~RUBY + user = AdminUser.find_by!(email: "#{AppBuilder::ADMIN_EMAIL}") + puts ActiveadminMcp::ApiToken.create!(user: user, name: "e2e").raw_token + RUBY + + class BootError < StandardError; end + + attr_reader :port, :pid + + class << self + attr_reader :instance + + def start! + @instance ||= new.tap(&:start!) + end + + def stop! + @instance&.stop! + @instance = nil + end + + # Creates an API token for the seeded admin user and returns the raw + # value, which the model only exposes at creation time. + def mint_token! + output = AppBuilder.run!(["bin/rails", "runner", MINT_SCRIPT]) + token = output[/aamcp_[0-9a-f]{64}/] + + raise BootError, "Could not mint an API token:\n\n#{output}" unless token + + token + end + end + + def initialize + @port = free_port + end + + def start! + FileUtils.mkdir_p(File.dirname(LOG_PATH)) + FileUtils.rm_f(LOG_PATH) + + Bundler.with_unbundled_env do + @pid = Process.spawn( + { "RAILS_ENV" => "development" }, + "bin/rails", "server", "-p", port.to_s, "-b", "127.0.0.1", + chdir: AppBuilder::APP_PATH, + out: [LOG_PATH, "a"], + err: [:child, :out] + ) + end + + at_exit { stop! } + wait_for_boot! + self + end + + def stop! + return unless @pid + + Process.kill("TERM", @pid) + Process.wait(@pid) + rescue Errno::ESRCH, Errno::ECHILD + nil # Already gone. + ensure + @pid = nil + end + + def base_url + "http://127.0.0.1:#{port}" + end + + def mcp_url + "#{base_url}/mcp" + end + + private + + def free_port + server = TCPServer.new("127.0.0.1", 0) + server.addr[1].tap { server.close } + end + + # Polls the mount point rather than the root path: a 401 or a JSON-RPC + # error both mean the engine is mounted and answering, which is all the + # suite needs to know before it starts. Rails booting but the engine + # failing to mount also answers HTTP requests (with an HTML 404), so a + # response alone is not enough evidence — it must look like the engine, + # not just like a webserver. + def wait_for_boot! + Timeout.timeout(BOOT_TIMEOUT) do + loop do + begin + response = Net::HTTP.post(URI(mcp_url), "{}", "Content-Type" => "application/json") + return if engine_response?(response) + + sleep 0.5 + rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, EOFError, SocketError + sleep 0.5 + end + end + end + rescue Timeout::Error + raise BootError, "Server did not boot within #{BOOT_TIMEOUT}s. Log tail:\n\n#{log_tail}" + end + + def engine_response?(response) + return true if response.code == "401" + + response.code == "200" && response["Content-Type"].to_s.include?("application/json") + end + + def log_tail + return "(no log at #{LOG_PATH})" unless File.exist?(LOG_PATH) + + File.readlines(LOG_PATH).last(40).join + end + end +end diff --git a/spec/e2e/support/mcp_client.rb b/spec/e2e/support/mcp_client.rb new file mode 100644 index 0000000..3079b40 --- /dev/null +++ b/spec/e2e/support/mcp_client.rb @@ -0,0 +1,57 @@ +require "json" +require "net/http" +require "uri" + +module E2E + # A minimal MCP client: JSON-RPC 2.0 over HTTP, the way a real client such + # as Claude Code talks to the mounted engine. + class McpClient + class ProtocolError < StandardError; end + + def initialize(url:, token: nil) + @uri = URI(url) + @token = token + @id = 0 + end + + def initialize_session + result_of("initialize", protocolVersion: "2025-06-18", clientInfo: { name: "e2e", version: "1.0" }) + end + + def tools_list + result_of("tools/list") + end + + # Tool results arrive as a pretty-printed JSON document inside a text + # content block, so unwrap both layers and hand back the payload itself. + def call_tool(name, arguments = {}) + result = result_of("tools/call", name: name, arguments: arguments) + text = result.dig("content", 0, "text") + + raise ProtocolError, "No text content in tool result: #{result.inspect}" unless text + + JSON.parse(text) + end + + def post(method, params = {}) + @id += 1 + request = Net::HTTP::Post.new(@uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{@token}" if @token + request.body = JSON.generate(jsonrpc: "2.0", id: @id, method: method, params: params) + + Net::HTTP.start(@uri.hostname, @uri.port) { |http| http.request(request) } + end + + private + + def result_of(method, params = {}) + response = post(method, params) + body = JSON.parse(response.body) + + raise ProtocolError, "JSON-RPC error from #{method}: #{body['error'].inspect}" if body["error"] + + body.fetch("result") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c064758..195e9f6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "rails" require "active_record" require "action_controller" diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb index 7059d69..bcd52b2 100644 --- a/spec/support/active_record.rb +++ b/spec/support/active_record.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - require "active_record" # Spin up an in-memory SQLite database with just the tables the ApiToken