Comparing changes

v0.1.0 → v0.2.0
6 commits 13 files changed
lib/elelem/mcp/client.rb
@@ -25,7 +25,7 @@ module Elelem
               "#{name}_#{tool["name"]}",
               {
                 description: tool["description"],
-                params: tool.dig("inputSchema", "properties") || {},
+                params: symbolize(tool.dig("inputSchema", "properties") || {}),
                 required: tool.dig("inputSchema", "required") || [],
                 fn: ->(a) { server(name).call(tool["name"], a) }
               }
@@ -40,6 +40,10 @@ module Elelem
 
       private
 
+      def symbolize(hash)
+        JSON.parse(JSON.generate(hash), symbolize_names: true)
+      end
+
       def load_config(configurations)
         configurations.each_with_object({}) do |path, merged|
           file = File.expand_path(path)
@@ -157,6 +161,7 @@ module Elelem
         @open_timeout = open_timeout
         @id = 0
         @session_id = nil
+        @access_token = nil
         handshake!
       end
 
@@ -185,7 +190,7 @@ module Elelem
         post(msg)
       end
 
-      def post(msg)
+      def post(msg, retry_auth: true)
         request = Net::HTTP::Post.new(@uri)
         request["content-type"] = "application/json"
         request_headers.each { |k, v| request[k] = v }
@@ -204,7 +209,10 @@ module Elelem
               return parse_response(response)
             when Net::HTTPUnauthorized
               response.body
-              raise "MCP: server requires OAuth (not yet supported)"
+              raise "Authorization failed" unless retry_auth
+
+              @access_token = OAuth.new(@uri.to_s).token
+              return post(msg, retry_auth: false)
             else
               raise "HTTP #{response.code}: #{response.body}"
             end
@@ -215,6 +223,7 @@ module Elelem
       def request_headers
         base = { "Accept" => "application/json, text/event-stream" }
         base["Mcp-Session-Id"] = @session_id if @session_id
+        base["Authorization"] = "Bearer #{@access_token}" if @access_token
         @headers.merge(base)
       end
 
