@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 +182 -0
- package/README.md +49 -2
- package/dist/src/adapters/pi.d.ts +43 -1
- package/dist/src/adapters/pi.js +58 -19
- package/dist/src/core/define-tool.d.ts +1 -1
- package/dist/src/core/execute-tool.d.ts +0 -1
- package/dist/src/core/execute-tool.js +44 -8
- package/dist/src/core/result-guards.d.ts +63 -0
- package/dist/src/core/result-guards.js +43 -0
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.js +2 -1
- package/dist/src/pi.d.ts +1 -1
- package/llms.txt +29 -3
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/src/adapters/pi.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
return source;
|
|
7
|
+
if (isValidationFailure(result)) {
|
|
8
|
+
return result.structuredContent;
|
|
12
9
|
}
|
|
13
|
-
const { kind: _ignored, ...rest } =
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
],
|