@warlock.js/ai-tools 4.5.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 (83) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/cjs/index.cjs +2519 -0
  5. package/cjs/index.cjs.map +1 -0
  6. package/esm/contracts/http.type.d.mts +96 -0
  7. package/esm/contracts/http.type.d.mts.map +1 -0
  8. package/esm/contracts/index.d.mts +4 -0
  9. package/esm/contracts/mcp.type.d.mts +216 -0
  10. package/esm/contracts/mcp.type.d.mts.map +1 -0
  11. package/esm/contracts/utility.type.d.mts +96 -0
  12. package/esm/contracts/utility.type.d.mts.map +1 -0
  13. package/esm/contracts/web.type.d.mts +136 -0
  14. package/esm/contracts/web.type.d.mts.map +1 -0
  15. package/esm/errors.d.mts +225 -0
  16. package/esm/errors.d.mts.map +1 -0
  17. package/esm/errors.mjs +136 -0
  18. package/esm/errors.mjs.map +1 -0
  19. package/esm/http/http-request.d.mts +57 -0
  20. package/esm/http/http-request.d.mts.map +1 -0
  21. package/esm/http/http-request.mjs +221 -0
  22. package/esm/http/http-request.mjs.map +1 -0
  23. package/esm/index.d.mts +19 -0
  24. package/esm/index.mjs +15 -0
  25. package/esm/mcp/client.mjs +199 -0
  26. package/esm/mcp/client.mjs.map +1 -0
  27. package/esm/mcp/index.d.mts +43 -0
  28. package/esm/mcp/index.d.mts.map +1 -0
  29. package/esm/mcp/index.mjs +19 -0
  30. package/esm/mcp/index.mjs.map +1 -0
  31. package/esm/mcp/json-schema-to-standard.d.mts +34 -0
  32. package/esm/mcp/json-schema-to-standard.d.mts.map +1 -0
  33. package/esm/mcp/json-schema-to-standard.mjs +147 -0
  34. package/esm/mcp/json-schema-to-standard.mjs.map +1 -0
  35. package/esm/mcp/serve.d.mts +46 -0
  36. package/esm/mcp/serve.d.mts.map +1 -0
  37. package/esm/mcp/serve.mjs +264 -0
  38. package/esm/mcp/serve.mjs.map +1 -0
  39. package/esm/mcp/transport.d.mts +48 -0
  40. package/esm/mcp/transport.d.mts.map +1 -0
  41. package/esm/mcp/transport.mjs +381 -0
  42. package/esm/mcp/transport.mjs.map +1 -0
  43. package/esm/mcp/transport.type.d.mts +51 -0
  44. package/esm/mcp/transport.type.d.mts.map +1 -0
  45. package/esm/node_modules/@standard-schema/spec/dist/index.d.mts +80 -0
  46. package/esm/node_modules/@standard-schema/spec/dist/index.d.mts.map +1 -0
  47. package/esm/register.d.mts +55 -0
  48. package/esm/register.d.mts.map +1 -0
  49. package/esm/register.mjs +21 -0
  50. package/esm/register.mjs.map +1 -0
  51. package/esm/schema.mjs +127 -0
  52. package/esm/schema.mjs.map +1 -0
  53. package/esm/utility/calculator.d.mts +35 -0
  54. package/esm/utility/calculator.d.mts.map +1 -0
  55. package/esm/utility/calculator.mjs +272 -0
  56. package/esm/utility/calculator.mjs.map +1 -0
  57. package/esm/utility/date-time.d.mts +57 -0
  58. package/esm/utility/date-time.d.mts.map +1 -0
  59. package/esm/utility/date-time.mjs +193 -0
  60. package/esm/utility/date-time.mjs.map +1 -0
  61. package/esm/utility/index.d.mts +2 -0
  62. package/esm/utility/index.mjs +4 -0
  63. package/esm/utility/schema.mjs +114 -0
  64. package/esm/utility/schema.mjs.map +1 -0
  65. package/esm/web/fetch-url.d.mts +39 -0
  66. package/esm/web/fetch-url.d.mts.map +1 -0
  67. package/esm/web/fetch-url.mjs +228 -0
  68. package/esm/web/fetch-url.mjs.map +1 -0
  69. package/esm/web/index.d.mts +2 -0
  70. package/esm/web/index.mjs +4 -0
  71. package/esm/web/schema.mjs +86 -0
  72. package/esm/web/schema.mjs.map +1 -0
  73. package/esm/web/web-search.d.mts +38 -0
  74. package/esm/web/web-search.d.mts.map +1 -0
  75. package/esm/web/web-search.mjs +167 -0
  76. package/esm/web/web-search.mjs.map +1 -0
  77. package/llms-full.txt +326 -0
  78. package/llms.txt +11 -0
  79. package/package.json +45 -0
  80. package/skills/README.md +17 -0
  81. package/skills/connect-mcp-server/SKILL.md +98 -0
  82. package/skills/expose-as-mcp-server/SKILL.md +85 -0
  83. package/skills/use-web-and-http-tools/SKILL.md +125 -0