lib/elelem/mcp/oauth.rb
@@ -0,0 +1,237 @@
+# frozen_string_literal: true
+
+module Elelem
+  module MCP
+    class OAuth
+      CALLBACK_PORT = 18273
+      REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
+
+      def initialize(resource_url, http: HttpAdapter.new)
+        @resource_url = resource_url
+        @http = http
+        @storage = TokenStorage.new
+      end
+
+      def token
+        stored = @storage.load(@resource_url)
+        return stored[:access_token] if stored && !expired?(stored)
+        return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
+
+        authorize
+      end
+
+      private
+
+      def expired?(stored)
+        return false unless stored[:expires_at]
+
+        Time.now.to_i >= stored[:expires_at] - 60
+      end
+
+      def authorize
+        metadata = discover_auth_server
+        client = load_or_register_client(metadata)
+        verifier, challenge = generate_pkce
+        state = SecureRandom.hex(16)
+
+        auth_url = build_auth_url(metadata, client, challenge, state)
+        open_browser(auth_url)
+
+        code = wait_for_callback(state)
+        tokens = exchange_code(metadata, client, code, verifier)
+
+        @storage.save(
+          @resource_url,
+          access_token: tokens["access_token"],
+          refresh_token: tokens["refresh_token"],
+          expires_in: tokens["expires_in"]
+        )
+
+        tokens["access_token"]
+      end
+
+      def refresh(refresh_token)
+        metadata = discover_auth_server
+        client = load_or_register_client(metadata)
+        uri = URI.parse(metadata["token_endpoint"])
+
+        body = {
+          grant_type: "refresh_token",
+          refresh_token: refresh_token,
+          client_id: client[:client_id]
+        }
+
+        response = post_form(uri, body)
+        tokens = JSON.parse(response.body)
+
+        @storage.save(
+          @resource_url,
+          access_token: tokens["access_token"],
+          refresh_token: tokens["refresh_token"] || refresh_token,
+          expires_in: tokens["expires_in"]
+        )
+
+        tokens["access_token"]
+      rescue StandardError => e
+        warn "Token refresh failed: #{e.message}"
+        authorize
+      end
+
+      def discover_auth_server
+        resource_uri = URI.parse(@resource_url)
+        metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
+
+        resource_metadata = fetch_json(metadata_url)
+        auth_server_url = resource_metadata["authorization_servers"]&.first
+        raise "No authorization server found" unless auth_server_url
+
+        auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
+        fetch_json(auth_metadata_url)
+      end
+
+      def load_or_register_client(metadata)
+        stored = @storage.load_client(@resource_url)
+        return stored if stored
+
+        client = register_client(metadata)
+        @storage.save_client(@resource_url, client)
+        @storage.load_client(@resource_url)
+      end
+
+      def register_client(metadata)
+        endpoint = metadata["registration_endpoint"]
+        raise "Dynamic registration not supported" unless endpoint
+
+        body = {
+          client_name: "elelem",
+          redirect_uris: [REDIRECT_URI],
+          grant_types: %w[authorization_code refresh_token],
+          response_types: ["code"],
+          token_endpoint_auth_method: "none"
+        }
+
+        response = post_json(endpoint, body)
+        JSON.parse(response.body)
+      end
+
+      def generate_pkce
+        verifier = SecureRandom.urlsafe_base64(32)
+        challenge = Base64.urlsafe_encode64(
+          Digest::SHA256.digest(verifier),
+          padding: false
+        )
+        [verifier, challenge]
+      end
+
+      def build_auth_url(metadata, client, challenge, state)
+        params = {
+          response_type: "code",
+          client_id: client[:client_id],
+          redirect_uri: REDIRECT_URI,
+          scope: metadata["scopes_supported"]&.join(" ") || "openid",
+          state: state,
+          code_challenge: challenge,
+          code_challenge_method: "S256"
+        }
+
+        "#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
+      end
+
+      def open_browser(url)
+        commands = ["xdg-open", "open", "start"]
+        commands.each do |cmd|
+          return if system(cmd, url, out: File::NULL, err: File::NULL)
+        end
+        warn "Open this URL in your browser: #{url}"
+      end
+
+      def wait_for_callback(expected_state)
+        code = nil
+        @server = WEBrick::HTTPServer.new(
+          Port: CALLBACK_PORT,
+          Logger: WEBrick::Log.new(File::NULL),
+          AccessLog: []
+        )
+
+        at_exit { @server&.shutdown }
+
+        @server.mount_proc("/callback") do |req, res|
+          state = req.query["state"]
+          raise "State mismatch" unless state == expected_state
+
+          code = req.query["code"]
+          res.content_type = "text/html"
+          res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
+          @server.shutdown
+        end
+
+        Timeout.timeout(120) { @server.start }
+        code
+      rescue Timeout::Error
+        @server.shutdown
+        raise "OAuth callback timed out"
+      end
+
+      def exchange_code(metadata, client, code, verifier)
+        uri = URI.parse(metadata["token_endpoint"])
+
+        body = {
+          grant_type: "authorization_code",
+          code: code,
+          redirect_uri: REDIRECT_URI,
+          client_id: client[:client_id],
+          code_verifier: verifier
+        }
+
+        response = post_form(uri, body)
+        JSON.parse(response.body)
+      end
+
+      def fetch_json(url)
+        response = nil
+        @http.get(url) { |r| response = r }
+        JSON.parse(response.body)
+      end
+
+      def post_json(url, body)
+        response = nil
+        @http.post(
+          url,
+          headers: { "Content-Type" => "application/json" },
+          body: body.to_json
+        ) { |r| response = r }
+        response
+      end
+
+      def post_form(uri, body)
+        response = nil
+        @http.post(
+          uri.to_s,
+          headers: { "Content-Type" => "application/x-www-form-urlencoded" },
+          body: URI.encode_www_form(body)
+        ) { |r| response = r }
+        response
+      end
+
+      class HttpAdapter
+        def get(url)
+          uri = URI.parse(url)
+          Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |conn|
+            yield conn.get(uri.request_uri)
+          end
+        end
+
+        def post(url, headers: {}, body: nil)
+          uri = URI.parse(url)
+          request = Net::HTTP::Post.new(uri)
+          headers.each { |k, v| request[k] = v }
+          request.body = body
+
+          Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |conn|
+            yield conn.request(request)
+          end
+        end
+      end
+    end
+  end
+end
lib/elelem/mcp/version.rb
@@ -2,6 +2,6 @@
 
 module Elelem
   module MCP
