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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +59 -0
- data/CONTRIBUTING.md +9 -0
- data/README.md +2 -2
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- 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 +113 -23
- data/lib/clacky/agent_config.rb +40 -18
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -1
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -7
- data/lib/clacky/default_extensions/general/ext.yml +1 -1
- data/lib/clacky/extension/api_extension.rb +11 -3
- data/lib/clacky/extension/api_loader.rb +4 -11
- data/lib/clacky/extension/packager.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- 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/media/generator.rb +3 -3
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +33 -2
- data/lib/clacky/providers.rb +13 -6
- data/lib/clacky/server/http_server.rb +102 -98
- 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 +21 -12
- 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/components/inline_input.rb +1 -0
- data/lib/clacky/ui2/components/input_area.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +0 -1
- data/lib/clacky/ui2/components/todo_area.rb +1 -0
- data/lib/clacky/ui2/line_editor.rb +1 -0
- data/lib/clacky/ui2/markdown_renderer.rb +5 -1
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +207 -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 +144 -213
- data/lib/clacky/web/components/chat-navigator.js +1 -1
- data/lib/clacky/web/features/billing/store.js +3 -3
- data/lib/clacky/web/features/billing/view.js +5 -61
- data/lib/clacky/web/features/extensions/store.js +1 -1
- data/lib/clacky/web/features/extensions/view.js +28 -5
- data/lib/clacky/web/features/new-session/view.js +43 -2
- data/lib/clacky/web/i18n.js +38 -22
- data/lib/clacky/web/index.html +19 -24
- data/lib/clacky/web/projects.js +96 -279
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +7 -1
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- 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
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -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",
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -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
|
data/lib/clacky/mcp/client.rb
CHANGED
|
@@ -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: {},
|
|
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
|