agentf 0.4.7 → 0.6.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.
@@ -3,9 +3,9 @@
3
3
  begin
4
4
  require "mcp"
5
5
  rescue LoadError
6
- require_relative "stub"
7
6
  end
8
7
  require "json"
8
+ require "set"
9
9
 
10
10
  module Agentf
11
11
  module MCP
@@ -20,7 +20,163 @@ module Agentf
20
20
  # AGENTF_MCP_ALLOW_WRITES - true/false, controls memory write tools
21
21
  # AGENTF_MCP_MAX_ARG_LENGTH - max length per string argument
22
22
  class Server
23
+ ToolDefinition = Struct.new(:name, :description, :arguments, :handler, keyword_init: true)
24
+
25
+ class ToolBuilder
26
+ attr_reader :arguments, :handler
27
+
28
+ def initialize(name)
29
+ @name = name
30
+ @arguments = {}
31
+ end
32
+
33
+ def description(value = nil)
34
+ @description = value unless value.nil?
35
+ @description
36
+ end
37
+
38
+ def argument(name, type, required: false, description:, items: nil, **_opts)
39
+ @arguments[name] = {
40
+ type: type,
41
+ items: items,
42
+ required: required,
43
+ schema: {
44
+ type: schema_type(type),
45
+ description: description
46
+ }.tap do |schema|
47
+ schema[:items] = { type: schema_type(items) } if type == Array && items
48
+ end
49
+ }
50
+ end
51
+
52
+ def call(&block)
53
+ @handler = block
54
+ end
55
+
56
+ private
57
+
58
+ def schema_type(type)
59
+ case type&.name
60
+ when "String" then "string"
61
+ when "Integer" then "integer"
62
+ when "Float" then "number"
63
+ when "TrueClass", "FalseClass" then "boolean"
64
+ when "Array" then "array"
65
+ when "Hash" then "object"
66
+ else
67
+ "string"
68
+ end
69
+ end
70
+ end
71
+
72
+ class RegistryAdapter
73
+ def initialize(name:, version:)
74
+ @name = name
75
+ @version = version
76
+ @tools = {}
77
+ end
78
+
79
+ def tool(name, &block)
80
+ builder = ToolBuilder.new(name)
81
+ builder.instance_eval(&block)
82
+ @tools[name] = ToolDefinition.new(
83
+ name: name,
84
+ description: builder.description,
85
+ arguments: builder.arguments,
86
+ handler: builder.handler
87
+ )
88
+ end
89
+
90
+ def list_tools
91
+ @tools.values.map do |tool|
92
+ {
93
+ name: tool.name,
94
+ description: tool.description.to_s,
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: tool.arguments.transform_values { |arg| arg[:schema] },
98
+ required: tool.arguments.select { |_k, arg| arg[:required] }.keys.map(&:to_s)
99
+ }
100
+ }
101
+ end
102
+ end
103
+
104
+ def call_tool(name, **args)
105
+ tool = @tools[name]
106
+ raise "Unknown tool: #{name}" unless tool
107
+
108
+ tool.handler.call(args)
109
+ rescue StandardError => e
110
+ e.message
111
+ end
112
+
113
+ def run
114
+ runtime_server = build_runtime_server
115
+ transport = ::MCP::Server::Transports::StdioTransport.new(runtime_server)
116
+ runtime_server.transport = transport if runtime_server.respond_to?(:transport=)
117
+ transport.open
118
+ end
119
+
120
+ private
121
+
122
+ def build_runtime_server
123
+ ensure_runtime_support!
124
+
125
+ runtime_server = ::MCP::Server.new(name: @name, version: @version)
126
+ adapter = self
127
+ @tools.each_value do |tool|
128
+ runtime_server.define_tool(
129
+ name: tool.name,
130
+ description: tool.description,
131
+ input_schema: runtime_input_schema(tool.arguments)
132
+ ) do |**kwargs|
133
+ result = tool.handler.call(kwargs)
134
+ adapter.send(:build_response, result)
135
+ rescue StandardError => e
136
+ ::MCP::Tool::Response.new([::MCP::Content::Text.new(e.message).to_h], error: true)
137
+ end
138
+ end
139
+ runtime_server
140
+ end
141
+
142
+ def ensure_runtime_support!
143
+ return if defined?(::MCP::Server::Transports::StdioTransport) && defined?(::MCP::Tool::Response) && defined?(::MCP::Content::Text)
144
+
145
+ raise "Installed MCP runtime does not support stdio server transport"
146
+ end
147
+
148
+ def runtime_input_schema(arguments)
149
+ schema = {
150
+ type: "object",
151
+ properties: arguments.transform_keys(&:to_s).transform_values { |arg| arg[:schema].transform_keys(&:to_s) }
152
+ }
153
+
154
+ required = arguments.select { |_k, arg| arg[:required] }.keys.map(&:to_s)
155
+ schema[:required] = required unless required.empty?
156
+ schema
157
+ end
158
+
159
+ def build_response(result)
160
+ return result if result.is_a?(::MCP::Tool::Response)
161
+
162
+ text = result.is_a?(String) ? result : JSON.generate(result)
163
+ structured = parse_structured_content(result)
164
+ ::MCP::Tool::Response.new([::MCP::Content::Text.new(text).to_h], structured_content: structured)
165
+ end
166
+
167
+ def parse_structured_content(result)
168
+ return result if result.is_a?(Hash)
169
+ return JSON.parse(result) if result.is_a?(String)
170
+ return result.as_json if result.respond_to?(:as_json)
171
+
172
+ nil
173
+ rescue JSON::ParserError
174
+ nil
175
+ end
176
+ end
177
+
23
178
  KNOWN_TOOLS = %w[
179
+ agentf-mcp-list-tools
24
180
  agentf-code-glob
25
181
  agentf-code-grep
26
182
  agentf-code-tree
@@ -28,29 +184,24 @@ module Agentf
28
184
  agentf-architecture-analyze-layers
29
185
  agentf-memory-recent
30
186
  agentf-memory-search
31
- agentf-memory-by-tag
32
187
  agentf-memory-by-agent
33
188
  agentf-memory-by-type
34
- agentf-memory-tags
35
- agentf-memory-pitfalls
189
+ agentf-memory-episodes
36
190
  agentf-memory-lessons
37
- agentf-memory-successes
38
191
  agentf-memory-intents
39
192
  agentf-memory-business-intents
40
193
  agentf-memory-feature-intents
41
194
  agentf-memory-neighbors
42
195
  agentf-memory-subgraph
196
+ agentf-memory-add-playbook
43
197
  agentf-memory-add-lesson
44
- agentf-memory-add-success
45
- agentf-memory-add-pitfall
46
198
  agentf-memory-add-business-intent
47
199
  agentf-memory-add-feature-intent
48
200
  ].freeze
