openclacky 1.5.6 → 1.5.8

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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +59 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/README.md +2 -2
  7. data/README_CN.md +1 -1
  8. data/README_JA.md +1 -1
  9. data/Rakefile +15 -0
  10. data/docs/design-emit-and-background-skills.md +253 -0
  11. data/docs/mcp-architecture.md +48 -9
  12. data/docs/mcp.example.json +9 -0
  13. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  14. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  15. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  16. data/lib/clacky/agent/cost_tracker.rb +17 -2
  17. data/lib/clacky/agent/hook_manager.rb +14 -3
  18. data/lib/clacky/agent/memory_updater.rb +17 -18
  19. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  20. data/lib/clacky/agent/session_serializer.rb +136 -22
  21. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  22. data/lib/clacky/agent/skill_evolution.rb +11 -7
  23. data/lib/clacky/agent/skill_manager.rb +20 -18
  24. data/lib/clacky/agent/skill_reflector.rb +3 -6
  25. data/lib/clacky/agent.rb +113 -23
  26. data/lib/clacky/agent_config.rb +40 -18
  27. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  28. data/lib/clacky/cli.rb +4 -1
  29. data/lib/clacky/client.rb +1 -1
  30. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
  31. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
  32. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  33. data/lib/clacky/extension/api_extension.rb +11 -3
  34. data/lib/clacky/extension/api_loader.rb +4 -11
  35. data/lib/clacky/extension/packager.rb +1 -1
  36. data/lib/clacky/extension/verifier.rb +1 -1
  37. data/lib/clacky/fanout.rb +100 -0
  38. data/lib/clacky/locales/en.rb +2 -0
  39. data/lib/clacky/locales/zh.rb +2 -0
  40. data/lib/clacky/mcp/cli_commands.rb +61 -0
  41. data/lib/clacky/mcp/client.rb +18 -2
  42. data/lib/clacky/mcp/connection_manager.rb +85 -0
  43. data/lib/clacky/mcp/http_transport.rb +25 -9
  44. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  45. data/lib/clacky/mcp/oauth/config.rb +51 -0
  46. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  47. data/lib/clacky/mcp/oauth/session.rb +88 -0
  48. data/lib/clacky/media/generator.rb +3 -3
  49. data/lib/clacky/message_format/anthropic.rb +27 -13
  50. data/lib/clacky/message_history.rb +33 -2
  51. data/lib/clacky/providers.rb +13 -6
  52. data/lib/clacky/server/http_server.rb +102 -98
  53. data/lib/clacky/server/session_registry.rb +11 -15
  54. data/lib/clacky/server/web_ui_controller.rb +1 -1
  55. data/lib/clacky/session_manager.rb +21 -12
  56. data/lib/clacky/shell_hook_loader.rb +14 -8
  57. data/lib/clacky/tools/invoke_skill.rb +3 -2
  58. data/lib/clacky/tools/terminal.rb +11 -4
  59. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  60. data/lib/clacky/ui2/components/input_area.rb +1 -0
  61. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  62. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  63. data/lib/clacky/ui2/line_editor.rb +1 -0
  64. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  65. data/lib/clacky/ui2/progress_handle.rb +27 -3
  66. data/lib/clacky/ui2/ui_controller.rb +207 -6
  67. data/lib/clacky/ui_interface.rb +13 -3
  68. data/lib/clacky/utils/arguments_parser.rb +73 -4
  69. data/lib/clacky/utils/model_pricing.rb +21 -0
  70. data/lib/clacky/version.rb +1 -1
  71. data/lib/clacky/web/app.css +144 -213
  72. data/lib/clacky/web/components/chat-navigator.js +1 -1
  73. data/lib/clacky/web/features/billing/store.js +3 -3
  74. data/lib/clacky/web/features/billing/view.js +5 -61
  75. data/lib/clacky/web/features/extensions/store.js +1 -1
  76. data/lib/clacky/web/features/extensions/view.js +28 -5
  77. data/lib/clacky/web/features/new-session/view.js +43 -2
  78. data/lib/clacky/web/i18n.js +38 -22
  79. data/lib/clacky/web/index.html +19 -24
  80. data/lib/clacky/web/projects.js +96 -279
  81. data/lib/clacky/web/sessions.js +169 -119
  82. data/lib/clacky/web/settings.js +7 -1
  83. data/lib/clacky/web/ws-dispatcher.js +61 -34
  84. data/lib/clacky.rb +2 -0
  85. metadata +12 -1
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # Runs a batch of blocking jobs on a bounded thread pool and collects their
5
+ # results in the order the jobs were given, regardless of completion order.
6
+ #
7
+ # Built for subagent fan-out: each job blocks inside Agent#run, so the pool is
8
+ # sized for concurrency rather than CPU count. A job that raises is captured
9
+ # as a failed slot instead of tearing down its siblings.
10
+ class Fanout
11
+ Result = Struct.new(:index, :value, :error, :duration, keyword_init: true) do
12
+ def ok?
13
+ error.nil?
14
+ end
15
+ end
16
+
17
+ DEFAULT_MAX_CONCURRENCY = 4
18
+
19
+ # @param max_concurrency [Integer] jobs allowed to run at once
20
+ # @param timeout [Numeric, nil] wall-clock budget for the whole batch
21
+ def initialize(max_concurrency: DEFAULT_MAX_CONCURRENCY, timeout: nil)
22
+ raise ArgumentError, "max_concurrency must be positive" unless max_concurrency.to_i.positive?
23
+
24
+ @max_concurrency = max_concurrency.to_i
25
+ @timeout = timeout
26
+ end
27
+
28
+ # @param jobs [Array<#call>] each job is invoked with no arguments
29
+ # @return [Array<Result>] one entry per job, aligned to the input order
30
+ def run(jobs)
31
+ return [] if jobs.empty?
32
+
33
+ pending = build_queue(jobs)
34
+ results = Array.new(jobs.size)
35
+ deadline = @timeout && (monotonic_now + @timeout)
36
+
37
+ workers = Array.new([@max_concurrency, jobs.size].min) do
38
+ Thread.new { drain(pending, results, deadline) }
39
+ end
40
+ join_all(workers, deadline)
41
+
42
+ fill_unfinished(results)
43
+ end
44
+
45
+ private def build_queue(jobs)
46
+ queue = Queue.new
47
+ jobs.each_with_index { |job, index| queue << [index, job] }
48
+ queue
49
+ end
50
+
51
+ private def drain(pending, results, deadline)
52
+ # pop(true) raises ThreadError when the queue is drained, which is the
53
+ # only exit condition — workers outlive individual jobs.
54
+ loop do
55
+ index, job = pending.pop(true)
56
+ break if deadline && monotonic_now >= deadline
57
+
58
+ results[index] = invoke(index, job)
59
+ end
60
+ rescue ThreadError
61
+ nil
62
+ end
63
+
64
+ private def invoke(index, job)
65
+ started = monotonic_now
66
+ value = job.call
67
+ Result.new(index: index, value: value, error: nil, duration: monotonic_now - started)
68
+ rescue StandardError, ScriptError => e
69
+ Result.new(index: index, value: nil, error: e, duration: monotonic_now - started)
70
+ end
71
+
72
+ private def join_all(workers, deadline)
73
+ workers.each do |worker|
74
+ if deadline
75
+ remaining = deadline - monotonic_now
76
+ worker.join(remaining.positive? ? remaining : 0) or worker.kill
77
+ else
78
+ worker.join
79
+ end
80
+ end
81
+ end
82
+
83
+ private def fill_unfinished(results)
84
+ results.each_with_index do |result, index|
85
+ next if result
86
+
87
+ results[index] = Result.new(
88
+ index: index,
89
+ value: nil,
90
+ error: FanoutTimeoutError.new("fan-out job #{index} did not finish within #{@timeout}s"),
91
+ duration: @timeout
92
+ )
93
+ end
94
+ end
95
+
96
+ private def monotonic_now
97
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ end
99
+ end
100
+ end
@@ -3,6 +3,8 @@
3
3
  module Clacky
