@feniix/bridgekit 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [0.16.1] - 2026-10-06
10
+
11
+ ### Fixed
12
+
13
+ - Validation errors report correct field names under TypeBox 1.3+, which the
14
+ `typebox ^1.1.31` range already resolves to on fresh installs. TypeBox 1.3
15
+ escapes JSON Pointer paths per RFC 6901, so a property named `a/b` surfaced as
16
+ `a~1b` (and `a~b` as `a~0b`); fields are now decoded back to the literal key.
17
+ - `additionalProperties` violations are reported once per key again. TypeBox 1.3
18
+ adds a per-key sub-schema error beside the summary, which duplicated fields and
19
+ let a losing union branch's closed-object errors slip past phantom suppression.
20
+ Both TypeBox 1.1 and 1.3 now produce identical validation errors.
21
+
22
+ ## [0.16.0] - 2026-10-06
23
+
24
+ ### Added
25
+
26
+ - Optional object-shaped `PortableTool.outputSchema`. Successful calls must return
27
+ matching `structuredContent`; missing or invalid output throws a tool-attributed
28
+ `TypeError`. Argument and domain failures are exempt from success schemas.
29
+ - Pi registrations forward `outputSchema`, and Pi results preserve
30
+ `structuredContent` alongside the existing renderer-facing `details`.
31
+ - MCP listings forward object-shaped output schemas, including intersections;
32
+ invalid output schemas are rejected at server construction.
33
+ - Schema-declaring `definePortableTool` calls check successful structured data at
34
+ compile time while preserving inferred result unions; explicit annotations can
35
+ retain schemas with a third `PortableTool` generic. Domain failures are exempt.
36
+ Explicit legacy two-generic function calls intentionally erase schema linkage
37
+ and retain runtime validation; inferred calls are recommended for type checking.
38
+ - Output-schema construction failures carry stable diagnostic codes, with
39
+ reference/union-specific recipes and MCP constructor attribution.
40
+
41
+ ### Changed (breaking)
42
+
43
+ - MCP runtime now uses `@modelcontextprotocol/server` SDK v2. `createMcpServer`
44
+ returns a v2 `Server`; consumers using its low-level API must migrate method
45
+ registration/context types and must not mix v1 and v2 SDK instances.
46
+ - `runMcpStdioServer` now serves both legacy MCP clients and modern protocol
47
+ `2026-07-28` via `serveStdio`. Its `Promise<void>` startup signature is unchanged.
48
+ This change requires a pre-1.0 minor release, not a patch.
49
+
50
+ ### Fixed
51
+
52
+ - Discarded modern stdio discovery probes no longer contaminate legacy fallback:
53
+ each factory invocation gets a fresh server. SDK-reported opening errors are
54
+ diagnosed on stderr without contaminating protocol stdout.
55
+ - Pi validates all output schemas before registering tools, preventing partial
56
+ registration when a later tool has an invalid schema.
57
+ - `definePortableTool` retains metadata composition and already-annotated
58
+ two-generic inputs while still checking concrete schema-bearing handlers.
59
+ Packed declarations pin both valid and invalid replacement handlers.
60
+
61
+ ## [0.15.0] - 2026-07-12
62
+
63
+ ### Added
64
+
65
+ - `PiHostExtras` accepts `renderCall` and `renderResult`, pi's per-tool TUI
66
+ renderers for the call line and for the collapsed/expanded result view.
67
+ `registerPiTools` forwards both to `pi.registerTool` by identity; the core
68
+ never invokes them and does not import `pi-tui`. Contributed by
69
+ [@akaGelo](https://github.com/akaGelo) in
70
+ [#72](https://github.com/feniix/bridgekit/pull/72).
71
+
72
+ ### Changed
73
+
74
+ - `docs/rfc-host-extras.md` records the renderers as Gap E, in scope as a
75
+ registration-time pass-through.
76
+
7
77
  ## [0.14.0] - 2026-05-28
8
78
 
9
79
  ### Added
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  BridgeKit provides reusable TypeBox-backed tool definitions and adapters for exposing one tool implementation through pi, MCP, and other hosts.
4
4
 
5
+ More about why this exists and where it is useful: [feniix-hq.net/projects/bridgekit](https://feniix-hq.net/projects/bridgekit/).
6
+
5
7
  ## Quickstart
6
8
 
7
9
  ```ts
@@ -107,6 +109,48 @@ Do not deep-import from `dist/` or `src/` in consuming packages.
107
109
 
108
110
  ### pi adapter
109
111
 
112
+ #### Declared structured output
113
+
114
+ Add an optional object-shaped `outputSchema` to a portable tool to describe its
115
+ successful `structuredContent` for both Pi and MCP:
116
+
117
+ ```ts
118
+ const echoTool = definePortableTool({
119
+ name: "echo",
120
+ title: "Echo",
121
+ description: "Echo text.",
122
+ parameters: Type.Object({ text: Type.String() }),
123
+ outputSchema: Type.Object({ text: Type.String() }),
124
+ execute: (args) => ({ text: args.text, structuredContent: { text: args.text } }),
125
+ });
126
+ ```
127
+
128
+ Inlined object schemas and intersections of objects are accepted. Success calls
129
+ must return matching `structuredContent`; legacy `details` alone does not satisfy
130
+ the schema. Missing/invalid output throws a tool-attributed `TypeError` at the
131
+ portable seam and becomes an error result through the adapters. Argument/domain
132
+ failures are exempt from success schemas. Without `outputSchema`, existing result
133
+ behavior is unchanged.
134
+
135
+ `definePortableTool` infers the output schema and checks synchronous/asynchronous
136
+ successful data at compile time while preserving the handler's inferred result
137
+ type. Domain failures must use the literal `isError: true` discriminator.
138
+ Runtime validation still protects JavaScript callers and dynamically sourced data.
139
+ For explicitly annotated tools, use the third `PortableTool<TParams, TResult, TOutput>`
140
+ generic to retain schema checking; existing two-generic annotations deliberately
141
+ erase schema specificity.
142
+ Explicit legacy `definePortableTool<TParams, TResult>(...)` calls also erase the
143
+ schema, even with a concrete `outputSchema`. Prefer inferred calls for compile-time
144
+ schema checking; runtime output validation still applies to the explicit calls.
145
+ Existing annotated tools and metadata-only spreads remain accepted by
146
+ `definePortableTool`; concrete schema-bearing spreads still check replacement
147
+ handlers. Treat `parameters` and `outputSchema` as immutable after registration:
148
+ do not mutate their contents or replace either schema object.
149
+
150
+ Pi registrations forward the schema; Pi results now preserve `structuredContent`
151
+ directly for programmatic/codemode consumers, alongside the renderer-facing
152
+ `details` described below. The new fields are omitted when not supplied.
153
+
110
154
  ```ts
111
155
  import { registerPiTools } from "@feniix/bridgekit/pi";
112
156
  import { createTools } from "./tools.js";
@@ -254,7 +298,30 @@ Tool `parameters` must resolve to a JSON-Schema object at the top level. `Type.O
254
298
 
255
299
  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.
256
300
 
257
- 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).
301
+ The two adapters return `{ isError: true }` for argument/domain failures by default.
302
+ Use result guards on portable values; both wire formats now preserve `structuredContent`,
303
+ and Pi also exposes its renderer-facing `details`.
304
+ The guards match error/data shape, not provenance, so Pi wire failures also match
305
+ at runtime. Raw Pi values still lack portable `text`; do not cast them to
306
+ `PortableToolResult` to use the typed guards.
307
+
308
+ #### MCP SDK v2 and modern stdio
309
+
310
+ `runMcpStdioServer(options)` serves both legacy MCP revisions and modern
311
+ `2026-07-28` through SDK v2's `serveStdio`. Its `Promise<void>` resolves after
312
+ startup wiring, not at shutdown. Stdin EOF closes the connection; long-running
313
+ tools must honor `ctx.signal` and release their own resources.
314
+
315
+ `createMcpServer` now returns `@modelcontextprotocol/server` SDK v2's low-level
316
+ `Server`. This is a source-level breaking change for consumers using SDK methods
317
+ or types, even though legacy clients work over the wire. A direct
318
+ `server.connect(StdioServerTransport)` remains legacy-only; use the runner for
319
+ modern serving. See [migration guidance](docs/mcp-v2-migration.md).
320
+
321
+ Some SDK v1 clients validate error data against cached success output schemas
322
+ in their convenience `callTool` method; their generic request API can preserve
323
+ that data. SDK v2 clients correctly exempt error results. HTTP and tasks remain
324
+ outside BridgeKit's runner support.
258
325
 
259
326
  ### bin-wrapper (since 0.11.0)
260
327
 
@@ -1,4 +1,4 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1
+ import { Server } from "@modelcontextprotocol/server";
2
2
  import type { TSchema } from "typebox";
3
3
  import type { PortableTool } from "../core/define-tool.js";
4
4
  export interface CreateMcpServerOptions {
@@ -14,7 +14,7 @@ export interface CreateMcpServerOptions {
14
14
  * The outer array is snapshotted at construction; pushing or removing
15
15
  * entries from the caller's `tools` array post-construction does not affect
16
16
  * `tools/list` or `tools/call`. Schemas inside each tool are held by
17
- * reference, not deep-cloned — treat `tool.parameters` as immutable once
17
+ * reference, not deep-cloned — treat `tool.parameters` and `tool.outputSchema` as immutable once
18
18
  * `createMcpServer` returns.
19
19
  */
20
20
  tools: readonly PortableTool<TSchema>[];
@@ -1,7 +1,7 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
1
+ import { Server } from "@modelcontextprotocol/server";
2
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
3
  import { executePortableTool } from "../core/execute-tool.js";
4
+ import { assertPortableOutputSchema, isObjectSchema, schemaTypeLabel, throwWithCode } from "../core/output-schema.js";
5
5
  function toMcpResult(result) {
6
6
  return {
7
7
  content: [{ type: "text", text: result.text }],
@@ -10,85 +10,22 @@ function toMcpResult(result) {
10
10
  };
11
11
  }
12
12
  /**
13
- * Returns true if `schema` resolves to a JSON-Schema object at the top level —
14
- * either `{"type": "object", ...}` directly, or an `allOf` composition whose
15
- * branches are all object schemas (as produced by `Type.Intersect`).
16
- *
17
- * The check walks the canonical JSON-Schema shape rather than poking at
18
- * TypeBox `Kind` symbols, mirroring how `executePortableTool` traverses
19
- * schemas. The MCP wire contract is "top-level object," so that's the
20
- * predicate, regardless of which TypeBox combinator built the schema.
21
- */
22
- function isObjectSchema(schema) {
23
- if (typeof schema !== "object" || schema === null)
24
- return false;
25
- const candidate = schema;
26
- // $ref takes structural precedence: a hybrid {type: "object", $ref: "..."}
27
- // is not an inlined object schema — it's a reference, and
28
- // assertObjectShapedParameters owns the rejection recipe. Return false so
29
- // the $ref branch runs.
30
- if (typeof candidate.$ref === "string")
31
- return false;
32
- if (candidate.type === "object")
33
- return true;
34
- if (Array.isArray(candidate.allOf) && candidate.allOf.length > 0) {
35
- return candidate.allOf.every((entry) => isObjectSchema(entry));
36
- }
37
- return false;
38
- }
39
- /**
40
- * Best-effort human-readable label for a non-object schema, used in error
41
- * messages. For `allOf` (TypeBox's `Intersect` lowering) we descend into the
42
- * branches and surface the first non-object branch by index — a bare `"allOf"`
43
- * label is misleading because the rejection is owned by one specific branch,
44
- * not the composition itself.
45
- *
46
- * The `$ref` check is first because `Type.Cyclic` produces
47
- * `{ $defs: {...}, $ref: "..." }` at the root; the `$ref` is the load-bearing
48
- * structural signal regardless of what else is set, and the recipe for that
49
- * shape (inline or split) is different from the generic `Type.Object(...)`
50
- * wrap recipe.
51
- */
52
- function schemaTypeLabel(schema) {
53
- if (typeof schema !== "object" || schema === null)
54
- return "unknown";
55
- const candidate = schema;
56
- if (typeof candidate.$ref === "string")
57
- return "$ref";
58
- if (typeof candidate.type === "string")
59
- return candidate.type;
60
- if (Array.isArray(candidate.anyOf))
61
- return "anyOf";
62
- if (Array.isArray(candidate.oneOf))
63
- return "oneOf";
64
- if (Array.isArray(candidate.allOf)) {
65
- if (candidate.allOf.length === 0)
66
- return "allOf (empty)";
67
- for (let i = 0; i < candidate.allOf.length; i++) {
68
- if (!isObjectSchema(candidate.allOf[i])) {
69
- return `allOf[${i}] resolves to type="${schemaTypeLabel(candidate.allOf[i])}"`;
70
- }
71
- }
72
- }
73
- return "unknown";
74
- }
75
- /**
76
- * Render `parameters` as MCP `inputSchema`. The MCP SDK Zod-validates that the
77
- * top-level schema has `type: "object"` on the client side of `tools/list`, so
13
+ * Render a validated object schema as MCP `inputSchema` or `outputSchema`.
14
+ * MCP clients require the top-level schema to have `type: "object"`, so
78
15
  * `Type.Intersect` (which TypeBox renders as `{ allOf: [...] }` with no top-
79
16
  * level `type`) needs `type: "object"` synthesized before transmission. This
80
17
  * is a no-op for `Type.Object` schemas, which already carry the field.
81
18
  *
82
- * The casts here are sound because `assertObjectShapedParameters` runs first
83
- * and rejects any schema whose top-level lowering isn't `type:"object"` or
84
- * `allOf` of objects — both shapes round-trip as MCP `Tool["inputSchema"]`.
19
+ * The casts here are sound because `assertObjectShapedParameters` or
20
+ * `assertPortableOutputSchema` runs first and rejects schemas whose top-level
21
+ * lowering isn't `type:"object"` or `allOf` of objects.
85
22
  */
86
- function toInputSchema(parameters) {
87
- const candidate = parameters;
23
+ function toMcpObjectSchema(schema) {
24
+ const candidate = schema;
88
25
  if (candidate.type === "object") {
89
- return parameters;
26
+ return schema;
90
27
  }
91
- return { type: "object", ...parameters };
28
+ return { type: "object", ...schema };
92
29
  }
93
30
  /**
94
31
  * Stable `error.code` values attached to `createMcpServer` construction
@@ -98,11 +35,6 @@ function toInputSchema(parameters) {
98
35
  const ERROR_CODE_NON_OBJECT_PARAMETERS = "BRIDGEKIT_MCP_NON_OBJECT_PARAMETERS";
99
36
  const ERROR_CODE_REF_PARAMETERS = "BRIDGEKIT_MCP_REF_PARAMETERS";
100
37
  const ERROR_CODE_DUPLICATE_TOOL_NAME = "BRIDGEKIT_MCP_DUPLICATE_TOOL_NAME";
101
- function throwWithCode(message, code) {
102
- const error = new Error(message);
103
- error.code = code;
104
- throw error;
105
- }
106
38
  function assertObjectShapedParameters(tools) {
107
39
  for (const tool of tools) {
108
40
  if (!isObjectSchema(tool.parameters)) {
@@ -150,6 +82,8 @@ function assertUniqueToolNames(tools) {
150
82
  }
151
83
  }
152
84
  export function createMcpServer(options) {
85
+ for (const tool of options.tools)
86
+ assertPortableOutputSchema(tool, "createMcpServer", "BRIDGEKIT_MCP");
153
87
  assertObjectShapedParameters(options.tools);
154
88
  assertUniqueToolNames(options.tools);
155
89
  // Build the dispatch map and the listing payload at construction so
@@ -176,7 +110,8 @@ export function createMcpServer(options) {
176
110
  name: tool.name,
177
111
  title: tool.title,
178
112
  description: tool.description,
179
- inputSchema: toInputSchema(tool.parameters),
113
+ inputSchema: toMcpObjectSchema(tool.parameters),
114
+ ...(tool.outputSchema !== undefined && { outputSchema: toMcpObjectSchema(tool.outputSchema) }),
180
115
  ...(hasAnnotations ? { annotations: { ...annotations } } : {}),
181
116
  };
182
117
  });
@@ -184,8 +119,8 @@ export function createMcpServer(options) {
184
119
  capabilities: { tools: { listChanged: false } },
185
120
  ...(options.instructions !== undefined && { instructions: options.instructions }),
186
121
  });
187
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpTools }));
188
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
122
+ server.setRequestHandler("tools/list", async () => ({ tools: mcpTools }));
123
+ server.setRequestHandler("tools/call", async (request, ctx) => {
189
124
  const tool = byName.get(request.params.name);
190
125
  if (!tool) {
191
126
  return {
@@ -196,9 +131,9 @@ export function createMcpServer(options) {
196
131
  try {
197
132
  const result = await executePortableTool(tool, request.params.arguments ?? {}, {
198
133
  host: "mcp",
199
- signal: extra.signal,
134
+ signal: ctx.mcpReq.signal,
200
135
  });
201
- return toMcpResult(result);
136
+ return server.projectCallToolResult(toMcpResult(result), tool.outputSchema !== undefined ? toMcpObjectSchema(tool.outputSchema) : undefined);
202
137
  }
203
138
  catch (error) {
204
139
  const message = error instanceof Error ? error.message : String(error);
@@ -211,6 +146,10 @@ export function createMcpServer(options) {
211
146
  return server;
212
147
  }
213
148
  export async function runMcpStdioServer(options) {
214
- const server = createMcpServer(options);
215
- await server.connect(new StdioServerTransport());
149
+ // Validate eagerly, but each discarded probe/connection must own its server:
150
+ // a modern probe installs era-specific handlers before it can be discarded.
151
+ createMcpServer(options);
152
+ serveStdio(() => createMcpServer(options), {
153
+ onerror: (error) => process.stderr.write(`[bridgekit-mcp] ${error.message}\n`),
154
+ });
216
155
  }
@@ -11,6 +11,7 @@ type PiToolUpdate = {
11
11
  type PiToolResult = {
12
12
  content: PiContent[];
13
13
  details: Record<string, unknown>;
14
+ structuredContent?: Record<string, unknown>;
14
15
  isError?: boolean;
15
16
  };
16
17
  type PiToolDefinition = {
@@ -18,6 +19,7 @@ type PiToolDefinition = {
18
19
  label: string;
19
20
  description: string;
20
21
  parameters: TSchema;
22
+ outputSchema?: TSchema;
21
23
  execute(toolCallId: string, params: unknown, signal?: AbortSignal, onUpdate?: (update: PiToolUpdate) => void, ctx?: unknown): Promise<PiToolResult>;
22
24
  promptSnippet?: string;
23
25
  promptGuidelines?: string[];
@@ -1,4 +1,5 @@
1
1
  import { executePortableTool } from "../core/execute-tool.js";
2
+ import { assertPortableOutputSchema } from "../core/output-schema.js";
2
3
  import { isValidationFailure } from "../core/result-guards.js";
3
4
  function toPiDetails(result) {
4
5
  return result.structuredContent ?? result.details ?? {};
@@ -40,6 +41,8 @@ let throwModeDeprecationWarned = false;
40
41
  * registerPiTools(pi, tools, { errorHandling: "throw" });
41
42
  */
42
43
  export function registerPiTools(pi, tools, options = {}) {
44
+ for (const tool of tools)
45
+ assertPortableOutputSchema(tool);
43
46
  const errorHandling = options.errorHandling ?? "return";
44
47
  if (errorHandling === "throw" && !throwModeDeprecationWarned) {
45
48
  throwModeDeprecationWarned = true;
@@ -56,6 +59,7 @@ export function registerPiTools(pi, tools, options = {}) {
56
59
  label: tool.title,
57
60
  description: tool.description,
58
61
  parameters: tool.parameters,
62
+ ...(tool.outputSchema !== undefined && { outputSchema: tool.outputSchema }),
59
63
  // Pass-through pi-side metadata. Each field is gated on `!== undefined`
60
64
  // so tools without `hostExtras.pi` build a registration object whose
61
65
  // own-property keys are unchanged from the pre-0.9 shape.
@@ -116,6 +120,7 @@ export function registerPiTools(pi, tools, options = {}) {
116
120
  return {
117
121
  content: [{ type: "text", text: result.text }],
118
122
  details: toPiDetails(result),
123
+ ...(result.structuredContent !== undefined && { structuredContent: result.structuredContent }),
119
124
  isError: result.isError === true,
120
125
  };
121
126
  },
@@ -153,12 +153,28 @@ export interface PortableToolHostExtras {
153
153
  pi?: PiHostExtras;
154
154
  mcp?: McpHostExtras;
155
155
  }
156
- export interface PortableTool<TParams extends TSchema = TSchema, TResult extends PortableToolResult = PortableToolResult> {
156
+ /** Successes satisfy the output schema; literal domain failures retain arbitrary data. */
157
+ type SchemaResult<TOutput extends TSchema> = (PortableToolResult & {
158
+ structuredContent: Exclude<Static<TOutput>, undefined> & Record<string, unknown>;
159
+ isError?: false;
160
+ }) | (PortableToolResult & {
161
+ isError: true;
162
+ });
163
+ type CheckedResult<TResult extends PortableToolResult, TOutput extends TSchema | undefined> = [TOutput] extends [
164
+ TSchema
165
+ ] ? TResult & SchemaResult<NoInfer<Extract<TOutput, TSchema>>> : TResult;
166
+ export interface PortableTool<TParams extends TSchema = TSchema, TResult extends PortableToolResult = PortableToolResult, TOutput extends TSchema | undefined = TSchema | undefined> {
157
167
  name: string;
158
168
  title: string;
159
169
  description: string;
160
170
  parameters: TParams;
161
- execute: (args: Static<TParams>, ctx: PortableToolContext) => TResult | Promise<TResult>;
171
+ /**
172
+ * Object-shaped TypeBox schema for successful structuredContent.
173
+ * Literal isError:true results are exempt. Treat this schema and parameters
174
+ * as immutable after registration, including replacing either schema object.
175
+ */
176
+ outputSchema?: TOutput;
177
+ execute: (args: Static<TParams>, ctx: PortableToolContext) => CheckedResult<TResult, TOutput> | Promise<CheckedResult<TResult, TOutput>>;
162
178
  /**
163
179
  * Optional per-host metadata. Adapters consume the keys they recognise;
164
180
  * unknown host namespaces are ignored. Absent → no behavior change.
@@ -170,4 +186,18 @@ export interface PortableTool<TParams extends TSchema = TSchema, TResult extends
170
186
  */
171
187
  hostExtras?: PortableToolHostExtras;
172
188
  }
173
- export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult>): PortableTool<TParams, TResult>;
189
+ export declare function definePortableTool<TParams extends TSchema, TOutput extends TSchema, TExecute extends (args: Static<TParams>, ctx: PortableToolContext) => SchemaResult<NoInfer<TOutput>> | Promise<SchemaResult<NoInfer<TOutput>>>>(tool: Omit<PortableTool<TParams, PortableToolResult, TOutput>, "execute" | "outputSchema"> & {
190
+ outputSchema: TOutput;
191
+ execute: TExecute;
192
+ }): PortableTool<TParams, Awaited<ReturnType<TExecute>>, TOutput>;
193
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult> & {
194
+ outputSchema?: undefined;
195
+ }): PortableTool<TParams, TResult, undefined>;
196
+ export declare function definePortableTool<TParams extends TSchema, TOutput extends TSchema, TExecute extends (args: Static<TParams>, ctx: PortableToolContext) => SchemaResult<NoInfer<TOutput>> | Promise<SchemaResult<NoInfer<TOutput>>>>(tool: Omit<PortableTool<TParams, PortableToolResult, TOutput>, "execute" | "outputSchema"> & {
197
+ outputSchema?: TOutput;
198
+ execute: TExecute;
199
+ }): PortableTool<TParams, Awaited<ReturnType<TExecute>>, TOutput>;
200
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult, TTool extends {
201
+ outputSchema?: TSchema | undefined;
202
+ } = PortableTool<TParams, TResult>>(tool: PortableTool<TParams, TResult> & TTool & (TSchema extends NonNullable<NoInfer<TTool>["outputSchema"]> ? unknown : never)): TTool;
203
+ export {};
@@ -1,17 +1,30 @@
1
1
  import { Check, Errors, Pointer } from "typebox/value";
2
+ import { assertPortableOutputSchema } from "./output-schema.js";
2
3
  const ROOT_FIELD = "(root)";
4
+ /**
5
+ * Decode one RFC 6901 JSON Pointer segment (`~1` → `/`, `~0` → `~`).
6
+ *
7
+ * TypeBox 1.3+ escapes property names in `instancePath` and `schemaPath`
8
+ * (`"a/b"` → `/a~1b`); TypeBox 1.1 did not. The package range spans both, so
9
+ * every lookup tries the raw segment first and the decoded form second.
10
+ */
11
+ function decodePointerSegment(segment) {
12
+ return segment.replaceAll("~1", "/").replaceAll("~0", "~");
13
+ }
3
14
  function fieldFromPath(instancePath) {
4
- return instancePath.split("/").filter(Boolean).at(-1) ?? ROOT_FIELD;
15
+ const last = instancePath.split("/").filter(Boolean).at(-1);
16
+ return last === undefined ? ROOT_FIELD : decodePointerSegment(last);
5
17
  }
6
18
  /**
7
19
  * Resolve the leaf property name from a TypeBox error by walking
8
20
  * `error.schemaPath` rather than `error.instancePath`.
9
21
  *
10
- * `instancePath` does not escape `/` inside property names (TypeBox does not
11
- * follow JSON Pointer RFC 6901's `~1` encoding), so a schema with BOTH a
12
- * slash-named property `"a/b"` AND a nested path `a.b` produces the same
22
+ * Before 1.3, TypeBox's `instancePath` did not escape `/` inside property
23
+ * names (no JSON Pointer RFC 6901 `~1` encoding), so a schema with BOTH a
24
+ * slash-named property `"a/b"` AND a nested path `a.b` produced the same
13
25
  * `instancePath: "/a/b"` for either failure — the two cases are
14
- * indistinguishable from the data path alone. `schemaPath` carries explicit
26
+ * indistinguishable from the data path alone. TypeBox 1.3+ escapes both paths
27
+ * (`/a~1b`, `#/properties/a~1b`); the walker accepts either form. `schemaPath` carries explicit
15
28
  * `/properties/` markers per nesting level, so the two cases become
16
29
  * structurally distinct:
17
30
  *
@@ -86,13 +99,17 @@ function walkSegments(node, segments, i, lastField) {
86
99
  return walkSegments(obj.items, segments, i + 1, lastField);
87
100
  }
88
101
  // `properties` command: subsequent segments form the property name (joined
89
- // by `/` to recover slash-named keys). Consume by greedy longest-prefix
90
- // match against the parent's actual `properties` keys, then descend.
102
+ // by `/` to recover unescaped slash-named keys from TypeBox < 1.3). Consume
103
+ // by greedy longest-prefix match against the parent's actual `properties`
104
+ // keys, then descend. The raw candidate wins; the RFC 6901-decoded form
105
+ // covers TypeBox 1.3+, which emits `a~1b` as a single segment.
91
106
  if (head === "properties" && obj.properties && typeof obj.properties === "object") {
92
107
  const props = obj.properties;
93
108
  for (let j = segments.length; j > i + 1; j--) {
94
- const candidate = segments.slice(i + 1, j).join("/");
95
- if (Object.hasOwn(props, candidate)) {
109
+ const raw = segments.slice(i + 1, j).join("/");
110
+ for (const candidate of new Set([raw, decodePointerSegment(raw)])) {
111
+ if (!Object.hasOwn(props, candidate))
112
+ continue;
96
113
  const child = walkSegments(props[candidate], segments, j, candidate);
97
114
  if (child !== undefined)
98
115
  return child;
@@ -150,12 +167,15 @@ function resolveSchemaAtPath(schema, instancePath) {
150
167
  if (!current || typeof current !== "object")
151
168
  return undefined;
152
169
  const obj = current;
153
- if (obj.properties && typeof obj.properties === "object" && segment in obj.properties) {
154
- const next = obj.properties[segment];
155
- if (next === undefined)
156
- return undefined;
157
- current = next;
158
- continue;
170
+ if (obj.properties && typeof obj.properties === "object") {
171
+ const key = segment in obj.properties ? segment : decodePointerSegment(segment);
172
+ if (key in obj.properties) {
173
+ const next = obj.properties[key];
174
+ if (next === undefined)
175
+ return undefined;
176
+ current = next;
177
+ continue;
178
+ }
159
179
  }
160
180
  if (obj.items !== undefined && /^\d+$/.test(segment)) {
161
181
  if (Array.isArray(obj.items)) {
@@ -356,7 +376,8 @@ function suppressSiblingErrorsUnderUnion(schema, value, errors) {
356
376
  const remainder = error.instancePath.slice(prefix.length);
357
377
  if (remainder.includes("/"))
358
378
  continue; // only direct discriminator props
359
- const losingValues = resolution.losingDiscriminators.get(remainder);
379
+ const losingValues = resolution.losingDiscriminators.get(remainder) ??
380
+ resolution.losingDiscriminators.get(decodePointerSegment(remainder));
360
381
  if (!losingValues)
361
382
  continue;
362
383
  if (error.keyword === "const" && losingValues.has(error.params.allowedValue))
@@ -369,6 +390,21 @@ function suppressSiblingErrorsUnderUnion(schema, value, errors) {
369
390
  return true;
370
391
  });
371
392
  }
393
+ /**
394
+ * TypeBox 1.3+ reports each additional property twice: once as the
395
+ * `additionalProperties` summary at the object (which `expandTypeBoxError`
396
+ * expands per key) and once per key as the failure of the `additionalProperties`
397
+ * sub-schema itself (`keyword: "boolean"` for `false`, or e.g. `"type"` for a
398
+ * schema value), with `schemaPath` ending at that keyword. TypeBox 1.1 emitted
399
+ * only the summary. Dropping the per-key duplicates keeps one error per key and
400
+ * lets union phantom suppression, which reasons about the summary, stay
401
+ * authoritative. A trailing segment after a `properties` command is a property
402
+ * literally named `additionalProperties`, not the keyword.
403
+ */
404
+ function isAdditionalPropertiesSubschemaError(error) {
405
+ const segments = error.schemaPath.split("/");
406
+ return segments.at(-1) === "additionalProperties" && segments.at(-2) !== "properties";
407
+ }
372
408
  function expandTypeBoxError(schema, error) {
373
409
  // Read offending property names from TypeBox's structured `params` rather
374
410
  // than parsing the message string. Two payoffs: locale independence (the
@@ -427,9 +463,7 @@ export function validatePortableToolArgs(tool, args) {
427
463
  // satisfy ONE branch, not all of them). Then dedupe the survivors by
428
464
  // (field, message) using JSON.stringify so the key can't collide regardless
429
465
  // of what characters field or message contain.
430
- const rawErrors = suppressSiblingErrorsUnderUnion(tool.parameters, args, [
431
- ...Errors(tool.parameters, args),
432
- ]);
466
+ const rawErrors = suppressSiblingErrorsUnderUnion(tool.parameters, args, [...Errors(tool.parameters, args)].filter((error) => !isAdditionalPropertiesSubschemaError(error)));
433
467
  const seen = new Set();
434
468
  const errors = rawErrors
435
469
  .flatMap((error) => expandTypeBoxError(tool.parameters, error))
@@ -443,6 +477,7 @@ export function validatePortableToolArgs(tool, args) {
443
477
  return { ok: false, errors };
444
478
  }
445
479
  export async function executePortableTool(tool, args, ctx) {
480
+ assertPortableOutputSchema(tool);
446
481
  const validation = validatePortableToolArgs(tool, args);
447
482
  if (!validation.ok) {
448
483
  return {
@@ -457,5 +492,13 @@ export async function executePortableTool(tool, args, ctx) {
457
492
  isError: true,
458
493
  };
459
494
  }
460
- return (await tool.execute(validation.args, ctx));
495
+ const result = await tool.execute(validation.args, ctx);
496
+ if (tool.outputSchema !== undefined &&
497
+ result.isError !== true &&
498
+ (result.structuredContent === undefined || !Check(tool.outputSchema, result.structuredContent))) {
499
+ // A handler violating its declared success contract is a programmer error,
500
+ // not an argument failure. Keep inferred success/error result types intact.
501
+ throw new TypeError(`Invalid structured output for ${tool.name}: structuredContent must match outputSchema.`);
502
+ }
503
+ return result;
461
504
  }
@@ -0,0 +1,7 @@
1
+ import type { PortableTool } from "./define-tool.js";
2
+ /** Follow JSON Schema structure rather than TypeBox-specific symbols. */
3
+ export declare function isObjectSchema(schema: unknown): boolean;
4
+ /** Identify the first unsupported root/branch for an actionable schema diagnostic. */
5
+ export declare function schemaTypeLabel(schema: unknown): string;
6
+ export declare function throwWithCode(message: string, code: string, ErrorType?: ErrorConstructor): never;
7
+ export declare function assertPortableOutputSchema(tool: PortableTool, context?: string, codePrefix?: string): void;
@@ -0,0 +1,53 @@
1
+ /** Follow JSON Schema structure rather than TypeBox-specific symbols. */
2
+ export function isObjectSchema(schema) {
3
+ if (typeof schema !== "object" || schema === null)
4
+ return false;
5
+ const candidate = schema;
6
+ if (typeof candidate.$ref === "string")
7
+ return false;
8
+ if (candidate.type === "object")
9
+ return true;
10
+ return Array.isArray(candidate.allOf) && candidate.allOf.length > 0 && candidate.allOf.every(isObjectSchema);
11
+ }
12
+ /** Identify the first unsupported root/branch for an actionable schema diagnostic. */
13
+ export function schemaTypeLabel(schema) {
14
+ if (typeof schema !== "object" || schema === null)
15
+ return "unknown";
16
+ const candidate = schema;
17
+ if (typeof candidate.$ref === "string")
18
+ return "$ref";
19
+ if (typeof candidate.type === "string")
20
+ return candidate.type;
21
+ if (Array.isArray(candidate.anyOf))
22
+ return "anyOf";
23
+ if (Array.isArray(candidate.oneOf))
24
+ return "oneOf";
25
+ if (Array.isArray(candidate.allOf)) {
26
+ if (candidate.allOf.length === 0)
27
+ return "allOf (empty)";
28
+ for (let i = 0; i < candidate.allOf.length; i++) {
29
+ if (!isObjectSchema(candidate.allOf[i])) {
30
+ return `allOf[${i}] resolves to type="${schemaTypeLabel(candidate.allOf[i])}"`;
31
+ }
32
+ }
33
+ }
34
+ return "unknown";
35
+ }
36
+ export function throwWithCode(message, code, ErrorType = Error) {
37
+ const error = new ErrorType(message);
38
+ error.code = code;
39
+ throw error;
40
+ }
41
+ export function assertPortableOutputSchema(tool, context, codePrefix = "BRIDGEKIT") {
42
+ if (tool.outputSchema !== undefined && !isObjectSchema(tool.outputSchema)) {
43
+ const label = schemaTypeLabel(tool.outputSchema);
44
+ let recipe = "Use an inlined object schema or an intersection of object schemas.";
45
+ if (label.includes("$ref")) {
46
+ recipe += " Top-level $ref / Type.Cyclic is unsupported; inline the referenced shape or split recursive shapes.";
47
+ }
48
+ else if (label.includes("anyOf") || label.includes("oneOf")) {
49
+ recipe += " Top-level unions are unsupported; flatten branches into one object or expose separate tools.";
50
+ }
51
+ throwWithCode(`${context ? `${context}: ` : ""}Invalid outputSchema for ${tool.name} (type="${label}"): ${recipe}`, `${codePrefix}_${label.includes("$ref") ? "REF" : "NON_OBJECT"}_OUTPUT_SCHEMA`, TypeError);
52
+ }
53
+ }
@@ -144,7 +144,8 @@ In `package.json`:
144
144
 
145
145
  pi behavior:
146
146
 
147
- - Valid portable results become pi tool results.
147
+ - Valid portable results become pi tool results with `structuredContent` preserved
148
+ alongside renderer-facing `details`. Optional `outputSchema` is forwarded.
148
149
  - 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
150
  - 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.
150
151
  - Progress updates from `ctx.progress?.(...)` map to pi updates.
@@ -269,6 +270,10 @@ MCP behavior:
269
270
  - `tools/call` validates arguments before invoking handlers.
270
271
  - Invalid arguments and portable `isError: true` results return MCP tool results with `isError: true`.
271
272
  - Unexpected thrown errors become MCP tool errors with text content.
273
+ - Optional object-shaped `outputSchema` is listed for successful structured results;
274
+ matching `structuredContent` is required and validated. Error results are exempt.
275
+ - The stdio runner serves legacy and modern `2026-07-28` clients through SDK v2.
276
+ It retains `Promise<void>` startup behavior; stdin EOF shuts down the connection.
272
277
  - The module stays import-passive and testable: tests can import `createMcpServerOptions()` without starting stdio.
273
278
 
274
279
  ---
package/llms.txt CHANGED
@@ -59,6 +59,18 @@ export function createTools() {
59
59
  - Keep portable tools host-neutral. Do not import pi or MCP SDKs from files that define tool behavior.
60
60
  - Tool `parameters` for MCP-compatible tools must resolve to a JSON-Schema object at the top level. `Type.Object(...)` is the common case; `Type.Intersect([Type.Object(...), Type.Object(...)])` is also accepted. Non-object top-level shapes throw at `createMcpServer` construction.
61
61
  - Return `{ text, structuredContent }` for successful results.
62
+ - Optional `outputSchema` declares successful structured data for both hosts: use
63
+ an inlined TypeBox object or object intersection. Missing/invalid successful
64
+ `structuredContent` throws `TypeError`; argument/domain error results are exempt.
65
+ Legacy `details` alone does not satisfy a declared output schema.
66
+ `definePortableTool` infers/checks schema-linked success data at compile time;
67
+ use literal `isError: true` for exempt domain failures. A third
68
+ `PortableTool<TParams, TResult, TOutput>` generic preserves the schema in explicit
69
+ annotations; legacy two-generic annotations erase it. Runtime checks still apply.
70
+ Explicit legacy `definePortableTool<TParams, TResult>(...)` calls also erase
71
+ schema inference, even for concrete schemas; prefer inferred calls for checking.
72
+ Existing tool annotations and metadata spreads remain composable. Treat both
73
+ `parameters` and `outputSchema` as immutable after registration (no mutation or replacement).
62
74
  - Use `isError: true` for domain/tool-level failures that should reach the model as tool output.
63
75
  - Throw only for unexpected programmer, adapter, or runtime failures.
64
76
  - Respect `ctx.signal` in long-running tools.
@@ -75,9 +87,19 @@ export function createTools() {
75
87
 
76
88
  - 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
89
  - 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.
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.
90
+ - Both adapters preserve `structuredContent`; Pi also maps it into renderer-facing
91
+ `details`. Both retain legacy `details` fallback. Output schemas are forwarded
92
+ to registrations/listings only when provided.
79
93
  - 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.
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`.
94
+ - Result guards take `PortableToolResult` values from `executePortableTool`, not
95
+ raw Pi wire objects (`content` instead of portable `text`).
96
+ Their runtime predicates are shape-based, so Pi structured failures also
97
+ match; that does not make a raw Pi result a valid portable typed value.
98
+ - MCP uses SDK v2's low-level `Server` and method-string handlers. Cancellation
99
+ reads `ctx.mcpReq.signal`. `runMcpStdioServer` serves legacy and modern
100
+ `2026-07-28` stdio; direct `Server.connect` remains legacy-only. The runner's
101
+ `Promise<void>` resolves at startup, not EOF. Migration details:
102
+ `docs/mcp-v2-migration.md`.
81
103
  - `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.
82
104
  - `PortableTool.hostExtras` (0.9+) carries optional per-host metadata in namespaced sub-objects (`pi`, `mcp`, plus module-augmentable custom hosts). Each field is optional and zero-cost when absent — a tool without `hostExtras` produces byte-identical pi registration and MCP `tools/list` payloads to 0.8.x. `hostExtras.pi.pendingMessage` fires `onUpdate({ content, details: { status: "pending" } })` exactly once before TypeBox validation; `hostExtras.pi.promptSnippet` / `promptGuidelines` pass through to pi's `registerTool`; `hostExtras.pi.renderCall` / `renderResult` are opaque TUI callbacks typed by exported `PiToolCallRenderer` / `PiToolResultRenderer` aliases and forwarded by identity; `hostExtras.mcp.annotations` (`title` / `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`) attaches MCP advisory hints. Tool definitions stay host-neutral — host-specific metadata lives in its namespace, not on the tool's top-level fields.
83
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.",
5
5
  "keywords": [
6
6
  "pi",
@@ -23,7 +23,7 @@
23
23
  "bugs": {
24
24
  "url": "https://github.com/feniix/bridgekit/issues"
25
25
  },
26
- "homepage": "https://github.com/feniix/bridgekit#readme",
26
+ "homepage": "https://feniix-hq.net/projects/bridgekit/",
27
27
  "exports": {
28
28
  ".": {
29
29
  "types": "./dist/src/index.d.ts",
@@ -69,11 +69,13 @@
69
69
  "examples/README.md"
70
70
  ],
71
71
  "dependencies": {
72
- "@modelcontextprotocol/sdk": "^1.29.0",
72
+ "@modelcontextprotocol/server": "^2.3.1",
73
73
  "typebox": "^1.1.31"
74
74
  },
75
75
  "devDependencies": {
76
- "@biomejs/biome": "2.4.16",
76
+ "@modelcontextprotocol/client": "^2.3.1",
77
+ "@modelcontextprotocol/sdk": "^1.30.1",
78
+ "@biomejs/biome": "2.5.15",
77
79
  "@total-typescript/shoehorn": "^0.1.2",
78
80
  "@types/node": "^25.9.1",
79
81
  "typescript": "^6.0.3"