Commit a107d9d
Changed files (5)
lib
elelem
spec
lib/elelem/mcp.rb
@@ -82,7 +82,7 @@ module Elelem
end
def logger
- @logger ||= Logger.new("mcp.log")
+ @logger ||= Logger.new(File.expand_path("~/.elelem/mcp.log"))
end
private
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/elelem/toolbox_spec.rb
@@ -60,4 +60,43 @@ RSpec.describe Elelem::Toolbox do
expect(result[:output]).to include("a")
end
end
+
+ describe "hooks" do
+ it "runs tool-specific before hooks" do
+ called = false
+ subject.before("read") { |_args| called = true }
+ subject.run("read", { "path" => __FILE__ })
+ expect(called).to be true
+ end
+
+ it "runs tool-specific after hooks" do
+ result_seen = nil
+ subject.after("read") { |_args, result| result_seen = result }
+ subject.run("read", { "path" => __FILE__ })
+ expect(result_seen[:content]).to include("RSpec.describe")
+ end
+
+ it "runs global before hooks with tool_name" do
+ tool_names = []
+ subject.before { |_args, tool_name:| tool_names << tool_name }
+ subject.run("read", { "path" => __FILE__ })
+ subject.run("write", { "path" => "/dev/null", "content" => "" })
+ expect(tool_names).to eq(["read", "write"])
+ end
+
+ it "runs global after hooks with tool_name" do
+ tool_names = []
+ subject.after { |_args, _result, tool_name:| tool_names << tool_name }
+ subject.run("read", { "path" => __FILE__ })
+ expect(tool_names).to eq(["read"])
+ end
+
+ it "runs global hooks before tool-specific hooks" do
+ order = []
+ subject.before { |_args, tool_name:| order << :global }
+ subject.before("read") { |_args| order << :specific }
+ subject.run("read", { "path" => __FILE__ })
+ expect(order).to eq([:global, :specific])
+ end
+ end
end