@kindgi/tools 0.0.0-bootstrap.0 → 0.1.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.
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/http.ts ADDED
@@ -0,0 +1,268 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { type ToolSpecSynthesizerOptions, registerToolSpecSynthesizer } from './spec-registry.js';
5
+ import type { HttpAuthSpec, HttpRequestBodySpec, HttpToolSpec, ToolContext } from './types.js';
6
+
7
+ /**
8
+ * First-party synthesizer for `HttpToolSpec` (kind: 'http').
9
+ *
10
+ * `defineTool({spec: {kind: 'http', ...}})` produces a runtime
11
+ * `Tool` whose handler is auto-generated to perform the outbound
12
+ * HTTP call. The registered tool dispatches, validates, and travels
13
+ * identically to a hand-written in-process tool — kernel, invoke
14
+ * pipeline, and MCP discovery treat it as a normal `Tool`. The
15
+ * declarative `spec` field on the manifest is preserved for
16
+ * middleware and for JSON export / cross-process registration.
17
+ *
18
+ * Behavior at invoke time:
19
+ *
20
+ * 1. Input JSON-Schema validation runs (via the standard invoke
21
+ * pipeline); handler receives a validated input.
22
+ * 2. `urlTemplate` placeholders are substituted from input keys.
23
+ * Missing key → throw. Substituted keys are still available in
24
+ * the request body (unless `requestBody.kind === 'input-passthrough'`
25
+ * with different semantics documented below).
26
+ * 3. `authorization` is resolved via `ctx.resolveSecret(secretRef)`.
27
+ * If the tool declares authorization but `ctx.resolveSecret` is
28
+ * undefined, the handler throws a clear error rather than
29
+ * silently dropping the credential.
30
+ * 4. Static headers are added as-is.
31
+ * 5. Request body is built per `requestBody.kind`:
32
+ * - `'json-input'` (default when `requestBody` absent for
33
+ * POST/PUT/PATCH): JSON-serialize the tool's input MINUS
34
+ * keys already consumed by `urlTemplate`.
35
+ * - `'input-passthrough'`: JSON-serialize the entire input
36
+ * without removing URL-consumed keys.
37
+ * - `'text'`: substitute `{param}` placeholders in `template`
38
+ * from input; send as `Content-Type: text/plain`.
39
+ * 6. `fetch()` with an AbortController that inherits from
40
+ * `ctx.abortSignal` and adds a `timeoutMs` deadline (default
41
+ * 30_000). Aborts propagate as `handler-error`.
42
+ * 7. Status is checked against `successStatus` (default 200-299).
43
+ * Failures throw with the response body as the message.
44
+ * 8. Response body is parsed as JSON (default) or returned as
45
+ * text. Output JSON-Schema validation runs in the invoke
46
+ * pipeline; handler doesn't validate.
47
+ */
48
+ function buildHttpHandler(
49
+ spec: HttpToolSpec,
50
+ toolId: string,
51
+ options: ToolSpecSynthesizerOptions = {},
52
+ ): (input: unknown, ctx: ToolContext) => Promise<unknown> {
53
+ const doFetch = options.fetch ?? fetch;
54
+ return async (input, ctx) => {
55
+ const inputObj = coerceInputToObject(input, toolId);
56
+ const { url, consumedKeys } = substituteUrl(spec.urlTemplate, inputObj, toolId);
57
+
58
+ const headers: Record<string, string> = {};
59
+ for (const h of spec.headers ?? []) {
60
+ headers[h.name] = substituteTemplate(h.value, inputObj);
61
+ }
62
+
63
+ if (spec.authorization !== undefined) {
64
+ applyAuth(
65
+ headers,
66
+ spec.authorization,
67
+ ctx,
68
+ toolId,
69
+ await resolveAuthSecret(spec.authorization, ctx, toolId),
70
+ );
71
+ }
72
+
73
+ const { body, contentType } = buildBody(spec, inputObj, consumedKeys);
74
+ if (body !== undefined && contentType !== undefined && headers['Content-Type'] === undefined) {
75
+ headers['Content-Type'] = contentType;
76
+ }
77
+
78
+ const timeoutMs = spec.timeoutMs ?? 30_000;
79
+ const controller = new AbortController();
80
+ const timeoutHandle = setTimeout(
81
+ () => controller.abort(new Error(`http-tool "${toolId}" timed out after ${timeoutMs}ms`)),
82
+ timeoutMs,
83
+ );
84
+ // Chain the caller's abort signal into ours — if the run is
85
+ // cancelled mid-flight, the outbound request cancels too.
86
+ const upstreamAbort = (): void => controller.abort(ctx.abortSignal.reason);
87
+ if (ctx.abortSignal.aborted) {
88
+ clearTimeout(timeoutHandle);
89
+ throw ctx.abortSignal.reason ?? new Error('aborted');
90
+ }
91
+ ctx.abortSignal.addEventListener('abort', upstreamAbort, { once: true });
92
+
93
+ let response: Response;
94
+ try {
95
+ response = await doFetch(url, {
96
+ method: spec.method,
97
+ headers,
98
+ ...(body !== undefined && { body }),
99
+ signal: controller.signal,
100
+ });
101
+ } finally {
102
+ clearTimeout(timeoutHandle);
103
+ ctx.abortSignal.removeEventListener('abort', upstreamAbort);
104
+ }
105
+
106
+ const successMin = spec.successStatus?.min ?? 200;
107
+ const successMax = spec.successStatus?.max ?? 299;
108
+ if (response.status < successMin || response.status > successMax) {
109
+ const text = await safeText(response);
110
+ throw new Error(
111
+ `http-tool "${toolId}" got status ${response.status} ${response.statusText || ''} — ${text.slice(0, 512)}`,
112
+ );
113
+ }
114
+
115
+ const parseJson = spec.parseJson ?? true;
116
+ if (!parseJson) {
117
+ return await response.text();
118
+ }
119
+ const rawText = await response.text();
120
+ if (rawText.length === 0) return null;
121
+ try {
122
+ return JSON.parse(rawText);
123
+ } catch (cause) {
124
+ throw new Error(
125
+ `http-tool "${toolId}" response was not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
126
+ );
127
+ }
128
+ };
129
+ }
130
+
131
+ // Register at module load. Framework barrel imports this module
132
+ // (`packages/tools/src/index.ts`) so any consumer that pulls in
133
+ // `@kindgi/tools` gets the 'http' kind registered automatically.
134
+ registerToolSpecSynthesizer('http', buildHttpHandler);
135
+
136
+ function coerceInputToObject(input: unknown, toolId: string): Record<string, unknown> {
137
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
138
+ throw new Error(
139
+ `http-tool "${toolId}" requires an object input for URL substitution; got ${typeof input}`,
140
+ );
141
+ }
142
+ return input as Record<string, unknown>;
143
+ }
144
+
145
+ /**
146
+ * Substitute `{name}` placeholders in `template` with values from
147
+ * `input`. Returns the substituted URL plus the set of keys that
148
+ * were consumed (used by `buildBody` to strip them from a
149
+ * `json-input` body).
150
+ *
151
+ * URL-safe encoding: values are `encodeURIComponent`'d. Numeric,
152
+ * boolean, and string primitives are coerced via `String()`; nested
153
+ * objects or arrays throw.
154
+ */
155
+ function substituteUrl(
156
+ template: string,
157
+ input: Record<string, unknown>,
158
+ toolId: string,
159
+ ): { readonly url: string; readonly consumedKeys: ReadonlySet<string> } {
160
+ const consumed = new Set<string>();
161
+ const url = template.replace(/\{(\w+)\}/g, (_, key: string) => {
162
+ if (!(key in input)) {
163
+ throw new Error(
164
+ `http-tool "${toolId}" urlTemplate references "{${key}}" but input has no such key`,
165
+ );
166
+ }
167
+ const value = input[key];
168
+ if (
169
+ value === null ||
170
+ value === undefined ||
171
+ typeof value === 'object' ||
172
+ typeof value === 'function' ||
173
+ typeof value === 'symbol'
174
+ ) {
175
+ throw new Error(
176
+ `http-tool "${toolId}" urlTemplate key "{${key}}" must be a primitive; got ${typeof value}`,
177
+ );
178
+ }
179
+ consumed.add(key);
180
+ return encodeURIComponent(String(value));
181
+ });
182
+ return { url, consumedKeys: consumed };
183
+ }
184
+
185
+ /**
186
+ * Substitute `{name}` placeholders in a plain string (used for
187
+ * header values + `text` body templates). Unlike `substituteUrl`,
188
+ * this does NOT URL-encode values — headers/text bodies want raw
189
+ * strings. Missing keys throw.
190
+ */
191
+ function substituteTemplate(template: string, input: Record<string, unknown>): string {
192
+ return template.replace(/\{(\w+)\}/g, (_, key: string) => {
193
+ if (!(key in input)) return `{${key}}`; // preserve if not in input (may be literal braces)
194
+ const value = input[key];
195
+ if (value === null || value === undefined) return '';
196
+ return String(value);
197
+ });
198
+ }
199
+
200
+ async function resolveAuthSecret(
201
+ auth: HttpAuthSpec,
202
+ ctx: ToolContext,
203
+ toolId: string,
204
+ ): Promise<string> {
205
+ if (ctx.resolveSecret === undefined) {
206
+ throw new Error(
207
+ `http-tool "${toolId}" declares authorization but ToolContext.resolveSecret is not wired. The dispatch site must populate resolveSecret from the deployment's SecretBinding.`,
208
+ );
209
+ }
210
+ return ctx.resolveSecret(auth.secretRef);
211
+ }
212
+
213
+ function applyAuth(
214
+ headers: Record<string, string>,
215
+ auth: HttpAuthSpec,
216
+ _ctx: ToolContext,
217
+ _toolId: string,
218
+ resolvedSecret: string,
219
+ ): void {
220
+ switch (auth.kind) {
221
+ case 'bearer':
222
+ headers.Authorization = `Bearer ${resolvedSecret}`;
223
+ return;
224
+ case 'header':
225
+ headers[auth.headerName] = resolvedSecret;
226
+ return;
227
+ }
228
+ }
229
+
230
+ function buildBody(
231
+ spec: HttpToolSpec,
232
+ input: Record<string, unknown>,
233
+ consumedKeys: ReadonlySet<string>,
234
+ ): { body: string | undefined; contentType: string | undefined } {
235
+ // Methods that don't carry a body by convention.
236
+ if (spec.method === 'GET' && spec.requestBody === undefined) {
237
+ return { body: undefined, contentType: undefined };
238
+ }
239
+ const kind: HttpRequestBodySpec['kind'] = spec.requestBody?.kind ?? 'json-input';
240
+ if (kind === 'json-input') {
241
+ const remaining: Record<string, unknown> = {};
242
+ for (const [k, v] of Object.entries(input)) {
243
+ if (!consumedKeys.has(k)) remaining[k] = v;
244
+ }
245
+ // Empty remaining input → still send an empty body so servers
246
+ // can distinguish "no body" from "empty body". For POST-with-only-
247
+ // URL-params the caller can explicitly configure `input-passthrough`
248
+ // or a `text` body if the server expects that shape.
249
+ if (Object.keys(remaining).length === 0) {
250
+ return { body: '', contentType: undefined };
251
+ }
252
+ return { body: JSON.stringify(remaining), contentType: 'application/json' };
253
+ }
254
+ if (kind === 'input-passthrough') {
255
+ return { body: JSON.stringify(input), contentType: 'application/json' };
256
+ }
257
+ // kind === 'text'
258
+ const template = (spec.requestBody as { kind: 'text'; template: string }).template;
259
+ return { body: substituteTemplate(template, input), contentType: 'text/plain' };
260
+ }
261
+
262
+ async function safeText(response: Response): Promise<string> {
263
+ try {
264
+ return await response.text();
265
+ } catch {
266
+ return '<no body>';
267
+ }
268
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ export {
5
+ defineTool,
6
+ defineToolAsync,
7
+ validateToolManifest,
8
+ TOOL_SCHEMA_URI_EXPORTED as TOOL_SCHEMA_URI,
9
+ } from './define.js';
10
+ export type {
11
+ DefineToolOptions,
12
+ DefineToolSpec,
13
+ DefinedTool,
14
+ InferInput,
15
+ InferOutput,
16
+ } from './define.js';
17
+ // Side-effect import — registers the 'http' spec synthesizer at
18
+ // package load so `defineTool({spec: {kind: 'http', ...}})` works
19
+ // out of the box for any consumer that imports @kindgi/tools.
20
+ import './http.js';
21
+ export { registerToolSpecSynthesizer, getToolSpecSynthesizer } from './spec-registry.js';
22
+ export type { ToolSpecSynthesizer, ToolSpecSynthesizerOptions } from './spec-registry.js';
23
+ export { invokeTool } from './invoke.js';
24
+ export { ToolPreconditionError, isToolPreconditionError } from './precondition.js';
25
+ export type { InvokeToolOptions } from './invoke.js';
26
+ export { createToolRegistry } from './registry.js';
27
+ export type { ToolRegistry, ToolResolution } from './registry.js';
28
+ export { toManifest, toMcpManifest } from './manifest.js';
29
+ export { EFFECT_KINDS } from './types.js';
30
+ export type {
31
+ AnyTool,
32
+ CodeArtifactRef,
33
+ Effect,
34
+ EffectKind,
35
+ HttpAuthSpec,
36
+ HttpHeaderSpec,
37
+ HttpMethod,
38
+ HttpRequestBodySpec,
39
+ HttpToolSpec,
40
+ JsonSchema,
41
+ McpToolManifest,
42
+ Need,
43
+ NetworkPolicy,
44
+ RuntimeLimits,
45
+ SandboxMode,
46
+ Tool,
47
+ ToolContext,
48
+ ToolManifest,
49
+ ToolSecretRef,
50
+ ToolSpec,
51
+ TypedNeeds,
52
+ } from './types.js';
53
+ export type {
54
+ DuplicateToolError,
55
+ DuplicateToolVersionError,
56
+ HandlerError,
57
+ InputValidationError,
58
+ InvalidSchemaError,
59
+ InvalidToolDefinitionError,
60
+ InvalidVersionRangeError,
61
+ OutputValidationError,
62
+ PreconditionFailedError,
63
+ ToolError,
64
+ ToolNotFoundError,
65
+ ToolVersionNotFoundError,
66
+ ToolVersionUnresolvableError,
67
+ UnknownEffectError,
68
+ } from './errors.js';
package/src/invoke.ts ADDED
@@ -0,0 +1,165 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { ValidateFunction } from 'ajv';
5
+ import * as addFormatsModule from 'ajv-formats';
6
+ import { Ajv2020 } from 'ajv/dist/2020.js';
7
+
8
+ import { parseWithSchema } from '@kindgi/schema';
9
+ import type { Result } from '@kindgi/types';
10
+
11
+ import type {
12
+ HandlerError,
13
+ InputValidationError,
14
+ OutputValidationError,
15
+ PreconditionFailedError,
16
+ ToolError,
17
+ } from './errors.js';
18
+ import { isToolPreconditionError } from './precondition.js';
19
+ import type { JsonSchema, Tool, ToolContext } from './types.js';
20
+
21
+ type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
22
+ const addFormatsRaw = addFormatsModule as unknown;
23
+ const addFormats: AddFormatsFn =
24
+ typeof addFormatsRaw === 'function'
25
+ ? (addFormatsRaw as AddFormatsFn)
26
+ : (addFormatsRaw as { default: AddFormatsFn }).default;
27
+
28
+ /**
29
+ * Cache compiled validators keyed by the schema *object reference*. Tools
30
+ * are defined once and reused, so schema identity is stable — the WeakMap
31
+ * releases entries when the tool is dropped.
32
+ */
33
+ const inputCache: WeakMap<JsonSchema, ValidateFunction> = new WeakMap();
34
+ const outputCache: WeakMap<JsonSchema, ValidateFunction> = new WeakMap();
35
+
36
+ /**
37
+ * A compiled validator. Input validators fill in each property's JSON
38
+ * Schema `default` (`useDefaults`) — the data they check is a copy of the
39
+ * caller's.
40
+ */
41
+ function compile(schema: JsonSchema, side: 'input' | 'output'): ValidateFunction {
42
+ const cache = side === 'input' ? inputCache : outputCache;
43
+ const cached = cache.get(schema);
44
+ if (cached !== undefined) return cached;
45
+ const ajv = new Ajv2020({
46
+ strict: true,
47
+ allErrors: true,
48
+ allowUnionTypes: false,
49
+ ...(side === 'input' && { useDefaults: true }),
50
+ });
51
+ addFormats(ajv);
52
+ const validator = ajv.compile(schema as object);
53
+ cache.set(schema, validator);
54
+ return validator;
55
+ }
56
+
57
+ /**
58
+ * The input a handler receives: validated against `tool.input` with its
59
+ * defaults filled in, then — for a Zod-authored tool — parsed by its Zod
60
+ * schema, so defaults, transforms and refinements apply and the handler
61
+ * gets what its `z.infer` type says.
62
+ */
63
+ async function prepareInput(
64
+ tool: Pick<Tool, 'id' | 'input' | 'inputZod'>,
65
+ input: unknown,
66
+ ): Promise<Result<unknown, InputValidationError>> {
67
+ const candidate = structuredClone(input);
68
+ const validator = compile(tool.input, 'input');
69
+ if (!validator(candidate)) {
70
+ return { kind: 'err', error: inputError(tool.id, validator.errors ?? []) };
71
+ }
72
+ if (tool.inputZod === undefined) return { kind: 'ok', value: candidate };
73
+ const parsed = await parseWithSchema(tool.inputZod, candidate);
74
+ if (parsed.kind === 'ok') return parsed;
75
+ return {
76
+ kind: 'err',
77
+ error: inputError(
78
+ tool.id,
79
+ parsed.issues.map((i) => ({
80
+ instancePath: (i.path ?? []).map((p) => `/${String(p)}`).join(''),
81
+ message: i.message,
82
+ })),
83
+ ),
84
+ };
85
+ }
86
+
87
+ function inputError(toolId: Tool['id'], errors: readonly unknown[]): InputValidationError {
88
+ return {
89
+ code: 'input-validation-failed',
90
+ message: `Input for tool "${toolId}" failed validation`,
91
+ toolId,
92
+ errors,
93
+ };
94
+ }
95
+
96
+ export interface InvokeToolOptions {
97
+ /**
98
+ * Skip validation of the handler's return value against `tool.output`.
99
+ * Trade-off: faster, but a broken handler can produce data that violates
100
+ * the declared contract without the runtime noticing. Default: false.
101
+ */
102
+ readonly skipOutputValidation?: boolean;
103
+ }
104
+
105
+ /**
106
+ * Invoke a tool with a caller-supplied input and context. The path is:
107
+ * 1. Validate a copy of `input` against `tool.input`, filling in its
108
+ * defaults, and — for a Zod-authored tool — parse it with
109
+ * `tool.inputZod` (defaults, transforms, refinements). Reject on
110
+ * failure.
111
+ * 2. Call `tool.handler(prepared, ctx)`. Catch any throw and return
112
+ * `handler-error` — invocation is total, throws don't escape.
113
+ * 3. Validate the return value against `tool.output` schema (unless the
114
+ * caller opts out).
115
+ *
116
+ * Validation cost is amortised via a schema-object-keyed cache — the second
117
+ * invocation of the same tool reuses the compiled Ajv validator.
118
+ */
119
+ export async function invokeTool<TInput = unknown, TOutput = unknown>(
120
+ tool: Tool<TInput, TOutput>,
121
+ input: unknown,
122
+ ctx: ToolContext,
123
+ options: InvokeToolOptions = {},
124
+ ): Promise<Result<TOutput, ToolError>> {
125
+ const prepared = await prepareInput(tool, input);
126
+ if (prepared.kind === 'err') return prepared;
127
+
128
+ let output: TOutput;
129
+ try {
130
+ output = await tool.handler(prepared.value as TInput, ctx);
131
+ } catch (cause) {
132
+ if (isToolPreconditionError(cause)) {
133
+ const refused: PreconditionFailedError = {
134
+ code: 'precondition-failed',
135
+ message: `Tool "${tool.id}" was not run: ${cause.reason}: ${cause.message}`,
136
+ toolId: tool.id,
137
+ reason: cause.reason,
138
+ cause,
139
+ };
140
+ return { kind: 'err', error: refused };
141
+ }
142
+ const err: HandlerError = {
143
+ code: 'handler-error',
144
+ message: `Tool "${tool.id}" handler threw: ${cause instanceof Error ? cause.message : String(cause)}`,
145
+ toolId: tool.id,
146
+ cause,
147
+ };
148
+ return { kind: 'err', error: err };
149
+ }
150
+
151
+ if (!options.skipOutputValidation) {
152
+ const outputValidator = compile(tool.output, 'output');
153
+ if (!outputValidator(output)) {
154
+ const err: OutputValidationError = {
155
+ code: 'output-validation-failed',
156
+ message: `Tool "${tool.id}" handler produced output that failed validation`,
157
+ toolId: tool.id,
158
+ errors: outputValidator.errors ?? [],
159
+ };
160
+ return { kind: 'err', error: err };
161
+ }
162
+ }
163
+
164
+ return { kind: 'ok', value: output };
165
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { AnyTool, McpToolManifest, ToolManifest } from './types.js';
5
+
6
+ /**
7
+ * Project a runtime `Tool` to its serialisable manifest — everything
8
+ * declared in `@kindgi/specs/tool.schema.json` minus the `handler` binding.
9
+ *
10
+ * Use for wire transport, MCP exposure (via `toMcpManifest`), and
11
+ * discovery endpoints. The result round-trips through JSON.
12
+ */
13
+ export function toManifest(tool: AnyTool): ToolManifest {
14
+ // Strip runtime-only bindings that never cross the wire: the handler
15
+ // function itself, and the optional Zod authoring schemas preserved
16
+ // for TS type inference on the runtime object.
17
+ const { handler: _handler, inputZod: _inputZod, outputZod: _outputZod, ...manifest } = tool;
18
+ return manifest;
19
+ }
20
+
21
+ /**
22
+ * Project a `Tool` to the MCP `tools/list` shape:
23
+ * `{ name, description, inputSchema, outputSchema? }`.
24
+ *
25
+ * MCP's tool discovery format is narrower than our own — no needs, no
26
+ * effects, no transport hint. Those stay on the Kindgi side (used by
27
+ * policy, capabilities, scheduling); the MCP client sees only what the
28
+ * spec exposes.
29
+ *
30
+ * `outputSchema` is optional in MCP; we always emit it so clients that
31
+ * read it get typed returns.
32
+ */
33
+ export function toMcpManifest(tool: AnyTool): McpToolManifest {
34
+ return {
35
+ name: tool.id,
36
+ description: tool.description,
37
+ inputSchema: tool.input,
38
+ outputSchema: tool.output,
39
+ };
40
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * A runtime's refusal to run a tool: something the tool declares wasn't
6
+ * there — a secret it declares couldn't be resolved, say. A runtime that
7
+ * wraps a tool's handler throws it before calling the tool's own code;
8
+ * `invokeTool` reports it as `precondition-failed` (not `handler-error`:
9
+ * the handler never ran), with the runtime's `reason` code.
10
+ *
11
+ * Recognized by a registered symbol, not `instanceof`, so a runtime and
12
+ * a tool that load different copies of this package still agree.
13
+ */
14
+ const PRECONDITION_FAILED = Symbol.for('kindgi.tools.precondition-failed');
15
+
16
+ export class ToolPreconditionError extends Error {
17
+ readonly [PRECONDITION_FAILED] = true;
18
+
19
+ /**
20
+ * @param reason The runtime's code for what was missing
21
+ * (`secret-unavailable`, `env-not-configured`, …).
22
+ * @param message What was missing, by name; never a secret's value.
23
+ */
24
+ constructor(
25
+ readonly reason: string,
26
+ message: string,
27
+ ) {
28
+ super(message);
29
+ this.name = 'ToolPreconditionError';
30
+ }
31
+ }
32
+
33
+ /** Is `value` a `ToolPreconditionError` (from any copy of this package)? */
34
+ export function isToolPreconditionError(value: unknown): value is ToolPreconditionError {
35
+ return (
36
+ typeof value === 'object' &&
37
+ value !== null &&
38
+ (value as { readonly [PRECONDITION_FAILED]?: unknown })[PRECONDITION_FAILED] === true
39
+ );
40
+ }