kward 0.73.1 → 0.75.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -1
@@ -0,0 +1,122 @@
1
+ require "json"
2
+ require_relative "base"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ module Tools
7
+ # Adapts an MCP server tool to Kward's model-callable tool interface.
8
+ class MCPTool < Base
9
+ attr_reader :server_name, :remote_name
10
+
11
+ def display_name
12
+ "#{server_name}.#{remote_name}"
13
+ end
14
+
15
+ def initialize(server_name:, client:, tool:)
16
+ @server_name = server_name.to_s
17
+ @client = client
18
+ @tool = tool
19
+ @remote_name = value(tool, "name").to_s
20
+ super(kward_name(@server_name, @remote_name), description, properties: {}, required: [])
21
+ end
22
+
23
+ def schema
24
+ {
25
+ type: "function",
26
+ function: {
27
+ name: name,
28
+ description: description,
29
+ parameters: input_schema
30
+ }
31
+ }
32
+ end
33
+
34
+ def call(args, _conversation, cancellation: nil)
35
+ cancellation&.raise_if_cancelled!
36
+ result = @client.call_tool(@remote_name, args || {})
37
+ format_result(result)
38
+ rescue StandardError => e
39
+ "MCP tool #{server_name}.#{remote_name} failed: #{e.message}"
40
+ end
41
+
42
+ def self.kward_name(server_name, tool_name)
43
+ "#{sanitize_name(server_name)}__#{sanitize_name(tool_name)}"
44
+ end
45
+
46
+ def self.sanitize_name(value)
47
+ text = value.to_s.gsub(/[^A-Za-z0-9_-]/, "_")
48
+ text = "mcp" if text.empty?
49
+ text[0, 60]
50
+ end
51
+
52
+ private
53
+
54
+ def kward_name(server_name, tool_name)
55
+ self.class.kward_name(server_name, tool_name)
56
+ end
57
+
58
+ def description
59
+ value(@tool, "description").to_s.empty? ? "MCP tool #{@server_name}.#{@remote_name}" : value(@tool, "description").to_s
60
+ end
61
+
62
+ def input_schema
63
+ schema = value(@tool, "inputSchema")
64
+ return empty_object_schema unless schema.is_a?(Hash)
65
+
66
+ schema.empty? ? empty_object_schema : schema
67
+ end
68
+
69
+ def empty_object_schema
70
+ { type: "object", properties: {}, additionalProperties: false }
71
+ end
72
+
73
+ def format_result(result)
74
+ return "MCP tool #{server_name}.#{remote_name} returned no content." unless result.is_a?(Hash)
75
+
76
+ parts = []
77
+ parts << "MCP tool #{server_name}.#{remote_name} reported an error:" if truthy?(value(result, "isError"))
78
+ parts.concat(format_content(value(result, "content")))
79
+
80
+ structured = value(result, "structuredContent")
81
+ parts << JSON.pretty_generate(structured) unless structured.nil?
82
+
83
+ parts.empty? ? "MCP tool #{server_name}.#{remote_name} returned no content." : parts.join("\n\n")
84
+ end
85
+
86
+ def format_content(content)
87
+ Array(content).filter_map do |entry|
88
+ next entry.to_s unless entry.is_a?(Hash)
89
+
90
+ case value(entry, "type")
91
+ when "text"
92
+ value(entry, "text").to_s
93
+ when "image"
94
+ "[MCP image content: #{value(entry, "mimeType") || "unknown MIME type"}]"
95
+ when "audio"
96
+ "[MCP audio content: #{value(entry, "mimeType") || "unknown MIME type"}]"
97
+ when "resource_link"
98
+ "[MCP resource link: #{value(entry, "uri") || value(entry, "name") || "unnamed"}]"
99
+ when "resource"
100
+ "[MCP embedded resource: #{resource_label(value(entry, "resource"))}]"
101
+ else
102
+ JSON.generate(entry)
103
+ end
104
+ end
105
+ end
106
+
107
+ def resource_label(resource)
108
+ return "unnamed" unless resource.is_a?(Hash)
109
+
110
+ value(resource, "uri") || value(resource, "name") || "unnamed"
111
+ end
112
+
113
+ def truthy?(value)
114
+ value == true || value.to_s == "true"
115
+ end
116
+
117
+ def value(object, key)
118
+ object[key] || object[key.to_sym]
119
+ end
120
+ end
121
+ end
122
+ end
@@ -8,12 +8,16 @@ module Kward
8
8
  # Tool wrapper for reading configured skill instructions.