49
201
 
50
202
  WRITE_TOOLS = Set.new(%w[
203
+ agentf-memory-add-playbook
51
204
  agentf-memory-add-lesson
52
- agentf-memory-add-success
53
- agentf-memory-add-pitfall
54
205
  agentf-memory-add-business-intent
55
206
  agentf-memory-add-feature-intent
56
207
  ]).freeze
@@ -66,6 +217,25 @@ module Agentf
66
217
  @server = build_server
67
218
  end
68
219
 
220
+ # Helper to centralize confirmation handling for MCP server write tools.
221
+ # Yields the block that performs the memory write and returns either the
222
+ # block result or the normalized confirmation hash produced by
223
+ # Agentf::Agents::Base#safe_memory_write.
224
+ def safe_mcp_memory_write(memory, attempted: {})
225
+ begin
226
+ yield
227
+ nil
228
+ rescue Agentf::Memory::RedisMemory::ConfirmationRequired => e
229
+ {
230
+ "confirmation_required" => true,
231
+ "confirmation_details" => e.details,
232
+ "attempted" => attempted,
233
+ "confirmed_write_token" => "confirmed",
234
+ "confirmation_prompt" => "Ask the user whether to save this memory. If they approve, call the same tool again after confirmation. If they decline, do not retry."
235
+ }
236
+ end
237
+ end
238
+
69
239
  # Start the stdio read loop (blocks until stdin closes).
70
240
  def run
71
241
  @server.run
@@ -139,7 +309,7 @@ module Agentf
139
309
  # ── Server construction ─────────────────────────────────────
140
310
 
141
311
  def build_server
