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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
rspec:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.1", "3.2", "3.3", "3.4"]

steps:
- uses: actions/checkout@v4

- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true

- name: Run specs
run: bundle exec rspec
2 changes: 2 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
--require spec_helper
--format documentation
3 changes: 3 additions & 0 deletions active_admin_mcp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ Gem::Specification.new do |spec|

spec.add_dependency "rails", ">= 6.1"
spec.add_dependency "activeadmin", ">= 2.0"

spec.add_development_dependency "rspec", "~> 3.0"
spec.add_development_dependency "sqlite3", ">= 1.4"
end
94 changes: 94 additions & 0 deletions spec/active_admin_mcp/api_token_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

require "spec_helper"
require "support/active_record"

RSpec.describe ActiveAdminMcp::ApiToken do
let(:user) { User.create!(email: "admin@example.com") }

after do
described_class.delete_all
User.delete_all
end

describe ".digest" do
it "returns the SHA256 hex digest of the raw token" do
expect(described_class.digest("secret"))
.to eq(Digest::SHA256.hexdigest("secret"))
end
end

describe "token generation on create" do
subject(:token) { described_class.create!(user: user) }

it "assigns a raw token with the aamcp_ prefix" do
expect(token.raw_token).to match(/\Aaamcp_[0-9a-f]{64}\z/)
end

it "stores only the digest of the raw token, never the raw value" do
expect(token.token_digest).to eq(described_class.digest(token.raw_token))
expect(token.reload.read_attribute(:token_digest)).not_to include(token.raw_token)
end
end

describe "validations" do
it "requires a user" do
record = described_class.new
expect(record).not_to be_valid
expect(record.errors[:user_id]).to be_present
end

it "rejects a duplicate token digest" do
# Force both records to generate the same raw token (and therefore the
# same digest) so the uniqueness validation has something to reject.
allow(SecureRandom).to receive(:hex).and_return("f" * 64)

described_class.create!(user: user)
duplicate = described_class.new(user: user)

expect(duplicate).not_to be_valid
expect(duplicate.errors[:token_digest]).to be_present
end
end

describe ".find_by_raw_token" do
it "finds the token matching the raw value" do
token = described_class.create!(user: user)
expect(described_class.find_by_raw_token(token.raw_token)).to eq(token)
end

it "returns nil for an unknown raw token" do
described_class.create!(user: user)
expect(described_class.find_by_raw_token("aamcp_unknown")).to be_nil
end

it "returns nil for a blank token without querying" do
expect(described_class.find_by_raw_token("")).to be_nil
expect(described_class.find_by_raw_token(nil)).to be_nil
end
end

describe "#touch_last_used!" do
subject(:token) { described_class.create!(user: user) }

it "sets last_used_at when it has never been used" do
expect { token.touch_last_used! }
.to change { token.reload.last_used_at }.from(nil)
end

it "refreshes last_used_at when the throttle window has passed" do
token.update_column(:last_used_at, 10.minutes.ago)

expect { token.touch_last_used! }
.to(change { token.reload.last_used_at })
end

it "does not update within the throttle window" do
recent = 1.minute.ago
token.update_column(:last_used_at, recent)

expect { token.touch_last_used! }
.not_to(change { token.reload.last_used_at })
end
end
end
55 changes: 55 additions & 0 deletions spec/active_admin_mcp/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveAdminMcp::Configuration do
subject(:config) { described_class.new }

describe "defaults" do
it "uses the expected default values" do
expect(config.authentication_method).to be_nil
expect(config.user_class).to eq("User")
expect(config.current_user_method).to eq(:current_admin_user)
expect(config.menu_parent).to be_nil
expect(config.mount_path).to eq("/mcp")
expect(config.mount_strategy).to eq(:prepend)
expect(config.auth_header_name).to eq("Authorization")
end
end

describe "#mount_strategy=" do
it "accepts each supported strategy" do
described_class::MOUNT_STRATEGIES.each do |strategy|
config.mount_strategy = strategy
expect(config.mount_strategy).to eq(strategy)
end
end

it "raises ArgumentError for an unsupported strategy" do
expect { config.mount_strategy = :sideways }
.to raise_error(ArgumentError, /Invalid mount strategy: sideways/)
end

it "lists the valid strategies in the error message" do
expect { config.mount_strategy = :nope }
.to raise_error(ArgumentError, /prepend, append, none/)
end
end

