@feniix/bridgekit 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,182 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@feniix/bridgekit` are documented here. The format
4
+ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.8.0] - Unreleased
8
+
9
+ ### Breaking
10
+
11
+ - **`PortableValidationError` renamed `path` → `field`.** No deprecation alias.
12
+ Update assertions accordingly. `field` is derived from TypeBox's structured
13
+ error data, not the localized message string. Resolves
14
+ [#33](https://github.com/feniix/bridgekit/issues/33).
15
+
16
+ ```ts
17
+ // Before (0.7 and earlier)
18
+ assert.equal(result.structuredContent.validationErrors[0].path, "/file_path");
19
+
20
+ // After (0.8+)
21
+ assert.equal(result.structuredContent.validationErrors[0].field, "file_path");
22
+ ```
23
+
24
+ Entries *inside* `validationErrors[]` share the `{ field, message }` shape
25
+ with handler-emitted domain-failure data so iteration code can read `.field`
26
+ uniformly. The `validationErrors[]` array itself only exists on the
27
+ `kind: "validation"` branch of `PortableToolErrorDetails` — always narrow
28
+ with `isValidationFailure` (or the discriminator) before iterating.
29
+
30
+ - **`validationErrors[]` cardinality change.** Previously, a TypeBox error
31
+ with the message `must have required properties X, Y` produced a single
32
+ `PortableValidationError` whose message listed all missing props. Now,
33
+ `executePortableTool` emits **one `PortableValidationError` per missing
34
+ property**, with `field` set to the missing prop name and `message`
35
+ normalized to `must have required property <field>`. Consumers asserting
36
+ `validationErrors.length === 1` for a multi-prop missing case will break.
37
+
38
+ ```ts
39
+ // Schema: Type.Object({ file_path: Type.String(), count: Type.Number() })
40
+ // Args: {}
41
+
42
+ // Before (0.7)
43
+ // validationErrors.length === 1
44
+ // validationErrors[0] === { path: "/", message: "must have required properties file_path, count" }
45
+
46
+ // After (0.8+)
47
+ // validationErrors.length === 2
48
+ // validationErrors === [
49
+ // { field: "file_path", message: "must have required property file_path" },
50
+ // { field: "count", message: "must have required property count" },
51
+ // ]
52
+ ```
53
+
54
+ - **Duplicate `(field, message)` pairs are deduplicated.** Union/discriminator
55
+ mismatches previously emitted multiple identical `(field, message)` entries
56
+ (one per failed union branch); they are now collapsed. Dedup only removes
57
+ entries — it never introduces new ones — so `validationErrors.length` can
58
+ only decrease relative to raw TypeBox output. Consumers locking in exact
59
+ counts on union failures may need to adjust.
60
+
61
+ ### Notes on field derivation
62
+
63
+ - `field` is derived from TypeBox's structured error:
64
+ - `keyword === "required"` reads `params.requiredProperties` and emits one
65
+ entry per missing prop name.
66
+ - `keyword === "additionalProperties"` reads `params.additionalProperties`
67
+ and emits one entry per offending key name. (Previously fell back to the
68
+ `(root)` sentinel — the offending key is now surfaced as `field`.)
69
+ - Other errors take the last meaningful segment of `instancePath`
70
+ (e.g. `/text` → `"text"`, `/items/0/name` → `"name"`).
71
+ - The structured-access path is locale- and message-format-independent.
72
+ - For array-element validation, `field` is the leaf segment, which can be a
73
+ numeric index (e.g. `field: "0"`) and loses the path context. If positional
74
+ information matters, keep schemas permissive at the BridgeKit boundary and
75
+ validate positionally in your handler.
76
+ - For root-level schema failures with empty `instancePath` (e.g. `null` passed
77
+ to a `Type.Object` schema), `field` is the sentinel `"(root)"` rather than
78
+ the empty string.
79
+
80
+ ## [0.7.0] - 2026-05-26
81
+
82
+ ### Changed
83
+
84
+ - **`registerPiTools` now returns `isError` results by default instead of
85
+ throwing.** The third parameter is a new `RegisterPiToolsOptions` with
86
+ `errorHandling?: "throw" | "return"`; the default is `"return"`, mirroring
87
+ the MCP adapter. Portable validation failures and portable `isError: true`
88
+ results now surface as `{ content, details, isError: true }` on the pi side.
89
+ Unexpected exceptions thrown by tool handlers are also caught in return
90
+ mode and surfaced as `{ content: [{type:"text", text: message}], details: {}, isError: true }`,
91
+ matching MCP. This makes the documented "pi throws, MCP returns" asymmetry
92
+ symmetric. Resolves [#2](https://github.com/feniix/bridgekit/issues/2).
93
+ - pi success-path results now include `isError: false` explicitly so consumers
94
+ can write `result.isError === false` symmetrically across pi and MCP.
95
+
96
+ ### Added
97
+
98
+ - `isValidationFailure(result)` and `isDomainFailure(result)` type guards in
99
+ the root entrypoint. Use them after `result.isError` to narrow between
100
+ BridgeKit-emitted validation failures and tool-emitted domain failures.
101
+ Resolves [#30](https://github.com/feniix/bridgekit/issues/30).
102
+ - `PortableValidationFailure` and `PortableDomainFailure` types alongside the
103
+ guards.
104
+ - `RegisterPiToolsOptions` exported from `@feniix/bridgekit/pi`.
105
+ - A `DeprecationWarning` (code `BRIDGEKIT_PI_THROW_DEPRECATED`) is emitted
106
+ once per process when `registerPiTools(..., { errorHandling: "throw" })`
107
+ is invoked, so consumers see the migration signal at runtime instead of
108
+ only in JSDoc.
109
+
110
+ ### Deprecated
111
+
112
+ - `registerPiTools(..., { errorHandling: "throw" })` is retained for one
113
+ minor-version cycle so existing pi extensions can migrate without source
114
+ changes. It will be removed in 1.0. Only the `"throw"` *value* is
115
+ deprecated; the `errorHandling` option itself stays. Migrate by switching
116
+ to the default `"return"` mode and branching on `result.isError`.
117
+
118
+ ### Removed
119
+
120
+ - The duplicate `PortableValidationError` re-export from `src/core/execute-tool.ts`.
121
+ The canonical export still lives in `src/core/define-tool.ts` and is surfaced
122
+ through `@feniix/bridgekit` unchanged.
123
+
124
+ ### Migration
125
+
126
+ ```ts
127
+ // Before (0.6 and earlier)
128
+ try {
129
+ const result = await piTool.execute(toolCallId, params, signal);
130
+ } catch (err) {
131
+ if (isPortableToolExecutionError(err)) {
132
+ if (err.details.kind === "validation") { /* TypeBox errors */ }
133
+ else { /* domain failure */ }
134
+ }
135
+ }
136
+
137
+ // After (0.7+)
138
+ const result = await piTool.execute(toolCallId, params, signal);
139
+ if (result.isError) {
140
+ if (isValidationFailure(result)) { /* result.structuredContent.validationErrors */ }
141
+ else if (isDomainFailure(result)) { /* handler-level error */ }
142
+ }
143
+ ```
144
+
145
+ ### Notes on the guards
146
+
147
+ `isValidationFailure` narrows `structuredContent` to
148
+ `{ kind: "validation"; tool: string; validationErrors: PortableValidationError[] }`
149
+ because `executePortableTool` produces that exact shape on TypeBox rejection.
150
+ The guard matches on shape, not provenance: a handler that returns the same
151
+ shape on its own will also satisfy it. Keep handler-emitted error shapes
152
+ distinct from the validation discriminator if that ambiguity matters.
153
+
154
+ `isDomainFailure` narrows only to `result & { isError: true }`. It does **not**
155
+ synthesize a `kind: "domain"` discriminator on `structuredContent` because the
156
+ tool's handler chose `structuredContent` freely and BridgeKit does not rewrite
157
+ it on the wire.
158
+
159
+ #### Caveat: `kind` is not the same between modes
160
+
161
+ The two `errorHandling` modes expose the failure discriminator on different
162
+ fields and with different semantics. Branch by mode, not by reading `.kind`
163
+ across both shapes:
164
+
165
+ | Mode | Where to read | Validation failure | Handler-emitted failure |
166
+ |------|---------------|--------------------|-------------------------|
167
+ | `"return"` (default, 0.7+) | `result.structuredContent` | `{ kind: "validation", tool, validationErrors }` (BridgeKit-emitted) | Whatever the handler returned — no synthesized `kind` |
168
+ | `"throw"` (deprecated) | `(err as PortableToolExecutionError).details` | `{ kind: "validation", tool, validationErrors }` | `{ kind: "domain", ...rest }` (BridgeKit synthesizes) |
169
+
170
+ In return mode, prefer the type guards (`isValidationFailure`,
171
+ `isDomainFailure`) over reading `kind` directly. In throw mode, the
172
+ `PortableToolExecutionError.details.kind` discriminator is stable but the
173
+ whole mode is deprecated.
174
+
175
+ ### Scope: where to call the guards
176
+
177
+ The guards take a `PortableToolResult` — the value `executePortableTool`
178
+ returns, or what your portable tool's own `execute` returns at the seam
179
+ between BridgeKit and a host. They are **not** designed to operate on the pi
180
+ adapter's wire object (`{ content, details, isError }`), which exposes
181
+ `details` instead of `structuredContent`. Calling the guards on a pi wire
182
+ object will always return `false`.
package/README.md CHANGED
@@ -21,6 +21,8 @@ Read these files in order:
21
21
  import {
22
22
  definePortableTool,
23
23
  executePortableTool,
24
+ isDomainFailure,
25
+ isValidationFailure,
24
26
  type PortableTool,
25
27
  type PortableToolBuiltInHost,
26
28
  type PortableToolContext,
@@ -88,7 +90,50 @@ export default function extension(pi: Parameters<typeof registerPiTools>[0]) {
88
90
  }
89
91
  ```
