@kindgi/tools 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +86 -1
  3. package/dist/define.d.ts +138 -0
  4. package/dist/define.d.ts.map +1 -0
  5. package/dist/define.js +318 -0
  6. package/dist/define.js.map +1 -0
  7. package/dist/errors.d.ts +113 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +4 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/http.d.ts +2 -0
  12. package/dist/http.d.ts.map +1 -0
  13. package/dist/http.js +213 -0
  14. package/dist/http.js.map +1 -0
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +14 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/invoke.d.ts +27 -0
  20. package/dist/invoke.d.ts.map +1 -0
  21. package/dist/invoke.js +127 -0
  22. package/dist/invoke.js.map +1 -0
  23. package/dist/manifest.d.ts +23 -0
  24. package/dist/manifest.d.ts.map +1 -0
  25. package/dist/manifest.js +37 -0
  26. package/dist/manifest.js.map +1 -0
  27. package/dist/precondition.d.ts +25 -0
  28. package/dist/precondition.d.ts.map +1 -0
  29. package/dist/precondition.js +34 -0
  30. package/dist/precondition.js.map +1 -0
  31. package/dist/registry.d.ts +87 -0
  32. package/dist/registry.d.ts.map +1 -0
  33. package/dist/registry.js +171 -0
  34. package/dist/registry.js.map +1 -0
  35. package/dist/spec-registry.d.ts +48 -0
  36. package/dist/spec-registry.d.ts.map +1 -0
  37. package/dist/spec-registry.js +37 -0
  38. package/dist/spec-registry.js.map +1 -0
  39. package/dist/tool.schema.json +297 -0
  40. package/dist/types.d.ts +508 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +14 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +64 -4
  45. package/src/define.ts +537 -0
  46. package/src/errors.ts +139 -0
  47. package/src/http.ts +268 -0
  48. package/src/index.ts +68 -0
  49. package/src/invoke.ts +165 -0
  50. package/src/manifest.ts +40 -0
  51. package/src/precondition.ts +40 -0
  52. package/src/registry.ts +262 -0
  53. package/src/spec-registry.ts +78 -0
  54. package/src/tool.schema.json +297 -0
  55. package/src/types.ts +538 -0
