elelem-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec +1 -0
- data/Rakefile +5 -1
- data/lib/elelem/mcp/client.rb +12 -3
- data/lib/elelem/mcp/oauth.rb +237 -0
- data/lib/elelem/mcp/version.rb +1 -1
- data/lib/elelem/mcp.rb +21 -14
- data/spec/elelem/mcp/http_server_spec.rb +96 -0
- data/spec/elelem/mcp/oauth_spec.rb +92 -0
- data/spec/elelem/mcp/token_storage_spec.rb +62 -0
- data/spec/spec_helper.rb +11 -0
- metadata +65 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 165e65bad3ab284e5049d9cffba0041be3f5c315253d48090d6dd7e754a4f715
|
|
4
|
+
data.tar.gz: c96c690ff8008695fa8f1eb19a4aa4202d7543a622340d813b3d2a257d2adad3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 65822b02aa43aec269f68811654cd566baecf9629adb95e15c5ef934228f95c20a54d148cba59af8754ecc19e33a42e66543797dff149e9c5780d88a97065b35
|
|
7
|
+
data.tar.gz: 7d2c03dac26fc843b9f8ea8734159de93509c81963528baee425810e440a51787db1d2f24cdb21574beb82e13d31114d8df2d5e2ca0cbcfafe885b0c648c60ba
|
data/.rspec
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--require spec_helper
|
data/Rakefile
CHANGED
data/lib/elelem/mcp/client.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Elelem
|
|
|
25
25
|
"#{name}_#{tool["name"]}",
|
|
26
26
|
{
|
|
27
27
|
description: tool["description"],
|
|
28
|
-
params: tool.dig("inputSchema", "properties") || {},
|
|
28
|
+
params: symbolize(tool.dig("inputSchema", "properties") || {}),
|
|
29
29
|
required: tool.dig("inputSchema", "required") || [],
|
|
30
30
|
fn: ->(a) { server(name).call(tool["name"], a) }
|
|
31
31
|
}
|
|
@@ -40,6 +40,10 @@ module Elelem
|
|
|
40
40
|
|
|
41
41
|
private
|
|
42
42
|
|
|
43
|
+
def symbolize(hash)
|
|
44
|
+
JSON.parse(JSON.generate(hash), symbolize_names: true)
|
|
45
|
+
end
|
|
46
|
+
|
|
43
47
|
def load_config(configurations)
|
|
44
48
|
configurations.each_with_object({}) do |path, merged|
|
|
45
49
|
file = File.expand_path(path)
|
|
@@ -157,6 +161,7 @@ module Elelem
|
|
|
157
161
|
@open_timeout = open_timeout
|
|
158
162
|
@id = 0
|
|
159
163
|
@session_id = nil
|
|
164
|
+
@access_token = nil
|
|
160
165
|
handshake!
|
|
161
166
|
end
|
|
162
167
|
|
|
@@ -185,7 +190,7 @@ module Elelem
|
|
|
185
190
|
post(msg)
|
|
186
191
|
end
|
|
187
192
|
|
|
188
|
-
def post(msg)
|
|
193
|
+
def post(msg, retry_auth: true)
|
|
189
194
|
request = Net::HTTP::Post.new(@uri)
|
|
190
195
|
request["content-type"] = "application/json"
|
|
191
196
|
request_headers.each { |k, v| request[k] = v }
|
|
@@ -204,7 +209,10 @@ module Elelem
|
|
|
204
209
|
return parse_response(response)
|
|
205
210
|
when Net::HTTPUnauthorized
|
|
206
211
|
response.body
|
|
207
|
-
raise "
|
|
212
|
+
raise "Authorization failed" unless retry_auth
|
|
213
|
+
|
|
214
|
+
@access_token = OAuth.new(@uri.to_s).token
|
|
215
|
+
return post(msg, retry_auth: false)
|
|
208
216
|
else
|
|
209
217
|
raise "HTTP #{response.code}: #{response.body}"
|
|
210
218
|
end
|
|
@@ -215,6 +223,7 @@ module Elelem
|
|
|
215
223
|
def request_headers
|
|
216
224
|
base = { "Accept" => "application/json, text/event-stream" }
|
|
217
225
|
base["Mcp-Session-Id"] = @session_id if @session_id
|
|
226
|
+
base["Authorization"] = "Bearer #{@access_token}" if @access_token
|
|
218
227
|
@headers.merge(base)
|
|
219
228
|
end
|
|
220
229
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module MCP
|
|
5
|
+
class OAuth
|
|
6
|
+
CALLBACK_PORT = 18273
|
|
7
|
+
REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
|
|
8
|
+
|
|
9
|
+
def initialize(resource_url, http: HttpAdapter.new)
|
|
10
|
+
@resource_url = resource_url
|
|
11
|
+
@http = http
|
|
12
|
+
@storage = TokenStorage.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def token
|
|
16
|
+
stored = @storage.load(@resource_url)
|
|
17
|
+
return stored[:access_token] if stored && !expired?(stored)
|
|
18
|
+
return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
|
|
19
|
+
|
|
20
|
+
authorize
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def expired?(stored)
|
|
26
|
+
return false unless stored[:expires_at]
|
|
27
|
+
|
|
28
|
+
Time.now.to_i >= stored[:expires_at] - 60
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def authorize
|
|
32
|
+
metadata = discover_auth_server
|
|
33
|
+
client = load_or_register_client(metadata)
|
|
34
|
+
verifier, challenge = generate_pkce
|
|
35
|
+
state = SecureRandom.hex(16)
|
|
36
|
+
|
|
37
|
+
auth_url = build_auth_url(metadata, client, challenge, state)
|
|
38
|
+
open_browser(auth_url)
|
|
39
|
+
|
|
40
|
+
code = wait_for_callback(state)
|
|
41
|
+
tokens = exchange_code(metadata, client, code, verifier)
|
|
42
|
+
|
|
43
|
+
@storage.save(
|
|
44
|
+
@resource_url,
|
|
45
|
+
access_token: tokens["access_token"],
|
|
46
|
+
refresh_token: tokens["refresh_token"],
|
|
47
|
+
expires_in: tokens["expires_in"]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
tokens["access_token"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def refresh(refresh_token)
|
|
54
|
+
metadata = discover_auth_server
|
|
55
|
+
client = load_or_register_client(metadata)
|
|
56
|
+
uri = URI.parse(metadata["token_endpoint"])
|
|
57
|
+
|
|
58
|
+
body = {
|
|
59
|
+
grant_type: "refresh_token",
|
|
60
|
+
refresh_token: refresh_token,
|
|
61
|
+
client_id: client[:client_id]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
response = post_form(uri, body)
|
|
65
|
+
tokens = JSON.parse(response.body)
|
|
66
|
+
|
|
67
|
+
@storage.save(
|
|
68
|
+
@resource_url,
|
|
69
|
+
access_token: tokens["access_token"],
|
|
70
|
+
refresh_token: tokens["refresh_token"] || refresh_token,
|
|
71
|
+
expires_in: tokens["expires_in"]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
tokens["access_token"]
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
warn "Token refresh failed: #{e.message}"
|
|
77
|
+
authorize
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def discover_auth_server
|
|
81
|
+
resource_uri = URI.parse(@resource_url)
|
|
82
|
+
metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
|
|
83
|
+
|
|
84
|
+
resource_metadata = fetch_json(metadata_url)
|
|
85
|
+
auth_server_url = resource_metadata["authorization_servers"]&.first
|
|
86
|
+
raise "No authorization server found" unless auth_server_url
|
|
87
|
+
|
|
88
|
+
auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
|
|
89
|
+
fetch_json(auth_metadata_url)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def load_or_register_client(metadata)
|
|
93
|
+
stored = @storage.load_client(@resource_url)
|
|
94
|
+
return stored if stored
|
|
95
|
+
|
|
96
|
+
client = register_client(metadata)
|
|
97
|
+
@storage.save_client(@resource_url, client)
|
|
98
|
+
@storage.load_client(@resource_url)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def register_client(metadata)
|
|
102
|
+
endpoint = metadata["registration_endpoint"]
|
|
103
|
+
raise "Dynamic registration not supported" unless endpoint
|
|
104
|
+
|
|
105
|
+
body = {
|
|
106
|
+
client_name: "elelem",
|
|
107
|
+
redirect_uris: [REDIRECT_URI],
|
|
108
|
+
grant_types: %w[authorization_code refresh_token],
|
|
109
|
+
response_types: ["code"],
|
|
110
|
+
token_endpoint_auth_method: "none"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
response = post_json(endpoint, body)
|
|
114
|
+
JSON.parse(response.body)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def generate_pkce
|
|
118
|
+
verifier = SecureRandom.urlsafe_base64(32)
|
|
119
|
+
challenge = Base64.urlsafe_encode64(
|
|
120
|
+
Digest::SHA256.digest(verifier),
|
|
121
|
+
padding: false
|
|
122
|
+
)
|
|
123
|
+
[verifier, challenge]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_auth_url(metadata, client, challenge, state)
|
|
127
|
+
params = {
|
|
128
|
+
response_type: "code",
|
|
129
|
+
client_id: client[:client_id],
|
|
130
|
+
redirect_uri: REDIRECT_URI,
|
|
131
|
+
scope: metadata["scopes_supported"]&.join(" ") || "openid",
|
|
132
|
+
state: state,
|
|
133
|
+
code_challenge: challenge,
|
|
134
|
+
code_challenge_method: "S256"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
"#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def open_browser(url)
|
|
141
|
+
commands = ["xdg-open", "open", "start"]
|
|
142
|
+
commands.each do |cmd|
|
|
143
|
+
return if system(cmd, url, out: File::NULL, err: File::NULL)
|
|
144
|
+
end
|
|
145
|
+
warn "Open this URL in your browser: #{url}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def wait_for_callback(expected_state)
|
|
149
|
+
code = nil
|
|
150
|
+
@server = WEBrick::HTTPServer.new(
|
|
151
|
+
Port: CALLBACK_PORT,
|
|
152
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
153
|
+
AccessLog: []
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
at_exit { @server&.shutdown }
|
|
157
|
+
|
|
158
|
+
@server.mount_proc("/callback") do |req, res|
|
|
159
|
+
state = req.query["state"]
|
|
160
|
+
raise "State mismatch" unless state == expected_state
|
|
161
|
+
|
|
162
|
+
code = req.query["code"]
|
|
163
|
+
res.content_type = "text/html"
|
|
164
|
+
res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
|
|
165
|
+
@server.shutdown
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Timeout.timeout(120) { @server.start }
|
|
169
|
+
code
|
|
170
|
+
rescue Timeout::Error
|
|
171
|
+
@server.shutdown
|
|
172
|
+
raise "OAuth callback timed out"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def exchange_code(metadata, client, code, verifier)
|
|
176
|
+
uri = URI.parse(metadata["token_endpoint"])
|
|
177
|
+
|
|
178
|
+
body = {
|
|
179
|
+
grant_type: "authorization_code",
|
|
180
|
+
code: code,
|
|
181
|
+
redirect_uri: REDIRECT_URI,
|
|
182
|
+
client_id: client[:client_id],
|
|
183
|
+
code_verifier: verifier
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
response = post_form(uri, body)
|
|
187
|
+
JSON.parse(response.body)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def fetch_json(url)
|
|
191
|
+
response = nil
|
|
192
|
+
@http.get(url) { |r| response = r }
|
|
193
|
+
JSON.parse(response.body)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def post_json(url, body)
|
|
197
|
+
response = nil
|
|
198
|
+
@http.post(
|
|
199
|
+
url,
|
|
200
|
+
headers: { "Content-Type" => "application/json" },
|
|
201
|
+
body: body.to_json
|
|
202
|
+
) { |r| response = r }
|
|
203
|
+
response
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def post_form(uri, body)
|
|
207
|
+
response = nil
|
|
208
|
+
@http.post(
|
|
209
|
+
uri.to_s,
|
|
210
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
211
|
+
body: URI.encode_www_form(body)
|
|
212
|
+
) { |r| response = r }
|
|
213
|
+
response
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
class HttpAdapter
|
|
217
|
+
def get(url)
|
|
218
|
+
uri = URI.parse(url)
|
|
219
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |conn|
|
|
220
|
+
yield conn.get(uri.request_uri)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def post(url, headers: {}, body: nil)
|
|
225
|
+
uri = URI.parse(url)
|
|
226
|
+
request = Net::HTTP::Post.new(uri)
|
|
227
|
+
headers.each { |k, v| request[k] = v }
|
|
228
|
+
request.body = body
|
|
229
|
+
|
|
230
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |conn|
|
|
231
|
+
yield conn.request(request)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
data/lib/elelem/mcp/version.rb
CHANGED
data/lib/elelem/mcp.rb
CHANGED
|
@@ -1,33 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
3
4
|
require "digest"
|
|
4
5
|
require "elelem"
|
|
5
6
|
require "fileutils"
|
|
6
7
|
require "json"
|
|
7
8
|
require "net/http"
|
|
8
9
|
require "open3"
|
|
10
|
+
require "securerandom"
|
|
11
|
+
require "timeout"
|
|
9
12
|
require "uri"
|
|
13
|
+
require "webrick"
|
|
10
14
|
|
|
11
15
|
require_relative "mcp/version"
|
|
12
16
|
require_relative "mcp/token_storage"
|
|
17
|
+
require_relative "mcp/oauth"
|
|
13
18
|
require_relative "mcp/client"
|
|
14
19
|
|
|
15
|
-
Elelem
|
|
16
|
-
mcp
|
|
20
|
+
Elelem.configure do |config|
|
|
21
|
+
config.setup(:mcp) do |agent|
|
|
22
|
+
mcp = Elelem::MCP::Client.memoized
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
Thread.new do
|
|
25
|
+
mcp.tools.each do |name, tool|
|
|
26
|
+
next if agent.toolbox.tools.key?(name)
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
agent.toolbox.add(
|
|
29
|
+
name,
|
|
30
|
+
description: tool[:description],
|
|
31
|
+
params: tool[:params],
|
|
32
|
+
required: tool[:required],
|
|
33
|
+
&tool[:fn]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
rescue => e
|
|
37
|
+
warn "MCP failed: #{e.message}"
|
|
29
38
|
end
|
|
30
|
-
rescue => e
|
|
31
|
-
warn "MCP failed: #{e.message}"
|
|
32
39
|
end
|
|
33
40
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Elelem::MCP::HttpServer do
|
|
4
|
+
let(:url) { "https://mcp.example.com/sse" }
|
|
5
|
+
let(:http) { double("http") }
|
|
6
|
+
|
|
7
|
+
describe "#parse_sse" do
|
|
8
|
+
subject { described_class.allocate }
|
|
9
|
+
|
|
10
|
+
it "parses single SSE event" do
|
|
11
|
+
response = double
|
|
12
|
+
allow(response).to receive(:read_body).and_yield("data: {\"result\": \"ok\"}\n\n")
|
|
13
|
+
|
|
14
|
+
result = subject.send(:parse_sse, response)
|
|
15
|
+
expect(result).to eq({ "result" => "ok" })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "parses chunked SSE events" do
|
|
19
|
+
response = double
|
|
20
|
+
chunks = ["data: {\"id\"", ": 1}\n\ndata: {\"id\": 2}\n\n"]
|
|
21
|
+
allow(response).to receive(:read_body) do |&block|
|
|
22
|
+
chunks.each { |c| block.call(c) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
result = subject.send(:parse_sse, response)
|
|
26
|
+
expect(result).to eq({ "id" => 2 })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "ignores non-data lines" do
|
|
30
|
+
response = double
|
|
31
|
+
allow(response).to receive(:read_body).and_yield("event: message\ndata: {\"value\": 42}\n\n")
|
|
32
|
+
|
|
33
|
+
result = subject.send(:parse_sse, response)
|
|
34
|
+
expect(result).to eq({ "value" => 42 })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "returns last event when multiple present" do
|
|
38
|
+
response = double
|
|
39
|
+
allow(response).to receive(:read_body).and_yield("data: {\"n\": 1}\n\ndata: {\"n\": 2}\n\n")
|
|
40
|
+
|
|
41
|
+
result = subject.send(:parse_sse, response)
|
|
42
|
+
expect(result).to eq({ "n" => 2 })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe "#parse_response" do
|
|
47
|
+
subject { described_class.allocate }
|
|
48
|
+
|
|
49
|
+
it "parses JSON response" do
|
|
50
|
+
response = double(content_type: "application/json", body: '{"tools": []}')
|
|
51
|
+
result = subject.send(:parse_response, response)
|
|
52
|
+
expect(result).to eq({ "tools" => [] })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "parses SSE response" do
|
|
56
|
+
response = double(content_type: "text/event-stream")
|
|
57
|
+
allow(response).to receive(:read_body).and_yield("data: {\"ok\": true}\n\n")
|
|
58
|
+
|
|
59
|
+
result = subject.send(:parse_response, response)
|
|
60
|
+
expect(result).to eq({ "ok" => true })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "returns nil for empty body" do
|
|
64
|
+
response = double(content_type: "application/json", body: "")
|
|
65
|
+
result = subject.send(:parse_response, response)
|
|
66
|
+
expect(result).to be_nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe "#request_headers" do
|
|
71
|
+
subject do
|
|
72
|
+
server = described_class.allocate
|
|
73
|
+
server.instance_variable_set(:@headers, {})
|
|
74
|
+
server.instance_variable_set(:@session_id, nil)
|
|
75
|
+
server.instance_variable_set(:@access_token, nil)
|
|
76
|
+
server
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "includes Accept header" do
|
|
80
|
+
headers = subject.send(:request_headers)
|
|
81
|
+
expect(headers["Accept"]).to eq("application/json, text/event-stream")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "includes session ID when set" do
|
|
85
|
+
subject.instance_variable_set(:@session_id, "abc123")
|
|
86
|
+
headers = subject.send(:request_headers)
|
|
87
|
+
expect(headers["Mcp-Session-Id"]).to eq("abc123")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "includes authorization when access_token set" do
|
|
91
|
+
subject.instance_variable_set(:@access_token, "token123")
|
|
92
|
+
headers = subject.send(:request_headers)
|
|
93
|
+
expect(headers["Authorization"]).to eq("Bearer token123")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Elelem::MCP::OAuth do
|
|
4
|
+
let(:resource_url) { "https://mcp.example.com/sse" }
|
|
5
|
+
let(:http) { double("http") }
|
|
6
|
+
let(:storage) { instance_double(Elelem::MCP::TokenStorage) }
|
|
7
|
+
|
|
8
|
+
subject { described_class.new(resource_url, http: http) }
|
|
9
|
+
|
|
10
|
+
before do
|
|
11
|
+
allow(Elelem::MCP::TokenStorage).to receive(:new).and_return(storage)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe "#token" do
|
|
15
|
+
context "with valid cached token" do
|
|
16
|
+
it "returns cached access_token" do
|
|
17
|
+
allow(storage).to receive(:load).with(resource_url).and_return({
|
|
18
|
+
access_token: "cached_token",
|
|
19
|
+
expires_at: Time.now.to_i + 3600
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
expect(subject.token).to eq("cached_token")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
context "with expired token and refresh_token" do
|
|
27
|
+
let(:auth_metadata) do
|
|
28
|
+
{
|
|
29
|
+
"authorization_endpoint" => "https://auth.example.com/authorize",
|
|
30
|
+
"token_endpoint" => "https://auth.example.com/token",
|
|
31
|
+
"registration_endpoint" => "https://auth.example.com/register"
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "refreshes using refresh_token" do
|
|
36
|
+
allow(storage).to receive(:load).with(resource_url).and_return({
|
|
37
|
+
access_token: "old_token",
|
|
38
|
+
refresh_token: "refresh_abc",
|
|
39
|
+
expires_at: Time.now.to_i - 100
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
allow(storage).to receive(:load_client).with(resource_url).and_return({
|
|
43
|
+
client_id: "elelem-123"
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
resource_response = double(body: { "authorization_servers" => ["https://auth.example.com"] }.to_json)
|
|
47
|
+
auth_response = double(body: auth_metadata.to_json)
|
|
48
|
+
token_response = double(body: { "access_token" => "new_token", "expires_in" => 3600 }.to_json)
|
|
49
|
+
|
|
50
|
+
allow(http).to receive(:get).with("https://mcp.example.com/.well-known/oauth-protected-resource").and_yield(resource_response)
|
|
51
|
+
allow(http).to receive(:get).with("https://auth.example.com/.well-known/oauth-authorization-server").and_yield(auth_response)
|
|
52
|
+
allow(http).to receive(:post).and_yield(token_response)
|
|
53
|
+
allow(storage).to receive(:save)
|
|
54
|
+
|
|
55
|
+
expect(subject.token).to eq("new_token")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe "PKCE generation" do
|
|
61
|
+
it "generates valid verifier and challenge" do
|
|
62
|
+
verifier, challenge = subject.send(:generate_pkce)
|
|
63
|
+
|
|
64
|
+
expect(verifier.length).to be >= 43
|
|
65
|
+
expect(challenge.length).to be >= 43
|
|
66
|
+
expect(challenge).not_to include("+", "/", "=")
|
|
67
|
+
|
|
68
|
+
expected_challenge = Base64.urlsafe_encode64(
|
|
69
|
+
Digest::SHA256.digest(verifier),
|
|
70
|
+
padding: false
|
|
71
|
+
)
|
|
72
|
+
expect(challenge).to eq(expected_challenge)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe "expiration check" do
|
|
77
|
+
it "considers token expired when within 60 seconds of expiry" do
|
|
78
|
+
stored = { expires_at: Time.now.to_i + 30 }
|
|
79
|
+
expect(subject.send(:expired?, stored)).to be true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "considers token valid when more than 60 seconds remain" do
|
|
83
|
+
stored = { expires_at: Time.now.to_i + 120 }
|
|
84
|
+
expect(subject.send(:expired?, stored)).to be false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "considers token valid when no expires_at" do
|
|
88
|
+
stored = { expires_at: nil }
|
|
89
|
+
expect(subject.send(:expired?, stored)).to be false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Elelem::MCP::TokenStorage do
|
|
4
|
+
subject { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:resource_url) { "https://example.com/mcp" }
|
|
7
|
+
let(:token_dir) { File.expand_path("~/.config/elelem/tokens") }
|
|
8
|
+
|
|
9
|
+
after do
|
|
10
|
+
Dir.glob(File.join(token_dir, "*.json")).each { |f| File.delete(f) rescue nil }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "#save and #load" do
|
|
14
|
+
it "stores and retrieves tokens" do
|
|
15
|
+
subject.save(resource_url, access_token: "abc123", refresh_token: "refresh456", expires_in: 3600)
|
|
16
|
+
|
|
17
|
+
stored = subject.load(resource_url)
|
|
18
|
+
expect(stored[:access_token]).to eq("abc123")
|
|
19
|
+
expect(stored[:refresh_token]).to eq("refresh456")
|
|
20
|
+
expect(stored[:expires_at]).to be > Time.now.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "returns nil for unknown resource" do
|
|
24
|
+
expect(subject.load("https://unknown.com")).to be_nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "handles missing refresh_token" do
|
|
28
|
+
subject.save(resource_url, access_token: "abc123")
|
|
29
|
+
|
|
30
|
+
stored = subject.load(resource_url)
|
|
31
|
+
expect(stored[:access_token]).to eq("abc123")
|
|
32
|
+
expect(stored[:refresh_token]).to be_nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "#save_client and #load_client" do
|
|
37
|
+
it "stores and retrieves client registration" do
|
|
38
|
+
client_data = { "client_id" => "elelem-123", "client_secret" => nil }
|
|
39
|
+
subject.save_client(resource_url, client_data)
|
|
40
|
+
|
|
41
|
+
stored = subject.load_client(resource_url)
|
|
42
|
+
expect(stored[:client_id]).to eq("elelem-123")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "returns nil for unknown client" do
|
|
46
|
+
expect(subject.load_client("https://unknown.com")).to be_nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe "file permissions" do
|
|
51
|
+
it "creates token files with 0600 permissions" do
|
|
52
|
+
subject.save(resource_url, access_token: "secret")
|
|
53
|
+
|
|
54
|
+
files = Dir.glob(File.join(token_dir, "*.json"))
|
|
55
|
+
expect(files).not_to be_empty
|
|
56
|
+
files.each do |f|
|
|
57
|
+
mode = File.stat(f).mode & 0o777
|
|
58
|
+
expect(mode).to eq(0o600)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: elelem-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mo khan
|
|
@@ -9,6 +9,20 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.3'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: digest
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,14 +71,14 @@ dependencies:
|
|
|
57
71
|
requirements:
|
|
58
72
|
- - "~>"
|
|
59
73
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
74
|
+
version: '3.0'
|
|
61
75
|
type: :runtime
|
|
62
76
|
prerelease: false
|
|
63
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
78
|
requirements:
|
|
65
79
|
- - "~>"
|
|
66
80
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '
|
|
81
|
+
version: '3.0'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: net-http
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -93,6 +107,34 @@ dependencies:
|
|
|
93
107
|
- - "~>"
|
|
94
108
|
- !ruby/object:Gem::Version
|
|
95
109
|
version: '0.2'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: securerandom
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0.4'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0.4'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: timeout
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0.6'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0.6'
|
|
96
138
|
- !ruby/object:Gem::Dependency
|
|
97
139
|
name: uri
|
|
98
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -107,6 +149,20 @@ dependencies:
|
|
|
107
149
|
- - "~>"
|
|
108
150
|
- !ruby/object:Gem::Version
|
|
109
151
|
version: '1.1'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: webrick
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - "~>"
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '1.9'
|
|
159
|
+
type: :runtime
|
|
160
|
+
prerelease: false
|
|
161
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
+
requirements:
|
|
163
|
+
- - "~>"
|
|
164
|
+
- !ruby/object:Gem::Version
|
|
165
|
+
version: '1.9'
|
|
110
166
|
description: Adds MCP server tool discovery (stdio and HTTP transports) as elelem
|
|
111
167
|
tools.
|
|
112
168
|
email:
|
|
@@ -115,12 +171,18 @@ executables: []
|
|
|
115
171
|
extensions: []
|
|
116
172
|
extra_rdoc_files: []
|
|
117
173
|
files:
|
|
174
|
+
- ".rspec"
|
|
118
175
|
- LICENSE.txt
|
|
119
176
|
- Rakefile
|
|
120
177
|
- lib/elelem/mcp.rb
|
|
121
178
|
- lib/elelem/mcp/client.rb
|
|
179
|
+
- lib/elelem/mcp/oauth.rb
|
|
122
180
|
- lib/elelem/mcp/token_storage.rb
|
|
123
181
|
- lib/elelem/mcp/version.rb
|
|
182
|
+
- spec/elelem/mcp/http_server_spec.rb
|
|
183
|
+
- spec/elelem/mcp/oauth_spec.rb
|
|
184
|
+
- spec/elelem/mcp/token_storage_spec.rb
|
|
185
|
+
- spec/spec_helper.rb
|
|
124
186
|
homepage: https://src.mokhan.ca/elelem/mcp
|
|
125
187
|
licenses:
|
|
126
188
|
- MIT
|