openclacky 1.5.6 → 1.5.7
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/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +9 -0
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +136 -22
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +111 -20
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -0
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
- data/lib/clacky/extension/api_extension.rb +11 -3
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +11 -5
- data/lib/clacky/server/http_server.rb +42 -22
- data/lib/clacky/server/session_registry.rb +11 -15
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +13 -11
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +206 -6
- data/lib/clacky/ui_interface.rb +13 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/model_pricing.rb +21 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +10 -3
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/i18n.js +22 -8
- data/lib/clacky/web/index.html +7 -11
- data/lib/clacky/web/projects.js +26 -0
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
|
@@ -23,12 +23,15 @@ module Clacky
|
|
|
23
23
|
DEFAULT_OPEN_TIMEOUT = 10
|
|
24
24
|
DEFAULT_READ_TIMEOUT = 120
|
|
25
25
|
|
|
26
|
-
def initialize(name:, url:, headers: {},
|
|
26
|
+
def initialize(name:, url:, headers: {}, authorization: nil, requester: nil,
|
|
27
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
27
28
|
@name = name
|
|
28
29
|
@uri = URI.parse(url)
|
|
29
30
|
raise TransportError, "MCP server '#{name}' url is not http(s): #{url}" unless %w[http https].include?(@uri.scheme)
|
|
30
31
|
|
|
31
32
|
@extra_headers = (headers || {}).transform_keys(&:to_s).transform_values(&:to_s)
|
|
33
|
+
@authorization = authorization
|
|
34
|
+
@requester = requester
|
|
32
35
|
@open_timeout = open_timeout
|
|
33
36
|
@read_timeout = read_timeout
|
|
34
37
|
|
|
@@ -79,21 +82,17 @@ module Clacky
|
|
|
79
82
|
@last_error ? "last error: #{@last_error.class}: #{@last_error.message}" : ""
|
|
80
83
|
end
|
|
81
84
|
|
|
82
|
-
private def dispatch_post(body, is_request:)
|
|
83
|
-
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
84
|
-
http.use_ssl = (@uri.scheme == "https")
|
|
85
|
-
http.open_timeout = @open_timeout
|
|
86
|
-
http.read_timeout = @read_timeout
|
|
87
|
-
|
|
85
|
+
private def dispatch_post(body, is_request:, retried: false)
|
|
88
86
|
req = Net::HTTP::Post.new(@uri.request_uri)
|
|
89
87
|
req["Content-Type"] = "application/json"
|
|
90
88
|
req["Accept"] = "application/json, text/event-stream"
|
|
91
89
|
req["MCP-Protocol-Version"] = Client::PROTOCOL_VERSION if defined?(Client::PROTOCOL_VERSION)
|
|
92
90
|
@lock.synchronize { req["Mcp-Session-Id"] = @session_id if @session_id }
|
|
93
91
|
@extra_headers.each { |k, v| req[k] = v }
|
|
92
|
+
@authorization&.authorization_headers&.each { |k, v| req[k] = v }
|
|
94
93
|
req.body = body
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
perform_request(req) do |res|
|
|
97
96
|
if (sid = res["Mcp-Session-Id"])
|
|
98
97
|
@lock.synchronize { @session_id = sid }
|
|
99
98
|
end
|
|
@@ -102,9 +101,16 @@ module Clacky
|
|
|
102
101
|
if status == 202
|
|
103
102
|
return
|
|
104
103
|
end
|
|
104
|
+
if status == 401 && @authorization && !retried
|
|
105
|
+
res.read_body.to_s
|
|
106
|
+
@authorization.invalidate!
|
|
107
|
+
return dispatch_post(body, is_request: is_request, retried: true)
|
|
108
|
+
end
|
|
105
109
|
if status >= 400
|
|
106
110
|
text = res.read_body.to_s
|
|
107
|
-
|
|
111
|
+
message = "HTTP #{status} from MCP server '#{@name}'"
|
|
112
|
+
message += ": #{text[0, 500]}" unless status == 401
|
|
113
|
+
raise TransportError, message
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
ctype = (res["Content-Type"] || "").downcase
|
|
@@ -123,6 +129,16 @@ module Clacky
|
|
|
123
129
|
end
|
|
124
130
|
end
|
|
125
131
|
|
|
132
|
+
private def perform_request(request, &block)
|
|
133
|
+
return @requester.call(request, &block) if @requester
|
|
134
|
+
|
|
135
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
136
|
+
http.use_ssl = (@uri.scheme == "https")
|
|
137
|
+
http.open_timeout = @open_timeout
|
|
138
|
+
http.read_timeout = @read_timeout
|
|
139
|
+
http.request(request, &block)
|
|
140
|
+
end
|
|
141
|
+
|
|
126
142
|
private def consume_sse(res)
|
|
127
143
|
buffer = String.new
|
|
128
144
|
res.read_body do |chunk|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "base64"
|
|
9
|
+
require "socket"
|
|
10
|
+
require "timeout"
|
|
11
|
+
|
|
12
|
+
module Clacky
|
|
13
|
+
module Mcp
|
|
14
|
+
module OAuth
|
|
15
|
+
class AuthorizationManager
|
|
16
|
+
Response = Struct.new(:status, :headers, :body)
|
|
17
|
+
MAX_RESPONSE_BYTES = 1_048_576
|
|
18
|
+
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
def self.pkce_challenge(verifier)
|
|
22
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(server_name:, config:, store:, requester: nil, callback: nil, clock: nil)
|
|
26
|
+
@server_name = server_name
|
|
27
|
+
@config = config
|
|
28
|
+
@store = store
|
|
29
|
+
@requester = requester || method(:request)
|
|
30
|
+
@callback = callback
|
|
31
|
+
@clock = clock || -> { Time.now.to_i }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def login(resource_metadata_url: nil)
|
|
35
|
+
raise Error, "MCP server '#{@server_name}' is not configured for OAuth" unless @config.enabled?
|
|
36
|
+
|
|
37
|
+
receiver = @callback || CallbackReceiver.new
|
|
38
|
+
redirect_uri = receiver.respond_to?(:redirect_uri) ? receiver.redirect_uri : "http://127.0.0.1:52963/callback"
|
|
39
|
+
metadata = discover_metadata(resource_metadata_url)
|
|
40
|
+
client = register_client(metadata, redirect_uri)
|
|
41
|
+
verifier = SecureRandom.urlsafe_base64(48, false)
|
|
42
|
+
state = SecureRandom.urlsafe_base64(32, false)
|
|
43
|
+
authorization_url = build_authorization_url(metadata, client.fetch("client_id"), redirect_uri, verifier, state)
|
|
44
|
+
callback_result = receiver.call(authorization_url, state)
|
|
45
|
+
raise Error, "OAuth callback state mismatch" unless callback_result["state"].to_s == state
|
|
46
|
+
raise Error, "OAuth authorization returned no code" if callback_result["code"].to_s.empty?
|
|
47
|
+
|
|
48
|
+
token = token_request(metadata.fetch("token_endpoint"), {
|
|
49
|
+
"grant_type" => "authorization_code",
|
|
50
|
+
"code" => callback_result.fetch("code"),
|
|
51
|
+
"redirect_uri" => redirect_uri,
|
|
52
|
+
"client_id" => client.fetch("client_id"),
|
|
53
|
+
"code_verifier" => verifier
|
|
54
|
+
})
|
|
55
|
+
grant = normalize_token(token).merge(
|
|
56
|
+
"client_id" => client.fetch("client_id"),
|
|
57
|
+
"client_secret" => client["client_secret"],
|
|
58
|
+
"token_endpoint_auth_method" => client["token_endpoint_auth_method"] || "none",
|
|
59
|
+
"token_endpoint" => metadata.fetch("token_endpoint"),
|
|
60
|
+
"authorization_server" => metadata.fetch("issuer"),
|
|
61
|
+
"resource" => @config.resource
|
|
62
|
+
)
|
|
63
|
+
@store.save(grant)
|
|
64
|
+
ensure
|
|
65
|
+
receiver.close if defined?(receiver) && receiver.respond_to?(:close)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def refresh(grant)
|
|
69
|
+
refresh_token = grant["refresh_token"].to_s
|
|
70
|
+
raise Error, "OAuth refresh token is missing; log in again" if refresh_token.empty?
|
|
71
|
+
|
|
72
|
+
fields = {
|
|
73
|
+
"grant_type" => "refresh_token",
|
|
74
|
+
"refresh_token" => refresh_token,
|
|
75
|
+
"client_id" => grant.fetch("client_id")
|
|
76
|
+
}
|
|
77
|
+
token = token_request(grant.fetch("token_endpoint"), fields,
|
|
78
|
+
client_secret: grant["client_secret"],
|
|
79
|
+
auth_method: grant["token_endpoint_auth_method"])
|
|
80
|
+
updated = grant.merge(normalize_token(token))
|
|
81
|
+
updated["refresh_token"] = refresh_token if token["refresh_token"].to_s.empty?
|
|
82
|
+
updated
|
|
83
|
+
rescue KeyError
|
|
84
|
+
raise Error, "stored OAuth grant is incomplete; log in again"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private def discover_metadata(explicit_resource_metadata_url)
|
|
88
|
+
protected_url = explicit_resource_metadata_url || default_resource_metadata_url
|
|
89
|
+
protected = get_json(protected_url, "protected resource metadata")
|
|
90
|
+
if protected["resource"] && protected["resource"] != @config.resource
|
|
91
|
+
raise Error, "OAuth protected resource metadata does not match the configured MCP resource"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
server = Array(protected["authorization_servers"]).first || protected["authorization_server"]
|
|
95
|
+
server = validate_https_url(server, "authorization server")
|
|
96
|
+
metadata_url = "#{server.sub(%r{/\z}, '')}/.well-known/oauth-authorization-server"
|
|
97
|
+
metadata = get_json(metadata_url, "authorization server metadata")
|
|
98
|
+
metadata["issuer"] ||= server
|
|
99
|
+
%w[issuer authorization_endpoint token_endpoint registration_endpoint].each do |key|
|
|
100
|
+
metadata[key] = validate_https_url(metadata[key], key.tr("_", " "))
|
|
101
|
+
end
|
|
102
|
+
raise Error, "authorization server metadata issuer does not match" unless metadata["issuer"] == server
|
|
103
|
+
unless Array(metadata["code_challenge_methods_supported"]).include?("S256")
|
|
104
|
+
raise Error, "authorization server does not support PKCE S256"
|
|
105
|
+
end
|
|
106
|
+
metadata
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private def default_resource_metadata_url
|
|
110
|
+
resource = URI.parse(@config.resource)
|
|
111
|
+
origin = "#{resource.scheme}://#{resource.host}"
|
|
112
|
+
origin += ":#{resource.port}" unless resource.port == 443
|
|
113
|
+
"#{origin}/.well-known/oauth-protected-resource#{resource.path}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private def register_client(metadata, redirect_uri)
|
|
117
|
+
response = @requester.call("POST", metadata.fetch("registration_endpoint"),
|
|
118
|
+
{ "Content-Type" => "application/json" },
|
|
119
|
+
JSON.generate(
|
|
120
|
+
"client_name" => "openclacky",
|
|
121
|
+
"application_type" => "native",
|
|
122
|
+
"redirect_uris" => [redirect_uri],
|
|
123
|
+
"grant_types" => %w[authorization_code refresh_token],
|
|
124
|
+
"response_types" => ["code"],
|
|
125
|
+
"token_endpoint_auth_method" => "none",
|
|
126
|
+
"scope" => "openid profile email offline_access"
|
|
127
|
+
))
|
|
128
|
+
client = parse_success_json(response, "OAuth client registration")
|
|
129
|
+
raise Error, "OAuth client registration returned no client_id" if client["client_id"].to_s.empty?
|
|
130
|
+
client
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private def build_authorization_url(metadata, client_id, redirect_uri, verifier, state)
|
|
134
|
+
uri = URI.parse(metadata.fetch("authorization_endpoint"))
|
|
135
|
+
uri.query = URI.encode_www_form(
|
|
136
|
+
"response_type" => "code",
|
|
137
|
+
"client_id" => client_id,
|
|
138
|
+
"redirect_uri" => redirect_uri,
|
|
139
|
+
"scope" => "openid profile email offline_access",
|
|
140
|
+
"code_challenge" => self.class.pkce_challenge(verifier),
|
|
141
|
+
"code_challenge_method" => "S256",
|
|
142
|
+
"state" => state,
|
|
143
|
+
"resource" => @config.resource
|
|
144
|
+
)
|
|
145
|
+
uri.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private def token_request(url, fields, client_secret: nil, auth_method: nil)
|
|
149
|
+
headers = { "Content-Type" => "application/x-www-form-urlencoded" }
|
|
150
|
+
if client_secret && auth_method == "client_secret_basic"
|
|
151
|
+
credentials = Base64.strict_encode64("#{fields['client_id']}:#{client_secret}")
|
|
152
|
+
headers["Authorization"] = "Basic #{credentials}"
|
|
153
|
+
elsif client_secret
|
|
154
|
+
fields = fields.merge("client_secret" => client_secret)
|
|
155
|
+
end
|
|
156
|
+
response = @requester.call("POST", validate_https_url(url, "token endpoint"), headers,
|
|
157
|
+
URI.encode_www_form(fields))
|
|
158
|
+
parse_success_json(response, "OAuth token exchange")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private def normalize_token(token)
|
|
162
|
+
access_token = token["access_token"].to_s
|
|
163
|
+
raise Error, "OAuth token response contained no access token" if access_token.empty?
|
|
164
|
+
{
|
|
165
|
+
"access_token" => access_token,
|
|
166
|
+
"refresh_token" => token["refresh_token"],
|
|
167
|
+
"token_type" => token["token_type"] || "Bearer",
|
|
168
|
+
"expires_at" => @clock.call + token.fetch("expires_in", 3600).to_i
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private def get_json(url, label)
|
|
173
|
+
response = @requester.call("GET", validate_https_url(url, label), { "Accept" => "application/json" }, nil)
|
|
174
|
+
parse_success_json(response, label)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private def parse_success_json(response, label)
|
|
178
|
+
status = response.status.to_i
|
|
179
|
+
raise Error, "#{label} failed with HTTP #{status}" unless status >= 200 && status < 300
|
|
180
|
+
body = response.body.to_s
|
|
181
|
+
raise Error, "#{label} response was too large" if body.bytesize > MAX_RESPONSE_BYTES
|
|
182
|
+
value = JSON.parse(body)
|
|
183
|
+
raise JSON::ParserError unless value.is_a?(Hash)
|
|
184
|
+
value
|
|
185
|
+
rescue JSON::ParserError
|
|
186
|
+
raise Error, "#{label} returned invalid JSON"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private def validate_https_url(value, label)
|
|
190
|
+
uri = URI.parse(value.to_s)
|
|
191
|
+
unless uri.scheme == "https" && uri.host && !uri.host.empty? && !uri.user && !uri.password
|
|
192
|
+
raise Error, "#{label} must be an HTTPS URL without credentials"
|
|
193
|
+
end
|
|
194
|
+
uri.to_s
|
|
195
|
+
rescue URI::InvalidURIError
|
|
196
|
+
raise Error, "#{label} must be an HTTPS URL"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private def request(method, url, headers, body)
|
|
200
|
+
uri = URI.parse(url)
|
|
201
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
202
|
+
http.use_ssl = true
|
|
203
|
+
http.open_timeout = 10
|
|
204
|
+
http.read_timeout = 30
|
|
205
|
+
request_class = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
|
|
206
|
+
request = request_class.new(uri.request_uri)
|
|
207
|
+
headers.each { |key, value| request[key] = value }
|
|
208
|
+
request.body = body if body
|
|
209
|
+
response = http.request(request)
|
|
210
|
+
Response.new(response.code.to_i, response.each_header.to_h, response.body.to_s)
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
raise Error, "OAuth network request failed (#{e.class})"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class CallbackReceiver
|
|
216
|
+
attr_reader :redirect_uri
|
|
217
|
+
|
|
218
|
+
def initialize(timeout: 300)
|
|
219
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
220
|
+
@timeout = timeout
|
|
221
|
+
@redirect_uri = "http://127.0.0.1:#{@server.addr[1]}/callback"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def call(authorization_url, _expected_state)
|
|
225
|
+
open_browser(authorization_url)
|
|
226
|
+
socket = Timeout.timeout(@timeout) { @server.accept }
|
|
227
|
+
request_line = socket.gets.to_s
|
|
228
|
+
path = request_line.split(" ")[1].to_s
|
|
229
|
+
query = URI.decode_www_form(URI.parse(path).query.to_s).to_h
|
|
230
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nOpenClacky authorization complete. You may close this tab.\n")
|
|
231
|
+
query
|
|
232
|
+
rescue Timeout::Error
|
|
233
|
+
raise Error, "OAuth authorization timed out"
|
|
234
|
+
ensure
|
|
235
|
+
socket.close if defined?(socket) && socket
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def close
|
|
239
|
+
@server.close unless @server.closed?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private def open_browser(url)
|
|
243
|
+
command = if RUBY_PLATFORM.include?("darwin")
|
|
244
|
+
["open", url]
|
|
245
|
+
elsif RUBY_PLATFORM =~ /mswin|mingw|cygwin/
|
|
246
|
+
["cmd", "/c", "start", "", url]
|
|
247
|
+
else
|
|
248
|
+
["xdg-open", url]
|
|
249
|
+
end
|
|
250
|
+
opened = system(*command, out: File::NULL, err: File::NULL)
|
|
251
|
+
raise Error, "could not open a browser for OAuth authorization" unless opened
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Mcp
|
|
7
|
+
module OAuth
|
|
8
|
+
class Config
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
attr_reader :resource
|
|
12
|
+
|
|
13
|
+
def self.from_server_spec(spec)
|
|
14
|
+
spec ||= {}
|
|
15
|
+
auth = spec["auth"]
|
|
16
|
+
enabled = auth.is_a?(Hash) && !auth.empty?
|
|
17
|
+
if enabled && auth["type"].to_s != "oauth"
|
|
18
|
+
raise Error, "unsupported MCP authentication type '#{auth['type']}'"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
resource = enabled ? (auth["resource"] || spec["url"]) : spec["url"]
|
|
22
|
+
new(enabled: enabled, resource: resource, endpoint: spec["url"])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(enabled:, resource:, endpoint: nil)
|
|
26
|
+
@enabled = enabled
|
|
27
|
+
@resource = resource.to_s
|
|
28
|
+
@endpoint = (endpoint || resource).to_s
|
|
29
|
+
validate! if enabled?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
@enabled == true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private def validate!
|
|
37
|
+
validate_https_url(@endpoint, "OAuth MCP endpoint")
|
|
38
|
+
validate_https_url(@resource, "OAuth MCP resource")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private def validate_https_url(value, label)
|
|
42
|
+
uri = URI.parse(value)
|
|
43
|
+
raise Error, "#{label} must be an HTTPS URL without credentials" unless
|
|
44
|
+
uri.scheme == "https" && uri.host && !uri.host.empty? && !uri.user && !uri.password
|
|
45
|
+
rescue URI::InvalidURIError
|
|
46
|
+
raise Error, "#{label} must be an HTTPS URL"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Mcp
|
|
9
|
+
module OAuth
|
|
10
|
+
class CredentialStore
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
def initialize(server_name:, home: Dir.home)
|
|
16
|
+
original_name = server_name.to_s
|
|
17
|
+
safe_name = original_name.gsub(/[^a-zA-Z0-9_.-]/, "_")
|
|
18
|
+
safe_name = "server" if safe_name.empty?
|
|
19
|
+
safe_name = "#{safe_name}-#{Digest::SHA256.hexdigest(original_name)[0, 12]}" if safe_name != original_name
|
|
20
|
+
@path = File.join(File.expand_path(home), ".clacky", "mcp", "oauth", "#{safe_name}.json")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load
|
|
24
|
+
return nil unless File.file?(@path)
|
|
25
|
+
|
|
26
|
+
with_lock(File::LOCK_SH) do
|
|
27
|
+
value = JSON.parse(File.read(@path))
|
|
28
|
+
raise JSON::ParserError unless value.is_a?(Hash)
|
|
29
|
+
value
|
|
30
|
+
end
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
raise Error, "invalid OAuth credential file; log in again"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def save(value)
|
|
36
|
+
ensure_directory
|
|
37
|
+
with_lock(File::LOCK_EX) do
|
|
38
|
+
temporary = "#{@path}.tmp-#{Process.pid}-#{rand(1_000_000)}"
|
|
39
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
40
|
+
file.write(JSON.generate(value))
|
|
41
|
+
file.write("\n")
|
|
42
|
+
file.flush
|
|
43
|
+
file.fsync
|
|
44
|
+
end
|
|
45
|
+
File.rename(temporary, @path)
|
|
46
|
+
File.chmod(0o600, @path)
|
|
47
|
+
ensure
|
|
48
|
+
FileUtils.rm_f(temporary) if defined?(temporary) && temporary
|
|
49
|
+
end
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def delete
|
|
54
|
+
return unless File.exist?(@path)
|
|
55
|
+
|
|
56
|
+
ensure_directory
|
|
57
|
+
with_lock(File::LOCK_EX) { FileUtils.rm_f(@path) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inspect
|
|
61
|
+
"#<#{self.class} path=#{@path.inspect}>"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private def ensure_directory
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
66
|
+
File.chmod(0o700, File.dirname(@path))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def with_lock(mode)
|
|
70
|
+
ensure_directory
|
|
71
|
+
lock_path = "#{@path}.lock"
|
|
72
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
73
|
+
lock.flock(mode)
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
lock.flock(File::LOCK_UN) rescue nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Mcp
|
|
7
|
+
module OAuth
|
|
8
|
+
class Session
|
|
9
|
+
EXPIRY_SKEW = 60
|
|
10
|
+
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class NotConnectedError < Error; end
|
|
13
|
+
|
|
14
|
+
def initialize(store:, manager:, clock: nil)
|
|
15
|
+
@store = store
|
|
16
|
+
@manager = manager
|
|
17
|
+
@clock = clock || -> { Time.now.to_i }
|
|
18
|
+
@force_refresh = false
|
|
19
|
+
@lock = Monitor.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def authorization_headers
|
|
23
|
+
@lock.synchronize do
|
|
24
|
+
grant = @store.load
|
|
25
|
+
raise NotConnectedError, "OAuth MCP is not connected; run `clacky mcp login SERVER`" unless grant
|
|
26
|
+
|
|
27
|
+
if @force_refresh || expiring?(grant)
|
|
28
|
+
grant = @manager.refresh(grant)
|
|
29
|
+
@store.save(grant)
|
|
30
|
+
@force_refresh = false
|
|
31
|
+
end
|
|
32
|
+
token = grant["access_token"].to_s
|
|
33
|
+
raise Error, "stored OAuth grant has no access token; log in again" if token.empty?
|
|
34
|
+
{ "Authorization" => "Bearer #{token}" }
|
|
35
|
+
end
|
|
36
|
+
rescue NotConnectedError, Error
|
|
37
|
+
raise
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
raise Error, "OAuth session failed (#{e.class}); log in again"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def invalidate!
|
|
43
|
+
@lock.synchronize { @force_refresh = true }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def login
|
|
47
|
+
@lock.synchronize do
|
|
48
|
+
grant = @manager.login
|
|
49
|
+
@force_refresh = false
|
|
50
|
+
grant
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def logout
|
|
55
|
+
@lock.synchronize do
|
|
56
|
+
@store.delete
|
|
57
|
+
@force_refresh = false
|
|
58
|
+
end
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connected?
|
|
63
|
+
!@store.load.nil?
|
|
64
|
+
rescue StandardError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def status
|
|
69
|
+
grant = @store.load
|
|
70
|
+
return { "connected" => false } unless grant
|
|
71
|
+
{
|
|
72
|
+
"connected" => true,
|
|
73
|
+
"expires_at" => grant["expires_at"],
|
|
74
|
+
"expired" => grant.fetch("expires_at", 0).to_i <= @clock.call
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def inspect
|
|
79
|
+
"#<#{self.class} connected=#{connected?}>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private def expiring?(grant)
|
|
83
|
+
grant.fetch("expires_at", 0).to_i <= @clock.call + EXPIRY_SKEW
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -19,6 +19,25 @@ module Clacky
|
|
|
19
19
|
module Anthropic
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
|
+
# Normalise a /v1/messages usage block to the codebase's canonical
|
|
23
|
+
# (OpenAI-style) prompt_tokens: total input including cache_read but
|
|
24
|
+
# excluding cache_creation.
|
|
25
|
+
#
|
|
26
|
+
# Anthropic reports input_tokens as the post-breakpoint tail only, with
|
|
27
|
+
# the cache buckets disjoint from it. Some /v1/messages-compatible
|
|
28
|
+
# gateways instead report input_tokens as the FULL input with both cache
|
|
29
|
+
# buckets already folded in; adding cache_read there would bill the whole
|
|
30
|
+
# cached prefix twice. Detect that by whether the cache buckets already
|
|
31
|
+
# fit inside input_tokens.
|
|
32
|
+
def normalise_prompt_tokens(input_tokens, cache_read, cache_creation)
|
|
33
|
+
cached_input = cache_read + cache_creation
|
|
34
|
+
if cached_input.positive? && input_tokens >= cached_input
|
|
35
|
+
input_tokens - cache_creation
|
|
36
|
+
else
|
|
37
|
+
input_tokens + cache_read
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
22
41
|
# ── Message type identification ───────────────────────────────────────────
|
|
23
42
|
|
|
24
43
|
# Returns true if the message is an Anthropic-native tool result stored in
|
|
@@ -169,22 +188,16 @@ module Clacky
|
|
|
169
188
|
else data["stop_reason"]
|
|
170
189
|
end
|
|
171
190
|
|
|
172
|
-
# Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
|
|
173
|
-
# input — cache_read_input_tokens and cache_creation_input_tokens are
|
|
174
|
-
# reported separately and are disjoint from input_tokens.
|
|
175
|
-
#
|
|
176
191
|
# Normalise to the codebase's canonical shape (OpenAI-style) so downstream
|
|
177
192
|
# (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
|
|
178
193
|
# provider-agnostic:
|
|
179
194
|
#
|
|
180
|
-
# prompt_tokens =
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
# ModelPricing does
|
|
184
|
-
# `regular_input = prompt_tokens - cache_read`.)
|
|
195
|
+
# prompt_tokens = total input incl. cache_read, excl. cache_write
|
|
196
|
+
# (ModelPricing does
|
|
197
|
+
# `regular_input = prompt_tokens - cache_read`.)
|
|
185
198
|
# completion_tokens = output
|
|
186
199
|
# total_tokens = THIS TURN'S new compute volume
|
|
187
|
-
# =
|
|
200
|
+
# = non_cached + cache_creation + output
|
|
188
201
|
# (cache_read is excluded because hits are ~free /
|
|
189
202
|
# already-paid-for; cache_creation IS new work this
|
|
190
203
|
# turn even though it's billed at write_rate.)
|
|
@@ -199,14 +212,15 @@ module Clacky
|
|
|
199
212
|
cache_creation = usage["cache_creation_input_tokens"].to_i
|
|
200
213
|
output_tokens = usage["output_tokens"].to_i
|
|
201
214
|
|
|
202
|
-
prompt_tokens = raw_input_tokens
|
|
215
|
+
prompt_tokens = normalise_prompt_tokens(raw_input_tokens, cache_read, cache_creation)
|
|
203
216
|
|
|
204
217
|
usage_data = {
|
|
205
218
|
prompt_tokens: prompt_tokens,
|
|
206
219
|
completion_tokens: output_tokens,
|
|
207
220
|
# Per-turn new compute: what the server freshly processed this request.
|
|
208
|
-
# Excludes cache_read (nearly free, already-paid-for).
|
|
209
|
-
|
|
221
|
+
# Excludes cache_read (nearly free, already-paid-for). Derived from the
|
|
222
|
+
# normalised prompt_tokens so it stays correct under both conventions.
|
|
223
|
+
total_tokens: (prompt_tokens - cache_read) + cache_creation + output_tokens,
|
|
210
224
|
# Signal to CostTracker: total_tokens above is already the per-turn
|
|
211
225
|
# delta (not a running cumulative like OpenAI's). CostTracker should
|
|
212
226
|
# NOT subtract previous_total when this flag is truthy.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Clacky
|
|
4
6
|
# MessageHistory wraps the conversation message list and exposes
|
|
5
7
|
# business-meaningful operations instead of raw array manipulation.
|
|
@@ -12,9 +14,18 @@ module Clacky
|
|
|
12
14
|
task_id created_at system_injected session_context memory_update
|
|
13
15
|
subagent_instructions subagent_result subagent_transcript token_usage
|
|
14
16
|
compressed_summary chunk_path truncated transient
|
|
15
|
-
chunk_index chunk_count
|
|
17
|
+
chunk_index chunk_count ext_events
|
|
16
18
|
].freeze
|
|
17
19
|
|
|
20
|
+
# Cap on persisted ext_events per message. These are milestone events
|
|
21
|
+
# (progress chatter is transient), so a handful per message is the norm —
|
|
22
|
+
# the cap only exists to stop a runaway extension. Oldest are dropped first.
|
|
23
|
+
MAX_EXT_EVENTS_PER_MESSAGE = 50
|
|
24
|
+
|
|
25
|
+
# Oversized payloads belong in a file, not in session.json (which is
|
|
26
|
+
# rewritten in full on every save).
|
|
27
|
+
MAX_EXT_EVENT_BYTES = 8 * 1024
|
|
28
|
+
|
|
18
29
|
def initialize(messages = [])
|
|
19
30
|
@messages = messages.dup
|
|
20
31
|
end
|
|
@@ -85,6 +96,26 @@ module Clacky
|
|
|
85
96
|
self
|
|
86
97
|
end
|
|
87
98
|
|
|
99
|
+
# Append a custom extension event onto the most recent message so it is
|
|
100
|
+
# persisted with the session and can be replayed after a reload.
|
|
101
|
+
# Events are anchored to the last message: on replay they are re-emitted
|
|
102
|
+
# right after that message, preserving chronological order.
|
|
103
|
+
# No-op when the history is still empty (nothing to anchor to).
|
|
104
|
+
def append_ext_event(event)
|
|
105
|
+
msg = @messages.last
|
|
106
|
+
return self unless msg
|
|
107
|
+
|
|
108
|
+
if JSON.generate(event).bytesize > MAX_EXT_EVENT_BYTES
|
|
109
|
+
Clacky::Logger.warn("ext event #{event[:type].inspect} dropped: payload exceeds #{MAX_EXT_EVENT_BYTES} bytes")
|
|
110
|
+
return self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
list = (msg[:ext_events] ||= [])
|
|
114
|
+
list.shift while list.size >= MAX_EXT_EVENTS_PER_MESSAGE
|
|
115
|
+
list << deep_sanitize_utf8(event)
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
88
119
|
# Mutate the last message matching the predicate lambda in-place.
|
|
89
120
|
# Used by execute_skill_with_subagent to update instruction messages.
|
|
90
121
|
def mutate_last_matching(predicate, &block)
|