@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 +57 -0
- package/README.md +68 -1
- package/dist/src/adapters/mcp.d.ts +2 -2
- package/dist/src/adapters/mcp.js +26 -87
- package/dist/src/adapters/pi.d.ts +2 -0
- package/dist/src/adapters/pi.js +5 -0
- package/dist/src/core/define-tool.d.ts +33 -3
- package/dist/src/core/execute-tool.js +11 -1
- package/dist/src/core/output-schema.d.ts +7 -0
- package/dist/src/core/output-schema.js +53 -0
- package/examples/README.md +6 -1
- package/llms.txt +24 -2
- package/package.json +6 -4
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
|
|
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/
|
|
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>[];
|
package/dist/src/adapters/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Server } from "@modelcontextprotocol/
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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`
|
|
83
|
-
* and rejects
|
|
84
|
-
* `allOf` of objects
|
|
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
|
|
87
|
-
const candidate =
|
|
23
|
+
function toMcpObjectSchema(schema) {
|
|
24
|
+
const candidate = schema;
|
|
88
25
|
if (candidate.type === "object") {
|
|
89
|
-
return
|
|
26
|
+
return schema;
|
|
90
27
|
}
|
|
91
|
-
return { type: "object", ...
|
|
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:
|
|
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(
|
|
188
|
-
server.setRequestHandler(
|
|
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:
|
|
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
|
-
|
|
215
|
-
|
|
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[];
|
package/dist/src/adapters/pi.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/examples/README.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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://
|
|
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/
|
|
72
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
73
73
|
"typebox": "^1.1.31"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@
|
|
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"
|