142
- s = ::MCP::Server.new(name: "agentf", version: Agentf::VERSION)
312
+ s = RegistryAdapter.new(name: "agentf", version: Agentf::VERSION)
143
313
  register_code_tools(s)
144
314
  register_memory_tools(s)
145
315
  register_architecture_tools(s)
@@ -218,7 +388,7 @@ module Agentf
218
388
  end
219
389
 
220
390
  s.tool("agentf-memory-search") do
221
- description "Search memories by keyword."
391
+ description "Search memories semantically."
222
392
  argument :query, String, required: true, description: "Search query"
223
393
  argument :limit, Integer, required: false, description: "How many results to return (1-100)"
224
394
  call do |args|
@@ -228,13 +398,13 @@ module Agentf
228
398
  end
229
399
  end
230
400
 
231
- s.tool("agentf-memory-by-tag") do
232
- description "Get memories by tag."
233
- argument :tag, String, required: true, description: "Tag to filter"
401
+ s.tool("agentf-memory-episodes") do
402
+ description "List episode memories with optional outcome filter."
403
+ argument :outcome, String, required: false, description: "Optional outcome filter: positive|negative|neutral"
234
404
  argument :limit, Integer, required: false, description: "How many results to return (1-100)"
235
405
  call do |args|
236
- mcp_server.send(:guard!, "agentf-memory-by-tag", **args)
237
- result = reviewer.get_by_tag(args[:tag], limit: args[:limit] || 10)
406
+ mcp_server.send(:guard!, "agentf-memory-episodes", **args)
407
+ result = reviewer.get_episodes(limit: args[:limit] || 10, outcome: args[:outcome])
238
408
  JSON.generate(result)
239
409
  end
240
410
  end
@@ -261,25 +431,6 @@ module Agentf
261
431
  end
262
432
  end
263
433
 
264
- s.tool("agentf-memory-tags") do
265
- description "List all unique memory tags."
266
- call do |args|
267
- mcp_server.send(:guard!, "agentf-memory-tags", **args)
268
- result = reviewer.get_all_tags
269
- JSON.generate(result)
270
- end
271
- end
272
-
273
- s.tool("agentf-memory-pitfalls") do
274
- description "List pitfall memories."
275
- argument :limit, Integer, required: false, description: "How many results to return (1-100)"
276
- call do |args|
277
- mcp_server.send(:guard!, "agentf-memory-pitfalls", **args)
278
- result = reviewer.get_pitfalls(limit: args[:limit] || 10)
279
- JSON.generate(result)
280
- end
281
- end
282
-
283
434
  s.tool("agentf-memory-lessons") do
284
435
  description "List lesson memories."
285
436
  argument :limit, Integer, required: false, description: "How many results to return (1-100)"
@@ -290,16 +441,6 @@ module Agentf
290
441
  end
291
442
  end
292
443
 
293
- s.tool("agentf-memory-successes") do
294
- description "List success memories."
295
- argument :limit, Integer, required: false, description: "How many results to return (1-100)"
296
- call do |args|
297
- mcp_server.send(:guard!, "agentf-memory-successes", **args)
298
- result = reviewer.get_successes(limit: args[:limit] || 10)
299
- JSON.generate(result)
300
- end
301
- end
302
-
303
444
  s.tool("agentf-memory-intents") do
304
445
  description "List intents (business|feature)."
305
446
  argument :kind, String, required: false, description: "Optional: business|feature"
@@ -344,19 +485,27 @@ module Agentf
344
485
  description "Store a business intent in Redis."
345
486
  argument :title, String, required: true, description: "Intent title"
346
487
  argument :description, String, required: true, description: "Intent description"
347
- argument :tags, Array, required: false, items: String, description: "Tags"
348
488
  argument :constraints, Array, required: false, items: String, description: "Constraints"
349
489
  argument :priority, Integer, required: false, description: "Priority"
350
490
  call do |args|
351
491
  mcp_server.send(:guard!, "agentf-memory-add-business-intent", **args)