4
4
  module Locales
5
5
  EN = {
6
+ "phase.skill_evolution" => "Reflecting on this task",
7
+ "phase.skipped" => "skipped",
6
8
  "llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
7
9
  "llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
8
10
  "llm.error.invalid_api_key" => "API key is invalid or expired, please update it in Settings",
@@ -3,6 +3,8 @@
3
3
  module Clacky
4
4
  module Locales
5
5
  ZH = {
6
+ "phase.skill_evolution" => "正在复盘本次任务",
7
+ "phase.skipped" => "已跳过",
6
8
  "llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
7
9
  "llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
8
10
  "llm.error.invalid_api_key" => "API Key 无效或已过期,请到设置中重新配置",
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require_relative "connection_manager"
6
+
7
+ module Clacky
8
+ module Mcp
9
+ class CliCommands < Thor
10
+ desc "capabilities", "Report MCP client capabilities"
11
+ option :json, type: :boolean, default: false
12
+ def capabilities
13
+ result = { "remote_oauth" => true }
14
+ emit(result, "Remote MCP OAuth: supported")
15
+ end
16
+
17
+ desc "login SERVER", "Authorize an OAuth-protected remote MCP server"
18
+ option :json, type: :boolean, default: false
19
+ def login(server)
20
+ result = manager(server).login
21
+ emit(result, "Connected MCP server '#{server}'.")
22
+ rescue ConnectionManager::Error => e
23
+ fail_with(e.message)
24
+ end
25
+
26
+ desc "status SERVER", "Show remote MCP authorization status"
27
+ option :json, type: :boolean, default: false
28
+ def status(server)
29
+ result = manager(server).status
30
+ label = result["connected"] ? "connected" : "disconnected"
31
+ emit(result, "MCP server '#{server}' is #{label}.")
32
+ rescue ConnectionManager::Error => e
33
+ fail_with(e.message)
34
+ end
35
+
36
+ desc "logout SERVER", "Delete credentials for a remote MCP server"
37
+ option :json, type: :boolean, default: false
38
+ def logout(server)
39
+ result = manager(server).logout
40
+ emit(result, "Disconnected MCP server '#{server}'.")
41
+ rescue ConnectionManager::Error => e
42
+ fail_with(e.message)
43
+ end
44
+
45
+ no_commands do
46
+ private def manager(server)
47
+ ConnectionManager.new(server_name: server)
48
+ end
49
+
50
+ private def emit(result, text)
51
+ puts(options[:json] ? JSON.generate(result) : text)
52
+ end
53
+
54
+ private def fail_with(message)
55
+ warn message
56
+ raise Thor::Error, message
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -6,6 +6,10 @@ require "monitor"
6
6
  require_relative "transport"
7
7
  require_relative "stdio_transport"
8
8
  require_relative "http_transport"
9
+ require_relative "oauth/config"
10
+ require_relative "oauth/credential_store"
11
+ require_relative "oauth/authorization_manager"
12
+ require_relative "oauth/session"
9
13
 
10
14
  module Clacky
11
15
  module Mcp
@@ -29,7 +33,7 @@ module Clacky
29
33
  # Build a Client from an mcp.json spec hash.
30
34
  # Recognized fields:
31
35
  # stdio: command (required), args, env, cwd
32
- # http: type: "http", url (required), headers
36
+ # http: type: "http", url (required), headers, auth
33
37
  def self.from_spec(name, spec)
34
38
  type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
35
39
  case type
@@ -45,12 +49,24 @@ module Clacky
45
49
  )
46
50
  )