describe "#authentication_enabled?" do
it "is true only when the method is :devise_token" do
config.authentication_method = :devise_token
expect(config.authentication_enabled?).to be(true)
end

it "is false when no authentication method is set" do
config.authentication_method = nil
expect(config.authentication_enabled?).to be(false)
end

it "is false for an unrecognised authentication method" do
config.authentication_method = :something_else
expect(config.authentication_enabled?).to be(false)
end
end
end
135 changes: 135 additions & 0 deletions spec/active_admin_mcp/request_handler_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe ActiveAdminMcp::RequestHandler do
subject(:handler) { described_class.new }

def handle(method, params = nil, id: 1)
request = { "id" => id, "method" => method }
request["params"] = params if params
handler.handle(request)
end

describe "#handle" do
describe "initialize" do
it "returns the protocol version, server info and capabilities" do
result = handle("initialize")[:result]

expect(result[:protocolVersion]).to eq(described_class::PROTOCOL_VERSION)
expect(result[:serverInfo]).to eq(name: "active-admin-mcp", version: ActiveAdminMcp::VERSION)
expect(result[:capabilities]).to eq(tools: {})
end

it "echoes the request id and jsonrpc version" do
response = handle("initialize", id: 42)

expect(response[:jsonrpc]).to eq("2.0")
expect(response[:id]).to eq(42)
end
end

describe "notifications/initialized" do
it "returns nil so the controller sends no content" do
expect(handle("notifications/initialized")).to be_nil
end
end

describe "ping" do
it "returns an empty result" do
expect(handle("ping")[:result]).to eq({})
end
end

describe "tools/list" do
it "advertises the list_resources and query tools" do
tools = handle("tools/list")[:result][:tools]

expect(tools.map { |t| t[:name] }).to contain_exactly("list_resources", "query")
end

it "marks resource as required on the query tool" do
tools = handle("tools/list")[:result][:tools]
query = tools.find { |t| t[:name] == "query" }

expect(query[:inputSchema][:required]).to eq(["resource"])
end
end

describe "unknown method" do
it "returns a -32601 Method not found error" do
response = handle("does/not/exist")

expect(response[:error][:code]).to eq(-32_601)
expect(response[:error][:message]).to eq("Method not found: does/not/exist")
end
end
end

describe "tools/call" do
def call_tool(name, arguments = {})
response = handle("tools/call", { "name" => name, "arguments" => arguments })
text = response[:result][:content].first[:text]
JSON.parse(text)
end

describe "list_resources" do
it "returns the registry's resources" do
resources = [{ name: "User", table: "users", attributes: %w[id email] }]
allow(ActiveAdminMcp::ResourceRegistry).to receive(:all).and_return(resources)

expect(call_tool("list_resources")).to eq("resources" => [
{ "name" => "User", "table" => "users", "attributes" => %w[id email] },
])
end
end

describe "query" do
let(:records) { [{ "id" => 1, "name" => "john" }] }
let(:relation) { double("relation", limit: records) }
let(:model) { double("model") }

before do
allow(records).to receive(:as_json).and_return(records)
allow(records).to receive(:size).and_return(records.length)
allow(model).to receive(:ransack).and_return(double("search", result: relation))
allow(ActiveAdminMcp::ResourceRegistry).to receive(:find)
.with("User").and_return(name: "User", model: model)
end

it "returns matching records with a count" do
result = call_tool("query", "resource" => "User", "q" => { "name_cont" => "john" })

expect(result).to eq("resource" => "User", "count" => 1, "records" => records)
end

it "defaults the limit to 25 when none is given" do
expect(relation).to receive(:limit).with(25).and_return(records)
call_tool("query", "resource" => "User")
end

it "caps the limit at 100" do
expect(relation).to receive(:limit).with(100).and_return(records)
call_tool("query", "resource" => "User", "limit" => 500)
end

it "passes an empty Ransack query when none is provided" do
expect(model).to receive(:ransack).with({}).and_return(double("search", result: relation))
call_tool("query", "resource" => "User")
end

it "returns an error when the resource is not found" do
allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil)

expect(call_tool("query", "resource" => "Ghost"))
.to eq("error" => "Resource not found: Ghost")
end
end

describe "an unknown tool" do
it "returns an error naming the tool" do
expect(call_tool("frobnicate")).to eq("error" => "Unknown tool: frobnicate")
end
end
end
end
Loading
Loading