earl-bot 0.1.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 +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- metadata +248 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Earl
|
|
7
|
+
module Mcp
|
|
8
|
+
# MCP handler exposing a manage_heartbeat tool to create, update, delete,
|
|
9
|
+
# and list heartbeat schedules. Writes changes to the heartbeats YAML file;
|
|
10
|
+
# the HeartbeatScheduler auto-reloads when it detects file changes.
|
|
11
|
+
# Conforms to the Server handler interface: tool_definitions, handles?, call.
|
|
12
|
+
class HeartbeatHandler
|
|
13
|
+
include HandlerBase
|
|
14
|
+
|
|
15
|
+
TOOL_NAMES = %w[manage_heartbeat].freeze
|
|
16
|
+
def self.config_path
|
|
17
|
+
@config_path ||= File.join(Earl.config_root, "heartbeats.yml")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
VALID_ACTIONS = %w[list create update delete].freeze
|
|
21
|
+
MUTABLE_FIELDS = %w[description cron interval run_at channel_id working_dir
|
|
22
|
+
prompt permission_mode persistent timeout enabled once].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(default_channel_id: nil, config_path: self.class.config_path)
|
|
25
|
+
@default_channel_id = default_channel_id
|
|
26
|
+
@config_path = config_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tool_definitions
|
|
30
|
+
[manage_heartbeat_definition]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
NAME_REQUIRED_ACTIONS = %w[create update delete].freeze
|
|
34
|
+
|
|
35
|
+
def call(name, arguments)
|
|
36
|
+
return unless handles?(name)
|
|
37
|
+
|
|
38
|
+
error = validate_action(arguments)
|
|
39
|
+
return error if error
|
|
40
|
+
|
|
41
|
+
send("handle_#{arguments["action"]}", arguments)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def validate_action(arguments)
|
|
47
|
+
action = arguments["action"]
|
|
48
|
+
return text_content("Error: action is required (list, create, update, delete)") unless action
|
|
49
|
+
unless VALID_ACTIONS.include?(action)
|
|
50
|
+
return text_content("Error: unknown action '#{action}'. Valid: #{VALID_ACTIONS.join(", ")}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
text_content("Error: name is required") if name_required_but_missing?(action, arguments)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def name_required_but_missing?(action, arguments)
|
|
57
|
+
return false unless NAME_REQUIRED_ACTIONS.include?(action)
|
|
58
|
+
|
|
59
|
+
hb_name = arguments["name"]
|
|
60
|
+
!hb_name || hb_name.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# --- Action handlers ---
|
|
64
|
+
|
|
65
|
+
def handle_list(_arguments)
|
|
66
|
+
data = load_yaml
|
|
67
|
+
heartbeats = data["heartbeats"] || {}
|
|
68
|
+
return text_content("No heartbeats defined.") if heartbeats.empty?
|
|
69
|
+
|
|
70
|
+
lines = heartbeats.map { |name, config| format_heartbeat(name, config) }
|
|
71
|
+
text_content("**Heartbeats (#{heartbeats.size}):**\n\n#{lines.join("\n\n")}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_create(arguments)
|
|
75
|
+
name = arguments["name"]
|
|
76
|
+
data = load_yaml
|
|
77
|
+
heartbeats = (data["heartbeats"] ||= {})
|
|
78
|
+
return text_content("Error: heartbeat '#{name}' already exists") if heartbeats.key?(name)
|
|
79
|
+
|
|
80
|
+
heartbeats[name] = build_entry(arguments)
|
|
81
|
+
save_yaml(data)
|
|
82
|
+
text_content("Created heartbeat '#{name}'. Scheduler will pick it up within 30 seconds.")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_update(arguments)
|
|
86
|
+
name = arguments["name"]
|
|
87
|
+
data = load_yaml
|
|
88
|
+
heartbeats = (data["heartbeats"] ||= {})
|
|
89
|
+
entry = heartbeats.fetch(name, nil)
|
|
90
|
+
return text_content("Error: heartbeat '#{name}' not found") unless entry
|
|
91
|
+
|
|
92
|
+
merge_entry(entry, arguments)
|
|
93
|
+
save_yaml(data)
|
|
94
|
+
text_content("Updated heartbeat '#{name}'. Scheduler will pick up changes within 30 seconds.")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_delete(arguments)
|
|
98
|
+
name = arguments["name"]
|
|
99
|
+
data = load_yaml
|
|
100
|
+
heartbeats = (data["heartbeats"] ||= {})
|
|
101
|
+
removed = heartbeats.delete(name)
|
|
102
|
+
return text_content("Error: heartbeat '#{name}' not found") unless removed
|
|
103
|
+
|
|
104
|
+
save_yaml(data)
|
|
105
|
+
text_content("Deleted heartbeat '#{name}'. Scheduler will remove it within 30 seconds.")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- YAML I/O ---
|
|
109
|
+
|
|
110
|
+
# YAML file persistence for heartbeat definitions.
|
|
111
|
+
module YamlPersistence
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def load_yaml
|
|
115
|
+
return { "heartbeats" => {} } unless File.exist?(@config_path)
|
|
116
|
+
|
|
117
|
+
data = YAML.safe_load_file(@config_path)
|
|
118
|
+
data.is_a?(Hash) ? data : { "heartbeats" => {} }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def save_yaml(data)
|
|
122
|
+
FileUtils.mkdir_p(File.dirname(@config_path))
|
|
123
|
+
# Use file lock to coordinate with HeartbeatScheduler#disable_heartbeat
|
|
124
|
+
File.open(@config_path, File::CREAT | File::WRONLY | File::TRUNC) do |file|
|
|
125
|
+
file.flock(File::LOCK_EX)
|
|
126
|
+
file.write(YAML.dump(data))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
include YamlPersistence
|
|
132
|
+
|
|
133
|
+
# Entry building: constructs and merges heartbeat configuration hashes.
|
|
134
|
+
module EntryBuilder
|
|
135
|
+
OPTIONAL_STRING_KEYS = %w[working_dir prompt permission_mode timeout].freeze
|
|
136
|
+
SCHEDULE_KEYS = %w[cron interval run_at].freeze
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def build_entry(arguments)
|
|
141
|
+
schedule = slice_present(arguments, SCHEDULE_KEYS)
|
|
142
|
+
schedule["run_at"] = Time.now.to_i if arguments["once"] && schedule.empty?
|
|
143
|
+
|
|
144
|
+
base = slice_present(arguments, %w[description once])
|
|
145
|
+
base.merge(
|
|
146
|
+
"schedule" => schedule,
|
|
147
|
+
"channel_id" => arguments["channel_id"] || @default_channel_id,
|
|
148
|
+
"enabled" => arguments.fetch("enabled", true)
|
|
149
|
+
).merge(slice_present(arguments, OPTIONAL_STRING_KEYS))
|
|
150
|
+
.merge(slice_key(arguments, "persistent"))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def slice_present(hash, keys)
|
|
154
|
+
keys.each_with_object({}) do |key, result|
|
|
155
|
+
result[key] = hash[key] if hash.key?(key)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def slice_key(hash, key)
|
|
160
|
+
hash.key?(key) ? { key => hash[key] } : {}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def merge_entry(entry, arguments)
|
|
164
|
+
schedule = (entry["schedule"] ||= {})
|
|
165
|
+
MUTABLE_FIELDS.each do |field|
|
|
166
|
+
next unless arguments.key?(field)
|
|
167
|
+
|
|
168
|
+
value = arguments[field]
|
|
169
|
+
target = SCHEDULE_KEYS.include?(field) ? schedule : entry
|
|
170
|
+
target[field] = value
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_schedule(arguments)
|
|
175
|
+
slice_present(arguments, SCHEDULE_KEYS)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
include EntryBuilder
|
|
180
|
+
|
|
181
|
+
# --- Formatting ---
|
|
182
|
+
|
|
183
|
+
# Human-readable heartbeat list formatting.
|
|
184
|
+
module Formatting
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def format_heartbeat(name, config)
|
|
188
|
+
schedule, enabled, once, description = extract_display_fields(config, name)
|
|
189
|
+
schedule_str = format_schedule(schedule)
|
|
190
|
+
enabled_str = enabled ? "enabled" : "disabled"
|
|
191
|
+
once_badge = once ? ", once" : ""
|
|
192
|
+
"- **#{name}** (#{enabled_str}#{once_badge}, #{schedule_str})\n #{description}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def extract_display_fields(config, name)
|
|
196
|
+
[
|
|
197
|
+
config["schedule"] || {},
|
|
198
|
+
config.fetch("enabled", true),
|
|
199
|
+
config.fetch("once", false),
|
|
200
|
+
config["description"] || name
|
|
201
|
+
]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_schedule(schedule)
|
|
205
|
+
cron = schedule["cron"]
|
|
206
|
+
interval = schedule["interval"]
|
|
207
|
+
run_at = schedule["run_at"]
|
|
208
|
+
|
|
209
|
+
return "cron: `#{cron}`" if cron
|
|
210
|
+
return "interval: #{interval}s" if interval
|
|
211
|
+
return "run_at: #{Time.at(run_at).strftime("%Y-%m-%d %H:%M:%S")}" if run_at
|
|
212
|
+
|
|
213
|
+
"no schedule"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
include Formatting
|
|
218
|
+
|
|
219
|
+
# --- MCP response helper ---
|
|
220
|
+
|
|
221
|
+
def text_content(text)
|
|
222
|
+
{ content: [{ type: "text", text: text }] }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Tool definition building: splits the large schema into composable property groups.
|
|
226
|
+
module ToolDefinitionBuilder
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def manage_heartbeat_definition
|
|
230
|
+
{
|
|
231
|
+
name: "manage_heartbeat",
|
|
232
|
+
description: heartbeat_tool_description,
|
|
233
|
+
inputSchema: heartbeat_input_schema
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def heartbeat_tool_description
|
|
238
|
+
"Manage Earl's heartbeat schedules. " \
|
|
239
|
+
"Create, update, delete, or list tasks that run on cron, interval, or one-shot (run_at) schedules."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def heartbeat_input_schema
|
|
243
|
+
{
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: heartbeat_properties,
|
|
246
|
+
required: %w[action]
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def heartbeat_properties
|
|
251
|
+
{}.merge(action_properties)
|
|
252
|
+
.merge(schedule_properties)
|
|
253
|
+
.merge(session_properties)
|
|
254
|
+
.merge(behavior_properties)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def action_properties
|
|
258
|
+
{
|
|
259
|
+
action: {
|
|
260
|
+
type: "string", enum: VALID_ACTIONS,
|
|
261
|
+
description: "Action to perform: list, create, update, or delete"
|
|
262
|
+
},
|
|
263
|
+
name: { type: "string", description: "Heartbeat name (required for create/update/delete)" },
|
|
264
|
+
description: { type: "string", description: "Human-readable description of the heartbeat" }
|
|
265
|
+
}
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def schedule_properties
|
|
269
|
+
{
|
|
270
|
+
cron: { type: "string", description: "Cron expression (e.g. '0 9 * * 1-5' for weekdays at 9am)" },
|
|
271
|
+
interval: { type: "integer", description: "Interval in seconds between runs (alternative to cron)" },
|
|
272
|
+
run_at: {
|
|
273
|
+
type: "integer",
|
|
274
|
+
description: "Unix timestamp for one-shot execution (alternative to cron/interval). " \
|
|
275
|
+
"Must be used with once: true to avoid repeated firing."
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def session_properties
|
|
281
|
+
{
|
|
282
|
+
channel_id: { type: "string",
|
|
283
|
+
description: "Mattermost channel ID to post results (defaults to current channel)" },
|
|
284
|
+
working_dir: { type: "string", description: "Working directory for the Claude session" },
|
|
285
|
+
prompt: { type: "string", description: "The prompt to send to Claude when the heartbeat fires" },
|
|
286
|
+
permission_mode: {
|
|
287
|
+
type: "string", enum: %w[auto interactive],
|
|
288
|
+
description: "Permission mode: auto (no approval) or interactive (requires reaction approval)"
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def behavior_properties
|
|
294
|
+
{
|
|
295
|
+
persistent: { type: "boolean", description: "Whether to reuse the same Claude session across runs" },
|
|
296
|
+
timeout: { type: "integer", description: "Maximum seconds to wait for completion (default: 600)" },
|
|
297
|
+
enabled: { type: "boolean", description: "Whether the heartbeat is active (default: true)" },
|
|
298
|
+
once: {
|
|
299
|
+
type: "boolean",
|
|
300
|
+
description: "Run once then auto-disable. Combine with run_at for scheduled one-shots, " \
|
|
301
|
+
"or omit schedule to fire immediately."
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
include ToolDefinitionBuilder
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
module Mcp
|
|
5
|
+
# MCP handler exposing save_memory and search_memory tools.
|
|
6
|
+
# Conforms to the Server handler interface: tool_definitions, handles?, call.
|
|
7
|
+
class MemoryHandler
|
|
8
|
+
include HandlerBase
|
|
9
|
+
|
|
10
|
+
TOOL_NAMES = %w[save_memory search_memory].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(store:, username: nil)
|
|
13
|
+
@store = store
|
|
14
|
+
@username = username
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tool_definitions
|
|
18
|
+
[save_memory_definition, search_memory_definition]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
DISPATCH = { "save_memory" => :handle_save, "search_memory" => :handle_search }.freeze
|
|
22
|
+
|
|
23
|
+
def call(name, arguments)
|
|
24
|
+
method = DISPATCH[name]
|
|
25
|
+
return unless method
|
|
26
|
+
|
|
27
|
+
send(method, arguments)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def handle_save(arguments)
|
|
33
|
+
text = arguments["text"] || arguments["fact"] || ""
|
|
34
|
+
username = arguments["username"] || @username || "unknown"
|
|
35
|
+
|
|
36
|
+
result = @store.save(username: username, text: text)
|
|
37
|
+
text_content("Saved: #{result[:entry]}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handle_search(arguments)
|
|
41
|
+
query = arguments["query"] || ""
|
|
42
|
+
limit = (arguments["limit"] || 20).to_i
|
|
43
|
+
|
|
44
|
+
results = @store.search(query: query, limit: limit)
|
|
45
|
+
return text_content("No memories found for: #{query}") if results.empty?
|
|
46
|
+
|
|
47
|
+
text_content(format_search_results(results))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_search_results(results)
|
|
51
|
+
count = results.size
|
|
52
|
+
formatted = results.map { |result| "**#{result[:file]}**: #{result[:line]}" }.join("\n")
|
|
53
|
+
"Found #{count} memor#{count == 1 ? "y" : "ies"}:\n#{formatted}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def text_content(text)
|
|
57
|
+
{ content: [{ type: "text", text: text }] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def save_memory_definition
|
|
61
|
+
build_tool_definition(
|
|
62
|
+
"save_memory",
|
|
63
|
+
"Save a fact or observation to persistent memory. " \
|
|
64
|
+
"Use this to remember important information about users, preferences, or context.",
|
|
65
|
+
text: { type: "string", description: "The fact or observation to save" },
|
|
66
|
+
username: { type: "string", description: "The username this memory relates to (optional)" }
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def search_memory_definition
|
|
71
|
+
build_tool_definition(
|
|
72
|
+
"search_memory",
|
|
73
|
+
"Search persistent memory for previously saved facts. " \
|
|
74
|
+
"Use this to recall information about users, preferences, or past interactions.",
|
|
75
|
+
query: { type: "string", description: "Keywords to search for" },
|
|
76
|
+
limit: { type: "integer", description: "Maximum results to return (default: 20)" }
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_tool_definition(name, description, **properties)
|
|
81
|
+
required = name == "save_memory" ? %w[text] : %w[query]
|
|
82
|
+
{
|
|
83
|
+
name: name, description: description,
|
|
84
|
+
inputSchema: { type: "object", properties: properties, required: required }
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
module Mcp
|
|
5
|
+
# Minimal JSON-RPC 2.0 server over stdio for the Claude CLI MCP integration.
|
|
6
|
+
# Routes tools/list and tools/call to registered handlers via duck-typed interface:
|
|
7
|
+
# #tool_definitions → Array of tool hashes
|
|
8
|
+
# #handles?(tool_name) → Boolean
|
|
9
|
+
# #call(tool_name, arguments) → result hash
|
|
10
|
+
class Server
|
|
11
|
+
include Logging
|
|
12
|
+
|
|
13
|
+
def initialize(handlers:, input: $stdin, output: $stdout)
|
|
14
|
+
@handlers = Array(handlers)
|
|
15
|
+
@input = input
|
|
16
|
+
@output = output
|
|
17
|
+
@output.sync = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
@input.each_line do |line|
|
|
22
|
+
request = parse_request(line)
|
|
23
|
+
next unless request
|
|
24
|
+
|
|
25
|
+
response = handle_request(request)
|
|
26
|
+
write_response(response) if response
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_request(line)
|
|
33
|
+
JSON.parse(line.strip)
|
|
34
|
+
rescue JSON::ParserError => error
|
|
35
|
+
log(:warn, "MCP: unparsable input: #{error.message}")
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_request(request)
|
|
40
|
+
method_name, request_id, params = request.values_at("method", "id", "params")
|
|
41
|
+
dispatch_method(method_name, request_id, params || {})
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dispatch_method(method_name, request_id, params)
|
|
45
|
+
case method_name
|
|
46
|
+
when "initialize" then initialize_response(request_id)
|
|
47
|
+
when "notifications/initialized" then nil
|
|
48
|
+
when "tools/list" then tools_list_response(request_id)
|
|
49
|
+
when "tools/call" then tools_call_response(request_id, params)
|
|
50
|
+
else error_response(request_id, -32_601, "Method not found: #{method_name}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize_response(id)
|
|
55
|
+
{
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
id: id,
|
|
58
|
+
result: {
|
|
59
|
+
protocolVersion: "2024-11-05",
|
|
60
|
+
capabilities: { tools: {} },
|
|
61
|
+
serverInfo: { name: "earl-mcp-server", version: "1.0.0" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tools_list_response(id)
|
|
67
|
+
{
|
|
68
|
+
jsonrpc: "2.0",
|
|
69
|
+
id: id,
|
|
70
|
+
result: {
|
|
71
|
+
tools: @handlers.flat_map(&:tool_definitions)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tools_call_response(id, params)
|
|
77
|
+
tool_name, arguments = extract_tool_params(params)
|
|
78
|
+
handler = find_tool_handler(tool_name)
|
|
79
|
+
return error_response(id, -32_602, "No handler for tool: #{tool_name}") unless handler
|
|
80
|
+
|
|
81
|
+
log(:info, "MCP tool call: #{tool_name}")
|
|
82
|
+
result = handler.call(tool_name, arguments)
|
|
83
|
+
log_tool_result(tool_name, result)
|
|
84
|
+
|
|
85
|
+
{ jsonrpc: "2.0", id: id, result: result }
|
|
86
|
+
rescue StandardError => error
|
|
87
|
+
handle_tool_error(id, error)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_tool_params(args)
|
|
91
|
+
tool_name = args["name"] || args.dig("arguments", "tool_name") || "unknown"
|
|
92
|
+
[tool_name, args["arguments"] || {}]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def find_tool_handler(tool_name)
|
|
96
|
+
@handlers.find { |candidate| candidate.handles?(tool_name) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def log_tool_result(tool_name, result)
|
|
100
|
+
log(:info, "MCP tool result for #{tool_name}: #{result.inspect[0..200]}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def handle_tool_error(id, error)
|
|
104
|
+
msg = error.message
|
|
105
|
+
log(:error, "MCP tool call error: #{error.class}: #{msg}")
|
|
106
|
+
log(:error, error.backtrace&.first(3)&.join("\n"))
|
|
107
|
+
error_response(id, -32_603, "Internal error: #{msg}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def error_response(id, code, message)
|
|
111
|
+
{
|
|
112
|
+
jsonrpc: "2.0",
|
|
113
|
+
id: id,
|
|
114
|
+
error: { code: code, message: message }
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def write_response(response)
|
|
119
|
+
@output.puts(JSON.generate(response))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|