package/cjs/index.cjs ADDED
@@ -0,0 +1,2519 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let _warlock_js_ai = require("@warlock.js/ai");
3
+ let node_child_process = require("node:child_process");
4
+ let node_readline = require("node:readline");
5
+
6
+ //#region ../@warlock.js/ai-tools/src/errors.ts
7
+ /**
8
+ * The `calculator` tool could not evaluate an expression — it was not
9
+ * valid arithmetic, divided by zero, or overflowed to a non-finite
10
+ * value.
11
+ *
12
+ * **Surface.** Thrown inside the tool handler, where `tool()` wraps it
13
+ * into a `ToolExecutionError` whose message is preserved verbatim and
14
+ * reaches the model as `{ error }` data, so the agent self-corrects
15
+ * rather than crashing. Extends the framework {@link AIError} (category
16
+ * `"tool"` via code `TOOL_EXEC_FAILED`) so it flows through the same
17
+ * typed error contract as every other AI error; branch on `error.type`
18
+ * for the specific failure.
19
+ *
20
+ * @example
21
+ * if (error instanceof CalculatorError && error.type === "divide-by-zero") {
22
+ * // the expression divided by zero — ask the model to revise it
23
+ * }
24
+ */
25
+ var CalculatorError = class extends _warlock_js_ai.AIError {
26
+ constructor(message, options) {
27
+ super("TOOL_EXEC_FAILED", message, options);
28
+ this.name = "CalculatorError";
29
+ this.type = options.type;
30
+ }
31
+ };
32
+ /**
33
+ * The `date_time` tool could not complete a call — a required field was
34
+ * missing or malformed, the unit/time zone was unrecognized, or the
35
+ * operation is unsupported.
36
+ *
37
+ * **Surface.** Thrown inside the tool handler, where `tool()` wraps it
38
+ * into a `ToolExecutionError` whose message reaches the model as
39
+ * `{ error }` data so the agent self-corrects. Extends the framework
40
+ * {@link AIError} (category `"tool"` via code `TOOL_EXEC_FAILED`); branch
41
+ * on `error.type` for the specific failure.
42
+ *
43
+ * @example
44
+ * if (error instanceof DateTimeError && error.type === "invalid-unit") {
45
+ * // the model passed an unknown unit — re-prompt with the allowed set
46
+ * }
47
+ */
48
+ var DateTimeError = class extends _warlock_js_ai.AIError {
49
+ constructor(message, options) {
50
+ super("TOOL_EXEC_FAILED", message, options);
51
+ this.name = "DateTimeError";
52
+ this.type = options.type;
53
+ }
54
+ };
55
+ /**
56
+ * The MCP client's transport layer failed — it could not connect, the
57
+ * peer spoke malformed JSON-RPC, a call timed out, or the transport was
58
+ * already closed.
59
+ *
60
+ * **Surface.** Connection / handshake failures surface at
61
+ * agent-construction time (the caller `await`s `client.tools()`). A
62
+ * `tools/call` failure raised mid-run is wrapped by `tool()` into a
63
+ * `ToolExecutionError` and reaches the model as `{ error }` data, so the
64
+ * agent self-corrects rather than crashing. Extends the framework
65
+ * {@link AIError} (category `"tool"`, code `TOOL_EXEC_FAILED`) so it
66
+ * flows through the same typed error contract as every other AI error;
67
+ * branch on `error.type` for the specific failure.
68
+ *
69
+ * @example
70
+ * if (error instanceof McpTransportError && error.type === "timeout") {
71
+ * // the remote call exceeded its deadline — retry or escalate
72
+ * }
73
+ */
74
+ var McpTransportError = class extends _warlock_js_ai.AIError {
75
+ constructor(message, options) {
76
+ super("TOOL_EXEC_FAILED", message, options);
77
+ this.name = "McpTransportError";
78
+ this.type = options.type;
79
+ this.method = options.method;
80
+ }
81
+ };
82
+ /**
83
+ * A web tool failed — a missing optional peer, an absent API key, a host
84
+ * rejected by the `allowHosts` guardrail, an unparseable URL, or a failed
85
+ * network call.
86
+ *
87
+ * **Surface.** Thrown from inside a tool's `execute`, so the framework's
88
+ * `tool()` wrapper catches it and surfaces it in the returned `{ error }`
89
+ * field (`invoke()` never throws) — the agent reads the failure as data
90
+ * and self-corrects rather than crashing. Extends the framework
91
+ * {@link AIError} (category `"tool"` via code `TOOL_EXEC_FAILED`) so it
92
+ * flows through the same typed error contract as every other AI error;
93
+ * branch on `error.type` for the specific failure.
94
+ *
95
+ * @example
96
+ * const { error } = await fetchTool.invoke({ url: "http://evil.test" });
97
+ * if (error instanceof WebToolError && error.type === "denied-host") {
98
+ * // the host was not in allowHosts — surfaced before any network call
99
+ * }
100
+ */
101
+ var WebToolError = class extends _warlock_js_ai.AIError {
102
+ constructor(message, options) {
103
+ super("TOOL_EXEC_FAILED", message, options);
104
+ this.name = "WebToolError";
105
+ this.type = options.type;
106
+ }
107
+ };
108
+ /**
109
+ * The `http_request` tool refused a call its construction-time policy
110
+ * does not permit — a disallowed method, a host outside the allowlist,
111
+ * or an unparseable URL. The rejection happens *before* any network
112
+ * request, so a guarded tool can never be coaxed into reaching an
113
+ * off-allowlist host (an SSRF guardrail).
114
+ *
115
+ * **Surface.** Thrown from inside the tool's `execute`, so the framework's
116
+ * `tool()` wrapper catches it and surfaces it in the returned `{ error }`
117
+ * field (`invoke()` never throws) — the agent reads the typed failure as
118
+ * data and self-corrects rather than crashing. Extends the framework
119
+ * {@link AIError} (category `"tool"` via code `TOOL_EXEC_FAILED`) so it
120
+ * flows through the same typed error contract as every other AI error;
121
+ * branch on `error.type` for the specific failure.
122
+ *
123
+ * @example
124
+ * const { error } = await httpTool.invoke({ url: "https://evil.test" });
125
+ * if (error instanceof HttpPolicyError && error.type === "host-not-allowed") {
126
+ * // the model tried to reach a host outside the configured allowlist
127
+ * }
128
+ */
129
+ var HttpPolicyError = class extends _warlock_js_ai.AIError {
130
+ constructor(message, options) {
131
+ super("TOOL_EXEC_FAILED", message, options);
132
+ this.name = "HttpPolicyError";
133
+ this.type = options.type;
134
+ }
135
+ };
136
+
137
+ //#endregion
138
+ //#region ../@warlock.js/ai-tools/src/schema.ts
139
+ /**
140
+ * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
141
+ * builders for the tools' input validation. The package pins only
142
+ * `@warlock.js/ai` as a required runtime peer and keeps every heavy
143
+ * dependency optional, so rather than pull in a schema library we
144
+ * hand-roll the few shapes the tools need — exactly the pattern
145
+ * `@warlock.js/ai`'s own `tool()` tests use. Each builder returns a
146
+ * `StandardSchemaV1`, which is what `tool({ input })` validates against
147
+ * before calling `execute`.
148
+ *
149
+ * These intentionally cover only the primitive cases the bundled tools
150
+ * require (`string`, an optional string drawn from a fixed set, an
151
+ * optional string-to-string record, and an opaque passthrough value),
152
+ * plus an `object` of fields. They are not a general-purpose validator.
153
+ */
154
+ /** The vendor tag stamped on every issue these builders produce. */
155
+ const VENDOR$3 = "ai-tools";
156
+ /** Required string field — rejects anything that is not a string. */
157
+ function stringField$2() {
158
+ return (value, key) => {
159
+ if (typeof value === "string") return { value };
160
+ return { issues: [{
161
+ message: `"${key}" must be a string`,
162
+ path: [key]
163
+ }] };
164
+ };
165
+ }
166
+ /**
167
+ * Optional string field constrained to a fixed set of allowed values.
168
+ * Accepts `undefined` (the property absent or explicitly undefined) or
169
+ * one of `allowed`; rejects any other string and every non-string type.
170
+ * Used for `method` so an out-of-vocabulary verb is a clean validation
171
+ * error rather than reaching the handler.
172
+ *
173
+ * @param allowed - The exhaustive set of permitted string literals.
174
+ */
175
+ function optionalStringEnumField(allowed) {
176
+ return (value, key) => {
177
+ if (value === void 0) return { value: void 0 };
178
+ if (typeof value === "string" && allowed.includes(value)) return { value };
179
+ return { issues: [{
180
+ message: `"${key}" must be one of: ${allowed.join(", ")}`,
181
+ path: [key]
182
+ }] };
183
+ };
184
+ }
185
+ /**
186
+ * Optional string-to-string record field — accepts `undefined` or a flat
187
+ * object whose every value is a string, and rejects arrays, non-objects,
188
+ * and any record with a non-string value. Used for per-call `headers`.
189
+ */
190
+ function optionalStringRecordField() {
191
+ return (value, key) => {
192
+ if (value === void 0) return { value: void 0 };
193
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
194
+ message: `"${key}" must be an object of string values when provided`,
195
+ path: [key]
196
+ }] };
197
+ const source = value;
198
+ const result = {};
199
+ for (const recordKey of Object.keys(source)) {
200
+ const entry = source[recordKey];
201
+ if (typeof entry !== "string") return { issues: [{
202
+ message: `"${key}.${recordKey}" must be a string`,
203
+ path: [key, recordKey]
204
+ }] };
205
+ result[recordKey] = entry;
206
+ }
207
+ return { value: result };
208
+ };
209
+ }
210
+ /**
211
+ * Opaque passthrough field — accepts any value (including `undefined`)
212
+ * verbatim, performing no validation. Used for a request `body` whose
213
+ * shape the tool deliberately does not constrain: an object is later
214
+ * JSON-serialized, a string is sent as-is.
215
+ */
216
+ function passthroughField() {
217
+ return (value) => ({ value });
218
+ }
219
+ /**
220
+ * Build a {@link StandardSchemaV1} for a flat object whose every property
221
+ * is validated by a {@link FieldValidator}. The input must be a non-null
222
+ * object; each declared field is validated and the (possibly coerced)
223
+ * values are collected into the typed result. All field issues are merged
224
+ * so the caller sees every problem at once.
225
+ *
226
+ * `T` is constrained to `object` rather than `Record<string, unknown>` so
227
+ * the tool IO `interface`s (which carry no implicit string index
228
+ * signature) satisfy it directly — only the declared keys in `shape` are
229
+ * ever read, so a string index signature is never required.
230
+ *
231
+ * @example
232
+ * const schema = objectSchema<{ url: string; method?: HttpMethod }>({
233
+ * url: stringField(),
234
+ * method: optionalStringEnumField(["GET", "POST"]),
235
+ * });
236
+ */
237
+ function objectSchema$2(shape) {
238
+ return { "~standard": {
239
+ version: 1,
240
+ vendor: VENDOR$3,
241
+ validate(input) {
242
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
243
+ const source = input;
244
+ const issues = [];
245
+ const result = {};
246
+ for (const key of Object.keys(shape)) {
247
+ const field = shape[key];
248
+ const present = key in source;
249
+ const outcome = field(source[key], key);
250
+ if ("issues" in outcome) {
251
+ issues.push(...outcome.issues);
252
+ continue;
253
+ }
254
+ if (outcome.value !== void 0 || present) result[key] = outcome.value;
255
+ }
256
+ if (issues.length > 0) return { issues };
257
+ return { value: result };
258
+ }
259
+ } };
260
+ }
261
+
262
+ //#endregion
263
+ //#region ../@warlock.js/ai-tools/src/http/http-request.ts
264
+ /** Default tool name exposed to the LLM. */
265
+ const DEFAULT_NAME$4 = "http_request";
266
+ /** Default per-request wall-clock timeout, in milliseconds. */
267
+ const DEFAULT_TIMEOUT_MS$1 = 15e3;
268
+ /** Default hard cap on response-body bytes before truncation. */
269
+ const DEFAULT_MAX_BYTES$1 = 1e6;
270
+ /** The full set of HTTP methods, in the order they appear in {@link HttpMethod}. */
271
+ const ALL_METHODS = [
272
+ "GET",
273
+ "POST",
274
+ "PUT",
275
+ "PATCH",
276
+ "DELETE"
277
+ ];
278
+ /** Methods that conventionally carry no request body — `body` is dropped for these. */
279
+ const BODYLESS_METHODS = new Set(["GET"]);
280
+ /**
281
+ * Standard Schema for {@link HttpRequestInput}. `url` is required;
282
+ * `method` is constrained to the canonical HTTP verb set (further
283
+ * narrowed to the tool's `allowMethods` at runtime); `headers` is an
284
+ * optional string-to-string record; `body` is an opaque passthrough the
285
+ * handler serializes based on its runtime type.
286
+ */
287
+ const httpRequestInputSchema = objectSchema$2({
288
+ method: optionalStringEnumField(ALL_METHODS),
289
+ url: stringField$2(),
290
+ headers: optionalStringRecordField(),
291
+ body: passthroughField()
292
+ });
293
+ /**
294
+ * Resolve the request target. With a `baseUrl` configured the model
295
+ * supplies a path joined against it; otherwise the model's `url` must be
296
+ * an absolute `http(s)` URL. Throws a typed {@link HttpPolicyError} of
297
+ * type `"invalid-url"` when the result cannot be parsed or is not an
298
+ * `http`/`https` URL — surfaced as `{ error }` data, never a crash.
299
+ */
300
+ function resolveUrl(rawUrl, baseUrl) {
301
+ let resolved;
302
+ try {
303
+ resolved = baseUrl !== void 0 ? new URL(rawUrl, baseUrl) : new URL(rawUrl);
304
+ } catch {
305
+ throw new HttpPolicyError(`http_request could not resolve a valid URL from "${rawUrl}"` + (baseUrl !== void 0 ? ` against base "${baseUrl}".` : "."), { type: "invalid-url" });
306
+ }
307
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw new HttpPolicyError(`http_request only permits http(s) URLs; got "${resolved.protocol}".`, { type: "invalid-url" });
308
+ return resolved;
309
+ }
310
+ /**
311
+ * Read a `Response` body, capping at `maxBytes`. Returns the decoded text
312
+ * and whether it was cut off. Streams chunk-by-chunk so an oversized body
313
+ * is abandoned at the cap rather than fully buffered; falls back to
314
+ * `response.text()` (then a post-hoc byte slice) when the body is not a
315
+ * readable stream (e.g. a stubbed `Response` in tests).
316
+ */
317
+ async function readCappedBody(response, maxBytes) {
318
+ const body = response.body;
319
+ if (!body) return {
320
+ text: "",
321
+ truncated: false
322
+ };
323
+ const decoder = new TextDecoder();
324
+ const reader = body.getReader();
325
+ let received = 0;
326
+ let truncated = false;
327
+ let text = "";
328
+ try {
329
+ for (;;) {
330
+ const { done, value } = await reader.read();
331
+ if (done) break;
332
+ if (!value) continue;
333
+ const remaining = maxBytes - received;
334
+ if (value.byteLength > remaining) {
335
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
336
+ received = maxBytes;
337
+ truncated = true;
338
+ break;
339
+ }
340
+ text += decoder.decode(value, { stream: true });
341
+ received += value.byteLength;
342
+ }
343
+ } finally {
344
+ await reader.cancel().catch(() => void 0);
345
+ reader.releaseLock();
346
+ }
347
+ text += decoder.decode();
348
+ return {
349
+ text,
350
+ truncated
351
+ };
352
+ }
353
+ /**
354
+ * Decide whether a response's `content-type` indicates JSON. Matches
355
+ * `application/json` and the `+json` structured-suffix convention
356
+ * (e.g. `application/vnd.api+json`), case-insensitively.
357
+ */
358
+ function isJsonContentType(contentType) {
359
+ if (!contentType) return false;
360
+ const value = contentType.toLowerCase();
361
+ return value.includes("application/json") || value.includes("+json");
362
+ }
363
+ /**
364
+ * Build the agent-facing `http_request` tool — a guarded HTTP/REST client
365
+ * over the global `fetch`. The `options` bound what the model may do; the
366
+ * model supplies the per-call URL / method / headers / body within those
367
+ * rails.
368
+ *
369
+ * **Guardrails (all enforced before the network call).**
370
+ * - **Method allowlist** — `allowMethods` (default `["GET"]`). A method
371
+ * outside the list is rejected with a typed
372
+ * {@link HttpPolicyError} (`type: "method-not-allowed"`).
373
+ * - **Host allowlist** — when `allowHosts` is set, any other host is
374
+ * rejected (`type: "host-not-allowed"`), an SSRF guardrail.
375
+ * - **`baseUrl` join** — when configured, the model passes a path that
376
+ * is resolved against `baseUrl`; otherwise it must pass an absolute
377
+ * `http(s)` URL. An unresolvable URL is rejected
378
+ * (`type: "invalid-url"`).
379
+ *
380
+ * **Request shaping.** Static `options.headers` are merged under the
381
+ * per-call `headers` (the per-call value wins). An object `body` is
382
+ * JSON-serialized with a `content-type: application/json` default; a
383
+ * string `body` is sent verbatim; `body` is dropped for bodyless methods
384
+ * (`GET`). The call is bounded by `timeoutMs` (default `15_000`) via an
385
+ * `AbortController`, also wired to `ctx.signal` for cooperative
386
+ * cancellation.
387
+ *
388
+ * **Response shaping.** Headers are returned with lower-cased keys. The
389
+ * body is read up to `maxBytes` (default `1_000_000`) and JSON-parsed
390
+ * when the response `content-type` is JSON, otherwise returned as text;
391
+ * `truncated` is `true` when the body was cut off at the cap (a truncated
392
+ * JSON body is returned as the raw partial string, since it can no longer
393
+ * be parsed).
394
+ *
395
+ * **Errors flow as data.** Every guardrail rejection and network failure
396
+ * is thrown inside `execute`; the framework's `tool()` wrapper catches it
397
+ * and surfaces it in the returned `{ error }` field, so the agent reads
398
+ * the failure and self-corrects rather than crashing.
399
+ *
400
+ * @param options - Construction-time policy bounding the tool.
401
+ * @returns A {@link ToolContract} the agent can call as `http_request`.
402
+ *
403
+ * @example
404
+ * const stripe = httpRequestTool({
405
+ * baseUrl: "https://api.stripe.com",
406
+ * allowHosts: ["api.stripe.com"],
407
+ * allowMethods: ["GET", "POST"],
408
+ * headers: { authorization: `Bearer ${process.env.STRIPE_KEY}` },
409
+ * });
410
+ * const { data } = await stripe.invoke({ method: "GET", url: "/v1/charges" });
411
+ */
412
+ function httpRequestTool(options = {}) {
413
+ const allowMethods = options.allowMethods ?? ["GET"];
414
+ const allowedMethodSet = new Set(allowMethods);
415
+ const allowHostSet = options.allowHosts ? new Set(options.allowHosts) : void 0;
416
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
417
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES$1;
418
+ const staticHeaders = options.headers;
419
+ return (0, _warlock_js_ai.tool)({
420
+ name: options.name ?? DEFAULT_NAME$4,
421
+ description: "Issue an HTTP request and return the status, response headers, and parsed body. Allowed methods and hosts are restricted by the tool's configuration; a request outside those rails is rejected before any network call. Pass an object body to send JSON, or a string to send it verbatim. The response body is JSON-parsed when the content-type is JSON, otherwise returned as text, and is capped — `truncated` is true when the body was cut off.",
422
+ action: (input) => `Requesting ${input.method ?? "GET"} ${input.url}`,
423
+ input: httpRequestInputSchema,
424
+ async execute(input, ctx) {
425
+ const method = input.method ?? "GET";
426
+ if (!allowedMethodSet.has(method)) throw new HttpPolicyError(`http_request method "${method}" is not allowed. Permitted methods: ${[...allowedMethodSet].join(", ")}.`, { type: "method-not-allowed" });
427
+ const url = resolveUrl(input.url, options.baseUrl);
428
+ if (allowHostSet && !allowHostSet.has(url.hostname)) throw new HttpPolicyError(`http_request host "${url.hostname}" is not in the allowlist. Permitted hosts: ${[...allowHostSet].join(", ")}.`, { type: "host-not-allowed" });
429
+ const headers = {
430
+ ...staticHeaders,
431
+ ...input.headers
432
+ };
433
+ let body;
434
+ if (!BODYLESS_METHODS.has(method) && input.body !== void 0) if (typeof input.body === "string") body = input.body;
435
+ else {
436
+ body = JSON.stringify(input.body);
437
+ if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) headers["content-type"] = "application/json";
438
+ }
439
+ const controller = new AbortController();
440
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
441
+ const onAbort = () => controller.abort();
442
+ if (ctx?.signal) if (ctx.signal.aborted) controller.abort();
443
+ else ctx.signal.addEventListener("abort", onAbort, { once: true });
444
+ let response;
445
+ try {
446
+ response = await fetch(url, {
447
+ method,
448
+ headers,
449
+ body,
450
+ signal: controller.signal
451
+ });
452
+ } finally {
453
+ clearTimeout(timer);
454
+ ctx?.signal?.removeEventListener("abort", onAbort);
455
+ }
456
+ const responseHeaders = {};
457
+ response.headers.forEach((value, key) => {
458
+ responseHeaders[key.toLowerCase()] = value;
459
+ });
460
+ const { text, truncated } = await readCappedBody(response, maxBytes);
461
+ let parsedBody = text;
462
+ if (!truncated && isJsonContentType(responseHeaders["content-type"]) && text.length > 0) try {
463
+ parsedBody = JSON.parse(text);
464
+ } catch {
465
+ parsedBody = text;
466
+ }
467
+ return {
468
+ status: response.status,
469
+ headers: responseHeaders,
470
+ body: parsedBody,
471
+ truncated
472
+ };
473
+ }
474
+ });
475
+ }
476
+
477
+ //#endregion
478
+ //#region ../@warlock.js/ai-tools/src/mcp/json-schema-to-standard.ts
479
+ /**
480
+ * The inverse of `@warlock.js/ai`'s `extractJsonSchema` (which goes
481
+ * Standard Schema → JSON Schema). Here we wrap a raw JSON Schema as a
482
+ * {@link StandardSchemaV1} whose `~standard.validate` runs a lazily-imported
483
+ * Ajv validator — so an MCP server's `inputSchema` (JSON Schema) becomes a
484
+ * `ToolConfig.input` the `tool()` factory can validate against.
485
+ *
486
+ * Ajv is an OPTIONAL peer, lazy-imported on first validate following the
487
+ * langfuse/readability pattern: a missing peer surfaces a curated install
488
+ * string (via the returned issues), never a raw module-resolution stack.
489
+ */
490
+ /** The structural vendor template, mirroring `passthroughSchema()`. */
491
+ const VENDOR$2 = "warlock-ai";
492
+ let AjvSdk;
493
+ let ajvInstance;
494
+ let isAjvAvailable;
495
+ let loadingPromise$1;
496
+ const AJV_INSTALL_INSTRUCTIONS = `
497
+ The MCP client's JSON-Schema validation requires the ajv package.
498
+ Install it with:
499
+
500
+ npm install ajv
501
+
502
+ Or with your preferred package manager:
503
+
504
+ pnpm add ajv
505
+ yarn add ajv
506
+ `.trim();
507
+ /**
508
+ * Settle the lazy import of `ajv` once, concurrency-safe. A bare `catch`
509
+ * flips the availability flag to `false`; the curated install string then
510
+ * surfaces at validate time as a Standard Schema issue, never a raw
511
+ * module-resolution error. The constructed `Ajv` instance is cached and
512
+ * reused for every schema compile.
513
+ */
514
+ async function loadAjv() {
515
+ if (isAjvAvailable !== void 0) return;
516
+ if (loadingPromise$1) return loadingPromise$1;
517
+ loadingPromise$1 = (async () => {
518
+ try {
519
+ AjvSdk = await import("ajv");
520
+ ajvInstance = new (AjvSdk.default ?? AjvSdk)({
521
+ allErrors: true,
522
+ strict: false
523
+ });
524
+ isAjvAvailable = true;
525
+ } catch {
526
+ isAjvAvailable = false;
527
+ }
528
+ })();
529
+ return loadingPromise$1;
530
+ }
531
+ /**
532
+ * Compile a JSON Schema with the shared Ajv instance, caching the compiled
533
+ * validator on a closure so repeated validations don't recompile. A schema
534
+ * Ajv itself rejects at compile time (an invalid meta-schema) degrades to
535
+ * an accept-all validator so a malformed remote schema can't wedge the
536
+ * tool — the server, not us, owns its schema's correctness.
537
+ */
538
+ function makeCompiler(schema) {
539
+ let compiled;
540
+ let attempted = false;
541
+ return () => {
542
+ if (attempted) return compiled;
543
+ attempted = true;
544
+ if (!ajvInstance) return;
545
+ try {
546
+ compiled = ajvInstance.compile(schema);
547
+ } catch {
548
+ compiled = void 0;
549
+ }
550
+ return compiled;
551
+ };
552
+ }
553
+ /**
554
+ * Wrap a raw JSON Schema as a {@link StandardSchemaV1} whose
555
+ * `~standard.validate` runs Ajv. The shape mirrors `passthroughSchema()`
556
+ * (`{ "~standard": { version: 1, vendor, validate } }`) so it drops into
557
+ * `tool({ input })` exactly like a native seal schema.
558
+ *
559
+ * Validation behavior:
560
+ * - **Valid input** → `{ value }` (the input is passed through unchanged;
561
+ * Ajv validates, it does not transform).
562
+ * - **Invalid input** → `{ issues }` carrying Ajv's `instancePath` +
563
+ * message per failure, so `tool()` produces a `SchemaValidationError`.
564
+ * - **Missing `ajv` peer** → a single issue carrying the curated install
565
+ * string, surfaced the same way (a developer-facing message in logs).
566
+ * - **No / empty schema** → an accept-all passthrough (an MCP tool may
567
+ * advertise no `inputSchema`).
568
+ *
569
+ * @param schema - The JSON Schema (an MCP tool's `inputSchema`), or
570
+ * `undefined` for a no-argument tool.
571
+ * @returns A `StandardSchemaV1<TInput>` ready for `tool({ input })`.
572
+ *
573
+ * @example
574
+ * const input = jsonSchemaToStandard<{ q: string }>({
575
+ * type: "object",
576
+ * properties: { q: { type: "string" } },
577
+ * required: ["q"],
578
+ * });
579
+ */
580
+ function jsonSchemaToStandard(schema) {
581
+ if (!schema || Object.keys(schema).length === 0) return { "~standard": {
582
+ version: 1,
583
+ vendor: VENDOR$2,
584
+ validate: (value) => ({ value })
585
+ } };
586
+ const compile = makeCompiler(schema);
587
+ return { "~standard": {
588
+ version: 1,
589
+ vendor: VENDOR$2,
590
+ async validate(value) {
591
+ await loadAjv();
592
+ if (!isAjvAvailable) return { issues: [{ message: AJV_INSTALL_INSTRUCTIONS }] };
593
+ const validator = compile();
594
+ if (!validator) return { value };
595
+ if (validator(value)) return { value };
596
+ const issues = (validator.errors ?? []).map((error) => ({
597
+ message: formatAjvError(error),
598
+ path: pathFromInstancePath(error.instancePath)
599
+ }));
600
+ return { issues: issues.length > 0 ? issues : [{ message: "input failed JSON Schema validation" }] };
601
+ }
602
+ } };
603
+ }
604
+ /**
605
+ * Render one Ajv error into a human-readable issue message. Prefixes the
606
+ * failing instance path (when present) so the model can see WHICH field
607
+ * was wrong, e.g. `/query: must be string`.
608
+ */
609
+ function formatAjvError(error) {
610
+ return `${error.instancePath ? `${error.instancePath}: ` : ""}${error.message ?? `failed "${error.keyword ?? "validation"}"`}`;
611
+ }
612
+ /**
613
+ * Convert an Ajv `instancePath` (a JSON-Pointer like `/items/0/name`) into
614
+ * the Standard Schema `path` segment array (`["items", "0", "name"]`).
615
+ * Empty paths (a root-level failure) become an empty array.
616
+ */
617
+ function pathFromInstancePath(instancePath) {
618
+ if (!instancePath) return [];
619
+ return instancePath.split("/").filter((segment) => segment.length > 0).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
620
+ }
621
+
622
+ //#endregion
623
+ //#region ../@warlock.js/ai-tools/src/mcp/transport.ts
624
+ /** Default per-request wait before a transport call is abandoned. */
625
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
626
+ /** The JSON-RPC version literal every outbound message carries. */
627
+ const JSONRPC_VERSION$1 = "2.0";
628
+ /**
629
+ * Wire a per-call timeout and an optional caller `AbortSignal` onto a
630
+ * pending request, returning a `cleanup()` that tears both down. The
631
+ * `onSettle` callback removes the pending entry from whatever registry the
632
+ * transport keeps so a late response can't double-settle.
633
+ */
634
+ function armCall(reject, method, options, onSettle) {
635
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
636
+ const timer = setTimeout(() => {
637
+ onSettle();
638
+ reject(new McpTransportError(`MCP request "${method}" timed out after ${timeoutMs}ms.`, {
639
+ type: "timeout",
640
+ method
641
+ }));
642
+ }, timeoutMs);
643
+ const onAbort = () => {
644
+ cleanup();
645
+ reject(new McpTransportError(`MCP request "${method}" was aborted.`, {
646
+ type: "closed",
647
+ method
648
+ }));
649
+ };
650
+ const signal = options?.signal;
651
+ if (signal) if (signal.aborted) queueMicrotask(onAbort);
652
+ else signal.addEventListener("abort", onAbort, { once: true });
653
+ function cleanup() {
654
+ clearTimeout(timer);
655
+ if (signal) signal.removeEventListener("abort", onAbort);
656
+ }
657
+ return cleanup;
658
+ }
659
+ /**
660
+ * The stdio transport — spawns a child process and speaks JSON-RPC over
661
+ * its stdin/stdout, one JSON object per line (newline-delimited framing).
662
+ * Uses only Node built-ins (`node:child_process` + `node:readline`); no
663
+ * dependency.
664
+ *
665
+ * Constructed via {@link createStdioTransport}; the class itself is
666
+ * internal.
667
+ */
668
+ var StdioTransport = class {
669
+ constructor(transport) {
670
+ this.pending = /* @__PURE__ */ new Map();
671
+ this.nextId = 1;
672
+ this.closed = false;
673
+ let child;
674
+ try {
675
+ child = (0, node_child_process.spawn)(transport.command, transport.args ?? [], {
676
+ env: transport.env,
677
+ stdio: [
678
+ "pipe",
679
+ "pipe",
680
+ "pipe"
681
+ ]
682
+ });
683
+ } catch (cause) {
684
+ const message = cause instanceof Error ? cause.message : String(cause);
685
+ throw new McpTransportError(`MCP stdio transport could not spawn "${transport.command}": ${message}`, {
686
+ type: "connect",
687
+ cause
688
+ });
689
+ }
690
+ this.child = child;
691
+ this.reader = (0, node_readline.createInterface)({ input: child.stdout });
692
+ this.reader.on("line", (line) => this.onLine(line));
693
+ child.on("exit", (code) => this.failAll("connect", `child exited with code ${code ?? "null"}`));
694
+ child.on("error", (error) => this.failAll("connect", error.message));
695
+ }
696
+ /**
697
+ * Parse one stdout line and route it to its pending request. Non-JSON
698
+ * lines (a server logging to stdout) and messages with no matching `id`
699
+ * (notifications, stray responses) are ignored — robustness over strictness.
700
+ */
701
+ onLine(line) {
702
+ const trimmed = line.trim();
703
+ if (!trimmed) return;
704
+ let message;
705
+ try {
706
+ message = JSON.parse(trimmed);
707
+ } catch {
708
+ return;
709
+ }
710
+ if (message.id === void 0 || message.id === null) return;
711
+ const call = this.pending.get(message.id);
712
+ if (!call) return;
713
+ this.pending.delete(message.id);
714
+ call.cleanup();
715
+ call.resolve(message);
716
+ }
717
+ /**
718
+ * Reject every pending call (and mark the transport unusable) when the
719
+ * child dies or errors — so a hung server can never leave a caller
720
+ * waiting forever.
721
+ */
722
+ failAll(type, reason) {
723
+ this.closed = true;
724
+ for (const [id, call] of this.pending) {
725
+ this.pending.delete(id);
726
+ call.cleanup();
727
+ call.reject(new McpTransportError(`MCP stdio transport failed: ${reason}.`, { type }));
728
+ }
729
+ }
730
+ request(request, options) {
731
+ if (this.closed) return Promise.reject(new McpTransportError("MCP stdio transport is closed.", {
732
+ type: "closed",
733
+ method: request.method
734
+ }));
735
+ const id = request.id;
736
+ return new Promise((resolve, reject) => {
737
+ const cleanup = armCall(reject, request.method, options, () => this.pending.delete(id));
738
+ this.pending.set(id, {
739
+ resolve: (response) => resolve(response),
740
+ reject,
741
+ cleanup
742
+ });
743
+ try {
744
+ this.child.stdin.write(`${JSON.stringify(request)}\n`);
745
+ } catch (cause) {
746
+ this.pending.delete(id);
747
+ cleanup();
748
+ const message = cause instanceof Error ? cause.message : String(cause);
749
+ reject(new McpTransportError(`MCP stdio transport failed to write request "${request.method}": ${message}`, {
750
+ type: "closed",
751
+ method: request.method,
752
+ cause
753
+ }));
754
+ }
755
+ });
756
+ }
757
+ async notify(method, params) {
758
+ if (this.closed) throw new McpTransportError("MCP stdio transport is closed.", {
759
+ type: "closed",
760
+ method
761
+ });
762
+ const notification = {
763
+ jsonrpc: JSONRPC_VERSION$1,
764
+ method,
765
+ params
766
+ };
767
+ this.child.stdin.write(`${JSON.stringify(notification)}\n`);
768
+ }
769
+ /** Allocate the next outbound request id. */
770
+ allocateId() {
771
+ return this.nextId++;
772
+ }
773
+ async close() {
774
+ if (this.closed) return;
775
+ this.closed = true;
776
+ this.reader.close();
777
+ this.failAll("closed", "transport closed by caller");
778
+ this.child.kill();
779
+ }
780
+ };
781
+ /**
782
+ * The Streamable HTTP transport — POSTs each JSON-RPC request to the
783
+ * server endpoint over the global `fetch` (Node 18+) and reads the single
784
+ * JSON response. No SSE-legacy, no WebSocket. Static `headers` (e.g. auth)
785
+ * are sent with every request.
786
+ *
787
+ * Constructed via {@link createHttpTransport}; the class itself is internal.
788
+ */
789
+ var HttpTransport = class {
790
+ constructor(transport) {
791
+ this.nextId = 1;
792
+ this.url = transport.url;
793
+ this.headers = {
794
+ "content-type": "application/json",
795
+ accept: "application/json, text/event-stream",
796
+ ...transport.headers
797
+ };
798
+ }
799
+ async request(request, options) {
800
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
801
+ const controller = new AbortController();
802
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
803
+ const onAbort = () => controller.abort();
804
+ const signal = options?.signal;
805
+ if (signal) if (signal.aborted) controller.abort();
806
+ else signal.addEventListener("abort", onAbort, { once: true });
807
+ let response;
808
+ try {
809
+ response = await fetch(this.url, {
810
+ method: "POST",
811
+ headers: this.headers,
812
+ body: JSON.stringify(request),
813
+ signal: controller.signal
814
+ });
815
+ } catch (cause) {
816
+ const aborted = controller.signal.aborted;
817
+ const message = cause instanceof Error ? cause.message : String(cause);
818
+ throw new McpTransportError(aborted ? `MCP request "${request.method}" timed out or was aborted after ${timeoutMs}ms.` : `MCP http transport request "${request.method}" failed: ${message}`, {
819
+ type: aborted ? "timeout" : "connect",
820
+ method: request.method,
821
+ cause
822
+ });
823
+ } finally {
824
+ clearTimeout(timer);
825
+ if (signal) signal.removeEventListener("abort", onAbort);
826
+ }
827
+ if (!response.ok) throw new McpTransportError(`MCP http transport request "${request.method}" returned HTTP ${response.status}.`, {
828
+ type: "connect",
829
+ method: request.method,
830
+ context: { status: response.status }
831
+ });
832
+ return this.parseBody(response, request.method);
833
+ }
834
+ /**
835
+ * Parse the HTTP response body into a JSON-RPC response. Streamable HTTP
836
+ * may answer with either `application/json` (a single response object)
837
+ * or `text/event-stream` (SSE frames); we read the body as text and
838
+ * extract the first JSON object, supporting the common `data: {...}`
839
+ * SSE line shape without a streaming parser.
840
+ */
841
+ async parseBody(response, method) {
842
+ const raw = await response.text();
843
+ const jsonText = (response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/event-stream") ? extractSseData(raw) : raw;
844
+ if (!jsonText) throw new McpTransportError(`MCP http transport got an empty response for "${method}".`, {
845
+ type: "protocol",
846
+ method
847
+ });
848
+ try {
849
+ return JSON.parse(jsonText);
850
+ } catch (cause) {
851
+ throw new McpTransportError(`MCP http transport got a non-JSON response for "${method}".`, {
852
+ type: "protocol",
853
+ method,
854
+ cause
855
+ });
856
+ }
857
+ }
858
+ async notify(method, params) {
859
+ const notification = {
860
+ jsonrpc: JSONRPC_VERSION$1,
861
+ method,
862
+ params
863
+ };
864
+ try {
865
+ await fetch(this.url, {
866
+ method: "POST",
867
+ headers: this.headers,
868
+ body: JSON.stringify(notification)
869
+ });
870
+ } catch (cause) {
871
+ throw new McpTransportError(`MCP http transport notification "${method}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
872
+ type: "connect",
873
+ method,
874
+ cause
875
+ });
876
+ }
877
+ }
878
+ /** Allocate the next outbound request id. */
879
+ allocateId() {
880
+ return this.nextId++;
881
+ }
882
+ async close() {}
883
+ };
884
+ /**
885
+ * Pull the first `data:` JSON payload out of an SSE response body. MCP's
886
+ * Streamable HTTP transport answers a single request with one SSE frame
887
+ * carrying the JSON-RPC response; we take the first non-empty `data:`
888
+ * line. Returns an empty string when none is found.
889
+ */
890
+ function extractSseData(body) {
891
+ for (const line of body.split(/\r?\n/)) {
892
+ const trimmed = line.trim();
893
+ if (trimmed.startsWith("data:")) {
894
+ const payload = trimmed.slice(5).trim();
895
+ if (payload && payload !== "[DONE]") return payload;
896
+ }
897
+ }
898
+ return "";
899
+ }
900
+ /**
901
+ * Build the concrete {@link McpTransportClient} for an {@link McpTransport}
902
+ * config — a {@link StdioTransport} for `type: "stdio"`, an
903
+ * {@link HttpTransport} for `type: "http"`. The returned client also
904
+ * carries an `allocateId()` for the JSON-RPC client to mint request ids.
905
+ *
906
+ * @param transport - The transport config (discriminated by `type`).
907
+ * @returns A transport client paired with its id allocator.
908
+ */
909
+ function createTransport(transport) {
910
+ if (transport.type === "stdio") return new StdioTransport(transport);
911
+ return new HttpTransport(transport);
912
+ }
913
+ /**
914
+ * A minimal JSON-RPC 2.0 request/response client over any
915
+ * {@link McpTransportClient}. Mints monotonic ids, frames the
916
+ * `{ jsonrpc, id, method, params }` envelope, and unwraps the response —
917
+ * translating a JSON-RPC `error` member into a typed
918
+ * {@link McpTransportError} so callers branch on `error.type` rather than
919
+ * parsing the wire.
920
+ *
921
+ * Constructed via {@link createJsonRpcClient}; the class itself is internal.
922
+ */
923
+ var JsonRpcClient = class {
924
+ constructor(transport) {
925
+ this.transport = transport;
926
+ }
927
+ /**
928
+ * Issue a JSON-RPC `method` call and resolve with its `result`,
929
+ * throwing a typed {@link McpTransportError} on a JSON-RPC error member
930
+ * or a malformed response (neither `result` nor `error`).
931
+ */
932
+ async call(method, params, options) {
933
+ const request = {
934
+ jsonrpc: JSONRPC_VERSION$1,
935
+ id: this.transport.allocateId(),
936
+ method,
937
+ params
938
+ };
939
+ const response = await this.transport.request(request, options);
940
+ if (response.error) throw new McpTransportError(`MCP "${method}" failed: ${response.error.message} (code ${response.error.code}).`, {
941
+ type: "protocol",
942
+ method,
943
+ context: { code: response.error.code },
944
+ cause: response.error.data
945
+ });
946
+ if (response.result === void 0) throw new McpTransportError(`MCP "${method}" returned a response with neither result nor error.`, {
947
+ type: "protocol",
948
+ method
949
+ });
950
+ return response.result;
951
+ }
952
+ /** Send a one-way JSON-RPC notification (no response awaited). */
953
+ notify(method, params) {
954
+ return this.transport.notify(method, params);
955
+ }
956
+ /** Close the underlying transport. */
957
+ close() {
958
+ return this.transport.close();
959
+ }
960
+ };
961
+ /**
962
+ * Build a {@link JsonRpcClientHandle} over a transport. Accepts either a
963
+ * pre-built {@link McpTransportClient} (the test seam — inject a scripted
964
+ * fake) or an {@link McpTransport} config, in which case the concrete
965
+ * transport is constructed via {@link createTransport}.
966
+ *
967
+ * When a bare {@link McpTransportClient} (without an `allocateId`) is
968
+ * injected, the client supplies its own monotonic id source.
969
+ *
970
+ * @param source - A transport client or an `McpTransport` config.
971
+ * @returns A JSON-RPC client handle.
972
+ */
973
+ function createJsonRpcClient(source) {
974
+ return new JsonRpcClient(isTransportConfig(source) ? createTransport(source) : withIdAllocator(source));
975
+ }
976
+ /**
977
+ * Distinguish an {@link McpTransport} config (a plain object with a `type`
978
+ * discriminator and no `request` method) from a built
979
+ * {@link McpTransportClient} (which exposes `request`).
980
+ */
981
+ function isTransportConfig(source) {
982
+ return typeof source.request !== "function";
983
+ }
984
+ /**
985
+ * Wrap an injected {@link McpTransportClient} that lacks its own
986
+ * `allocateId` with a monotonic id source, so the JSON-RPC client can mint
987
+ * request ids uniformly regardless of whether the transport was built here
988
+ * or supplied by a test.
989
+ */
990
+ function withIdAllocator(client) {
991
+ const candidate = client;
992
+ if (typeof candidate.allocateId === "function") return candidate;
993
+ let nextId = 1;
994
+ return Object.assign(client, { allocateId: () => nextId++ });
995
+ }
996
+
997
+ //#endregion
998
+ //#region ../@warlock.js/ai-tools/src/mcp/client.ts
999
+ /** Default per-call timeout for `tools/call`. */
1000
+ const DEFAULT_CALL_TIMEOUT_MS = 3e4;
1001
+ /** The MCP protocol version this client advertises in `initialize`. */
1002
+ const PROTOCOL_VERSION$1 = "2025-06-18";
1003
+ /**
1004
+ * The internal {@link McpClient} implementation. Owns one JSON-RPC client
1005
+ * over a transport, runs the `initialize` handshake on first use, lists
1006
+ * the server's tools, and adapts each into a {@link ToolContract} whose
1007
+ * `execute` issues `tools/call`. The adapted contracts are cached after
1008
+ * the first `tools()` so repeat calls don't re-handshake.
1009
+ *
1010
+ * Constructed via {@link mcp}; the class itself is internal.
1011
+ */
1012
+ var McpClientImpl = class {
1013
+ constructor(source, options = {}) {
1014
+ this.initialized = false;
1015
+ this.rpc = createJsonRpcClient(source);
1016
+ this.options = options;
1017
+ }
1018
+ /**
1019
+ * Run the MCP `initialize` handshake exactly once, then send the
1020
+ * `notifications/initialized` notification the protocol requires before
1021
+ * any other request. Wraps a handshake failure as a typed
1022
+ * {@link McpTransportError} of type `"connect"`.
1023
+ */
1024
+ async handshake() {
1025
+ if (this.initialized) return;
1026
+ try {
1027
+ await this.rpc.call("initialize", {
1028
+ protocolVersion: PROTOCOL_VERSION$1,
1029
+ capabilities: {},
1030
+ clientInfo: {
1031
+ name: "@warlock.js/ai-tools",
1032
+ version: "4.4.0"
1033
+ }
1034
+ });
1035
+ await this.rpc.notify("notifications/initialized");
1036
+ } catch (cause) {
1037
+ if (cause instanceof McpTransportError) throw cause;
1038
+ throw new McpTransportError(`MCP initialize handshake failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
1039
+ type: "connect",
1040
+ method: "initialize",
1041
+ cause
1042
+ });
1043
+ }
1044
+ this.initialized = true;
1045
+ }
1046
+ tools() {
1047
+ if (this.cached) return Promise.resolve(this.cached);
1048
+ if (this.pending) return this.pending;
1049
+ this.pending = this.listAndAdapt().then((tools) => {
1050
+ this.cached = tools;
1051
+ return tools;
1052
+ }).finally(() => {
1053
+ this.pending = void 0;
1054
+ });
1055
+ return this.pending;
1056
+ }
1057
+ /**
1058
+ * Handshake, `tools/list`, and adapt each descriptor into a
1059
+ * {@link ToolContract}, applying the `filter` and `namePrefix` options.
1060
+ */
1061
+ async listAndAdapt() {
1062
+ await this.handshake();
1063
+ const descriptors = (await this.rpc.call("tools/list")).tools ?? [];
1064
+ const filter = this.options.filter;
1065
+ return (filter ? descriptors.filter((d) => filter(d.name)) : descriptors).map((descriptor) => this.adapt(descriptor));
1066
+ }
1067
+ /**
1068
+ * Adapt one remote tool descriptor into a {@link ToolContract}: build
1069
+ * the input schema from its JSON Schema via {@link jsonSchemaToStandard},
1070
+ * prefix the name, and route `execute` through a `tools/call` that
1071
+ * honors `ctx.signal`, unwraps the content blocks, and throws on an
1072
+ * `isError` result so `tool()` wraps it.
1073
+ */
1074
+ adapt(descriptor) {
1075
+ const prefixedName = `${this.options.namePrefix ?? ""}${descriptor.name}`;
1076
+ const remoteName = descriptor.name;
1077
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
1078
+ const input = jsonSchemaToStandard(descriptor.inputSchema);
1079
+ return (0, _warlock_js_ai.tool)({
1080
+ name: prefixedName,
1081
+ description: descriptor.description ?? `Invoke the remote MCP tool "${remoteName}".`,
1082
+ input,
1083
+ execute: async (args, ctx) => {
1084
+ const result = await this.rpc.call("tools/call", {
1085
+ name: remoteName,
1086
+ arguments: args ?? {}
1087
+ }, {
1088
+ signal: ctx?.signal,
1089
+ timeoutMs
1090
+ });
1091
+ if (result.isError) throw new McpTransportError(`MCP tool "${remoteName}" returned an error: ${unwrapContent(result.content)}`, {
1092
+ type: "protocol",
1093
+ method: "tools/call"
1094
+ });
1095
+ return unwrapResult(result.content);
1096
+ }
1097
+ });
1098
+ }
1099
+ close() {
1100
+ return this.rpc.close();
1101
+ }
1102
+ };
1103
+ /**
1104
+ * Flatten an MCP `tools/call` result's content blocks into the value a
1105
+ * tool returns. Text blocks are concatenated; a single block whose text is
1106
+ * valid JSON is parsed so structured tool output flows back as an object
1107
+ * rather than a string. Non-text blocks are preserved as `type`-tagged
1108
+ * objects (MCP's wire `type` is kept; any inbound `kind` is normalized to
1109
+ * `type`).
1110
+ */
1111
+ function unwrapResult(content) {
1112
+ const blocks = content ?? [];
1113
+ if (blocks.length === 1 && blocks[0].type === "text") return tryParseJson(blocks[0].text ?? "");
1114
+ return blocks.map((block) => normalizeBlock(block));
1115
+ }
1116
+ /**
1117
+ * Render content blocks to a short human string for error messages — the
1118
+ * concatenated text of every text block.
1119
+ */
1120
+ function unwrapContent(content) {
1121
+ return (content ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join(" ").trim();
1122
+ }
1123
+ /**
1124
+ * Normalize one content block onto our `type`-only shape: translate an
1125
+ * inbound `kind` discriminator to `type` (and strip `kind`) so the value a
1126
+ * tool returns never carries MCP's `kind` vocabulary.
1127
+ */
1128
+ function normalizeBlock(block) {
1129
+ const { kind, ...rest } = block;
1130
+ const type = block.type ?? kind ?? "unknown";
1131
+ return {
1132
+ ...rest,
1133
+ type
1134
+ };
1135
+ }
1136
+ /**
1137
+ * Parse a string as JSON, returning the parsed value on success or the
1138
+ * original string when it is not JSON — so a plain-text tool result stays
1139
+ * a string while a JSON tool result becomes an object.
1140
+ */
1141
+ function tryParseJson(text) {
1142
+ const trimmed = text.trim();
1143
+ if (!trimmed) return text;
1144
+ const first = trimmed[0];
1145
+ if (first !== "{" && first !== "[") return text;
1146
+ try {
1147
+ return JSON.parse(trimmed);
1148
+ } catch {
1149
+ return text;
1150
+ }
1151
+ }
1152
+ /**
1153
+ * Connect to an external MCP server and adapt its tools as agent tools
1154
+ * (Direction A: server → local agent tools).
1155
+ *
1156
+ * Opens the transport lazily and exposes {@link McpClient.tools}, which on
1157
+ * first call runs the `initialize` handshake, lists the server's tools via
1158
+ * `tools/list`, and maps each into a {@link ToolContract}:
1159
+ * - **input schema** — the remote tool's JSON Schema is wrapped as a
1160
+ * Standard Schema via {@link jsonSchemaToStandard} (Ajv-backed, an
1161
+ * optional peer);
1162
+ * - **execute** — issues `tools/call` honoring `ctx.signal` and the
1163
+ * configured `timeoutMs`, unwraps the result content, and throws on an
1164
+ * `isError` result so the `tool()` wrapper surfaces it as `{ error }`;
1165
+ * - **name** — prefixed with `options.namePrefix` to avoid local
1166
+ * collisions; only tools passing `options.filter` are adapted.
1167
+ *
1168
+ * The adapted contracts are cached after the first `tools()` call, so the
1169
+ * handshake + list happen exactly once. The returned contracts drop
1170
+ * straight into `ai.agent({ tools: [...] })`.
1171
+ *
1172
+ * @param server - The transport config (`{ type: "stdio" }` /
1173
+ * `{ type: "http" }`). A pre-built transport client may be injected for
1174
+ * testing.
1175
+ * @param options - Prefix / filter / per-call timeout.
1176
+ * @returns An {@link McpClient} handle.
1177
+ *
1178
+ * @example
1179
+ * const github = mcp(
1180
+ * { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
1181
+ * { namePrefix: "github." },
1182
+ * );
1183
+ * const dev = ai.agent({ model, tools: [...(await github.tools())] });
1184
+ */
1185
+ function mcp$1(server, options) {
1186
+ return new McpClientImpl(server, options);
1187
+ }
1188
+
1189
+ //#endregion
1190
+ //#region ../@warlock.js/ai-tools/src/mcp/serve.ts
1191
+ /** JSON-RPC version literal every outbound message carries. */
1192
+ const JSONRPC_VERSION = "2.0";
1193
+ /** Default JSON-Schema dialect emitted for each tool's `inputSchema`. */
1194
+ const DEFAULT_SCHEMA_TARGET = "draft-2020-12";
1195
+ /** Default advertised server version when the caller omits one. */
1196
+ const DEFAULT_VERSION = "4.4.0";
1197
+ /** The MCP protocol version this server advertises in `initialize`. */
1198
+ const PROTOCOL_VERSION = "2025-06-18";
1199
+ /** JSON-RPC standard error codes we emit. */
1200
+ const JSON_RPC_METHOD_NOT_FOUND = -32601;
1201
+ const JSON_RPC_INVALID_PARAMS = -32602;
1202
+ /**
1203
+ * Resolve the {@link McpServeSource} (either an object exposing `tools()`
1204
+ * or a literal `ToolContract[]`) into a flat contract array.
1205
+ */
1206
+ function resolveTools(source) {
1207
+ if (Array.isArray(source)) return source;
1208
+ return source.tools();
1209
+ }
1210
+ /**
1211
+ * The pure protocol core of `serve` — maps one JSON-RPC request to its
1212
+ * response, with no I/O. Both the stdio and http serve-transports pump
1213
+ * their inbound requests through this, and specs can drive it directly.
1214
+ *
1215
+ * Handles exactly the MCP slice this package serves: `initialize`,
1216
+ * `tools/list`, and `tools/call`. Any other method answers with a
1217
+ * JSON-RPC `method not found` error.
1218
+ *
1219
+ * Constructed via {@link createServeHandler}.
1220
+ */
1221
+ var McpServeHandler = class {
1222
+ constructor(source, options) {
1223
+ this.tools = resolveTools(source);
1224
+ this.byName = new Map(this.tools.map((contract) => [contract.name, contract]));
1225
+ this.options = options;
1226
+ }
1227
+ /**
1228
+ * Dispatch one inbound JSON-RPC request to its handler and produce the
1229
+ * response. A handler that throws is mapped to a JSON-RPC error response
1230
+ * — the serve loop never crashes on a bad request.
1231
+ */
1232
+ async handle(request) {
1233
+ try {
1234
+ switch (request.method) {
1235
+ case "initialize": return this.ok(request.id, this.initializeResult());
1236
+ case "tools/list": return this.ok(request.id, { tools: this.listTools() });
1237
+ case "tools/call": return this.ok(request.id, await this.callTool(request.params));
1238
+ default: return this.error(request.id, JSON_RPC_METHOD_NOT_FOUND, `Method "${request.method}" is not supported by this MCP server.`);
1239
+ }
1240
+ } catch (cause) {
1241
+ const message = cause instanceof Error ? cause.message : String(cause);
1242
+ return this.error(request.id, JSON_RPC_INVALID_PARAMS, message);
1243
+ }
1244
+ }
1245
+ /** Build the `initialize` result advertising name / version / capabilities. */
1246
+ initializeResult() {
1247
+ return {
1248
+ protocolVersion: PROTOCOL_VERSION,
1249
+ capabilities: { tools: {} },
1250
+ serverInfo: {
1251
+ name: this.options.name,
1252
+ version: this.options.version ?? DEFAULT_VERSION
1253
+ }
1254
+ };
1255
+ }
1256
+ /**
1257
+ * Build the `tools/list` payload: one {@link McpToolDescriptor} per
1258
+ * contract, its `inputSchema` extracted via `extractJsonSchema` at the
1259
+ * configured dialect (default `draft-2020-12` — overriding
1260
+ * `extractJsonSchema`'s own `openai-strict` default to a neutral MCP draft).
1261
+ */
1262
+ listTools() {
1263
+ const target = this.options.schemaTarget ?? DEFAULT_SCHEMA_TARGET;
1264
+ return this.tools.map((contract) => {
1265
+ const inputSchema = (0, _warlock_js_ai.extractJsonSchema)(contract.input, { target }) ?? {
1266
+ type: "object",
1267
+ properties: {}
1268
+ };
1269
+ return {
1270
+ name: contract.name,
1271
+ description: contract.description,
1272
+ inputSchema
1273
+ };
1274
+ });
1275
+ }
1276
+ /**
1277
+ * Route a `tools/call` to the named contract's `invoke()` and map the
1278
+ * never-throwing {@link import("@warlock.js/ai").ToolInvokeResult}: `data`
1279
+ * → a text content block, `error` → an `isError: true` result. An unknown
1280
+ * tool name throws (mapped to a JSON-RPC error by {@link handle}).
1281
+ */
1282
+ async callTool(params) {
1283
+ const { name, args } = readCallParams(params);
1284
+ const contract = this.byName.get(name);
1285
+ if (!contract) throw new McpTransportError(`Unknown tool "${name}".`, {
1286
+ type: "protocol",
1287
+ method: "tools/call"
1288
+ });
1289
+ const result = await contract.invoke(args);
1290
+ if (result.error) return {
1291
+ content: [{
1292
+ type: "text",
1293
+ text: result.error.message
1294
+ }],
1295
+ isError: true
1296
+ };
1297
+ return {
1298
+ content: [{
1299
+ type: "text",
1300
+ text: serializeData(result.data)
1301
+ }],
1302
+ isError: false
1303
+ };
1304
+ }
1305
+ /** Build a JSON-RPC success response. */
1306
+ ok(id, result) {
1307
+ return {
1308
+ jsonrpc: JSONRPC_VERSION,
1309
+ id,
1310
+ result
1311
+ };
1312
+ }
1313
+ /** Build a JSON-RPC error response. */
1314
+ error(id, code, message) {
1315
+ return {
1316
+ jsonrpc: JSONRPC_VERSION,
1317
+ id,
1318
+ error: {
1319
+ code,
1320
+ message
1321
+ }
1322
+ };
1323
+ }
1324
+ };
1325
+ /**
1326
+ * Read and validate the `tools/call` params into `{ name, args }`. Throws
1327
+ * a typed {@link McpTransportError} when `name` is missing — mapped to a
1328
+ * JSON-RPC `invalid params` error by the handler.
1329
+ */
1330
+ function readCallParams(params) {
1331
+ if (typeof params !== "object" || params === null) throw new McpTransportError("tools/call params must be an object.", {
1332
+ type: "protocol",
1333
+ method: "tools/call"
1334
+ });
1335
+ const record = params;
1336
+ if (typeof record.name !== "string") throw new McpTransportError("tools/call requires a string `name`.", {
1337
+ type: "protocol",
1338
+ method: "tools/call"
1339
+ });
1340
+ return {
1341
+ name: record.name,
1342
+ args: record.arguments ?? {}
1343
+ };
1344
+ }
1345
+ /**
1346
+ * Serialize a tool's `data` for an MCP text content block — a string is
1347
+ * passed verbatim, everything else is JSON-stringified so structured
1348
+ * output crosses the wire as text the consuming client can re-parse.
1349
+ */
1350
+ function serializeData(data) {
1351
+ if (typeof data === "string") return data;
1352
+ if (data === void 0) return "";
1353
+ return JSON.stringify(data);
1354
+ }
1355
+ /**
1356
+ * Build the pure protocol handler for a serve source. Exposed (alongside
1357
+ * {@link serve}) so callers and tests can drive the MCP protocol without an
1358
+ * actual transport — feed it a JSON-RPC request, get the response.
1359
+ *
1360
+ * @param source - The tools to expose (an object with `tools()` or a literal array).
1361
+ * @param options - Serve options (name / version / schema target).
1362
+ * @returns An object whose `handle(request)` maps a request to a response.
1363
+ */
1364
+ function createServeHandler(source, options) {
1365
+ return new McpServeHandler(source, options);
1366
+ }
1367
+ /**
1368
+ * The internal {@link McpServer} — owns a {@link McpServeHandler} and a
1369
+ * transport pump. For `stdio` it reads newline-delimited JSON-RPC requests
1370
+ * from `process.stdin` and writes responses to `process.stdout`; the
1371
+ * `http` transport is accepted but listening is deferred to the host
1372
+ * (a serve-over-HTTP needs a server the caller owns).
1373
+ *
1374
+ * Constructed via {@link serve}; the class itself is internal.
1375
+ */
1376
+ var McpServerImpl = class {
1377
+ constructor(source, options) {
1378
+ this.running = false;
1379
+ this.handler = new McpServeHandler(source, options);
1380
+ this.options = options;
1381
+ }
1382
+ async start() {
1383
+ if (this.running) return;
1384
+ if ((this.options.transport ?? { type: "stdio" }).type !== "stdio") throw new McpTransportError("serve() over http requires a host-provided server; only stdio is auto-pumped.", { type: "connect" });
1385
+ this.running = true;
1386
+ this.reader = (0, node_readline.createInterface)({ input: process.stdin });
1387
+ this.reader.on("line", (line) => {
1388
+ this.onLine(line);
1389
+ });
1390
+ }
1391
+ /**
1392
+ * Parse one stdin line as a JSON-RPC request, dispatch it through the
1393
+ * handler, and write the response as a single line to stdout. Non-JSON
1394
+ * lines and notifications (no `id`) are ignored.
1395
+ */
1396
+ async onLine(line) {
1397
+ const trimmed = line.trim();
1398
+ if (!trimmed) return;
1399
+ let request;
1400
+ try {
1401
+ request = JSON.parse(trimmed);
1402
+ } catch {
1403
+ return;
1404
+ }
1405
+ if (request.id === void 0 || request.id === null) return;
1406
+ const response = await this.handler.handle(request);
1407
+ process.stdout.write(`${JSON.stringify(response)}\n`);
1408
+ }
1409
+ async stop() {
1410
+ this.running = false;
1411
+ this.reader?.close();
1412
+ this.reader = void 0;
1413
+ }
1414
+ };
1415
+ /**
1416
+ * Expose a built agent / supervisor / orchestrator (or a raw
1417
+ * `ToolContract[]`) AS an MCP server (Direction B: local primitive → MCP
1418
+ * server other clients consume).
1419
+ *
1420
+ * Enumerates `source.tools()` (or the literal array) once at construction.
1421
+ * `tools/list` answers with each tool's `inputSchema` extracted via
1422
+ * `extractJsonSchema` at the configured `schemaTarget` (default
1423
+ * `draft-2020-12`). `tools/call` routes to the named contract's
1424
+ * `invoke()` and maps the never-throwing result — `data` becomes a text
1425
+ * content block, `error` becomes an `isError: true` result — so a failing
1426
+ * tool surfaces as a normal MCP tool error rather than crashing the server.
1427
+ *
1428
+ * The default transport is `stdio`, pumped over `process.stdin` /
1429
+ * `process.stdout`. Serving over HTTP is left to a host-owned server;
1430
+ * `start()` rejects an `http` transport (the protocol core is available
1431
+ * via {@link createServeHandler} for a caller's own HTTP wiring).
1432
+ *
1433
+ * @param source - The tools to expose.
1434
+ * @param options - Server name, version, transport, and schema dialect.
1435
+ * @returns An {@link McpServer} with `start()` / `stop()`.
1436
+ *
1437
+ * @example
1438
+ * serve(
1439
+ * { tools: () => ws.allTools() },
1440
+ * { name: "warlock-workspace", transport: { type: "stdio" } },
1441
+ * ).start();
1442
+ */
1443
+ function serve(source, options) {
1444
+ return new McpServerImpl(source, options);
1445
+ }
1446
+
1447
+ //#endregion
1448
+ //#region ../@warlock.js/ai-tools/src/mcp/index.ts
1449
+ /**
1450
+ * The `ai.mcp` factory value: the client factory with `.serve` attached.
1451
+ * `Object.assign` keeps `mcp` callable (Direction A) while widening it with
1452
+ * the `serve` member (Direction B) — one object, both directions. The
1453
+ * `declare module "@warlock.js/ai"` augmentation and the runtime
1454
+ * registration (`ai.mcp = mcp`) live in `../register`, so this barrel is a
1455
+ * pure value/type module the registrar consumes.
1456
+ */
1457
+ const mcp = Object.assign((server, options) => mcp$1(server, options), { serve });
1458
+
1459
+ //#endregion
1460
+ //#region ../@warlock.js/ai-tools/src/utility/schema.ts
1461
+ /**
1462
+ * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
1463
+ * builders for the utility tools' input validation. This package pins
1464
+ * only `@warlock.js/ai` as a runtime peer, so rather than pull in a
1465
+ * schema library we hand-roll the few field shapes the calculator and
1466
+ * date-time tools need — the same approach `@warlock.js/ai`'s own
1467
+ * `tool()` tests use. Each builder returns a {@link StandardSchemaV1},
1468
+ * which is what `tool({ input })` validates against before calling
1469
+ * `execute`.
1470
+ *
1471
+ * These intentionally cover only the primitive cases the UTILITY tools
1472
+ * require (`string`, `optional string`, `optional number`, and a
1473
+ * `literal union` enum). They are not a general-purpose validator.
1474
+ */
1475
+ /** The vendor tag stamped on every issue these builders produce. */
1476
+ const VENDOR$1 = "ai-tools";
1477
+ /** Required string field — rejects anything that is not a string. */
1478
+ function stringField$1() {
1479
+ return (value, key) => {
1480
+ if (typeof value === "string") return { value };
1481
+ return { issues: [{
1482
+ message: `"${key}" must be a string`,
1483
+ path: [key]
1484
+ }] };
1485
+ };
1486
+ }
1487
+ /**
1488
+ * Optional string field — accepts `undefined` (the property absent or
1489
+ * explicitly undefined) or a string, and rejects every other type.
1490
+ */
1491
+ function optionalStringField() {
1492
+ return (value, key) => {
1493
+ if (value === void 0) return { value: void 0 };
1494
+ if (typeof value === "string") return { value };
1495
+ return { issues: [{
1496
+ message: `"${key}" must be a string when provided`,
1497
+ path: [key]
1498
+ }] };
1499
+ };
1500
+ }
1501
+ /**
1502
+ * Optional finite-number field — accepts `undefined` or a finite number,
1503
+ * rejecting `NaN`/`Infinity` and non-number types.
1504
+ */
1505
+ function optionalNumberField$1() {
1506
+ return (value, key) => {
1507
+ if (value === void 0) return { value: void 0 };
1508
+ if (typeof value === "number" && Number.isFinite(value)) return { value };
1509
+ return { issues: [{
1510
+ message: `"${key}" must be a finite number when provided`,
1511
+ path: [key]
1512
+ }] };
1513
+ };
1514
+ }
1515
+ /**
1516
+ * Required literal-union field — accepts only one of `allowed`, rejecting
1517
+ * everything else. Used for discriminators like the date-time `op`.
1518
+ *
1519
+ * @param allowed - The closed set of permitted string literals.
1520
+ */
1521
+ function literalField(allowed) {
1522
+ return (value, key) => {
1523
+ if (typeof value === "string" && allowed.includes(value)) return { value };
1524
+ return { issues: [{
1525
+ message: `"${key}" must be one of: ${allowed.join(", ")}`,
1526
+ path: [key]
1527
+ }] };
1528
+ };
1529
+ }
1530
+ /**
1531
+ * Build a {@link StandardSchemaV1} for a flat object whose every property
1532
+ * is validated by a {@link FieldValidator}. The input must be a non-null
1533
+ * object; each declared field is validated and the (possibly coerced)
1534
+ * values are collected into the typed result. All field issues are merged
1535
+ * so the caller sees every problem at once.
1536
+ *
1537
+ * `T` is constrained to `object` rather than `Record<string, unknown>` so
1538
+ * the tool IO `interface`s (which carry no implicit string index
1539
+ * signature) satisfy it directly — only the declared keys in `shape` are
1540
+ * ever read, so a string index signature is never required.
1541
+ *
1542
+ * @example
1543
+ * const schema = objectSchema<{ expression: string }>({
1544
+ * expression: stringField(),
1545
+ * });
1546
+ */
1547
+ function objectSchema$1(shape) {
1548
+ return { "~standard": {
1549
+ version: 1,
1550
+ vendor: VENDOR$1,
1551
+ validate(input) {
1552
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
1553
+ const source = input;
1554
+ const issues = [];
1555
+ const result = {};
1556
+ for (const key of Object.keys(shape)) {
1557
+ const field = shape[key];
1558
+ const outcome = field(source[key], key);
1559
+ if ("issues" in outcome) {
1560
+ issues.push(...outcome.issues);
1561
+ continue;
1562
+ }
1563
+ if (outcome.value !== void 0) result[key] = outcome.value;
1564
+ }
1565
+ if (issues.length > 0) return { issues };
1566
+ return { value: result };
1567
+ }
1568
+ } };
1569
+ }
1570
+
1571
+ //#endregion
1572
+ //#region ../@warlock.js/ai-tools/src/utility/calculator.ts
1573
+ /** Default tool name exposed to the LLM. */
1574
+ const DEFAULT_NAME$3 = "calculator";
1575
+ /** Input schema for the `calculator` tool: a single `expression` string. */
1576
+ const inputSchema$3 = objectSchema$1({ expression: stringField$1() });
1577
+ /** The closed set of supported binary operators. */
1578
+ const OPERATORS = {
1579
+ "+": {
1580
+ precedence: 1,
1581
+ associativity: "left",
1582
+ apply: (a, b) => a + b
1583
+ },
1584
+ "-": {
1585
+ precedence: 1,
1586
+ associativity: "left",
1587
+ apply: (a, b) => a - b
1588
+ },
1589
+ "*": {
1590
+ precedence: 2,
1591
+ associativity: "left",
1592
+ apply: (a, b) => a * b
1593
+ },
1594
+ "/": {
1595
+ precedence: 2,
1596
+ associativity: "left",
1597
+ apply: (a, b) => {
1598
+ if (b === 0) throw new CalculatorError("Division by zero.", { type: "divide-by-zero" });
1599
+ return a / b;
1600
+ }
1601
+ },
1602
+ "%": {
1603
+ precedence: 2,
1604
+ associativity: "left",
1605
+ apply: (a, b) => {
1606
+ if (b === 0) throw new CalculatorError("Modulo by zero.", { type: "divide-by-zero" });
1607
+ return a % b;
1608
+ }
1609
+ },
1610
+ "^": {
1611
+ precedence: 3,
1612
+ associativity: "right",
1613
+ apply: (a, b) => a ** b
1614
+ }
1615
+ };
1616
+ /**
1617
+ * Tokenize an arithmetic expression into {@link Token}s. Recognizes
1618
+ * decimal and scientific-notation numbers (`3`, `4.5`, `1e3`, `2.5E-2`),
1619
+ * the operators in {@link OPERATORS}, and parentheses; whitespace is
1620
+ * skipped. Any other character is a syntax error — there is no path to
1621
+ * an identifier, function call, or property access, so nothing
1622
+ * code-like can be smuggled in.
1623
+ *
1624
+ * @throws CalculatorError `type: "syntax"` on an unrecognized character
1625
+ * or a malformed number.
1626
+ */
1627
+ function tokenize(expression) {
1628
+ const tokens = [];
1629
+ let index = 0;
1630
+ while (index < expression.length) {
1631
+ const char = expression[index];
1632
+ if (char === " " || char === " " || char === "\n" || char === "\r") {
1633
+ index += 1;
1634
+ continue;
1635
+ }
1636
+ if (char === "(" || char === ")") {
1637
+ tokens.push({
1638
+ type: "paren",
1639
+ value: char
1640
+ });
1641
+ index += 1;
1642
+ continue;
1643
+ }
1644
+ if (char in OPERATORS) {
1645
+ tokens.push({
1646
+ type: "operator",
1647
+ value: char
1648
+ });
1649
+ index += 1;
1650
+ continue;
1651
+ }
1652
+ if (isDigit(char) || char === ".") {
1653
+ const { value, nextIndex } = readNumber(expression, index);
1654
+ tokens.push({
1655
+ type: "number",
1656
+ value
1657
+ });
1658
+ index = nextIndex;
1659
+ continue;
1660
+ }
1661
+ throw new CalculatorError(`Unexpected character "${char}" at position ${index}. Only numbers, parentheses, and the operators + - * / % ^ are allowed.`, { type: "syntax" });
1662
+ }
1663
+ return tokens;
1664
+ }
1665
+ /** True for an ASCII digit `0`–`9`. */
1666
+ function isDigit(char) {
1667
+ return char >= "0" && char <= "9";
1668
+ }
1669
+ /**
1670
+ * Read a single number literal starting at `start`. Consumes an optional
1671
+ * integer part, optional fraction, and optional exponent
1672
+ * (`e`/`E` with an optional sign). Returns the parsed value and the index
1673
+ * just past the literal.
1674
+ *
1675
+ * @throws CalculatorError `type: "syntax"` if the consumed run is not a
1676
+ * valid finite number (e.g. a lone `.` or `1e` with no exponent).
1677
+ */
1678
+ function readNumber(expression, start) {
1679
+ let index = start;
1680
+ while (index < expression.length && isDigit(expression[index])) index += 1;
1681
+ if (expression[index] === ".") {
1682
+ index += 1;
1683
+ while (index < expression.length && isDigit(expression[index])) index += 1;
1684
+ }
1685
+ if (expression[index] === "e" || expression[index] === "E") {
1686
+ index += 1;
1687
+ if (expression[index] === "+" || expression[index] === "-") index += 1;
1688
+ while (index < expression.length && isDigit(expression[index])) index += 1;
1689
+ }
1690
+ const literal = expression.slice(start, index);
1691
+ const value = Number(literal);
1692
+ if (!Number.isFinite(value)) throw new CalculatorError(`Invalid number literal "${literal}".`, { type: "syntax" });
1693
+ return {
1694
+ value,
1695
+ nextIndex: index
1696
+ };
1697
+ }
1698
+ /**
1699
+ * Evaluate a token stream with a single left-to-right pass that resolves
1700
+ * unary signs, then a shunting-yard conversion that interleaves operator
1701
+ * application — so the result is produced without ever building an AST or
1702
+ * calling `eval`/`Function`.
1703
+ *
1704
+ * Unary `+`/`-` are detected positionally: a `+`/`-` is unary when it
1705
+ * starts the expression or directly follows another operator or an
1706
+ * opening paren. A unary `-` folds into the following number literal
1707
+ * (and a unary `+` is a no-op), which keeps the operator stack purely
1708
+ * binary.
1709
+ *
1710
+ * @throws CalculatorError on malformed structure (`type: "syntax"`),
1711
+ * division/modulo by zero (`type: "divide-by-zero"`), or a non-finite
1712
+ * result (`type: "overflow"`).
1713
+ */
1714
+ function evaluate(tokens) {
1715
+ const values = [];
1716
+ const operators = [];
1717
+ /** Pop the top operator and apply it to the top two values. */
1718
+ const applyTop = () => {
1719
+ const operator = operators.pop();
1720
+ if (operator === void 0) throw new CalculatorError("Malformed expression.", { type: "syntax" });
1721
+ const right = values.pop();
1722
+ const left = values.pop();
1723
+ if (left === void 0 || right === void 0) throw new CalculatorError(`Operator "${operator}" is missing an operand.`, { type: "syntax" });
1724
+ values.push(OPERATORS[operator].apply(left, right));
1725
+ };
1726
+ let expectOperand = true;
1727
+ let pendingSign = 1;
1728
+ for (let i = 0; i < tokens.length; i += 1) {
1729
+ const token = tokens[i];
1730
+ if (token.type === "number") {
1731
+ if (!expectOperand) throw new CalculatorError("Unexpected number — a number cannot directly follow another value.", { type: "syntax" });
1732
+ values.push(pendingSign * token.value);
1733
+ pendingSign = 1;
1734
+ expectOperand = false;
1735
+ continue;
1736
+ }
1737
+ if (token.type === "paren") {
1738
+ if (token.value === "(") {
1739
+ if (!expectOperand) throw new CalculatorError("Unexpected \"(\" — it cannot directly follow a value.", { type: "syntax" });
1740
+ if (pendingSign === -1) {
1741
+ values.push(-1);
1742
+ operators.push("*");
1743
+ pendingSign = 1;
1744
+ }
1745
+ operators.push("(");
1746
+ expectOperand = true;
1747
+ continue;
1748
+ }
1749
+ if (expectOperand) throw new CalculatorError("Unexpected \")\" — an operand was expected.", { type: "syntax" });
1750
+ let foundOpen = false;
1751
+ while (operators.length > 0) {
1752
+ if (operators[operators.length - 1] === "(") {
1753
+ operators.pop();
1754
+ foundOpen = true;
1755
+ break;
1756
+ }
1757
+ applyTop();
1758
+ }
1759
+ if (!foundOpen) throw new CalculatorError("Unbalanced parentheses.", { type: "syntax" });
1760
+ expectOperand = false;
1761
+ continue;
1762
+ }
1763
+ if (expectOperand) {
1764
+ if (token.value === "-") {
1765
+ pendingSign = -pendingSign;
1766
+ continue;
1767
+ }
1768
+ if (token.value === "+") continue;
1769
+ throw new CalculatorError(`Operator "${token.value}" has no left-hand operand.`, { type: "syntax" });
1770
+ }
1771
+ const incoming = OPERATORS[token.value];
1772
+ while (operators.length > 0) {
1773
+ const top = operators[operators.length - 1];
1774
+ if (top === "(") break;
1775
+ const topSpec = OPERATORS[top];
1776
+ const higher = topSpec.precedence > incoming.precedence;
1777
+ const equalLeft = topSpec.precedence === incoming.precedence && incoming.associativity === "left";
1778
+ if (higher || equalLeft) {
1779
+ applyTop();
1780
+ continue;
1781
+ }
1782
+ break;
1783
+ }
1784
+ operators.push(token.value);
1785
+ expectOperand = true;
1786
+ }
1787
+ if (expectOperand) throw new CalculatorError("Expression ends with an operator or is empty.", { type: "syntax" });
1788
+ while (operators.length > 0) {
1789
+ if (operators[operators.length - 1] === "(") throw new CalculatorError("Unbalanced parentheses.", { type: "syntax" });
1790
+ applyTop();
1791
+ }
1792
+ const result = values.pop();
1793
+ if (result === void 0 || values.length > 0) throw new CalculatorError("Malformed expression.", { type: "syntax" });
1794
+ if (!Number.isFinite(result)) throw new CalculatorError("Result is not a finite number.", { type: "overflow" });
1795
+ return result;
1796
+ }
1797
+ /**
1798
+ * Build the `calculator` tool — a SAFE arithmetic evaluator the agent can
1799
+ * call to compute a numeric expression. It supports `+ - * / % ^`, unary
1800
+ * signs, parentheses, and decimal/scientific-notation literals, with the
1801
+ * usual precedence (`^` highest and right-associative, then `* / %`, then
1802
+ * `+ -`).
1803
+ *
1804
+ * **Safety.** The expression is tokenized and evaluated with a
1805
+ * shunting-yard pass — it NEVER calls `eval` or `new Function`. The
1806
+ * lexer only recognizes numbers, parentheses, and the fixed operator set,
1807
+ * so there is no path to an identifier, function call, or property
1808
+ * access; any other character is a syntax error returned as data.
1809
+ *
1810
+ * **Errors flow as data.** A malformed expression, division/modulo by
1811
+ * zero, or a non-finite result throws a {@link CalculatorError} inside
1812
+ * the handler; `tool()` catches it and surfaces it in the returned
1813
+ * `{ error }` field (the LLM-visible message is preserved), so the agent
1814
+ * reads the failure and self-corrects instead of crashing.
1815
+ *
1816
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
1817
+ * @returns A `ToolContract<{ expression }, { result }>` ready to drop into `tools: []`.
1818
+ *
1819
+ * @example
1820
+ * const calc = calculatorTool();
1821
+ * const { data } = await calc.invoke({ expression: "(3 + 4) * 2" });
1822
+ * console.log(data?.result); // 14
1823
+ */
1824
+ function calculatorTool(options) {
1825
+ return (0, _warlock_js_ai.tool)({
1826
+ name: options?.name ?? DEFAULT_NAME$3,
1827
+ description: "Evaluate an arithmetic expression and return the numeric result. Supports + - * / % ^, parentheses, unary +/-, and decimal or scientific-notation numbers. Does not support variables or functions — pass a literal expression like \"(3 + 4) * 2\".",
1828
+ input: inputSchema$3,
1829
+ async execute(input) {
1830
+ const tokens = tokenize(input.expression);
1831
+ if (tokens.length === 0) throw new CalculatorError("Expression is empty.", { type: "syntax" });
1832
+ return { result: evaluate(tokens) };
1833
+ }
1834
+ });
1835
+ }
1836
+
1837
+ //#endregion
1838
+ //#region ../@warlock.js/ai-tools/src/utility/date-time.ts
1839
+ /** Default tool name exposed to the LLM. */
1840
+ const DEFAULT_NAME$2 = "date_time";
1841
+ /** Input schema for the `date_time` tool. */
1842
+ const inputSchema$2 = objectSchema$1({
1843
+ op: literalField([
1844
+ "now",
1845
+ "add",
1846
+ "diff",
1847
+ "format"
1848
+ ]),
1849
+ iso: optionalStringField(),
1850
+ from: optionalStringField(),
1851
+ to: optionalStringField(),
1852
+ amount: optionalNumberField$1(),
1853
+ unit: optionalStringField(),
1854
+ timeZone: optionalStringField(),
1855
+ format: optionalStringField()
1856
+ });
1857
+ /**
1858
+ * The time units `add` and `diff` understand, each as its length in
1859
+ * milliseconds. Calendar-unaware on purpose: a "month" / "year" has no
1860
+ * fixed millisecond length, so they are deliberately excluded — adding
1861
+ * `30 days` is unambiguous in a way adding `1 month` is not.
1862
+ */
1863
+ const UNIT_MS = {
1864
+ milliseconds: 1,
1865
+ seconds: 1e3,
1866
+ minutes: 6e4,
1867
+ hours: 36e5,
1868
+ days: 864e5,
1869
+ weeks: 6048e5
1870
+ };
1871
+ /** Singular aliases mapped to their canonical plural unit key. */
1872
+ const UNIT_ALIASES = {
1873
+ millisecond: "milliseconds",
1874
+ ms: "milliseconds",
1875
+ second: "seconds",
1876
+ sec: "seconds",
1877
+ s: "seconds",
1878
+ minute: "minutes",
1879
+ min: "minutes",
1880
+ m: "minutes",
1881
+ hour: "hours",
1882
+ hr: "hours",
1883
+ h: "hours",
1884
+ day: "days",
1885
+ d: "days",
1886
+ week: "weeks",
1887
+ w: "weeks"
1888
+ };
1889
+ /** Resolve a (possibly aliased / singular) unit token to its ms length. */
1890
+ function unitToMs(unit) {
1891
+ const ms = UNIT_MS[UNIT_ALIASES[unit] ?? unit];
1892
+ if (ms === void 0) throw new DateTimeError(`Unknown unit "${unit}". Supported units: ${Object.keys(UNIT_MS).join(", ")}.`, { type: "invalid-unit" });
1893
+ return ms;
1894
+ }
1895
+ /**
1896
+ * Parse an ISO-8601 instant into a {@link Date}, or throw a typed
1897
+ * {@link DateTimeError} when the string is missing or unparseable.
1898
+ *
1899
+ * @param iso - The ISO string from the model (may be undefined).
1900
+ * @param field - The input field name, for the error message.
1901
+ */
1902
+ function parseIso(iso, field) {
1903
+ if (iso === void 0) throw new DateTimeError(`"${field}" is required for this operation.`, { type: "invalid-input" });
1904
+ const date = new Date(iso);
1905
+ if (Number.isNaN(date.getTime())) throw new DateTimeError(`"${field}" is not a valid ISO-8601 instant: "${iso}".`, { type: "invalid-input" });
1906
+ return date;
1907
+ }
1908
+ /**
1909
+ * Render a {@link Date} in a target time zone using `Intl`. The
1910
+ * `"iso"` format (the default) returns the instant's UTC ISO string;
1911
+ * any other `format` value is treated as an `Intl.DateTimeFormat`
1912
+ * locale-style rendering in the given `timeZone`.
1913
+ *
1914
+ * @throws DateTimeError `type: "invalid-time-zone"` when `timeZone` is
1915
+ * not a recognized IANA zone.
1916
+ */
1917
+ function render$1(date, format, timeZone) {
1918
+ if (format === void 0 || format === "iso") {
1919
+ if (timeZone !== void 0) assertTimeZone(timeZone);
1920
+ return date.toISOString();
1921
+ }
1922
+ try {
1923
+ return new Intl.DateTimeFormat("en-US", {
1924
+ timeZone,
1925
+ dateStyle: format === "date" ? "medium" : void 0,
1926
+ timeStyle: format === "time" ? "medium" : void 0,
1927
+ ...format === "datetime" ? {
1928
+ dateStyle: "medium",
1929
+ timeStyle: "medium"
1930
+ } : {}
1931
+ }).format(date);
1932
+ } catch (error) {
1933
+ throw new DateTimeError(`Could not render with format "${format}"${timeZone ? ` in time zone "${timeZone}"` : ""}.`, {
1934
+ type: "invalid-time-zone",
1935
+ cause: error
1936
+ });
1937
+ }
1938
+ }
1939
+ /**
1940
+ * Validate an IANA time zone by attempting to construct a formatter for
1941
+ * it; an unrecognized zone makes `Intl` throw a `RangeError`.
1942
+ *
1943
+ * @throws DateTimeError `type: "invalid-time-zone"` for an unknown zone.
1944
+ */
1945
+ function assertTimeZone(timeZone) {
1946
+ try {
1947
+ new Intl.DateTimeFormat("en-US", { timeZone });
1948
+ } catch (error) {
1949
+ throw new DateTimeError(`Unknown time zone "${timeZone}".`, {
1950
+ type: "invalid-time-zone",
1951
+ cause: error
1952
+ });
1953
+ }
1954
+ }
1955
+ /**
1956
+ * Build the `date_time` tool — a small clock/calendar utility the agent
1957
+ * can call to read the current instant, shift an instant, measure the
1958
+ * gap between two instants, or render one. The `op` discriminator selects
1959
+ * the operation:
1960
+ *
1961
+ * - **`now`** — the current instant, rendered per `format` / `timeZone`
1962
+ * (defaults to a UTC ISO string). Reads the injectable {@link Clock}.
1963
+ * - **`add`** — `iso` shifted by `amount` of `unit` (e.g.
1964
+ * `+3 days`); a negative `amount` shifts backward.
1965
+ * - **`diff`** — the signed difference `to − iso`, expressed in `unit`.
1966
+ * - **`format`** — `iso` rendered per `format` / `timeZone`.
1967
+ *
1968
+ * Units are millisecond-based (`milliseconds`…`weeks`, plus common
1969
+ * aliases); calendar-relative `month`/`year` are intentionally
1970
+ * unsupported because they have no fixed length.
1971
+ *
1972
+ * **Deterministic in tests.** The clock backing `now` is injectable via
1973
+ * `deps.clock`; production defaults to `Date.now`.
1974
+ *
1975
+ * **Errors flow as data.** A missing/invalid field, an unknown unit, or
1976
+ * an unrecognized time zone throws a {@link DateTimeError} inside the
1977
+ * handler; `tool()` surfaces it in `{ error }` so the agent self-corrects.
1978
+ *
1979
+ * @param options - Optional overrides; `name` renames the tool,
1980
+ * `defaultTimeZone` applies when a call omits `timeZone`.
1981
+ * @param deps - Injectable dependencies (the {@link Clock}); defaults to real time.
1982
+ * @returns A `ToolContract<DateTimeInput, { value }>` ready for `tools: []`.
1983
+ *
1984
+ * @example
1985
+ * const clock = () => Date.parse("2026-06-22T00:00:00Z");
1986
+ * const dt = dateTimeTool({}, { clock });
1987
+ * const { data } = await dt.invoke({ op: "now" });
1988
+ * console.log(data?.value); // "2026-06-22T00:00:00.000Z"
1989
+ */
1990
+ function dateTimeTool(options, deps) {
1991
+ const clock = deps?.clock ?? Date.now;
1992
+ const defaultTimeZone = options?.defaultTimeZone;
1993
+ return (0, _warlock_js_ai.tool)({
1994
+ name: options?.name ?? DEFAULT_NAME$2,
1995
+ description: "Work with dates and times. Set op to: \"now\" (current instant), \"add\" (shift iso by amount of unit), \"diff\" (signed to − from in unit; pass the start instant as iso or from), or \"format\" (render iso). Units are milliseconds, seconds, minutes, hours, days, or weeks (month/year are not supported). Pass instants as ISO-8601 strings; set format to \"iso\" (default), \"date\", \"time\", or \"datetime\", and timeZone as an IANA zone like \"Africa/Cairo\".",
1996
+ input: inputSchema$2,
1997
+ async execute(input) {
1998
+ const timeZone = input.timeZone ?? defaultTimeZone;
1999
+ switch (input.op) {
2000
+ case "now": return { value: render$1(new Date(clock()), input.format, timeZone) };
2001
+ case "format": return { value: render$1(parseIso(input.iso, "iso"), input.format, timeZone) };
2002
+ case "add": {
2003
+ const date = parseIso(input.iso, "iso");
2004
+ if (input.amount === void 0) throw new DateTimeError("\"amount\" is required for the \"add\" operation.", { type: "invalid-input" });
2005
+ if (input.unit === void 0) throw new DateTimeError("\"unit\" is required for the \"add\" operation.", { type: "invalid-input" });
2006
+ return { value: render$1(new Date(date.getTime() + input.amount * unitToMs(input.unit)), input.format, timeZone) };
2007
+ }
2008
+ case "diff": {
2009
+ const from = parseIso(input.iso ?? input.from, "iso (or from)");
2010
+ const to = parseIso(input.to, "to");
2011
+ if (input.unit === void 0) throw new DateTimeError("\"unit\" is required for the \"diff\" operation.", { type: "invalid-input" });
2012
+ const diff = (to.getTime() - from.getTime()) / unitToMs(input.unit);
2013
+ return { value: String(diff) };
2014
+ }
2015
+ default: {
2016
+ const unreachable = input.op;
2017
+ throw new DateTimeError(`Unsupported operation "${String(unreachable)}".`, { type: "unsupported-op" });
2018
+ }
2019
+ }
2020
+ }
2021
+ });
2022
+ }
2023
+
2024
+ //#endregion
2025
+ //#region ../@warlock.js/ai-tools/src/web/schema.ts
2026
+ /**
2027
+ * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
2028
+ * builders for the web tools' input validation. The package pins only
2029
+ * `@warlock.js/ai` as a runtime peer, so rather than pull in a schema
2030
+ * library we hand-roll the few shapes the web tools need — exactly the
2031
+ * pattern `@warlock.js/ai`'s own `tool()` tests use and the sibling
2032
+ * `@warlock.js/ai-workspace` package follows. Each builder returns a
2033
+ * `StandardSchemaV1`, which is what `tool({ input })` validates against
2034
+ * before calling `execute`.
2035
+ *
2036
+ * These intentionally cover only the primitive cases the WEB tools
2037
+ * require (`string`, `optional number`, and an `object` of fields). They
2038
+ * are not a general-purpose validator.
2039
+ */
2040
+ /** The vendor tag stamped on every issue these builders produce. */
2041
+ const VENDOR = "ai-tools";
2042
+ /** Required string field — rejects anything that is not a string. */
2043
+ function stringField() {
2044
+ return (value, key) => {
2045
+ if (typeof value === "string") return { value };
2046
+ return { issues: [{
2047
+ message: `"${key}" must be a string`,
2048
+ path: [key]
2049
+ }] };
2050
+ };
2051
+ }
2052
+ /**
2053
+ * Optional finite-number field — accepts `undefined` or a finite number,
2054
+ * rejecting `NaN`/`Infinity` and non-number types.
2055
+ */
2056
+ function optionalNumberField() {
2057
+ return (value, key) => {
2058
+ if (value === void 0) return { value: void 0 };
2059
+ if (typeof value === "number" && Number.isFinite(value)) return { value };
2060
+ return { issues: [{
2061
+ message: `"${key}" must be a finite number when provided`,
2062
+ path: [key]
2063
+ }] };
2064
+ };
2065
+ }
2066
+ /**
2067
+ * Build a {@link StandardSchemaV1} for a flat object whose every property
2068
+ * is validated by a {@link FieldValidator}. The input must be a non-null
2069
+ * object; each declared field is validated and the (possibly coerced)
2070
+ * values are collected into the typed result. All field issues are merged
2071
+ * so the caller sees every problem at once.
2072
+ *
2073
+ * `T` is constrained to `object` rather than `Record<string, unknown>` so
2074
+ * the tool IO `interface`s (which carry no implicit string index
2075
+ * signature) satisfy it directly — only the declared keys in `shape` are
2076
+ * ever read, so a string index signature is never required.
2077
+ *
2078
+ * @example
2079
+ * const schema = objectSchema<{ query: string; maxResults?: number }>({
2080
+ * query: stringField(),
2081
+ * maxResults: optionalNumberField(),
2082
+ * });
2083
+ */
2084
+ function objectSchema(shape) {
2085
+ return { "~standard": {
2086
+ version: 1,
2087
+ vendor: VENDOR,
2088
+ validate(input) {
2089
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
2090
+ const source = input;
2091
+ const issues = [];
2092
+ const result = {};
2093
+ for (const key of Object.keys(shape)) {
2094
+ const field = shape[key];
2095
+ const outcome = field(source[key], key);
2096
+ if ("issues" in outcome) {
2097
+ issues.push(...outcome.issues);
2098
+ continue;
2099
+ }
2100
+ if (outcome.value !== void 0) result[key] = outcome.value;
2101
+ }
2102
+ if (issues.length > 0) return { issues };
2103
+ return { value: result };
2104
+ }
2105
+ } };
2106
+ }
2107
+
2108
+ //#endregion
2109
+ //#region ../@warlock.js/ai-tools/src/web/fetch-url.ts
2110
+ /** Default tool name exposed to the LLM. */
2111
+ const DEFAULT_NAME$1 = "fetch_url";
2112
+ /** Default hard cap on response bytes read before truncation. */
2113
+ const DEFAULT_MAX_BYTES = 1e6;
2114
+ /** Default per-request timeout in milliseconds. */
2115
+ const DEFAULT_TIMEOUT_MS = 15e3;
2116
+ /** Default rendering mode for the fetched document. */
2117
+ const DEFAULT_EXTRACT = "text";
2118
+ /** Input schema: `{ url }`. */
2119
+ const inputSchema$1 = objectSchema({ url: stringField() });
2120
+ const READABILITY_MODULE_ID = "@mozilla/readability";
2121
+ const JSDOM_MODULE_ID = "jsdom";
2122
+ let ReadabilitySdk;
2123
+ let JsdomSdk;
2124
+ let isReadabilityAvailable;
2125
+ let loadingPromise;
2126
+ const READABILITY_INSTALL_INSTRUCTIONS = `
2127
+ The fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.
2128
+ Install them with:
2129
+
2130
+ npm install @mozilla/readability jsdom
2131
+
2132
+ Or with your preferred package manager:
2133
+
2134
+ pnpm add @mozilla/readability jsdom
2135
+ yarn add @mozilla/readability jsdom
2136
+ `.trim();
2137
+ /**
2138
+ * Settle the lazy import of `@mozilla/readability` + `jsdom` once,
2139
+ * concurrency-safe. Only needed for the `"text"` / `"markdown"` extract
2140
+ * modes — `"html"` returns the raw body and never loads them. A bare
2141
+ * `catch` flips the flag to `false`; the curated install string surfaces
2142
+ * at use time via {@link WebToolError}, never a raw module-resolution
2143
+ * stack trace.
2144
+ */
2145
+ function loadReadability() {
2146
+ if (isReadabilityAvailable !== void 0) return Promise.resolve();
2147
+ if (loadingPromise) return loadingPromise;
2148
+ loadingPromise = (async () => {
2149
+ try {
2150
+ ReadabilitySdk = await import(READABILITY_MODULE_ID);
2151
+ JsdomSdk = await import(JSDOM_MODULE_ID);
2152
+ isReadabilityAvailable = true;
2153
+ } catch {
2154
+ isReadabilityAvailable = false;
2155
+ }
2156
+ })();
2157
+ return loadingPromise;
2158
+ }
2159
+ /**
2160
+ * Parse the input URL, rejecting anything unparseable or not over
2161
+ * `http`/`https` with a typed {@link WebToolError}.
2162
+ */
2163
+ function parseUrl(raw) {
2164
+ let url;
2165
+ try {
2166
+ url = new URL(raw);
2167
+ } catch {
2168
+ throw new WebToolError(`fetch_url received an unparseable URL: "${raw}".`, { type: "invalid-url" });
2169
+ }
2170
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new WebToolError(`fetch_url only supports http(s) URLs; got "${url.protocol}".`, { type: "invalid-url" });
2171
+ return url;
2172
+ }
2173
+ /**
2174
+ * Enforce the host allowlist (an SSRF guardrail) before any network call.
2175
+ * When `allowHosts` is set, a request to a host not in it is rejected;
2176
+ * matching is exact on the URL hostname (case-insensitive).
2177
+ */
2178
+ function assertHostAllowed(url, allowHosts) {
2179
+ if (!allowHosts || allowHosts.length === 0) return;
2180
+ const host = url.hostname.toLowerCase();
2181
+ if (!allowHosts.some((entry) => entry.toLowerCase() === host)) throw new WebToolError(`fetch_url blocked host "${url.hostname}" — it is not in the allowHosts allowlist.`, {
2182
+ type: "denied-host",
2183
+ context: { host: url.hostname }
2184
+ });
2185
+ }
2186
+ /**
2187
+ * Read the response body up to `maxBytes`, stopping early once the cap is
2188
+ * reached. Returns the decoded text and whether it was truncated. When
2189
+ * the body has no stream (a stubbed `Response`), falls back to `.text()`
2190
+ * and truncates the decoded string at `maxBytes`.
2191
+ */
2192
+ async function readBody(response, maxBytes) {
2193
+ const stream = response.body;
2194
+ if (!stream) {
2195
+ const text = await response.text();
2196
+ if (text.length > maxBytes) return {
2197
+ body: text.slice(0, maxBytes),
2198
+ truncated: true
2199
+ };
2200
+ return {
2201
+ body: text,
2202
+ truncated: false
2203
+ };
2204
+ }
2205
+ const reader = stream.getReader();
2206
+ const chunks = [];
2207
+ let received = 0;
2208
+ let truncated = false;
2209
+ for (;;) {
2210
+ const { done, value } = await reader.read();
2211
+ if (done) break;
2212
+ if (value) {
2213
+ received += value.byteLength;
2214
+ chunks.push(value);
2215
+ if (received >= maxBytes) {
2216
+ truncated = true;
2217
+ await reader.cancel();
2218
+ break;
2219
+ }
2220
+ }
2221
+ }
2222
+ const merged = new Uint8Array(received);
2223
+ let offset = 0;
2224
+ for (const chunk of chunks) {
2225
+ merged.set(chunk, offset);
2226
+ offset += chunk.byteLength;
2227
+ }
2228
+ const sliced = truncated ? merged.subarray(0, maxBytes) : merged;
2229
+ return {
2230
+ body: new TextDecoder().decode(sliced),
2231
+ truncated
2232
+ };
2233
+ }
2234
+ /**
2235
+ * Render the fetched HTML into the configured `extract` mode. `"html"`
2236
+ * returns the raw body untouched; `"text"` and `"markdown"` run
2237
+ * `@mozilla/readability` over a `jsdom` document, throwing a curated
2238
+ * {@link WebToolError} when those optional peers are absent. `"markdown"`
2239
+ * applies a light heading/paragraph conversion over the extracted text.
2240
+ */
2241
+ async function render(html, url, extract) {
2242
+ if (extract === "html") return html;
2243
+ await loadReadability();
2244
+ if (!isReadabilityAvailable) throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: "missing-peer" });
2245
+ const dom = new JsdomSdk.JSDOM(html, { url });
2246
+ const article = new ReadabilitySdk.Readability(dom.window.document).parse();
2247
+ if (extract === "markdown") return article?.content ? htmlToMarkdown(article.content) : article?.textContent ?? "";
2248
+ return article?.textContent ?? "";
2249
+ }
2250
+ /**
2251
+ * Minimal HTML→Markdown reduction for readability's extracted article
2252
+ * HTML — headings become `#` prefixes, paragraphs/line-breaks become
2253
+ * blank-line separated blocks, and remaining tags are stripped. This is a
2254
+ * pragmatic conversion, not a full CommonMark serializer.
2255
+ */
2256
+ function htmlToMarkdown(html) {
2257
+ return html.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_match, level, text) => {
2258
+ return `\n\n${"#".repeat(Number(level))} ${stripTags(text).trim()}\n\n`;
2259
+ }).replace(/<\/(p|div|section|article|li)>/gi, "\n\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").trim();
2260
+ }
2261
+ /** Strip any remaining HTML tags from a fragment. */
2262
+ function stripTags(html) {
2263
+ return html.replace(/<[^>]+>/g, "");
2264
+ }
2265
+ /**
2266
+ * Build the agent-facing `fetch_url` tool — fetch a URL over the global
2267
+ * `fetch` (Node 18+) and hand the model back rendered `content`.
2268
+ *
2269
+ * Guardrails, applied in order before/around the network call:
2270
+ * - **`allowHosts`** — when set, a request to any host not in the list is
2271
+ * rejected before the fetch (an SSRF guardrail).
2272
+ * - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`
2273
+ * (default {@link DEFAULT_TIMEOUT_MS}).
2274
+ * - **`maxBytes`** — the response body is read up to this cap and the
2275
+ * result flags `truncated` when it was cut off (default
2276
+ * {@link DEFAULT_MAX_BYTES}).
2277
+ * - **`extract`** — `"html"` returns the raw body; `"text"` (default) and
2278
+ * `"markdown"` run `@mozilla/readability` over `jsdom`, lazily imported
2279
+ * so they stay OPTIONAL peers — a missing peer throws the curated
2280
+ * install string.
2281
+ *
2282
+ * **Errors flow as data.** Every guardrail rejection and network failure
2283
+ * throws a typed {@link WebToolError}; the `tool()` wrapper catches it and
2284
+ * surfaces it in the returned `{ error }` field — `invoke()` never throws
2285
+ * — so the agent reads the failure and self-corrects.
2286
+ *
2287
+ * @param options - Tool-name override, byte cap, timeout, extract mode,
2288
+ * and host allowlist.
2289
+ * @returns A `ToolContract<{ url }, FetchUrlResult>`.
2290
+ *
2291
+ * @example
2292
+ * const fetchUrl = fetchUrlTool({ extract: "text", allowHosts: ["docs.stripe.com"] });
2293
+ * const { data } = await fetchUrl.invoke({ url: "https://docs.stripe.com/api" });
2294
+ * console.log(data?.content, data?.truncated);
2295
+ */
2296
+ function fetchUrlTool(options) {
2297
+ const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
2298
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2299
+ const extract = options?.extract ?? DEFAULT_EXTRACT;
2300
+ const allowHosts = options?.allowHosts;
2301
+ if (extract !== "html") loadReadability();
2302
+ return (0, _warlock_js_ai.tool)({
2303
+ name: options?.name ?? DEFAULT_NAME$1,
2304
+ description: "Fetch a web page or document by URL and return its main content as text (readability-extracted), raw HTML, or Markdown. Use to read a specific page you already have the URL for.",
2305
+ input: inputSchema$1,
2306
+ async execute(input) {
2307
+ const url = parseUrl(input.url);
2308
+ assertHostAllowed(url, allowHosts);
2309
+ let response;
2310
+ try {
2311
+ response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2312
+ } catch (cause) {
2313
+ throw new WebToolError(`fetch_url request failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
2314
+ type: "request-failed",
2315
+ cause
2316
+ });
2317
+ }
2318
+ const { body, truncated } = await readBody(response, maxBytes);
2319
+ const content = await render(body, response.url || url.toString(), extract);
2320
+ return {
2321
+ url: response.url || url.toString(),
2322
+ status: response.status,
2323
+ content,
2324
+ truncated
2325
+ };
2326
+ }
2327
+ });
2328
+ }
2329
+
2330
+ //#endregion
2331
+ //#region ../@warlock.js/ai-tools/src/web/web-search.ts
2332
+ /** Default tool name exposed to the LLM. */
2333
+ const DEFAULT_NAME = "web_search";
2334
+ /** Default per-call result cap when the constructor does not set one. */
2335
+ const DEFAULT_MAX_RESULTS = 5;
2336
+ /**
2337
+ * Per-provider configuration — the environment variable consulted when no
2338
+ * `apiKey` is passed, plus the human-readable provider label used in
2339
+ * error messages.
2340
+ */
2341
+ const PROVIDER_ENV = {
2342
+ tavily: "TAVILY_API_KEY",
2343
+ brave: "BRAVE_API_KEY",
2344
+ serpapi: "SERPAPI_API_KEY"
2345
+ };
2346
+ /** Input schema: `{ query, maxResults? }`. */
2347
+ const inputSchema = objectSchema({
2348
+ query: stringField(),
2349
+ maxResults: optionalNumberField()
2350
+ });
2351
+ /**
2352
+ * Resolve the API key from explicit options or the provider's environment
2353
+ * variable, throwing a typed {@link WebToolError} when neither is present.
2354
+ */
2355
+ function resolveApiKey(provider, apiKey) {
2356
+ const key = apiKey ?? process.env[PROVIDER_ENV[provider]];
2357
+ if (!key) throw new WebToolError(`web_search requires an API key for the "${provider}" provider. Pass { apiKey } or set the ${PROVIDER_ENV[provider]} environment variable.`, { type: "missing-key" });
2358
+ return key;
2359
+ }
2360
+ /**
2361
+ * Clamp the model-requested result count into `[1, max]`. An omitted /
2362
+ * non-positive request falls back to the configured default.
2363
+ */
2364
+ function clampResults(requested, max) {
2365
+ if (requested === void 0 || requested < 1) return Math.min(DEFAULT_MAX_RESULTS, max);
2366
+ return Math.min(Math.max(1, Math.floor(requested)), max);
2367
+ }
2368
+ /**
2369
+ * Issue the provider HTTP call and return its parsed JSON, mapping a
2370
+ * non-OK status or a network failure to a typed {@link WebToolError}.
2371
+ */
2372
+ async function fetchJson(url, init, provider) {
2373
+ let response;
2374
+ try {
2375
+ response = await fetch(url, init);
2376
+ } catch (cause) {
2377
+ throw new WebToolError(`web_search request to the "${provider}" provider failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
2378
+ type: "request-failed",
2379
+ cause
2380
+ });
2381
+ }
2382
+ if (!response.ok) throw new WebToolError(`web_search "${provider}" provider returned HTTP ${response.status}.`, {
2383
+ type: "request-failed",
2384
+ context: { status: response.status }
2385
+ });
2386
+ return response.json();
2387
+ }
2388
+ /** Drive Tavily's `/search` HTTP API and normalize its hits. */
2389
+ async function searchTavily(query, maxResults, apiKey) {
2390
+ return ((await fetchJson("https://api.tavily.com/search", {
2391
+ method: "POST",
2392
+ headers: { "content-type": "application/json" },
2393
+ body: JSON.stringify({
2394
+ api_key: apiKey,
2395
+ query,
2396
+ max_results: maxResults
2397
+ })
2398
+ }, "tavily")).results ?? []).slice(0, maxResults).map((hit) => ({
2399
+ title: hit.title ?? "",
2400
+ url: hit.url ?? "",
2401
+ snippet: hit.content ?? "",
2402
+ score: hit.score
2403
+ }));
2404
+ }
2405
+ /** Drive Brave's web-search HTTP API and normalize its hits. */
2406
+ async function searchBrave(query, maxResults, apiKey) {
2407
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
2408
+ url.searchParams.set("q", query);
2409
+ url.searchParams.set("count", String(maxResults));
2410
+ return ((await fetchJson(url.toString(), {
2411
+ method: "GET",
2412
+ headers: {
2413
+ accept: "application/json",
2414
+ "x-subscription-token": apiKey
2415
+ }
2416
+ }, "brave")).web?.results ?? []).slice(0, maxResults).map((hit) => ({
2417
+ title: hit.title ?? "",
2418
+ url: hit.url ?? "",
2419
+ snippet: hit.description ?? ""
2420
+ }));
2421
+ }
2422
+ /** Drive SerpAPI's `search.json` HTTP API and normalize its hits. */
2423
+ async function searchSerpApi(query, maxResults, apiKey) {
2424
+ const url = new URL("https://serpapi.com/search.json");
2425
+ url.searchParams.set("q", query);
2426
+ url.searchParams.set("num", String(maxResults));
2427
+ url.searchParams.set("api_key", apiKey);
2428
+ return ((await fetchJson(url.toString(), { method: "GET" }, "serpapi")).organic_results ?? []).slice(0, maxResults).map((hit) => ({
2429
+ title: hit.title ?? "",
2430
+ url: hit.link ?? "",
2431
+ snippet: hit.snippet ?? ""
2432
+ }));
2433
+ }
2434
+ /**
2435
+ * Build the agent-facing `web_search` tool over a {@link SearchProvider}.
2436
+ *
2437
+ * The provider's HTTP API is called directly through the global `fetch`
2438
+ * (Node 18+) — Tavily via `POST /search` (LLM-ready snippets + relevance
2439
+ * scores), Brave and SerpAPI via their `GET` endpoints. The API key is
2440
+ * taken from `options.apiKey`, falling back to the provider's environment
2441
+ * variable (`TAVILY_API_KEY` / `BRAVE_API_KEY` / `SERPAPI_API_KEY`). No
2442
+ * provider SDK is required for the HTTP path; `@tavily/core` remains an
2443
+ * optional peer for callers who prefer it, but this factory never forces
2444
+ * it to be installed.
2445
+ *
2446
+ * The model passes `{ query, maxResults? }`; `maxResults` is clamped into
2447
+ * `[1, options.maxResults]` (default {@link DEFAULT_MAX_RESULTS}).
2448
+ *
2449
+ * **Errors flow as data.** A missing key, a non-OK provider status, or a
2450
+ * network failure throws a typed {@link WebToolError}; the `tool()`
2451
+ * wrapper catches it and surfaces it in the returned `{ error }` field —
2452
+ * `invoke()` never throws — so the agent can read the failure and
2453
+ * self-correct.
2454
+ *
2455
+ * @param options - Provider selection, API key, result cap, and an
2456
+ * optional tool-name override.
2457
+ * @returns A `ToolContract<{ query; maxResults? }, WebSearchResult>`.
2458
+ *
2459
+ * @example
2460
+ * const search = webSearchTool({ provider: "tavily" });
2461
+ * const { data } = await search.invoke({ query: "warlock.js ai tools" });
2462
+ * for (const hit of data?.results ?? []) console.log(hit.title, hit.url);
2463
+ */
2464
+ function webSearchTool(options) {
2465
+ const provider = options.provider;
2466
+ const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
2467
+ return (0, _warlock_js_ai.tool)({
2468
+ name: options.name ?? DEFAULT_NAME,
2469
+ description: "Search the web for current information and return a ranked list of results (title, URL, and a short snippet). Use for facts that may have changed since training, or anything you are unsure about.",
2470
+ input: inputSchema,
2471
+ async execute(input) {
2472
+ const apiKey = resolveApiKey(provider, options.apiKey);
2473
+ const limit = clampResults(input.maxResults, maxResults);
2474
+ let results;
2475
+ switch (provider) {
2476
+ case "tavily":
2477
+ results = await searchTavily(input.query, limit, apiKey);
2478
+ break;
2479
+ case "brave":
2480
+ results = await searchBrave(input.query, limit, apiKey);
2481
+ break;
2482
+ case "serpapi":
2483
+ results = await searchSerpApi(input.query, limit, apiKey);
2484
+ break;
2485
+ }
2486
+ return { results };
2487
+ }
2488
+ });
2489
+ }
2490
+
2491
+ //#endregion
2492
+ //#region ../@warlock.js/ai-tools/src/register.ts
2493
+ _warlock_js_ai.ai.tools = {
2494
+ webSearch: webSearchTool,
2495
+ fetchUrl: fetchUrlTool,
2496
+ http: httpRequestTool,
2497
+ calculator: calculatorTool,
2498
+ dateTime: (options) => dateTimeTool(options)
2499
+ };
2500
+ _warlock_js_ai.ai.mcp = mcp;
2501
+
2502
+ //#endregion
2503
+ exports.CalculatorError = CalculatorError;
2504
+ exports.DateTimeError = DateTimeError;
2505
+ exports.HttpPolicyError = HttpPolicyError;
2506
+ exports.McpTransportError = McpTransportError;
2507
+ exports.WebToolError = WebToolError;
2508
+ exports.calculatorTool = calculatorTool;
2509
+ exports.createJsonRpcClient = createJsonRpcClient;
2510
+ exports.createServeHandler = createServeHandler;
2511
+ exports.createTransport = createTransport;
2512
+ exports.dateTimeTool = dateTimeTool;
2513
+ exports.fetchUrlTool = fetchUrlTool;
2514
+ exports.httpRequestTool = httpRequestTool;
2515
+ exports.jsonSchemaToStandard = jsonSchemaToStandard;
2516
+ exports.mcp = mcp;
2517
+ exports.serve = serve;
2518
+ exports.webSearchTool = webSearchTool;
2519
+ //# sourceMappingURL=index.cjs.map