9
9
  class ReadSkill < Base
10
10
  # Builds the tool schema and stores the execution dependency.
11
- def initialize
11
+ def initialize(skills: nil)
12
+ name_property = { type: "string", description: "Skill name." }
13
+ skill_names = Array(skills).map(&:name).compact.sort
14
+ name_property[:enum] = skill_names unless skill_names.empty?
15
+
12
16
  super(
13
17
  "read_skill",
14
18
  "Read configured skill instructions/files.",
15
19
  properties: {
16
- name: { type: "string", description: "Skill name." },
20
+ name: name_property,
17
21
  path: { type: "string", description: "Path inside skill; default SKILL.md." }
18
22
  },
19
23
  required: ["name"]
@@ -21,11 +25,12 @@ module Kward
21
25
  end
22
26
 
23
27
  # Executes the tool and returns model-facing output text.
24
- def call(args, _conversation, cancellation: nil)
28
+ def call(args, conversation, cancellation: nil)
25
29
  name = argument(args, :name, "")
26
30
  path = argument(args, :path)
31
+ workspace_root = conversation.respond_to?(:workspace_root) ? conversation.workspace_root : Dir.pwd
27
32
 
28
- ConfigFiles.read_skill_file(name, path)
33
+ ConfigFiles.read_skill_file(name, path, workspace_root: workspace_root)
29
34
  end
30
35
  end
31
36
  end
@@ -1,5 +1,6 @@
1
1
  require_relative "../config_files"
2
2
  require_relative "../conversation"
3
+ require_relative "../hooks"
3
4
  require_relative "ask_user_question"
4
5
  require_relative "code_search"
5
6
  require_relative "context_budget_stats"
@@ -8,6 +9,7 @@ require_relative "edit_file"
8
9
  require_relative "fetch_content"
9
10
  require_relative "fetch_raw"
10
11
  require_relative "list_directory"
12
+ require_relative "mcp_tool"
11
13
  require_relative "read_file"
12
14
  require_relative "read_skill"
13
15
  require_relative "run_shell_command"
@@ -19,6 +21,7 @@ require_relative "search/code"
19
21
  require_relative "search/web"
20
22
  require_relative "search/web_fetch"
21
23
  require_relative "tool_call"
24
+ require_relative "../mcp/server_config"
22
25
  require_relative "../telemetry/logger"
23
26
  require_relative "../tool_output_compactor"
24
27
  require_relative "../workspace"
@@ -60,7 +63,7 @@ module Kward
60
63
  # @param web_search_enabled [Boolean, nil] override for web search exposure
61
64
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
62
65
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
63
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil)
66
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
64
67
  @workspace = workspace
65
68
  @prompt = prompt
66
69
  @web_search = web_search
@@ -75,8 +78,18 @@ module Kward
75
78
  @tool_output_compactor = tool_output_compactor
76
79
  @telemetry_logger = telemetry_logger
77
80
  @context_budget_meter = context_budget_meter
81
+ @tool_approval = tool_approval
82
+ @hook_manager = hook_manager
83
+ @hook_context = hook_context
84
+ @mcp_clients = if mcp_clients
85
+ mcp_clients
86
+ elsif @allowed_tool_names
87
+ []
88
+ else
89
+ MCP::ServerConfig.clients_from_config(ConfigFiles.read_config)
90
+ end
78
91
  @tools = build_tools.freeze
79
- @schemas = build_schema_tools.map(&:schema).freeze
92
+ @schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
80
93
  end
81
94
 
82
95
  # Executes a model-requested tool call and appends the result to the
@@ -96,10 +109,18 @@ module Kward
96
109
  tool = @tools[name]
97
110
 
98
111
  original_content = if tool
99
- if mutation_tool?(name) && !write_lock_owned?
112
+ before_tool = run_hook("tool_call_before", conversation, payload: tool_payload(name, args, tool_call))
113
+ args = hook_arguments(before_tool, args)
114
+ if before_tool.denied?
115
+ hook_denied_content(before_tool, "tool call denied: #{name}")
116
+ elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
117
+ hook_denied_content(before_tool, "tool call approval denied: #{name}")
118
+ elsif mutation_tool?(name) && !write_lock_owned?
100
119
  "Workspace write denied: another worker owns the write lock."
