@warble/claude-agent-sdk 0.15.3 → 0.16.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.
package/README.md CHANGED
@@ -93,6 +93,29 @@ over turn. `--stream-json` streams one `WarbleChatEvent` NDJSON line per event
93
93
  `{"t":"answer",…}` line; every turn also emits a `{"t":"session","id":…}` line — on success **and**
94
94
  on a failed turn — so a caller can resume that conversation with `--resume <session-id>`.
95
95
 
96
+ `chat --host-mcp-config <absolute path>` lets a host give the agent its own tools through one stdio
97
+ MCP server ([`src/hostMcp.ts`](./src/hostMcp.ts)). The file is a closed JSON object:
98
+
99
+ ```json
100
+ {
101
+ "name": "host_tools",
102
+ "command": "/absolute/path/to/server",
103
+ "args": ["--token-file", "/absolute/path/to/token"],
104
+ "tools": ["query"],
105
+ "instruction": "Optional single line appended to the system prompt."
106
+ }
107
+ ```
108
+
109
+ It must be an absolute path to a regular file (opened with `O_NOFOLLOW`, so a symlink is refused),
110
+ owned by the current user, with no group or other permission bits (`chmod 600`). `name` matches
111
+ `[a-z0-9_]{1,32}`, `tools` is a non-empty list of `[a-z0-9_]{1,64}`, `instruction` is one line of at
112
+ most 300 characters, and unknown keys are rejected. The server is passed to `query()` as a stdio
113
+ `mcpServers` entry; the read-only guardrail then allows exactly `mcp__<name>__<tool>` for the listed
114
+ tools and still refuses every other `mcp__*` tool, and the `wren`-only Bash rule is unchanged. Keep
115
+ secrets out of this file — pass a token file path in `args` instead; the CLI never prints the file,
116
+ its `args`, or the environment. Hybrid-staged plans refuse the flag. Without it, `chat` behaves
117
+ exactly as before.
118
+
96
119
  `list-models` emits exactly one versioned JSON object for the currently authenticated Claude
97
120
  subscription. It exposes only model ID, display name, and description; unavailable authentication,
98
121
  runtime, timeout, or protocol states are returned as sanitized JSON. It uses an empty SDK input and
@@ -299,6 +299,98 @@ function emitAgentModule(prepared, opts = {}) {
299
299
  ].join("\n");
300
300
  }
301
301
 
