@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.
- package/LICENSE +201 -0
- package/README.md +86 -1
- package/dist/define.d.ts +138 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +318 -0
- package/dist/define.js.map +1 -0
- package/dist/errors.d.ts +113 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +213 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/invoke.d.ts +27 -0
- package/dist/invoke.d.ts.map +1 -0
- package/dist/invoke.js +127 -0
- package/dist/invoke.js.map +1 -0
- package/dist/manifest.d.ts +23 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +37 -0
- package/dist/manifest.js.map +1 -0
- package/dist/precondition.d.ts +25 -0
- package/dist/precondition.d.ts.map +1 -0
- package/dist/precondition.js +34 -0
- package/dist/precondition.js.map +1 -0
- package/dist/registry.d.ts +87 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +171 -0
- package/dist/registry.js.map +1 -0
- package/dist/spec-registry.d.ts +48 -0
- package/dist/spec-registry.d.ts.map +1 -0
- package/dist/spec-registry.js +37 -0
- package/dist/spec-registry.js.map +1 -0
- package/dist/tool.schema.json +297 -0
- package/dist/types.d.ts +508 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -4
- package/src/define.ts +537 -0
- package/src/errors.ts +139 -0
- package/src/http.ts +268 -0
- package/src/index.ts +68 -0
- package/src/invoke.ts +165 -0
- package/src/manifest.ts +40 -0
- package/src/precondition.ts +40 -0
- package/src/registry.ts +262 -0
- package/src/spec-registry.ts +78 -0
- package/src/tool.schema.json +297 -0
- 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
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|