120
+ elsif tool_approval_denied?(tool_call, name, args, cancellation)
121
+ "Declined: tool execution denied by user: #{name}"
101
122
  else
102
- tool.call(args, conversation, cancellation: cancellation)
123
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
103
124
  end
104
125
  else
105
126
  "Unknown tool: #{name}"
@@ -112,7 +133,7 @@ module Kward
112
133
  end
113
134
 
114
135
  artifact_id = nil
115
- model_content = @tool_output_compactor.compact(name, content) do
136
+ model_content = compact_tool_output(name, content, original_content, conversation) do
116
137
  artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
117
138
  end
118
139
  record_context_budget(conversation, name, before: original_content, after: model_content)
@@ -127,8 +148,73 @@ module Kward
127
148
  model_content
128
149
  end
129
150
 
151
+ def metadata_for(name)
152
+ tool = @tools[name.to_s]
153
+ return unknown_tool_metadata(name) unless tool
154
+
155
+ tool_metadata(tool)
156
+ end
157
+
130
158
  private
131
159
 
160
+ def schema_with_metadata(tool)
161
+ tool.schema.merge(metadata: tool_metadata(tool))
162
+ end
163
+
164
+ def tool_metadata(tool)
165
+ if tool.is_a?(Tools::MCPTool)
166
+ {
167
+ source: "mcp",
168
+ displayName: tool.display_name,
169
+ serverName: tool.server_name,
170
+ remoteName: tool.remote_name
171
+ }
172
+ else
173
+ {
174
+ source: source_for_tool(tool),
175
+ displayName: tool.name
176
+ }
177
+ end
178
+ end
179
+
180
+ def source_for_tool(tool)
181
+ case tool
182
+ when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
183
+ "web"
184
+ when Tools::ReadSkill
185
+ "skill"
186
+ when Tools::AskUserQuestion
187
+ "ui"
188
+ else
189
+ "builtin"
190
+ end
191
+ end
192
+
193
+ def unknown_tool_metadata(name)
194
+ {
195
+ source: "unknown",
196
+ displayName: name.to_s
197
+ }
198
+ end
199
+
200
+ def compact_tool_output(name, content, original_content, conversation, &store_artifact)
201
+ before = run_hook("tool_output_compact_before", conversation, payload: {
202
+ tool_name: name,
203
+ bytes: content.bytesize,
204
+ duplicate: content != original_content
205
+ })
206
+ return content if before.denied? || before.approval_required?
207
+
208
+ compacted = @tool_output_compactor.compact(name, content, &store_artifact)
209
+ run_hook("tool_output_compact_after", conversation, payload: {
210
+ tool_name: name,
211
+ bytes_before: content.bytesize,
212
+ bytes_after: compacted.bytesize,
213
+ compacted: compacted != content
214
+ }) if compacted != content
215
+ compacted
216
+ end
217
+
132
218
  def record_context_budget(conversation, name, before:, after:)
133
219
  meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
134
220
  return unless meter
@@ -161,10 +247,184 @@ module Kward
161
247
  )
162
248
  end
163
249
 