-    VERSION = "0.1.0"
+    VERSION = "0.2.0"
   end
 end
lib/elelem/mcp.rb
@@ -1,33 +1,40 @@
 # frozen_string_literal: true
 
+require "base64"
 require "digest"
 require "elelem"
 require "fileutils"
 require "json"
 require "net/http"
 require "open3"
+require "securerandom"
+require "timeout"
 require "uri"
+require "webrick"
 
 require_relative "mcp/version"
 require_relative "mcp/token_storage"
+require_relative "mcp/oauth"
 require_relative "mcp/client"
 
-Elelem::Plugins.register(:mcp) do |agent|
-  mcp = Elelem::MCP::Client.memoized
+Elelem.configure do |config|
+  config.setup(:mcp) do |agent|
+    mcp = Elelem::MCP::Client.memoized
 
-  Thread.new do
-    mcp.tools.each do |name, tool|
-      next if agent.toolbox.tools.key?(name)
+    Thread.new do
+      mcp.tools.each do |name, tool|
+        next if agent.toolbox.tools.key?(name)
 
-      agent.toolbox.add(
-        name,
-        description: tool[:description],
-        params: tool[:params],
-        required: tool[:required],
-        &tool[:fn]
-      )
+        agent.toolbox.add(
+          name,
+          description: tool[:description],
+          params: tool[:params],
+          required: tool[:required],
+          &tool[:fn]
+        )
+      end
+    rescue => e
+      warn "MCP failed: #{e.message}"
     end
-  rescue => e
-    warn "MCP failed: #{e.message}"
   end
 end
