@warlock.js/ai-tools 5.33.0 → 5.34.1

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: expose-as-mcp-server
3
- 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.'
3
+ 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`, `onAuthRefusal`, `McpHttpAuthRefusal`, `McpServeError`, `server.start()`, `server.stop()`, `{ url, close }`, `{ type: "http", host, port, path, auth }`, `bearer`, `authorize`, `authenticate`, `beforeCall`, `listFilter`, `validateCallParams`, `McpError`, `routes`, `McpHttpRouteContext`, `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.'
4
4
  ---
5
5
 
6
6
  # Expose tools AS an MCP server (Direction B)
@@ -64,12 +64,60 @@ transport: {
64
64
  }
65
65
  ```
66
66
 
67
- - 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.
67
+ - 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. By default, failures → `401`; bearer or authenticate failures include a Bearer `WWW-Authenticate` challenge.
68
68
  - A foreign or `null` `Origin` → `403` (DNS-rebinding guard from the MCP spec). Requests without `Origin` pass.
69
69
  - On loopback, a `Host` other than `localhost` / `127.0.0.1` / `[::1]` (or `allowedHosts`) → `403`.
70
70
  - Stateless: no session id; `GET` / `DELETE` → `405`; batches → `400`; unsupported `MCP-Protocol-Version` → `400`.
71
71
  - Plain HTTP: put TLS in front before exposing beyond the machine.
72
72
 
73
+ ## Customize HTTP authentication refusals
74
+
75
+ Use `transport.onAuthRefusal` for a JSON error contract shared by the MCP endpoint
76
+ and `transport.routes`. It runs only after a failed bearer/authorize/authenticate
77
+ check. Host/Origin refusals always take precedence and never call it.
78
+
79
+ ```ts
80
+ import { ai } from "@warlock.js/ai";
81
+ import type { McpHttpAuthRefusal } from "@warlock.js/ai-tools";
82
+ import "@warlock.js/ai-tools";
83
+
84
+ const onAuthRefusal: McpHttpAuthRefusal = async ({ check, target }) => ({
85
+ status: 401,
86
+ headers: { "cache-control": "no-store" },
87
+ body: { error: "MEMBER_UNAUTHORIZED", check, target },
88
+ });
89
+
90
+ const server = ai.mcp.serve([], {
91
+ name: "member-tools",
92
+ transport: {
93
+ type: "http",
94
+ auth: { authorize: () => false },
95
+ onAuthRefusal,
96
+ routes: [{ method: "GET", path: "/policy", handler: () => ({ body: {} }) }],
97
+ },
98
+ });
99
+ const { url, close } = await server.start();
100
+ // url and `${url}/policy` share the custom refusal; no handler runs.
101
+ await close();
102
+ ```
103
+
104
+ - Context has only `check: "bearer" | "authorize" | "authenticate"`, uppercase
105
+ `method`, query-free `path`, and `target: "mcp" | "route"`. No error, credentials,
106
+ headers, principal or request body is passed. Thrown auth details stay private.
107
+ - Return `{ status?, headers?, body }` or a promise. Status defaults to 401 and
108
+ must be an integer 400–599. Required body is plain JSON, including primitives,
109
+ arrays and null; no cycles, class instances, non-finite numbers or undefined entries.
110
+ - Header allowlist: `www-authenticate`, `cache-control`, `retry-after`,
111
+ `content-language`, `x-request-id`. Public types use lowercase keys; runtime
112
+ matches names case-insensitively and validates HTTP string values. Headers merge
113
+ over the default challenge. Framing, cookies, CORS and redirects cannot be set.
114
+ - Void, throws/rejections and any invalid result fall back to the original 401
115
+ JSON-RPC `Unauthorized.` response and default headers. A custom denial cannot
116
+ grant authority: it ends the request without later auth, route, policy or tool
117
+ handlers. Successful auth skips the refusal hook.
118
+ - Exported types: `McpHttpAuthRefusal`, `McpHttpAuthRefusalContext`,
119
+ `McpHttpAuthRefusalResponse`, `McpHttpAuthRefusalBody`, `McpHttpAuthRefusalHeader`.
120
+
73
121
  ## Tools read request headers from `ctx.request`
74
122
 
75
123
  ```ts