250
+ def execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
251
+ args = run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
252
+ run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
253
+ run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
254
+ content = tool.call(args, conversation, cancellation: cancellation)
255
+ run_mcp_after_hooks(name, args, content, conversation)
256
+ run_shell_after_hooks(name, args, content, conversation)
257
+ run_file_change_after_hooks(name, args, content, conversation)
258
+ run_hook("tool_call_after", conversation, payload: tool_payload(name, args, tool_call).merge(content: content.to_s))
259
+ content
260
+ rescue HookDenied => e
261
+ e.message
262
+ rescue StandardError => e
263
+ run_mcp_error_hooks(name, args, e, conversation)
264
+ run_hook("tool_call_error", conversation, payload: tool_payload(name, args, tool_call).merge(error: e.message))
265
+ raise
266
+ end
267
+
268
+ def run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
269
+ return unless mcp_tool?(name)
270
+
271
+ result = run_hook("mcp_tool_before", conversation, payload: mcp_payload(name, args))
272
+ if result.denied?
273
+ raise HookDenied, hook_denied_content(result, "MCP tool denied")
274
+ end
275
+ if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
276
+ raise HookDenied, hook_denied_content(result, "MCP tool approval denied")
277
+ end
278
+ end
279
+
280
+ def run_mcp_after_hooks(name, args, content, conversation)
281
+ return unless mcp_tool?(name)
282
+
283
+ run_hook("mcp_tool_after", conversation, payload: mcp_payload(name, args).merge(content: content.to_s))
284
+ end
285
+
286
+ def run_mcp_error_hooks(name, args, error, conversation)
287
+ return unless mcp_tool?(name)
288
+
289
+ run_hook("mcp_tool_error", conversation, payload: mcp_payload(name, args).merge(error: error.message))
290
+ end
291
+
292
+ def run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
293
+ return args unless name.to_s == "run_shell_command"
294
+
295
+ result = run_hook("shell_command_before", conversation, payload: shell_payload(args))
296
+ if result.denied?
297
+ raise HookDenied, hook_denied_content(result, "shell command denied")
298
+ end
299
+ if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
300
+ raise HookDenied, hook_denied_content(result, "shell command approval denied")
301
+ end
302
+ shell_arguments(result.payload, args)
303
+ end
304
+
305
+ def run_shell_after_hooks(name, args, content, conversation)
306
+ return unless name.to_s == "run_shell_command"
307
+
308
+ run_hook("shell_command_after", conversation, payload: shell_payload(args).merge(content: content.to_s))
309
+ end
310
+
311
+ def run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
312
+ return unless ToolCall.file_change_tool?(name)
313
+
314
+ result = run_hook("file_change_before", conversation, payload: file_change_payload(name, args))
315
+ if result.denied?
316
+ raise HookDenied, hook_denied_content(result, "file change denied")
317
+ end
318
+ if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
319
+ raise HookDenied, hook_denied_content(result, "file change approval denied")
320
+ end
321
+ end
322
+
323
+ def run_file_change_after_hooks(name, args, content, conversation)
324
+ return unless ToolCall.file_change_tool?(name)
325
+ return unless content.to_s.start_with?("Wrote ", "Edited ")
326
+
327
+ run_hook("file_change_after", conversation, payload: file_change_payload(name, args).merge(content: content.to_s))
328
+ end
329
+
330
+ HookDenied = Class.new(StandardError)
331
+
332
+ def run_hook(name, conversation, payload: {})
333
+ return Hooks::Manager::Result.new(event: nil, decision: Hooks::Decision.allow, decisions: [], warnings: [], messages: [], payload: payload) unless @hook_manager
334
+
335
+ @hook_manager.run(Hooks::Event.new(
336
+ name: name,
337
+ workspace: { root: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil },
338
+ payload: payload
339
+ ), context: @hook_context || default_hook_context(conversation))
340
+ end
341
+
342
+ def default_hook_context(conversation)
343
+ return nil unless defined?(PluginRegistry::Context)
344
+
345
+ PluginRegistry::Context.new(conversation: conversation, workspace_root: @workspace.respond_to?(:root) ? @workspace.root.to_s : Dir.pwd)
346
+ end
347
+
348
+ def tool_payload(name, args, tool_call)
349
+ metadata = metadata_for(name)
350
+ {
351
+ tool_name: name,
352
+ arguments: args,
353
+ tool_call_id: tool_call["id"] || tool_call[:id],
354
+ source: metadata[:source] || metadata["source"],
355
+ server_name: metadata[:serverName] || metadata["serverName"],
356
+ remote_name: metadata[:remoteName] || metadata["remoteName"]
357
+ }.compact
358
+ end
359
+
360
+ def hook_arguments(result, fallback)
361
+ payload_arguments = result.payload[:arguments] || result.payload["arguments"]
362
+ payload_arguments.is_a?(Hash) ? payload_arguments : fallback
363
+ end
364
+
365
+ def mcp_payload(name, args)
366
+ tool_payload(name, args, {}).slice(:tool_name, :arguments, :source, :server_name, :remote_name)
367
+ end
368
+
369
+ def shell_payload(args)
370
+ {
371
+ tool_name: "run_shell_command",
372
+ command: args[:command] || args["command"],
373
+ timeout_seconds: args[:timeout_seconds] || args["timeout_seconds"] || Workspace::DEFAULT_COMMAND_TIMEOUT_SECONDS,
374
+ cwd: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil
375
+ }.compact
376
+ end
377
+
378
+ def file_change_payload(name, args)
379
+ operation = name.to_s.start_with?("edit") ? "edit" : "write"
380
+ path = args[:path] || args["path"]
381
+ payload = {
382
+ tool_name: name,
383
+ operation: operation,
384
+ path: path,
385
+ files: [{ path: path, operation: operation }]
386
+ }
387
+ if operation == "edit"
388
+ payload[:edits] = args[:edits] || args["edits"]
389
+ else
390
+ payload[:content] = args[:content] || args["content"]
391
+ end
392
+ payload.compact
393
+ end
394
+
395
+ def shell_arguments(payload, fallback)
396
+ fallback.merge(
397
+ command: payload[:command] || payload["command"] || fallback[:command] || fallback["command"],
398
+ timeout_seconds: payload[:timeout_seconds] || payload["timeout_seconds"] || fallback[:timeout_seconds] || fallback["timeout_seconds"]
399
+ )
400
+ end
401
+
402
+ def hook_denied_content(result, fallback)
403
+ "Declined: #{result.decision.message || fallback}"
404
+ end
405
+
406
+ def hook_approval_denied?(result, tool_call, name, args, cancellation)
407
+ return true unless @tool_approval
408
+
409
+ approval_args = args.merge("hook_message" => result.decision.message)
410
+ @tool_approval.call(tool_call: tool_call, name: name, args: approval_args, cancellation: cancellation) == false
411
+ end
412
+
413
+ def mcp_tool?(name)
414
+ metadata = metadata_for(name)
415
+ (metadata[:source] || metadata["source"]).to_s == "mcp"
416
+ end
417
+
164
418
  def mutation_tool?(name)
