kward 0.73.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3b7a272f3d5171660fc2669a486830e334910cfef50af203b1eeedb989b6289
4
- data.tar.gz: 3fb9b4ede65361609b6a1c594431d20136bb83c57e205e4b1b32332db75b2fbb
3
+ metadata.gz: b2f648e16f1d5c7187e428b30ecc2d5945f067c5decf98d1db46c51e1974a7d6
4
+ data.tar.gz: 5acbd39bd18701659c3699a80f6a660ae5bc85c8366083ee7aa3ec184dc93706
5
5
  SHA512:
6
- metadata.gz: 9dec61bbf302b69eb5501a130c29d8104b342266cac1489f53608051db1cb03fb44a5b3d47388f43e2f271673f1ba2b4744637dc108701d13a37abd322913657
7
- data.tar.gz: 5d057f370c989e973b2cd2bbef8464932251f7b2ffcddcfff6c86d3ecb28db4c5a1241e3eda1f5fa3b7f460cddbd7938dde6396e3335d6bbe9fb1b131d81f9a4
6
+ metadata.gz: 92de6ca9d486d9bc95d6c36d5bffa2bc591ad18b9dbf499aba14dc349c6f6f8492869b78788e7370a38b0e90067326a0678c0e3efb1a552b12604ab8db55d15d
7
+ data.tar.gz: 97f901a2f5992b34d6e3a75d92137518e6b16471ae66b73455cbbccfba740a480045271cac670d82d2920ceb61a48c4ecd2b83ae3de7d97b8bdecb8b284e658c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to Kward will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.74.0] - 2026-06-30
8
+
9
+ ### Added
10
+
11
+ - Added more Vim-compatible Vibe editor bindings, including `Y`, `>>`/`<<`, `~`, `g_`, `|`, `W`/`E`/`B`, `ge`/`gE`, case operators, `gJ`, counted `%`, previous-change mark jumps, jump-list navigation, and linewise paste semantics.
12
+ - Added local stdio MCP server support through `mcpServers`, exposing configured MCP server tools to Kward turns.
13
+ - Added smartcase, live cursor movement, cancel restore, and visible match highlighting to editor search.
14
+ - Added Vibe editor `:e <filename>` and `:e! <filename>` commands with path tab completion.
15
+ - Added `--skip-config` as an emergency fallback that ignores the main config file for one run.
16
+
17
+ ### Fixed
18
+
19
+ - Fixed editor undo/redo so selections do not become sticky after restoring buffer contents.
20
+ - Fixed busy composer slash commands so they are blocked instead of being queued or sent as in-flight steering.
21
+
22
+ ## [0.73.1] - 2026-06-30
23
+
24
+ ### Fixed
25
+
26
+ - Fixed Vibe editor `dG` so it deletes from the current line through the end of the file.
27
+ - Fixed session picker delete confirmation in terminals that send printable keys as CSI-u escape sequences.
28
+
7
29
  ## [0.73.0] - 2026-06-29
8
30
 
9
31
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kward (0.73.0)
4
+ kward (0.74.0)
5
5
  base64
6
6
  nokogiri
7
7
  tiktoken_ruby
@@ -146,7 +146,7 @@ CHECKSUMS
146
146
  html-proofer (5.2.1) sha256=fdd958a7cbf9c3255fb96fe7cfc4e611f64e2706e469488a3326309ad007d2fd
147
147
  io-event (1.16.2) sha256=9f9cb0a96ea5c3850a672606c65f27bc96d7621399ef6196acbfe2be0cd1279c
148
148
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
149
- kward (0.73.0)
149
+ kward (0.74.0)
150
150
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
151
151
  metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072
152
152
  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
data/doc/configuration.md CHANGED
@@ -13,6 +13,19 @@ Small examples:
13
13
  }
14
14
  ```
15
15
 
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "safari": {
20
+ "command": "/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver",
21
+ "args": ["--mcp"]
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ See [MCP servers](mcp.md) for connecting local Model Context Protocol servers such as Safari's browser automation server.
28
+
16
29
  ```json