352
- id = memory.store_business_intent(
353
- title: args[:title],
354
- description: args[:description],
355
- tags: args[:tags] || [],
356
- constraints: args[:constraints] || [],
357
- priority: args[:priority] || 1
358
- )
359
- JSON.generate(id: id, type: "business_intent", status: "stored")
492
+ begin
493
+ id = nil
494
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-business-intent", args: args }) do
495
+ id = memory.store_business_intent(
496
+ title: args[:title],
497
+ description: args[:description],
498
+ constraints: args[:constraints] || [],
499
+ priority: args[:priority] || 1
500
+ )
501
+ end
502
+
503
+ if res.is_a?(Hash) && res["confirmation_required"]
504
+ JSON.generate(confirmation_required: true, confirmation_details: res["confirmation_details"], attempted: res["attempted"])
505
+ else
506
+ JSON.generate(id: id, type: "business_intent", status: "stored")
507
+ end
508
+ end
360
509
  end
361
510
  end
362
511
 
@@ -364,21 +513,29 @@ module Agentf
364
513
  description "Store a feature intent in Redis."
365
514
  argument :title, String, required: true, description: "Intent title"
366
515
  argument :description, String, required: true, description: "Intent description"
367
- argument :tags, Array, required: false, items: String, description: "Tags"
368
516
  argument :acceptance, Array, required: false, items: String, description: "Acceptance criteria"
369
517
  argument :non_goals, Array, required: false, items: String, description: "Non-goals"
370
518
  argument :related_task_id, String, required: false, description: "Related task id"
371
519
  call do |args|
372
520
  mcp_server.send(:guard!, "agentf-memory-add-feature-intent", **args)
373
- id = memory.store_feature_intent(
374
- title: args[:title],
375
- description: args[:description],
376
- tags: args[:tags] || [],
377
- acceptance_criteria: args[:acceptance] || [],
378
- non_goals: args[:non_goals] || [],
379
- related_task_id: args[:related_task_id]
380
- )
381
- JSON.generate(id: id, type: "feature_intent", status: "stored")
521
+ begin
522
+ id = nil
523
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-feature-intent", args: args }) do
524
+ id = memory.store_feature_intent(
525
+ title: args[:title],
526
+ description: args[:description],
527
+ acceptance_criteria: args[:acceptance] || [],
528
+ non_goals: args[:non_goals] || [],
529
+ related_task_id: args[:related_task_id]
530
+ )
531
+ end
532
+
533
+ if res.is_a?(Hash) && res["confirmation_required"]
534
+ JSON.generate(confirmation_required: true, confirmation_details: res["confirmation_details"], attempted: res["attempted"])
535
+ else
536
+ JSON.generate(id: id, type: "feature_intent", status: "stored")
537
+ end
538
+ end
382
539
  end
383
540
  end
384
541
 
@@ -418,69 +575,92 @@ module Agentf
418
575
  end
419
576
  end
420
577
 
578
+ s.tool("agentf-mcp-list-tools") do
579
+ description "List MCP tools and current guardrail status."
580
+ call do |_args|
581
+ # Use guard to ensure the caller is allowed to invoke tools
582
+ mcp_server.send(:guard!, "agentf-mcp-list-tools", **{})
583
+
584
+ tools = s.list_tools
585
+ guard = {
586
+ allowed_tools: mcp_server.guardrails[:allowed_tools].to_a,
587
+ allow_writes: mcp_server.guardrails[:allow_writes],
588
+ max_arg_length: mcp_server.guardrails[:max_arg_length]
589
+ }
590
+
591
+ JSON.generate({ tools: tools, guardrails: guard })
592
+ end
593
+ end
594
+
421
595
  s.tool("agentf-memory-add-lesson") do
422
596
  description "Store a lesson memory in Redis."
423
597
  argument :title, String, required: true, description: "Lesson title"
424
598
  argument :description, String, required: true, description: "Lesson description"
425
599
  argument :agent, String, required: false, description: "Agent name"
426
- argument :tags, Array, required: false, items: String, description: "Tags"
427
600
  argument :context, String, required: false, description: "Context"
428
601
  call do |args|
429
602
  mcp_server.send(:guard!, "agentf-memory-add-lesson", **args)