165
419
  ToolCall.write_lock_required?(name)
166
420
  end
167
421
 
422
+ def tool_approval_denied?(tool_call, name, args, cancellation)
423
+ return false unless @tool_approval
424
+
425
+ @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
426
+ end
427
+
168
428
  def write_lock_owned?
169
429
  return true unless @write_lock
170
430
 
@@ -182,6 +442,7 @@ module Kward
182
442
  "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
183
443
  )
184
444
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
445
+ tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
185
446
  tools << @tools["read_skill"] if skills_available?
186
447
  tools << @tools["ask_user_question"] if ask_user_question_available?
187
448
  tools.compact
@@ -192,9 +453,9 @@ module Kward
192
453
  Tools::WebSearch.new(web_search: @web_search),
193
454
  Tools::FetchContent.new(web_fetch: @web_fetch),
194
455
  Tools::FetchRaw.new(web_fetch: @web_fetch),
195
- Tools::ReadSkill.new,
456
+ Tools::ReadSkill.new(skills: discovered_skills),
196
457
  Tools::AskUserQuestion.new(prompt: @prompt)
197
- ]
458
+ ] + mcp_tool_values
198
459
  end
199
460
 
200
461
  def core_tools
@@ -219,9 +480,36 @@ module Kward
219
480
  true
220
481
  end
221
482
 
483
+ def discovered_skills(workspace_root: nil)
484
+ return @skills unless @skills.nil?
485
+
486
+ @discovered_skills ||= {}
487
+ root = workspace_root || (@workspace.respond_to?(:root) ? @workspace.root : Dir.pwd)
488
+ @discovered_skills[root] ||= ConfigFiles.skills(workspace_root: root)
489
+ end
490
+
222
491
  def skills_available?
223
- skills = @skills.nil? ? ConfigFiles.skills : @skills
224
- skills.any?
492
+ discovered_skills.any?
493
+ end
494
+
495
+ def mcp_tool_values
496
+ @mcp_tool_values ||= build_mcp_tools
497
+ end
498
+
499
+ def build_mcp_tools
500
+ Array(@mcp_clients).flat_map do |client|
501
+ client.list_tools.map do |tool|
502
+ Tools::MCPTool.new(server_name: client.name, client: client, tool: tool)
503
+ end
504
+ rescue StandardError => e
505
+ @telemetry_logger.log(
506
+ "mcp",
507
+ "server_unavailable",
508
+ "server" => client.respond_to?(:name) ? client.name : "unknown",
509
+ "error" => e.message
510
+ )
511
+ []
512
+ end
225
513
  end
226
514
 
227
515
  def ask_user_question_available?
@@ -5,7 +5,9 @@ require "net/http"
5
5
  require "open3"
6
6
  require "pathname"
7
7
  require "uri"
