@feniix/bridgekit 0.15.0 → 0.16.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,63 @@ 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.0] - 2026-10-06
10
+
11
+ ### Added
12
+
13
+ - Optional object-shaped `PortableTool.outputSchema`. Successful calls must return
14
+ matching `structuredContent`; missing or invalid output throws a tool-attributed
15
+ `TypeError`. Argument and domain failures are exempt from success schemas.
16
+ - Pi registrations forward `outputSchema`, and Pi results preserve
17
+ `structuredContent` alongside the existing renderer-facing `details`.
18
+ - MCP listings forward object-shaped output schemas, including intersections;
19
+ invalid output schemas are rejected at server construction.
20
+ - Schema-declaring `definePortableTool` calls check successful structured data at
21
+ compile time while preserving inferred result unions; explicit annotations can
22
+ retain schemas with a third `PortableTool` generic. Domain failures are exempt.
23
+ Explicit legacy two-generic function calls intentionally erase schema linkage
24
+ and retain runtime validation; inferred calls are recommended for type checking.
25
+ - Output-schema construction failures carry stable diagnostic codes, with
26
+ reference/union-specific recipes and MCP constructor attribution.
27
+
28
+ ### Changed (breaking)
29
+
30
+ - MCP runtime now uses `@modelcontextprotocol/server` SDK v2. `createMcpServer`
31
+ returns a v2 `Server`; consumers using its low-level API must migrate method
32
+ registration/context types and must not mix v1 and v2 SDK instances.
33
+ - `runMcpStdioServer` now serves both legacy MCP clients and modern protocol
34
+ `2026-07-28` via `serveStdio`. Its `Promise<void>` startup signature is unchanged.
35
+ This change requires a pre-1.0 minor release, not a patch.
36
+
37
+ ### Fixed
38
+
39
+ - Discarded modern stdio discovery probes no longer contaminate legacy fallback:
40
+ each factory invocation gets a fresh server. SDK-reported opening errors are
41
+ diagnosed on stderr without contaminating protocol stdout.
42
+ - Pi validates all output schemas before registering tools, preventing partial
43
+ registration when a later tool has an invalid schema.
44
+ - `definePortableTool` retains metadata composition and already-annotated
45
+ two-generic inputs while still checking concrete schema-bearing handlers.
46
+ Packed declarations pin both valid and invalid replacement handlers.
47
+
48
+ ## [0.15.0] - 2026-07-12
49
+
50
+ ### Added
51
+
52
+ - `PiHostExtras` accepts `renderCall` and `renderResult`, pi's per-tool TUI
53
+ renderers for the call line and for the collapsed/expanded result view.
54
+ `registerPiTools` forwards both to `pi.registerTool` by identity; the core
55
+ never invokes them and does not import `pi-tui`. Contributed by
56
+ [@akaGelo](https://github.com/akaGelo) in
57
+ [#72](https://github.com/feniix/bridgekit/pull/72).
58
+
59
+ ### Changed
60
+
61
+ - `docs/rfc-host-extras.md` records the renderers as Gap E, in scope as a
62
+ registration-time pass-through.
63
+
7
64
  ## [0.14.0] - 2026-05-28
8
65
 
9
66
  ### 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,4 +1,5 @@
1
1
  import { Check, Errors, Pointer } from "typebox/value";
2
+ import { assertPortableOutputSchema } from "./output-schema.js";
2
3
  const ROOT_FIELD = "(root)";
3
4
  function fieldFromPath(instancePath) {
4
5
  return instancePath.split("/").filter(Boolean).at(-1) ?? ROOT_FIELD;
@@ -443,6 +444,7 @@ export function validatePortableToolArgs(tool, args) {
443
444
  return { ok: false, errors };
444
445
  }
445
446
  export async function executePortableTool(tool, args, ctx) {
447
+ assertPortableOutputSchema(tool);
446
448
  const validation = validatePortableToolArgs(tool, args);
447
449
  if (!validation.ok) {
448
450
  return {
@@ -457,5 +459,13 @@ export async function executePortableTool(tool, args, ctx) {
457
459
  isError: true,
458
460
  };
459
461
  }
460
- return (await tool.execute(validation.args, ctx));
462
+ const result = await tool.execute(validation.args, ctx);
463
+ if (tool.outputSchema !== undefined &&
464
+ result.isError !== true &&
465
+ (result.structuredContent === undefined || !Check(tool.outputSchema, result.structuredContent))) {
466
+ // A handler violating its declared success contract is a programmer error,
467
+ // not an argument failure. Keep inferred success/error result types intact.
468
+ throw new TypeError(`Invalid structured output for ${tool.name}: structuredContent must match outputSchema.`);
469
+ }
470
+ return result;
461
471
  }
@@ -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.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",
@@ -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.4",
77
79
  "@total-typescript/shoehorn": "^0.1.2",
78
80
  "@types/node": "^25.9.1",
79
81
  "typescript": "^6.0.3"