302
+ // src/hostMcp.ts
303
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from "fs";
304
+ import { isAbsolute } from "path";
305
+ var NAME_RE = /^[a-z0-9_]{1,32}$/;
306
+ var TOOL_RE = /^[a-z0-9_]{1,64}$/;
307
+ var LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
308
+ var MAX_INSTRUCTION = 300;
309
+ var ALLOWED_KEYS = /* @__PURE__ */ new Set(["name", "command", "args", "tools", "instruction"]);
310
+ function reject(reason) {
311
+ throw new DispatchError(`--host-mcp-config: ${reason}`);
312
+ }
313
+ function readTrustedFile(path) {
314
+ if (!isAbsolute(path)) reject("the path must be absolute");
315
+ const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
316
+ if (uid === void 0) reject("file ownership cannot be verified on this platform");
317
+ let fd;
318
+ try {
319
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
320
+ } catch (e) {
321
+ const code = e.code;
322
+ if (code === "ELOOP") reject("the path must not be a symbolic link");
323
+ reject(`the file cannot be opened (${code ?? "unknown error"})`);
324
+ }
325
+ try {
326
+ const st = fstatSync(fd);
327
+ if (!st.isFile()) reject("the path must be a regular file");
328
+ if (st.uid !== uid) reject("the file must be owned by the current user");
329
+ if ((st.mode & 63) !== 0) {
330
+ reject("the file must not be accessible by group or others (chmod 600)");
331
+ }
332
+ return readFileSync(fd, "utf8");
333
+ } finally {
334
+ closeSync(fd);
335
+ }
336
+ }
337
+ function parseHostMcpConfig(value) {
338
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
339
+ reject("the file must contain a JSON object");
340
+ }
341
+ const obj = value;
342
+ for (const key of Object.keys(obj)) {
343
+ if (!ALLOWED_KEYS.has(key)) reject(`unknown key '${key.slice(0, 64)}'`);
344
+ }
345
+ const { name, command, args, tools, instruction } = obj;
346
+ if (typeof name !== "string" || !NAME_RE.test(name)) reject("'name' must match [a-z0-9_]{1,32}");
347
+ if (typeof command !== "string" || !isAbsolute(command)) {
348
+ reject("'command' must be an absolute path");
349
+ }
350
+ if (!Array.isArray(args) || !args.every((a) => typeof a === "string")) {
351
+ reject("'args' must be an array of strings");
352
+ }
353
+ if (!Array.isArray(tools) || tools.length === 0) reject("'tools' must be a non-empty array");
354
+ for (const t of tools) {
355
+ if (typeof t !== "string" || !TOOL_RE.test(t)) {
356
+ reject("each of 'tools' must match [a-z0-9_]{1,64}");
357
+ }
358
+ }
359
+ if (new Set(tools).size !== tools.length) reject("'tools' must not repeat a tool");
360
+ if (instruction !== void 0) {
361
+ if (typeof instruction !== "string" || instruction.trim().length === 0) {
362
+ reject("'instruction' must be a non-empty string");
363
+ }
364
+ if (instruction.length > MAX_INSTRUCTION) {
365
+ reject(`'instruction' must be at most ${MAX_INSTRUCTION} characters`);
366
+ }
367
+ if (LINE_BREAK_RE.test(instruction)) reject("'instruction' must be a single line");
368
+ }
369
+ return {
370
+ name,
371
+ command,
372
+ args: [...args],
373
+ tools: [...tools],
374
+ ...instruction !== void 0 ? { instruction } : {}
375
+ };
376
+ }
377
+ function loadHostMcpConfig(path) {
378
+ const raw = readTrustedFile(path);
379
+ let parsed;
380
+ try {
381
+ parsed = JSON.parse(raw);
382
+ } catch {
383
+ reject("the file is not valid JSON");
384
+ }
385
+ return parseHostMcpConfig(parsed);
386
+ }
387
+ function hostMcpToolNames(cfg) {
388
+ return cfg.tools.map((tool3) => `mcp__${cfg.name}__${tool3}`);
389
+ }
390
+ function hostMcpServerEntry(cfg) {
391
+ return { type: "stdio", command: cfg.command, args: [...cfg.args] };
392
+ }
393
+
302
394
  // src/closure.ts
