kward 0.73.1 → 0.74.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/Gemfile.lock +2 -2
- data/doc/configuration.md +14 -1
- data/doc/mcp.md +72 -0
- data/doc/rpc.md +1 -0
- data/doc/usage.md +7 -0
- data/lib/kward/cli/commands.rb +3 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/interactive_turn.rb +9 -3
- data/lib/kward/cli/tabs.rb +6 -9
- data/lib/kward/cli.rb +38 -1
- data/lib/kward/config_files.rb +35 -2
- data/lib/kward/mcp/client.rb +56 -0
- data/lib/kward/mcp/server_config.rb +55 -0
- data/lib/kward/mcp/stdio_transport.rb +106 -0
- data/lib/kward/prompt_interface/editor/controller.rb +64 -3
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +16 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +67 -12
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/tools/mcp_tool.rb +118 -0
- data/lib/kward/tools/registry.rb +32 -2
- data/lib/kward/version.rb +1 -1
- metadata +6 -1
|
@@ -88,10 +88,26 @@ module Kward
|
|
|
88
88
|
visible = line.to_s[column_offset.to_i, text_width].to_s
|
|
89
89
|
rendered = editor_render_visible_line(visible, line_index)
|
|
90
90
|
line_start = @editor_state.line_start_offset(line_index)
|
|
91
|
+
rendered = editor_overlay_search_matches(rendered, line_start, column_offset, visible.length)
|
|
91
92
|
rendered = editor_overlay_line_selections(rendered, line_start, column_offset, visible.length)
|
|
92
93
|
editor_overlay_secondary_cursors(rendered, line_start, column_offset, visible.length, text_width)
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
def editor_overlay_search_matches(rendered, line_start, column_offset, visible_length)
|
|
97
|
+
ranges = @editor_state.search_match_ranges
|
|
98
|
+
return rendered if ranges.empty?
|
|
99
|
+
|
|
100
|
+
visible_offset = line_start + column_offset.to_i
|
|
101
|
+
match_ranges = ranges.filter_map do |range|
|
|
102
|
+
match_start = [range[0] - visible_offset, 0].max
|
|
103
|
+
match_end = [range[1] - visible_offset, visible_length].min
|
|
104
|
+
[match_start, match_end] if match_start < match_end
|
|
105
|
+
end
|
|
106
|
+
return rendered if match_ranges.empty?
|
|
107
|
+
|
|
108
|
+
editor_overlay_selection(rendered, match_ranges)
|
|
109
|
+
end
|
|
110
|
+
|
|
95
111
|
def editor_overlay_line_selections(rendered, line_start, column_offset, visible_length)
|
|
96
112
|
ranges = @editor_state.selection_ranges
|
|
97
113
|
return rendered if ranges.empty?
|
|
@@ -6,44 +6,82 @@ module Kward
|
|
|
6
6
|
class EditorSearch
|
|
7
7
|
attr_reader :query, :direction
|
|
8
8
|
|
|
9
|
+
def self.match_ranges(text, query, base_offset: 0)
|
|
10
|
+
query = query.to_s
|
|
11
|
+
return [] if query.empty?
|
|
12
|
+
|
|
13
|
+
haystack, needle = normalized_pair(text.to_s, query)
|
|
14
|
+
ranges = []
|
|
15
|
+
start = 0
|
|
16
|
+
while (index = haystack.index(needle, start))
|
|
17
|
+
ranges << [base_offset + index, base_offset + index + query.length]
|
|
18
|
+
start = index + [query.length, 1].max
|
|
19
|
+
end
|
|
20
|
+
ranges
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.normalized_pair(buffer, query)
|
|
24
|
+
return [buffer, query] if case_sensitive?(query)
|
|
25
|
+
|
|
26
|
+
[buffer.downcase, query.downcase]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.case_sensitive?(query)
|
|
30
|
+
query.to_s.match?(/[[:upper:]]/)
|
|
31
|
+
end
|
|
32
|
+
|
|
9
33
|
def initialize(direction: :forward)
|
|
10
34
|
@active = false
|
|
11
35
|
@query = +""
|
|
12
36
|
@direction = direction
|
|
37
|
+
@origin_cursor = nil
|
|
38
|
+
@current_match = nil
|
|
13
39
|
end
|
|
14
40
|
|
|
15
41
|
def active?
|
|
16
42
|
@active == true
|
|
17
43
|
end
|
|
18
44
|
|
|
19
|
-
def begin(direction = :forward)
|
|
45
|
+
def begin(direction = :forward, cursor: nil)
|
|
20
46
|
@active = true
|
|
21
47
|
@direction = direction
|
|
22
48
|
@query = +""
|
|
49
|
+
@origin_cursor = cursor
|
|
50
|
+
@current_match = nil
|
|
23
51
|
status_prefix
|
|
24
52
|
end
|
|
25
53
|
|
|
26
|
-
def cancel
|
|
54
|
+
def cancel(restore_cursor: true)
|
|
27
55
|
@active = false
|
|
28
|
-
|
|
56
|
+
cursor = @origin_cursor if restore_cursor
|
|
57
|
+
@origin_cursor = nil
|
|
58
|
+
@current_match = nil
|
|
59
|
+
{ cursor: cursor, status: "Search cancelled", found: false }
|
|
29
60
|
end
|
|
30
61
|
|
|
31
|
-
def append(text)
|
|
62
|
+
def append(text, buffer:, cursor:)
|
|
32
63
|
@query << text.to_s
|
|
33
|
-
|
|
64
|
+
live_result(buffer: buffer, cursor: cursor)
|
|
34
65
|
end
|
|
35
66
|
|
|
36
|
-
def delete_character
|
|
67
|
+
def delete_character(buffer:, cursor:)
|
|
37
68
|
@query = @query[0...-1].to_s
|
|
38
|
-
|
|
69
|
+
live_result(buffer: buffer, cursor: cursor)
|
|
39
70
|
end
|
|
40
71
|
|
|
41
72
|
def confirm(buffer:, cursor:)
|
|
42
73
|
confirmed_query = @query.to_s
|
|
43
74
|
@active = false
|
|
75
|
+
@origin_cursor = nil
|
|
44
76
|
return { status: "Search cancelled", found: false } if confirmed_query.empty?
|
|
45
77
|
|
|
78
|
+
if @current_match
|
|
79
|
+
return { cursor: @current_match, status: "Found: #{confirmed_query}", found: true }
|
|
80
|
+
end
|
|
81
|
+
|
|
46
82
|
repeat(buffer: buffer, cursor: cursor, direction: @direction, query: confirmed_query)
|
|
83
|
+
ensure
|
|
84
|
+
@current_match = nil
|
|
47
85
|
end
|
|
48
86
|
|
|
49
87
|
def repeat(buffer:, cursor:, direction: @direction, query: @query)
|
|
@@ -52,12 +90,7 @@ module Kward
|
|
|
52
90
|
|
|
53
91
|
@query = query
|
|
54
92
|
@direction = direction
|
|
55
|
-
index =
|
|
56
|
-
search_from = cursor.positive? ? cursor - 1 : buffer.length
|
|
57
|
-
buffer.rindex(query, search_from) || buffer.rindex(query)
|
|
58
|
-
else
|
|
59
|
-
buffer.index(query, cursor + 1) || buffer.index(query)
|
|
60
|
-
end
|
|
93
|
+
index = find_match(buffer, query, cursor, direction)
|
|
61
94
|
|
|
62
95
|
if index
|
|
63
96
|
{ cursor: index, status: "Found: #{query}", found: true }
|
|
@@ -68,6 +101,30 @@ module Kward
|
|
|
68
101
|
|
|
69
102
|
private
|
|
70
103
|
|
|
104
|
+
def live_result(buffer:, cursor:)
|
|
105
|
+
return { cursor: @origin_cursor, status: status_prefix, found: false } if @query.empty?
|
|
106
|
+
|
|
107
|
+
@origin_cursor = cursor if @origin_cursor.nil?
|
|
108
|
+
index = find_match(buffer, @query, @origin_cursor, @direction)
|
|
109
|
+
@current_match = index
|
|
110
|
+
if index
|
|
111
|
+
{ cursor: index, status: "#{status_prefix} #{@query}", found: true }
|
|
112
|
+
else
|
|
113
|
+
{ cursor: @origin_cursor, status: "No match: #{@query}", found: false }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_match(buffer, query, cursor, direction)
|
|
118
|
+
haystack, needle = self.class.normalized_pair(buffer.to_s, query.to_s)
|
|
119
|
+
cursor = cursor.to_i
|
|
120
|
+
if direction == :backward
|
|
121
|
+
search_from = cursor.positive? ? cursor - 1 : haystack.length
|
|
122
|
+
haystack.rindex(needle, search_from) || haystack.rindex(needle)
|
|
123
|
+
else
|
|
124
|
+
haystack.index(needle, cursor + 1) || haystack.index(needle)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
71
128
|
def status_prefix
|
|
72
129
|
@direction == :backward ? "Search backward:" : "Search:"
|
|
73
130
|
end
|
|
@@ -19,6 +19,7 @@ module Kward
|
|
|
19
19
|
attr_reader :path, :display_path, :language, :original_content, :original_digest, :original_mtime, :original_size
|
|
20
20
|
attr_reader :buffer, :undo_stack, :redo_stack, :kill_buffer, :kill_ring, :last_yank_range, :last_yank_index
|
|
21
21
|
attr_accessor :viewport_row, :viewport_column, :status, :overwrite_confirmed, :quit_confirmed, :search_active, :search_query, :search_direction, :new_file, :editor_mode, :emacs_pending, :readonly, :diff_view
|
|
22
|
+
attr_reader :search_match_ranges
|
|
22
23
|
|
|
23
24
|
def initialize(path:, content:, new_file: false, editor_mode: "modern", readonly: false, diff_view: false, virtual: false, display_path: nil, language: nil)
|
|
24
25
|
@path = virtual ? nil : path.to_s
|
|
@@ -45,6 +46,7 @@ module Kward
|
|
|
45
46
|
@search_active = @search.active?
|
|
46
47
|
@search_query = @search.query
|
|
47
48
|
@search_direction = @search.direction
|
|
49
|
+
@search_match_ranges = []
|
|
48
50
|
@kill_state = EditorKillRing.new
|
|
49
51
|
@kill_buffer = @kill_state.kill_buffer
|
|
50
52
|
@selections = EditorSelections.new(cursor: @cursor, buffer_length: @buffer.length)
|
|
@@ -80,6 +82,7 @@ module Kward
|
|
|
80
82
|
@search_active = other.search_active
|
|
81
83
|
@search_query = other.search_query.dup
|
|
82
84
|
@search_direction = other.search_direction
|
|
85
|
+
@search_match_ranges = other.search_match_ranges.map(&:dup)
|
|
83
86
|
@kill_state = EditorKillRing.new(
|
|
84
87
|
kill_buffer: other.kill_buffer.dup,
|
|
85
88
|
kill_ring: other.kill_ring.map(&:dup),
|
|
@@ -231,6 +234,51 @@ module Kward
|
|
|
231
234
|
sync_vibe_state
|
|
232
235
|
end
|
|
233
236
|
|
|
237
|
+
def vibe_register_types
|
|
238
|
+
@vibe_state.register_types
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def vibe_register_types=(value)
|
|
242
|
+
@vibe_state.register_types = value
|
|
243
|
+
sync_vibe_state
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def vibe_kill_linewise
|
|
247
|
+
@vibe_state.kill_linewise
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def vibe_kill_linewise=(value)
|
|
251
|
+
@vibe_state.kill_linewise = value
|
|
252
|
+
sync_vibe_state
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def vibe_previous_change_cursor
|
|
256
|
+
@vibe_state.previous_change_cursor
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def vibe_previous_change_cursor=(value)
|
|
260
|
+
@vibe_state.previous_change_cursor = value
|
|
261
|
+
sync_vibe_state
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def vibe_jump_back_list
|
|
265
|
+
@vibe_state.jump_back_list
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def vibe_jump_back_list=(value)
|
|
269
|
+
@vibe_state.jump_back_list = value
|
|
270
|
+
sync_vibe_state
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def vibe_jump_forward_list
|
|
274
|
+
@vibe_state.jump_forward_list
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def vibe_jump_forward_list=(value)
|
|
278
|
+
@vibe_state.jump_forward_list = value
|
|
279
|
+
sync_vibe_state
|
|
280
|
+
end
|
|
281
|
+
|
|
234
282
|
def vibe_macros
|
|
235
283
|
@vibe_state.macros
|
|
236
284
|
end
|
|
@@ -372,7 +420,7 @@ module Kward
|
|
|
372
420
|
end
|
|
373
421
|
|
|
374
422
|
restore_editor_snapshot(snapshot)
|
|
375
|
-
changed!
|
|
423
|
+
changed!
|
|
376
424
|
@status = "Undo"
|
|
377
425
|
true
|
|
378
426
|
end
|
|
@@ -385,7 +433,7 @@ module Kward
|
|
|
385
433
|
end
|
|
386
434
|
|
|
387
435
|
restore_editor_snapshot(snapshot)
|
|
388
|
-
changed!
|
|
436
|
+
changed!
|
|
389
437
|
@status = "Redo"
|
|
390
438
|
true
|
|
391
439
|
end
|
|
@@ -909,27 +957,28 @@ module Kward
|
|
|
909
957
|
end
|
|
910
958
|
|
|
911
959
|
def begin_search(direction = :forward)
|
|
912
|
-
@status = @search.begin(direction)
|
|
960
|
+
@status = @search.begin(direction, cursor: @cursor)
|
|
913
961
|
sync_search_state
|
|
914
962
|
true
|
|
915
963
|
end
|
|
916
964
|
|
|
917
|
-
def cancel_search
|
|
918
|
-
@
|
|
919
|
-
|
|
965
|
+
def cancel_search(restore_cursor: true)
|
|
966
|
+
apply_search_result(@search.cancel(restore_cursor: restore_cursor))
|
|
967
|
+
@search_match_ranges = []
|
|
920
968
|
true
|
|
921
969
|
end
|
|
922
970
|
|
|
923
|
-
def
|
|
924
|
-
@
|
|
925
|
-
sync_search_state
|
|
971
|
+
def clear_search_highlights
|
|
972
|
+
@search_match_ranges = []
|
|
926
973
|
true
|
|
927
974
|
end
|
|
928
975
|
|
|
976
|
+
def append_search(text)
|
|
977
|
+
apply_search_result(@search.append(text, buffer: @buffer, cursor: @cursor))
|
|
978
|
+
end
|
|
979
|
+
|
|
929
980
|
def delete_search_character
|
|
930
|
-
@
|
|
931
|
-
sync_search_state
|
|
932
|
-
true
|
|
981
|
+
apply_search_result(@search.delete_character(buffer: @buffer, cursor: @cursor))
|
|
933
982
|
end
|
|
934
983
|
|
|
935
984
|
def confirm_search
|
|
@@ -1224,6 +1273,7 @@ module Kward
|
|
|
1224
1273
|
@search_active = @search.active?
|
|
1225
1274
|
@search_query = @search.query
|
|
1226
1275
|
@search_direction = @search.direction
|
|
1276
|
+
@search_match_ranges = EditorSearch.match_ranges(@buffer, @search_query)
|
|
1227
1277
|
end
|
|
1228
1278
|
|
|
1229
1279
|
def sync_vibe_state
|
|
@@ -1236,6 +1286,11 @@ module Kward
|
|
|
1236
1286
|
@vibe_visual_block_insert = @vibe_state.visual_block_insert
|
|
1237
1287
|
@vibe_marks = @vibe_state.marks
|
|
1238
1288
|
@vibe_registers = @vibe_state.registers
|
|
1289
|
+
@vibe_register_types = @vibe_state.register_types
|
|
1290
|
+
@vibe_kill_linewise = @vibe_state.kill_linewise
|
|
1291
|
+
@vibe_previous_change_cursor = @vibe_state.previous_change_cursor
|
|
1292
|
+
@vibe_jump_back_list = @vibe_state.jump_back_list
|
|
1293
|
+
@vibe_jump_forward_list = @vibe_state.jump_forward_list
|
|
1239
1294
|
@vibe_macros = @vibe_state.macros
|
|
1240
1295
|
@vibe_recording_macro = @vibe_state.recording_macro
|
|
1241
1296
|
@vibe_last_macro = @vibe_state.last_macro
|
|
@@ -6,7 +6,8 @@ module Kward
|
|
|
6
6
|
class VibeEditorState
|
|
7
7
|
attr_accessor :mode, :pending, :command, :last_change, :last_find
|
|
8
8
|
attr_accessor :last_visual_selection, :visual_block_insert
|
|
9
|
-
attr_accessor :marks, :registers, :macros, :recording_macro, :last_macro
|
|
9
|
+
attr_accessor :marks, :registers, :register_types, :macros, :recording_macro, :last_macro
|
|
10
|
+
attr_accessor :kill_linewise, :previous_change_cursor, :jump_back_list, :jump_forward_list
|
|
10
11
|
|
|
11
12
|
def initialize(editor_mode: "modern")
|
|
12
13
|
@mode = editor_mode == "vibe" ? "normal" : nil
|
|
@@ -18,6 +19,11 @@ module Kward
|
|
|
18
19
|
@visual_block_insert = nil
|
|
19
20
|
@marks = {}
|
|
20
21
|
@registers = {}
|
|
22
|
+
@register_types = {}
|
|
23
|
+
@kill_linewise = false
|
|
24
|
+
@previous_change_cursor = nil
|
|
25
|
+
@jump_back_list = []
|
|
26
|
+
@jump_forward_list = []
|
|
21
27
|
@macros = {}
|
|
22
28
|
@recording_macro = nil
|
|
23
29
|
@last_macro = nil
|
|
@@ -34,6 +40,11 @@ module Kward
|
|
|
34
40
|
state.visual_block_insert = other.visual_block_insert&.dup
|
|
35
41
|
state.marks = other.marks.transform_values(&:dup)
|
|
36
42
|
state.registers = other.registers.transform_values(&:dup)
|
|
43
|
+
state.register_types = other.register_types.transform_values(&:dup)
|
|
44
|
+
state.kill_linewise = other.kill_linewise
|
|
45
|
+
state.previous_change_cursor = other.previous_change_cursor
|
|
46
|
+
state.jump_back_list = other.jump_back_list.map(&:dup)
|
|
47
|
+
state.jump_forward_list = other.jump_forward_list.map(&:dup)
|
|
37
48
|
state.macros = other.macros.transform_values(&:dup)
|
|
38
49
|
state.recording_macro = other.recording_macro
|
|
39
50
|
state.last_macro = other.last_macro
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -434,6 +434,7 @@ module Kward
|
|
|
434
434
|
memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
|
|
435
435
|
workers: workers_capability,
|
|
436
436
|
commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
437
|
+
mcp: { supported: true, transport: "stdio", config: "mcpServers", exposes: ["tools"], unsupported: ["resources", "prompts", "sampling", "streamableHttp"] },
|
|
437
438
|
startupResources: { supported: true, method: STARTUP_RESOURCE_METHODS.first },
|
|
438
439
|
starterPack: { supported: false, reason: "cliOnlyInstallCommand" },
|
|
439
440
|
shell: { supported: false, reason: "interactiveTuiOnly" },
|
|
@@ -0,0 +1,118 @@
|
|
|
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 initialize(server_name:, client:, tool:)
|
|
12
|
+
@server_name = server_name.to_s
|
|
13
|
+
@client = client
|
|
14
|
+
@tool = tool
|
|
15
|
+
@remote_name = value(tool, "name").to_s
|
|
16
|
+
super(kward_name(@server_name, @remote_name), description, properties: {}, required: [])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def schema
|
|
20
|
+
{
|
|
21
|
+
type: "function",
|
|
22
|
+
function: {
|
|
23
|
+
name: name,
|
|
24
|
+
description: description,
|
|
25
|
+
parameters: input_schema
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(args, _conversation, cancellation: nil)
|
|
31
|
+
cancellation&.raise_if_cancelled!
|
|
32
|
+
result = @client.call_tool(@remote_name, args || {})
|
|
33
|
+
format_result(result)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
"MCP tool #{server_name}.#{remote_name} failed: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.kward_name(server_name, tool_name)
|
|
39
|
+
"#{sanitize_name(server_name)}__#{sanitize_name(tool_name)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.sanitize_name(value)
|
|
43
|
+
text = value.to_s.gsub(/[^A-Za-z0-9_-]/, "_")
|
|
44
|
+
text = "mcp" if text.empty?
|
|
45
|
+
text[0, 60]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def kward_name(server_name, tool_name)
|
|
51
|
+
self.class.kward_name(server_name, tool_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def description
|
|
55
|
+
value(@tool, "description").to_s.empty? ? "MCP tool #{@server_name}.#{@remote_name}" : value(@tool, "description").to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def input_schema
|
|
59
|
+
schema = value(@tool, "inputSchema")
|
|
60
|
+
return empty_object_schema unless schema.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
schema.empty? ? empty_object_schema : schema
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def empty_object_schema
|
|
66
|
+
{ type: "object", properties: {}, additionalProperties: false }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_result(result)
|
|
70
|
+
return "MCP tool #{server_name}.#{remote_name} returned no content." unless result.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
parts = []
|
|
73
|
+
parts << "MCP tool #{server_name}.#{remote_name} reported an error:" if truthy?(value(result, "isError"))
|
|
74
|
+
parts.concat(format_content(value(result, "content")))
|
|
75
|
+
|
|
76
|
+
structured = value(result, "structuredContent")
|
|
77
|
+
parts << JSON.pretty_generate(structured) unless structured.nil?
|
|
78
|
+
|
|
79
|
+
parts.empty? ? "MCP tool #{server_name}.#{remote_name} returned no content." : parts.join("\n\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_content(content)
|
|
83
|
+
Array(content).filter_map do |entry|
|
|
84
|
+
next entry.to_s unless entry.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
case value(entry, "type")
|
|
87
|
+
when "text"
|
|
88
|
+
value(entry, "text").to_s
|
|
89
|
+
when "image"
|
|
90
|
+
"[MCP image content: #{value(entry, "mimeType") || "unknown MIME type"}]"
|
|
91
|
+
when "audio"
|
|
92
|
+
"[MCP audio content: #{value(entry, "mimeType") || "unknown MIME type"}]"
|
|
93
|
+
when "resource_link"
|
|
94
|
+
"[MCP resource link: #{value(entry, "uri") || value(entry, "name") || "unnamed"}]"
|
|
95
|
+
when "resource"
|
|
96
|
+
"[MCP embedded resource: #{resource_label(value(entry, "resource"))}]"
|
|
97
|
+
else
|
|
98
|
+
JSON.generate(entry)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resource_label(resource)
|
|
104
|
+
return "unnamed" unless resource.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
value(resource, "uri") || value(resource, "name") || "unnamed"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def truthy?(value)
|
|
110
|
+
value == true || value.to_s == "true"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def value(object, key)
|
|
114
|
+
object[key] || object[key.to_sym]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "edit_file"
|
|
|
8
8
|
require_relative "fetch_content"
|
|
9
9
|
require_relative "fetch_raw"
|
|
10
10
|
require_relative "list_directory"
|
|
11
|
+
require_relative "mcp_tool"
|
|
11
12
|
require_relative "read_file"
|
|
12
13
|
require_relative "read_skill"
|
|
13
14
|
require_relative "run_shell_command"
|
|
@@ -19,6 +20,7 @@ require_relative "search/code"
|
|
|
19
20
|
require_relative "search/web"
|
|
20
21
|
require_relative "search/web_fetch"
|
|
21
22
|
require_relative "tool_call"
|
|
23
|
+
require_relative "../mcp/server_config"
|
|
22
24
|
require_relative "../telemetry/logger"
|
|
23
25
|
require_relative "../tool_output_compactor"
|
|
24
26
|
require_relative "../workspace"
|
|
@@ -60,7 +62,7 @@ module Kward
|
|
|
60
62
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
61
63
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
62
64
|
# @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)
|
|
65
|
+
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)
|
|
64
66
|
@workspace = workspace
|
|
65
67
|
@prompt = prompt
|
|
66
68
|
@web_search = web_search
|
|
@@ -75,6 +77,13 @@ module Kward
|
|
|
75
77
|
@tool_output_compactor = tool_output_compactor
|
|
76
78
|
@telemetry_logger = telemetry_logger
|
|
77
79
|
@context_budget_meter = context_budget_meter
|
|
80
|
+
@mcp_clients = if mcp_clients
|
|
81
|
+
mcp_clients
|
|
82
|
+
elsif @allowed_tool_names
|
|
83
|
+
[]
|
|
84
|
+
else
|
|
85
|
+
MCP::ServerConfig.clients_from_config(ConfigFiles.read_config)
|
|
86
|
+
end
|
|
78
87
|
@tools = build_tools.freeze
|
|
79
88
|
@schemas = build_schema_tools.map(&:schema).freeze
|
|
80
89
|
end
|
|
@@ -182,6 +191,7 @@ module Kward
|
|
|
182
191
|
"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
192
|
)
|
|
184
193
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
194
|
+
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
185
195
|
tools << @tools["read_skill"] if skills_available?
|
|
186
196
|
tools << @tools["ask_user_question"] if ask_user_question_available?
|
|
187
197
|
tools.compact
|
|
@@ -194,7 +204,7 @@ module Kward
|
|
|
194
204
|
Tools::FetchRaw.new(web_fetch: @web_fetch),
|
|
195
205
|
Tools::ReadSkill.new,
|
|
196
206
|
Tools::AskUserQuestion.new(prompt: @prompt)
|
|
197
|
-
]
|
|
207
|
+
] + mcp_tool_values
|
|
198
208
|
end
|
|
199
209
|
|
|
200
210
|
def core_tools
|
|
@@ -224,6 +234,26 @@ module Kward
|
|
|
224
234
|
skills.any?
|
|
225
235
|
end
|
|
226
236
|
|
|
237
|
+
def mcp_tool_values
|
|
238
|
+
@mcp_tool_values ||= build_mcp_tools
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def build_mcp_tools
|
|
242
|
+
Array(@mcp_clients).flat_map do |client|
|
|
243
|
+
client.list_tools.map do |tool|
|
|
244
|
+
Tools::MCPTool.new(server_name: client.name, client: client, tool: tool)
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
@telemetry_logger.log(
|
|
248
|
+
"mcp",
|
|
249
|
+
"server_unavailable",
|
|
250
|
+
"server" => client.respond_to?(:name) ? client.name : "unknown",
|
|
251
|
+
"error" => e.message
|
|
252
|
+
)
|
|
253
|
+
[]
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
227
257
|
def ask_user_question_available?
|
|
228
258
|
return false if @ask_user_question_enabled == false
|
|
229
259
|
|
data/lib/kward/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kward
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.74.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kai Wood
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- doc/files.md
|
|
138
138
|
- doc/getting-started.md
|
|
139
139
|
- doc/git.md
|
|
140
|
+
- doc/mcp.md
|
|
140
141
|
- doc/memory.md
|
|
141
142
|
- doc/personas.md
|
|
142
143
|
- doc/plugins.md
|
|
@@ -197,6 +198,9 @@ files:
|
|
|
197
198
|
- lib/kward/local_command_runner.rb
|
|
198
199
|
- lib/kward/local_pty_command_runner.rb
|
|
199
200
|
- lib/kward/markdown_transcript.rb
|
|
201
|
+
- lib/kward/mcp/client.rb
|
|
202
|
+
- lib/kward/mcp/server_config.rb
|
|
203
|
+
- lib/kward/mcp/stdio_transport.rb
|
|
200
204
|
- lib/kward/memory/manager.rb
|
|
201
205
|
- lib/kward/message_access.rb
|
|
202
206
|
- lib/kward/message_text.rb
|
|
@@ -305,6 +309,7 @@ files:
|
|
|
305
309
|
- lib/kward/tools/fetch_content.rb
|
|
306
310
|
- lib/kward/tools/fetch_raw.rb
|
|
307
311
|
- lib/kward/tools/list_directory.rb
|
|
312
|
+
- lib/kward/tools/mcp_tool.rb
|
|
308
313
|
- lib/kward/tools/read_file.rb
|
|
309
314
|
- lib/kward/tools/read_skill.rb
|
|
310
315
|
- lib/kward/tools/registry.rb
|