17
30
  {
18
31
  "memory": {
@@ -263,7 +276,7 @@ Modern mode uses composer-style keys: `Ctrl+S` saves, `Ctrl+Q` quits, `Ctrl+F` s
263
276
 
264
277
  Emacs mode uses Emacs-style non-modal keys: `Ctrl+X Ctrl+S` saves, `Ctrl+X Ctrl+C` quits, `Ctrl+S` searches forward, `Ctrl+R` searches backward, `Ctrl+Space` sets the mark, `Ctrl+W` kills the region or previous word, `Alt+W` copies the region, `Ctrl+K` kills to end of line, `Ctrl+Y` yanks, and `Alt+Y` cycles the per-buffer kill ring after a yank.
265
278
 
266
- Vibe mode opens files in normal mode and supports a compact classic-vibe subset: normal/insert/command modes, character and line visual modes with `v`/`V`, `h/j/k/l`, word and line movement, counts, simple `d`/`y` operator motions, `dd`, `yy`, `p`, `/` search, `u` undo, and `:w`, `:q`, `:q!`, `:wq`, `:x`, and `:number` commands. Yanks also copy to the terminal clipboard when OSC 52 is supported.
279
+ Vibe mode opens files in normal mode and supports a compact Vim-style subset: normal/insert/command modes, character, line, and block visual modes, `h/j/k/l`, word and WORD movement, counts, `d`/`y`/`c` operators, line yanks and indentation, case operators, marks, macros, jump-list navigation, linewise paste, search, undo/redo, and common `:w`, `:q`, `:q!`, `:wq`, `:x`, `:e`, substitution, and `:number` commands. Yanks also copy to the terminal clipboard when OSC 52 is supported.
267
280
 
268
281
  ## Session settings
269
282
 
data/doc/mcp.md ADDED
@@ -0,0 +1,72 @@
1
+ # MCP servers
2
+
3
+ Kward can connect to local [Model Context Protocol](https://modelcontextprotocol.io/) servers and expose their tools to the model alongside Kward's built-in workspace tools.
4
+
5
+ This is useful when another app ships an MCP server for something Kward should be able to inspect or control. For example, Safari Technology Preview includes a Safari MCP server that lets agents inspect pages, console output, network activity, screenshots, and other browser state.
6
+
7
+ ## Configure a local MCP server
8
+
9
+ Add an `mcpServers` object to your Kward config file:
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "safari": {
15
+ "command": "/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver",
16
+ "args": ["--mcp"],
17
+ "enabled": true
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ Kward starts enabled MCP servers with stdio transport, discovers their tools, and advertises those tools to the model. Tool names are prefixed with the server name so they do not collide with built-in tools or tools from another MCP server.
24
+
25
+ For the Safari MCP server, install Safari Technology Preview and enable:
26
+
27
+ 1. Safari Settings > Advanced > Show features for web developers
28
+ 2. Safari Settings > Developer > Enable remote automation and external agents
29
+
30
+ Then ask Kward something like:
31
+
32
+ ```text
33
+ Open http://localhost:3000 in Safari and check the console for errors.
34
+ ```
35
+
36
+ or:
37
+
38
+ ```text
39
+ Inspect my local app in Safari and tell me why the layout is broken.
40
+ ```
41
+
42
+ ## Configuration fields
43
+
44
+ Each server supports:
45
+
46
+ - `command` — executable path to launch. Required.
47
+ - `args` — command arguments. Optional.
48
+ - `env` — environment variables to add or override for the server process. Optional.
49
+ - `timeout_seconds` — request timeout for MCP initialization, tool listing, and tool calls. Optional; defaults to 10 seconds.
50
+ - `enabled` — set to `false` to keep a server configured but disabled.
51
+
52
+ Example with an environment variable and custom timeout:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "example": {
58
+ "command": "/usr/local/bin/example-mcp",
59
+ "args": ["--stdio"],
60
+ "env": { "EXAMPLE_MODE": "local" },
61
+ "timeout_seconds": 20
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## Notes and limitations
68
+
69
+ - Kward currently supports local stdio MCP servers.
70
+ - Kward currently exposes MCP tools. MCP resources, prompts, sampling, and Streamable HTTP are not implemented yet.
71
+ - MCP tool results are returned to the model as text. Structured content is included as JSON. Image, audio, and resource results are summarized as placeholders for now.
72
+ - MCP servers can expose sensitive local application state. Only configure servers you trust, especially browser automation servers that can read page content or screenshots.
data/doc/rpc.md CHANGED
@@ -60,6 +60,7 @@ Detailed capability fields include:
60
60
  - `auth`: Tauren auth provider format, OpenAI and Anthropic OAuth, OpenRouter API-key login, GitHub/Copilot status reporting, and provider logout for stored credentials. GitHub OAuth login is CLI-only; RPC reports `supported: false` for the GitHub provider with a reason string.
61
61
  - `memory`: opt-in structured memory support, interactive prompt injection only, JSON/JSONL local storage, and dedicated `memory/*` methods.
62
62
  - `commands`: supported `commands/list` capability for prompt, skill, and plugin command sources, plus plugin execution through `commands/run` or plugin slash turns.
63
+ - `mcp`: local stdio MCP server support through the shared `mcpServers` config. RPC exposes MCP tools to turns; MCP resources, prompts, sampling, and Streamable HTTP are explicitly unsupported for now.
63
64
  - `startupResources`: supported startup resource listing for context, skills, prompts, and plugins.
64
65
  - `extensionUi`: question bridge support via `ui/question` and `ui/answerQuestion`, plus plugin footer updates via `ui/footer`; other UI primitives are explicitly unsupported.
65
66
  - `composer`: composer-only UI features. Interactive session diff totals are explicitly unsupported over RPC (`composer.sessionDiff.supported: false`) because RPC clients already receive per-tool diff results and no live composer status payload is exposed. Clipboard copy is also unsupported over RPC (`composer.copy.supported: false`) because UI clients own clipboard access.
data/doc/usage.md CHANGED
@@ -104,6 +104,13 @@ kward --working-directory ~/code/project
104
104
  kward --working-directory ~/code/project "Summarize this repository"
105
105
  ```
106
106
 
107
+ If your main config file is broken and prevents startup, use `--skip-config` to ignore it for one run:
108
+
109
+ ```bash
110
+ kward --skip-config doctor
111
+ kward --skip-config edit ~/.kward/config.json
112
+ ```
113
+
107
114
  ## Interactive slash commands
108
115
 
109
116
  Slash commands run local actions in the current session. Most do not send a prompt to the model; exceptions like `/git` and `/workers` orchestrate local flows that may then trigger model work.
@@ -77,6 +77,7 @@ module Kward
77
77
  #{option.call("--working-directory=PATH")} Run Kward from PATH
78
78
  #{option.call("--mode=MODE")} Execution mode: auto, chat, oneshot, filter
79
79
  #{option.call("--filter")} Shortcut for --mode filter
80
+ #{option.call("--skip-config")} Ignore the main config file for this run
80
81
  #{option.call("--help")}, #{option.call("-h")} Show this help
81
82
  #{option.call("--version")}, #{option.call("-v")} Show the installed version
82
83
 
@@ -214,6 +215,8 @@ module Kward
214
215
  break
215
216
  when "--experimental-workers"
216
217
  @experimental_workers = true
218
+ when "--skip-config"
219
+ @skip_config = true
217
220
  when "--filter"
218
221
  @requested_mode = "filter"
219
222
  when "--mode"
@@ -16,24 +16,27 @@ module Kward
16
16
  end
17
17
 
18
18
  def doctor_checks
19
- config = safely_read_config
19
+ config_result = safely_read_config
20
+ config = config_result.is_a?(Hash) ? config_result : {}
20
21
  [
21
22
  doctor_config_check,
22
- doctor_config_json_check(config),
23
+ doctor_config_json_check(config_result),
23
24
  doctor_directory_check("Config directory", ConfigFiles.config_dir),
24
25
  doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
25
26
  doctor_workspace_check,
26
27
  doctor_model_check,
27
28
  doctor_auth_check(config),
28
- doctor_pan_check(config),
29
+ doctor_pan_check(config_result),
29
30
  { status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
30
31
  ]
31
32
  end
32
33
 
33
34
  def safely_read_config
34
35
  ConfigFiles.read_config
35
- rescue StandardError
36
- nil
36
+ rescue ConfigFiles::ConfigError => e
37
+ e
38
+ rescue StandardError => e
39
+ e
37
40
  end
38
41
 
39
42
  def doctor_config_check
@@ -46,10 +49,13 @@ module Kward
46
49
  { status: :warning, label: "Config", message: "not found: #{path}" }
47
50
  end
48
51
 
49
- def doctor_config_json_check(config)
50
- return { status: :error, label: "Config JSON", message: "invalid or unreadable" } unless config.is_a?(Hash)
52
+ def doctor_config_json_check(config_result)
53
+ return { status: :ok, label: "Config JSON", message: "valid" } if config_result.is_a?(Hash)
54
+ if config_result.is_a?(ConfigFiles::ConfigError)
55
+ return { status: :error, label: "Config JSON", message: "invalid: #{config_result.detail}" }
56
+ end
51
57
 
52
- { status: :ok, label: "Config JSON", message: "valid" }
58
+ { status: :error, label: "Config JSON", message: "unreadable: #{config_result.message}" }
53
59
  end
54
60
 
55
61
  def doctor_directory_check(label, path, create: false)
@@ -96,8 +102,10 @@ module Kward
96
102
  { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
97
103
  end
98
104
 
99
- def doctor_pan_check(config)
100
- pan = config.to_h["pan_mode"] || {}
105
+ def doctor_pan_check(config_result)
106
+ return { status: :warning, label: "Pan mode", message: "skipped because config is invalid" } if config_result.is_a?(ConfigFiles::ConfigError)
107
+
108
+ pan = config_result.to_h["pan_mode"] || {}
101
109
  if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
102
110
  { status: :ok, label: "Pan mode", message: "credentials configured" }
103
111
  else
@@ -184,11 +184,13 @@ module Kward
184
184
  poll_result = @prompt.poll_input
185
185
  case poll_result
186
186
  when String
187
- return poll_result if handle_busy_worker_input(poll_result, agent, queued_inputs)
188
-
189
- if slash_command_input?(poll_result)
187
+ if busy_queued_command?(poll_result)
190
188
  queued_inputs << poll_result
191
189
  @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
190
+ elsif slash_command_input?(poll_result)
191
+ # Slash commands are local control actions. Running or queuing them
192
+ # from the busy composer is surprising because the state they act on
193
+ # may have changed by the time the active turn finishes.
192
194
  elsif steering && !poll_result.strip.empty?
193
195
  begin
194
196
  steering.submit(poll_result)
@@ -226,6 +228,10 @@ module Kward
226
228
  input.to_s.strip.start_with?("/")
227
229
  end
228
230
 
231
+ def busy_queued_command?(input)
232
+ ["/exit", "/quit", "/new"].include?(input.to_s.strip)
233
+ end
234
+
229
235
  def handle_busy_worker_input(input, agent, queued_inputs)
230
236
  return false unless agent
231
237
 
@@ -488,18 +488,15 @@ module Kward
488
488
  end
489
489
 
490
490
  def handle_tab_busy_input(tab, input)
491
- command = input.to_s.strip
492
- if command == "/workers" || command.start_with?("/workers ") || command == "/tab" || command.start_with?("/tab ")
493
- _handled, replacement_agent = handle_local_slash_command(command, tab.agent, @session_store)
494
- tab.agent = replacement_agent if replacement_agent?(replacement_agent)
495
- restore_busy_input_prompt
496
- return
497
- end
498
-
499
- if slash_command_input?(input)
491
+ if busy_queued_command?(input)
500
492
  tab.queued_inputs << input
501
493
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
502
494
  return
495
+ elsif slash_command_input?(input)
496
+ # Slash commands are local control actions. Running or queuing them
497
+ # from the busy composer is surprising because the state they act on
498
+ # may have changed by the time the active turn finishes.
499
+ return
503
500
  end
504
501
 
505
502
  if tab.steering && !input.to_s.strip.empty?
data/lib/kward/cli.rb CHANGED
@@ -95,7 +95,7 @@ module Kward
95
95
  include CLI::InteractiveTurn
96
96
  include CLI::ToolSummaries
97
97
 
98
- def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: Client.new, session_store: nil, context_usage: ContextUsage.new)
98
+ def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: nil, session_store: nil, context_usage: ContextUsage.new)
99
99
  @argv = argv
100
100
  @stdin = stdin
101
101
  @prompt = prompt
@@ -109,6 +109,7 @@ module Kward
109
109
  @working_directory = nil
110
110
  @prompt_delimited = false
111
111
  @requested_mode = "auto"
112
+ @skip_config = false
112
113
  @experimental_workers = false
113
114
  @foreground_turn_active = false
114
115
  @pending_reasoning_config = nil
@@ -122,11 +123,41 @@ module Kward
122
123
  # @return [void]
123
124
  def run
124
125
  @argv = extract_global_options(@argv)
126
+ ConfigFiles.skip_config = @skip_config
125
127
  with_working_directory { dispatch }
128
+ rescue ConfigFiles::ConfigError => e
129
+ warn config_error_message(e)
130
+ exit 1
126
131
  rescue ArgumentError => e
127
132
  warn e.message
128
133
  warn "Run `kward help` for available commands."
129
134
  exit 1
135
+ ensure
136
+ ConfigFiles.skip_config = false
137
+ end
138
+
139
+ def ensure_client!
140
+ @client ||= Client.new
141
+ end
142
+
143
+ def config_error_message(error)
144
+ <<~MESSAGE.rstrip
145
+ Invalid Kward config #{error.format}.
146
+
147
+ File:
148
+ #{error.path}
149
+
150
+ Parser error:
151
+ #{error.detail}
152
+
153
+ Kward cannot safely continue with this config.
154
+
155
+ Repair it with:
156
+ kward edit #{error.path}
157
+
158
+ Emergency fallback:
159
+ kward --skip-config doctor
160
+ MESSAGE
130
161
  end
131
162
 
132
163
  def dispatch
@@ -173,6 +204,7 @@ module Kward
173
204
  end
174
205
  raise ArgumentError, command_usage("doctor") unless @argv.length == 1
175
206
 
207
+ ensure_client!
176
208
  print_doctor
177
209
  return
178
210
  end
@@ -194,6 +226,7 @@ module Kward
194
226
  return
195
227
  end
196
228
 
229
+ ensure_client!
197
230
  print_sysprompt(@argv[1..] || [])
198
231
  return
199
232
  end
@@ -205,6 +238,7 @@ module Kward
205
238
  end
206
239
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
207
240
 
241
+ ensure_client!
208
242
  Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
209
243
  return
210
244
  end
@@ -226,6 +260,7 @@ module Kward
226
260
  return
227
261
  end
228
262
 
263
+ ensure_client!
229
264
  handle_openrouter_command(@argv[1..] || [])
230
265
  return
231
266
  end
@@ -237,6 +272,7 @@ module Kward
237
272
  end
238
273
  raise ArgumentError, command_usage("pan") unless @argv.length == 1
239
274
 
275
+ ensure_client!
240
276
  PanServer.new(client: @client, working_directory: current_workspace_root).run
241
277
  return
242
278
  end
@@ -267,6 +303,7 @@ module Kward
267
303
  end
268
304
 
269
305
  def run_prompt_or_interactive
306
+ ensure_client!
270
307
  stdin_input = read_stdin_input
271
308
  first_prompt = one_shot_prompt_argument
272
309
 
@@ -22,6 +22,17 @@ module Kward
22
22
  # config, prompt, skill, plugin, cache, memory, and session locations instead of
23
23
  # reconstructing `~/.kward` paths independently.
24
24
  module ConfigFiles
25
+ class ConfigError < StandardError
26
+ attr_reader :path, :format, :detail
27
+
28
+ def initialize(path:, format:, detail:)
29
+ @path = path
30
+ @format = format
31
+ @detail = detail
32
+ super("Invalid Kward config #{format}: #{path}: #{detail}")
33
+ end
34
+ end
35
+
25
36
  MAX_SKILL_FILE_BYTES = 100_000
26
37
  MAX_PROMPT_FILE_BYTES = 32 * 1024
27
38
  DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
@@ -45,8 +56,18 @@ module Kward
45
56
  end
46
57
  end
47
58
 
59
+ @skip_config = false
60
+
48
61
  module_function
49
62
 
63
+ def skip_config=(value)
64
+ @skip_config = value
65
+ end
66
+
67
+ def skip_config?
68
+ @skip_config == true
69
+ end
70
+
50
71
  # Directory that contains Kward's user config and adjacent prompt/skill
51
72
  # data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
52
73
  #
@@ -94,6 +115,7 @@ module Kward
94
115
  "auto_resume" => false
95
116
  },
96
117
  "enforce_workspace_agents_file" => false,
118
+ "mcpServers" => {},
97
119
  "tools" => {
98
120
  "workspace_guardrails" => true
99
121
  }
@@ -103,6 +125,7 @@ module Kward
103
125
  # Performs ensure default config for configuration file and path handling.
104
126
  def ensure_default_config!(path = config_path)
105
127
  path = File.expand_path(path)
128
+ return false if skip_config? && path == config_path
106
129
  return false if File.exist?(path)
107
130
 
108
131
  write_config(default_config, path)
@@ -156,11 +179,12 @@ module Kward
156
179
  # @return [Hash] parsed config object
157
180
  def read_config(path = config_path)
158
181
  path = File.expand_path(path)
182
+ return {} if skip_config? && path == config_path
159
183
  return {} unless File.exist?(path)
160
184
 
161
185
  JSON.parse(File.read(path))
162
- rescue JSON::ParserError
163
- raise "Invalid Kward config JSON: #{path}"
186
+ rescue JSON::ParserError => e
187
+ raise ConfigError.new(path: path, format: "JSON", detail: e.message)
164
188
  end
165
189
 
166
190
  # Writes config JSON using private file permissions.
@@ -168,6 +192,9 @@ module Kward
168
192
  # @param config [Hash] config object to persist
169
193
  # @param path [String] config file path
170
194
  def write_config(config, path = config_path)
195
+ path = File.expand_path(path)
196
+ raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
197
+
171
198
  PrivateFile.write_json(path, config)
172
199
  end
173
200
 
@@ -361,6 +388,12 @@ module Kward
361
388
  value.is_a?(Hash) ? value : {}
362
389
  end
363
390
 
391
+ # Returns configured MCP stdio servers, or an empty config when absent.
392
+ def mcp_servers(config = read_config)
393
+ value = config["mcpServers"] || config.dig("mcp", "servers")
394
+ value.is_a?(Hash) ? value : {}
395
+ end
396
+
364
397
  # Validates and persists terminal overlay settings.
365
398
  def update_overlay_settings(values)
366
399
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
@@ -0,0 +1,56 @@
1
+ require_relative "stdio_transport"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model Context Protocol client support.
6
+ module MCP
7
+ PROTOCOL_VERSION = "2025-11-25"
8
+
9
+ # Minimal MCP client for discovering and invoking server tools.
10
+ class Client
11
+ attr_reader :name
12
+
13
+ def initialize(name:, transport:)
14
+ @name = name.to_s
15
+ @transport = transport
16
+ @initialized = false
17
+ end
18
+
19
+ def initialize!
20
+ return if @initialized
21
+
22
+ @transport.request("initialize", initialize_params)
23
+ @transport.notify("notifications/initialized")
24
+ @initialized = true
25
+ end
26
+
27
+ def list_tools
28
+ initialize!
29
+ result = @transport.request("tools/list", {})
30
+ Array(result["tools"] || result[:tools])
31
+ end
32
+
33
+ def call_tool(name, arguments = {})
34
+ initialize!
35
+ @transport.request("tools/call", { name: name, arguments: arguments || {} })
36
+ end
37
+
38
+ def close
39
+ @transport.close
40
+ end
41
+
42
+ private
43
+
44
+ def initialize_params
45
+ {
46
+ protocolVersion: PROTOCOL_VERSION,
47
+ capabilities: {},
48
+ clientInfo: {
49
+ name: "kward",
50
+ version: Kward.const_defined?(:VERSION) ? Kward::VERSION : "0.0.0"
51
+ }
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,55 @@
1
+ require_relative "../config_files"
2
+ require_relative "client"
3
+ require_relative "stdio_transport"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Model Context Protocol client support.
8
+ module MCP
9
+ # Builds MCP clients from Kward configuration.
10
+ module ServerConfig
11
+ module_function
12
+
13
+ def clients_from_config(config)
14
+ servers = ConfigFiles.mcp_servers(config)
15
+
16
+ servers.filter_map do |name, settings|
17
+ client_from_settings(name, settings)
18
+ end
19
+ end
20
+
21
+ def client_from_settings(name, settings)
22
+ return nil unless settings.is_a?(Hash)
23
+ return nil if settings["enabled"] == false
24
+
25
+ command = settings["command"].to_s
26
+ return nil if command.empty?
27
+
28
+ Client.new(
29
+ name: name,
30
+ transport: StdioTransport.new(
31
+ command: command,
32
+ args: settings["args"] || [],
33
+ env: normalized_env(settings["env"]),
34
+ timeout_seconds: positive_number(settings["timeout_seconds"] || settings["timeoutSeconds"])
35
+ )
36
+ )
37
+ end
38
+
39
+ def normalized_env(value)
40
+ return {} unless value.is_a?(Hash)
41
+
42
+ value.each_with_object({}) do |(key, item), env|
43
+ env[key.to_s] = item.to_s
44
+ end
45
+ end
46
+
47
+ def positive_number(value)
48
+ number = value.to_f
49
+ number.positive? ? number : nil
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end