303
395
  function freezeArray(values) {
304
396
  return Object.freeze(values);
@@ -1902,12 +1994,12 @@ import {
1902
1994
  existsSync,
1903
1995
  lstatSync,
1904
1996
  mkdirSync,
1905
- readFileSync,
1997
+ readFileSync as readFileSync2,
1906
1998
  realpathSync,
1907
1999
  statSync,
1908
2000
  writeFileSync
1909
2001
  } from "fs";
1910
- import { dirname, isAbsolute, join, parse, resolve, sep } from "path";
2002
+ import { dirname, isAbsolute as isAbsolute2, join, parse, resolve, sep } from "path";
1911
2003
  function assetDirForIr(irPath) {
1912
2004
  const { dir, name } = parse(resolve(irPath));
1913
2005
  return join(dir, `${name}.assets`);
@@ -1932,7 +2024,7 @@ function prepareAssetLanding(ir, irPath, cwd) {
1932
2024
  `asset '${asset.path}' of component '${node.id}' is not a regular file. Reading a pipe or device would block the dispatch indefinitely instead of failing, so anything that is not a plain file is refused.`
1933
2025
  );
1934
2026
  }
1935
- const data = readFileSync(source);
2027
+ const data = readFileSync2(source);
1936
2028
  const actual = `sha256:${createHash("sha256").update(data).digest("hex")}`;
1937
2029
  if (actual !== asset.hash) {
1938
2030
  throw new DispatchError(
@@ -1959,7 +2051,7 @@ function landAssets(ir, irPath, cwd) {
1959
2051
  }
1960
2052
  function assertContainedRelativePath(relative, componentId) {
1961
2053
  const climbs = relative.split(/[\\/]/).includes("..");
1962
- if (isAbsolute(relative) || climbs || relative.startsWith(sep)) {
2054
+ if (isAbsolute2(relative) || climbs || relative.startsWith(sep)) {
1963
2055
  throw new DispatchError(
1964
2056
  `asset path '${relative}' of component '${componentId}' must be a relative path with no '..' segments. A manifest naming a path outside the directory it lands in would be an arbitrary file write, so it is refused rather than resolved.`
1965
2057
  );
@@ -2162,6 +2254,9 @@ function makeReadOnlyGuard(cfg) {
2162
2254
  denials.push({ tool: toolName, reason: reason2 });
2163
2255
  return deny(reason2);
2164
2256
  }
2257
+ if (cfg.hostMcpTools?.includes(toolName)) {
2258
+ return allow(input);
2259
+ }
2165
2260
  const reason = `tool '${toolName}' is not permitted for this component.`;
2166
2261
  denials.push({ tool: toolName, reason });
2167
2262
  return deny(reason);
@@ -2603,6 +2698,27 @@ function aggregateTrace(messages, meta, denials) {
2603
2698
  denials
2604
2699
  };
2605
2700
  }
2701
+ function applyHostMcp(options, hostMcp) {
2702
+ if (options.mcpServers && hostMcp.name in options.mcpServers) {
2703
+ throw new DispatchError(
2704
+ `--host-mcp-config: MCP server name '${hostMcp.name}' is already used by this plan`
2705
+ );
2706
+ }
2707
+ const next = {
2708
+ ...options,
2709
+ mcpServers: { ...options.mcpServers, [hostMcp.name]: hostMcpServerEntry(hostMcp) }
2710
+ };
2711
+ if (hostMcp.instruction !== void 0) {
2712
+ if (typeof options.systemPrompt !== "string") {
2713
+ throw new DispatchError(
2714
+ "--host-mcp-config: 'instruction' needs a plain-string system prompt, which this plan does not have"
2715
+ );
2716
+ }
2717
+ next.systemPrompt = `${options.systemPrompt}
2718
+ ${hostMcp.instruction}`;
2719
+ }
2720
+ return next;
2721
+ }
2606
2722
  var DispatchSessionError = class extends DispatchError {
2607
2723
  constructor(message, sessionId) {
2608
2724
  super(message);
@@ -2642,6 +2758,11 @@ async function runDispatch(plan, cfg) {
2642
2758
  );
2643
2759
  }
2644
2760
  if (plan.meta.mode === "hybrid-staged") {
2761
+ if (cfg.hostMcp) {
2762
+ throw new DispatchError(
2763
+ "--host-mcp-config is not supported for a hybrid-staged plan (steps span providers)"
2764
+ );
2765
+ }
2645
2766
  return process.env["WARBLE_HYBRID_MODE"] === "tool" ? runHybridTool(plan, cfg) : runHybridStaged(plan, cfg);
2646
2767
  }
2647
2768
  mkdirSync3(cfg.outDir, { recursive: true });
@@ -2652,12 +2773,13 @@ async function runDispatch(plan, cfg) {
2652
2773
  readOnly: plan.meta.readOnly,
2653
2774
  writeScope,
2654
2775
  cwd,
2655
- setupScope: plan.meta.setupScope
2776
+ setupScope: plan.meta.setupScope,
2777
+ ...cfg.hostMcp ? { hostMcpTools: hostMcpToolNames(cfg.hostMcp) } : {}
2656
2778
  });
2657
2779
  const venvBin = join4(cwd, ".venv", "bin");
2658
2780
  const pathEnv = existsSync3(venvBin) ? `${venvBin}:${process.env.PATH ?? ""}` : process.env.PATH ?? "";
2659
2781
  const env = { ...process.env, PATH: pathEnv };
2660
- const options = {
2782
+ const baseOptions = {
2661
2783
  ...plan.options,
2662
2784
  canUseTool: composeCanUseTool(plan.options.canUseTool, canUseTool),
2663
2785
  // Read never reaches `canUseTool` for an in-cwd path in the real SDK (see guardrails.ts); this
@@ -2666,6 +2788,7 @@ async function runDispatch(plan, cfg) {
2666
2788
  env,
2667
2789
  ...cfg.resume ? { resume: cfg.resume } : {}
2668
2790
  };
2791
+ const options = cfg.hostMcp ? applyHostMcp(baseOptions, cfg.hostMcp) : baseOptions;
2669
2792
  if (cfg.assets) landAssets(cfg.assets.ir, cfg.assets.irPath, cwd);
2670
2793
  reportPromptFingerprint(cfg, options);
2671
2794
  const mapper = new ChatEventMapper(plan.meta.verb);
@@ -3797,7 +3920,7 @@ async function runComposedDispatch(plan, cfg) {
3797
3920
  }
3798
3921
 
3799
3922
  // src/dispatch.ts
3800
- import { dirname as dirname2, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
3923
+ import { dirname as dirname2, isAbsolute as isAbsolute3, resolve as resolve2 } from "path";
3801
3924
  var UNAVAILABLE_COMPONENT_REASON = "component is unavailable on the configured runtime";
3802
3925
  function deepFreeze(value) {
3803
3926
  if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
@@ -3842,7 +3965,7 @@ function assertInvocationRealization(closure, reports, target) {
3842
3965
  function resolveProjectCwd(node, opts) {
3843
3966
  if (opts.project) return resolve2(opts.project);
3844
3967
  const p = node.context_binding.project;
3845
- if (isAbsolute2(p)) return p;
3968
+ if (isAbsolute3(p)) return p;
3846
3969
  const baseDir = opts.irPath ? dirname2(resolve2(opts.irPath)) : process.cwd();
3847
3970
  return resolve2(baseDir, p);
3848
3971
  }
@@ -4285,10 +4408,10 @@ async function withinTimeout(operation, timeoutMs, abortController) {
4285
4408
  try {
4286
4409
  return await Promise.race([
4287
4410
  operation,
4288
- new Promise((_, reject) => {
4411
+ new Promise((_, reject2) => {
4289
4412
  timer = setTimeout(() => {
4290
4413
  abortController.abort();
4291
- reject(new Error("model catalog timed out"));
4414
+ reject2(new Error("model catalog timed out"));
4292
4415
  }, timeoutMs);
4293
4416
  })
4294
4417
  ]);
@@ -4450,6 +4573,9 @@ function createChatSession(plan, runCfg, initialResumeSessionId) {
4450
4573
  export {
4451
4574
  DispatchError,
4452
4575
  emitAgentModule,
4576
+ parseHostMcpConfig,
4577
+ loadHostMcpConfig,
4578
+ hostMcpToolNames,
4453
4579
  componentDependencies,
4454
4580
  resolveComponentClosure,
4455
4581
  REALIZATION_KINDS,
@@ -4533,4 +4659,4 @@ export {
4533
4659
  ChatSession,
4534
4660
  createChatSession
4535
4661
  };
4536
- //# sourceMappingURL=chunk-XH2CFK2E.js.map
4662
+ //# sourceMappingURL=chunk-6GZGHFX7.js.map