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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -0,0 +1,343 @@
1
+ ---
2
+ name: mcp-manager
3
+ description: |
4
+ Manage MCP (Model Context Protocol) servers for openclacky: add, list, probe, remove,
5
+ reconfigure. Edits ~/.clacky/mcp.json so the user never writes JSON by hand.
6
+ Trigger on: add mcp, install mcp, setup mcp, configure mcp, mcp list, mcp remove,
7
+ mcp probe, mcp reconfigure.
8
+ argument-hint: "add | list | probe <name> | remove <name> | reconfigure <name>"
9
+ allowed-tools:
10
+ - Bash
11
+ - Read
12
+ - Write
13
+ - Edit
14
+ - AskFollowupQuestion
15
+ ---
16
+
17
+ # MCP Manager Skill
18
+
19
+ Manage MCP servers for openclacky. The user's MCP configuration lives at
20
+ `~/.clacky/mcp.json` (the same format Claude Desktop and Cursor use). You never
21
+ ask the user to edit it by hand — you do it for them through the local clacky
22
+ HTTP API.
23
+
24
+ ---
25
+
26
+ ## Command Parsing
27
+
28
+ | User says | Subcommand |
29
+ |---|---|
30
+ | `add mcp`, `install mcp`, `connect <something>`, "I want clacky to read my files / access github / query my db / search the web" | `add` |
31
+ | `mcp list`, `mcp status`, "what mcps do I have" | `list` |
32
+ | `mcp probe <name>`, "what tools does <name> have" | `probe` |
33
+ | `mcp remove <name>`, `mcp delete <name>` | `remove` |
34
+ | `mcp reconfigure <name>`, `mcp fix <name>` | `reconfigure` |
35
+
36
+ If the intent is unclear, default to **`add`** — it's the most common ask.
37
+
38
+ ---
39
+
40
+ ## Server Coordinates
41
+
42
+ All API calls go to the local clacky server. The host and port are exposed via
43
+ environment variables:
44
+
45
+ ```bash
46
+ HOST="${CLACKY_SERVER_HOST:-127.0.0.1}"
47
+ PORT="${CLACKY_SERVER_PORT:-7070}"
48
+ BASE="http://${HOST}:${PORT}"
49
+ ```
50
+
51
+ All write operations require requests to come from `127.0.0.1` or `::1`. They
52
+ will, because we're running locally.
53
+
54
+ ---
55
+
56
+ ## API Cheat Sheet
57
+
58
+ | Action | Call |
59
+ |---|---|
60
+ | List configured servers | `curl -s ${BASE}/api/mcp` |
61
+ | Add a server | `curl -s -X POST ${BASE}/api/mcp -H 'Content-Type: application/json' -d '{...}'` |
62
+ | Update a server | `curl -s -X PUT ${BASE}/api/mcp/<name> -H 'Content-Type: application/json' -d '{...}'` |
63
+ | Remove a server | `curl -s -X DELETE ${BASE}/api/mcp/<name>` |
64
+ | Probe tools | `curl -s -X POST ${BASE}/api/mcp/<name>/probe` |
65
+
66
+ Request body for create/update — **stdio** (local process, default):
67
+
68
+ ```json
69
+ {
70
+ "name": "filesystem",
71
+ "command": "npx",
72
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/Documents"],
73
+ "env": { "API_KEY": "xxx" },
74
+ "description": "Read/write files in ~/Documents"
75
+ }
76
+ ```
77
+
78
+ Request body for create/update — **http** (remote server, streamable-http):
79
+
80
+ ```json
81
+ {
82
+ "name": "linear",
83
+ "type": "http",
84
+ "url": "https://mcp.linear.app/sse",
85
+ "headers": { "Authorization": "Bearer lin_api_xxx" },
86
+ "description": "Linear issues and projects"
87
+ }
88
+ ```
89
+
90
+ If `type` is omitted but `url` is present, the server treats it as `http`.
91
+
92
+ ---
93
+
94
+ ## Known-Good Server Catalog
95
+
96
+ When the user describes what they want, match it to one of these and propose it.
97
+ Each entry: package, what it does, required params, recommended `description`.
98
+
99
+ ### 1. `filesystem` — read/write local files
100
+ - **When**: "read my files", "access my desktop", "browse my code"
101
+ - **Command**: `npx`
102
+ - **Args**: `["-y", "@modelcontextprotocol/server-filesystem", "<ABSOLUTE_PATH>"]`
103
+ - **Required**: absolute directory path (ask user; default to `~/Documents`)
104
+ - **Tools**: read_file, write_file, list_directory, search_files, etc.
105
+
106
+ ### 2. `github` — GitHub repos, issues, PRs
107
+ - **When**: "access github", "manage my repos", "read my issues"
108
+ - **Command**: `npx`
109
+ - **Args**: `["-y", "@modelcontextprotocol/server-github"]`
110
+ - **Env**: `{ "GITHUB_PERSONAL_ACCESS_TOKEN": "<TOKEN>" }`
111
+ - **Required**: PAT from https://github.com/settings/tokens (recommend `repo` scope)
112
+
113
+ ### 3. `fetch` — fetch HTTP URLs as markdown
114
+ - **When**: "fetch web pages", "read articles by url"
115
+ - **Command**: `uvx`
116
+ - **Args**: `["mcp-server-fetch"]`
117
+ - **Required**: nothing
118
+ - **Note**: needs Python `uv` installed (`brew install uv`)
119
+
120
+ ### 4. `memory` — persistent knowledge graph
121
+ - **When**: "remember things across sessions", "give clacky long-term memory"
122
+ - **Command**: `npx`
123
+ - **Args**: `["-y", "@modelcontextprotocol/server-memory"]`
124
+ - **Required**: nothing
125
+
126
+ ### 5. `postgres` — query a Postgres database
127
+ - **When**: "query my database", "connect to postgres"
128
+ - **Command**: `npx`
129
+ - **Args**: `["-y", "@modelcontextprotocol/server-postgres", "<DATABASE_URL>"]`
130
+ - **Required**: DATABASE_URL like `postgresql://user:pass@host:5432/dbname`
131
+
132
+ ### 6. `slack` — Slack messages
133
+ - **When**: "read slack", "send slack messages"
134
+ - **Command**: `npx`
135
+ - **Args**: `["-y", "@modelcontextprotocol/server-slack"]`
136
+ - **Env**: `{ "SLACK_BOT_TOKEN": "xoxb-...", "SLACK_TEAM_ID": "T..." }`
137
+ - **Required**: bot token and team id (Slack admin → app config)
138
+
139
+ ### 7. `brave-search` — web search via Brave API
140
+ - **When**: "search the web", "give clacky search"
141
+ - **Command**: `npx`
142
+ - **Args**: `["-y", "@modelcontextprotocol/server-brave-search"]`
143
+ - **Env**: `{ "BRAVE_API_KEY": "<KEY>" }`
144
+ - **Required**: free API key from https://api.search.brave.com/
145
+
146
+ ### 8. `puppeteer` — browser automation
147
+ - **When**: "automate the browser", "scrape with js"
148
+ - **Command**: `npx`
149
+ - **Args**: `["-y", "@modelcontextprotocol/server-puppeteer"]`
150
+ - **Required**: nothing (downloads Chromium on first run)
151
+
152
+ ### Custom (anything else)
153
+ If the user names a package or path you don't recognize, take the spec from them
154
+ verbatim and pass it through. Always confirm `command`, `args`, and `env` back
155
+ in plain language before saving.
156
+
157
+ ### Remote / HTTP servers (streamable-http)
158
+ Some MCP servers are hosted services and don't ship as a CLI — you connect over
159
+ HTTPS instead. **Trigger when** the user gives you a URL ending in `/mcp`,
160
+ `/sse`, or hosted on `*.mcp.*` / `mcp.*.app`, or says "the server is at
161
+ https://...".
162
+
163
+ - **Type**: `http`
164
+ - **Required**: `url` (the streamable-http endpoint)
165
+ - **Optional**: `headers` — typically `{ "Authorization": "Bearer <token>" }`
166
+
167
+ Examples of remote MCP servers in the wild:
168
+ - Linear: `https://mcp.linear.app/sse` (Bearer API key)
169
+ - Cloudflare: `https://<workers-subdomain>.workers.dev/mcp` (Bearer token)
170
+ - GitHub Copilot: `https://api.githubcopilot.com/mcp/` (OAuth, advanced)
171
+
172
+ When the user pastes a URL, ask:
173
+ 1. What service is this? (so you can pick a `name` and `description`)
174
+ 2. Does it need an authorization header? If yes, paste the token.
175
+
176
+ Save with `type: "http"`. The local clacky never spawns a process for these —
177
+ it just POSTs JSON-RPC over HTTPS.
178
+
179
+ > ⚠️ Wrapping a regular CLI tool: if the user gives you a CLI command that is
180
+ > **not** a stdio MCP server (e.g. `mcp-cli`, `some-api-cli login`), do NOT save
181
+ > it as a stdio MCP entry — it won't speak JSON-RPC over stdin. Tell them: *"This
182
+ > looks like a regular CLI, not an MCP server. Does the service offer an HTTPS
183
+ > endpoint instead?"*
184
+
185
+ ---
186
+
187
+ ## Subcommand: `add` — the primary flow
188
+
189
+ Goal: the user describes what they want, you produce a working MCP entry +
190
+ confirm it works. Keep questions minimal.
191
+
192
+ ### Step 1 — Identify intent
193
+ - If the user's first message already names a server (e.g. "add filesystem"),
194
+ pick that catalog entry directly.
195
+ - Otherwise, ask **one** open question: *"What would you like Clacky to be
196
+ able to do? (e.g. read your files, access GitHub, search the web)"*
197
+ - Match their answer to the catalog. If multiple match, present 2–3 options
198
+ with one-line descriptions and let them pick.
199
+
200
+ ### Step 2 — Environment preflight
201
+ Before asking for parameters, check the runtime is installed:
202
+
203
+ ```bash
204
+ # For npx-based servers
205
+ which npx >/dev/null 2>&1 || echo "MISSING_NPX"
206
+
207
+ # For uvx-based servers
208
+ which uvx >/dev/null 2>&1 || echo "MISSING_UVX"
209
+ ```
210
+
211
+ If missing, tell the user how to install (`brew install node` for npx,
212
+ `brew install uv` for uvx) and stop. Do not proceed.
213
+
214
+ ### Step 3 — Collect parameters
215
+ Ask only for the **business-meaningful** params from the catalog entry:
216
+ - For `filesystem`: which directory? Default offer: `~/Documents`. Resolve `~`
217
+ to an absolute path before saving.
218
+ - For `github`/`brave-search`/`slack`: tell them where to get the token, then
219
+ ask them to paste it.
220
+ - For `postgres`: ask for the connection URL.
221
+
222
+ Never invent values. If you don't have a sensible default, ask.
223
+
224
+ ### Step 4 — Confirm
225
+ Show the user the spec you're about to save, in plain language:
226
+
227
+ > I'll add a server called **filesystem** that runs `npx -y @modelcontextprotocol/server-filesystem /Users/me/Documents`. It'll let me read and write files in your Documents folder. OK?
228
+
229
+ For secrets (tokens, passwords), echo only the last 4 characters: `***...abcd`.
230
+
231
+ ### Step 5 — Save
232
+ For stdio:
233
+ ```bash
234
+ curl -s -X POST ${BASE}/api/mcp \
235
+ -H 'Content-Type: application/json' \
236
+ -d '{
237
+ "name": "filesystem",
238
+ "command": "npx",
239
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/Documents"],
240
+ "description": "Read/write files in ~/Documents"
241
+ }'
242
+ ```
243
+
244
+ For http:
245
+ ```bash
246
+ curl -s -X POST ${BASE}/api/mcp \
247
+ -H 'Content-Type: application/json' \
248
+ -d '{
249
+ "name": "linear",
250
+ "type": "http",
251
+ "url": "https://mcp.linear.app/sse",
252
+ "headers": { "Authorization": "Bearer lin_api_xxx" },
253
+ "description": "Linear issues and projects"
254
+ }'
255
+ ```
256
+
257
+ If the response has `"ok": false`, show the error and ask the user how to
258
+ proceed (retry, edit, abort).
259
+
260
+ ### Step 6 — Probe
261
+ Immediately verify the server starts and exposes tools:
262
+
263
+ ```bash
264
+ curl -s -X POST ${BASE}/api/mcp/filesystem/probe
265
+ ```
266
+
267
+ - **`ok: true`**: extract `tools[]`, summarize for the user. Example:
268
+ > Done. **filesystem** is working — Clacky now has 11 new tools (read_file, write_file, list_directory, ...). Try asking me to *list files in your Documents folder*.
269
+ - **`ok: false`**: show the error verbatim and offer common fixes:
270
+ - "command not found" → wrong runtime, suggest re-running with correct one
271
+ - "ENOENT" / "no such file" → bad path, ask for a valid one
272
+ - timeout → package may be downloading on first run; suggest retrying
273
+ - auth-related → token wrong/expired, offer `reconfigure`
274
+
275
+ ### Step 7 — Hint at next steps
276
+ End with a one-line nudge: how the user can use the new MCP next. Examples:
277
+ - filesystem: "Try: *list the files in my Documents folder*"
278
+ - github: "Try: *show me my open PRs*"
279
+ - fetch: "Try: *fetch https://news.ycombinator.com and summarize*"
280
+
281
+ ---
282
+
283
+ ## Subcommand: `list`
284
+
285
+ ```bash
286
+ curl -s ${BASE}/api/mcp
287
+ ```
288
+
289
+ Render as a short table. If `configured: false`, say so and offer to run `add`.
290
+
291
+ ```
292
+ | Name | Command | Args summary | Has env |
293
+ |--------------|---------|------------------------|---------|
294
+ | filesystem | npx | @modelcontextprotocol… | no |
295
+ | github | npx | @modelcontextprotocol… | yes |
296
+ ```
297
+
298
+ Don't show full args if they contain absolute paths — collapse them with `…`.
299
+
300
+ ---
301
+
302
+ ## Subcommand: `probe <name>`
303
+
304
+ ```bash
305
+ curl -s -X POST ${BASE}/api/mcp/<name>/probe
306
+ ```
307
+
308
+ If `ok: true`, list every tool with a one-line description. If `ok: false`, run
309
+ the same error-fixing flow as in `add` step 6.
310
+
311
+ ---
312
+
313
+ ## Subcommand: `remove <name>`
314
+
315
+ 1. Confirm with the user first: *"Remove **<name>**? Its tools will no longer
316
+ be available to Clacky. (Y/n)"*
317
+ 2. On yes:
318
+ ```bash
319
+ curl -s -X DELETE ${BASE}/api/mcp/<name>
320
+ ```
321
+ 3. Confirm completion in one line.
322
+
323
+ ---
324
+
325
+ ## Subcommand: `reconfigure <name>`
326
+
327
+ 1. Fetch current spec from `/api/mcp` and show it back.
328
+ 2. Ask which fields to change (path / token / args).
329
+ 3. Build the new spec and `PUT /api/mcp/<name>`.
330
+ 4. Probe to verify, same as `add` step 6.
331
+
332
+ ---
333
+
334
+ ## General Rules
335
+
336
+ - **Never write directly to `~/.clacky/mcp.json`.** Always go through the API.
337
+ - **Never echo full secrets.** Mask all but last 4 chars of tokens/URLs.
338
+ - **One question at a time.** Don't dump a form on the user.
339
+ - **Stop on errors.** Don't proceed past a failed preflight or probe.
340
+ - **Quote real error messages.** Don't paraphrase API errors — users may need to
341
+ google them.
342
+ - **Stay in scope.** If the user wants to write/edit a non-MCP file or do
343
+ unrelated work, hand back to the main agent.
@@ -14,8 +14,10 @@ module Clacky
14
14
  # timer.start # call after each agent run completes