47
51
  when "http", "streamable-http"
52
+ oauth_config = OAuth::Config.from_server_spec(spec)
53
+ authorization = nil
54
+ if oauth_config.enabled?
55
+ store = OAuth::CredentialStore.new(server_name: name)
56
+ manager = OAuth::AuthorizationManager.new(
57
+ server_name: name,
58
+ config: oauth_config,
59
+ store: store
60
+ )
61
+ authorization = OAuth::Session.new(store: store, manager: manager)
62
+ end
48
63
  new(
49
64
  name: name,
50
65
  transport: HttpTransport.new(
51
66
  name: name,
52
67
  url: spec["url"],
53
- headers: spec["headers"] || {}
68
+ headers: spec["headers"] || {},
69
+ authorization: authorization
54
70
  )
55
71
  )
56
72
  else
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "oauth/config"
5
+ require_relative "oauth/credential_store"
6
+ require_relative "oauth/authorization_manager"
7
+ require_relative "oauth/session"
8
+
9
+ module Clacky
10
+ module Mcp
11
+ class ConnectionManager
12
+ class Error < StandardError; end
13
+
14
+ def initialize(server_name:, home: Dir.home, working_dir: Dir.pwd, session_factory: nil)
15
+ @server_name = server_name.to_s
16
+ @home = File.expand_path(home)
17
+ @working_dir = working_dir && File.expand_path(working_dir)
18
+ @session_factory = session_factory || lambda do |_config, store, manager|
19
+ OAuth::Session.new(store: store, manager: manager)
20
+ end
21
+ end
22
+
23
+ def login
24
+ session.login
25
+ safe_status
26
+ rescue OAuth::AuthorizationManager::Error, OAuth::Session::Error, OAuth::CredentialStore::Error => e
27
+ raise Error, e.message
28
+ end
29
+
30
+ def status
31
+ safe_status
32
+ rescue OAuth::CredentialStore::Error => e
33
+ raise Error, e.message
34
+ end
35
+
36
+ def logout
37
+ session.logout
38
+ { "server" => @server_name, "connected" => false }
39
+ rescue OAuth::Session::Error, OAuth::CredentialStore::Error => e
40
+ raise Error, e.message
41
+ end
42
+
43
+ private def safe_status
44
+ session.status.merge("server" => @server_name)
45
+ end
46
+
47
+ private def session
48
+ return @session if @session
49
+
50
+ spec = server_spec
51
+ raise Error, "MCP server '#{@server_name}' is not configured" unless spec
52
+ config = OAuth::Config.from_server_spec(spec)
53
+ raise Error, "MCP server '#{@server_name}' is not configured for OAuth" unless config.enabled?
54
+ store = OAuth::CredentialStore.new(server_name: @server_name, home: @home)
55
+ manager = OAuth::AuthorizationManager.new(
56
+ server_name: @server_name,
57
+ config: config,
58
+ store: store
59
+ )
60
+ @session = @session_factory.call(config, store, manager)
61
+ rescue OAuth::Config::Error => e
62
+ raise Error, e.message
63
+ end
64
+
65
+ private def server_spec
66
+ servers = {}
67
+ config_paths.each do |path|
68
+ next unless File.file?(path)
69
+ document = JSON.parse(File.read(path))
70
+ source = document["mcpServers"] || document["servers"] || {}
71
+ source.each { |name, spec| servers[name.to_s] = spec if spec.is_a?(Hash) }
72
+ rescue JSON::ParserError
73
+ raise Error, "invalid MCP configuration in #{path}"
74
+ end
75
+ servers[@server_name]
76
+ end
77
+
78
+ private def config_paths
79
+ paths = [File.join(@home, ".clacky", "mcp.json")]
80
+ paths << File.join(@working_dir, ".clacky", "mcp.json") if @working_dir
81
+ paths
82
+ end
83
+ end
84
+ end
85
+ end
@@ -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: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
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
- http.request(req) do |res|
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
- raise TransportError, "HTTP #{status} from MCP server '#{@name}': #{text[0, 500]}"
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