8
+ require_relative "../../path_guard"
8
9
  require_relative "../../config_files"
10
+ require_relative "web"
9
11
 
10
12
  # Namespace for the Kward CLI agent runtime.
11
13
  module Kward
@@ -24,7 +26,7 @@ module Kward
24
26
  ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
25
27
 
26
28
  # Creates an object for code search and repository cache operations.
27
- def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
29
+ def initialize(cache_root: nil, http_client: WebSearch::NetHttpClient.new(user_agent: "Kward code_search"), git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
28
30
  @cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
29
31
  @http_client = http_client
30
32
  @git_runner = git_runner
@@ -49,26 +51,6 @@ module Kward
49
51
  "Error: code_search failed: #{redact(e.message)}"
50
52
  end
51
53
 
52
- # HTTP adapter used by code search registry/package lookups.
53
- class NetHttpClient
54
- def get_json(url, headers: {})
55
- JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
56
- end
57
-
58
- def get_text(url, headers: {})
59
- uri = URI(url)
60
- request = Net::HTTP::Get.new(uri)
61
- request["User-Agent"] = "Kward code_search"
62
- headers.each { |key, val| request[key] = val }
63
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
64
- http.request(request)
65
- end
66
- raise "HTTP #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
67
-
68
- response.body
69
- end
70
- end
71
-
72
54
  # Git command adapter used by repository cache operations.
73
55
  class GitRunner
74
56
  def run(*args, chdir: nil)
@@ -376,7 +358,7 @@ module Kward
376
358
  end
377
359
 
378
360
  def inside_root?(root, path)
379
- path == root || path.start_with?(root + File::SEPARATOR)
361
+ PathGuard.inside?(path, root)
380
362
  end
381
363
 
382
364
  def inside_cache?(path)
@@ -54,7 +54,10 @@ module Kward
54
54
  return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
55
55
 
56
56
  max_results = bounded_max_results(args_value(args, "max_results") || args_value(args, "num_results"))
57
- provider = normalize_provider(args_value(args, "provider") || config_value("provider") || "auto")
57
+ provider_value = args_value(args, "provider")
58
+ provider_value = config_value("provider") if provider_value.nil?
59
+ provider_value = "auto" if provider_value.nil? || provider_value.to_s.empty?
60
+ provider = normalize_provider(provider_value)
58
61
  return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
59
62
 
60
63
  options = {
@@ -549,8 +552,10 @@ module Kward
549
552
 
550
553
  def args_value(args, key)
551
554
  return nil unless args.is_a?(Hash)
555
+ return args[key] if args.key?(key)
556
+ return args[key.to_sym] if args.key?(key.to_sym)
552
557
 
553
- args[key] || args[key.to_sym]
558
+ nil
554
559
  end
555
560
 
556
561
  def success?(response)
@@ -717,10 +722,26 @@ module Kward
717
722
  class NetHttpClient
718
723
  Response = Struct.new(:code, :body, :headers, keyword_init: true)
719
724
 
725
+ def initialize(user_agent: nil)
726
+ @user_agent = user_agent
727
+ end
728
+
720
729
  def get(url, headers: {})
721
730
  request(url, Net::HTTP::Get, headers: headers)
722
731
  end
723
732
 
733
+ def get_json(url, headers: {})
734
+ JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
735
+ end
736
+
737
+ def get_text(url, headers: {})
738
+ uri = URI.parse(url)
739
+ response = get(uri.to_s, headers: headers)
740
+ raise "HTTP #{response.code} from #{uri.host}" unless success?(response.code)
741
+
742
+ response.body
743
+ end
744
+
724
745
  def post(url, form:, headers: {})
725
746
  request(url, Net::HTTP::Post, headers: headers) do |http_request|
726
747
  http_request.set_form_data(form)
@@ -739,12 +760,17 @@ module Kward
739
760
  uri = URI.parse(url)
740
761
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
741
762
  http_request = request_class.new(uri)
763
+ http_request["User-Agent"] = @user_agent if @user_agent
742
764
  headers.each { |key, value| http_request[key] = value }
743
765
  yield http_request if block_given?
744
766
  response = http.request(http_request)
745
767
  Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
746
768
  end
747
769
  end
770
+
771
+ def success?(code)
772
+ code.to_i.between?(200, 299)
773
+ end
748
774
  end
749
775
  end
750
776
  end