spec/elelem/mcp/http_server_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::HttpServer do
+  let(:url) { "https://mcp.example.com/sse" }
+  let(:http) { double("http") }
+
+  describe "#parse_sse" do
+    subject { described_class.allocate }
+
+    it "parses single SSE event" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("data: {\"result\": \"ok\"}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "result" => "ok" })
+    end
+
+    it "parses chunked SSE events" do
+      response = double
+      chunks = ["data: {\"id\"", ": 1}\n\ndata: {\"id\": 2}\n\n"]
+      allow(response).to receive(:read_body) do |&block|
+        chunks.each { |c| block.call(c) }
+      end
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "id" => 2 })
+    end
+
+    it "ignores non-data lines" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("event: message\ndata: {\"value\": 42}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "value" => 42 })
+    end
+
+    it "returns last event when multiple present" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("data: {\"n\": 1}\n\ndata: {\"n\": 2}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "n" => 2 })
+    end
+  end
+
+  describe "#parse_response" do
+    subject { described_class.allocate }
+
+    it "parses JSON response" do
+      response = double(content_type: "application/json", body: '{"tools": []}')
+      result = subject.send(:parse_response, response)
+      expect(result).to eq({ "tools" => [] })
+    end
+
+    it "parses SSE response" do
+      response = double(content_type: "text/event-stream")
+      allow(response).to receive(:read_body).and_yield("data: {\"ok\": true}\n\n")
+
+      result = subject.send(:parse_response, response)
+      expect(result).to eq({ "ok" => true })
+    end
+
+    it "returns nil for empty body" do
+      response = double(content_type: "application/json", body: "")
+      result = subject.send(:parse_response, response)
+      expect(result).to be_nil
+    end
+  end
+
+  describe "#request_headers" do
+    subject do
+      server = described_class.allocate
+      server.instance_variable_set(:@headers, {})
+      server.instance_variable_set(:@session_id, nil)
+      server.instance_variable_set(:@access_token, nil)
+      server
+    end
+
+    it "includes Accept header" do
+      headers = subject.send(:request_headers)
+      expect(headers["Accept"]).to eq("application/json, text/event-stream")
+    end
+
+    it "includes session ID when set" do
+      subject.instance_variable_set(:@session_id, "abc123")
+      headers = subject.send(:request_headers)
+      expect(headers["Mcp-Session-Id"]).to eq("abc123")
+    end
+
+    it "includes authorization when access_token set" do
+      subject.instance_variable_set(:@access_token, "token123")
+      headers = subject.send(:request_headers)
+      expect(headers["Authorization"]).to eq("Bearer token123")
+    end
+  end
+end
spec/elelem/mcp/oauth_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::OAuth do
+  let(:resource_url) { "https://mcp.example.com/sse" }
+  let(:http) { double("http") }
+  let(:storage) { instance_double(Elelem::MCP::TokenStorage) }
+
+  subject { described_class.new(resource_url, http: http) }
+
+  before do
+    allow(Elelem::MCP::TokenStorage).to receive(:new).and_return(storage)
+  end
+
+  describe "#token" do
+    context "with valid cached token" do
+      it "returns cached access_token" do
+        allow(storage).to receive(:load).with(resource_url).and_return({
+          access_token: "cached_token",
+          expires_at: Time.now.to_i + 3600
+        })
+
+        expect(subject.token).to eq("cached_token")
+      end
+    end
+
+    context "with expired token and refresh_token" do
+      let(:auth_metadata) do
+        {
+          "authorization_endpoint" => "https://auth.example.com/authorize",
+          "token_endpoint" => "https://auth.example.com/token",
+          "registration_endpoint" => "https://auth.example.com/register"
+        }
+      end
+
+      it "refreshes using refresh_token" do
+        allow(storage).to receive(:load).with(resource_url).and_return({
+          access_token: "old_token",
+          refresh_token: "refresh_abc",
+          expires_at: Time.now.to_i - 100
+        })
+
+        allow(storage).to receive(:load_client).with(resource_url).and_return({
+          client_id: "elelem-123"
+        })
+
+        resource_response = double(body: { "authorization_servers" => ["https://auth.example.com"] }.to_json)
+        auth_response = double(body: auth_metadata.to_json)
+        token_response = double(body: { "access_token" => "new_token", "expires_in" => 3600 }.to_json)
+
+        allow(http).to receive(:get).with("https://mcp.example.com/.well-known/oauth-protected-resource").and_yield(resource_response)
+        allow(http).to receive(:get).with("https://auth.example.com/.well-known/oauth-authorization-server").and_yield(auth_response)
+        allow(http).to receive(:post).and_yield(token_response)
+        allow(storage).to receive(:save)
+
+        expect(subject.token).to eq("new_token")
+      end
+    end
+  end
+
+  describe "PKCE generation" do
+    it "generates valid verifier and challenge" do
+      verifier, challenge = subject.send(:generate_pkce)
+
+      expect(verifier.length).to be >= 43
+      expect(challenge.length).to be >= 43
+      expect(challenge).not_to include("+", "/", "=")
+
+      expected_challenge = Base64.urlsafe_encode64(
+        Digest::SHA256.digest(verifier),
+        padding: false
+      )
+      expect(challenge).to eq(expected_challenge)
+    end
+  end
+
+  describe "expiration check" do
+    it "considers token expired when within 60 seconds of expiry" do
+      stored = { expires_at: Time.now.to_i + 30 }
+      expect(subject.send(:expired?, stored)).to be true
+    end
+
+    it "considers token valid when more than 60 seconds remain" do
+      stored = { expires_at: Time.now.to_i + 120 }
+      expect(subject.send(:expired?, stored)).to be false
+    end
+
+    it "considers token valid when no expires_at" do
+      stored = { expires_at: nil }
+      expect(subject.send(:expired?, stored)).to be false
+    end
+  end
+end
spec/elelem/mcp/token_storage_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::TokenStorage do
+  subject { described_class.new }
+
+  let(:resource_url) { "https://example.com/mcp" }
+  let(:token_dir) { File.expand_path("~/.config/elelem/tokens") }
+
+  after do
+    Dir.glob(File.join(token_dir, "*.json")).each { |f| File.delete(f) rescue nil }
+  end
+
+  describe "#save and #load" do
+    it "stores and retrieves tokens" do
+      subject.save(resource_url, access_token: "abc123", refresh_token: "refresh456", expires_in: 3600)
+
+      stored = subject.load(resource_url)
+      expect(stored[:access_token]).to eq("abc123")
+      expect(stored[:refresh_token]).to eq("refresh456")
+      expect(stored[:expires_at]).to be > Time.now.to_i
+    end
+
+    it "returns nil for unknown resource" do
+      expect(subject.load("https://unknown.com")).to be_nil
+    end
+
+    it "handles missing refresh_token" do
+      subject.save(resource_url, access_token: "abc123")
+
+      stored = subject.load(resource_url)
+      expect(stored[:access_token]).to eq("abc123")
+      expect(stored[:refresh_token]).to be_nil
+    end
+  end
+
+  describe "#save_client and #load_client" do
+    it "stores and retrieves client registration" do
+      client_data = { "client_id" => "elelem-123", "client_secret" => nil }
+      subject.save_client(resource_url, client_data)
+
+      stored = subject.load_client(resource_url)
+      expect(stored[:client_id]).to eq("elelem-123")
+    end
+
+    it "returns nil for unknown client" do
+      expect(subject.load_client("https://unknown.com")).to be_nil
+    end
+  end
+
+  describe "file permissions" do
+    it "creates token files with 0600 permissions" do
+      subject.save(resource_url, access_token: "secret")
+
+      files = Dir.glob(File.join(token_dir, "*.json"))
+      expect(files).not_to be_empty
+      files.each do |f|
+        mode = File.stat(f).mode & 0o777
+        expect(mode).to eq(0o600)
+      end
+    end
+  end
+end
spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require_relative "../lib/elelem/mcp"
+
+RSpec.configure do |config|
+  config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+end
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper
elelem-mcp.gemspec
@@ -25,11 +25,15 @@ Gem::Specification.new do |spec|
   end
   spec.require_paths = ["lib"]
 
+  spec.add_dependency "base64", "~> 0.3"
   spec.add_dependency "digest", "~> 3.2"
   spec.add_dependency "elelem", "~> 0.1"
   spec.add_dependency "fileutils", "~> 1.8"
-  spec.add_dependency "json", "~> 2.21"
+  spec.add_dependency "json", "~> 3.0"
   spec.add_dependency "net-http", "~> 0.9"
   spec.add_dependency "open3", "~> 0.2"
+  spec.add_dependency "securerandom", "~> 0.4"
+  spec.add_dependency "timeout", "~> 0.6"
   spec.add_dependency "uri", "~> 1.1"
+  spec.add_dependency "webrick", "~> 1.9"
 end
Gemfile
@@ -6,3 +6,4 @@ gemspec name: "elelem-mcp"
 
 gem "irb"
 gem "rake", "~> 13.0"
+gem "rspec", "~> 3.0"
Gemfile.lock
@@ -0,0 +1,147 @@
+PATH
+  remote: .
+  specs:
+    elelem-mcp (0.2.0)
+      base64 (~> 0.3)
+      digest (~> 3.2)
+      elelem (~> 0.1)
+      fileutils (~> 1.8)
+      json (~> 3.0)
+      net-http (~> 0.9)
+      open3 (~> 0.2)
+      securerandom (~> 0.4)
+      timeout (~> 0.6)
+      uri (~> 1.1)
+      webrick (~> 1.9)
+
+GEM
+  remote: https://rubygems.org/
+  specs:
+    base64 (0.3.0)
+    bigdecimal (4.1.2)
+    diff-lcs (1.6.2)
+    digest (3.2.1)
+    elelem (0.13.0)
+      erb (~> 6.0)
+      io-console (~> 0.9)
+      json (~> 3.0)
+      json_schemer (~> 2.5)
+      logger (~> 1.7)
+      open3 (~> 0.2)
+      optparse (~> 0.8)
+      pathname (~> 0.5)
+      reline (~> 0.7)
+      shellwords (~> 0.2)
+      uri (~> 1.0)
+    erb (6.0.7)
+    fileutils (1.8.0)
+    hana (1.3.7)
+    io-console (0.9.2)
+    irb (1.18.0)
+      pp (>= 0.6.0)
+      prism (>= 1.3.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    json (3.0.0)
+    json_schemer (2.5.0)
+      bigdecimal
+      hana (~> 1.3)
+      regexp_parser (~> 2.0)
+      simpleidn (~> 0.2)
+    logger (1.7.0)
+    net-http (0.9.1)
+      uri (>= 0.11.1)
+    open3 (0.2.1)
+    optparse (0.8.1)
+    pathname (0.5.0)
+    pp (0.6.4)
+      prettyprint
+    prettyprint (0.2.0)
+    prism (1.9.0)
+    rake (13.4.2)
+    rbs (4.2.0)
+      logger
+      prism (>= 1.6.0)
+      tsort
+    rdoc (8.0.0)
+      erb
+      prism (>= 1.6.0)
+      rbs (>= 4.0.0)
+      tsort
+    regexp_parser (2.12.0)
+    reline (0.7.0)
+      io-console (~> 0.5)
+    rspec (3.13.2)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    rspec-core (3.13.6)
+      rspec-support (~> 3.13.0)
+    rspec-expectations (3.13.5)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-mocks (3.13.8)
+      diff-lcs (>= 1.2.0, < 2.0)
+      rspec-support (~> 3.13.0)
+    rspec-support (3.13.7)
+    securerandom (0.4.1)
+    shellwords (0.2.2)
+    simpleidn (0.3.0)
+    timeout (0.6.1)
+    tsort (0.2.0)
+    uri (1.1.1)
+    webrick (1.9.2)
+
+PLATFORMS
+  ruby
+  x86_64-linux
+
+DEPENDENCIES
+  elelem-mcp!
+  irb
+  rake (~> 13.0)
+  rspec (~> 3.0)
+
+CHECKSUMS
+  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+  bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
+  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
+  digest (3.2.1) sha256=ab3312b4e272d7d5dc41c564c86a25861a1f34ac5153374199a0b74861395947
+  elelem (0.13.0)
+  elelem-mcp (0.2.0)
+  erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+  fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+  hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
+  io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
+  json (3.0.0) sha256=1ff82a28c05c5cc7b646f3e3a3ac710e4ae2aa933690cba12f1f650b0ecdeb99
+  json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
+  open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
+  optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+  pathname (0.5.0) sha256=d5a331784f6e1f2fefb31c2ff0b8855aabfb661d807284ead9fa47b883d81623
+  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+  rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+  rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
+  reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
+  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
+  securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+  shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
+  simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
+  timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
+  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+  webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
+
+BUNDLED WITH
+  4.0.20
Rakefile
@@ -1,4 +1,8 @@
 # frozen_string_literal: true
 
 require "bundler/gem_tasks"
-task default: %i[]
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task default: %i[spec]