90
92
 
91
- Portable validation failures and portable results with `isError: true` reject with `PortableToolExecutionError` in pi so the host sees a native tool failure. The error message is the portable `text`, and `.details` is populated from `structuredContent` or `details`. Progress updates from `ctx.progress?.(...)` map to pi tool updates.
93
+ By default (`errorHandling: "return"`, as of 0.7) the pi adapter mirrors the MCP adapter: portable validation failures and portable `isError: true` results surface as `{ content, details, isError: true }` so consumers can branch on `result.isError` and narrow with `isValidationFailure` / `isDomainFailure`. Unexpected handler exceptions are caught and surfaced as `{ content: [{type:"text", text: message}], details: {}, isError: true }`, matching MCP. Success-path results include `isError: false` explicitly so consumers can use the same strict-equality checks across both adapters. `details` is populated from `structuredContent` first, then from `details`, then `{}`. Progress updates from `ctx.progress?.(...)` map to pi tool updates.
94
+
95
+ The pre-0.7 behavior — throw `PortableToolExecutionError` on `isError` — is still available for one deprecation cycle:
96
+
97
+ ```ts
98
+ registerPiTools(pi, createTools(), { errorHandling: "throw" });
99
+ ```
100
+
101
+ Selecting `errorHandling: "throw"` emits a `DeprecationWarning` (code `BRIDGEKIT_PI_THROW_DEPRECATED`) once per process. Only the `"throw"` value is deprecated; the `errorHandling` option itself remains. Migrate by switching to the default and branching on the returned result:
102
+
103
+ ```ts
104
+ // Before (0.6 and earlier)
105
+ try {
106
+ const result = await piTool.execute(...);
107
+ } catch (err) {
108
+ if (isPortableToolExecutionError(err)) {
109
+ if (err.details.kind === "validation") { /* TypeBox errors */ }
110
+ else { /* domain failure */ }
111
+ }
112
+ }
113
+
114
+ // After (0.7+)
115
+ const result = await piTool.execute(...);
116
+ if (result.isError) {
117
+ if (isValidationFailure(result)) {
118
+ // validationErrors is Array<{ field: string; message: string }>.
119
+ for (const { field, message } of result.structuredContent.validationErrors) {
120
+ // ...
121
+ }
122
+ } else if (isDomainFailure(result)) { /* handler-level error */ }
123
+ }
124
+ ```
125
+
126
+ `PortableValidationError` exposes `{ field, message }`. `field` is derived from TypeBox's structured error data — required-property errors read `params.requiredProperties` (so a prop named `"a,b"` survives intact and the value is locale-independent); other errors take the last meaningful segment of the offending JSON pointer (`text`, not `/text`). One error is emitted per missing required property, and duplicate `(field, message)` pairs (e.g. from union mismatches) are deduplicated. Validation and domain errors share this `{ field, message }` shape, so a consumer reading `.field` does not need to branch on which kind of failure produced the entry. (`path` was the pre-0.8.0 name; it has been removed.)
127
+
128
+ For array-element validation, `field` is the leaf segment, which can be a numeric index (e.g. `field: "0"`) and loses path context. For root-level schema failures with empty `instancePath` (e.g. `null` passed to a `Type.Object` schema), `field` is the sentinel `"(root)"`.
129
+
130
+ The two modes expose the failure discriminator on different fields. In the
131
+ new default `"return"` mode, prefer the result guards over inspecting a
132
+ `kind` field directly — handler-emitted failures do not carry a synthesized
133
+ `kind`. In the deprecated `"throw"` mode, the discriminator lives on
134
+ `(err as PortableToolExecutionError).details.kind` (`"validation"` or
135
+ `"domain"`). The guards operate on a `PortableToolResult`; they are not
136
+ designed to be called on the pi adapter's wire object.
92
137
 
