@feniix/bridgekit 0.13.0 → 0.14.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 CHANGED
@@ -4,6 +4,39 @@ All notable changes to `@feniix/bridgekit` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.14.0] - 2026-05-28
8
+
9
+ ### Added
10
+
11
+ - `PortableTool` now carries an inferred success-result generic in addition to
12
+ its TypeBox parameter generic. `definePortableTool` and `executePortableTool`
13
+ preserve handler `structuredContent` types for direct/programmatic callers,
14
+ while validation failures remain `isError: true` results that narrow with
15
+ `isValidationFailure`.
16
+ - `runBinWrapper` now validates its string inputs before spawning or importing:
17
+ `mcpEntry` rejects absolute paths, `..` path segments, and NUL bytes;
18
+ `buildScript` rejects option-shaped or shell-shaped script names.
19
+ - CI and release checks now run `npm audit --omit=dev --audit-level=high`, and
20
+ Dependabot is configured for npm and GitHub Actions updates.
21
+
22
+ ### Changed
23
+
24
+ - TypeScript is now strict-plus: `noUncheckedIndexedAccess` and
25
+ `exactOptionalPropertyTypes` are enabled in addition to `strict`. The packed
26
+ consumer smoke test compiles installed declarations with the same flags.
27
+ - The Release workflow is manual-only (`workflow_dispatch`) with a `main`
28
+ branch guard; publishing no longer happens automatically after CI on `main`.
29
+ Third-party GitHub Actions are pinned by full commit SHA.
30
+ - Documentation now reflects the current pi default error-return behavior, the
31
+ install-time MCP SDK dependency footprint, pre-1.0 stability/support
32
+ expectations, and MCP stdio-safe `buildStdio` examples.
33
+
34
+ ### Fixed
35
+
36
+ - `isValidationFailure` now verifies every `validationErrors[]` entry has a
37
+ string `field` and `message` before narrowing to `PortableValidationFailure`.
38
+ - `package-lock.json` root metadata now matches `package.json#version`.
39
+
7
40
  ## [0.13.0] - 2026-05-27
8
41
 
9
42
  ### Added
package/README.md CHANGED
@@ -61,12 +61,18 @@ npm install @feniix/bridgekit typebox
61
61
 
62
62
  This package is ESM-only and supports Node.js 22.19.0 or newer. Published modules are import-passive and marked as side-effect free; tools are registered or servers are started only when the exported adapter functions are called.
63
63
 
64
+ ## Stability and support
65
+
66
+ BridgeKit is pre-1.0. Patch releases are intended to be compatible within the current minor, but `0.x.0` minor bumps may contain source-level breaking changes while the API is settling. Production consumers that need maximum stability should pin an exact version or a patch-only range and read `CHANGELOG.md` before bumping.
67
+
68
+ Issue triage is best-effort, with priority on packaging regressions, install failures, and SDK-compatibility breaks.
69
+
64
70
  ## Why
65
71
 
66
72
  - Define a tool once; ship it to pi and MCP without per-host forks.
67
73
  - Host-neutral tool files: no pi or MCP SDK imports in the tool definition itself.
68
74
  - TypeBox schemas pass through to MCP `inputSchema` directly — no JSON Schema conversion step.
69
- - Import-passive, `sideEffects: false`, four-entrypoint split (`.`, `./pi`, `./mcp`, `./bin-wrapper`) so pi-only consumers do not pull the MCP SDK and vice versa.
75
+ - Import-passive, `sideEffects: false`, four-entrypoint split (`.`, `./pi`, `./mcp`, `./bin-wrapper`) so ESM/bundler consumers that use only core or pi entrypoints do not import or bundle the MCP adapter code. The MCP SDK is still a normal package dependency today, so all npm installs include its transitive dependencies.
70
76
  - Conformance-tested public surface: a packed-install smoke test enforces the runtime export set, deep-import rejection, and type-level strictness against the installed declarations.
71
77
 
72
78
  ## API reference
@@ -95,6 +101,8 @@ import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper";
95
101
 
96
102
  Do not deep-import from `dist/` or `src/` in consuming packages.
97
103
 
104
+ `definePortableTool` infers both the TypeBox-backed argument type and the handler's success result shape. `executePortableTool` preserves that inferred success result for direct/programmatic callers, while validation failures are returned as `isError: true` results that can be narrowed with `isValidationFailure`.
105
+
98
106
  ### pi adapter
99
107
 
100
108
  ```ts
@@ -106,7 +114,7 @@ export default function extension(pi: Parameters<typeof registerPiTools>[0]) {
106
114
  }
107
115
  ```