package/src/define.ts ADDED
@@ -0,0 +1,537 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import * as addFormatsModule from 'ajv-formats';
5
+ import { Ajv2020 } from 'ajv/dist/2020.js';
6
+
7
+ import {
8
+ createSpecRegistry,
9
+ isZodSchema,
10
+ loadZodConverter,
11
+ loadZodConverterSync,
12
+ toJSONSchemaSync,
13
+ } from '@kindgi/schema';
14
+ import type {
15
+ AnySchema,
16
+ InvalidZodConversionError,
17
+ SpecRegistry,
18
+ ZodConverter,
19
+ ZodLikeSchema,
20
+ } from '@kindgi/schema';
21
+ import type { Result, ToolId } from '@kindgi/types';
22
+
23
+ import type {
24
+ InvalidSchemaError,
25
+ InvalidToolDefinitionError,
26
+ ToolError,
27
+ UnknownEffectError,
28
+ } from './errors.js';
29
+ import { type ToolSpecSynthesizerOptions, getToolSpecSynthesizer } from './spec-registry.js';
30
+ import toolSchema from './tool.schema.json' with { type: 'json' };
31
+ import {
32
+ EFFECT_KINDS,
33
+ type Tool,
34
+ type ToolContext,
35
+ type ToolManifest,
36
+ type ToolSpec,
37
+ } from './types.js';
38
+
39
+ const TOOL_SCHEMA_URI = 'https://kindgi.com/schemas/v1/tool.schema.json';
40
+
41
+ /**
42
+ * Exact semver regex — accepts `MAJOR.MINOR.PATCH[-PRERELEASE]`. Tool
43
+ * VERSIONS are always exact (a tool row ships a specific semver);
44
+ * agent bindings reference tools via `{ id, version: <range> }`
45
+ * where the range grammar is npm-style (parsed by `semver` at
46
+ * dispatch time).
47
+ */
48
+ const SEMVER_EXACT_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
49
+
50
+ // ajv-formats CJS/ESM interop — same trick used in @kindgi/schema.
51
+ type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
52
+ const addFormatsRaw = addFormatsModule as unknown;
53
+ const addFormats: AddFormatsFn =
54
+ typeof addFormatsRaw === 'function'
55
+ ? (addFormatsRaw as AddFormatsFn)
56
+ : (addFormatsRaw as { default: AddFormatsFn }).default;
57
+
58
+ let cachedRegistry: SpecRegistry | undefined;
59
+ function registry(): SpecRegistry {
60
+ if (cachedRegistry !== undefined) return cachedRegistry;
61
+ const built = createSpecRegistry([toolSchema]);
62
+ if (built.kind === 'err') {
63
+ throw new Error(`@kindgi/tools: bundled schema failed to compile: ${built.error.message}`);
64
+ }
65
+ cachedRegistry = built.value;
66
+ return cachedRegistry;
67
+ }
68
+
69
+ /**
70
+ * Sync converter resolver. `@kindgi/schema` handles caching + peer-dep
71
+ * absence; when zod is installed, this returns the converter on every
72
+ * call after the first at O(1); when zod is missing, this returns
73
+ * `undefined` and callers surface a typed `invalid-schema` error.
74
+ */
75
+ function resolveZodConverterSync(): ZodConverter | undefined {
76
+ return loadZodConverterSync();
77
+ }
78
+
79
+ /**
80
+ * Sanity-check that a candidate JSON Schema compiles under Draft 2020-12.
81
+ * We build a throwaway Ajv per call — cheap for authoring-time, safe under
82
+ * concurrent defineTool() calls.
83
+ */
84
+ function compilesAsSchema(
85
+ schema: unknown,
86
+ where: 'input' | 'output',
87
+ ): InvalidSchemaError | undefined {
88
+ const ajv = new Ajv2020({ strict: true, allErrors: true, allowUnionTypes: false });
89
+ addFormats(ajv);
90
+ try {
91
+ ajv.compile(schema as object);
92
+ return undefined;
93
+ } catch (cause) {
94
+ return {
95
+ code: 'invalid-schema',
96
+ message: `Tool ${where} schema does not compile: ${cause instanceof Error ? cause.message : String(cause)}`,
97
+ where,
98
+ cause,
99
+ };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Given an `AnySchema` (JSON Schema object OR Zod v4 schema), return the
105
+ * JSON Schema wire form. Converts synchronously using the pre-loaded Zod
106
+ * converter — callers must have called `ensureZodConverter()` first if
107
+ * they might be handed a Zod schema.
108
+ */
109
+ function resolveWireSchema(
110
+ schema: AnySchema,
111
+ where: 'input' | 'output',
112
+ converter: ZodConverter | undefined,
113
+ ): Result<
114
+ { readonly wire: Readonly<Record<string, unknown>>; readonly zod?: ZodLikeSchema },
115
+ InvalidSchemaError
116
+ > {
117
+ if (!isZodSchema(schema)) {
118
+ return { kind: 'ok', value: { wire: schema } };
119
+ }
120
+ // The input side for `input` (a `.default()` field is optional to the
121
+ // caller), the output side for `output` (the field is always there).
122
+ const converted = toJSONSchemaSync(schema, converter, where);
123
+ if (converted.kind === 'err') {
124
+ return { kind: 'err', error: toSchemaError(converted.error, where) };
125
+ }
126
+ return { kind: 'ok', value: { wire: converted.value, zod: schema } };
127
+ }
128
+
129
+ function toSchemaError(
130
+ err: InvalidZodConversionError,
131
+ where: 'input' | 'output',
132
+ ): InvalidSchemaError {
133
+ return {
134
+ code: 'invalid-schema',
135
+ message: `Tool ${where} Zod schema conversion failed: ${err.message}`,
136
+ where,
137
+ cause: err.cause,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Author-time inference from a Zod schema's `_zod.input` — what a caller
143
+ * may send. Non-Zod schemas fall through to `unknown`.
144
+ *
145
+ * A handler's input is typed `InferOutput` of the input schema: it gets
146
+ * the parsed value (defaults filled, transforms applied), not what the
147
+ * caller sent.
148
+ */
149
+ export type InferInput<T> = T extends { readonly _zod: { readonly input: infer I } } ? I : unknown;
150
+
151
+ /** Symmetric — inferred from `_zod.output`. */
152
+ export type InferOutput<T> = T extends { readonly _zod: { readonly output: infer O } }
153
+ ? O
154
+ : unknown;
155
+
156
+ /**
157
+ * The author-facing spec accepted by `defineTool`. Two generic parameters,
158
+ * one per schema slot, each of which is either a JSON Schema object or a
159
+ * Zod v4 schema. Two more generics carry the handler's actual argument
160
+ * and return types — defaulting to the schema-inferred types, but
161
+ * available for contextual inference from the handler literal: authors
162
+ * who write `handler: async () => ({ x: 1 })` get
163
+ * `TOutput = { x: number }` without declaring it separately.
164
+ */
165
+ export interface DefineToolSpec<
166
+ TInSchema extends AnySchema,
167
+ TOutSchema extends AnySchema,
168
+ THandlerIn = InferOutput<TInSchema>,
169
+ THandlerOut = InferOutput<TOutSchema>,
170
+ > extends Omit<ToolManifest, 'input' | 'output'> {
171
+ /**
172
+ * Tool input schema. Accepts either a Zod v4 schema (auto-converted
173
+ * to JSON Schema at author time; preserved on `tool.inputZod` for
174
+ * TS-side type inference) or a JSON Schema Draft 2020-12 object.
175
+ * Handed to the model as the tool's parameter schema so it can
176
+ * generate valid calls.
177
+ */
178
+ readonly input: TInSchema;
179
+ /**
180
+ * Tool output schema. Same Zod-or-JSON-Schema shape as `input`.
181
+ * `invokeTool` validates the handler's return value against this
182
+ * before surfacing it — an off-schema return fails with `bad-output`.
183
+ */
184
+ readonly output: TOutSchema;
185
+ /**
186
+ * Imperative handler. Mutually exclusive with `spec` — a tool is
187
+ * either author-written code or a declarative spec, never both.
188
+ * Omit when providing `spec`; the framework synthesizes a handler
189
+ * from the spec's kind via the registered synthesizer.
190
+ */
191
+ readonly handler?: (input: THandlerIn, ctx: ToolContext) => Promise<THandlerOut>;
192
+ }
193
+
194
+ /**
195
+ * The concrete Tool returned by `defineTool`. When the author passes a
196
+ * Zod schema at the `input` slot, `inputZod` is present and carries the
197
+ * original schema so callers can `z.infer<typeof tool.inputZod>` at the
198
+ * type level. Symmetric for `output`.
199
+ *
200
+ * Wire form (`tool.input` / `tool.output`) is always the converted JSON
201
+ * Schema — non-TS consumers see no change.
202
+ */
203
+ export type DefinedTool<
204
+ TInSchema extends AnySchema,
205
+ TOutSchema extends AnySchema,
206
+ THandlerIn = InferOutput<TInSchema>,
207
+ THandlerOut = InferOutput<TOutSchema>,
208
+ > = Tool<THandlerIn, THandlerOut> &
209
+ (TInSchema extends ZodLikeSchema
210
+ ? { readonly inputZod: TInSchema }
211
+ : { readonly inputZod?: undefined }) &
212
+ (TOutSchema extends ZodLikeSchema
213
+ ? { readonly outputZod: TOutSchema }
214
+ : { readonly outputZod?: undefined });
215
+
216
+ /**
217
+ * Build a validated `Tool` from a user-supplied definition.
218
+ *
219
+ * Two authoring surfaces coexist:
220
+ * 1. JSON Schema Draft 2020-12 objects — the only shape that crosses
221
+ * the wire. Non-TS consumers use this.
222
+ * 2. Zod v4 schemas — TS-user sugar. Framework detects at author time,
223
+ * converts via `z.toJSONSchema()`, caches the JSON Schema wire form
224
+ * on `tool.input` / `tool.output`, preserves the original Zod schema
225
+ * on `tool.inputZod` / `tool.outputZod` for static type inference.
226
+ *
227
+ * Verifies (fail-fast):
228
+ * 1. The manifest projection conforms to `@kindgi/specs/tool.schema.json`.
229
+ * 2. Every declared `effects[].kind` is in the closed `EFFECT_KINDS` set.
230
+ * 3. `input` and `output` are legal JSON Schema Draft 2020-12 documents
231
+ * (after Zod conversion, when applicable).
232
+ *
233
+ * The `handler` is not validated at author time — its input/output are
234
+ * enforced by `invokeTool` at each call. Returning a tool from this function
235
+ * is the runtime's promise that it's structurally safe to invoke.
236
+ *
237
+ * `zod` is a **peer dependency**. Workspaces that never author with Zod
238
+ * never install it and never pay any resolution cost. Callers that DO
239
+ * pass a Zod schema without `zod` installed get a clean
240
+ * `invalid-schema` error surfaced through the returned `Result`.
241
+ */
242
+ export function defineTool<
243
+ const TInSchema extends AnySchema,
244
+ const TOutSchema extends AnySchema,
245
+ THandlerIn = InferOutput<TInSchema>,
246
+ THandlerOut = InferOutput<TOutSchema>,
247
+ >(
248
+ spec: DefineToolSpec<TInSchema, TOutSchema, THandlerIn, THandlerOut>,
249
+ options?: DefineToolOptions,
250
+ ): Result<DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>, ToolError>;
251
+ /**
252
+ * Overload with explicit data-type generics, for JSON-Schema-only
253
+ * authors who want to state `<TInput, TOutput>` themselves. Without the
254
+ * generics, inference derives the handler types from a Zod schema, or
255
+ * (for JSON Schema) from the handler literal's return type.
256
+ */
257
+ export function defineTool<TInput = unknown, TOutput = unknown>(
258
+ spec: Tool<TInput, TOutput>,
259
+ options?: DefineToolOptions,
260
+ ): Result<Tool<TInput, TOutput>, ToolError>;
261
+ export function defineTool(
262
+ spec: DefineToolSpec<AnySchema, AnySchema>,
263
+ options: DefineToolOptions = {},
264
+ ): Result<DefinedTool<AnySchema, AnySchema>, ToolError> {
265
+ // Fast path: JSON-Schema-only authoring never needs the peer dep.
266
+ const authoredWithZod = isZodSchema(spec.input) || isZodSchema(spec.output);
267
+ const converter = authoredWithZod ? resolveZodConverterSync() : undefined;
268
+ return finalizeDefinition(spec, converter, options);
269
+ }
270
+
271
+ /** How `defineTool` builds a tool; for the runtime, not for pack authors. */
272
+ export interface DefineToolOptions {
273
+ /** What a declarative `spec`'s synthesized handler gets (the runtime's guarded `fetch`). */
274
+ readonly synthesizer?: ToolSpecSynthesizerOptions;
275
+ }
276
+
277
+ /**
278
+ * Async companion to `defineTool` — resolves the Zod peer dependency
279
+ * via dynamic `import()` instead of Node's `createRequire`. Prefer this
280
+ * in environments that prohibit `createRequire` (edge runtimes, some
281
+ * bundler configurations) OR for pack-init code that already lives on
282
+ * the async path.
283
+ */
284
+ export async function defineToolAsync<
285
+ const TInSchema extends AnySchema,
286
+ const TOutSchema extends AnySchema,
287
+ THandlerIn = InferOutput<TInSchema>,
288
+ THandlerOut = InferOutput<TOutSchema>,
289
+ >(
290
+ spec: DefineToolSpec<TInSchema, TOutSchema, THandlerIn, THandlerOut>,
291
+ options: DefineToolOptions = {},
292
+ ): Promise<Result<DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>, ToolError>> {
293
+ const authoredWithZod = isZodSchema(spec.input) || isZodSchema(spec.output);
294
+ const converter = authoredWithZod ? await loadZodConverter() : undefined;
295
+ return finalizeDefinition(spec, converter, options);
296
+ }
297
+
298
+ function finalizeDefinition<
299
+ TInSchema extends AnySchema,
300
+ TOutSchema extends AnySchema,
301
+ THandlerIn,
302
+ THandlerOut,
303
+ >(
304
+ spec: DefineToolSpec<TInSchema, TOutSchema, THandlerIn, THandlerOut>,
305
+ converter: ZodConverter | undefined,
306
+ options: DefineToolOptions,
307
+ ): Result<DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>, ToolError> {
308
+ // `version` is required on every tool manifest.
309
+ // Deep semver grammar checked here via a lightweight regex; range
310
+ // parsing (for agent-side `tools[].version` refs) happens elsewhere
311
+ // via the `semver` library at dispatch time. This regex accepts
312
+ // exact semvers only — tool VERSIONS are exact (a tool ships a
313
+ // specific `1.2.3`); ranges live on the agent binding.
314
+ const specVersion = (spec as { readonly version?: unknown }).version;
315
+ if (typeof specVersion !== 'string' || !SEMVER_EXACT_RE.test(specVersion)) {
316
+ return {
317
+ kind: 'err',
318
+ error: {
319
+ code: 'invalid-tool-definition',
320
+ message:
321
+ '`version` is required on every tool manifest and must be an exact semver (e.g., `1.2.3`, `1.2.3-beta.1`). Agent bindings then reference tools via `{ id, version: <range> }`.',
322
+ issues: [
323
+ {
324
+ path: '/version',
325
+ message: 'version must be a non-empty exact semver string',
326
+ },
327
+ ],
328
+ },
329
+ };
330
+ }
331
+
332
+ // handler / spec mutual-exclusion + spec synthesis.
333
+ const authorHandler = (spec as { readonly handler?: unknown }).handler;
334
+ const declarativeSpec = (spec as { readonly spec?: ToolSpec }).spec;
335
+ if (authorHandler !== undefined && declarativeSpec !== undefined) {
336
+ return {
337
+ kind: 'err',
338
+ error: {
339
+ code: 'invalid-tool-definition',
340
+ message:
341
+ '`handler` and `spec` are mutually exclusive. Provide an imperative handler OR a declarative spec, not both.',
342
+ issues: [{ path: '/handler', message: 'handler + spec both set' }],
343
+ },
344
+ };
345
+ }
346
+ if (authorHandler === undefined && declarativeSpec === undefined) {
347
+ return {
348
+ kind: 'err',
349
+ error: {
350
+ code: 'invalid-tool-definition',
351
+ message:
352
+ 'A tool must declare exactly one of `handler` (imperative) or `spec` (declarative).',
353
+ issues: [{ path: '/handler', message: 'handler + spec both absent' }],
354
+ },
355
+ };
356
+ }
357
+ let effectiveHandler = authorHandler as
358
+ | ((input: THandlerIn, ctx: ToolContext) => Promise<THandlerOut>)
359
+ | undefined;
360
+ if (declarativeSpec !== undefined) {
361
+ const synth = getToolSpecSynthesizer(declarativeSpec.kind);
362
+ if (synth === undefined) {
363
+ return {
364
+ kind: 'err',
365
+ error: {
366
+ code: 'invalid-tool-definition',
367
+ message: `No synthesizer registered for tool spec kind "${declarativeSpec.kind}". Import the package that owns this kind (e.g. \`@kindgi/tools\` for 'http') before calling \`defineTool\`.`,
368
+ issues: [{ path: '/spec/kind', message: `unknown kind "${declarativeSpec.kind}"` }],
369
+ },
370
+ };
371
+ }
372
+ effectiveHandler = synth(
373
+ declarativeSpec,
374
+ (spec as { readonly id: string }).id,
375
+ options.synthesizer,
376
+ ) as (input: THandlerIn, ctx: ToolContext) => Promise<THandlerOut>;
377
+ }
378
+
379
+ const inputResolved = resolveWireSchema(spec.input, 'input', converter);
380
+ if (inputResolved.kind === 'err') return { kind: 'err', error: inputResolved.error };
381
+ const outputResolved = resolveWireSchema(spec.output, 'output', converter);
382
+ if (outputResolved.kind === 'err') return { kind: 'err', error: outputResolved.error };
383
+
384
+ // Rebuild the spec against the JSON Schema wire form. Downstream code
385
+ // (invokeTool, manifest projection, MCP publish) reads `.input` /
386
+ // `.output`; the original Zod objects are stored separately on
387
+ // `inputZod` / `outputZod`.
388
+ const wireSpec = {
389
+ ...(spec as unknown as ToolManifest),
390
+ input: inputResolved.value.wire,
391
+ output: outputResolved.value.wire,
392
+ handler: effectiveHandler,
393
+ } as unknown as Tool<THandlerIn, THandlerOut>;
394
+
395
+ const manifest: ToolManifest = stripHandler(wireSpec);
396
+
397
+ const schemaResult = registry().validate<ToolManifest>(TOOL_SCHEMA_URI, manifest);
398
+ if (schemaResult.kind === 'err') {
399
+ const err = schemaResult.error;
400
+ if (err.code !== 'validation-error') {
401
+ throw new Error(`@kindgi/tools: unexpected schema error ${err.code}: ${err.message}`);
402
+ }
403
+ return { kind: 'err', error: toDefinitionError(err) };
404
+ }
405
+
406
+ const badEffect = checkEffectKinds(manifest);
407
+ if (badEffect) return { kind: 'err', error: badEffect };
408
+
409
+ const badInput = compilesAsSchema(manifest.input, 'input');
410
+ if (badInput) return { kind: 'err', error: badInput };
411
+ const badOutput = compilesAsSchema(manifest.output, 'output');
412
+ if (badOutput) return { kind: 'err', error: badOutput };
413
+
414
+ const tool = {
415
+ ...wireSpec,
416
+ ...(inputResolved.value.zod !== undefined && { inputZod: inputResolved.value.zod }),
417
+ ...(outputResolved.value.zod !== undefined && { outputZod: outputResolved.value.zod }),
418
+ } as unknown as DefinedTool<TInSchema, TOutSchema, THandlerIn, THandlerOut>;
419
+
420
+ return { kind: 'ok', value: tool };
421
+ }
422
+
423
+ function stripHandler<TInput, TOutput>(spec: Tool<TInput, TOutput>): ToolManifest {
424
+ // Structural copy dropping `handler`. `handler` is a function and Ajv rejects
425
+ // functions during validation, so it MUST be stripped before schema check.
426
+ const { handler: _handler, inputZod: _iz, outputZod: _oz, ...manifest } = spec;
427
+ return manifest;
428
+ }
429
+
430
+ function toDefinitionError(err: {
431
+ readonly errors: readonly unknown[];
432
+ readonly message: string;
433
+ }): InvalidToolDefinitionError {
434
+ const issues = err.errors.map((e) => {
435
+ const ajvErr = e as { instancePath?: unknown; message?: unknown };
436
+ return {
437
+ path: typeof ajvErr.instancePath === 'string' ? ajvErr.instancePath : '',
438
+ message: typeof ajvErr.message === 'string' ? ajvErr.message : 'validation failed',
439
+ };
440
+ });
441
+ return { code: 'invalid-tool-definition', message: err.message, issues };
442
+ }
443
+
444
+ function checkEffectKinds(manifest: ToolManifest): UnknownEffectError | undefined {
445
+ const kinds = new Set<string>(EFFECT_KINDS);
446
+ for (const eff of manifest.effects ?? []) {
447
+ if (!kinds.has(eff.kind)) {
448
+ return {
449
+ code: 'unknown-effect',
450
+ message: `Tool "${manifest.id}" declares unknown effect kind "${eff.kind}"`,
451
+ kind: eff.kind,
452
+ };
453
+ }
454
+ }
455
+ return undefined;
456
+ }
457
+
458
+ /**
459
+ * Validate an arbitrary wire manifest against `@kindgi/specs/tool.schema.json`
460
+ * + the same effect / input-schema / output-schema checks `defineTool`
461
+ * runs, but without requiring a runtime `handler`. Used by transport
462
+ * layers (HTTP API, MCP publish) that accept metadata-only tool
463
+ * registrations — the actual handler is bound server-side via the
464
+ * runtime registry.
465
+ *
466
+ * The returned value has the manifest fields only; there is no
467
+ * `handler` (metadata-only shape).
468
+ */
469
+ export function validateToolManifest(
470
+ manifest: unknown,
471
+ ): Result<ToolManifest, InvalidToolDefinitionError | InvalidSchemaError | UnknownEffectError> {
472
+ if (manifest === null || typeof manifest !== 'object') {
473
+ return {
474
+ kind: 'err',
475
+ error: {
476
+ code: 'invalid-tool-definition',
477
+ message: 'Tool manifest must be an object',
478
+ issues: [{ path: '', message: 'must be an object' }],
479
+ },
480
+ };
481
+ }
482
+ // `defineTool` strips `handler` before schema validation; forward the
483
+ // wire object unchanged. `handler` isn't in the ToolManifest shape,
484
+ // so any incidental value on the wire is ignored by validation.
485
+ const { handler: _ignored, ...candidate } = manifest as Record<string, unknown>;
486
+ const schemaResult = registry().validate<ToolManifest>(TOOL_SCHEMA_URI, candidate);
487
+ if (schemaResult.kind === 'err') {
488
+ const err = schemaResult.error;
489
+ if (err.code !== 'validation-error') {
490
+ throw new Error(`@kindgi/tools: unexpected schema error ${err.code}: ${err.message}`);
491
+ }
492
+ return { kind: 'err', error: toDefinitionError(err) };
493
+ }
494
+ const parsed = candidate as unknown as ToolManifest;
495
+ const badEffect = checkEffectKinds(parsed);
496
+ if (badEffect) return { kind: 'err', error: badEffect };
497
+ const badInput = compilesAsSchema(parsed.input, 'input');
498
+ if (badInput) return { kind: 'err', error: badInput };
499
+ const badOutput = compilesAsSchema(parsed.output, 'output');
500
+ if (badOutput) return { kind: 'err', error: badOutput };
501
+ const badUrl = checkHttpUrlTemplate(parsed);
502
+ if (badUrl) return { kind: 'err', error: badUrl };
503
+ return { kind: 'ok', value: parsed };
504
+ }
505
+
506
+ /**
507
+ * An HTTP tool's `urlTemplate` fills `{placeholders}` in its path and
508
+ * query only. In the scheme, host or port, a run's input (or a model's
509
+ * arguments) would choose where the server sends the request.
510
+ */
511
+ function checkHttpUrlTemplate(manifest: ToolManifest): InvalidToolDefinitionError | undefined {
512
+ const spec = manifest.spec;
513
+ if (spec?.kind !== 'http') return undefined;
514
+ const origin = /^(https?):\/\/([^/?#]*)/i.exec(spec.urlTemplate);
515
+ const message =
516
+ origin === null
517
+ ? 'must start with http:// or https:// and a host'
518
+ : /[{}]/.test(origin[2] ?? '')
519
+ ? 'fills {placeholders} in its path and query only, never in its host or port'
520
+ : (origin[2] ?? '') === ''
521
+ ? 'must name a host'
522
+ : undefined;
523
+ if (message === undefined) return undefined;
524
+ return {
525
+ code: 'invalid-tool-definition',
526
+ message: `Tool "${manifest.id}" spec.urlTemplate ${message}`,
527
+ issues: [{ path: '/spec/urlTemplate', message }],
528
+ };
529
+ }
530
+
531
+ /** Exported for tests and downstream registry building. */
532
+ export function toolIdOf(spec: Pick<ToolManifest, 'id'>): ToolId {
533
+ return spec.id;
534
+ }
535
+
536
+ /** The `$id` of the JSON Schema this loader validates against. */
537
+ export const TOOL_SCHEMA_URI_EXPORTED = TOOL_SCHEMA_URI;
package/src/errors.ts ADDED
@@ -0,0 +1,139 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { ToolId } from '@kindgi/types';
5
+
6
+ /**
7
+ * Errors emitted by @kindgi/tools. Every variant carries a `code` for
8
+ * pattern matching; messages are human-readable, not API contract.
9
+ */
10
+ export type ToolError =
11
+ | InvalidToolDefinitionError
12
+ | InvalidSchemaError
13
+ | UnknownEffectError
14
+ | DuplicateToolError
15
+ | DuplicateToolVersionError
16
+ | ToolNotFoundError
17
+ | ToolVersionNotFoundError
18
+ | InvalidVersionRangeError
19
+ | ToolVersionUnresolvableError
20
+ | InputValidationError
21
+ | OutputValidationError
22
+ | HandlerError
23
+ | PreconditionFailedError;
24
+
25
+ /** `defineTool` was called with something the schema loader rejects. */
26
+ export interface InvalidToolDefinitionError {
27
+ readonly code: 'invalid-tool-definition';
28
+ readonly message: string;
29
+ readonly issues: readonly { readonly path: string; readonly message: string }[];
30
+ }
31
+
32
+ /** The provided `input` or `output` is not a valid JSON Schema Draft 2020-12. */
33
+ export interface InvalidSchemaError {
34
+ readonly code: 'invalid-schema';
35
+ readonly message: string;
36
+ readonly where: 'input' | 'output';
37
+ readonly cause: unknown;
38
+ }
39
+
40
+ /** An effect kind not in `EFFECT_KINDS` was declared. */
41
+ export interface UnknownEffectError {
42
+ readonly code: 'unknown-effect';
43
+ readonly message: string;
44
+ readonly kind: string;
45
+ }
46
+
47
+ /**
48
+ * ToolRegistry.register was called with an id that already exists AND the
49
+ * registry doesn't support multiple versions per id. `createToolRegistry`
50
+ * holds several versions per id and returns `duplicate-tool-version`
51
+ * instead when the same (id, version) is registered twice.
52
+ */
53
+ export interface DuplicateToolError {
54
+ readonly code: 'duplicate-tool';
55
+ readonly message: string;
56
+ readonly id: ToolId;
57
+ }
58
+
59
+ /** Same `(id, version)` pair was registered twice — versions must be unique. */
60
+ export interface DuplicateToolVersionError {
61
+ readonly code: 'duplicate-tool-version';
62
+ readonly message: string;
63
+ readonly id: ToolId;
64
+ readonly version: string;
65
+ }
66
+
67
+ /** ToolRegistry.get was called with an id that isn't registered. */
68
+ export interface ToolNotFoundError {
69
+ readonly code: 'tool-not-found';
70
+ readonly message: string;
71
+ readonly id: ToolId;
72
+ }
73
+
74
+ /** The id exists but the requested exact version doesn't. */
75
+ export interface ToolVersionNotFoundError {
76
+ readonly code: 'tool-version-not-found';
77
+ readonly message: string;
78
+ readonly id: ToolId;
79
+ readonly version: string;
80
+ }
81
+
82
+ /** The provided version range isn't a valid semver range (per npm grammar). */
83
+ export interface InvalidVersionRangeError {
84
+ readonly code: 'invalid-version-range';
85
+ readonly message: string;
86
+ readonly id: ToolId;
87
+ readonly range: string;
88
+ }
89
+
90
+ /**
91
+ * `resolve(id, range)` found the id but no registered version satisfies
92
+ * the range. Distinct from `tool-not-found` (no id at all) and from
93
+ * `invalid-version-range` (grammar error on the range string).
94
+ */
95
+ export interface ToolVersionUnresolvableError {
96
+ readonly code: 'tool-version-unresolvable';
97
+ readonly message: string;
98
+ readonly id: ToolId;
99
+ readonly range: string;
100
+ readonly availableVersions: readonly string[];
101
+ }
102
+
103
+ /** invokeTool: the caller's input didn't validate against tool.input. */
104
+ export interface InputValidationError {
105
+ readonly code: 'input-validation-failed';
106
+ readonly message: string;
107
+ readonly toolId: ToolId;
108
+ readonly errors: readonly unknown[];
109
+ }
110
+
111
+ /** invokeTool: the handler returned something that didn't validate against tool.output. */
112
+ export interface OutputValidationError {
113
+ readonly code: 'output-validation-failed';
114
+ readonly message: string;
115
+ readonly toolId: ToolId;
116
+ readonly errors: readonly unknown[];
117
+ }
118
+
119
+ /** invokeTool: the handler threw. */
120
+ export interface HandlerError {
121
+ readonly code: 'handler-error';
122
+ readonly message: string;
123
+ readonly toolId: ToolId;
124
+ readonly cause: unknown;
125
+ }
126
+
127
+ /**
128
+ * invokeTool: the runtime refused to run the tool — something it declares
129
+ * wasn't there (a `ToolPreconditionError` from the runtime's handler
130
+ * wrapper). The tool's own code never ran.
131
+ */
132
+ export interface PreconditionFailedError {
133
+ readonly code: 'precondition-failed';
134
+ readonly message: string;
135
+ readonly toolId: ToolId;
136
+ /** The runtime's code for what was missing (`secret-unavailable`, …). */
137
+ readonly reason: string;
138
+ readonly cause: unknown;
139
+ }