openclacky 1.1.6 → 1.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/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +1 -1
- data/CONTRIBUTING.md +92 -0
- data/README.md +10 -0
- data/README_CN.md +10 -0
- data/ROADMAP.md +29 -0
- data/docs/billing-system.md +340 -0
- data/docs/mcp-architecture.md +114 -0
- data/docs/mcp.example.json +22 -0
- data/lib/clacky/agent/cost_tracker.rb +37 -0
- data/lib/clacky/agent/llm_caller.rb +0 -1
- data/lib/clacky/agent/session_serializer.rb +2 -11
- data/lib/clacky/agent/skill_manager.rb +73 -26
- data/lib/clacky/agent/system_prompt_builder.rb +0 -5
- data/lib/clacky/agent/time_machine.rb +6 -0
- data/lib/clacky/agent.rb +26 -1
- data/lib/clacky/agent_config.rb +9 -19
- data/lib/clacky/billing/billing_record.rb +67 -0
- data/lib/clacky/billing/billing_store.rb +193 -0
- data/lib/clacky/cli.rb +108 -6
- data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
- data/lib/clacky/idle_compression_timer.rb +4 -2
- data/lib/clacky/mcp/client.rb +204 -0
- data/lib/clacky/mcp/http_transport.rb +155 -0
- data/lib/clacky/mcp/registry.rb +229 -0
- data/lib/clacky/mcp/skill_provider.rb +75 -0
- data/lib/clacky/mcp/stdio_transport.rb +112 -0
- data/lib/clacky/mcp/transport.rb +23 -0
- data/lib/clacky/mcp/virtual_skill.rb +131 -0
- data/lib/clacky/message_history.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
- data/lib/clacky/server/http_server.rb +519 -15
- data/lib/clacky/server/server_master.rb +8 -14
- data/lib/clacky/server/session_registry.rb +24 -2
- data/lib/clacky/server/web_ui_controller.rb +4 -0
- data/lib/clacky/session_manager.rb +41 -12
- data/lib/clacky/skill.rb +1 -5
- data/lib/clacky/skill_loader.rb +36 -5
- data/lib/clacky/tools/browser.rb +217 -38
- data/lib/clacky/tools/trash_manager.rb +154 -3
- data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
- data/lib/clacky/ui_interface.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +11 -7
- data/lib/clacky/utils/trash_directory.rb +37 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2907 -1764
- data/lib/clacky/web/app.js +84 -10
- data/lib/clacky/web/billing.js +275 -0
- data/lib/clacky/web/brand.js +3 -0
- data/lib/clacky/web/i18n.js +242 -24
- data/lib/clacky/web/index.html +351 -134
- data/lib/clacky/web/mcp.js +328 -0
- data/lib/clacky/web/sessions.js +193 -11
- data/lib/clacky/web/settings.js +686 -174
- data/lib/clacky/web/sidebar.js +2 -0
- data/lib/clacky/web/trash.js +323 -60
- data/lib/clacky/web/ws-dispatcher.js +14 -1
- data/lib/clacky.rb +4 -0
- data/scripts/install.ps1 +23 -11
- metadata +30 -10
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
9
|
+
require_relative "transport"
|
|
10
|
+
|
|
11
|
+
module Clacky
|
|
12
|
+
module Mcp
|
|
13
|
+
# MCP streamable-http transport (spec 2025-03-26).
|
|
14
|
+
#
|
|
15
|
+
# One endpoint URL handles both client→server (POST) and server→client (SSE).
|
|
16
|
+
# We POST every JSON-RPC message; the server may respond with either:
|
|
17
|
+
# - application/json → single response, deliver immediately
|
|
18
|
+
# - text/event-stream → one or more "data:" SSE events, each a JSON-RPC msg
|
|
19
|
+
#
|
|
20
|
+
# Session tracking: the server returns Mcp-Session-Id on the initialize
|
|
21
|
+
# response; we echo it on every subsequent request.
|
|
22
|
+
class HttpTransport < Transport
|
|
23
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
24
|
+
DEFAULT_READ_TIMEOUT = 120
|
|
25
|
+
|
|
26
|
+
def initialize(name:, url:, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
27
|
+
@name = name
|
|
28
|
+
@uri = URI.parse(url)
|
|
29
|
+
raise TransportError, "MCP server '#{name}' url is not http(s): #{url}" unless %w[http https].include?(@uri.scheme)
|
|
30
|
+
|
|
31
|
+
@extra_headers = (headers || {}).transform_keys(&:to_s).transform_values(&:to_s)
|
|
32
|
+
@open_timeout = open_timeout
|
|
33
|
+
@read_timeout = read_timeout
|
|
34
|
+
|
|
35
|
+
@session_id = nil
|
|
36
|
+
@on_message = nil
|
|
37
|
+
@lock = Monitor.new
|
|
38
|
+
@alive = false
|
|
39
|
+
@last_error = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start
|
|
43
|
+
@alive = true
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stop
|
|
48
|
+
@alive = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def alive?
|
|
52
|
+
@alive
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def send_message(payload)
|
|
56
|
+
raise TransportError, "transport stopped" unless @alive
|
|
57
|
+
|
|
58
|
+
body = JSON.generate(payload)
|
|
59
|
+
is_request = payload.is_a?(Hash) && payload.key?(:id) || (payload.is_a?(Hash) && payload.key?("id"))
|
|
60
|
+
|
|
61
|
+
Thread.new do
|
|
62
|
+
begin
|
|
63
|
+
dispatch_post(body, is_request: is_request)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
@last_error = e
|
|
66
|
+
@on_message&.call({
|
|
67
|
+
"id" => payload[:id] || payload["id"],
|
|
68
|
+
"error" => { "code" => -32000, "message" => "HTTP transport error: #{e.message}" }
|
|
69
|
+
})
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def on_message(&blk)
|
|
75
|
+
@on_message = blk
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def stderr_tail(bytes: 4096)
|
|
79
|
+
@last_error ? "last error: #{@last_error.class}: #{@last_error.message}" : ""
|
|
80
|
+
end
|
|
81
|
+
|
|
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
|
+
|
|
88
|
+
req = Net::HTTP::Post.new(@uri.request_uri)
|
|
89
|
+
req["Content-Type"] = "application/json"
|
|
90
|
+
req["Accept"] = "application/json, text/event-stream"
|
|
91
|
+
req["MCP-Protocol-Version"] = Client::PROTOCOL_VERSION if defined?(Client::PROTOCOL_VERSION)
|
|
92
|
+
@lock.synchronize { req["Mcp-Session-Id"] = @session_id if @session_id }
|
|
93
|
+
@extra_headers.each { |k, v| req[k] = v }
|
|
94
|
+
req.body = body
|
|
95
|
+
|
|
96
|
+
http.request(req) do |res|
|
|
97
|
+
if (sid = res["Mcp-Session-Id"])
|
|
98
|
+
@lock.synchronize { @session_id = sid }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
status = res.code.to_i
|
|
102
|
+
if status == 202
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
if status >= 400
|
|
106
|
+
text = res.read_body.to_s
|
|
107
|
+
raise TransportError, "HTTP #{status} from MCP server '#{@name}': #{text[0, 500]}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
ctype = (res["Content-Type"] || "").downcase
|
|
111
|
+
if ctype.include?("text/event-stream")
|
|
112
|
+
consume_sse(res)
|
|
113
|
+
else
|
|
114
|
+
text = res.read_body.to_s
|
|
115
|
+
return if text.strip.empty?
|
|
116
|
+
begin
|
|
117
|
+
msg = JSON.parse(text)
|
|
118
|
+
rescue JSON::ParserError => e
|
|
119
|
+
raise TransportError, "invalid JSON from MCP server '#{@name}': #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
deliver(msg)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private def consume_sse(res)
|
|
127
|
+
buffer = String.new
|
|
128
|
+
res.read_body do |chunk|
|
|
129
|
+
buffer << chunk
|
|
130
|
+
while (idx = buffer.index("\n\n"))
|
|
131
|
+
event = buffer.slice!(0, idx + 2)
|
|
132
|
+
data_lines = event.each_line.map(&:chomp).select { |l| l.start_with?("data:") }
|
|
133
|
+
next if data_lines.empty?
|
|
134
|
+
payload = data_lines.map { |l| l.sub(/\Adata:\s?/, "") }.join("\n")
|
|
135
|
+
next if payload.empty?
|
|
136
|
+
begin
|
|
137
|
+
msg = JSON.parse(payload)
|
|
138
|
+
rescue JSON::ParserError
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
deliver(msg)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private def deliver(msg)
|
|
147
|
+
if msg.is_a?(Array)
|
|
148
|
+
msg.each { |m| @on_message&.call(m) }
|
|
149
|
+
else
|
|
150
|
+
@on_message&.call(msg)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "monitor"
|
|
6
|
+
|
|
7
|
+
require_relative "client"
|
|
8
|
+
require_relative "virtual_skill"
|
|
9
|
+
|
|
10
|
+
module Clacky
|
|
11
|
+
module Mcp
|
|
12
|
+
# Central registry for MCP servers configured by the user.
|
|
13
|
+
#
|
|
14
|
+
# Responsibilities:
|
|
15
|
+
# - Load ~/.clacky/mcp.json (or project .clacky/mcp.json) on demand.
|
|
16
|
+
# - For each declared server, expose a VirtualSkill so the main agent
|
|
17
|
+
# sees it as a one-line capability in the AVAILABLE SKILLS section.
|
|
18
|
+
# No tool schemas leak into the main context.
|
|
19
|
+
# - Lazily spawn the server process the first time invoke_skill('mcp:xxx')
|
|
20
|
+
# happens, cache the connection, and reap idle servers after a timeout.
|
|
21
|
+
# - Provide a single call_tool entry point for Tools::McpCall to dispatch
|
|
22
|
+
# into.
|
|
23
|
+
class Registry
|
|
24
|
+
# User-facing config files, in priority order (later wins).
|
|
25
|
+
# Resolved at load time (not at constant evaluation) so HOME changes — e.g.
|
|
26
|
+
# in tests using stub_const or Dir.mktmpdir + ENV — are honored.
|
|
27
|
+
private def global_config_paths
|
|
28
|
+
[File.join(Dir.home, ".clacky", "mcp.json")]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# How long an MCP server may sit idle before we reap it. Vital for the
|
|
32
|
+
# "no gateway" promise: we never keep stale processes around.
|
|
33
|
+
DEFAULT_IDLE_TIMEOUT = 300 # 5 min
|
|
34
|
+
|
|
35
|
+
# How long to wait for tools/list during cold metadata collection.
|
|
36
|
+
DESCRIPTION_FETCH_TIMEOUT = 20
|
|
37
|
+
|
|
38
|
+
attr_reader :servers
|
|
39
|
+
|
|
40
|
+
def initialize(working_dir: nil, idle_timeout: DEFAULT_IDLE_TIMEOUT)
|
|
41
|
+
@working_dir = working_dir
|
|
42
|
+
@idle_timeout = idle_timeout
|
|
43
|
+
@servers = {} # name => spec hash
|
|
44
|
+
@clients = {} # name => Client (only when started)
|
|
45
|
+
@virtual_skills_cache = nil
|
|
46
|
+
@lock = Monitor.new
|
|
47
|
+
@reaper_thread = nil
|
|
48
|
+
|
|
49
|
+
load_config
|
|
50
|
+
start_reaper
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reload mcp.json (e.g. user added a server) and invalidate caches.
|
|
54
|
+
# Existing live clients survive; only stopped/removed servers get cleaned.
|
|
55
|
+
def reload
|
|
56
|
+
@lock.synchronize do
|
|
57
|
+
old_names = @servers.keys
|
|
58
|
+
@servers = {}
|
|
59
|
+
load_config
|
|
60
|
+
@virtual_skills_cache = nil
|
|
61
|
+
|
|
62
|
+
(old_names - @servers.keys).each do |gone|
|
|
63
|
+
@clients.delete(gone)&.stop
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Map of server name -> VirtualSkill. Cached because rebuilding it triggers
|
|
69
|
+
# tools/list against every cold server, which we want to do at most once
|
|
70
|
+
# per process.
|
|
71
|
+
#
|
|
72
|
+
# Implementation note: we do NOT pre-spawn servers here. We need their
|
|
73
|
+
# tool list to populate the VirtualSkill body, but we only fetch it the
|
|
74
|
+
# first time the *subagent* actually fires up. For the system-prompt
|
|
75
|
+
# description we use the user-provided "description" field from mcp.json,
|
|
76
|
+
# falling back to a placeholder. This keeps app startup zero-cost.
|
|
77
|
+
def virtual_skills
|
|
78
|
+
@lock.synchronize do
|
|
79
|
+
return @virtual_skills_cache.values if @virtual_skills_cache
|
|
80
|
+
|
|
81
|
+
@virtual_skills_cache = {}
|
|
82
|
+
@servers.each do |name, spec|
|
|
83
|
+
@virtual_skills_cache[name] = VirtualSkill.new(
|
|
84
|
+
server_name: name,
|
|
85
|
+
description: spec["description"] || default_description_for(name)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
@virtual_skills_cache.values
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Return a fresh VirtualSkill for a server. The HttpServer's MCP tools
|
|
93
|
+
# endpoint uses this to satisfy probe requests; tool schemas are no
|
|
94
|
+
# longer attached to the skill itself — clients fetch them separately
|
|
95
|
+
# via /api/mcp/:name/tools.
|
|
96
|
+
# @param server_name [String]
|
|
97
|
+
# @return [VirtualSkill, nil]
|
|
98
|
+
def virtual_skill_for(server_name)
|
|
99
|
+
return nil unless @servers.key?(server_name)
|
|
100
|
+
|
|
101
|
+
spec = @servers[server_name]
|
|
102
|
+
VirtualSkill.new(
|
|
103
|
+
server_name: server_name,
|
|
104
|
+
description: spec["description"] || default_description_for(server_name)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Fetch the live tool list (and lazily cold-start the server). Used by
|
|
109
|
+
# the HttpServer for /api/mcp/:name/tools and /api/mcp/:name/probe.
|
|
110
|
+
def tool_definitions(server_name)
|
|
111
|
+
client = ensure_started(server_name)
|
|
112
|
+
return [] unless client
|
|
113
|
+
|
|
114
|
+
client.tool_definitions
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Execute a tool call against an MCP server. Used by Tools::McpCall.
|
|
118
|
+
# @return [Hash] MCP `tools/call` result
|
|
119
|
+
def call_tool(server_name, tool_name, arguments)
|
|
120
|
+
client = ensure_started(server_name)
|
|
121
|
+
raise Mcp::Client::TransportError, "MCP server '#{server_name}' is not configured" unless client
|
|
122
|
+
|
|
123
|
+
client.call_tool(tool_name, arguments)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Has the user configured any MCP servers?
|
|
127
|
+
def any?
|
|
128
|
+
!@servers.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def configured?(server_name)
|
|
132
|
+
@servers.key?(server_name)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Stop all live MCP server processes. Safe to call from at_exit hooks
|
|
136
|
+
# and on agent shutdown.
|
|
137
|
+
def shutdown
|
|
138
|
+
@lock.synchronize do
|
|
139
|
+
@reaper_thread&.kill rescue nil
|
|
140
|
+
@reaper_thread = nil
|
|
141
|
+
@clients.each_value { |c| c.stop rescue nil }
|
|
142
|
+
@clients.clear
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Spawn (if needed) and return the client for a server. Returns nil if the
|
|
147
|
+
# server is not configured. Raises Mcp::Client::TransportError if the
|
|
148
|
+
# process refuses to start.
|
|
149
|
+
private def ensure_started(server_name)
|
|
150
|
+
spec = @servers[server_name]
|
|
151
|
+
return nil unless spec
|
|
152
|
+
|
|
153
|
+
@lock.synchronize do
|
|
154
|
+
existing = @clients[server_name]
|
|
155
|
+
return existing if existing&.started?
|
|
156
|
+
|
|
157
|
+
client = Client.from_spec(server_name, spec)
|
|
158
|
+
client.start
|
|
159
|
+
@clients[server_name] = client
|
|
160
|
+
client
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private def load_config
|
|
165
|
+
paths = global_config_paths
|
|
166
|
+
if @working_dir
|
|
167
|
+
paths = paths + [File.join(@working_dir, ".clacky", "mcp.json")]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
paths.each do |path|
|
|
171
|
+
next unless File.exist?(path)
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
data = JSON.parse(File.read(path))
|
|
175
|
+
rescue JSON::ParserError => e
|
|
176
|
+
Clacky::Logger.warn("Skipping malformed MCP config #{path}: #{e.message}") if defined?(Clacky::Logger)
|
|
177
|
+
next
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
servers = data["mcpServers"] || data["servers"] || {}
|
|
181
|
+
servers.each do |name, spec|
|
|
182
|
+
next unless spec.is_a?(Hash)
|
|
183
|
+
next if spec["disabled"] == true
|
|
184
|
+
|
|
185
|
+
type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
|
|
186
|
+
case type
|
|
187
|
+
when "stdio"
|
|
188
|
+
next unless spec["command"]
|
|
189
|
+
when "http", "streamable-http"
|
|
190
|
+
next unless spec["url"]
|
|
191
|
+
else
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@servers[name.to_s] = spec
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private def default_description_for(name)
|
|
201
|
+
"MCP server '#{name}'. Use this skill to delegate any task that this server " \
|
|
202
|
+
"can handle. The subagent will see the server's full tool list at fork time."
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private def start_reaper
|
|
206
|
+
return if @idle_timeout.nil? || @idle_timeout <= 0
|
|
207
|
+
|
|
208
|
+
@reaper_thread = Thread.new do
|
|
209
|
+
loop do
|
|
210
|
+
sleep [@idle_timeout / 5, 30].min
|
|
211
|
+
now = Time.now
|
|
212
|
+
@lock.synchronize do
|
|
213
|
+
@clients.each do |name, client|
|
|
214
|
+
next unless client.last_used_at
|
|
215
|
+
if now - client.last_used_at > @idle_timeout
|
|
216
|
+
client.stop
|
|
217
|
+
@clients.delete(name)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError
|
|
223
|
+
# Reaper thread must never crash the main agent. Best-effort.
|
|
224
|
+
end
|
|
225
|
+
@reaper_thread.name = "mcp-reaper" if @reaper_thread.respond_to?(:name=)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "virtual_skill"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Mcp
|
|
9
|
+
# Static, read-only provider that translates ~/.clacky/mcp.json (and the
|
|
10
|
+
# project-level override) into VirtualSkill instances for the SkillLoader.
|
|
11
|
+
#
|
|
12
|
+
# Unlike Mcp::Registry, this class never spawns server processes, never
|
|
13
|
+
# talks JSON-RPC, and holds no mutable state. All actual MCP traffic flows
|
|
14
|
+
# through the local Clacky HTTP API (/api/mcp/:server/tools and /call),
|
|
15
|
+
# which subagents reach via curl. This keeps agents process-light and
|
|
16
|
+
# decouples skill discovery from server lifecycle.
|
|
17
|
+
class SkillProvider
|
|
18
|
+
def initialize(working_dir: nil)
|
|
19
|
+
@working_dir = working_dir
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def virtual_skills
|
|
23
|
+
load_servers.map do |name, spec|
|
|
24
|
+
VirtualSkill.new(
|
|
25
|
+
server_name: name,
|
|
26
|
+
description: spec["description"] || default_description_for(name)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private def load_servers
|
|
32
|
+
servers = {}
|
|
33
|
+
config_paths.each do |path|
|
|
34
|
+
next unless File.exist?(path)
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
data = JSON.parse(File.read(path))
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
Clacky::Logger.warn("Skipping malformed MCP config #{path}: #{e.message}") if defined?(Clacky::Logger)
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
(data["mcpServers"] || data["servers"] || {}).each do |name, spec|
|
|
44
|
+
next unless spec.is_a?(Hash)
|
|
45
|
+
next if spec["disabled"] == true
|
|
46
|
+
|
|
47
|
+
type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
|
|
48
|
+
case type
|
|
49
|
+
when "stdio"
|
|
50
|
+
next unless spec["command"]
|
|
51
|
+
when "http", "streamable-http"
|
|
52
|
+
next unless spec["url"]
|
|
53
|
+
else
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
servers[name.to_s] = spec
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
servers
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private def config_paths
|
|
64
|
+
paths = [File.join(Dir.home, ".clacky", "mcp.json")]
|
|
65
|
+
paths << File.join(@working_dir, ".clacky", "mcp.json") if @working_dir
|
|
66
|
+
paths
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def default_description_for(name)
|
|
70
|
+
"MCP server '#{name}'. Required entry point for any operation against " \
|
|
71
|
+
"this server — invoke this skill so a subagent runs the calls."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "monitor"
|
|
6
|
+
|
|
7
|
+
require_relative "transport"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module Mcp
|
|
11
|
+
class StdioTransport < Transport
|
|
12
|
+
def initialize(name:, command:, args: [], env: {}, cwd: nil)
|
|
13
|
+
@name = name
|
|
14
|
+
@command = command
|
|
15
|
+
@args = Array(args)
|
|
16
|
+
@env = env || {}
|
|
17
|
+
@cwd = cwd
|
|
18
|
+
|
|
19
|
+
@stdin = @stdout = @stderr = nil
|
|
20
|
+
@wait_thr = nil
|
|
21
|
+
@reader_thr = nil
|
|
22
|
+
@on_message = nil
|
|
23
|
+
@lock = Monitor.new
|
|
24
|
+
@stderr_buf = String.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start
|
|
28
|
+
full_env = ENV.to_h.merge(@env.transform_keys(&:to_s).transform_values(&:to_s))
|
|
29
|
+
opts = { unsetenv_others: false }
|
|
30
|
+
opts[:chdir] = @cwd if @cwd && File.directory?(@cwd)
|
|
31
|
+
|
|
32
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(full_env, @command, *@args, opts)
|
|
33
|
+
@stdin.sync = true
|
|
34
|
+
|
|
35
|
+
Thread.new do
|
|
36
|
+
@stderr.each_line do |line|
|
|
37
|
+
@lock.synchronize do
|
|
38
|
+
@stderr_buf << line
|
|
39
|
+
@stderr_buf.replace(@stderr_buf[-32_768, 32_768] || @stderr_buf) if @stderr_buf.bytesize > 65_536
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
rescue IOError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
start_reader
|
|
46
|
+
self
|
|
47
|
+
rescue Errno::ENOENT => e
|
|
48
|
+
raise TransportError, "MCP server '#{@name}' command not found: #{@command} (#{e.message})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stop
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
return unless @wait_thr&.alive?
|
|
54
|
+
begin
|
|
55
|
+
Process.kill("TERM", @wait_thr.pid)
|
|
56
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
57
|
+
end
|
|
58
|
+
deadline = Time.now + 2
|
|
59
|
+
sleep 0.05 while @wait_thr.alive? && Time.now < deadline
|
|
60
|
+
if @wait_thr.alive?
|
|
61
|
+
begin
|
|
62
|
+
Process.kill("KILL", @wait_thr.pid)
|
|
63
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
ensure
|
|
67
|
+
[@stdin, @stdout, @stderr].each { |io| io&.close rescue nil }
|
|
68
|
+
@reader_thr&.kill rescue nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def alive?
|
|
73
|
+
!!(@wait_thr && @wait_thr.alive?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def send_message(payload)
|
|
77
|
+
line = JSON.generate(payload) + "\n"
|
|
78
|
+
@lock.synchronize do
|
|
79
|
+
raise TransportError, "MCP server '#{@name}' stdin closed" if @stdin.nil? || @stdin.closed?
|
|
80
|
+
@stdin.write(line)
|
|
81
|
+
end
|
|
82
|
+
rescue Errno::EPIPE => e
|
|
83
|
+
raise TransportError, "MCP server '#{@name}' stdin pipe broken: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_message(&blk)
|
|
87
|
+
@on_message = blk
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stderr_tail(bytes: 4096)
|
|
91
|
+
@lock.synchronize { @stderr_buf[-bytes, bytes] || @stderr_buf.dup }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private def start_reader
|
|
95
|
+
@reader_thr = Thread.new do
|
|
96
|
+
@stdout.each_line do |line|
|
|
97
|
+
line = line.strip
|
|
98
|
+
next if line.empty?
|
|
99
|
+
begin
|
|
100
|
+
msg = JSON.parse(line)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
@on_message&.call(msg)
|
|
105
|
+
end
|
|
106
|
+
rescue IOError
|
|
107
|
+
@on_message&.call({ "__transport_closed__" => true })
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Mcp
|
|
5
|
+
# Abstract transport. Concrete transports must implement:
|
|
6
|
+
# #start -> open the channel (spawn process / open connection)
|
|
7
|
+
# #stop -> close everything; must be idempotent
|
|
8
|
+
# #alive? -> whether the channel is healthy
|
|
9
|
+
# #send_message(h) -> serialize and write one JSON-RPC message
|
|
10
|
+
# #on_message(&b) -> register callback invoked with each parsed inbound JSON message
|
|
11
|
+
# #stderr_tail(bytes:) -> recent diagnostic text (may be empty)
|
|
12
|
+
class Transport
|
|
13
|
+
class TransportError < StandardError; end
|
|
14
|
+
|
|
15
|
+
def start; raise NotImplementedError; end
|
|
16
|
+
def stop; raise NotImplementedError; end
|
|
17
|
+
def alive?; raise NotImplementedError; end
|
|
18
|
+
def send_message(_); raise NotImplementedError; end
|
|
19
|
+
def on_message(&_blk); raise NotImplementedError; end
|
|
20
|
+
def stderr_tail(bytes: 4096); ""; end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|