htm 0.0.15 → 0.0.17

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/CHANGELOG.md +67 -0
  4. data/README.md +97 -1592
  5. data/bin/htm_mcp +31 -0
  6. data/config/database.yml +7 -4
  7. data/docs/getting-started/installation.md +31 -11
  8. data/docs/guides/mcp-server.md +456 -21
  9. data/docs/multi_framework_support.md +2 -2
  10. data/examples/mcp_client.rb +2 -2
  11. data/examples/rails_app/.gitignore +2 -0
  12. data/examples/rails_app/Gemfile +22 -0
  13. data/examples/rails_app/Gemfile.lock +438 -0
  14. data/examples/rails_app/Procfile.dev +1 -0
  15. data/examples/rails_app/README.md +98 -0
  16. data/examples/rails_app/Rakefile +5 -0
  17. data/examples/rails_app/app/assets/stylesheets/application.css +83 -0
  18. data/examples/rails_app/app/assets/stylesheets/inter-font.css +6 -0
  19. data/examples/rails_app/app/controllers/application_controller.rb +19 -0
  20. data/examples/rails_app/app/controllers/dashboard_controller.rb +27 -0
  21. data/examples/rails_app/app/controllers/files_controller.rb +205 -0
  22. data/examples/rails_app/app/controllers/memories_controller.rb +102 -0
  23. data/examples/rails_app/app/controllers/robots_controller.rb +44 -0
  24. data/examples/rails_app/app/controllers/search_controller.rb +46 -0
  25. data/examples/rails_app/app/controllers/tags_controller.rb +30 -0
  26. data/examples/rails_app/app/javascript/application.js +4 -0
  27. data/examples/rails_app/app/javascript/controllers/application.js +9 -0
  28. data/examples/rails_app/app/javascript/controllers/index.js +6 -0
  29. data/examples/rails_app/app/views/dashboard/index.html.erb +123 -0
  30. data/examples/rails_app/app/views/files/index.html.erb +108 -0
  31. data/examples/rails_app/app/views/files/new.html.erb +321 -0
  32. data/examples/rails_app/app/views/files/show.html.erb +130 -0
  33. data/examples/rails_app/app/views/layouts/application.html.erb +124 -0
  34. data/examples/rails_app/app/views/memories/_memory_card.html.erb +51 -0
  35. data/examples/rails_app/app/views/memories/deleted.html.erb +62 -0
  36. data/examples/rails_app/app/views/memories/edit.html.erb +35 -0
  37. data/examples/rails_app/app/views/memories/index.html.erb +81 -0
  38. data/examples/rails_app/app/views/memories/new.html.erb +71 -0
  39. data/examples/rails_app/app/views/memories/show.html.erb +126 -0
  40. data/examples/rails_app/app/views/robots/index.html.erb +106 -0
  41. data/examples/rails_app/app/views/robots/new.html.erb +36 -0
  42. data/examples/rails_app/app/views/robots/show.html.erb +79 -0
  43. data/examples/rails_app/app/views/search/index.html.erb +184 -0
  44. data/examples/rails_app/app/views/shared/_navbar.html.erb +52 -0
  45. data/examples/rails_app/app/views/shared/_stat_card.html.erb +52 -0
  46. data/examples/rails_app/app/views/tags/index.html.erb +131 -0
  47. data/examples/rails_app/app/views/tags/show.html.erb +67 -0
  48. data/examples/rails_app/bin/dev +8 -0
  49. data/examples/rails_app/bin/rails +4 -0
  50. data/examples/rails_app/bin/rake +4 -0
  51. data/examples/rails_app/config/application.rb +33 -0
  52. data/examples/rails_app/config/boot.rb +5 -0
  53. data/examples/rails_app/config/database.yml +15 -0
  54. data/examples/rails_app/config/environment.rb +5 -0
  55. data/examples/rails_app/config/importmap.rb +7 -0
  56. data/examples/rails_app/config/routes.rb +38 -0
  57. data/examples/rails_app/config/tailwind.config.js +35 -0
  58. data/examples/rails_app/config.ru +5 -0
  59. data/examples/rails_app/log/.keep +0 -0
  60. data/examples/rails_app/tmp/local_secret.txt +1 -0
  61. data/lib/htm/active_record_config.rb +2 -5
  62. data/lib/htm/configuration.rb +35 -2
  63. data/lib/htm/database.rb +3 -6
  64. data/lib/htm/mcp/cli.rb +333 -0
  65. data/lib/htm/mcp/group_tools.rb +476 -0
  66. data/lib/htm/mcp/resources.rb +89 -0
  67. data/lib/htm/mcp/server.rb +98 -0
  68. data/lib/htm/mcp/tools.rb +488 -0
  69. data/lib/htm/models/file_source.rb +5 -3
  70. data/lib/htm/railtie.rb +0 -4
  71. data/lib/htm/tasks.rb +7 -4
  72. data/lib/htm/version.rb +1 -1
  73. data/lib/tasks/htm.rake +6 -9
  74. metadata +59 -4
  75. data/bin/htm_mcp.rb +0 -621