93
138
  ## MCP adapter
94
139
 
@@ -127,7 +172,9 @@ if (process.argv[1] && realpathIfPossible(resolve(process.argv[1])) === realpath
127
172
 
128
173
  The MCP adapter uses low-level `tools/list` and `tools/call` handlers so TypeBox schemas are exposed as JSON Schema directly. It intentionally does not expose a high-level `registerMcpTools` helper.
129
174
 
130
- MCP invalid input and portable `isError: true` results return `CallToolResult` with `isError: true`. Exporting a server-options factory keeps MCP entrypoints import-passive and easy to test without starting stdio.
175
+ Portable validation failures and portable `isError: true` results return `CallToolResult` with `isError: true`. `structuredContent` is preserved; `details` is used only as a fallback when `structuredContent` is absent. Exporting a server-options factory keeps MCP entrypoints import-passive and easy to test without starting stdio.
176
+
177
+ The two adapters now read in parallel: invalid args and portable `isError` results return `{ isError: true }` from both hosts by default, and the same result-guard helpers (`isValidationFailure`, `isDomainFailure`) narrow them on either side.
131
178
 
132
179
  ## Custom host typing
133
180
 
@@ -11,6 +11,7 @@ type PiToolUpdate = {
11
11
  type PiToolResult = {
12
12
  content: PiContent[];
13
13
  details: Record<string, unknown>;
14
+ isError?: boolean;
14
15
  };
15
16
  type PiToolDefinition = {
16
17
  name: string;
@@ -22,10 +23,51 @@ type PiToolDefinition = {
22
23
  export type PiToolRegistration = {
23
24
  registerTool(tool: PiToolDefinition): unknown;
24
25
  };
26
+ /**
27
+ * How the pi adapter surfaces portable `isError: true` results (both
28
+ * BridgeKit-emitted validation failures and tool-emitted domain failures).
29
+ *
30
+ * - `"return"` (default as of 0.7) — return `{ content, details, isError: true }`
31
+ * so the result mirrors the MCP adapter's `CallToolResult`. Consumers branch
32
+ * on `result.isError` and may narrow with `isValidationFailure` /
33
+ * `isDomainFailure`.
34
+ * - `"throw"` (deprecated, scheduled for removal in 1.0) — throw
35
+ * `PortableToolExecutionError`. Available for one minor-version cycle so
36
+ * existing pi extensions can migrate without source changes. Selecting
37
+ * this value emits a `DeprecationWarning` (code
38
+ * `BRIDGEKIT_PI_THROW_DEPRECATED`) once per process.
39
+ */
40
+ export interface RegisterPiToolsOptions {
41
+ /**
42
+ * Controls how `isError: true` portable results are surfaced. Defaults to
43
+ * `"return"`. Passing `"throw"` is deprecated and will be removed in 1.0;
44
+ * only the `"throw"` value is deprecated, not the option itself.
45
+ *
46
+ * @default "return"
47
+ */
48
+ errorHandling?: "throw" | "return";
49
+ }
25
50
  export declare class PortableToolExecutionError extends Error {
26
51
  readonly details: PortableToolErrorDetails;
27
52
  constructor(result: PortableToolResult);
28
53
  }
29
54
  export declare function isPortableToolExecutionError(error: unknown): error is PortableToolExecutionError;
30
- export declare function registerPiTools(pi: PiToolRegistration, tools: readonly PortableTool<TSchema>[]): void;
55
+ /**
56
+ * Register a set of portable tools with a pi tool registry.
57
+ *
58
+ * Defaults to `errorHandling: "return"` so pi behaves like the MCP adapter:
59
+ * portable `isError: true` results and TypeBox validation failures surface as
60
+ * `{ content, details, isError: true }` rather than thrown exceptions. Pass
61
+ * `{ errorHandling: "throw" }` to opt into the pre-0.7 throwing behavior for
62
+ * one deprecation cycle.
63
+ *
64
+ * @example
65
+ * // 0.7+ default: errors as data.
66
+ * registerPiTools(pi, tools);
67
+ *
68
+ * @example
69
+ * // Deprecated, but available through 0.x.
70
+ * registerPiTools(pi, tools, { errorHandling: "throw" });
71
+ */
72
+ export declare function registerPiTools(pi: PiToolRegistration, tools: readonly PortableTool<TSchema>[], options?: RegisterPiToolsOptions): void;
31
73
  export {};
@@ -1,16 +1,13 @@
1
1
  import { executePortableTool } from "../core/execute-tool.js";
2
+ import { isValidationFailure } from "../core/result-guards.js";
2
3
  function toPiDetails(result) {
3
4
  return result.structuredContent ?? result.details ?? {};
4
5
  }
5
- function isValidationDetails(source) {
6
- return source.kind === "validation" && typeof source.tool === "string" && Array.isArray(source.validationErrors);
7
- }
8
6
  function toPortableToolErrorDetails(result) {
9
- const source = toPiDetails(result);
10
- if (isValidationDetails(source)) {
11
- return source;
7
+ if (isValidationFailure(result)) {
8
+ return result.structuredContent;
12
9
  }
13
- const { kind: _ignored, ...rest } = source;
10
+ const { kind: _ignored, ...rest } = toPiDetails(result);
14
11
  return { kind: "domain", ...rest };
15
12
  }
16
13
  export class PortableToolExecutionError extends Error {
@@ -24,7 +21,34 @@ export class PortableToolExecutionError extends Error {
24
21
  export function isPortableToolExecutionError(error) {
25
22
  return error instanceof PortableToolExecutionError;
26
23
  }
27
- export function registerPiTools(pi, tools) {
24
+ let throwModeDeprecationWarned = false;
25
+ /**
26
+ * Register a set of portable tools with a pi tool registry.
27
+ *
28
+ * Defaults to `errorHandling: "return"` so pi behaves like the MCP adapter:
29
+ * portable `isError: true` results and TypeBox validation failures surface as
30
+ * `{ content, details, isError: true }` rather than thrown exceptions. Pass
31
+ * `{ errorHandling: "throw" }` to opt into the pre-0.7 throwing behavior for
32
+ * one deprecation cycle.
33
+ *
34
+ * @example
35
+ * // 0.7+ default: errors as data.
36
+ * registerPiTools(pi, tools);
37
+ *
38
+ * @example
39
+ * // Deprecated, but available through 0.x.
40
+ * registerPiTools(pi, tools, { errorHandling: "throw" });
41
+ */
42
+ export function registerPiTools(pi, tools, options = {}) {
43
+ const errorHandling = options.errorHandling ?? "return";
44
+ if (errorHandling === "throw" && !throwModeDeprecationWarned) {
45
+ throwModeDeprecationWarned = true;
46
+ process.emitWarning('`registerPiTools(..., { errorHandling: "throw" })` is deprecated and will be removed in 1.0. ' +
47
+ 'Migrate to the default `"return"` mode and branch on `result.isError`.', {
48
+ type: "DeprecationWarning",
49
+ code: "BRIDGEKIT_PI_THROW_DEPRECATED",
50
+ });
51
+ }
28
52
  for (const tool of tools) {
29
53
  pi.registerTool({
30
54
  name: tool.name,
@@ -32,22 +56,37 @@ export function registerPiTools(pi, tools) {
32
56
  description: tool.description,
33
57
  parameters: tool.parameters,
34
58
  async execute(_toolCallId, params, signal, onUpdate, _ctx) {
35
- const result = await executePortableTool(tool, params, {
36
- host: "pi",
37
- signal,
38
- progress(update) {
39
- onUpdate?.({
40
- content: [{ type: "text", text: update.text }],
41
- details: toPiDetails(update),
42
- });
43
- },
44
- });
45
- if (result.isError) {
59
+ let result;
60
+ try {
61
+ result = await executePortableTool(tool, params, {
62
+ host: "pi",
63
+ signal,
64
+ progress(update) {
65
+ onUpdate?.({
66
+ content: [{ type: "text", text: update.text }],
67
+ details: toPiDetails(update),
68
+ });
69
+ },
70
+ });
71
+ }
72
+ catch (error) {
73
+ if (errorHandling === "throw") {
74
+ throw error;
75
+ }
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ return {
78
+ content: [{ type: "text", text: message }],
79
+ details: {},
80
+ isError: true,
81
+ };
82
+ }
83
+ if (result.isError && errorHandling === "throw") {
46
84
  throw new PortableToolExecutionError(result);
47
85
  }
48
86
  return {
49
87
  content: [{ type: "text", text: result.text }],
50
88
  details: toPiDetails(result),
89
+ isError: result.isError === true,
51
90
  };
52
91
  },
53
92
  });
@@ -33,7 +33,7 @@ export type PortableToolErrorDetails = {
33
33
  kind: "domain";
34
34
  } & Record<string, unknown>);
35
35
  export interface PortableValidationError {
36
- path: string;
36
+ field: string;
37
37
  message: string;
38
38
  }
39
39
  export type PortableToolBuiltInHost = "pi" | "mcp" | "test";
@@ -1,6 +1,5 @@
1
1
  import type { TSchema } from "typebox";
2
2
  import type { PortableTool, PortableToolBuiltInHost, PortableToolContext, PortableToolResult, PortableValidationError } from "./define-tool.js";
3
- export type { PortableValidationError };
4
3
  export declare function validatePortableToolArgs<THost extends string = PortableToolBuiltInHost>(tool: PortableTool<TSchema, THost>, args: unknown): {
5
4
  ok: true;
6
5
  } | {
@@ -1,22 +1,58 @@
1
1
  import { Check, Errors } from "typebox/value";
2
+ const ROOT_FIELD = "(root)";
3
+ function expandTypeBoxError(error) {
4
+ // Read offending property names from TypeBox's structured `params` rather
5
+ // than parsing the message string. Two payoffs: locale independence (the
6
+ // helper works the same after `Locale.Set("de_DE")`), and faithful
7
+ // preservation of property names that contain delimiters TypeBox's prose
8
+ // would split on (commas, etc.).
9
+ if (error.keyword === "required") {
10
+ const props = error.params.requiredProperties.filter((p) => p.length > 0);
11
+ if (props.length > 0) {
12
+ return props.map((field) => ({
13
+ field,
14
+ message: `must have required property ${field}`,
15
+ }));
16
+ }
17
+ }
18
+ if (error.keyword === "additionalProperties") {
19
+ const props = error.params.additionalProperties.filter((p) => p.length > 0);
20
+ if (props.length > 0) {
21
+ return props.map((field) => ({
22
+ field,
23
+ message: `must not have additional property ${field}`,
24
+ }));
25
+ }
26
+ }
27
+ const segments = error.instancePath.split("/").filter(Boolean);
28
+ return [{ field: segments.at(-1) ?? ROOT_FIELD, message: error.message }];
29
+ }
2
30
  export function validatePortableToolArgs(tool, args) {
3
31
  if (Check(tool.parameters, args)) {
4
32
  return { ok: true };
5
33
  }
6
- return {
7
- ok: false,
8
- errors: [...Errors(tool.parameters, args)].map((error) => ({
9
- path: error.instancePath || "/",
10
- message: error.message,
11
- })),
12
- };
34
+ // TypeBox can emit multiple errors per offending field (e.g. union/anyOf
35
+ // mismatches fire one error per failed branch). Dedupe by (field, message)
36
+ // using JSON.stringify so the key can't collide regardless of what
37
+ // characters field or message contain.
38
+ const seen = new Set();
39
+ const errors = [...Errors(tool.parameters, args)]
40
+ .flatMap((error) => expandTypeBoxError(error))
41
+ .filter(({ field, message }) => {
42
+ const key = JSON.stringify([field, message]);
43
+ if (seen.has(key))
44
+ return false;
45
+ seen.add(key);
46
+ return true;
47
+ });
48
+ return { ok: false, errors };
13
49
  }
14
50
  export async function executePortableTool(tool, args, ctx) {
15
51
  const validation = validatePortableToolArgs(tool, args);
16
52
  if (!validation.ok) {
17
53
  return {
18
54
  text: `Invalid arguments for ${tool.name}: ${validation.errors
19
- .map((error) => `${error.path} ${error.message}`)
55
+ .map((error) => `${error.field}: ${error.message}`)
20
56
  .join("; ")}`,
21
57
  structuredContent: {
22
58
  kind: "validation",
@@ -0,0 +1,63 @@
1
+ import type { PortableToolResult, PortableValidationError } from "./define-tool.js";
2
+ /**
3
+ * Validation-failure narrowing of `PortableToolResult`.
4
+ *
5
+ * `executePortableTool` produces this shape when TypeBox rejects the args:
6
+ * `structuredContent` carries `kind: "validation"`, the offending tool name,
7
+ * and the list of validation errors. The flag is also set to `isError: true`.
8
+ *
9
+ * This is the typed form a consumer can match against in both pi (default
10
+ * `errorHandling: "return"`) and MCP adapters.
11
+ */
12
+ export type PortableValidationFailure = PortableToolResult & {
13
+ isError: true;
14
+ structuredContent: {
15
+ kind: "validation";
16
+ tool: string;
17
+ validationErrors: PortableValidationError[];
18
+ };
19
+ };
20
+ /**
21
+ * Domain-failure narrowing of `PortableToolResult`.
22
+ *
23
+ * Any `result.isError === true` that is not a validation failure is treated
24
+ * as a domain failure. `structuredContent` stays whatever the handler chose;
25
+ * the guard does not synthesize a `kind: "domain"` discriminator on the wire.
26
+ * (The pi adapter's `PortableToolExecutionError.details` does synthesize that
27
+ * discriminator for the deprecated `errorHandling: "throw"` path.)
28
+ */
29
+ export type PortableDomainFailure = PortableToolResult & {
30
+ isError: true;
31
+ };
32
+ /**
33
+ * Type guard for results produced by `executePortableTool` when TypeBox
34
+ * validation rejected the args.
35
+ *
36
+ * The guard matches on shape, not provenance: a tool handler that manually
37
+ * returns `{ isError: true, structuredContent: { kind: "validation", tool, validationErrors } }`
38
+ * will also satisfy this predicate. BridgeKit cannot authenticate the source of
39
+ * a result on the wire, so handler-emitted validation-shaped failures are
40
+ * indistinguishable from BridgeKit-emitted ones. If that ambiguity matters,
41
+ * keep handler-emitted error shapes distinct from the validation discriminator.
42
+ *
43
+ * @example
44
+ * const result = await executePortableTool(tool, args, ctx);
45
+ * if (result.isError) {
46
+ * if (isValidationFailure(result)) {
47
+ * log.warn({ errors: result.structuredContent.validationErrors }, "bad args");
48
+ * }
49
+ * }
50
+ */
51
+ export declare function isValidationFailure(result: PortableToolResult): result is PortableValidationFailure;
52
+ /**
53
+ * Type guard for handler-emitted `isError: true` results that are not
54
+ * validation failures. Use after `result.isError` to branch between
55
+ * BridgeKit-emitted validation failures and tool-emitted domain failures.
56
+ *
57
+ * @example
58
+ * const result = await executePortableTool(tool, args, ctx);
59
+ * if (isDomainFailure(result)) {
60
+ * metrics.increment("tool.domain_error", { tool: tool.name });
61
+ * }
62
+ */
63
+ export declare function isDomainFailure(result: PortableToolResult): result is PortableDomainFailure;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Type guard for results produced by `executePortableTool` when TypeBox
3
+ * validation rejected the args.
4
+ *
5
+ * The guard matches on shape, not provenance: a tool handler that manually
6
+ * returns `{ isError: true, structuredContent: { kind: "validation", tool, validationErrors } }`
7
+ * will also satisfy this predicate. BridgeKit cannot authenticate the source of
8
+ * a result on the wire, so handler-emitted validation-shaped failures are
9
+ * indistinguishable from BridgeKit-emitted ones. If that ambiguity matters,
10
+ * keep handler-emitted error shapes distinct from the validation discriminator.
11
+ *
12
+ * @example
13
+ * const result = await executePortableTool(tool, args, ctx);
14
+ * if (result.isError) {
15
+ * if (isValidationFailure(result)) {
16
+ * log.warn({ errors: result.structuredContent.validationErrors }, "bad args");
17
+ * }
18
+ * }
19
+ */
20
+ export function isValidationFailure(result) {
21
+ if (result.isError !== true)
22
+ return false;
23
+ const structured = result.structuredContent;
24
+ if (!structured || typeof structured !== "object")
25
+ return false;
26
+ return (structured.kind === "validation" &&
27
+ typeof structured.tool === "string" &&
28
+ Array.isArray(structured.validationErrors));
29
+ }
30
+ /**
31
+ * Type guard for handler-emitted `isError: true` results that are not
32
+ * validation failures. Use after `result.isError` to branch between
33
+ * BridgeKit-emitted validation failures and tool-emitted domain failures.
34
+ *
35
+ * @example
36
+ * const result = await executePortableTool(tool, args, ctx);
37
+ * if (isDomainFailure(result)) {
38
+ * metrics.increment("tool.domain_error", { tool: tool.name });
39
+ * }
40
+ */
41
+ export function isDomainFailure(result) {
42
+ return result.isError === true && !isValidationFailure(result);
43
+ }
@@ -1,2 +1,3 @@
1
- export { definePortableTool, type PortableTool, type PortableToolBuiltInHost, type PortableToolContext, type PortableToolErrorDetails, type PortableToolHost, type PortableToolResult, } from "./core/define-tool.js";
2
- export { executePortableTool, type PortableValidationError, validatePortableToolArgs, } from "./core/execute-tool.js";
1
+ export { definePortableTool, type PortableTool, type PortableToolBuiltInHost, type PortableToolContext, type PortableToolErrorDetails, type PortableToolHost, type PortableToolResult, type PortableValidationError, } from "./core/define-tool.js";
2
+ export { executePortableTool, validatePortableToolArgs } from "./core/execute-tool.js";
3
+ export { isDomainFailure, isValidationFailure, type PortableDomainFailure, type PortableValidationFailure, } from "./core/result-guards.js";
package/dist/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { definePortableTool, } from "./core/define-tool.js";
2
- export { executePortableTool, validatePortableToolArgs, } from "./core/execute-tool.js";
2
+ export { executePortableTool, validatePortableToolArgs } from "./core/execute-tool.js";
3
+ export { isDomainFailure, isValidationFailure, } from "./core/result-guards.js";
package/dist/src/pi.d.ts CHANGED
@@ -1 +1 @@
1
- export { isPortableToolExecutionError, type PiToolRegistration, PortableToolExecutionError, registerPiTools, } from "./adapters/pi.js";
1
+ export { isPortableToolExecutionError, type PiToolRegistration, PortableToolExecutionError, type RegisterPiToolsOptions, registerPiTools, } from "./adapters/pi.js";
package/llms.txt CHANGED
@@ -12,7 +12,12 @@ Use this package when you need to define a tool once and expose it through both
12
12
  ## Import map
13
13
 
14
14
  ```ts
15
- import { definePortableTool, executePortableTool } from "@feniix/bridgekit";
15
+ import {
16
+ definePortableTool,
17
+ executePortableTool,
18
+ isDomainFailure,
19
+ isValidationFailure,
20
+ } from "@feniix/bridgekit";
16
21
  import { registerPiTools } from "@feniix/bridgekit/pi";
17
22
  import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp";
18
23
  ```
@@ -65,9 +70,30 @@ export function createTools() {
65
70
 
66
71
  ## Host behavior
67
72
 
68
- - pi invalid arguments and portable `isError: true` results throw `PortableToolExecutionError`, because pi expects native tool failures. Tests should assert rejected execution plus `.details`, not a returned `{ isError: true }` object.
69
- - MCP invalid arguments and portable `isError: true` results return `CallToolResult` with `isError: true`.
73
+ - pi invalid arguments and portable `isError: true` results return `{ content, details, isError: true }` by default (as of 0.7). Unexpected handler exceptions are also caught in return mode and surfaced as `{ content, details: {}, isError: true }`. Branch on `result.isError` and narrow with `isValidationFailure` / `isDomainFailure` from `@feniix/bridgekit`.
74
+ - MCP invalid arguments and portable `isError: true` results return `CallToolResult` with `isError: true`. Same guards narrow the result.
70
75
  - MCP preserves `structuredContent`; pi maps `structuredContent` into the pi `details` field. Both adapters fall back to `details` for legacy/debug payloads when `structuredContent` is absent.
76
+ - Validation errors and domain errors share the `{ field, message }` shape *inside* `validationErrors[]`. The array itself only appears on `kind: "validation"` — narrow with `isValidationFailure` (or check the discriminator) before iterating. `field` is derived from TypeBox's structured error data: required-property errors read `params.requiredProperties` (one entry per missing prop), additional-property errors read `params.additionalProperties` (one entry per offending key), other errors take the last meaningful segment of the offending JSON pointer (`text`, not `/text`). Locale-independent and prop-names-with-commas-safe. Duplicate `(field, message)` pairs from union mismatches are deduplicated. As of 0.8.0; `path` no longer exists. Caveats: array-element fields can be numeric indices (`field: "0"`, path context lost); root-level schema failures use the sentinel `field: "(root)"` rather than empty string.
77
+ - The result guards take a `PortableToolResult` — the value `executePortableTool` returns at the seam between BridgeKit and a host. They are not designed to be called on the pi adapter's wire object (`{ content, details, isError }`), which exposes `details` instead of `structuredContent`.
78
+ - `registerPiTools(pi, tools, { errorHandling: "throw" })` opts into the pre-0.7 behavior where `isError` results throw `PortableToolExecutionError`. Selecting this value emits a `DeprecationWarning` (code `BRIDGEKIT_PI_THROW_DEPRECATED`) once per process; only the `"throw"` value is deprecated, not the option itself. Scheduled for removal in 1.0; do not adopt it for new code.
79
+
80
+ ### Result-guard usage
81
+
82
+ ```ts
83
+ const result = await executePortableTool(tool, args, { host: "test" });
84
+ if (result.isError) {
85
+ if (isValidationFailure(result)) {
86
+ // validationErrors is typed: Array<{ field: string; message: string }>.
87
+ for (const { field, message } of result.structuredContent.validationErrors) {
88
+ log.warn({ tool: result.structuredContent.tool, field, message }, "bad args");
89
+ }
90
+ } else if (isDomainFailure(result)) {
91
+ // Handler-emitted failure; result.structuredContent is whatever the
92
+ // handler chose. No synthesized `kind` discriminator.
93
+ metrics.increment("tool.domain_error", { tool: tool.name });
94
+ }
95
+ }
96
+ ```
71
97
 
72
98
  ## Custom host typing
73
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.",
5
5
  "keywords": [
6
6
  "pi",
@@ -60,6 +60,7 @@
60
60
  "!dist/**/*.map",
61
61
  "!dist/tsconfig.tsbuildinfo",
62
62
  "README.md",
63
+ "CHANGELOG.md",
63
64
  "llms.txt",
64
65
  "examples/README.md"
65
66
  ],