108
116
 
109
- 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.
117
+ By default (`errorHandling: "return"`, as of 0.7) the pi adapter mirrors MCP's error-as-data posture: portable validation failures and portable `isError: true` results surface as `{ content, details, isError: true }` so consumers can branch on `result.isError`. The pi wire object exposes machine-readable data on `details` (sourced from `structuredContent` first, then `details`, then `{}`); use the result guards on `PortableToolResult` values at the portable seam, not directly on the pi wire object. 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. Progress updates from `ctx.progress?.(...)` map to pi tool updates.
110
118
 
111
119
  The pre-0.7 behavior — throw `PortableToolExecutionError` on `isError` — is still available for one deprecation cycle:
112
120
 
@@ -130,12 +138,12 @@ try {
130
138
  // After (0.7+)
131
139
  const result = await piTool.execute(...);
132
140
  if (result.isError) {
133
- if (isValidationFailure(result)) {
141
+ if (result.details.kind === "validation" && Array.isArray(result.details.validationErrors)) {
134
142
  // validationErrors is Array<{ field: string; message: string }>.
135
- for (const { field, message } of result.structuredContent.validationErrors) {
143
+ for (const { field, message } of result.details.validationErrors) {
136
144
  // ...
137
145
  }
138
- } else if (isDomainFailure(result)) { /* handler-level error */ }
146
+ } else { /* handler-level error; details is whatever the handler returned */ }
139
147
  }
140
148
  ```
141
149
 
@@ -150,17 +158,19 @@ For **discriminated unions** (`Type.Union([Type.Object({tag: Literal("a"), …})
150
158
  For nested discriminated unions, `field` is the leaf segment of the JSON pointer (`"name"` for `/event/name`), not the full path. Consumers needing full path context for ambiguous prop names should track the active union branch out-of-band.
151
159
 
152
160
  The two modes expose the failure discriminator on different fields. In the
153
- new default `"return"` mode, prefer the result guards over inspecting a
154
- `kind` field directly — handler-emitted failures do not carry a synthesized
155
- `kind`. In the deprecated `"throw"` mode, the discriminator lives on
156
- `(err as PortableToolExecutionError).details.kind` (`"validation"` or
157
- `"domain"`). The guards operate on a `PortableToolResult`; they are not
158
- designed to be called on the pi adapter's wire object.
161
+ new default `"return"` mode, validation failures surface on the pi wire
162
+ object as `result.details.kind === "validation"`; handler-emitted failures
163
+ do not carry a synthesized `kind`. In the deprecated `"throw"` mode, the
164
+ discriminator lives on `(err as PortableToolExecutionError).details.kind`
165
+ (`"validation"` or `"domain"`). The guards operate on a `PortableToolResult`;
166
+ they are not designed to be called on the pi adapter's wire object.
159
167
 
160
168
  #### Per-host metadata via `hostExtras`
161
169
 
162
170
  `PortableTool.hostExtras` is an optional namespace for host-specific fields that should travel with the tool definition rather than a parallel sidecar map. The pi adapter reads `hostExtras.pi`; the MCP adapter reads `hostExtras.mcp`; each adapter ignores keys it does not recognise. Tools that omit `hostExtras` see no behavior change.
163
171
 
172
+ Custom host adapters can reuse the core execution helpers, but the built-in `ctx.host` union is intentionally closed (`"pi" | "mcp" | "test"`). Custom adapters should cast at their boundary and carry custom dispatch state on adapter-owned fields; use module augmentation of `PortableToolHostExtras` for type-safe host-specific metadata.
173
+
164
174
  ```ts
165
175
  import { Type } from "typebox";
166
176
  import { definePortableTool } from "@feniix/bridgekit";
@@ -231,7 +241,7 @@ Tool `parameters` must resolve to a JSON-Schema object at the top level. `Type.O
231
241
 
232
242
  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.
233
243
 
234
- 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.
244
+ The two adapters now read in parallel: invalid args and portable `isError` results return `{ isError: true }` from both hosts by default. The result-guard helpers (`isValidationFailure`, `isDomainFailure`) narrow `PortableToolResult` values at the portable seam; adapter wire values expose the same data through host-specific fields (`structuredContent` for MCP, `details` for pi).
235
245
 
236
246
  ### bin-wrapper (since 0.11.0)
237
247
 
@@ -245,21 +255,23 @@ await runBinWrapper({
245
255
  metaUrl: import.meta.url,
246
256
  mcpEntry: "dist/extensions/mcp-server.js",
247
257
  buildScript: "build:mcp",
258
+ // MCP stdio bins: keep build stdout off the JSON-RPC channel.
259
+ buildStdio: ["ignore", "inherit", "inherit"],
248
260
  });
249
261
  ```
250
262
 
251
263
  Options:
252
264
 
253
265
  - `metaUrl` (required): `import.meta.url` of the bin script. Used to locate the package root (the bin's parent directory).
254
- - `mcpEntry` (required): path to the compiled MCP entry, relative to the package root (e.g. `"dist/extensions/mcp-server.js"`).
255
- - `buildScript` (required): npm script to invoke when the entry is missing (e.g. `"build:mcp"`).
266
+ - `mcpEntry` (required): path to the compiled MCP entry, relative to the package root (e.g. `"dist/extensions/mcp-server.js"`). Absolute paths, `..` path segments, and NUL bytes are rejected.
267
+ - `buildScript` (required): npm script to invoke when the entry is missing (e.g. `"build:mcp"`). Names must be literal script identifiers containing only letters, numbers, `:`, `_`, `@`, `.`, or `-`, and must not start with `-`.
256
268
  - `buildTimeoutMs` (optional, default `60_000`): timeout for the build subprocess. Distinct "Build timed out…" diagnostic fires when exceeded.
257
269
  - `logPrefix` (optional, default `"bridgekit-bin"`): prefix on the "Failed to build…" diagnostic.
258
270
  - `buildStdio` (optional, default `"inherit"`): `stdio` mode passed to `spawnSync` when the build script runs. **MCP stdio server bins should pass `["ignore", "inherit", "inherit"]`** so the build subprocess's stdout cannot contaminate the parent's JSON-RPC framing channel (`process.stdout`). stderr stays inherited so build diagnostics remain visible.
259
271
 
260
272
  The MCP entry module must export `async function runServer(): Promise<void>`. Consumers with a different export name can alias on export.
261
273
 
262
- **Security: `mcpEntry` and `buildScript` must be literal strings in the caller's source.** The helper joins `mcpEntry` onto the resolved package root and dynamically `import()`s it, and it passes `buildScript` to `spawnSync` (with `shell: true` on Windows where `&`, `|`, and `^` are shell metacharacters). Sourcing either from CLI args or environment variables exposes arbitrary-file import and Windows command injection.
274
+ **Security: `mcpEntry` and `buildScript` must be literal strings in the caller's source.** The helper joins `mcpEntry` onto the resolved package root and dynamically `import()`s it, and it passes `buildScript` to `spawnSync` (with `shell: true` on Windows where `&`, `|`, and `^` are shell metacharacters). BridgeKit rejects absolute/traversing entry paths and shell-shaped script names, but sourcing either option from CLI args or environment variables is still outside the supported threat model.
263
275
 
264
276
  Behavior on missing entry:
265
277
 
@@ -297,8 +309,8 @@ Package and release checklist:
297
309
  - If an npm-launched bin depends on generated output, use `runBinWrapper` from `@feniix/bridgekit/bin-wrapper` (since 0.11.0) — it resolves the package-local generated entry, runs the package-local build when output is missing in workspace/local execution, preserves build failures, and distinguishes timeout from build error in its diagnostic. The bin script becomes a three-line invocation; no hand-rolled wrapper needed.
298
310
  - If a package keeps a source-loaded host entrypoint (for example a pi extension source file), use a package-local MCP build behind that wrapper and narrow the build to the MCP entrypoint plus shared host-neutral modules.
299
311
  - Declare a compatible Node engine (`>=22.19.0`) in downstream packages that expose BridgeKit-powered MCP bins.
300
- - Run `npm run check`, `npm run test`, `npm run pack:dry-run`, and `npm run package-smoke` before publishing.
301
- - Treat `docs/releasing.md` as the future release handoff; this repository is not configured for automated publish yet.
312
+ - Run `npm run check`, `npm test`, `npm run pack:dry-run`, `npm run package-smoke`, and `npm audit --omit=dev --audit-level=high` before publishing.
313
+ - Treat `docs/releasing.md` as the release handoff; publishing is manual via the Release workflow and npm trusted publishing.
302
314
 
303
315
  See `examples/README.md` for complete copyable examples.
304
316
 
@@ -182,7 +182,7 @@ export function createMcpServer(options) {
182
182
  });
183
183
  const server = new Server({ name: options.name, version: options.version }, {
184
184
  capabilities: { tools: { listChanged: false } },
185
- instructions: options.instructions,
185
+ ...(options.instructions !== undefined && { instructions: options.instructions }),
186
186
  });
187
187
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpTools }));
188
188
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
@@ -87,7 +87,7 @@ export function registerPiTools(pi, tools, options = {}) {
87
87
  }
88
88
  result = await executePortableTool(tool, params, {
89
89
  host: "pi",
90
- signal,
90
+ ...(signal !== undefined && { signal }),
91
91
  progress(update) {
92
92
  onUpdate?.({
93
93
  content: [{ type: "text", text: update.text }],
@@ -5,15 +5,41 @@
5
5
  // under inv-deep-imports-fail).
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { existsSync } from "node:fs";
8
- import { dirname, join, resolve } from "node:path";
8
+ import { dirname, isAbsolute, join, resolve } from "node:path";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  const DEFAULT_BUILD_TIMEOUT_MS = 60_000;
11
11
  const DEFAULT_LOG_PREFIX = "bridgekit-bin";
12
+ const SAFE_BUILD_SCRIPT_NAME = /^[A-Za-z0-9:_@.-]+$/;
13
+ function validateMcpEntry(mcpEntry) {
14
+ if (mcpEntry.length === 0) {
15
+ throw new Error("[bridgekit/bin-wrapper] mcpEntry must be a non-empty package-relative path.");
16
+ }
17
+ if (mcpEntry.includes("\0")) {
18
+ throw new Error("[bridgekit/bin-wrapper] mcpEntry must not contain NUL bytes.");
19
+ }
20
+ if (isAbsolute(mcpEntry) || /^[A-Za-z]:[\\/]/.test(mcpEntry)) {
21
+ throw new Error("[bridgekit/bin-wrapper] mcpEntry must be relative to the package root, not absolute.");
22
+ }
23
+ if (mcpEntry.split(/[\\/]+/).some((segment) => segment === "..")) {
24
+ throw new Error("[bridgekit/bin-wrapper] mcpEntry must not contain '..' path segments.");
25
+ }
26
+ }
27
+ function validateBuildScript(buildScript) {
28
+ if (buildScript.length === 0) {
29
+ throw new Error("[bridgekit/bin-wrapper] buildScript must be a non-empty npm script name.");
30
+ }
31
+ if (buildScript.startsWith("-") || !SAFE_BUILD_SCRIPT_NAME.test(buildScript)) {
32
+ throw new Error("[bridgekit/bin-wrapper] buildScript must be a literal npm script name containing only letters, " +
33
+ "numbers, ':', '_', '@', '.', or '-' (and must not start with '-').");
34
+ }
35
+ }
12
36
  export const defaultBinWrapperDeps = {
13
37
  spawnSync,
14
38
  exit: process.exit,
15
39
  };
16
40
  export async function runBinWrapperWithDeps(options, deps) {
41
+ validateMcpEntry(options.mcpEntry);
42
+ validateBuildScript(options.buildScript);
17
43
  const packageRoot = resolve(dirname(fileURLToPath(options.metaUrl)), "..");
18
44
  const entryPath = join(packageRoot, options.mcpEntry);
19
45
  if (!existsSync(entryPath)) {
@@ -138,12 +138,12 @@ export interface PortableToolHostExtras {
138
138
  pi?: PiHostExtras;
139
139
  mcp?: McpHostExtras;
140
140
  }
141
- export interface PortableTool<TParams extends TSchema = TSchema> {
141
+ export interface PortableTool<TParams extends TSchema = TSchema, TResult extends PortableToolResult = PortableToolResult> {
142
142
  name: string;
143
143
  title: string;
144
144
  description: string;
145
145
  parameters: TParams;
146
- execute: (args: Static<TParams>, ctx: PortableToolContext) => PortableToolResult | Promise<PortableToolResult>;
146
+ execute: (args: Static<TParams>, ctx: PortableToolContext) => TResult | Promise<TResult>;
147
147
  /**
148
148
  * Optional per-host metadata. Adapters consume the keys they recognise;
149
149
  * unknown host namespaces are ignored. Absent → no behavior change.
@@ -155,4 +155,4 @@ export interface PortableTool<TParams extends TSchema = TSchema> {
155
155
  */
156
156
  hostExtras?: PortableToolHostExtras;
157
157
  }
158
- export declare function definePortableTool<TParams extends TSchema>(tool: PortableTool<TParams>): PortableTool<TParams>;
158
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult>): PortableTool<TParams, TResult>;
@@ -1,9 +1,22 @@
1
- import type { TSchema } from "typebox";
1
+ import type { Static, TSchema } from "typebox";
2
2
  import type { PortableTool, PortableToolContext, PortableToolResult, PortableValidationError } from "./define-tool.js";
3
- export declare function validatePortableToolArgs(tool: PortableTool<TSchema>, args: unknown): {
3
+ import type { PortableValidationFailure } from "./result-guards.js";
4
+ type PortableToolSuccess<TResult extends PortableToolResult> = TResult & {
5
+ details?: Record<string, unknown>;
6
+ isError?: boolean;
7
+ } & (TResult extends {
8
+ structuredContent?: infer TStructured;
9
+ } ? {
10
+ structuredContent?: TStructured;
11
+ } : {
12
+ structuredContent?: Record<string, unknown>;
13
+ });
14
+ export declare function validatePortableToolArgs<TParams extends TSchema>(tool: PortableTool<TParams>, args: unknown): {
4
15
  ok: true;
16
+ args: Static<TParams>;
5
17
  } | {
6
18
  ok: false;
7
19
  errors: PortableValidationError[];
8
20
  };
9
- export declare function executePortableTool(tool: PortableTool<TSchema>, args: unknown, ctx: PortableToolContext): Promise<PortableToolResult>;
21
+ export declare function executePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult>, args: unknown, ctx: PortableToolContext): Promise<PortableToolSuccess<TResult> | PortableValidationFailure>;
22
+ export {};
@@ -151,17 +151,19 @@ function resolveSchemaAtPath(schema, instancePath) {
151
151
  return undefined;
152
152
  const obj = current;
153
153
  if (obj.properties && typeof obj.properties === "object" && segment in obj.properties) {
154
- current = obj.properties[segment];
155
- if (current === undefined)
154
+ const next = obj.properties[segment];
155
+ if (next === undefined)
156
156
  return undefined;
157
+ current = next;
157
158
  continue;
158
159
  }
159
160
  if (obj.items !== undefined && /^\d+$/.test(segment)) {
160
161
  if (Array.isArray(obj.items)) {
161
162
  const idx = Number(segment);
162
- if (idx >= obj.items.length)
163
+ const next = getArrayEntry(obj.items, idx);
164
+ if (next === undefined)
163
165
  return undefined;
164
- current = obj.items[idx];
166
+ current = next;
165
167
  }
166
168
  else {
167
169
  current = obj.items;
@@ -207,12 +209,26 @@ function branchDiscriminatorMatches(branch, value) {
207
209
  }
208
210
  return hasDiscriminator;
209
211
  }
212
+ function findActiveUnionBranch(branches, value) {
213
+ let match;
214
+ for (const [index, branch] of branches.entries()) {
215
+ if (!branchDiscriminatorMatches(branch, value))
216
+ continue;
217
+ if (match !== undefined)
218
+ return undefined;
219
+ match = { index, branch };
220
+ }
221
+ return match;
222
+ }
210
223
  function isSubsetOf(props, set) {
211
224
  for (const p of props)
212
225
  if (!set.has(p))
213
226
  return false;
214
227
  return true;
215
228
  }
229
+ function getArrayEntry(entries, index) {
230
+ return index >= 0 && index < entries.length ? entries[index] : undefined;
231
+ }
216
232
  function suppressSiblingErrorsUnderUnion(schema, value, errors) {
217
233
  // For unions of objects, TypeBox emits per-branch `required` /
218
234
  // `additionalProperties` errors at the union's path. The user only needs
@@ -253,17 +269,12 @@ function suppressSiblingErrorsUnderUnion(schema, value, errors) {
253
269
  continue;
254
270
  }
255
271
  const branchValue = Pointer.Get(value, path);
256
- const matchedIndices = [];
257
- for (let i = 0; i < branches.length; i++) {
258
- if (branchDiscriminatorMatches(branches[i], branchValue))
259
- matchedIndices.push(i);
260
- }
261
- if (matchedIndices.length !== 1) {
272
+ const activeMatch = findActiveUnionBranch(branches, branchValue);
273
+ if (!activeMatch) {
262
274
  resolutions.set(path, { kind: "no-active" });
263
275
  continue;
264
276
  }
265
- const activeIndex = matchedIndices[0];
266
- const active = branches[activeIndex];
277
+ const { branch: active, index: activeIndex } = activeMatch;
267
278
  const branchRequired = new Set(active.required ?? []);
268
279
  const branchProps = new Set(Object.keys(active.properties ?? {}));
269
280
  // Map of property-name -> set of disallowed values held by losing
@@ -275,7 +286,10 @@ function suppressSiblingErrorsUnderUnion(schema, value, errors) {
275
286
  for (let i = 0; i < branches.length; i++) {
276
287
  if (i === activeIndex)
277
288
  continue;
278
- for (const [key, propSchema] of Object.entries(branches[i].properties ?? {})) {
289
+ const branch = getArrayEntry(branches, i);
290
+ if (!branch)
291
+ continue;
292
+ for (const [key, propSchema] of Object.entries(branch.properties ?? {})) {
279
293
  const values = readDiscriminatorValues(propSchema);
280
294
  if (values.length === 0)
281
295
  continue;
@@ -404,7 +418,7 @@ function expandTypeBoxError(schema, error) {
404
418
  }
405
419
  export function validatePortableToolArgs(tool, args) {
406
420
  if (Check(tool.parameters, args)) {
407
- return { ok: true };
421
+ return { ok: true, args };
408
422
  }
409
423
  // TypeBox can emit multiple errors per offending field (e.g. union/anyOf
410
424
  // mismatches fire one error per failed branch). First, when an anyOf/oneOf
@@ -443,5 +457,5 @@ export async function executePortableTool(tool, args, ctx) {
443
457
  isError: true,
444
458
  };
445
459
  }
446
- return tool.execute(args, ctx);
460
+ return (await tool.execute(validation.args, ctx));
447
461
  }
@@ -29,25 +29,6 @@ export type PortableValidationFailure = PortableToolResult & {
29
29
  export type PortableDomainFailure = PortableToolResult & {
30
30
  isError: true;
31
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
32
  export declare function isValidationFailure(result: PortableToolResult): result is PortableValidationFailure;
52
33
  /**
53
34
  * Type guard for handler-emitted `isError: true` results that are not
@@ -17,15 +17,23 @@
17
17
  * }
18
18
  * }
19
19
  */
20
+ function isPortableValidationError(error) {
21
+ if (!error || typeof error !== "object")
22
+ return false;
23
+ const candidate = error;
24
+ return typeof candidate.field === "string" && typeof candidate.message === "string";
25
+ }
20
26
  export function isValidationFailure(result) {
21
27
  if (result.isError !== true)
22
28
  return false;
23
29
  const structured = result.structuredContent;
24
30
  if (!structured || typeof structured !== "object")
25
31
  return false;
26
- return (structured.kind === "validation" &&
27
- typeof structured.tool === "string" &&
28
- Array.isArray(structured.validationErrors));
32
+ const candidate = structured;
33
+ return (candidate.kind === "validation" &&
34
+ typeof candidate.tool === "string" &&
35
+ Array.isArray(candidate.validationErrors) &&
36
+ candidate.validationErrors.every(isPortableValidationError));
29
37
  }
30
38
  /**
31
39
  * Type guard for handler-emitted `isError: true` results that are not
@@ -145,7 +145,8 @@ In `package.json`:
145
145
  pi behavior:
146
146
 
147
147
  - Valid portable results become pi tool results.
148
- - Portable results with `isError: true` reject with `PortableToolExecutionError`; tests should assert a rejected execution plus error `.details`.
148
+ - Invalid arguments and portable results with `isError: true` return `{ content, details, isError: true }` by default; tests should branch on `result.isError` and inspect `details`.
149
+ - The legacy throw mode is still available with `registerPiTools(pi, tools, { errorHandling: "throw" })`, but it is deprecated and should not be used for new code.
149
150
  - Progress updates from `ctx.progress?.(...)` map to pi updates.
150
151
 
151
152
  ---
@@ -209,7 +210,7 @@ In `package.json`:
209
210
  "node": ">=22.19.0"
210
211
  },
211
212
  "dependencies": {
212
- "@feniix/bridgekit": "^0.2.2",
213
+ "@feniix/bridgekit": "^0.14.0",
213
214
  "typebox": "^1.1.31"
214
215
  }
215
216
  }
@@ -235,7 +236,7 @@ For mixed source-loaded pi + compiled MCP packages, keep the pi source entrypoin
235
236
  "node": ">=22.19.0"
236
237
  },
237
238
  "dependencies": {
238
- "@feniix/bridgekit": "^0.2.2",
239
+ "@feniix/bridgekit": "^0.14.0",
239
240
  "typebox": "^1.1.31"
240
241
  }
241
242
  }
@@ -251,12 +252,14 @@ await runBinWrapper({
251
252
  metaUrl: import.meta.url,
252
253
  mcpEntry: "dist/extensions/mcp-server.js",
253
254
  buildScript: "build:mcp",
255
+ // MCP stdio bins: keep build stdout off the JSON-RPC channel.
256
+ buildStdio: ["ignore", "inherit", "inherit"],
254
257
  });
255
258
  ```
256
259
 
257
- `mcpEntry` and `buildScript` are the two options consumers typically vary. The MCP entry module must export `runServer(): Promise<void>` (the convention used throughout these examples). `buildTimeoutMs` (default `60_000`) and `logPrefix` (default `"bridgekit-bin"`) are available for tuning but rarely needed.
260
+ `mcpEntry` and `buildScript` are the two options consumers typically vary. The MCP entry module must export `runServer(): Promise<void>` (the convention used throughout these examples). `buildStdio`, `buildTimeoutMs` (default `60_000`), and `logPrefix` (default `"bridgekit-bin"`) are available for tuning but rarely needed outside MCP-stdio stdout isolation.
258
261
 
259
- Both `mcpEntry` and `buildScript` must be **literal strings** in your bin source. The helper joins `mcpEntry` onto the resolved package root and dynamically `import()`s it, and it passes `buildScript` to `spawnSync` (with `shell: true` on Windows where `&`, `|`, and `^` are shell metacharacters). Sourcing either from CLI args, environment variables, or other runtime input exposes arbitrary-file import and Windows command injection — the trusted-literal expectation is the threat model.
262
+ Both `mcpEntry` and `buildScript` must be **literal strings** in your bin source. The helper joins `mcpEntry` onto the resolved package root and dynamically `import()`s it, and it passes `buildScript` to `spawnSync` (with `shell: true` on Windows where `&`, `|`, and `^` are shell metacharacters). BridgeKit rejects absolute/traversing entry paths and shell-shaped script names, but sourcing either option from CLI args, environment variables, or other runtime input remains outside the supported threat model.
260
263
 
261
264
  Commit the wrapper with executable mode (`chmod +x bin/my-tools-mcp.js`) and verify `npm pack --dry-run --json` includes it with executable mode.
262
265
 
@@ -398,8 +401,8 @@ For publishable tool packages:
398
401
  - Avoid `workspace:` or `file:` ranges in publishable package dependencies.
399
402
  - Avoid dangling `sourceMappingURL` comments: either publish maps and useful sources, or disable source maps for package builds.
400
403
  - Ensure the npm bin entrypoint starts with a shebang, is executable (`chmod +x` or equivalent), and appears in `npm pack --dry-run --json` with executable mode.
401
- - When a bin depends on generated output, prefer a checked-in wrapper under `bin/` over pointing directly at `dist/`; test existing output, missing output, failed builds, and successful builds that omit the expected file.
404
+ - When a bin depends on generated output, prefer a checked-in wrapper under `bin/` over pointing directly at `dist/`; for MCP stdio bins set `buildStdio: ["ignore", "inherit", "inherit"]`; test existing output, missing output, failed builds, and successful builds that omit the expected file.
402
405
  - If only the MCP bin needs compiled output, narrow its tsconfig to the MCP entrypoint and shared host-neutral modules instead of compiling unrelated host adapters.
403
406
  - Add a packed-install smoke test that installs tarballs into a temporary project.
404
- - For BridgeKit itself, run `npm run check`, `npm run test`, `npm run pack:dry-run`, and `npm run package-smoke` before release.
407
+ - For BridgeKit itself, run `npm run check`, `npm run test`, `npm run pack:dry-run`, `npm run package-smoke`, and `npm audit --omit=dev --audit-level=high` before release.
405
408
  - Keep imports side-effect free; registration and server startup should happen only in explicit entrypoints.
package/llms.txt CHANGED
@@ -26,7 +26,7 @@ import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; // optional: buil
26
26
  - Root entrypoint: host-neutral tool definitions, validation, and execution helpers.
27
27
  - `/pi`: pi adapter only.
28
28
  - `/mcp`: MCP server adapter only.
29
- - `/bin-wrapper`: optional helper for npm `bin` scripts that build a local compiled MCP entry on first invocation. `mcpEntry` and `buildScript` must be literal strings in the caller's source (they're the dynamic-import target and the `spawnSync` arg; sourcing them from env or CLI is an arbitrary-file-import and Windows command-injection vector).
29
+ - `/bin-wrapper`: optional helper for npm `bin` scripts that build a local compiled MCP entry on first invocation. `mcpEntry` and `buildScript` must be literal strings in the caller's source (they're the dynamic-import target and the `spawnSync` arg; bridgekit rejects absolute/traversing entry paths and shell-shaped script names, but sourcing them from env or CLI remains outside the supported threat model).
30
30
 
31
31
  Do not deep-import from `dist/` or `src/` in consumer code. Reading published declarations for documentation is fine; imports should use the package entrypoints above.
32
32
 
@@ -69,11 +69,12 @@ export function createTools() {
69
69
  - TypeBox validation runs before the portable handler. If you need custom guidance for missing fields or wrong primitive types, use a permissive schema plus domain validation; otherwise accept BridgeKit's structural validation result.
70
70
  - MCP stdio entrypoints should be testable: export a server-options factory and a run function, then start stdio only when executed as the package bin.
71
71
  - Keep package runtime Node >=22.19.0 and ESM-only.
72
+ - TypeScript is strict-plus: `strict`, `noUncheckedIndexedAccess`, and `exactOptionalPropertyTypes` are enabled.
72
73
 
73
74
  ## Host behavior
74
75
 
75
- - 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`.
76
- - MCP invalid arguments and portable `isError: true` results return `CallToolResult` with `isError: true`. Same guards narrow the result.
76
+ - 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`; validation failures surface as `result.details.kind === "validation"`. The result guards from `@feniix/bridgekit` operate on `PortableToolResult` values at the portable seam, not directly on the pi wire object.
77
+ - MCP invalid arguments and portable `isError: true` results return `CallToolResult` with `isError: true`. Inspect `structuredContent` on MCP wire results, or use the guards on `PortableToolResult` values before/at the adapter seam.
77
78
  - 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.
78
79
  - 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), `const`/`enum` errors carry the allowed value(s) in the message (`must equal "create"` / `must equal one of "create", "update"`), 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. As of 0.8.2: **discriminated unions** (`Type.Union([Type.Object({tag:Literal("a"),…}),Type.Object({tag:Literal("b"),…})])`) surface the *active branch's* missing-required hints when exactly one branch's discriminator is satisfied — instead of the bare `anyOf` summary. Recognized discriminator shapes: `Literal` (`const`), `enum`, and `Union of Literals` (`anyOf` of consts). Works inside `Type.Array(...)` per-element. When no branch matches (invalid discriminator), falls back to the 0.8.1 behavior (drop sibling `required`, keep `const`/`enum`/`anyOf` summary). 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; for nested discriminated unions `field` is the leaf segment, not the full path.
79
80
  - 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`.
@@ -119,7 +120,7 @@ Some hosts, such as pi in source-extension packages, may intentionally load Type
119
120
  });
120
121
  ```
121
122
 
122
- `mcpEntry` and `buildScript` must be literal strings — sourcing them from CLI args or env vars opens arbitrary-file import and Windows command injection. The MCP entry module must export `async function runServer(): Promise<void>`. `buildStdio` defaults to `"inherit"`; MCP stdio server bins should override to `["ignore", "inherit", "inherit"]` (since 0.13.0) because `process.stdout` is the JSON-RPC channel and the build subprocess otherwise contaminates framing.
123
+ `mcpEntry` and `buildScript` must be literal strings — bridgekit rejects absolute/traversing entry paths and shell-shaped script names, but sourcing them from CLI args or env vars remains outside the supported threat model. The MCP entry module must export `async function runServer(): Promise<void>`. `buildStdio` defaults to `"inherit"`; MCP stdio server bins should override to `["ignore", "inherit", "inherit"]` (since 0.13.0) because `process.stdout` is the JSON-RPC channel and the build subprocess otherwise contaminates framing.
123
124
  - Narrow the MCP build to the MCP entrypoint and shared host-neutral modules; avoid compiling pi adapter entrypoints into the standalone MCP path.
124
125
  - Put runtime imports used by the compiled MCP bin in `dependencies`, not only peers or dev dependencies.
125
126
  - Declare BridgeKit's Node engine requirement in the downstream package (`>=22.19.0`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -73,10 +73,10 @@
73
73
  "typebox": "^1.1.31"
74
74
  },
75
75
  "devDependencies": {
76
- "@biomejs/biome": "2.4.10",
76
+ "@biomejs/biome": "2.4.16",
77
77
  "@total-typescript/shoehorn": "^0.1.2",
78
- "@types/node": "^22.19.17",
79
- "typescript": "^5.5.0"
78
+ "@types/node": "^25.9.1",
79
+ "typescript": "^6.0.3"
80
80
  },
81
81
  "license": "MIT"
82
82
  }