@@ -0,0 +1,476 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fast_mcp'
4
+
5
+ class HTM
6
+ module MCP
7
+ # Session state for robot groups
8
+ module GroupSession
9
+ class << self
10
+ def groups
11
+ @groups ||= {}
12
+ end
13
+
14
+ def get_group(name)
15
+ groups[name]
16
+ end
17
+
18
+ def set_group(name, group)
19
+ groups[name] = group
20
+ end
21
+
22
+ def remove_group(name)
23
+ group = groups.delete(name)
24
+ group&.shutdown
25
+ end
26
+
27
+ def group_names
28
+ groups.keys
29
+ end
30
+ end
31
+ end
32
+
33
+ # Tool: Create a new robot group
34
+ class CreateGroupTool < FastMcp::Tool
35
+ description "Create a new robot group for coordinating multiple robots with shared working memory"
36
+
37
+ arguments do
38
+ required(:name).filled(:string).description("Unique name for the robot group")
39
+ optional(:max_tokens).filled(:integer).description("Maximum token budget for shared working memory (default: 4000)")
40
+ optional(:join_as).filled(:string).description("Role to join as: 'active' or 'passive' (default: 'active')")
41
+ end
42
+
43
+ def call(name:, max_tokens: 4000, join_as: 'active')
44
+ Session.logger&.info "CreateGroupTool called: name=#{name.inspect}, max_tokens=#{max_tokens}"
45
+
46
+ if GroupSession.get_group(name)
47
+ return { success: false, error: "Group '#{name}' already exists in this session" }.to_json
48
+ end
49
+
50
+ # Get current robot name
51
+ robot_name = Session.robot_name
52
+
53
+ # Create the group with current robot as initial member
54
+ active = join_as == 'active' ? [robot_name] : []
55
+ passive = join_as == 'passive' ? [robot_name] : []
56
+
57
+ group = HTM::RobotGroup.new(
58
+ name: name,
59
+ active: active,
60
+ passive: passive,
61
+ max_tokens: max_tokens
62
+ )
63
+
64
+ GroupSession.set_group(name, group)
65
+
66
+ Session.logger&.info "Group created: #{name}, robot=#{robot_name} joined as #{join_as}"
67
+
68
+ {
69
+ success: true,
70
+ group_name: name,
71
+ max_tokens: max_tokens,
72
+ robot_name: robot_name,
73
+ role: join_as,
74
+ message: "Group '#{name}' created. Robot '#{robot_name}' joined as #{join_as}."
75
+ }.to_json
76
+ rescue StandardError => e
77
+ Session.logger&.error "CreateGroupTool error: #{e.message}"
78
+ { success: false, error: e.message }.to_json
79
+ end
80
+ end
81
+
82
+ # Tool: List all robot groups
83
+ class ListGroupsTool < FastMcp::Tool
84
+ description "List all robot groups in this session with their status"
85
+
86
+ arguments do
87
+ end
88
+
89
+ def call
90
+ Session.logger&.info "ListGroupsTool called"
91
+
92
+ groups = GroupSession.group_names.map do |name|
93
+ group = GroupSession.get_group(name)
94
+ status = group.status
95
+ {
96
+ name: name,
97
+ active_robots: status[:active],
98
+ passive_robots: status[:passive],
99
+ total_members: status[:total_members],
100
+ in_sync: status[:in_sync],
101
+ token_utilization: status[:token_utilization]
102
+ }
103
+ end
104
+
105
+ Session.logger&.info "ListGroupsTool complete: #{groups.length} groups"
106
+
107
+ {
108
+ success: true,
109
+ count: groups.length,
110
+ groups: groups
111
+ }.to_json
112
+ end
113
+ end
114
+
115
+ # Tool: Get detailed group status
116
+ class GetGroupStatusTool < FastMcp::Tool
117
+ description "Get detailed status of a specific robot group"
118
+
119
+ arguments do
120
+ required(:name).filled(:string).description("Name of the robot group")
121
+ end
122
+
123
+ def call(name:)
124
+ Session.logger&.info "GetGroupStatusTool called: name=#{name.inspect}"
125
+
126
+ group = GroupSession.get_group(name)
127
+ unless group
128
+ return { success: false, error: "Group '#{name}' not found in this session" }.to_json
129
+ end
130
+
131
+ status = group.status
132
+ sync_stats = group.sync_stats
133
+
134
+ Session.logger&.info "GetGroupStatusTool complete: #{name}"
135
+
136
+ {
137
+ success: true,
138
+ group_name: name,
139
+ status: status,
140
+ sync_stats: sync_stats
141
+ }.to_json
142
+ end
143
+ end
144
+
145
+ # Tool: Join current robot to an existing group
146
+ class JoinGroupTool < FastMcp::Tool
147
+ description "Join the current robot to an existing robot group"
148
+
149
+ arguments do
150
+ required(:name).filled(:string).description("Name of the robot group to join")
151
+ optional(:role).filled(:string).description("Role to join as: 'active' or 'passive' (default: 'active')")
152
+ end
153
+
154
+ def call(name:, role: 'active')
155
+ Session.logger&.info "JoinGroupTool called: name=#{name.inspect}, role=#{role}"
156
+
157
+ group = GroupSession.get_group(name)
158
+ unless group
159
+ return { success: false, error: "Group '#{name}' not found in this session" }.to_json
160
+ end
161
+
162
+ robot_name = Session.robot_name
163
+
164
+ if group.member?(robot_name)
165
+ return { success: false, error: "Robot '#{robot_name}' is already a member of group '#{name}'" }.to_json
166
+ end
167
+
168
+ robot_id = if role == 'passive'
169
+ group.add_passive(robot_name)
170
+ else
171
+ group.add_active(robot_name)
172
+ end
173
+
174
+ Session.logger&.info "Robot #{robot_name} joined group #{name} as #{role}"
175
+
176
+ {
177
+ success: true,
178
+ group_name: name,
179
+ robot_name: robot_name,
180
+ robot_id: robot_id,
181
+ role: role,
182
+ message: "Robot '#{robot_name}' joined group '#{name}' as #{role}"
183
+ }.to_json
184
+ rescue ArgumentError => e
185
+ { success: false, error: e.message }.to_json
186
+ end
187
+ end
188
+
189
+ # Tool: Leave a robot group
190
+ class LeaveGroupTool < FastMcp::Tool
191
+ description "Remove the current robot from a robot group"
192
+
193
+ arguments do
194
+ required(:name).filled(:string).description("Name of the robot group to leave")
195
+ end
196
+
197
+ def call(name:)
198
+ Session.logger&.info "LeaveGroupTool called: name=#{name.inspect}"
199
+
200
+ group = GroupSession.get_group(name)
201
+ unless group
202
+ return { success: false, error: "Group '#{name}' not found in this session" }.to_json
203
+ end
204
+
205
+ robot_name = Session.robot_name
206
+
207
+ unless group.member?(robot_name)
208
+ return { success: false, error: "Robot '#{robot_name}' is not a member of group '#{name}'" }.to_json
209
+ end
210
+
211
+ group.remove(robot_name)
212
+
213
+ Session.logger&.info "Robot #{robot_name} left group #{name}"
214
+
215
+ {
216
+ success: true,
217
+ group_name: name,
218
+ robot_name: robot_name,
219
+ message: "Robot '#{robot_name}' left group '#{name}'"
220
+ }.to_json
221
+ end
222
+ end
223
+
224
+ # Tool: Remember via group (syncs to all members)
225
+ class GroupRememberTool < FastMcp::Tool
226
+ description "Store information in a robot group's shared working memory (syncs to all members)"
227
+
228
+ arguments do
229
+ required(:group_name).filled(:string).description("Name of the robot group")
230
+ required(:content).filled(:string).description("The content to remember")
231
+ optional(:tags).array(:string).description("Optional tags for categorization")
232
+ optional(:metadata).hash.description("Optional metadata key-value pairs")
233
+ end
234
+
235
+ def call(group_name:, content:, tags: [], metadata: {})
236
+ Session.logger&.info "GroupRememberTool called: group=#{group_name.inspect}, content=#{content[0..50].inspect}..."
237
+
238
+ group = GroupSession.get_group(group_name)
239
+ unless group
240
+ return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
241
+ end
242
+
243
+ robot_name = Session.robot_name
244
+ node_id = group.remember(content, originator: robot_name, tags: tags, metadata: metadata)
245
+
246
+ Session.logger&.info "Group memory stored: node_id=#{node_id}, group=#{group_name}"
247
+
248
+ {
249
+ success: true,
250
+ node_id: node_id,
251
+ group_name: group_name,
252
+ originator: robot_name,
253
+ message: "Memory stored and synced to all group members"
254
+ }.to_json
255
+ rescue StandardError => e
256
+ Session.logger&.error "GroupRememberTool error: #{e.message}"
257
+ { success: false, error: e.message }.to_json
258
+ end
259
+ end
260
+
261
+ # Tool: Recall from group's shared memory
262
+ class GroupRecallTool < FastMcp::Tool
263
+ description "Search and retrieve memories from a robot group's shared working memory"
264
+
265
+ arguments do
266
+ required(:group_name).filled(:string).description("Name of the robot group")
267
+ required(:query).filled(:string).description("Search query")
268
+ optional(:limit).filled(:integer).description("Maximum number of results (default: 10)")
269
+ optional(:strategy).filled(:string).description("Search strategy: 'vector', 'fulltext', or 'hybrid' (default: 'hybrid')")
270
+ end
271
+
272
+ def call(group_name:, query:, limit: 10, strategy: 'hybrid')
273
+ Session.logger&.info "GroupRecallTool called: group=#{group_name.inspect}, query=#{query.inspect}"
274
+
275
+ group = GroupSession.get_group(group_name)
276
+ unless group
277
+ return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
278
+ end
279
+
280
+ memories = group.recall(query, limit: limit, strategy: strategy.to_sym)
281
+
282
+ Session.logger&.info "GroupRecallTool complete: found #{memories.length} memories"
283
+
284
+ {
285
+ success: true,
286
+ group_name: group_name,
287
+ query: query,
288
+ strategy: strategy,
289
+ count: memories.length,
290
+ results: memories
291
+ }.to_json
292
+ rescue StandardError => e
293
+ Session.logger&.error "GroupRecallTool error: #{e.message}"
294
+ { success: false, error: e.message }.to_json
295
+ end
296
+ end
297
+
298
+ # Tool: Promote a passive robot to active
299
+ class PromoteRobotTool < FastMcp::Tool
300
+ description "Promote a passive robot to active status in a group"
301
+
302
+ arguments do
303
+ required(:group_name).filled(:string).description("Name of the robot group")
304
+ required(:robot_name).filled(:string).description("Name of the passive robot to promote")
305
+ end
306
+
307
+ def call(group_name:, robot_name:)
308
+ Session.logger&.info "PromoteRobotTool called: group=#{group_name.inspect}, robot=#{robot_name.inspect}"
309
+
310
+ group = GroupSession.get_group(group_name)
311
+ unless group
312
+ return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
313
+ end
314
+
315
+ group.promote(robot_name)
316
+
317
+ Session.logger&.info "Robot #{robot_name} promoted to active in group #{group_name}"
318
+
319
+ {
320
+ success: true,
321
+ group_name: group_name,
322
+ robot_name: robot_name,
323
+ message: "Robot '#{robot_name}' promoted to active"
324
+ }.to_json
325
+ rescue ArgumentError => e
326
+ { success: false, error: e.message }.to_json
327
+ end
328
+ end
329
+
330
+ # Tool: Trigger automatic failover
331
+ class FailoverTool < FastMcp::Tool
332
+ description "Trigger automatic failover - promotes first passive robot to active"
333
+
334
+ arguments do
335
+ required(:group_name).filled(:string).description("Name of the robot group")
336
+ end
337
+
338
+ def call(group_name:)
339
+ Session.logger&.info "FailoverTool called: group=#{group_name.inspect}"
340
+
341
+ group = GroupSession.get_group(group_name)
342
+ unless group
343
+ return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
344
+ end
345
+
346
+ promoted = group.failover!
347
+
348
+ Session.logger&.info "Failover complete: #{promoted} promoted to active in group #{group_name}"
349
+
350
+ {
351
+ success: true,
352
+ group_name: group_name,
353
+ promoted_robot: promoted,
354
+ message: "Failover complete. Robot '#{promoted}' is now active."
355
+ }.to_json
356
+ rescue RuntimeError => e
357
+ { success: false, error: e.message }.to_json
358
+ end
359
+ end
360
+
361
+ # Tool: Force sync all group members
362
+ class SyncGroupTool < FastMcp::Tool
363
+ description "Force synchronization of all group members' working memory"
364
+
365
+ arguments do
366
+ required(:group_name).filled(:string).description("Name of the robot group")
367
+ end
368
+
369
+ def call(group_name:)
370
+ Session.logger&.info "SyncGroupTool called: group=#{group_name.inspect}"
371
+
372
+ group = GroupSession.get_group(group_name)
373
+ unless group
374
+ return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
375
+ end
376
+
377
+ result = group.sync_all
378
+
379
+ Session.logger&.info "SyncGroupTool complete: synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
380
+
381
+ {
382
+ success: true,
383
+ group_name: group_name,
384
+ synced_nodes: result[:synced_nodes],
385
+ members_updated: result[:members_updated],
386
+ in_sync: group.in_sync?,
387
+ message: "Synced #{result[:synced_nodes]} nodes to #{result[:members_updated]} members"
388
+ }.to_json
389
+ end
390
+ end
391
+
392
+ # Tool: Get group's shared working memory contents
393
+ class GetGroupWorkingMemoryTool < FastMcp::Tool
394
+ description "Get all nodes in a group's shared working memory"
395
+
396
+ arguments do
397
+ required(:group_name).filled(:string).description("Name of the robot group")
398
+ end
399
+
400
+ def call(group_name:)
401
+ Session.logger&.info "GetGroupWorkingMemoryTool called: group=#{group_name.inspect}"
402
+
403
+ group = GroupSession.get_group(group_name)
404
+ unless group
405
+ return { success: false, error: "Group '#{group_name}' not found in this session" }.to_json
406
+ end
407
+
408
+ contents = group.working_memory_contents.map do |node|
409
+ {
410
+ id: node.id,
411
+ content: node.content,
412
+ tags: node.tags.map(&:name),
413
+ created_at: node.created_at.iso8601
414
+ }
415
+ end
416
+
417
+ status = group.status
418
+
419
+ Session.logger&.info "GetGroupWorkingMemoryTool complete: #{contents.length} nodes"
420
+
421
+ {
422
+ success: true,
423
+ group_name: group_name,
424
+ count: contents.length,
425
+ token_count: status[:working_memory_tokens],
426
+ token_utilization: status[:token_utilization],
427
+ contents: contents
428
+ }.to_json
429
+ end
430
+ end
431
+
432
+ # Tool: Shutdown a robot group
433
+ class ShutdownGroupTool < FastMcp::Tool
434
+ description "Shutdown a robot group and release its resources"
435
+
436
+ arguments do
437
+ required(:name).filled(:string).description("Name of the robot group to shutdown")
438
+ end
439
+
440
+ def call(name:)
441
+ Session.logger&.info "ShutdownGroupTool called: name=#{name.inspect}"
442
+
443
+ group = GroupSession.get_group(name)
444
+ unless group
445
+ return { success: false, error: "Group '#{name}' not found in this session" }.to_json
446
+ end
447
+
448
+ GroupSession.remove_group(name)
449
+
450
+ Session.logger&.info "Group #{name} shutdown complete"
451
+
452
+ {
453
+ success: true,
454
+ group_name: name,
455
+ message: "Group '#{name}' has been shutdown"
456
+ }.to_json
457
+ end
458
+ end
459
+
460
+ # All group tools for registration
461
+ GROUP_TOOLS = [
462
+ CreateGroupTool,
463
+ ListGroupsTool,
464
+ GetGroupStatusTool,
465
+ JoinGroupTool,
466
+ LeaveGroupTool,
467
+ GroupRememberTool,
468
+ GroupRecallTool,
469
+ GetGroupWorkingMemoryTool,
470
+ PromoteRobotTool,
471
+ FailoverTool,
472
+ SyncGroupTool,
473
+ ShutdownGroupTool
474
+ ].freeze
475
+ end
476
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fast_mcp'
4
+
5
+ class HTM
6
+ module MCP
7
+ # Resource: Robot Groups
8
+ class RobotGroupsResource < FastMcp::Resource
9
+ uri "htm://groups"
10
+ resource_name "HTM Robot Groups"
11
+ mime_type "application/json"
12
+
13
+ def content
14
+ groups = GroupSession.group_names.map do |name|
15
+ group = GroupSession.get_group(name)
16
+ group.status
17
+ end
18
+
19
+ {
20
+ count: groups.length,
21
+ groups: groups
22
+ }.to_json
23
+ end
24
+ end
25
+
26
+ # Resource: Memory Statistics
27
+ class MemoryStatsResource < FastMcp::Resource
28
+ uri "htm://statistics"
29
+ resource_name "HTM Memory Statistics"
30
+ mime_type "application/json"
31
+
32
+ def content
33
+ htm = Session.htm_instance
34
+ {
35
+ total_nodes: HTM::Models::Node.count,
36
+ total_tags: HTM::Models::Tag.count,
37
+ total_robots: HTM::Models::Robot.count,
38
+ current_robot: htm.robot_name,
39
+ robot_id: htm.robot_id,
40
+ robot_initialized: Session.robot_initialized?,
41
+ embedding_provider: HTM.configuration.embedding_provider,
42
+ embedding_model: HTM.configuration.embedding_model
43
+ }.to_json
44
+ end
45
+ end
46
+
47
+ # Resource: Tag Hierarchy
48
+ class TagHierarchyResource < FastMcp::Resource
49
+ uri "htm://tags/hierarchy"
50
+ resource_name "HTM Tag Hierarchy"
51
+ mime_type "text/plain"
52
+
53
+ def content
54
+ HTM::Models::Tag.all.tree_string
55
+ end
56
+ end
57
+
58
+ # Resource: Recent Memories
59
+ class RecentMemoriesResource < FastMcp::Resource
60
+ uri "htm://memories/recent"
61
+ resource_name "Recent HTM Memories"
62
+ mime_type "application/json"
63
+
64
+ def content
65
+ recent = HTM::Models::Node.includes(:tags)
66
+ .order(created_at: :desc)
67
+ .limit(20)
68
+ .map do |node|
69
+ {
70
+ id: node.id,
71
+ content: node.content[0..200],
72
+ tags: node.tags.map(&:name),
73
+ created_at: node.created_at.iso8601
74
+ }
75
+ end
76
+
77
+ { recent_memories: recent }.to_json
78
+ end
79
+ end
80
+
81
+ # All resources for registration
82
+ RESOURCES = [
83
+ MemoryStatsResource,
84
+ TagHierarchyResource,
85
+ RecentMemoriesResource,
86
+ RobotGroupsResource
87
+ ].freeze
88
+ end
89
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'fast_mcp'
5
+ require 'ruby_llm'
6
+
7
+ require_relative 'tools'
8
+ require_relative 'group_tools'
9
+ require_relative 'resources'
10
+
11
+ class HTM
12
+ module MCP
13
+ # MCP Server setup and lifecycle management
14
+ module Server
15
+ module_function
16
+
17
+ def start
18
+ check_database_config!
19
+ verify_database_connection!
20
+ configure_logging!
21
+ configure_htm!
22
+
23
+ server = create_server
24
+ register_tools(server)
25
+ register_resources(server)
26
+
27
+ server.start
28
+ end
29
+
30
+ def check_database_config!
31
+ unless ENV['HTM_DBURL'] || ENV['HTM_DBNAME']
32
+ warn "Error: Database not configured."
33
+ warn "Set HTM_DBURL or HTM_DBNAME environment variable."
34
+ warn "Run 'htm_mcp help' for details."
35
+ exit 1
36
+ end
37
+ end
38
+
39
+ def verify_database_connection!
40
+ HTM::ActiveRecordConfig.establish_connection!
41
+ # Quick connectivity test
42
+ ActiveRecord::Base.connection.execute("SELECT 1")
43
+ rescue => e
44
+ warn "Error: Cannot connect to database."
45
+ warn e.message
46
+ CLI.print_error_suggestion(e.message)
47
+ exit 1
48
+ end
49
+
50
+ def configure_logging!
51
+ # IMPORTANT: MCP uses STDIO for JSON-RPC communication.
52
+ # ALL logging must go to STDERR to avoid corrupting the protocol.
53
+ @stderr_logger = Logger.new($stderr)
54
+ @stderr_logger.level = Logger::INFO
55
+ @stderr_logger.formatter = proc do |severity, datetime, _progname, msg|
56
+ "[MCP #{severity}] #{datetime.strftime('%H:%M:%S')} #{msg}\n"
57
+ end
58
+
59
+ # Silent logger for RubyLLM/HTM internals (prevents STDOUT corruption)
60
+ @silent_logger = Logger.new(IO::NULL)
61
+
62
+ # Configure RubyLLM to not log to STDOUT (corrupts MCP protocol)
63
+ RubyLLM.configure do |config|
64
+ config.logger = @silent_logger
65
+ end
66
+
67
+ # Set logger for MCP session
68
+ Session.logger = @stderr_logger
69
+ end
70
+
71
+ def configure_htm!
72
+ HTM.configure do |config|
73
+ config.job_backend = :inline # Synchronous for MCP responses
74
+ config.logger = @silent_logger # Silent logging for MCP
75
+ end
76
+ end
77
+
78
+ def create_server
79
+ FastMcp::Server.new(
80
+ name: 'htm-memory-server',
81
+ version: HTM::VERSION
82
+ )
83
+ end
84
+
85
+ def register_tools(server)
86
+ # Register individual robot/memory tools
87
+ TOOLS.each { |tool| server.register_tool(tool) }
88
+
89
+ # Register group tools
90
+ GROUP_TOOLS.each { |tool| server.register_tool(tool) }
91
+ end
92
+
93
+ def register_resources(server)
94
+ RESOURCES.each { |resource| server.register_resource(resource) }
95
+ end
96
+ end
97
+ end
98
+ end