@warlock.js/ai-tools 5.31.0 → 5.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/cjs/index.cjs +327 -60
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/http.type.d.mts +4 -5
- package/esm/contracts/http.type.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +2 -1
- package/esm/contracts/mcp-serve.type.d.mts +97 -37
- package/esm/contracts/mcp-serve.type.d.mts.map +1 -1
- package/esm/contracts/mcp.type.d.mts +29 -19
- package/esm/contracts/mcp.type.d.mts.map +1 -1
- package/esm/contracts/utility.type.d.mts +7 -8
- package/esm/contracts/utility.type.d.mts.map +1 -1
- package/esm/contracts/web.type.d.mts +9 -10
- package/esm/contracts/web.type.d.mts.map +1 -1
- package/esm/errors.d.mts +31 -26
- package/esm/errors.d.mts.map +1 -1
- package/esm/http/http-request.d.mts +2 -3
- package/esm/http/http-request.d.mts.map +1 -1
- package/esm/http/http-request.mjs +7 -5
- package/esm/http/http-request.mjs.map +1 -1
- package/esm/index.d.mts +6 -2
- package/esm/index.mjs +2 -1
- package/esm/mcp/client.mjs +2 -1
- package/esm/mcp/client.mjs.map +1 -1
- package/esm/mcp/index.d.mts +5 -4
- package/esm/mcp/index.d.mts.map +1 -1
- package/esm/mcp/index.mjs +2 -1
- package/esm/mcp/index.mjs.map +1 -1
- package/esm/mcp/json-schema-to-standard.d.mts +1 -3
- package/esm/mcp/json-schema-to-standard.d.mts.map +1 -1
- package/esm/mcp/protocol.d.mts +1 -2
- package/esm/mcp/protocol.d.mts.map +1 -1
- package/esm/mcp/result.d.mts +17 -0
- package/esm/mcp/result.d.mts.map +1 -0
- package/esm/mcp/result.mjs +85 -0
- package/esm/mcp/result.mjs.map +1 -0
- package/esm/mcp/serve-http.mjs +64 -25
- package/esm/mcp/serve-http.mjs.map +1 -1
- package/esm/mcp/serve-requests.mjs +62 -0
- package/esm/mcp/serve-requests.mjs.map +1 -0
- package/esm/mcp/serve.d.mts +5 -5
- package/esm/mcp/serve.d.mts.map +1 -1
- package/esm/mcp/serve.mjs +97 -18
- package/esm/mcp/serve.mjs.map +1 -1
- package/esm/mcp/transport.d.mts +4 -5
- package/esm/mcp/transport.d.mts.map +1 -1
- package/esm/mcp/transport.mjs +12 -6
- package/esm/mcp/transport.mjs.map +1 -1
- package/esm/mcp/transport.type.d.mts +2 -2
- package/esm/mcp/transport.type.d.mts.map +1 -1
- package/esm/register.d.mts +3 -4
- package/esm/register.d.mts.map +1 -1
- package/esm/utility/calculator.d.mts +1 -3
- package/esm/utility/calculator.d.mts.map +1 -1
- package/esm/utility/date-time.d.mts +3 -5
- package/esm/utility/date-time.d.mts.map +1 -1
- package/esm/utility/date-time.mjs +2 -1
- package/esm/utility/date-time.mjs.map +1 -1
- package/esm/utility/index.d.mts +2 -1
- package/esm/utility/index.mjs +1 -1
- package/esm/web/fetch-url.d.mts +2 -3
- package/esm/web/fetch-url.d.mts.map +1 -1
- package/esm/web/fetch-url.mjs +2 -1
- package/esm/web/fetch-url.mjs.map +1 -1
- package/esm/web/index.d.mts +2 -1
- package/esm/web/index.mjs +1 -1
- package/esm/web/web-search.d.mts +2 -3
- package/esm/web/web-search.d.mts.map +1 -1
- package/esm/web/web-search.mjs +3 -4
- package/esm/web/web-search.mjs.map +1 -1
- package/llms-full.txt +122 -3
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/skills/connect-mcp-server/SKILL.md +15 -0
- package/skills/expose-as-mcp-server/SKILL.md +107 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { JsonRpcRequest, JsonRpcResponse } from "../contracts/mcp.type.mjs";
|
|
2
|
+
import "../contracts/index.mjs";
|
|
2
3
|
//#region ../../packages/ai-tools/src/mcp/transport.type.d.ts
|
|
3
4
|
/**
|
|
4
5
|
* Internal contract every concrete MCP transport (stdio / Streamable HTTP)
|
|
@@ -14,7 +15,7 @@ import { JsonRpcRequest, JsonRpcResponse } from "../contracts/mcp.type.mjs";
|
|
|
14
15
|
* **`type`, never `kind`.** This package's discriminators are all `type`;
|
|
15
16
|
* any inbound MCP `kind` is translated at the client boundary.
|
|
16
17
|
*/
|
|
17
|
-
interface McpTransportClient {
|
|
18
|
+
export interface McpTransportClient {
|
|
18
19
|
/**
|
|
19
20
|
* Send a JSON-RPC request and resolve with its matching response.
|
|
20
21
|
*
|
|
@@ -47,5 +48,4 @@ interface McpTransportClient {
|
|
|
47
48
|
close(): Promise<void>;
|
|
48
49
|
}
|
|
49
50
|
//#endregion
|
|
50
|
-
export { McpTransportClient };
|
|
51
51
|
//# sourceMappingURL=transport.type.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transport.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/mcp/transport.type.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"transport.type.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/mcp/transport.type.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAgBiB;;;;;;;;;;;;;EAaf,QAAQ,mBACN,SAAS,gBACT;IAAY,SAAS;IAAa;MACjC,QAAQ,gBAAgB;;;;;;;;EAS3B,OAAO,gBAAgB,mBAAmB;;;;;;EAO1C,SAAS"}
|
package/esm/register.d.mts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { FetchUrlInput, FetchUrlOptions, FetchUrlResult, WebSearchInput, WebSearchOptions, WebSearchResult } from "./contracts/web.type.mjs";
|
|
2
2
|
import { HttpRequestInput, HttpRequestOptions, HttpRequestResult } from "./contracts/http.type.mjs";
|
|
3
3
|
import { CalculatorInput, CalculatorOptions, CalculatorResult, DateTimeInput, DateTimeOptions, DateTimeResult } from "./contracts/utility.type.mjs";
|
|
4
|
+
import "./contracts/index.mjs";
|
|
4
5
|
import { McpFactory } from "./mcp/index.mjs";
|
|
5
6
|
import { ToolContract, measureTools } from "@warlock.js/ai";
|
|
6
|
-
|
|
7
7
|
//#region ../../packages/ai-tools/src/register.d.ts
|
|
8
8
|
/**
|
|
9
9
|
* The `ai.tools.*` namespace — the five ready-made agent tools this
|
|
@@ -13,7 +13,7 @@ import { ToolContract, measureTools } from "@warlock.js/ai";
|
|
|
13
13
|
* across the tool sub-barrels, mirroring the `ai.workspace` augmentation in
|
|
14
14
|
* `@warlock.js/ai-workspace`.
|
|
15
15
|
*/
|
|
16
|
-
interface AiToolsNamespace {
|
|
16
|
+
export interface AiToolsNamespace {
|
|
17
17
|
/** Search the web via a chosen provider; returns ranked LLM-ready hits. */
|
|
18
18
|
webSearch(options: WebSearchOptions): ToolContract<WebSearchInput, WebSearchResult>;
|
|
19
19
|
/** Fetch a URL (host-allowlisted, byte-capped) and return its content. */
|
|
@@ -60,7 +60,6 @@ declare module "@warlock.js/ai" {
|
|
|
60
60
|
*/
|
|
61
61
|
mcp: McpFactory;
|
|
62
62
|
}
|
|
63
|
-
}
|
|
63
|
+
}
|
|
64
64
|
//#endregion
|
|
65
|
-
export { AiToolsNamespace };
|
|
66
65
|
//# sourceMappingURL=register.d.mts.map
|
package/esm/register.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"register.d.mts","names":[],"sources":["../../../../../../../packages/ai-tools/src/register.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"register.d.mts","names":[],"sources":["../../../../../../../packages/ai-tools/src/register.ts"],"mappings":";;;;;;;;;;;;;;;iBAiCiB;;EAEf,UAAU,SAAS,mBAAmB,aAAa,gBAAgB;;EAEnE,SAAS,UAAU,kBAAkB,aAAa,eAAe;;EAEjE,KAAK,UAAU,qBAAqB,aAAa,kBAAkB;;EAEnE,WAAW,UAAU,oBAAoB,aAAa,iBAAiB;;EAEvE,SAAS,UAAU,kBAAkB,aAAa,eAAe;;;;;;;;;;;EAWjE,gBAAgB;;;;;;;;;;;YAYN;;;;;;IAMR,OAAO;;;;;;;IAOP,KAAK"}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { CalculatorInput, CalculatorOptions, CalculatorResult } from "../contracts/utility.type.mjs";
|
|
2
2
|
import { ToolContract } from "@warlock.js/ai";
|
|
3
|
-
|
|
4
3
|
//#region ../../packages/ai-tools/src/utility/calculator.d.ts
|
|
5
4
|
/**
|
|
6
5
|
* Build the `calculator` tool — a SAFE arithmetic evaluator the agent can
|
|
@@ -29,7 +28,6 @@ import { ToolContract } from "@warlock.js/ai";
|
|
|
29
28
|
* const { data } = await calc.invoke({ expression: "(3 + 4) * 2" });
|
|
30
29
|
* console.log(data?.result); // 14
|
|
31
30
|
*/
|
|
32
|
-
declare function calculatorTool(options?: CalculatorOptions): ToolContract<CalculatorInput, CalculatorResult>;
|
|
31
|
+
export declare function calculatorTool(options?: CalculatorOptions): ToolContract<CalculatorInput, CalculatorResult>;
|
|
33
32
|
//#endregion
|
|
34
|
-
export { calculatorTool };
|
|
35
33
|
//# sourceMappingURL=calculator.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"calculator.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/utility/calculator.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"calculator.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/utility/calculator.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAoYgB,eACd,UAAU,oBACT,aAAa,iBAAiB"}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { DateTimeInput, DateTimeOptions, DateTimeResult } from "../contracts/utility.type.mjs";
|
|
2
2
|
import { ToolContract } from "@warlock.js/ai";
|
|
3
|
-
|
|
4
3
|
//#region ../../packages/ai-tools/src/utility/date-time.d.ts
|
|
5
4
|
/**
|
|
6
5
|
* A clock seam so tests are deterministic. Defaults to the real
|
|
7
6
|
* {@link Date}; a test injects a fixed instant. Returns milliseconds
|
|
8
7
|
* since the Unix epoch — the same contract as `Date.now()`.
|
|
9
8
|
*/
|
|
10
|
-
type Clock = () => number;
|
|
9
|
+
export type Clock = () => number;
|
|
11
10
|
/** Construction dependencies for {@link dateTimeTool}, all optional. */
|
|
12
|
-
interface DateTimeToolDeps {
|
|
11
|
+
export interface DateTimeToolDeps {
|
|
13
12
|
/**
|
|
14
13
|
* The clock used by the `now` op. Defaults to `Date.now`. Injected in
|
|
15
14
|
* tests for deterministic output.
|
|
@@ -51,7 +50,6 @@ interface DateTimeToolDeps {
|
|
|
51
50
|
* const { data } = await dt.invoke({ op: "now" });
|
|
52
51
|
* console.log(data?.value); // "2026-06-22T00:00:00.000Z"
|
|
53
52
|
*/
|
|
54
|
-
declare function dateTimeTool(options?: DateTimeOptions, deps?: DateTimeToolDeps): ToolContract<DateTimeInput, DateTimeResult>;
|
|
53
|
+
export declare function dateTimeTool(options?: DateTimeOptions, deps?: DateTimeToolDeps): ToolContract<DateTimeInput, DateTimeResult>;
|
|
55
54
|
//#endregion
|
|
56
|
-
export { Clock, DateTimeToolDeps, dateTimeTool };
|
|
57
55
|
//# sourceMappingURL=date-time.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"date-time.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/utility/date-time.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"date-time.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/utility/date-time.ts"],"mappings":";;;;;;;;YAqBY;;iBAGK;;;;;EAKf,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAgLM,aACd,UAAU,iBACV,OAAO,mBACN,aAAa,eAAe"}
|
|
@@ -55,7 +55,8 @@ const UNIT_ALIASES = {
|
|
|
55
55
|
};
|
|
56
56
|
/** Resolve a (possibly aliased / singular) unit token to its ms length. */
|
|
57
57
|
function unitToMs(unit) {
|
|
58
|
-
const
|
|
58
|
+
const canonical = UNIT_ALIASES[unit] ?? unit;
|
|
59
|
+
const ms = UNIT_MS[canonical];
|
|
59
60
|
if (ms === void 0) throw new DateTimeError(`Unknown unit "${unit}". Supported units: ${Object.keys(UNIT_MS).join(", ")}.`, { type: "invalid-unit" });
|
|
60
61
|
return ms;
|
|
61
62
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"date-time.mjs","names":[],"sources":["../../../../../../../../packages/ai-tools/src/utility/date-time.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport { DateTimeError } from \"../errors\";\nimport { literalField, objectSchema, optionalNumberField, optionalStringField } from \"./schema\";\nimport type {\n DateTimeInput,\n DateTimeOp,\n DateTimeOptions,\n DateTimeResult,\n} from \"../contracts/utility.type\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"date_time\";\n\n/** The closed set of operations the tool implements. */\nconst OPS: readonly DateTimeOp[] = [\"now\", \"add\", \"diff\", \"format\"];\n\n/**\n * A clock seam so tests are deterministic. Defaults to the real\n * {@link Date}; a test injects a fixed instant. Returns milliseconds\n * since the Unix epoch — the same contract as `Date.now()`.\n */\nexport type Clock = () => number;\n\n/** Construction dependencies for {@link dateTimeTool}, all optional. */\nexport interface DateTimeToolDeps {\n /**\n * The clock used by the `now` op. Defaults to `Date.now`. Injected in\n * tests for deterministic output.\n */\n clock?: Clock;\n}\n\n/** Input schema for the `date_time` tool. */\nconst inputSchema = objectSchema<DateTimeInput>({\n op: literalField<DateTimeOp>(OPS),\n iso: optionalStringField(),\n from: optionalStringField(),\n to: optionalStringField(),\n amount: optionalNumberField(),\n unit: optionalStringField(),\n timeZone: optionalStringField(),\n format: optionalStringField(),\n});\n\n/**\n * The time units `add` and `diff` understand, each as its length in\n * milliseconds. Calendar-unaware on purpose: a \"month\" / \"year\" has no\n * fixed millisecond length, so they are deliberately excluded — adding\n * `30 days` is unambiguous in a way adding `1 month` is not.\n */\nconst UNIT_MS: Record<string, number> = {\n milliseconds: 1,\n seconds: 1_000,\n minutes: 60_000,\n hours: 3_600_000,\n days: 86_400_000,\n weeks: 604_800_000,\n};\n\n/** Singular aliases mapped to their canonical plural unit key. */\nconst UNIT_ALIASES: Record<string, string> = {\n millisecond: \"milliseconds\",\n ms: \"milliseconds\",\n second: \"seconds\",\n sec: \"seconds\",\n s: \"seconds\",\n minute: \"minutes\",\n min: \"minutes\",\n m: \"minutes\",\n hour: \"hours\",\n hr: \"hours\",\n h: \"hours\",\n day: \"days\",\n d: \"days\",\n week: \"weeks\",\n w: \"weeks\",\n};\n\n/** Resolve a (possibly aliased / singular) unit token to its ms length. */\nfunction unitToMs(unit: string): number {\n const canonical = UNIT_ALIASES[unit] ?? unit;\n const ms = UNIT_MS[canonical];\n\n if (ms === undefined) {\n throw new DateTimeError(\n `Unknown unit \"${unit}\". Supported units: ${Object.keys(UNIT_MS).join(\", \")}.`,\n { type: \"invalid-unit\" },\n );\n }\n\n return ms;\n}\n\n/**\n * Parse an ISO-8601 instant into a {@link Date}, or throw a typed\n * {@link DateTimeError} when the string is missing or unparseable.\n *\n * @param iso - The ISO string from the model (may be undefined).\n * @param field - The input field name, for the error message.\n */\nfunction parseIso(iso: string | undefined, field: string): Date {\n if (iso === undefined) {\n throw new DateTimeError(`\"${field}\" is required for this operation.`, {\n type: \"invalid-input\",\n });\n }\n\n const date = new Date(iso);\n\n if (Number.isNaN(date.getTime())) {\n throw new DateTimeError(`\"${field}\" is not a valid ISO-8601 instant: \"${iso}\".`, {\n type: \"invalid-input\",\n });\n }\n\n return date;\n}\n\n/**\n * Render a {@link Date} in a target time zone using `Intl`. The\n * `\"iso\"` format (the default) returns the instant's UTC ISO string;\n * any other `format` value is treated as an `Intl.DateTimeFormat`\n * locale-style rendering in the given `timeZone`.\n *\n * @throws DateTimeError `type: \"invalid-time-zone\"` when `timeZone` is\n * not a recognized IANA zone.\n */\nfunction render(date: Date, format: string | undefined, timeZone: string | undefined): string {\n if (format === undefined || format === \"iso\") {\n // `timeZone` is irrelevant to a UTC ISO string, but validate it when\n // supplied so a bad zone is reported rather than silently ignored.\n if (timeZone !== undefined) {\n assertTimeZone(timeZone);\n }\n\n return date.toISOString();\n }\n\n try {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n dateStyle: format === \"date\" ? \"medium\" : undefined,\n timeStyle: format === \"time\" ? \"medium\" : undefined,\n ...(format === \"datetime\" ? { dateStyle: \"medium\", timeStyle: \"medium\" } : {}),\n }).format(date);\n } catch (error) {\n throw new DateTimeError(\n `Could not render with format \"${format}\"${timeZone ? ` in time zone \"${timeZone}\"` : \"\"}.`,\n { type: \"invalid-time-zone\", cause: error },\n );\n }\n}\n\n/**\n * Validate an IANA time zone by attempting to construct a formatter for\n * it; an unrecognized zone makes `Intl` throw a `RangeError`.\n *\n * @throws DateTimeError `type: \"invalid-time-zone\"` for an unknown zone.\n */\nfunction assertTimeZone(timeZone: string): void {\n try {\n new Intl.DateTimeFormat(\"en-US\", { timeZone });\n } catch (error) {\n throw new DateTimeError(`Unknown time zone \"${timeZone}\".`, {\n type: \"invalid-time-zone\",\n cause: error,\n });\n }\n}\n\n/**\n * Build the `date_time` tool — a small clock/calendar utility the agent\n * can call to read the current instant, shift an instant, measure the\n * gap between two instants, or render one. The `op` discriminator selects\n * the operation:\n *\n * - **`now`** — the current instant, rendered per `format` / `timeZone`\n * (defaults to a UTC ISO string). Reads the injectable {@link Clock}.\n * - **`add`** — `iso` shifted by `amount` of `unit` (e.g.\n * `+3 days`); a negative `amount` shifts backward.\n * - **`diff`** — the signed difference `to − iso`, expressed in `unit`.\n * - **`format`** — `iso` rendered per `format` / `timeZone`.\n *\n * Units are millisecond-based (`milliseconds`…`weeks`, plus common\n * aliases); calendar-relative `month`/`year` are intentionally\n * unsupported because they have no fixed length.\n *\n * **Deterministic in tests.** The clock backing `now` is injectable via\n * `deps.clock`; production defaults to `Date.now`.\n *\n * **Errors flow as data.** A missing/invalid field, an unknown unit, or\n * an unrecognized time zone throws a {@link DateTimeError} inside the\n * handler; `tool()` surfaces it in `{ error }` so the agent self-corrects.\n *\n * @param options - Optional overrides; `name` renames the tool,\n * `defaultTimeZone` applies when a call omits `timeZone`.\n * @param deps - Injectable dependencies (the {@link Clock}); defaults to real time.\n * @returns A `ToolContract<DateTimeInput, { value }>` ready for `tools: []`.\n *\n * @example\n * const clock = () => Date.parse(\"2026-06-22T00:00:00Z\");\n * const dt = dateTimeTool({}, { clock });\n * const { data } = await dt.invoke({ op: \"now\" });\n * console.log(data?.value); // \"2026-06-22T00:00:00.000Z\"\n */\nexport function dateTimeTool(\n options?: DateTimeOptions,\n deps?: DateTimeToolDeps,\n): ToolContract<DateTimeInput, DateTimeResult> {\n const clock: Clock = deps?.clock ?? Date.now;\n const defaultTimeZone = options?.defaultTimeZone;\n\n return tool<DateTimeInput, DateTimeResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n 'Work with dates and times. Set op to: \"now\" (current instant), ' +\n '\"add\" (shift iso by amount of unit), \"diff\" (signed to − from in unit; ' +\n \"pass the start instant as iso or from), \" +\n 'or \"format\" (render iso). Units are milliseconds, seconds, minutes, ' +\n \"hours, days, or weeks (month/year are not supported). Pass instants as \" +\n 'ISO-8601 strings; set format to \"iso\" (default), \"date\", \"time\", or ' +\n '\"datetime\", and timeZone as an IANA zone like \"Africa/Cairo\".',\n input: inputSchema,\n async execute(input) {\n const timeZone = input.timeZone ?? defaultTimeZone;\n\n switch (input.op) {\n case \"now\": {\n const date = new Date(clock());\n\n return { value: render(date, input.format, timeZone) };\n }\n\n case \"format\": {\n const date = parseIso(input.iso, \"iso\");\n\n return { value: render(date, input.format, timeZone) };\n }\n\n case \"add\": {\n const date = parseIso(input.iso, \"iso\");\n\n if (input.amount === undefined) {\n throw new DateTimeError('\"amount\" is required for the \"add\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n if (input.unit === undefined) {\n throw new DateTimeError('\"unit\" is required for the \"add\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n const shifted = new Date(date.getTime() + input.amount * unitToMs(input.unit));\n\n return { value: render(shifted, input.format, timeZone) };\n }\n\n case \"diff\": {\n // Accept `from` as an alias for `iso` — models naturally pass\n // `from` / `to` for a difference. `iso` wins when both are set.\n const from = parseIso(input.iso ?? input.from, \"iso (or from)\");\n const to = parseIso(input.to, \"to\");\n\n if (input.unit === undefined) {\n throw new DateTimeError('\"unit\" is required for the \"diff\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n const diff = (to.getTime() - from.getTime()) / unitToMs(input.unit);\n\n return { value: String(diff) };\n }\n\n default: {\n // Exhaustiveness guard — the schema's literal union should make\n // this unreachable, but a future op added to the union without a\n // case here surfaces as typed data rather than silent fallthrough.\n const unreachable: never = input.op;\n\n throw new DateTimeError(`Unsupported operation \"${String(unreachable)}\".`, {\n type: \"unsupported-op\",\n });\n }\n }\n },\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"date-time.mjs","names":[],"sources":["../../../../../../../../packages/ai-tools/src/utility/date-time.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport { DateTimeError } from \"../errors\";\nimport { literalField, objectSchema, optionalNumberField, optionalStringField } from \"./schema\";\nimport type {\n DateTimeInput,\n DateTimeOp,\n DateTimeOptions,\n DateTimeResult,\n} from \"../contracts/utility.type\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"date_time\";\n\n/** The closed set of operations the tool implements. */\nconst OPS: readonly DateTimeOp[] = [\"now\", \"add\", \"diff\", \"format\"];\n\n/**\n * A clock seam so tests are deterministic. Defaults to the real\n * {@link Date}; a test injects a fixed instant. Returns milliseconds\n * since the Unix epoch — the same contract as `Date.now()`.\n */\nexport type Clock = () => number;\n\n/** Construction dependencies for {@link dateTimeTool}, all optional. */\nexport interface DateTimeToolDeps {\n /**\n * The clock used by the `now` op. Defaults to `Date.now`. Injected in\n * tests for deterministic output.\n */\n clock?: Clock;\n}\n\n/** Input schema for the `date_time` tool. */\nconst inputSchema = objectSchema<DateTimeInput>({\n op: literalField<DateTimeOp>(OPS),\n iso: optionalStringField(),\n from: optionalStringField(),\n to: optionalStringField(),\n amount: optionalNumberField(),\n unit: optionalStringField(),\n timeZone: optionalStringField(),\n format: optionalStringField(),\n});\n\n/**\n * The time units `add` and `diff` understand, each as its length in\n * milliseconds. Calendar-unaware on purpose: a \"month\" / \"year\" has no\n * fixed millisecond length, so they are deliberately excluded — adding\n * `30 days` is unambiguous in a way adding `1 month` is not.\n */\nconst UNIT_MS: Record<string, number> = {\n milliseconds: 1,\n seconds: 1_000,\n minutes: 60_000,\n hours: 3_600_000,\n days: 86_400_000,\n weeks: 604_800_000,\n};\n\n/** Singular aliases mapped to their canonical plural unit key. */\nconst UNIT_ALIASES: Record<string, string> = {\n millisecond: \"milliseconds\",\n ms: \"milliseconds\",\n second: \"seconds\",\n sec: \"seconds\",\n s: \"seconds\",\n minute: \"minutes\",\n min: \"minutes\",\n m: \"minutes\",\n hour: \"hours\",\n hr: \"hours\",\n h: \"hours\",\n day: \"days\",\n d: \"days\",\n week: \"weeks\",\n w: \"weeks\",\n};\n\n/** Resolve a (possibly aliased / singular) unit token to its ms length. */\nfunction unitToMs(unit: string): number {\n const canonical = UNIT_ALIASES[unit] ?? unit;\n const ms = UNIT_MS[canonical];\n\n if (ms === undefined) {\n throw new DateTimeError(\n `Unknown unit \"${unit}\". Supported units: ${Object.keys(UNIT_MS).join(\", \")}.`,\n { type: \"invalid-unit\" },\n );\n }\n\n return ms;\n}\n\n/**\n * Parse an ISO-8601 instant into a {@link Date}, or throw a typed\n * {@link DateTimeError} when the string is missing or unparseable.\n *\n * @param iso - The ISO string from the model (may be undefined).\n * @param field - The input field name, for the error message.\n */\nfunction parseIso(iso: string | undefined, field: string): Date {\n if (iso === undefined) {\n throw new DateTimeError(`\"${field}\" is required for this operation.`, {\n type: \"invalid-input\",\n });\n }\n\n const date = new Date(iso);\n\n if (Number.isNaN(date.getTime())) {\n throw new DateTimeError(`\"${field}\" is not a valid ISO-8601 instant: \"${iso}\".`, {\n type: \"invalid-input\",\n });\n }\n\n return date;\n}\n\n/**\n * Render a {@link Date} in a target time zone using `Intl`. The\n * `\"iso\"` format (the default) returns the instant's UTC ISO string;\n * any other `format` value is treated as an `Intl.DateTimeFormat`\n * locale-style rendering in the given `timeZone`.\n *\n * @throws DateTimeError `type: \"invalid-time-zone\"` when `timeZone` is\n * not a recognized IANA zone.\n */\nfunction render(date: Date, format: string | undefined, timeZone: string | undefined): string {\n if (format === undefined || format === \"iso\") {\n // `timeZone` is irrelevant to a UTC ISO string, but validate it when\n // supplied so a bad zone is reported rather than silently ignored.\n if (timeZone !== undefined) {\n assertTimeZone(timeZone);\n }\n\n return date.toISOString();\n }\n\n try {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n dateStyle: format === \"date\" ? \"medium\" : undefined,\n timeStyle: format === \"time\" ? \"medium\" : undefined,\n ...(format === \"datetime\" ? { dateStyle: \"medium\", timeStyle: \"medium\" } : {}),\n }).format(date);\n } catch (error) {\n throw new DateTimeError(\n `Could not render with format \"${format}\"${timeZone ? ` in time zone \"${timeZone}\"` : \"\"}.`,\n { type: \"invalid-time-zone\", cause: error },\n );\n }\n}\n\n/**\n * Validate an IANA time zone by attempting to construct a formatter for\n * it; an unrecognized zone makes `Intl` throw a `RangeError`.\n *\n * @throws DateTimeError `type: \"invalid-time-zone\"` for an unknown zone.\n */\nfunction assertTimeZone(timeZone: string): void {\n try {\n new Intl.DateTimeFormat(\"en-US\", { timeZone });\n } catch (error) {\n throw new DateTimeError(`Unknown time zone \"${timeZone}\".`, {\n type: \"invalid-time-zone\",\n cause: error,\n });\n }\n}\n\n/**\n * Build the `date_time` tool — a small clock/calendar utility the agent\n * can call to read the current instant, shift an instant, measure the\n * gap between two instants, or render one. The `op` discriminator selects\n * the operation:\n *\n * - **`now`** — the current instant, rendered per `format` / `timeZone`\n * (defaults to a UTC ISO string). Reads the injectable {@link Clock}.\n * - **`add`** — `iso` shifted by `amount` of `unit` (e.g.\n * `+3 days`); a negative `amount` shifts backward.\n * - **`diff`** — the signed difference `to − iso`, expressed in `unit`.\n * - **`format`** — `iso` rendered per `format` / `timeZone`.\n *\n * Units are millisecond-based (`milliseconds`…`weeks`, plus common\n * aliases); calendar-relative `month`/`year` are intentionally\n * unsupported because they have no fixed length.\n *\n * **Deterministic in tests.** The clock backing `now` is injectable via\n * `deps.clock`; production defaults to `Date.now`.\n *\n * **Errors flow as data.** A missing/invalid field, an unknown unit, or\n * an unrecognized time zone throws a {@link DateTimeError} inside the\n * handler; `tool()` surfaces it in `{ error }` so the agent self-corrects.\n *\n * @param options - Optional overrides; `name` renames the tool,\n * `defaultTimeZone` applies when a call omits `timeZone`.\n * @param deps - Injectable dependencies (the {@link Clock}); defaults to real time.\n * @returns A `ToolContract<DateTimeInput, { value }>` ready for `tools: []`.\n *\n * @example\n * const clock = () => Date.parse(\"2026-06-22T00:00:00Z\");\n * const dt = dateTimeTool({}, { clock });\n * const { data } = await dt.invoke({ op: \"now\" });\n * console.log(data?.value); // \"2026-06-22T00:00:00.000Z\"\n */\nexport function dateTimeTool(\n options?: DateTimeOptions,\n deps?: DateTimeToolDeps,\n): ToolContract<DateTimeInput, DateTimeResult> {\n const clock: Clock = deps?.clock ?? Date.now;\n const defaultTimeZone = options?.defaultTimeZone;\n\n return tool<DateTimeInput, DateTimeResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n 'Work with dates and times. Set op to: \"now\" (current instant), ' +\n '\"add\" (shift iso by amount of unit), \"diff\" (signed to − from in unit; ' +\n \"pass the start instant as iso or from), \" +\n 'or \"format\" (render iso). Units are milliseconds, seconds, minutes, ' +\n \"hours, days, or weeks (month/year are not supported). Pass instants as \" +\n 'ISO-8601 strings; set format to \"iso\" (default), \"date\", \"time\", or ' +\n '\"datetime\", and timeZone as an IANA zone like \"Africa/Cairo\".',\n input: inputSchema,\n async execute(input) {\n const timeZone = input.timeZone ?? defaultTimeZone;\n\n switch (input.op) {\n case \"now\": {\n const date = new Date(clock());\n\n return { value: render(date, input.format, timeZone) };\n }\n\n case \"format\": {\n const date = parseIso(input.iso, \"iso\");\n\n return { value: render(date, input.format, timeZone) };\n }\n\n case \"add\": {\n const date = parseIso(input.iso, \"iso\");\n\n if (input.amount === undefined) {\n throw new DateTimeError('\"amount\" is required for the \"add\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n if (input.unit === undefined) {\n throw new DateTimeError('\"unit\" is required for the \"add\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n const shifted = new Date(date.getTime() + input.amount * unitToMs(input.unit));\n\n return { value: render(shifted, input.format, timeZone) };\n }\n\n case \"diff\": {\n // Accept `from` as an alias for `iso` — models naturally pass\n // `from` / `to` for a difference. `iso` wins when both are set.\n const from = parseIso(input.iso ?? input.from, \"iso (or from)\");\n const to = parseIso(input.to, \"to\");\n\n if (input.unit === undefined) {\n throw new DateTimeError('\"unit\" is required for the \"diff\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n const diff = (to.getTime() - from.getTime()) / unitToMs(input.unit);\n\n return { value: String(diff) };\n }\n\n default: {\n // Exhaustiveness guard — the schema's literal union should make\n // this unreachable, but a future op added to the union without a\n // case here surfaces as typed data rather than silent fallthrough.\n const unreachable: never = input.op;\n\n throw new DateTimeError(`Unsupported operation \"${String(unreachable)}\".`, {\n type: \"unsupported-op\",\n });\n }\n }\n },\n });\n}\n"],"mappings":"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"}
|
package/esm/utility/index.d.mts
CHANGED
package/esm/utility/index.mjs
CHANGED
package/esm/web/fetch-url.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FetchUrlInput, FetchUrlOptions, FetchUrlResult } from "../contracts/web.type.mjs";
|
|
2
|
+
import "../contracts/index.mjs";
|
|
2
3
|
import { ToolContract } from "@warlock.js/ai";
|
|
3
|
-
|
|
4
4
|
//#region ../../packages/ai-tools/src/web/fetch-url.d.ts
|
|
5
5
|
/**
|
|
6
6
|
* Build the agent-facing `fetch_url` tool — fetch a URL over the global
|
|
@@ -41,7 +41,6 @@ import { ToolContract } from "@warlock.js/ai";
|
|
|
41
41
|
* const { data } = await fetchUrl.invoke({ url: "https://docs.stripe.com/api" });
|
|
42
42
|
* console.log(data?.content, data?.truncated);
|
|
43
43
|
*/
|
|
44
|
-
declare function fetchUrlTool(options?: FetchUrlOptions): ToolContract<FetchUrlInput, FetchUrlResult>;
|
|
44
|
+
export declare function fetchUrlTool(options?: FetchUrlOptions): ToolContract<FetchUrlInput, FetchUrlResult>;
|
|
45
45
|
//#endregion
|
|
46
|
-
export { fetchUrlTool };
|
|
47
46
|
//# sourceMappingURL=fetch-url.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-url.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/fetch-url.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"fetch-url.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/fetch-url.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA2SgB,aACd,UAAU,kBACT,aAAa,eAAe"}
|
package/esm/web/fetch-url.mjs
CHANGED
|
@@ -224,7 +224,8 @@ function fetchUrlTool(options) {
|
|
|
224
224
|
type: "denied-host",
|
|
225
225
|
cause
|
|
226
226
|
});
|
|
227
|
-
|
|
227
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
228
|
+
throw new WebToolError(`fetch_url request failed: ${message}`, {
|
|
228
229
|
type: "request-failed",
|
|
229
230
|
cause
|
|
230
231
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-url.mjs","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/fetch-url.ts"],"sourcesContent":["import { guardedFetch, OutboundPolicyError, tool, type ToolContract } from \"@warlock.js/ai\";\nimport type { FetchUrlExtract, FetchUrlInput, FetchUrlOptions, FetchUrlResult } from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"fetch_url\";\n\n/** Default hard cap on response bytes read before truncation. */\nconst DEFAULT_MAX_BYTES = 1_000_000;\n\n/** Default per-request timeout in milliseconds. */\nconst DEFAULT_TIMEOUT_MS = 15_000;\n\n/** Default rendering mode for the fetched document. */\nconst DEFAULT_EXTRACT: FetchUrlExtract = \"text\";\n\n/** Input schema: `{ url }`. */\nconst inputSchema = objectSchema<FetchUrlInput>({\n url: stringField(),\n});\n\n// ============================================================\n// Lazily-loaded @mozilla/readability + jsdom (OPTIONAL peers)\n// ============================================================\n\n/**\n * Minimal structural shapes of the optional `@mozilla/readability` + `jsdom`\n * peers — only the members this file actually uses. Declared locally so the\n * package type-checks even when the peers are NOT installed (they are lazy\n * optional peers, loaded below via a `string` specifier so `tsc` never tries\n * to statically resolve them).\n */\ninterface ReadabilityArticleLike {\n readonly content?: string | null;\n readonly textContent?: string | null;\n}\ninterface ReadabilityInstanceLike {\n parse(): ReadabilityArticleLike | null;\n}\ninterface ReadabilityModuleLike {\n Readability: new (document: unknown) => ReadabilityInstanceLike;\n}\ninterface JsdomInstanceLike {\n readonly window: { readonly document: unknown };\n}\ninterface JsdomModuleLike {\n JSDOM: new (html: string, options?: { url?: string }) => JsdomInstanceLike;\n}\n\n// String specifiers typed `string` (not a literal) so `tsc` does not attempt\n// to resolve these optional peers at build time.\nconst READABILITY_MODULE_ID: string = \"@mozilla/readability\";\nconst JSDOM_MODULE_ID: string = \"jsdom\";\n\nlet ReadabilitySdk: ReadabilityModuleLike;\nlet JsdomSdk: JsdomModuleLike;\nlet isReadabilityAvailable: boolean | undefined;\nlet loadingPromise: Promise<void> | undefined;\n\nconst READABILITY_INSTALL_INSTRUCTIONS = `\nThe fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.\nInstall them with:\n\n npm install @mozilla/readability jsdom\n\nOr with your preferred package manager:\n\n pnpm add @mozilla/readability jsdom\n yarn add @mozilla/readability jsdom\n`.trim();\n\n/**\n * Settle the lazy import of `@mozilla/readability` + `jsdom` once,\n * concurrency-safe. Only needed for the `\"text\"` / `\"markdown\"` extract\n * modes — `\"html\"` returns the raw body and never loads them. A bare\n * `catch` flips the flag to `false`; the curated install string surfaces\n * at use time via {@link WebToolError}, never a raw module-resolution\n * stack trace.\n */\nfunction loadReadability(): Promise<void> {\n if (isReadabilityAvailable !== undefined) {\n return Promise.resolve();\n }\n\n if (loadingPromise) {\n return loadingPromise;\n }\n\n loadingPromise = (async () => {\n try {\n ReadabilitySdk = (await import(READABILITY_MODULE_ID)) as ReadabilityModuleLike;\n JsdomSdk = (await import(JSDOM_MODULE_ID)) as JsdomModuleLike;\n isReadabilityAvailable = true;\n } catch {\n isReadabilityAvailable = false;\n }\n })();\n\n return loadingPromise;\n}\n\n/**\n * Parse the input URL, rejecting anything unparseable or not over\n * `http`/`https` with a typed {@link WebToolError}.\n */\nfunction parseUrl(raw: string): URL {\n let url: URL;\n\n try {\n url = new URL(raw);\n } catch {\n throw new WebToolError(`fetch_url received an unparseable URL: \"${raw}\".`, {\n type: \"invalid-url\",\n });\n }\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n throw new WebToolError(`fetch_url only supports http(s) URLs; got \"${url.protocol}\".`, {\n type: \"invalid-url\",\n });\n }\n\n return url;\n}\n\n/**\n * Enforce the host allowlist (an SSRF guardrail) before any network call.\n * When `allowHosts` is set, a request to a host not in it is rejected;\n * matching is exact on the URL hostname (case-insensitive).\n */\nfunction assertHostAllowed(url: URL, allowHosts?: string[]): void {\n if (!allowHosts || allowHosts.length === 0) {\n return;\n }\n\n const host = url.hostname.toLowerCase();\n const allowed = allowHosts.some((entry) => entry.toLowerCase() === host);\n\n if (!allowed) {\n throw new WebToolError(\n `fetch_url blocked host \"${url.hostname}\" — it is not in the allowHosts allowlist.`,\n { type: \"denied-host\", context: { host: url.hostname } },\n );\n }\n}\n\n/**\n * Read the response body up to `maxBytes`, stopping early once the cap is\n * reached. Returns the decoded text and whether it was truncated. When\n * the body has no stream (a stubbed `Response`), falls back to `.text()`\n * and truncates the decoded string at `maxBytes`.\n */\nasync function readBody(\n response: Response,\n maxBytes: number,\n): Promise<{ body: string; truncated: boolean }> {\n const stream = response.body;\n\n if (!stream) {\n const text = await response.text();\n\n if (text.length > maxBytes) {\n return { body: text.slice(0, maxBytes), truncated: true };\n }\n\n return { body: text, truncated: false };\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let received = 0;\n let truncated = false;\n\n for (;;) {\n const { done, value } = await reader.read();\n\n if (done) {\n break;\n }\n\n if (value) {\n received += value.byteLength;\n chunks.push(value);\n\n if (received >= maxBytes) {\n truncated = true;\n await reader.cancel();\n break;\n }\n }\n }\n\n const merged = new Uint8Array(received);\n let offset = 0;\n\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const sliced = truncated ? merged.subarray(0, maxBytes) : merged;\n const body = new TextDecoder().decode(sliced);\n\n return { body, truncated };\n}\n\n/**\n * Render the fetched HTML into the configured `extract` mode. `\"html\"`\n * returns the raw body untouched; `\"text\"` and `\"markdown\"` run\n * `@mozilla/readability` over a `jsdom` document, throwing a curated\n * {@link WebToolError} when those optional peers are absent. `\"markdown\"`\n * applies a light heading/paragraph conversion over the extracted text.\n */\nasync function render(html: string, url: string, extract: FetchUrlExtract): Promise<string> {\n if (extract === \"html\") {\n return html;\n }\n\n await loadReadability();\n\n if (!isReadabilityAvailable) {\n throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: \"missing-peer\" });\n }\n\n const dom = new JsdomSdk.JSDOM(html, { url });\n const article = new ReadabilitySdk.Readability(dom.window.document).parse();\n\n if (extract === \"markdown\") {\n return article?.content ? htmlToMarkdown(article.content) : (article?.textContent ?? \"\");\n }\n\n return article?.textContent ?? \"\";\n}\n\n/**\n * Minimal HTML→Markdown reduction for readability's extracted article\n * HTML — headings become `#` prefixes, paragraphs/line-breaks become\n * blank-line separated blocks, and remaining tags are stripped. This is a\n * pragmatic conversion, not a full CommonMark serializer.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<h([1-6])[^>]*>(.*?)<\\/h\\1>/gis, (_match, level: string, text: string) => {\n const hashes = \"#\".repeat(Number(level));\n\n return `\\n\\n${hashes} ${stripTags(text).trim()}\\n\\n`;\n })\n .replace(/<\\/(p|div|section|article|li)>/gi, \"\\n\\n\")\n .replace(/<br\\s*\\/?>/gi, \"\\n\")\n .replace(/<[^>]+>/g, \"\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n}\n\n/** Strip any remaining HTML tags from a fragment. */\nfunction stripTags(html: string): string {\n return html.replace(/<[^>]+>/g, \"\");\n}\n\n/**\n * Build the agent-facing `fetch_url` tool — fetch a URL over the global\n * `fetch` (Node 18+) and hand the model back rendered `content`.\n *\n * Guardrails, applied in order before/around the network call:\n * - **Private-network deny (default).** Every request — and every\n * redirect hop — goes through the framework's `guardedFetch` outbound\n * policy, which refuses private / loopback / link-local /\n * cloud-metadata addresses (and hostnames resolving to them) unless\n * `allowPrivateNetwork: true` is set. This applies even when\n * `allowHosts` is not configured, so a bare `ai.tools.fetchUrl()` is\n * not an SSRF primitive.\n * - **`allowHosts`** — when set, a request to any host not in the list is\n * rejected before the fetch, and redirect targets are held to the same\n * allowlist.\n * - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`\n * (default {@link DEFAULT_TIMEOUT_MS}).\n * - **`maxBytes`** — the response body is read up to this cap and the\n * result flags `truncated` when it was cut off (default\n * {@link DEFAULT_MAX_BYTES}).\n * - **`extract`** — `\"html\"` returns the raw body; `\"text\"` (default) and\n * `\"markdown\"` run `@mozilla/readability` over `jsdom`, lazily imported\n * so they stay OPTIONAL peers — a missing peer throws the curated\n * install string.\n *\n * **Errors flow as data.** Every guardrail rejection and network failure\n * throws a typed {@link WebToolError}; the `tool()` wrapper catches it and\n * surfaces it in the returned `{ error }` field — `invoke()` never throws\n * — so the agent reads the failure and self-corrects.\n *\n * @param options - Tool-name override, byte cap, timeout, extract mode,\n * and host allowlist.\n * @returns A `ToolContract<{ url }, FetchUrlResult>`.\n *\n * @example\n * const fetchUrl = fetchUrlTool({ extract: \"text\", allowHosts: [\"docs.stripe.com\"] });\n * const { data } = await fetchUrl.invoke({ url: \"https://docs.stripe.com/api\" });\n * console.log(data?.content, data?.truncated);\n */\nexport function fetchUrlTool(\n options?: FetchUrlOptions,\n): ToolContract<FetchUrlInput, FetchUrlResult> {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const extract = options?.extract ?? DEFAULT_EXTRACT;\n const allowHosts = options?.allowHosts;\n const allowPrivateNetwork = options?.allowPrivateNetwork ?? false;\n\n // Warm the readability peers non-blockingly when the configured mode\n // needs them, so the curated install string is ready (and logged at\n // first use) without delaying construction.\n if (extract !== \"html\") {\n void loadReadability();\n }\n\n return tool<FetchUrlInput, FetchUrlResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n \"Fetch a web page or document by URL and return its main content as \" +\n \"text (readability-extracted), raw HTML, or Markdown. Use to read a \" +\n \"specific page you already have the URL for.\",\n input: inputSchema,\n async execute(input) {\n const url = parseUrl(input.url);\n assertHostAllowed(url, allowHosts);\n\n let response: Response;\n\n try {\n // The shared hardened outbound path: private-IP deny by default,\n // per-hop redirect re-validation, timeout — never a raw fetch.\n response = await guardedFetch(url.toString(), {\n allowedSchemes: [\"http\", \"https\"],\n hostAllowlist: allowHosts,\n denyPrivateIPsAfterDNS: !allowPrivateNetwork,\n timeoutMs,\n });\n } catch (cause) {\n if (cause instanceof OutboundPolicyError) {\n throw new WebToolError(`fetch_url blocked: ${cause.message}`, {\n type: \"denied-host\",\n cause,\n });\n }\n\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(`fetch_url request failed: ${message}`, {\n type: \"request-failed\",\n cause,\n });\n }\n\n const { body, truncated } = await readBody(response, maxBytes);\n const content = await render(body, response.url || url.toString(), extract);\n\n return {\n url: response.url || url.toString(),\n status: response.status,\n content,\n truncated,\n };\n },\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fetch-url.mjs","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/fetch-url.ts"],"sourcesContent":["import { guardedFetch, OutboundPolicyError, tool, type ToolContract } from \"@warlock.js/ai\";\nimport type { FetchUrlExtract, FetchUrlInput, FetchUrlOptions, FetchUrlResult } from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"fetch_url\";\n\n/** Default hard cap on response bytes read before truncation. */\nconst DEFAULT_MAX_BYTES = 1_000_000;\n\n/** Default per-request timeout in milliseconds. */\nconst DEFAULT_TIMEOUT_MS = 15_000;\n\n/** Default rendering mode for the fetched document. */\nconst DEFAULT_EXTRACT: FetchUrlExtract = \"text\";\n\n/** Input schema: `{ url }`. */\nconst inputSchema = objectSchema<FetchUrlInput>({\n url: stringField(),\n});\n\n// ============================================================\n// Lazily-loaded @mozilla/readability + jsdom (OPTIONAL peers)\n// ============================================================\n\n/**\n * Minimal structural shapes of the optional `@mozilla/readability` + `jsdom`\n * peers — only the members this file actually uses. Declared locally so the\n * package type-checks even when the peers are NOT installed (they are lazy\n * optional peers, loaded below via a `string` specifier so `tsc` never tries\n * to statically resolve them).\n */\ninterface ReadabilityArticleLike {\n readonly content?: string | null;\n readonly textContent?: string | null;\n}\ninterface ReadabilityInstanceLike {\n parse(): ReadabilityArticleLike | null;\n}\ninterface ReadabilityModuleLike {\n Readability: new (document: unknown) => ReadabilityInstanceLike;\n}\ninterface JsdomInstanceLike {\n readonly window: { readonly document: unknown };\n}\ninterface JsdomModuleLike {\n JSDOM: new (html: string, options?: { url?: string }) => JsdomInstanceLike;\n}\n\n// String specifiers typed `string` (not a literal) so `tsc` does not attempt\n// to resolve these optional peers at build time.\nconst READABILITY_MODULE_ID: string = \"@mozilla/readability\";\nconst JSDOM_MODULE_ID: string = \"jsdom\";\n\nlet ReadabilitySdk: ReadabilityModuleLike;\nlet JsdomSdk: JsdomModuleLike;\nlet isReadabilityAvailable: boolean | undefined;\nlet loadingPromise: Promise<void> | undefined;\n\nconst READABILITY_INSTALL_INSTRUCTIONS = `\nThe fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.\nInstall them with:\n\n npm install @mozilla/readability jsdom\n\nOr with your preferred package manager:\n\n pnpm add @mozilla/readability jsdom\n yarn add @mozilla/readability jsdom\n`.trim();\n\n/**\n * Settle the lazy import of `@mozilla/readability` + `jsdom` once,\n * concurrency-safe. Only needed for the `\"text\"` / `\"markdown\"` extract\n * modes — `\"html\"` returns the raw body and never loads them. A bare\n * `catch` flips the flag to `false`; the curated install string surfaces\n * at use time via {@link WebToolError}, never a raw module-resolution\n * stack trace.\n */\nfunction loadReadability(): Promise<void> {\n if (isReadabilityAvailable !== undefined) {\n return Promise.resolve();\n }\n\n if (loadingPromise) {\n return loadingPromise;\n }\n\n loadingPromise = (async () => {\n try {\n ReadabilitySdk = (await import(READABILITY_MODULE_ID)) as ReadabilityModuleLike;\n JsdomSdk = (await import(JSDOM_MODULE_ID)) as JsdomModuleLike;\n isReadabilityAvailable = true;\n } catch {\n isReadabilityAvailable = false;\n }\n })();\n\n return loadingPromise;\n}\n\n/**\n * Parse the input URL, rejecting anything unparseable or not over\n * `http`/`https` with a typed {@link WebToolError}.\n */\nfunction parseUrl(raw: string): URL {\n let url: URL;\n\n try {\n url = new URL(raw);\n } catch {\n throw new WebToolError(`fetch_url received an unparseable URL: \"${raw}\".`, {\n type: \"invalid-url\",\n });\n }\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n throw new WebToolError(`fetch_url only supports http(s) URLs; got \"${url.protocol}\".`, {\n type: \"invalid-url\",\n });\n }\n\n return url;\n}\n\n/**\n * Enforce the host allowlist (an SSRF guardrail) before any network call.\n * When `allowHosts` is set, a request to a host not in it is rejected;\n * matching is exact on the URL hostname (case-insensitive).\n */\nfunction assertHostAllowed(url: URL, allowHosts?: string[]): void {\n if (!allowHosts || allowHosts.length === 0) {\n return;\n }\n\n const host = url.hostname.toLowerCase();\n const allowed = allowHosts.some((entry) => entry.toLowerCase() === host);\n\n if (!allowed) {\n throw new WebToolError(\n `fetch_url blocked host \"${url.hostname}\" — it is not in the allowHosts allowlist.`,\n { type: \"denied-host\", context: { host: url.hostname } },\n );\n }\n}\n\n/**\n * Read the response body up to `maxBytes`, stopping early once the cap is\n * reached. Returns the decoded text and whether it was truncated. When\n * the body has no stream (a stubbed `Response`), falls back to `.text()`\n * and truncates the decoded string at `maxBytes`.\n */\nasync function readBody(\n response: Response,\n maxBytes: number,\n): Promise<{ body: string; truncated: boolean }> {\n const stream = response.body;\n\n if (!stream) {\n const text = await response.text();\n\n if (text.length > maxBytes) {\n return { body: text.slice(0, maxBytes), truncated: true };\n }\n\n return { body: text, truncated: false };\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let received = 0;\n let truncated = false;\n\n for (;;) {\n const { done, value } = await reader.read();\n\n if (done) {\n break;\n }\n\n if (value) {\n received += value.byteLength;\n chunks.push(value);\n\n if (received >= maxBytes) {\n truncated = true;\n await reader.cancel();\n break;\n }\n }\n }\n\n const merged = new Uint8Array(received);\n let offset = 0;\n\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const sliced = truncated ? merged.subarray(0, maxBytes) : merged;\n const body = new TextDecoder().decode(sliced);\n\n return { body, truncated };\n}\n\n/**\n * Render the fetched HTML into the configured `extract` mode. `\"html\"`\n * returns the raw body untouched; `\"text\"` and `\"markdown\"` run\n * `@mozilla/readability` over a `jsdom` document, throwing a curated\n * {@link WebToolError} when those optional peers are absent. `\"markdown\"`\n * applies a light heading/paragraph conversion over the extracted text.\n */\nasync function render(html: string, url: string, extract: FetchUrlExtract): Promise<string> {\n if (extract === \"html\") {\n return html;\n }\n\n await loadReadability();\n\n if (!isReadabilityAvailable) {\n throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: \"missing-peer\" });\n }\n\n const dom = new JsdomSdk.JSDOM(html, { url });\n const article = new ReadabilitySdk.Readability(dom.window.document).parse();\n\n if (extract === \"markdown\") {\n return article?.content ? htmlToMarkdown(article.content) : (article?.textContent ?? \"\");\n }\n\n return article?.textContent ?? \"\";\n}\n\n/**\n * Minimal HTML→Markdown reduction for readability's extracted article\n * HTML — headings become `#` prefixes, paragraphs/line-breaks become\n * blank-line separated blocks, and remaining tags are stripped. This is a\n * pragmatic conversion, not a full CommonMark serializer.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<h([1-6])[^>]*>(.*?)<\\/h\\1>/gis, (_match, level: string, text: string) => {\n const hashes = \"#\".repeat(Number(level));\n\n return `\\n\\n${hashes} ${stripTags(text).trim()}\\n\\n`;\n })\n .replace(/<\\/(p|div|section|article|li)>/gi, \"\\n\\n\")\n .replace(/<br\\s*\\/?>/gi, \"\\n\")\n .replace(/<[^>]+>/g, \"\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n}\n\n/** Strip any remaining HTML tags from a fragment. */\nfunction stripTags(html: string): string {\n return html.replace(/<[^>]+>/g, \"\");\n}\n\n/**\n * Build the agent-facing `fetch_url` tool — fetch a URL over the global\n * `fetch` (Node 18+) and hand the model back rendered `content`.\n *\n * Guardrails, applied in order before/around the network call:\n * - **Private-network deny (default).** Every request — and every\n * redirect hop — goes through the framework's `guardedFetch` outbound\n * policy, which refuses private / loopback / link-local /\n * cloud-metadata addresses (and hostnames resolving to them) unless\n * `allowPrivateNetwork: true` is set. This applies even when\n * `allowHosts` is not configured, so a bare `ai.tools.fetchUrl()` is\n * not an SSRF primitive.\n * - **`allowHosts`** — when set, a request to any host not in the list is\n * rejected before the fetch, and redirect targets are held to the same\n * allowlist.\n * - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`\n * (default {@link DEFAULT_TIMEOUT_MS}).\n * - **`maxBytes`** — the response body is read up to this cap and the\n * result flags `truncated` when it was cut off (default\n * {@link DEFAULT_MAX_BYTES}).\n * - **`extract`** — `\"html\"` returns the raw body; `\"text\"` (default) and\n * `\"markdown\"` run `@mozilla/readability` over `jsdom`, lazily imported\n * so they stay OPTIONAL peers — a missing peer throws the curated\n * install string.\n *\n * **Errors flow as data.** Every guardrail rejection and network failure\n * throws a typed {@link WebToolError}; the `tool()` wrapper catches it and\n * surfaces it in the returned `{ error }` field — `invoke()` never throws\n * — so the agent reads the failure and self-corrects.\n *\n * @param options - Tool-name override, byte cap, timeout, extract mode,\n * and host allowlist.\n * @returns A `ToolContract<{ url }, FetchUrlResult>`.\n *\n * @example\n * const fetchUrl = fetchUrlTool({ extract: \"text\", allowHosts: [\"docs.stripe.com\"] });\n * const { data } = await fetchUrl.invoke({ url: \"https://docs.stripe.com/api\" });\n * console.log(data?.content, data?.truncated);\n */\nexport function fetchUrlTool(\n options?: FetchUrlOptions,\n): ToolContract<FetchUrlInput, FetchUrlResult> {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const extract = options?.extract ?? DEFAULT_EXTRACT;\n const allowHosts = options?.allowHosts;\n const allowPrivateNetwork = options?.allowPrivateNetwork ?? false;\n\n // Warm the readability peers non-blockingly when the configured mode\n // needs them, so the curated install string is ready (and logged at\n // first use) without delaying construction.\n if (extract !== \"html\") {\n void loadReadability();\n }\n\n return tool<FetchUrlInput, FetchUrlResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n \"Fetch a web page or document by URL and return its main content as \" +\n \"text (readability-extracted), raw HTML, or Markdown. Use to read a \" +\n \"specific page you already have the URL for.\",\n input: inputSchema,\n async execute(input) {\n const url = parseUrl(input.url);\n assertHostAllowed(url, allowHosts);\n\n let response: Response;\n\n try {\n // The shared hardened outbound path: private-IP deny by default,\n // per-hop redirect re-validation, timeout — never a raw fetch.\n response = await guardedFetch(url.toString(), {\n allowedSchemes: [\"http\", \"https\"],\n hostAllowlist: allowHosts,\n denyPrivateIPsAfterDNS: !allowPrivateNetwork,\n timeoutMs,\n });\n } catch (cause) {\n if (cause instanceof OutboundPolicyError) {\n throw new WebToolError(`fetch_url blocked: ${cause.message}`, {\n type: \"denied-host\",\n cause,\n });\n }\n\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(`fetch_url request failed: ${message}`, {\n type: \"request-failed\",\n cause,\n });\n }\n\n const { body, truncated } = await readBody(response, maxBytes);\n const content = await render(body, response.url || url.toString(), extract);\n\n return {\n url: response.url || url.toString(),\n status: response.status,\n content,\n truncated,\n };\n },\n });\n}\n"],"mappings":"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"}
|
package/esm/web/index.d.mts
CHANGED
package/esm/web/index.mjs
CHANGED
package/esm/web/web-search.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { WebSearchInput, WebSearchOptions, WebSearchResult } from "../contracts/web.type.mjs";
|
|
2
|
+
import "../contracts/index.mjs";
|
|
2
3
|
import { ToolContract } from "@warlock.js/ai";
|
|
3
|
-
|
|
4
4
|
//#region ../../packages/ai-tools/src/web/web-search.d.ts
|
|
5
5
|
/**
|
|
6
6
|
* Build the agent-facing `web_search` tool over a {@link SearchProvider}.
|
|
@@ -32,7 +32,6 @@ import { ToolContract } from "@warlock.js/ai";
|
|
|
32
32
|
* const { data } = await search.invoke({ query: "warlock.js ai tools" });
|
|
33
33
|
* for (const hit of data?.results ?? []) console.log(hit.title, hit.url);
|
|
34
34
|
*/
|
|
35
|
-
declare function webSearchTool(options: WebSearchOptions): ToolContract<WebSearchInput, WebSearchResult>;
|
|
35
|
+
export declare function webSearchTool(options: WebSearchOptions): ToolContract<WebSearchInput, WebSearchResult>;
|
|
36
36
|
//#endregion
|
|
37
|
-
export { webSearchTool };
|
|
38
37
|
//# sourceMappingURL=web-search.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-search.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/web-search.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"web-search.d.mts","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/web-search.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAsNgB,cACd,SAAS,mBACR,aAAa,gBAAgB"}
|
package/esm/web/web-search.mjs
CHANGED
|
@@ -48,7 +48,8 @@ async function fetchJson(url, init, provider) {
|
|
|
48
48
|
try {
|
|
49
49
|
response = await fetch(url, init);
|
|
50
50
|
} catch (cause) {
|
|
51
|
-
|
|
51
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
52
|
+
throw new WebToolError(`web_search request to the "${provider}" provider failed: ${message}`, {
|
|
52
53
|
type: "request-failed",
|
|
53
54
|
cause
|
|
54
55
|
});
|
|
@@ -153,9 +154,7 @@ function webSearchTool(options) {
|
|
|
153
154
|
case "brave":
|
|
154
155
|
results = await searchBrave(input.query, limit, apiKey);
|
|
155
156
|
break;
|
|
156
|
-
case "serpapi":
|
|
157
|
-
results = await searchSerpApi(input.query, limit, apiKey);
|
|
158
|
-
break;
|
|
157
|
+
case "serpapi": results = await searchSerpApi(input.query, limit, apiKey);
|
|
159
158
|
}
|
|
160
159
|
return { results };
|
|
161
160
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-search.mjs","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/web-search.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport type {\n SearchProvider,\n WebSearchInput,\n WebSearchOptions,\n WebSearchResult,\n WebSearchResultItem,\n} from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, optionalNumberField, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"web_search\";\n\n/** Default per-call result cap when the constructor does not set one. */\nconst DEFAULT_MAX_RESULTS = 5;\n\n/**\n * Per-provider configuration — the environment variable consulted when no\n * `apiKey` is passed, plus the human-readable provider label used in\n * error messages.\n */\nconst PROVIDER_ENV: Record<SearchProvider, string> = {\n tavily: \"TAVILY_API_KEY\",\n brave: \"BRAVE_API_KEY\",\n serpapi: \"SERPAPI_API_KEY\",\n};\n\n/** Input schema: `{ query, maxResults? }`. */\nconst inputSchema = objectSchema<WebSearchInput>({\n query: stringField(),\n maxResults: optionalNumberField(),\n});\n\n/**\n * Resolve the API key from explicit options or the provider's environment\n * variable, throwing a typed {@link WebToolError} when neither is present.\n */\nfunction resolveApiKey(provider: SearchProvider, apiKey?: string): string {\n const key = apiKey ?? process.env[PROVIDER_ENV[provider]];\n\n if (!key) {\n throw new WebToolError(\n `web_search requires an API key for the \"${provider}\" provider. ` +\n `Pass { apiKey } or set the ${PROVIDER_ENV[provider]} environment variable.`,\n { type: \"missing-key\" },\n );\n }\n\n return key;\n}\n\n/**\n * Clamp the model-requested result count into `[1, max]`. An omitted /\n * non-positive request falls back to the configured default.\n */\nfunction clampResults(requested: number | undefined, max: number): number {\n if (requested === undefined || requested < 1) {\n return Math.min(DEFAULT_MAX_RESULTS, max);\n }\n\n return Math.min(Math.max(1, Math.floor(requested)), max);\n}\n\n/**\n * Shape of the relevant slice of a Tavily `/search` response. Tavily\n * returns LLM-ready `content` snippets and a relevance `score` per hit.\n */\ninterface TavilyResponse {\n results?: Array<{ title?: string; url?: string; content?: string; score?: number }>;\n}\n\n/** Shape of the relevant slice of a Brave web-search response. */\ninterface BraveResponse {\n web?: { results?: Array<{ title?: string; url?: string; description?: string }> };\n}\n\n/** Shape of the relevant slice of a SerpAPI `search.json` response. */\ninterface SerpApiResponse {\n organic_results?: Array<{ title?: string; link?: string; snippet?: string }>;\n}\n\n/**\n * Issue the provider HTTP call and return its parsed JSON, mapping a\n * non-OK status or a network failure to a typed {@link WebToolError}.\n */\nasync function fetchJson(\n url: string,\n init: RequestInit,\n provider: SearchProvider,\n): Promise<unknown> {\n let response: Response;\n\n try {\n response = await fetch(url, init);\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(`web_search request to the \"${provider}\" provider failed: ${message}`, {\n type: \"request-failed\",\n cause,\n });\n }\n\n if (!response.ok) {\n throw new WebToolError(`web_search \"${provider}\" provider returned HTTP ${response.status}.`, {\n type: \"request-failed\",\n context: { status: response.status },\n });\n }\n\n return response.json();\n}\n\n/** Drive Tavily's `/search` HTTP API and normalize its hits. */\nasync function searchTavily(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const json = (await fetchJson(\n \"https://api.tavily.com/search\",\n {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults }),\n },\n \"tavily\",\n )) as TavilyResponse;\n\n return (json.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.content ?? \"\",\n score: hit.score,\n }));\n}\n\n/** Drive Brave's web-search HTTP API and normalize its hits. */\nasync function searchBrave(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://api.search.brave.com/res/v1/web/search\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"count\", String(maxResults));\n\n const json = (await fetchJson(\n url.toString(),\n {\n method: \"GET\",\n headers: { accept: \"application/json\", \"x-subscription-token\": apiKey },\n },\n \"brave\",\n )) as BraveResponse;\n\n return (json.web?.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.description ?? \"\",\n }));\n}\n\n/** Drive SerpAPI's `search.json` HTTP API and normalize its hits. */\nasync function searchSerpApi(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://serpapi.com/search.json\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"num\", String(maxResults));\n url.searchParams.set(\"api_key\", apiKey);\n\n const json = (await fetchJson(url.toString(), { method: \"GET\" }, \"serpapi\")) as SerpApiResponse;\n\n return (json.organic_results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.link ?? \"\",\n snippet: hit.snippet ?? \"\",\n }));\n}\n\n/**\n * Build the agent-facing `web_search` tool over a {@link SearchProvider}.\n *\n * The provider's HTTP API is called directly through the global `fetch`\n * (Node 18+) — Tavily via `POST /search` (LLM-ready snippets + relevance\n * scores), Brave and SerpAPI via their `GET` endpoints. The API key is\n * taken from `options.apiKey`, falling back to the provider's environment\n * variable (`TAVILY_API_KEY` / `BRAVE_API_KEY` / `SERPAPI_API_KEY`). No\n * provider SDK is required for the HTTP path; `@tavily/core` remains an\n * optional peer for callers who prefer it, but this factory never forces\n * it to be installed.\n *\n * The model passes `{ query, maxResults? }`; `maxResults` is clamped into\n * `[1, options.maxResults]` (default {@link DEFAULT_MAX_RESULTS}).\n *\n * **Errors flow as data.** A missing key, a non-OK provider status, or a\n * network failure throws a typed {@link WebToolError}; the `tool()`\n * wrapper catches it and surfaces it in the returned `{ error }` field —\n * `invoke()` never throws — so the agent can read the failure and\n * self-correct.\n *\n * @param options - Provider selection, API key, result cap, and an\n * optional tool-name override.\n * @returns A `ToolContract<{ query; maxResults? }, WebSearchResult>`.\n *\n * @example\n * const search = webSearchTool({ provider: \"tavily\" });\n * const { data } = await search.invoke({ query: \"warlock.js ai tools\" });\n * for (const hit of data?.results ?? []) console.log(hit.title, hit.url);\n */\nexport function webSearchTool(\n options: WebSearchOptions,\n): ToolContract<WebSearchInput, WebSearchResult> {\n const provider = options.provider;\n const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;\n\n return tool<WebSearchInput, WebSearchResult>({\n name: options.name ?? DEFAULT_NAME,\n description:\n \"Search the web for current information and return a ranked list of \" +\n \"results (title, URL, and a short snippet). Use for facts that may have \" +\n \"changed since training, or anything you are unsure about.\",\n input: inputSchema,\n async execute(input) {\n const apiKey = resolveApiKey(provider, options.apiKey);\n const limit = clampResults(input.maxResults, maxResults);\n\n let results: WebSearchResultItem[];\n\n switch (provider) {\n case \"tavily\":\n results = await searchTavily(input.query, limit, apiKey);\n break;\n case \"brave\":\n results = await searchBrave(input.query, limit, apiKey);\n break;\n case \"serpapi\":\n results = await searchSerpApi(input.query, limit, apiKey);\n break;\n }\n\n return { results };\n },\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"web-search.mjs","names":[],"sources":["../../../../../../../../packages/ai-tools/src/web/web-search.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport type {\n SearchProvider,\n WebSearchInput,\n WebSearchOptions,\n WebSearchResult,\n WebSearchResultItem,\n} from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, optionalNumberField, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"web_search\";\n\n/** Default per-call result cap when the constructor does not set one. */\nconst DEFAULT_MAX_RESULTS = 5;\n\n/**\n * Per-provider configuration — the environment variable consulted when no\n * `apiKey` is passed, plus the human-readable provider label used in\n * error messages.\n */\nconst PROVIDER_ENV: Record<SearchProvider, string> = {\n tavily: \"TAVILY_API_KEY\",\n brave: \"BRAVE_API_KEY\",\n serpapi: \"SERPAPI_API_KEY\",\n};\n\n/** Input schema: `{ query, maxResults? }`. */\nconst inputSchema = objectSchema<WebSearchInput>({\n query: stringField(),\n maxResults: optionalNumberField(),\n});\n\n/**\n * Resolve the API key from explicit options or the provider's environment\n * variable, throwing a typed {@link WebToolError} when neither is present.\n */\nfunction resolveApiKey(provider: SearchProvider, apiKey?: string): string {\n const key = apiKey ?? process.env[PROVIDER_ENV[provider]];\n\n if (!key) {\n throw new WebToolError(\n `web_search requires an API key for the \"${provider}\" provider. ` +\n `Pass { apiKey } or set the ${PROVIDER_ENV[provider]} environment variable.`,\n { type: \"missing-key\" },\n );\n }\n\n return key;\n}\n\n/**\n * Clamp the model-requested result count into `[1, max]`. An omitted /\n * non-positive request falls back to the configured default.\n */\nfunction clampResults(requested: number | undefined, max: number): number {\n if (requested === undefined || requested < 1) {\n return Math.min(DEFAULT_MAX_RESULTS, max);\n }\n\n return Math.min(Math.max(1, Math.floor(requested)), max);\n}\n\n/**\n * Shape of the relevant slice of a Tavily `/search` response. Tavily\n * returns LLM-ready `content` snippets and a relevance `score` per hit.\n */\ninterface TavilyResponse {\n results?: Array<{ title?: string; url?: string; content?: string; score?: number }>;\n}\n\n/** Shape of the relevant slice of a Brave web-search response. */\ninterface BraveResponse {\n web?: { results?: Array<{ title?: string; url?: string; description?: string }> };\n}\n\n/** Shape of the relevant slice of a SerpAPI `search.json` response. */\ninterface SerpApiResponse {\n organic_results?: Array<{ title?: string; link?: string; snippet?: string }>;\n}\n\n/**\n * Issue the provider HTTP call and return its parsed JSON, mapping a\n * non-OK status or a network failure to a typed {@link WebToolError}.\n */\nasync function fetchJson(\n url: string,\n init: RequestInit,\n provider: SearchProvider,\n): Promise<unknown> {\n let response: Response;\n\n try {\n response = await fetch(url, init);\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(`web_search request to the \"${provider}\" provider failed: ${message}`, {\n type: \"request-failed\",\n cause,\n });\n }\n\n if (!response.ok) {\n throw new WebToolError(`web_search \"${provider}\" provider returned HTTP ${response.status}.`, {\n type: \"request-failed\",\n context: { status: response.status },\n });\n }\n\n return response.json();\n}\n\n/** Drive Tavily's `/search` HTTP API and normalize its hits. */\nasync function searchTavily(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const json = (await fetchJson(\n \"https://api.tavily.com/search\",\n {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults }),\n },\n \"tavily\",\n )) as TavilyResponse;\n\n return (json.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.content ?? \"\",\n score: hit.score,\n }));\n}\n\n/** Drive Brave's web-search HTTP API and normalize its hits. */\nasync function searchBrave(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://api.search.brave.com/res/v1/web/search\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"count\", String(maxResults));\n\n const json = (await fetchJson(\n url.toString(),\n {\n method: \"GET\",\n headers: { accept: \"application/json\", \"x-subscription-token\": apiKey },\n },\n \"brave\",\n )) as BraveResponse;\n\n return (json.web?.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.description ?? \"\",\n }));\n}\n\n/** Drive SerpAPI's `search.json` HTTP API and normalize its hits. */\nasync function searchSerpApi(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://serpapi.com/search.json\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"num\", String(maxResults));\n url.searchParams.set(\"api_key\", apiKey);\n\n const json = (await fetchJson(url.toString(), { method: \"GET\" }, \"serpapi\")) as SerpApiResponse;\n\n return (json.organic_results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.link ?? \"\",\n snippet: hit.snippet ?? \"\",\n }));\n}\n\n/**\n * Build the agent-facing `web_search` tool over a {@link SearchProvider}.\n *\n * The provider's HTTP API is called directly through the global `fetch`\n * (Node 18+) — Tavily via `POST /search` (LLM-ready snippets + relevance\n * scores), Brave and SerpAPI via their `GET` endpoints. The API key is\n * taken from `options.apiKey`, falling back to the provider's environment\n * variable (`TAVILY_API_KEY` / `BRAVE_API_KEY` / `SERPAPI_API_KEY`). No\n * provider SDK is required for the HTTP path; `@tavily/core` remains an\n * optional peer for callers who prefer it, but this factory never forces\n * it to be installed.\n *\n * The model passes `{ query, maxResults? }`; `maxResults` is clamped into\n * `[1, options.maxResults]` (default {@link DEFAULT_MAX_RESULTS}).\n *\n * **Errors flow as data.** A missing key, a non-OK provider status, or a\n * network failure throws a typed {@link WebToolError}; the `tool()`\n * wrapper catches it and surfaces it in the returned `{ error }` field —\n * `invoke()` never throws — so the agent can read the failure and\n * self-correct.\n *\n * @param options - Provider selection, API key, result cap, and an\n * optional tool-name override.\n * @returns A `ToolContract<{ query; maxResults? }, WebSearchResult>`.\n *\n * @example\n * const search = webSearchTool({ provider: \"tavily\" });\n * const { data } = await search.invoke({ query: \"warlock.js ai tools\" });\n * for (const hit of data?.results ?? []) console.log(hit.title, hit.url);\n */\nexport function webSearchTool(\n options: WebSearchOptions,\n): ToolContract<WebSearchInput, WebSearchResult> {\n const provider = options.provider;\n const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;\n\n return tool<WebSearchInput, WebSearchResult>({\n name: options.name ?? DEFAULT_NAME,\n description:\n \"Search the web for current information and return a ranked list of \" +\n \"results (title, URL, and a short snippet). Use for facts that may have \" +\n \"changed since training, or anything you are unsure about.\",\n input: inputSchema,\n async execute(input) {\n const apiKey = resolveApiKey(provider, options.apiKey);\n const limit = clampResults(input.maxResults, maxResults);\n\n let results: WebSearchResultItem[];\n\n switch (provider) {\n case \"tavily\":\n results = await searchTavily(input.query, limit, apiKey);\n break;\n case \"brave\":\n results = await searchBrave(input.query, limit, apiKey);\n break;\n case \"serpapi\":\n results = await searchSerpApi(input.query, limit, apiKey);\n break;\n }\n\n return { results };\n },\n });\n}\n"],"mappings":";;;;;;AAYA,MAAM,eAAe;;AAGrB,MAAM,sBAAsB;;;;;;AAO5B,MAAM,eAA+C;CACnD,QAAQ;CACR,OAAO;CACP,SAAS;AACX;;AAGA,MAAM,cAAc,aAA6B;CAC/C,OAAO,YAAY;CACnB,YAAY,oBAAoB;AAClC,CAAC;;;;;AAMD,SAAS,cAAc,UAA0B,QAAyB;CACxE,MAAM,MAAM,UAAU,QAAQ,IAAI,aAAa;CAE/C,IAAI,CAAC,KACH,MAAM,IAAI,aACR,2CAA2C,SAAS,yCACpB,aAAa,UAAU,yBACvD,EAAE,MAAM,cAAc,CACxB;CAGF,OAAO;AACT;;;;;AAMA,SAAS,aAAa,WAA+B,KAAqB;CACxE,IAAI,cAAc,UAAa,YAAY,GACzC,OAAO,KAAK,IAAI,qBAAqB,GAAG;CAG1C,OAAO,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,SAAS,CAAC,GAAG,GAAG;AACzD;;;;;AAwBA,eAAe,UACb,KACA,MACA,UACkB;CAClB,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK,IAAI;CAClC,SAAS,OAAO;EACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EAErE,MAAM,IAAI,aAAa,8BAA8B,SAAS,qBAAqB,WAAW;GAC5F,MAAM;GACN;EACF,CAAC;CACH;CAEA,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,aAAa,eAAe,SAAS,2BAA2B,SAAS,OAAO,IAAI;EAC5F,MAAM;EACN,SAAS,EAAE,QAAQ,SAAS,OAAO;CACrC,CAAC;CAGH,OAAO,SAAS,KAAK;AACvB;;AAGA,eAAe,aACb,OACA,YACA,QACgC;CAWhC,SAAQ,MAVY,UAClB,iCACA;EACE,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;EAC9C,MAAM,KAAK,UAAU;GAAE,SAAS;GAAQ;GAAO,aAAa;EAAW,CAAC;CAC1E,GACA,QACF,EAEY,CAAC,WAAW,CAAC,EAAC,CAAE,MAAM,GAAG,UAAU,CAAC,CAAC,KAAK,SAAS;EAC7D,OAAO,IAAI,SAAS;EACpB,KAAK,IAAI,OAAO;EAChB,SAAS,IAAI,WAAW;EACxB,OAAO,IAAI;CACb,EAAE;AACJ;;AAGA,eAAe,YACb,OACA,YACA,QACgC;CAChC,MAAM,MAAM,IAAI,IAAI,gDAAgD;CACpE,IAAI,aAAa,IAAI,KAAK,KAAK;CAC/B,IAAI,aAAa,IAAI,SAAS,OAAO,UAAU,CAAC;CAWhD,SAAQ,MATY,UAClB,IAAI,SAAS,GACb;EACE,QAAQ;EACR,SAAS;GAAE,QAAQ;GAAoB,wBAAwB;EAAO;CACxE,GACA,OACF,EAEY,CAAC,KAAK,WAAW,CAAC,EAAC,CAAE,MAAM,GAAG,UAAU,CAAC,CAAC,KAAK,SAAS;EAClE,OAAO,IAAI,SAAS;EACpB,KAAK,IAAI,OAAO;EAChB,SAAS,IAAI,eAAe;CAC9B,EAAE;AACJ;;AAGA,eAAe,cACb,OACA,YACA,QACgC;CAChC,MAAM,MAAM,IAAI,IAAI,iCAAiC;CACrD,IAAI,aAAa,IAAI,KAAK,KAAK;CAC/B,IAAI,aAAa,IAAI,OAAO,OAAO,UAAU,CAAC;CAC9C,IAAI,aAAa,IAAI,WAAW,MAAM;CAItC,SAAQ,MAFY,UAAU,IAAI,SAAS,GAAG,EAAE,QAAQ,MAAM,GAAG,SAAS,EAE9D,CAAC,mBAAmB,CAAC,EAAC,CAAE,MAAM,GAAG,UAAU,CAAC,CAAC,KAAK,SAAS;EACrE,OAAO,IAAI,SAAS;EACpB,KAAK,IAAI,QAAQ;EACjB,SAAS,IAAI,WAAW;CAC1B,EAAE;AACJ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,SAAgB,cACd,SAC+C;CAC/C,MAAM,WAAW,QAAQ;CACzB,MAAM,aAAa,QAAQ,cAAc;CAEzC,OAAO,KAAsC;EAC3C,MAAM,QAAQ,QAAQ;EACtB,aACE;EAGF,OAAO;EACP,MAAM,QAAQ,OAAO;GACnB,MAAM,SAAS,cAAc,UAAU,QAAQ,MAAM;GACrD,MAAM,QAAQ,aAAa,MAAM,YAAY,UAAU;GAEvD,IAAI;GAEJ,QAAQ,UAAR;IACE,KAAK;KACH,UAAU,MAAM,aAAa,MAAM,OAAO,OAAO,MAAM;KACvD;IACF,KAAK;KACH,UAAU,MAAM,YAAY,MAAM,OAAO,OAAO,MAAM;KACtD;IACF,KAAK,WACH,UAAU,MAAM,cAAc,MAAM,OAAO,OAAO,MAAM;GAE5D;GAEA,OAAO,EAAE,QAAQ;EACnB;CACF,CAAC;AACH"}
|
package/llms-full.txt
CHANGED
|
@@ -95,6 +95,21 @@ For each remote tool:
|
|
|
95
95
|
2. `execute` issues `tools/call` honoring `ctx.signal` (cooperative abort) and the configured `timeoutMs`, then unwraps the result content (a single JSON text block is parsed back into an object);
|
|
96
96
|
3. an MCP `isError` result is thrown so the surrounding `tool()` wraps it as a `ToolExecutionError` — the agent reads it as `{ error }` data and self-corrects.
|
|
97
97
|
|
|
98
|
+
### Selecting one discovered tool
|
|
99
|
+
|
|
100
|
+
When selecting one discovered tool, guard the array result before invoking it.
|
|
101
|
+
A server may advertise no tools, and `noUncheckedIndexedAccess` reflects that:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import type { McpClient } from "@warlock.js/ai-tools";
|
|
105
|
+
|
|
106
|
+
export async function invokeFirst(client: McpClient, input: unknown) {
|
|
107
|
+
const [contract] = await client.tools();
|
|
108
|
+
if (!contract) throw new Error("The MCP server advertised no tools");
|
|
109
|
+
return contract.invoke(input);
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
98
113
|
## `type`, never `kind`
|
|
99
114
|
|
|
100
115
|
MCP's wire vocabulary uses `kind` in some content/capability descriptors. The client translates any inbound `kind` to `type` when normalizing content blocks, so a value a tool returns never carries MCP's `kind` — every discriminator the agent sees is `type`.
|
|
@@ -132,7 +147,7 @@ The MCP client does not use `@modelcontextprotocol/sdk`: it speaks MCP's JSON-RP
|
|
|
132
147
|
|
|
133
148
|
---
|
|
134
149
|
name: expose-as-mcp-server
|
|
135
|
-
description: 'Expose a @warlock.js/ai agent / supervisor / orchestrator (or a raw ToolContract[], or named tool groups) AS a Model Context Protocol (MCP) server other clients consume (Direction B) with `ai.mcp.serve(source, options)` — over stdio or authenticated Streamable HTTP. Triggers: `ai.mcp.serve(`, `serve(`, `createServeHandler`, `McpServer`, `McpServeOptions`, `McpServeSource`, `McpServeTransport`, `McpHttpServeTransport`, `McpHttpAuth`, `McpServeError`, `server.start()`, `server.stop()`, `{ url, close }`, `{ type: "http", host, port, path, auth }`, `bearer`, `authorize`, `allowedOrigins`, `maxBodyBytes`, `prefixToolNames`, `groups`, `resources`, `prompts`, `ctx.request.headers`, `schemaTarget`, `tools/list`, `tools/call`, `{ type: "stdio" }`; ''expose my agent as an MCP server'', ''serve my tools over HTTP'', ''give each agent session its own MCP endpoint'', ''protect my MCP server with a token'', ''publish my tools to Cursor/Claude Desktop'', ''serve MCP resources or prompts'', ''tool name collision''; typical import `import "@warlock.js/ai-tools"; import { ai } from "@warlock.js/ai"`. Skip: CONSUMING an external MCP server (the reverse direction) — the `connect-mcp-server` topic; the built-in web/HTTP/calc/date tools — the `use-web-and-http-tools` topic.'
|
|
150
|
+
description: 'Expose a @warlock.js/ai agent / supervisor / orchestrator (or a raw ToolContract[], or named tool groups) AS a Model Context Protocol (MCP) server other clients consume (Direction B) with `ai.mcp.serve(source, options)` — over stdio or authenticated Streamable HTTP. Triggers: `ai.mcp.serve(`, `serve(`, `createServeHandler`, `McpServer`, `McpServeOptions`, `McpServeSource`, `McpServeTransport`, `McpHttpServeTransport`, `McpHttpAuth`, `McpServeError`, `server.start()`, `server.stop()`, `{ url, close }`, `{ type: "http", host, port, path, auth }`, `bearer`, `authorize`, `authenticate`, `beforeCall`, `requestId`, `principal`, `notifications/cancelled`, `allowedOrigins`, `maxBodyBytes`, `prefixToolNames`, `groups`, `resources`, `prompts`, `ctx.request.headers`, `schemaTarget`, `tools/list`, `tools/call`, `{ type: "stdio" }`; ''expose my agent as an MCP server'', ''serve my tools over HTTP'', ''give each agent session its own MCP endpoint'', ''protect my MCP server with a token'', ''publish my tools to Cursor/Claude Desktop'', ''serve MCP resources or prompts'', ''tool name collision''; typical import `import "@warlock.js/ai-tools"; import { ai } from "@warlock.js/ai"`. Skip: CONSUMING an external MCP server (the reverse direction) — the `connect-mcp-server` topic; the built-in web/HTTP/calc/date tools — the `use-web-and-http-tools` topic.'
|
|
136
151
|
---
|
|
137
152
|
|
|
138
153
|
# Expose tools AS an MCP server (Direction B)
|
|
@@ -196,7 +211,7 @@ transport: {
|
|
|
196
211
|
}
|
|
197
212
|
```
|
|
198
213
|
|
|
199
|
-
-
|
|
214
|
+
- Checks run in order: `bearer`, `authorize`, `authenticate`; every configured check must pass. A failed check skips later hooks. `authenticate` alone satisfies the non-loopback auth requirement. Host/Origin checks run first. A throwing authentication hook denies. Failures → `401`; bearer or authenticate failures include a Bearer `WWW-Authenticate` challenge.
|
|
200
215
|
- A foreign or `null` `Origin` → `403` (DNS-rebinding guard from the MCP spec). Requests without `Origin` pass.
|
|
201
216
|
- On loopback, a `Host` other than `localhost` / `127.0.0.1` / `[::1]` (or `allowedHosts`) → `403`.
|
|
202
217
|
- Stateless: no session id; `GET` / `DELETE` → `405`; batches → `400`; unsupported `MCP-Protocol-Version` → `400`.
|
|
@@ -223,6 +238,110 @@ const { url, close } = await ai.mcp
|
|
|
223
238
|
|
|
224
239
|
Header names are lower-cased; every client header is present (including `authorization`). Over stdio `ctx.request` is absent. Nothing on `ctx.request` reaches the model.
|
|
225
240
|
|
|
241
|
+
## Authenticated context, policy and cancellation
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { ai } from "@warlock.js/ai";
|
|
245
|
+
import "@warlock.js/ai-tools";
|
|
246
|
+
import type { McpHttpRequestInfo, McpCallRefusal } from "@warlock.js/ai-tools";
|
|
247
|
+
|
|
248
|
+
declare function findMember(
|
|
249
|
+
request: McpHttpRequestInfo,
|
|
250
|
+
): Promise<{ id: string; canCall: boolean } | undefined>;
|
|
251
|
+
const denied: McpCallRefusal = {
|
|
252
|
+
isError: true,
|
|
253
|
+
content: [{ type: "text", text: "Member cannot call tools" }],
|
|
254
|
+
_meta: { policy: "membership" },
|
|
255
|
+
};
|
|
256
|
+
const identity = ai.tool({
|
|
257
|
+
name: "identity",
|
|
258
|
+
description: "Report the authenticated caller and request id",
|
|
259
|
+
execute: async (_input: unknown, ctx) => {
|
|
260
|
+
ctx?.signal?.throwIfAborted();
|
|
261
|
+
return { requestId: ctx?.requestId, principal: ctx?.principal };
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const server = ai.mcp.serve([identity], {
|
|
266
|
+
name: "member-tools",
|
|
267
|
+
transport: { type: "http", auth: { authenticate: findMember } },
|
|
268
|
+
beforeCall: async (context, tool, input) => {
|
|
269
|
+
const member = context.principal; // unknown — narrow before use
|
|
270
|
+
if (
|
|
271
|
+
typeof member !== "object" ||
|
|
272
|
+
member === null ||
|
|
273
|
+
!("canCall" in member) ||
|
|
274
|
+
member.canCall !== true
|
|
275
|
+
) {
|
|
276
|
+
return denied;
|
|
277
|
+
}
|
|
278
|
+
// context.requestId: JSON-RPC id; tool: original ToolContract;
|
|
279
|
+
// input: validated data, treated as read-only.
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- `authenticate(request)` may be synchronous or async. `undefined` or a thrown error → `401` with `WWW-Authenticate: Bearer realm="mcp"`; every other value becomes `context.principal` and `ctx.principal`. Secrets in thrown auth errors are not exposed.
|
|
285
|
+
- `beforeCall(context, tool, input)` runs after schema validation. Return nothing to allow; return `McpCallRefusal` (`isError: true`) to send native content/metadata unchanged, independent of result mode. Throw → JSON-RPC `-32602`. Invalid refusals → `-32603`. Invalid input skips policy. The boundary validates before policy and `invoke` keeps its own validation, so schemas must be deterministic and policy input read-only.
|
|
286
|
+
- Stdio identity: `transport: { type: "stdio", principal: { id: "local" } }` or `transport: { type: "stdio", principalProvider: async (context) => ({ id: "local", requestId: context.requestId }) }`. The provider runs per request, takes precedence over static principal, and receives the request id and abort signal. A provider throw → JSON-RPC internal error. No HTTP request metadata exists over stdio.
|
|
287
|
+
- Public types: `McpBeforeCall`, `McpCallRefusal`, `McpServeToolContext`, `McpStdioServeTransport`; `ToolContext` in `@warlock.js/ai` adds optional `requestId` and `principal`.
|
|
288
|
+
|
|
289
|
+
[Cancellation (MCP 2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation): send `{ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 42, reason: "Caller cancelled" } }`. The matching signal aborts; no JSON-RPC response is sent even if the tool later returns or throws. Unknown/completed/malformed ids are ignored; `initialize` cannot be cancelled. String and numeric ids are distinct; calls sharing an in-flight id still execute, but ambiguous cancellation in one scope is ignored.
|
|
290
|
+
|
|
291
|
+
HTTP cancellation POSTs must repeat caller credentials and application headers (`Authorization`, `Cookie`, tenant/custom auth headers), from the same remote address. Transport headers such as content length, accept, protocol version and connection may differ. Different credentials can reuse ids safely. Clients sharing credential/application headers must use unique in-flight ids for cancellation to identify a call. Notifications get `202`; a cancelled HTTP call ends with `202`, no JSON-RPC body, when its handler settles. Stdio scopes cancellation to the connection. HTTP disconnect, stdio EOF/close/stop, or HTTP shutdown after the drain timeout also abort tools. Pass `ctx.signal` into cancellable work.
|
|
292
|
+
|
|
293
|
+
## Return native MCP results
|
|
294
|
+
|
|
295
|
+
Default results become one text block, even when an object resembles an MCP result. Return the exported `mcpResult(...)` helper to preserve native content blocks, `isError`, `structuredContent` and `_meta` over stdio or HTTP:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import { mcpResult } from "@warlock.js/ai-tools";
|
|
299
|
+
import { ai } from "@warlock.js/ai";
|
|
300
|
+
|
|
301
|
+
const count = ai.tool({
|
|
302
|
+
name: "count",
|
|
303
|
+
description: "Return a structured count",
|
|
304
|
+
meta: {
|
|
305
|
+
outputSchema: {
|
|
306
|
+
type: "object",
|
|
307
|
+
properties: { count: { type: "number" } },
|
|
308
|
+
required: ["count"],
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
execute: async (_input: unknown, ctx) =>
|
|
312
|
+
mcpResult({
|
|
313
|
+
content: [{ type: "text", text: '{"count":2}' }],
|
|
314
|
+
structuredContent: { count: 2 },
|
|
315
|
+
_meta: { tenant: ctx?.request?.headers["x-tenant-id"] ?? "unknown" },
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
ai.mcp.serve([count], { name: "counts" });
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The helper returns a branded `McpNativeResult` wrapper. The serving boundary validates and unwraps it without altering its payload. `isError: true` is sent as-is; thrown tool errors retain the existing mapping.
|
|
323
|
+
|
|
324
|
+
For tools that already return native objects, opt in at the server or tool level:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
const nativeTool = ai.tool({
|
|
328
|
+
name: "native-result",
|
|
329
|
+
description: "Return a native result",
|
|
330
|
+
meta: { mcpResults: "native" }, // this tool opts in even on a default server
|
|
331
|
+
execute: async () => ({ content: [{ type: "text", text: "fixture" }], isError: true }),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
ai.mcp.serve([nativeTool], { name: "native-tools", results: "native" });
|
|
335
|
+
// On a native-mode server, meta: { mcpResults: "text" } disables detection for a tool.
|
|
336
|
+
// Explicit mcpResult(...) returns always opt in, regardless of the detection setting.
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Native mode detects objects containing any of `content`, `isError`, `structuredContent` or `_meta`; other values keep text serialization. `content` must be an array of valid MCP text/image/audio/resource-link/embedded-resource blocks. Optional `isError` must be boolean; `structuredContent` and `_meta` must be JSON objects. Invalid or non-JSON native payloads answer a JSON-RPC internal error (`-32603`), including malformed helper payloads.
|
|
340
|
+
|
|
341
|
+
`meta.outputSchema` is emitted as `outputSchema` in `tools/list`; `structuredContent` passes through unchanged. Tool authors own schema conformity. Follow the [MCP 2025-11-25 tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#structured-content): also include serialized structured output in a text block for compatibility.
|
|
342
|
+
|
|
343
|
+
Request identity is unchanged: native tools receive each HTTP call's headers and remote address on `ctx.request`, plus `ctx.signal`. Over stdio, `ctx.request` is absent. Returning a native result does not automatically expose request metadata; the tool controls its response.
|
|
344
|
+
|
|
226
345
|
## Sources and tool-name collisions
|
|
227
346
|
|
|
228
347
|
```ts
|
|
@@ -268,7 +387,7 @@ ai.mcp.serve(tools, {
|
|
|
268
387
|
|
|
269
388
|
- **`initialize`** → `{ name, version }`, capabilities `tools` (+ `resources` / `prompts` when configured); protocol version negotiated from `SUPPORTED_PROTOCOL_VERSIONS` (`2025-11-25`, `2025-06-18`, `2025-03-26`).
|
|
270
389
|
- **`tools/list`** → `inputSchema` via `extractJsonSchema(contract.input, { target: schemaTarget })` (default `"draft-2020-12"`).
|
|
271
|
-
- **`tools/call`** → `invoke()`; `data` → text block (JSON-stringified unless a string), `error` → `isError: true`. Unknown tool → JSON-RPC error.
|
|
390
|
+
- **`tools/call`** → `invoke()`; default `data` → text block (JSON-stringified unless a string), opted-in native results → validated passthrough, `error` → `isError: true`. Unknown tool → JSON-RPC error.
|
|
272
391
|
- **`ping`** → `{}`.
|
|
273
392
|
|
|
274
393
|
## Drive the protocol yourself (tests, custom servers)
|