@feniix/bridgekit 0.14.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 +83 -3
- package/dist/src/adapters/mcp.d.ts +2 -2
- package/dist/src/adapters/mcp.js +26 -87
- package/dist/src/adapters/pi.d.ts +5 -1
- package/dist/src/adapters/pi.js +8 -0
- package/dist/src/core/define-tool.d.ts +48 -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/dist/src/index.d.ts +1 -1
- package/examples/README.md +18 -4
- package/llms.txt +25 -3
- 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
|
|
@@ -83,6 +85,8 @@ import {
|
|
|
83
85
|
executePortableTool,
|
|
84
86
|
isDomainFailure,
|
|
85
87
|
isValidationFailure,
|
|
88
|
+
type PiToolCallRenderer,
|
|
89
|
+
type PiToolResultRenderer,
|
|
86
90
|
type PortableTool,
|
|
87
91
|
type PortableToolBuiltInHost,
|
|
88
92
|
type PortableToolContext,
|
|
@@ -105,6 +109,48 @@ Do not deep-import from `dist/` or `src/` in consuming packages.
|
|
|
105
109
|
|
|
106
110
|
### pi adapter
|
|
107
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
|
+
|
|
108
154
|
```ts
|
|
109
155
|
import { registerPiTools } from "@feniix/bridgekit/pi";
|
|
110
156
|
import { createTools } from "./tools.js";
|
|
@@ -167,13 +213,19 @@ they are not designed to be called on the pi adapter's wire object.
|
|
|
167
213
|
|
|
168
214
|
#### Per-host metadata via `hostExtras`
|
|
169
215
|
|
|
170
|
-
`PortableTool.hostExtras` is an optional namespace for host-specific fields that should travel with the tool definition rather than a parallel sidecar map. The pi adapter reads `hostExtras.pi`; the MCP adapter reads `hostExtras.mcp`; each adapter ignores keys it does not recognise. Tools that omit `hostExtras` see no behavior change.
|
|
216
|
+
`PortableTool.hostExtras` is an optional namespace for host-specific fields that should travel with the tool definition rather than a parallel sidecar map. The pi adapter reads `hostExtras.pi`; the MCP adapter reads `hostExtras.mcp`; each adapter ignores keys it does not recognise. Tools that omit `hostExtras` see no behavior change. Pi TUI renderers use the exported `PiToolCallRenderer` / `PiToolResultRenderer` aliases; they are opaque pass-through callbacks, so bridgekit does not import pi-tui types or validate their return values.
|
|
171
217
|
|
|
172
218
|
Custom host adapters can reuse the core execution helpers, but the built-in `ctx.host` union is intentionally closed (`"pi" | "mcp" | "test"`). Custom adapters should cast at their boundary and carry custom dispatch state on adapter-owned fields; use module augmentation of `PortableToolHostExtras` for type-safe host-specific metadata.
|
|
173
219
|
|
|
174
220
|
```ts
|
|
175
221
|
import { Type } from "typebox";
|
|
176
|
-
import { definePortableTool } from "@feniix/bridgekit";
|
|
222
|
+
import { definePortableTool, type PiToolCallRenderer, type PiToolResultRenderer } from "@feniix/bridgekit";
|
|
223
|
+
|
|
224
|
+
const renderSummaryCall: PiToolCallRenderer = (args) => ({ type: "text", text: `summarise ${args.text}` });
|
|
225
|
+
const renderSummaryResult: PiToolResultRenderer = (result, options) => ({
|
|
226
|
+
type: "text",
|
|
227
|
+
text: options.expanded ? result.content?.[0]?.text : "summary ready",
|
|
228
|
+
});
|
|
177
229
|
|
|
178
230
|
export const generateSummaryTool = definePortableTool({
|
|
179
231
|
name: "generate_summary",
|
|
@@ -190,6 +242,9 @@ export const generateSummaryTool = definePortableTool({
|
|
|
190
242
|
pendingMessage: "Summarising...",
|
|
191
243
|
promptSnippet: "Use this tool when the user asks for a short summary.",
|
|
192
244
|
promptGuidelines: ["Prefer < 80 chars.", "Strip markdown."],
|
|
245
|
+
// Optional pi TUI renderers, forwarded by identity to pi.registerTool.
|
|
246
|
+
renderCall: renderSummaryCall,
|
|
247
|
+
renderResult: renderSummaryResult,
|
|
193
248
|
},
|
|
194
249
|
mcp: {
|
|
195
250
|
annotations: { readOnlyHint: true },
|
|
@@ -198,6 +253,8 @@ export const generateSummaryTool = definePortableTool({
|
|
|
198
253
|
});
|
|
199
254
|
```
|
|
200
255
|
|
|
256
|
+
`renderCall` is invoked by pi before execution to render the call line. `renderResult` is invoked by pi after execution to render the result; its `options.expanded` flag tracks the user's Ctrl+O collapsed/expanded toggle. BridgeKit forwards both functions by identity to `pi.registerTool`.
|
|
257
|
+
|
|
201
258
|
See `docs/rfc-host-extras.md` for the design rationale (which fields are in scope, why a top-level field beats a sidecar map, the closure rule for future additions). `PortableToolHostExtras` is module-augmentable for custom host adapters; declare your namespace via `declare module "@feniix/bridgekit"`.
|
|
202
259
|
|
|
203
260
|
### MCP adapter
|
|
@@ -241,7 +298,30 @@ Tool `parameters` must resolve to a JSON-Schema object at the top level. `Type.O
|
|
|
241
298
|
|
|
242
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.
|
|
243
300
|
|
|
244
|
-
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.
|
|
245
325
|
|
|
246
326
|
### bin-wrapper (since 0.11.0)
|
|
247
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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TSchema } from "typebox";
|
|
2
|
-
import type { PortableTool, PortableToolErrorDetails, PortableToolResult } from "../core/define-tool.js";
|
|
2
|
+
import type { PiToolCallRenderer, PiToolResultRenderer, PortableTool, PortableToolErrorDetails, PortableToolResult } from "../core/define-tool.js";
|
|
3
3
|
type PiContent = {
|
|
4
4
|
type: "text";
|
|
5
5
|
text: string;
|
|
@@ -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,9 +19,12 @@ 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[];
|
|
26
|
+
renderCall?: PiToolCallRenderer;
|
|
27
|
+
renderResult?: PiToolResultRenderer;
|
|
24
28
|
};
|
|
25
29
|
export type PiToolRegistration = {
|
|
26
30
|
registerTool(tool: PiToolDefinition): unknown;
|
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.
|
|
@@ -65,6 +69,9 @@ export function registerPiTools(pi, tools, options = {}) {
|
|
|
65
69
|
// pi-coding-agent's registerTool input is `string[]` (mutable). The boundary
|
|
66
70
|
// copy satisfies both sides without weakening either declared type.
|
|
67
71
|
...(piExtras?.promptGuidelines !== undefined && { promptGuidelines: [...piExtras.promptGuidelines] }),
|
|
72
|
+
// TUI renderers forwarded verbatim from hostExtras.pi.
|
|
73
|
+
...(piExtras?.renderCall !== undefined && { renderCall: piExtras.renderCall }),
|
|
74
|
+
...(piExtras?.renderResult !== undefined && { renderResult: piExtras.renderResult }),
|
|
68
75
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
69
76
|
let result;
|
|
70
77
|
try {
|
|
@@ -113,6 +120,7 @@ export function registerPiTools(pi, tools, options = {}) {
|
|
|
113
120
|
return {
|
|
114
121
|
content: [{ type: "text", text: result.text }],
|
|
115
122
|
details: toPiDetails(result),
|
|
123
|
+
...(result.structuredContent !== undefined && { structuredContent: result.structuredContent }),
|
|
116
124
|
isError: result.isError === true,
|
|
117
125
|
};
|
|
118
126
|
},
|
|
@@ -42,6 +42,19 @@ export interface PortableToolContext {
|
|
|
42
42
|
signal?: AbortSignal;
|
|
43
43
|
progress?: (update: PortableToolResult) => void;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Optional TUI renderer for the tool call line (before execution).
|
|
47
|
+
* Receives `(args, theme, context)` and returns a pi-tui Component.
|
|
48
|
+
* BridgeKit passes this through verbatim — no validation or wrapping.
|
|
49
|
+
*/
|
|
50
|
+
export type PiToolCallRenderer = (args: any, theme: any, context: any) => any;
|
|
51
|
+
/**
|
|
52
|
+
* Optional TUI renderer for the tool result (after execution).
|
|
53
|
+
* Receives `(result, options, theme, context)` and returns a pi-tui Component.
|
|
54
|
+
* `options.expanded` is toggled by the user via Ctrl+O in pi's TUI.
|
|
55
|
+
* BridgeKit passes this through verbatim — no validation or wrapping.
|
|
56
|
+
*/
|
|
57
|
+
export type PiToolResultRenderer = (result: any, options: any, theme: any, context: any) => any;
|
|
45
58
|
/**
|
|
46
59
|
* Pi-specific entries on `PortableTool.hostExtras`. Read only by the pi
|
|
47
60
|
* adapter; the MCP adapter ignores this namespace.
|
|
@@ -83,6 +96,8 @@ export interface PiHostExtras {
|
|
|
83
96
|
* once attached to a tool definition.
|
|
84
97
|
*/
|
|
85
98
|
promptGuidelines?: readonly string[];
|
|
99
|
+
renderCall?: PiToolCallRenderer;
|
|
100
|
+
renderResult?: PiToolResultRenderer;
|
|
86
101
|
}
|
|
87
102
|
/**
|
|
88
103
|
* MCP-specific entries on `PortableTool.hostExtras`. Read only by the MCP
|
|
@@ -138,12 +153,28 @@ export interface PortableToolHostExtras {
|
|
|
138
153
|
pi?: PiHostExtras;
|
|
139
154
|
mcp?: McpHostExtras;
|
|
140
155
|
}
|
|
141
|
-
|
|
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> {
|
|
142
167
|
name: string;
|
|
143
168
|
title: string;
|
|
144
169
|
description: string;
|
|
145
170
|
parameters: TParams;
|
|
146
|
-
|
|
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>>;
|
|
147
178
|
/**
|
|
148
179
|
* Optional per-host metadata. Adapters consume the keys they recognise;
|
|
149
180
|
* unknown host namespaces are ignored. Absent → no behavior change.
|
|
@@ -155,4 +186,18 @@ export interface PortableTool<TParams extends TSchema = TSchema, TResult extends
|
|
|
155
186
|
*/
|
|
156
187
|
hostExtras?: PortableToolHostExtras;
|
|
157
188
|
}
|
|
158
|
-
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/dist/src/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { definePortableTool, type McpHostExtras, type PiHostExtras, type PortableTool, type PortableToolBuiltInHost, type PortableToolContext, type PortableToolErrorDetails, type PortableToolHostExtras, type PortableToolResult, type PortableValidationError, } from "./core/define-tool.js";
|
|
1
|
+
export { definePortableTool, type McpHostExtras, type PiHostExtras, type PiToolCallRenderer, type PiToolResultRenderer, type PortableTool, type PortableToolBuiltInHost, type PortableToolContext, type PortableToolErrorDetails, type PortableToolHostExtras, type PortableToolResult, type PortableValidationError, } from "./core/define-tool.js";
|
|
2
2
|
export { executePortableTool, validatePortableToolArgs } from "./core/execute-tool.js";
|
|
3
3
|
export { isDomainFailure, isValidationFailure, type PortableDomainFailure, type PortableValidationFailure, } from "./core/result-guards.js";
|
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,17 +270,27 @@ 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
|
---
|
|
275
280
|
|
|
276
281
|
## 4. Per-host metadata via `hostExtras`
|
|
277
282
|
|
|
278
|
-
When a tool needs host-specific metadata — pi's `pendingMessage` for a "Processing..." signal, MCP's annotations as advisory hints to clients — the canonical place is `PortableTool.hostExtras`. Each host has its own namespace; adapters read the keys they recognise and ignore the rest. Tools that omit `hostExtras` see no behavior change.
|
|
283
|
+
When a tool needs host-specific metadata — pi's `pendingMessage` for a "Processing..." signal, pi TUI renderers for custom collapsed/expanded display, or MCP's annotations as advisory hints to clients — the canonical place is `PortableTool.hostExtras`. Each host has its own namespace; adapters read the keys they recognise and ignore the rest. Tools that omit `hostExtras` see no behavior change.
|
|
279
284
|
|
|
280
285
|
```ts
|
|
281
286
|
import { Type } from "typebox";
|
|
282
|
-
import { definePortableTool } from "@feniix/bridgekit";
|
|
287
|
+
import { definePortableTool, type PiToolCallRenderer, type PiToolResultRenderer } from "@feniix/bridgekit";
|
|
288
|
+
|
|
289
|
+
const renderSummariseCall: PiToolCallRenderer = (args) => ({ type: "text", text: `summarise ${args.text}` });
|
|
290
|
+
const renderSummariseResult: PiToolResultRenderer = (result, options) => ({
|
|
291
|
+
type: "text",
|
|
292
|
+
text: options.expanded ? result.content?.[0]?.text : "summary ready",
|
|
293
|
+
});
|
|
283
294
|
|
|
284
295
|
export const summariseTool = definePortableTool({
|
|
285
296
|
name: "summarise",
|
|
@@ -296,6 +307,9 @@ export const summariseTool = definePortableTool({
|
|
|
296
307
|
pendingMessage: "Summarising...",
|
|
297
308
|
promptSnippet: "Use this tool when the user asks for a short summary.",
|
|
298
309
|
promptGuidelines: ["Prefer < 80 chars.", "Strip markdown."],
|
|
310
|
+
// Opaque pi TUI callbacks. BridgeKit forwards these by identity.
|
|
311
|
+
renderCall: renderSummariseCall,
|
|
312
|
+
renderResult: renderSummariseResult,
|
|
299
313
|
},
|
|
300
314
|
mcp: {
|
|
301
315
|
// Advisory hints clients may surface. Do not affect validation or
|
|
@@ -308,7 +322,7 @@ export const summariseTool = definePortableTool({
|
|
|
308
322
|
|
|
309
323
|
Best practices for `hostExtras`:
|
|
310
324
|
|
|
311
|
-
- Keep tool *behavior* host-neutral. `hostExtras` carries
|
|
325
|
+
- Keep portable tool *behavior* host-neutral. `hostExtras` carries host presentation metadata; pi renderers should stay presentation-only and are forwarded verbatim, not invoked by BridgeKit.
|
|
312
326
|
- Set only the fields the adapter recognises; unrecognised keys are silently ignored, but they add noise to the definition.
|
|
313
327
|
- If you ship an adapter for a host outside `"pi" | "mcp"`, cast `ctx.host` at the adapter boundary (the host union is fixed since 0.10.0). For type-safe per-host metadata in your adapter's namespace, extend `PortableToolHostExtras` via `declare module "@feniix/bridgekit"` so consumers of your adapter get type safety on the new namespace.
|
|
314
328
|
|
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,11 +87,21 @@ 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
|
-
- `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
|
|
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
|
|
|
84
106
|
### Result-guard usage
|
|
85
107
|
|
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"
|