430
- id = memory.store_episode(
431
- type: "lesson",
432
- title: args[:title],
433
- description: args[:description],
434
- agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
435
- tags: args[:tags] || [],
436
- context: args[:context].to_s,
437
- code_snippet: ""
438
- )
439
- JSON.generate(id: id, type: "lesson", status: "stored")
440
- end
441
- end
442
-
443
- s.tool("agentf-memory-add-success") do
444
- description "Store a success memory in Redis."
445
- argument :title, String, required: true, description: "Success title"
446
- argument :description, String, required: true, description: "Success description"
447
- argument :agent, String, required: false, description: "Agent name"
448
- argument :tags, Array, required: false, items: String, description: "Tags"
449
- argument :context, String, required: false, description: "Context"
450
- call do |args|
451
- mcp_server.send(:guard!, "agentf-memory-add-success", **args)
452
- id = memory.store_episode(
453
- type: "success",
454
- title: args[:title],
455
- description: args[:description],
456
- agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
457
- tags: args[:tags] || [],
458
- context: args[:context].to_s,
459
- code_snippet: ""
460
- )
461
- JSON.generate(id: id, type: "success", status: "stored")
603
+ begin
604
+ id = nil
605
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-lesson", args: args }) do
606
+ id = memory.store_episode(
607
+ type: "lesson",
608
+ title: args[:title],
609
+ description: args[:description],
610
+ agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
611
+ context: args[:context].to_s,
612
+ code_snippet: ""
613
+ )
614
+ end
615
+
616
+ if res.is_a?(Hash) && res["confirmation_required"]
617
+ JSON.generate(
618
+ confirmation_required: true,
619
+ confirmation_details: res["confirmation_details"],
620
+ attempted: res["attempted"],
621
+ confirmed_write_token: res["confirmed_write_token"],
622
+ confirmation_prompt: res["confirmation_prompt"]
623
+ )
624
+ else
625
+ JSON.generate(id: id, type: "lesson", status: "stored")
626
+ end
627
+ end
462
628
  end
463
629
  end
464
630
 
465
- s.tool("agentf-memory-add-pitfall") do
466
- description "Store a pitfall memory in Redis."
467
- argument :title, String, required: true, description: "Pitfall title"
468
- argument :description, String, required: true, description: "Pitfall description"
631
+ s.tool("agentf-memory-add-playbook") do
632
+ description "Store a playbook memory in Redis."
633
+ argument :title, String, required: true, description: "Playbook title"
634
+ argument :description, String, required: true, description: "Playbook description"
469
635
  argument :agent, String, required: false, description: "Agent name"
470
- argument :tags, Array, required: false, items: String, description: "Tags"
471
- argument :context, String, required: false, description: "Context"
636
+ argument :steps, Array, required: false, items: String, description: "Ordered playbook steps"
637
+ argument :feature_area, String, required: false, description: "Feature area"
472
638
  call do |args|
473
- mcp_server.send(:guard!, "agentf-memory-add-pitfall", **args)
474
- id = memory.store_episode(
475
- type: "pitfall",
476
- title: args[:title],
477
- description: args[:description],
478
- agent: args[:agent] || Agentf::AgentRoles::ENGINEER,
479
- tags: args[:tags] || [],
480
- context: args[:context].to_s,
481
- code_snippet: ""
482
- )
483
- JSON.generate(id: id, type: "pitfall", status: "stored")
639
+ mcp_server.send(:guard!, "agentf-memory-add-playbook", **args)
640
+ begin
641
+ id = nil
642
+ res = mcp_server.send(:safe_mcp_memory_write, memory, attempted: { tool: "agentf-memory-add-playbook", args: args }) do
643
+ id = memory.store_playbook(
644
+ title: args[:title],
645
+ description: args[:description],
646
+ agent: args[:agent] || Agentf::AgentRoles::PLANNER,
647
+ steps: args[:steps] || [],
648
+ feature_area: args[:feature_area]
649
+ )
650
+ end
651
+
652
+ if res.is_a?(Hash) && res["confirmation_required"]
653
+ JSON.generate(
654
+ confirmation_required: true,
655
+ confirmation_details: res["confirmation_details"],
656
+ attempted: res["attempted"],
657
+ confirmed_write_token: res["confirmed_write_token"],
658
+ confirmation_prompt: res["confirmation_prompt"]
659
+ )
660
+ else
661
+ JSON.generate(id: id, type: "playbook", status: "stored")
662
+ end
663
+ end
484
664
  end
485
665
  end
486
666
  end