@@ -135,7 +183,7 @@ const server = ai.mcp.serve([identity], {
135
183
  ```
136
184
 
137
185
  - `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.
138
- - `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.
186
+ - `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 `McpError` → its own code/message/data; other throws → 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.
139
187
  - 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.
140
188
  - Public types: `McpBeforeCall`, `McpCallRefusal`, `McpServeToolContext`, `McpStdioServeTransport`; `ToolContext` in `@warlock.js/ai` adds optional `requestId` and `principal`.
141
189
 
@@ -143,6 +191,106 @@ const server = ai.mcp.serve([identity], {
143
191
 
144
192
  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.
145
193
 
194
+ ## Control discovery, call envelopes and HTTP side routes
195
+
196
+ Use this complete example for hidden-but-callable tools, custom protocol errors,
197
+ unknown envelope policy, and side routes under the same authentication:
198
+
199
+ ```ts
200
+ import { randomBytes } from "node:crypto";
201
+ import { ai } from "@warlock.js/ai";
202
+ import { McpError } from "@warlock.js/ai-tools";
203
+
204
+ const tools = [ai.tools.calculator(), ai.tools.dateTime()];
205
+ const token = randomBytes(32).toString("hex");
206
+ const canCall = (principal: unknown, name: string) =>
207
+ principal === "member" && tools.some((tool) => tool.name === name);
208
+
209
+ const server = ai.mcp.serve(tools, {
210
+ name: "member-tools",
211
+ listFilter: async (context, descriptors) =>
212
+ context.principal === "member"
213
+ ? descriptors.filter((entry) => entry.name === "calculator")
214
+ : [],
215
+ validateCallParams: (_context, params) => {
216
+ const unsupported = Object.keys(params).filter(
217
+ (key) => !["name", "arguments", "_meta"].includes(key),
218
+ );
219
+ if (unsupported.length > 0) {
220
+ throw new McpError(-32600, "Unsupported call envelope", { fields: unsupported });
221
+ }
222
+ },
223
+ beforeCall: (context, tool) => {
224
+ if (!canCall(context.principal, tool.name)) {
225
+ throw new McpError(-32600, "Denied");
226
+ }
227
+ },
228
+ transport: {
229
+ type: "http",
230
+ path: "/mcp",
231
+ auth: { bearer: token, authenticate: () => "member" },
232
+ routes: [
233
+ {
234
+ method: "GET",
235
+ path: "/tool-policy",
236
+ handler: (context) => ({
237
+ body: { allowed: canCall(context.principal, context.url.searchParams.get("name") ?? "") },
238
+ }),
239
+ },
240
+ ],
241
+ },
242
+ });
243
+ const { url, close } = await server.start();
244
+ // `${url}/tool-policy?name=date_time` requires the same bearer token.
245
+ // date_time stays callable via tools/call, under beforeCall, despite being hidden.
246
+ await close();
247
+ ```
248
+
249
+ - `listFilter(context, tools)` runs per `tools/list`, on fresh wire descriptors
250
+ with schemas and exposed (possibly prefixed) names. Return a descriptor array,
251
+ possibly empty or reordered. It only affects discovery; call lookup and policy
252
+ are independent. A throwing filter fails that list request, leaving tools callable.
253
+ - `validateCallParams(context, params)` runs after the basic object/name check,
254
+ before lookup/schema validation/`beforeCall`/invoke. It sees the full read-only
255
+ params including unknown fields, `_meta` and `task`. Return nothing or throw.
256
+ Both hooks support promises and the same request id/principal/request/signal
257
+ as `beforeCall`, on HTTP and stdio. For stdio, use
258
+ `transport: { type: "stdio", principal: "member" }` with these same hooks.
259
+ - `McpError(code, message, data?)` extends `Error`. `beforeCall` and
260
+ `validateCallParams` preserve its integer code, message and optional data in
261
+ JSON-RPC errors. SDK `McpError` is recognized by Error/name/integer code without
262
+ a runtime SDK dependency; its `.message` is preserved including the SDK prefix.
263
+ Other thrown values stay `-32602`; tool execution errors stay `isError: true`.
264
+ - Defaults continue ignoring extra envelope fields. The server speaks
265
+ `2025-11-25`, `2025-06-18`, `2025-03-26`, and advertises no task capability.
266
+ [MCP 2025-11-25 task handling](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-support-and-handling)
267
+ requires ignoring task metadata without that capability. The example's
268
+ rejection of `task` is an explicit app policy; omit it to retain default behavior.
269
+ Allow `_meta`, which is protocol metadata.
270
+ - `transport.routes` is HTTP-only. Host/Origin and all configured bearer,
271
+ authorize and authenticate checks run before body reads/handlers. Auth is
272
+ optional on loopback and required elsewhere, just as for MCP. `context` has
273
+ `request`, `principal`, parsed `url`, raw UTF-8 `body`, `signal`; no request id.
274
+ Route bodies share `maxBodyBytes`. MCP-only JSON/header checks do not apply.
275
+ A handler returns `{ status?: number, headers?: Record<string, string>, body?: unknown }`;
276
+ status defaults to 200, body is JSON, omitted body is empty, HEAD suppresses it.
277
+ Exceptions return a generic 500. Disconnect and shutdown abort the signal;
278
+ routes share `close()` draining. Reuse your policy function explicitly;
279
+ route requests do not invoke `beforeCall`.
280
+ - Route suffix `/tool-policy` appends to MCP path `/mcp` or `/mcp/` as
281
+ `/mcp/tool-policy` (root MCP path `/` gives `/tool-policy`). Exact methods:
282
+ GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS. No wildcard, automatic HEAD or CORS.
283
+ Query strings do not affect matching; read them with `url.searchParams`.
284
+ Known path + wrong method → 405 with Allow; unknown path → 404.
285
+ - Route suffixes require nonempty segments containing letters/digits/`_-.~`.
286
+ Empty suffix, `/`, dot segments, percent escapes, repeated or trailing slashes,
287
+ queries, fragments and wildcards fail start with `McpServeError.type: "config"`.
288
+ With routes, the MCP base must be a canonical pathname. All methods on the
289
+ MCP endpoint are reserved, so routes cannot override it. Duplicate method/path
290
+ pairs fail start with `"collision"`; different methods on a suffix are allowed.
291
+ - Public types: `McpListFilter`, `McpValidateCallParams`, `McpHttpRoute`,
292
+ `McpHttpRouteContext`, `McpHttpRouteResponse`.
293
+
146
294
  ## Return native MCP results
147
295
 
148
296
  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:
@@ -161,3 +161,24 @@ if (error instanceof WebToolError && error.type === "denied-host") {
161
161
  - The `use-a-workspace` topic of the `warlock-js-ai-workspace` skill — the filesystem + shell tool belt (jailed `read_file` / `edit_file` / `run_shell` / …).
162
162
  - The `define-ai-tool` topic of the `warlock-js-ai` skill — the `tool()` / `ToolContract` seam every tool here is built on.
163
163
  - The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent that consumes these tools.
164
+
165
+ ## Add the belt to a discovery catalogue
166
+
167
+ Registration extends the core namespace, preserving `ai.tools.catalogue` and `ai.tools.measure`:
168
+
169
+ ```ts
170
+ import "@warlock.js/ai-tools";
171
+ import { ai, type ModelContract } from "@warlock.js/ai";
172
+
173
+ declare const model: ModelContract;
174
+ const surface = ai.tools.catalogue({
175
+ core: [ai.tools.calculator()],
176
+ searchable: [ai.tools.dateTime()],
177
+ limit: 8,
178
+ });
179
+ const assistant = ai.agent({ model, tools: surface.tools });
180
+ const measured = await ai.measure({ model, tools: surface.tools });
181
+ console.log(measured.parts.tools.tokens);
182
+ ```
183
+
184
+ The core plus `tool_search` / `tool_call` stays advertised. Hidden calls still traverse target policy, approval, validation, accounting and tracing. The `define-ai-tool` topic covers lazy descriptors, ranking and measurement comparisons. Native provider tools stay alongside the catalogue; eager ones included in its lists pass through unchanged and are excluded from discovery.