15
15
  # timer.cancel # call when new user input arrives
16
16
  class IdleCompressionTimer
17
- # Seconds of inactivity before idle compression is triggered
18
- IDLE_DELAY = 180
17
+ # Seconds of inactivity before idle compression is triggered.
18
+ # Kept under the 5-minute prompt cache TTL so the compression call itself
19
+ # still hits the existing prefix cache.
20
+ IDLE_DELAY = 314
19
21
 
20
22
  # @param agent [Clacky::Agent] the agent whose messages will be compressed
21
23
  # @param session_manager [Clacky::SessionManager, nil] used to persist session after compression
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+
6
+ require_relative "transport"
7
+ require_relative "stdio_transport"
8
+ require_relative "http_transport"
9
+
10
+ module Clacky
11
+ module Mcp
12
+ # JSON-RPC 2.0 client for a single MCP server.
13
+ #
14
+ # Lifecycle: open transport on #start, send `initialize` handshake, then any
15
+ # number of `tools/list` / `tools/call` requests, then #stop closes the
16
+ # transport. Transport is selected by spec["type"]: "stdio" (default) or "http".
17
+ class Client
18
+ class McpError < StandardError; end
19
+ TransportError = Mcp::Transport::TransportError
20
+ class ProtocolError < McpError; end
21
+
22
+ DEFAULT_TIMEOUT = 30
23
+ INIT_TIMEOUT = 15
24
+
25
+ PROTOCOL_VERSION = "2024-11-05"
26
+
27
+ attr_reader :name, :tools, :server_info, :started_at, :last_used_at
28
+
29
+ # Build a Client from an mcp.json spec hash.
30
+ # Recognized fields:
31
+ # stdio: command (required), args, env, cwd
32
+ # http: type: "http", url (required), headers
33
+ def self.from_spec(name, spec)
34
+ type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
35
+ case type
36
+ when "stdio"
37
+ new(
38
+ name: name,
39
+ transport: StdioTransport.new(
40
+ name: name,
41
+ command: spec["command"],
42
+ args: Array(spec["args"]),
43
+ env: spec["env"] || {},
44
+ cwd: spec["cwd"]
45
+ )
46
+ )
47
+ when "http", "streamable-http"
48
+ new(
49
+ name: name,
50
+ transport: HttpTransport.new(
51
+ name: name,
52
+ url: spec["url"],
53
+ headers: spec["headers"] || {}
54
+ )
55
+ )
56
+ else
57
+ raise McpError, "unsupported MCP transport type '#{type}' for server '#{name}'"
58
+ end
59
+ end
60
+
61
+ def initialize(name:, transport: nil, command: nil, args: [], env: {}, cwd: nil)
62
+ @name = name
63
+ @transport = transport || StdioTransport.new(name: name, command: command, args: args, env: env, cwd: cwd)
64
+
65
+ @pending = {}
66
+ @next_id = 0
67
+ @lock = Monitor.new
68
+ @started = false
69
+
70
+ @tools = []
71
+ @server_info = nil
72
+ @started_at = nil
73
+ @last_used_at = nil
74
+
75
+ @transport.on_message do |msg|
76
+ if msg["__transport_closed__"]
77
+ @lock.synchronize do
78
+ @pending.each_value { |q| q.push({ "error" => { "code" => -32000, "message" => "transport closed" } }) }
79
+ @pending.clear
80
+ end
81
+ next
82
+ end
83
+
84
+ id = msg["id"]
85
+ if id && (queue = @lock.synchronize { @pending.delete(id) })
86
+ queue.push(msg)
87
+ end
88
+ end
89
+ end
90
+
91
+ def started?
92
+ @started
93
+ end
94
+
95
+ def start
96
+ already_started = false
97
+ @lock.synchronize do
98
+ if @started
99
+ already_started = true
100
+ else
101
+ @transport.start
102
+ end
103
+ end
104
+ return self if already_started
105
+
106
+ handshake
107
+ fetch_tools
108
+
109
+ @lock.synchronize do
110
+ @started = true
111
+ @started_at = Time.now
112
+ @last_used_at = @started_at
113
+ end
114
+ self
115
+ end
116
+
117
+ def stop
118
+ @lock.synchronize do
119
+ @transport.stop rescue nil
120
+ @started = false
121
+ end
122
+ end
123
+
124
+ def tool_definitions
125
+ @tools.map do |t|
126
+ {
127
+ type: "function",
128
+ function: {
129
+ name: t["name"],
130
+ description: t["description"].to_s,
131
+ parameters: t["inputSchema"] || { type: "object", properties: {} }
132
+ }
133
+ }
134
+ end
135
+ end
136
+
137
+ def call_tool(tool_name, arguments = {})
138
+ ensure_started!
139
+ @last_used_at = Time.now
140
+ request("tools/call", { name: tool_name, arguments: arguments || {} })
141
+ end
142
+
143
+ def stderr_tail(bytes: 4096)
144
+ @transport.stderr_tail(bytes: bytes)
145
+ end
146
+
147
+ private def ensure_started!
148
+ raise TransportError, "MCP client '#{@name}' is not started" unless @started
149
+ raise TransportError, "MCP server '#{@name}' transport closed" unless @transport.alive?
150
+ end
151
+
152
+ private def handshake
153
+ result = request("initialize", {
154
+ protocolVersion: PROTOCOL_VERSION,
155
+ capabilities: { tools: {} },
156
+ clientInfo: { name: "openclacky", version: Clacky::VERSION }
157
+ }, timeout: INIT_TIMEOUT)
158
+
159
+ @server_info = result["serverInfo"]
160
+ notify("notifications/initialized")
161
+ end
162
+
163
+ private def fetch_tools
164
+ result = request("tools/list", {})
165
+ @tools = result["tools"] || []
166
+ end
167
+
168
+ private def request(method, params, timeout: DEFAULT_TIMEOUT)
169
+ id = nil
170
+ queue = Queue.new
171
+ @lock.synchronize do
172
+ id = (@next_id += 1)
173
+ @pending[id] = queue
174
+ end
175
+
176
+ payload = { jsonrpc: "2.0", id: id, method: method, params: params }
177
+ @transport.send_message(payload)
178
+
179
+ msg = nil
180
+ begin
181
+ require "timeout"
182
+ msg = Timeout.timeout(timeout) { queue.pop }
183
+ rescue Timeout::Error
184
+ msg = nil
185
+ end
186
+
187
+ if msg.nil?
188
+ @lock.synchronize { @pending.delete(id) }
189
+ raise TransportError, "MCP request '#{method}' to '#{@name}' timed out after #{timeout}s"
190
+ end
191
+
192
+ if (err = msg["error"])
193
+ raise ProtocolError, "MCP server '#{@name}' error on #{method}: #{err["message"]} (code #{err["code"]})"
194
+ end
195
+
196
+ msg["result"] || {}
197
+ end
198
+
199
+ private def notify(method, params = {})
200
+ @transport.send_message({ jsonrpc: "2.0", method: method, params: params })
201
+ end
202
+ end
203
+ end
204
+ end