@feniix/bridgekit 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -1
- package/README.md +35 -0
- package/dist/src/adapters/mcp.d.ts +15 -2
- package/dist/src/adapters/mcp.js +142 -8
- package/dist/src/adapters/pi.d.ts +2 -0
- package/dist/src/adapters/pi.js +27 -0
- package/dist/src/core/define-tool.d.ts +106 -0
- package/dist/src/core/execute-tool.js +54 -41
- package/dist/src/index.d.ts +1 -1
- package/examples/README.md +154 -1
- package/llms.txt +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,85 @@ All notable changes to `@feniix/bridgekit` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [0.
|
|
7
|
+
## [0.9.1] - 2026-05-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- pi adapter now spreads `hostExtras.pi.promptGuidelines` into a fresh
|
|
12
|
+
`string[]` before handing it to `pi.registerTool`. Bridgekit's declared type
|
|
13
|
+
stays `readonly string[]` (signals immutability of consumer-owned metadata),
|
|
14
|
+
but the boundary copy satisfies pi-coding-agent's mutable `string[]`
|
|
15
|
+
contract. Resolves contravariance friction observed in three downstream
|
|
16
|
+
consumers that previously needed `pi as unknown as PiToolRegistration`
|
|
17
|
+
casts. Resolves [#47](https://github.com/feniix/bridgekit/issues/47).
|
|
18
|
+
|
|
19
|
+
## [0.9.0] - 2026-05-26
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Widened `createMcpServer` / `runMcpStdioServer` `tools` parameter from
|
|
24
|
+
`PortableTool<TObject>[]` to `PortableTool<TSchema>[]`. `Type.Intersect([
|
|
25
|
+
Type.Object(...), Type.Object(...)])` is now accepted alongside
|
|
26
|
+
`Type.Object(...)`. Schemas that don't resolve to a JSON-Schema object at the
|
|
27
|
+
top level throw at server construction with a named-tool error message
|
|
28
|
+
pointing to the actionable wrapping recipe. On the wire, `tools/list`
|
|
29
|
+
synthesises `type: "object"` on schemas whose top-level lowering is `allOf`
|
|
30
|
+
so MCP SDK clients that Zod-validate `inputSchema.type` continue to accept
|
|
31
|
+
them; existing `Type.Object` consumers see no change in payload shape.
|
|
32
|
+
Resolves [#29](https://github.com/feniix/bridgekit/issues/29).
|
|
33
|
+
- `createMcpServer` construction error now appends Union-specific guidance
|
|
34
|
+
when the offending tool's parameters lower to `anyOf`/`oneOf`, including
|
|
35
|
+
when the Union is nested inside an `allOf` branch (the `Type.Intersect`
|
|
36
|
+
recipe is wrong for that shape). The bare allOf-with-mixed-branches case
|
|
37
|
+
now names the first non-object branch by index in the `type="..."` label
|
|
38
|
+
instead of the misdirecting `type="allOf"`.
|
|
39
|
+
- `tools/list` payload is now pre-computed at `createMcpServer` construction
|
|
40
|
+
rather than per-request. The outer `tools` array is snapshotted at that
|
|
41
|
+
point: pushing or removing entries from the caller's array post-construction
|
|
42
|
+
does not affect listing or dispatch. Schemas inside each tool remain held
|
|
43
|
+
by reference; treat `tool.parameters` as immutable once the server is
|
|
44
|
+
constructed.
|
|
45
|
+
- `createMcpServer` construction errors now carry a stable `error.code` so
|
|
46
|
+
consumers have a non-string anchor for branching: `"BRIDGEKIT_MCP_NON_OBJECT_PARAMETERS"`
|
|
47
|
+
for the top-level-object guard, `"BRIDGEKIT_MCP_DUPLICATE_TOOL_NAME"` for
|
|
48
|
+
the new duplicate-name guard. Message text remains recipe-shaped for
|
|
49
|
+
human reading but is not a public contract; branch on `.code`.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- `PortableTool.hostExtras` — optional per-host metadata namespace for
|
|
54
|
+
host-specific fields without polluting the host-neutral tool definition.
|
|
55
|
+
`hostExtras.pi` carries `pendingMessage` (fires a pre-execute `onUpdate`
|
|
56
|
+
so pi-side tools can surface a "Processing..." signal without bypassing
|
|
57
|
+
`registerPiTools`), `promptSnippet`, and `promptGuidelines` (passed
|
|
58
|
+
through to the pi host's `registerTool` call). `hostExtras.mcp.annotations`
|
|
59
|
+
(`title` / `readOnlyHint` / `destructiveHint` / `idempotentHint` /
|
|
60
|
+
`openWorldHint`) is consumed in this release: annotations are attached to
|
|
61
|
+
`tools/list` entries, closing the RFC §4 30-day rollback gate in the same
|
|
62
|
+
release that declares the namespace rather than deferring to 0.9.x. Tools
|
|
63
|
+
that don't set `hostExtras` see zero behavior change: the pi registration
|
|
64
|
+
payload's key set is byte-identical to 0.8.x and the MCP `Tool` entry omits
|
|
65
|
+
the `annotations` field entirely. The shape is module-augmentable: a
|
|
66
|
+
custom-host adapter can extend `PortableToolHostExtras` via
|
|
67
|
+
`declare module "@feniix/bridgekit"` to claim its own namespace. Resolves
|
|
68
|
+
[#28](https://github.com/feniix/bridgekit/issues/28).
|
|
69
|
+
- `createMcpServer` now rejects duplicate tool names at construction with
|
|
70
|
+
a tool-attributed error (code `BRIDGEKIT_MCP_DUPLICATE_TOOL_NAME`). The
|
|
71
|
+
previous behavior silently overwrote earlier registrations in the dispatch
|
|
72
|
+
map while still listing both on `tools/list`, leaving the earlier tool
|
|
73
|
+
unreachable on `tools/call`.
|
|
74
|
+
- RFC: `docs/rfc-host-extras.md` (design doc for [#28](https://github.com/feniix/bridgekit/issues/28); no code change).
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- `PortableValidationError.field` preservation for slash-named properties
|
|
79
|
+
(the 0.8.3 fix) now descends into `Type.Intersect` (`allOf`) branches.
|
|
80
|
+
Previously a property named `a/b` inside an Intersect branch resolved as
|
|
81
|
+
`b` because the schema walker stopped at the root's missing `properties`
|
|
82
|
+
field; the walker now probes each `allOf` branch with the full remaining
|
|
83
|
+
path.
|
|
84
|
+
|
|
85
|
+
## [0.8.3] - 2026-05-26
|
|
8
86
|
|
|
9
87
|
### Fixed
|
|
10
88
|
|
package/README.md
CHANGED
|
@@ -141,6 +141,39 @@ new default `"return"` mode, prefer the result guards over inspecting a
|
|
|
141
141
|
`"domain"`). The guards operate on a `PortableToolResult`; they are not
|
|
142
142
|
designed to be called on the pi adapter's wire object.
|
|
143
143
|
|
|
144
|
+
### Per-host metadata via `hostExtras`
|
|
145
|
+
|
|
146
|
+
`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.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { Type } from "typebox";
|
|
150
|
+
import { definePortableTool } from "@feniix/bridgekit";
|
|
151
|
+
|
|
152
|
+
export const generateSummaryTool = definePortableTool({
|
|
153
|
+
name: "generate_summary",
|
|
154
|
+
title: "Generate Summary",
|
|
155
|
+
description: "Summarise a block of text.",
|
|
156
|
+
parameters: Type.Object({ text: Type.String() }),
|
|
157
|
+
execute(args) {
|
|
158
|
+
return { text: args.text.slice(0, 80) };
|
|
159
|
+
},
|
|
160
|
+
hostExtras: {
|
|
161
|
+
pi: {
|
|
162
|
+
// Fires once before TypeBox validation runs. Lets the pi host show a
|
|
163
|
+
// "Processing..." signal without a custom registration wrapper.
|
|
164
|
+
pendingMessage: "Summarising...",
|
|
165
|
+
promptSnippet: "Use this tool when the user asks for a short summary.",
|
|
166
|
+
promptGuidelines: ["Prefer < 80 chars.", "Strip markdown."],
|
|
167
|
+
},
|
|
168
|
+
mcp: {
|
|
169
|
+
annotations: { readOnlyHint: true },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
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"`.
|
|
176
|
+
|
|
144
177
|
## MCP adapter
|
|
145
178
|
|
|
146
179
|
```ts
|
|
@@ -178,6 +211,8 @@ if (process.argv[1] && realpathIfPossible(resolve(process.argv[1])) === realpath
|
|
|
178
211
|
|
|
179
212
|
The MCP adapter uses low-level `tools/list` and `tools/call` handlers so TypeBox schemas are exposed as JSON Schema directly. It intentionally does not expose a high-level `registerMcpTools` helper.
|
|
180
213
|
|
|
214
|
+
Tool `parameters` must resolve to a JSON-Schema object at the top level. `Type.Object(...)` is the common case; `Type.Intersect([Type.Object(...), Type.Object(...)])` of object schemas is also accepted (its `allOf` lowering is recognised, and `type: "object"` is synthesised onto the `tools/list` response so MCP clients that validate the inputSchema shape stay happy). Non-object top-level schemas (`Type.String()`, `Type.Union([Type.Object(...), Type.Object(...)])`, etc.) throw at server construction with a named-tool error so the failure surfaces at adapter setup, not at first `tools/call`.
|
|
215
|
+
|
|
181
216
|
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.
|
|
182
217
|
|
|
183
218
|
The two adapters now read in parallel: invalid args and portable `isError` results return `{ isError: true }` from both hosts by default, and the same result-guard helpers (`isValidationFailure`, `isDomainFailure`) narrow them on either side.
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { TSchema } from "typebox";
|
|
3
3
|
import type { PortableTool } from "../core/define-tool.js";
|
|
4
4
|
export interface CreateMcpServerOptions {
|
|
5
5
|
name: string;
|
|
6
6
|
version: string;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Portable tools to expose. Each tool's `parameters` must resolve to a
|
|
9
|
+
* JSON-Schema object at the top level — `Type.Object(...)` or
|
|
10
|
+
* `Type.Intersect([Type.Object(...), Type.Object(...)])`. Other top-level
|
|
11
|
+
* shapes (`Type.String()`, `Type.Union([Type.Object(...), Type.Object(...)])`,
|
|
12
|
+
* etc.) throw at `createMcpServer` construction with a tool-attributed error.
|
|
13
|
+
*
|
|
14
|
+
* The outer array is snapshotted at construction; pushing or removing
|
|
15
|
+
* entries from the caller's `tools` array post-construction does not affect
|
|
16
|
+
* `tools/list` or `tools/call`. Schemas inside each tool are held by
|
|
17
|
+
* reference, not deep-cloned — treat `tool.parameters` as immutable once
|
|
18
|
+
* `createMcpServer` returns.
|
|
19
|
+
*/
|
|
20
|
+
tools: readonly PortableTool<TSchema>[];
|
|
8
21
|
instructions?: string;
|
|
9
22
|
}
|
|
10
23
|
export declare function createMcpServer(options: CreateMcpServerOptions): Server;
|
package/dist/src/adapters/mcp.js
CHANGED
|
@@ -10,20 +10,154 @@ function toMcpResult(result) {
|
|
|
10
10
|
isError: result.isError ?? false,
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Returns true if `schema` resolves to a JSON-Schema object at the top level —
|
|
15
|
+
* either `{"type": "object", ...}` directly, or an `allOf` composition whose
|
|
16
|
+
* branches are all object schemas (as produced by `Type.Intersect`).
|
|
17
|
+
*
|
|
18
|
+
* The check walks the canonical JSON-Schema shape rather than poking at
|
|
19
|
+
* TypeBox `Kind` symbols, mirroring how `executePortableTool` traverses
|
|
20
|
+
* schemas. The MCP wire contract is "top-level object," so that's the
|
|
21
|
+
* predicate, regardless of which TypeBox combinator built the schema.
|
|
22
|
+
*/
|
|
23
|
+
function isObjectSchema(schema) {
|
|
24
|
+
if (typeof schema !== "object" || schema === null)
|
|
25
|
+
return false;
|
|
26
|
+
const candidate = schema;
|
|
27
|
+
if (candidate.type === "object")
|
|
28
|
+
return true;
|
|
29
|
+
if (Array.isArray(candidate.allOf) && candidate.allOf.length > 0) {
|
|
30
|
+
return candidate.allOf.every((entry) => isObjectSchema(entry));
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort human-readable label for a non-object schema, used in error
|
|
36
|
+
* messages. For `allOf` (TypeBox's `Intersect` lowering) we descend into the
|
|
37
|
+
* branches and surface the first non-object branch by index — a bare `"allOf"`
|
|
38
|
+
* label is misleading because the rejection is owned by one specific branch,
|
|
39
|
+
* not the composition itself.
|
|
40
|
+
*/
|
|
41
|
+
function schemaTypeLabel(schema) {
|
|
42
|
+
if (typeof schema !== "object" || schema === null)
|
|
43
|
+
return "unknown";
|
|
44
|
+
const candidate = schema;
|
|
45
|
+
if (typeof candidate.type === "string")
|
|
46
|
+
return candidate.type;
|
|
47
|
+
if (Array.isArray(candidate.anyOf))
|
|
48
|
+
return "anyOf";
|
|
49
|
+
if (Array.isArray(candidate.oneOf))
|
|
50
|
+
return "oneOf";
|
|
51
|
+
if (Array.isArray(candidate.allOf)) {
|
|
52
|
+
if (candidate.allOf.length === 0)
|
|
53
|
+
return "allOf (empty)";
|
|
54
|
+
for (let i = 0; i < candidate.allOf.length; i++) {
|
|
55
|
+
if (!isObjectSchema(candidate.allOf[i])) {
|
|
56
|
+
return `allOf[${i}] resolves to type="${schemaTypeLabel(candidate.allOf[i])}"`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Render `parameters` as MCP `inputSchema`. The MCP SDK Zod-validates that the
|
|
64
|
+
* top-level schema has `type: "object"` on the client side of `tools/list`, so
|
|
65
|
+
* `Type.Intersect` (which TypeBox renders as `{ allOf: [...] }` with no top-
|
|
66
|
+
* level `type`) needs `type: "object"` synthesized before transmission. This
|
|
67
|
+
* is a no-op for `Type.Object` schemas, which already carry the field.
|
|
68
|
+
*
|
|
69
|
+
* The casts here are sound because `assertObjectShapedParameters` runs first
|
|
70
|
+
* and rejects any schema whose top-level lowering isn't `type:"object"` or
|
|
71
|
+
* `allOf` of objects — both shapes round-trip as MCP `Tool["inputSchema"]`.
|
|
72
|
+
*/
|
|
73
|
+
function toInputSchema(parameters) {
|
|
74
|
+
const candidate = parameters;
|
|
75
|
+
if (candidate.type === "object") {
|
|
76
|
+
return parameters;
|
|
77
|
+
}
|
|
78
|
+
return { type: "object", ...parameters };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stable `error.code` values attached to `createMcpServer` construction
|
|
82
|
+
* failures so consumers have a non-string anchor (the message text is
|
|
83
|
+
* recipe-shaped and may evolve; the code is part of the public contract).
|
|
84
|
+
*/
|
|
85
|
+
const ERROR_CODE_NON_OBJECT_PARAMETERS = "BRIDGEKIT_MCP_NON_OBJECT_PARAMETERS";
|
|
86
|
+
const ERROR_CODE_DUPLICATE_TOOL_NAME = "BRIDGEKIT_MCP_DUPLICATE_TOOL_NAME";
|
|
87
|
+
function throwWithCode(message, code) {
|
|
88
|
+
const error = new Error(message);
|
|
89
|
+
error.code = code;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
function assertObjectShapedParameters(tools) {
|
|
93
|
+
for (const tool of tools) {
|
|
94
|
+
if (!isObjectSchema(tool.parameters)) {
|
|
95
|
+
const typeLabel = schemaTypeLabel(tool.parameters);
|
|
96
|
+
let message = `createMcpServer: tool "${tool.name}" has a non-object parameters schema (type="${typeLabel}"). ` +
|
|
97
|
+
"MCP requires Type.Object(...) at the top level; use Type.Object({ value: Type.String() }) " +
|
|
98
|
+
"to wrap a single-field schema, or Type.Intersect([Type.Object(...), Type.Object(...)]) for " +
|
|
99
|
+
"merged object schemas.";
|
|
100
|
+
if (typeLabel.includes("anyOf") || typeLabel.includes("oneOf")) {
|
|
101
|
+
// Union lowers to `anyOf`/`oneOf` (OR) — at the top level or nested
|
|
102
|
+
// inside an `allOf` branch. The generic `Type.Intersect` (AND) advice
|
|
103
|
+
// above is the wrong recipe for that case, so append union-specific
|
|
104
|
+
// guidance.
|
|
105
|
+
message +=
|
|
106
|
+
" Top-level Type.Union([Type.Object(...), ...]) is not supported by the MCP wire layer; " +
|
|
107
|
+
"flatten branches into a single Type.Object(...) with optional discriminator fields, " +
|
|
108
|
+
"or expose each branch as a separate tool.";
|
|
109
|
+
}
|
|
110
|
+
throwWithCode(message, ERROR_CODE_NON_OBJECT_PARAMETERS);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function assertUniqueToolNames(tools) {
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const tool of tools) {
|
|
117
|
+
if (seen.has(tool.name)) {
|
|
118
|
+
throwWithCode(`createMcpServer: tool "${tool.name}" is registered more than once. ` +
|
|
119
|
+
"MCP tool names must be unique within a server — silently overwriting would make the " +
|
|
120
|
+
"earlier registration unreachable on `tools/call`.", ERROR_CODE_DUPLICATE_TOOL_NAME);
|
|
121
|
+
}
|
|
122
|
+
seen.add(tool.name);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
13
125
|
export function createMcpServer(options) {
|
|
126
|
+
assertObjectShapedParameters(options.tools);
|
|
127
|
+
assertUniqueToolNames(options.tools);
|
|
128
|
+
// Build the dispatch map and the listing payload at construction so
|
|
129
|
+
// `tools/list` returns a pre-computed array and post-construction mutations
|
|
130
|
+
// to the caller's array cannot leak unvalidated schemas onto the wire.
|
|
14
131
|
const byName = new Map(options.tools.map((tool) => [tool.name, tool]));
|
|
132
|
+
const mcpTools = options.tools.map((tool) => {
|
|
133
|
+
const annotations = tool.hostExtras?.mcp?.annotations;
|
|
134
|
+
// MCP advisory hints from `hostExtras.mcp.annotations`. Two gates:
|
|
135
|
+
// 1. `annotations !== undefined` — a tool without hostExtras builds a
|
|
136
|
+
// Tool entry whose own-property keys are byte-identical to 0.8.x.
|
|
137
|
+
// 2. `Object.keys(annotations).length > 0` — an explicitly empty
|
|
138
|
+
// annotations object is semantically identical to no annotations
|
|
139
|
+
// and is omitted from the wire payload (not emitted as `{}`).
|
|
140
|
+
//
|
|
141
|
+
// The non-empty branch shallow-clones the annotations object so that
|
|
142
|
+
// post-construction mutation of the caller's `tool.hostExtras.mcp.annotations`
|
|
143
|
+
// cannot leak into subsequent `tools/list` responses. The clone is
|
|
144
|
+
// safe-by-construction because annotation fields are primitive scalars
|
|
145
|
+
// (the MCP spec defines only `title: string` and four `…Hint: boolean`
|
|
146
|
+
// fields); no nested mutation surface exists.
|
|
147
|
+
const hasAnnotations = annotations !== undefined && Object.keys(annotations).length > 0;
|
|
148
|
+
return {
|
|
149
|
+
name: tool.name,
|
|
150
|
+
title: tool.title,
|
|
151
|
+
description: tool.description,
|
|
152
|
+
inputSchema: toInputSchema(tool.parameters),
|
|
153
|
+
...(hasAnnotations ? { annotations: { ...annotations } } : {}),
|
|
154
|
+
};
|
|
155
|
+
});
|
|
15
156
|
const server = new Server({ name: options.name, version: options.version }, {
|
|
16
157
|
capabilities: { tools: { listChanged: false } },
|
|
17
158
|
instructions: options.instructions,
|
|
18
159
|
});
|
|
19
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
20
|
-
tools: options.tools.map((tool) => ({
|
|
21
|
-
name: tool.name,
|
|
22
|
-
title: tool.title,
|
|
23
|
-
description: tool.description,
|
|
24
|
-
inputSchema: tool.parameters,
|
|
25
|
-
})),
|
|
26
|
-
}));
|
|
160
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpTools }));
|
|
27
161
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
28
162
|
const tool = byName.get(request.params.name);
|
|
29
163
|
if (!tool) {
|
|
@@ -19,6 +19,8 @@ type PiToolDefinition = {
|
|
|
19
19
|
description: string;
|
|
20
20
|
parameters: TSchema;
|
|
21
21
|
execute(toolCallId: string, params: unknown, signal?: AbortSignal, onUpdate?: (update: PiToolUpdate) => void, ctx?: unknown): Promise<PiToolResult>;
|
|
22
|
+
promptSnippet?: string;
|
|
23
|
+
promptGuidelines?: readonly string[];
|
|
22
24
|
};
|
|
23
25
|
export type PiToolRegistration = {
|
|
24
26
|
registerTool(tool: PiToolDefinition): unknown;
|
package/dist/src/adapters/pi.js
CHANGED
|
@@ -50,14 +50,41 @@ export function registerPiTools(pi, tools, options = {}) {
|
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
for (const tool of tools) {
|
|
53
|
+
const piExtras = tool.hostExtras?.pi;
|
|
53
54
|
pi.registerTool({
|
|
54
55
|
name: tool.name,
|
|
55
56
|
label: tool.title,
|
|
56
57
|
description: tool.description,
|
|
57
58
|
parameters: tool.parameters,
|
|
59
|
+
// Pass-through pi-side metadata. Each field is gated on `!== undefined`
|
|
60
|
+
// so tools without `hostExtras.pi` build a registration object whose
|
|
61
|
+
// own-property keys are unchanged from the pre-0.9 shape.
|
|
62
|
+
...(piExtras?.promptSnippet !== undefined && { promptSnippet: piExtras.promptSnippet }),
|
|
63
|
+
// Spread into a fresh array at the boundary: bridgekit declares promptGuidelines
|
|
64
|
+
// as `readonly string[]` (signal: "we don't mutate consumer metadata"), but
|
|
65
|
+
// pi-coding-agent's registerTool input is `string[]` (mutable). The boundary
|
|
66
|
+
// copy satisfies both sides without weakening either declared type.
|
|
67
|
+
...(piExtras?.promptGuidelines !== undefined && { promptGuidelines: [...piExtras.promptGuidelines] }),
|
|
58
68
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
59
69
|
let result;
|
|
60
70
|
try {
|
|
71
|
+
// Pre-execute lifecycle hook. Fires exactly once per call, before
|
|
72
|
+
// TypeBox validation runs, when the tool declares a non-empty
|
|
73
|
+
// `hostExtras.pi.pendingMessage`. Silently no-ops when the pi host
|
|
74
|
+
// does not supply `onUpdate`. See RFC §3 (Gap B).
|
|
75
|
+
//
|
|
76
|
+
// Wrapped inside the same try/catch that guards the handler so a
|
|
77
|
+
// throwing host `onUpdate` is routed through `errorHandling` (and
|
|
78
|
+
// does not skip past the catch as an uncaught exception). The
|
|
79
|
+
// handler is NOT invoked when the pre-execute emit throws —
|
|
80
|
+
// erroring before validation is a host-channel failure, not an
|
|
81
|
+
// argument failure.
|
|
82
|
+
if (piExtras?.pendingMessage !== undefined && piExtras.pendingMessage !== "") {
|
|
83
|
+
onUpdate?.({
|
|
84
|
+
content: [{ type: "text", text: piExtras.pendingMessage }],
|
|
85
|
+
details: { status: "pending" },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
61
88
|
result = await executePortableTool(tool, params, {
|
|
62
89
|
host: "pi",
|
|
63
90
|
signal,
|
|
@@ -43,11 +43,117 @@ export interface PortableToolContext<THost extends string = PortableToolBuiltInH
|
|
|
43
43
|
signal?: AbortSignal;
|
|
44
44
|
progress?: (update: PortableToolResult) => void;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Pi-specific entries on `PortableTool.hostExtras`. Read only by the pi
|
|
48
|
+
* adapter; the MCP adapter ignores this namespace.
|
|
49
|
+
*
|
|
50
|
+
* **Snapshot semantics.** Pi-side fields are read at registration time:
|
|
51
|
+
* `pendingMessage` is re-read at each tool invocation (so a mutation between
|
|
52
|
+
* two calls would be observed), while `promptSnippet` / `promptGuidelines`
|
|
53
|
+
* are captured once during `registerPiTools` and never re-read. Treat all
|
|
54
|
+
* pi-side fields as immutable once `registerPiTools` returns.
|
|
55
|
+
*
|
|
56
|
+
* @see PortableToolHostExtras
|
|
57
|
+
*/
|
|
58
|
+
export interface PiHostExtras {
|
|
59
|
+
/**
|
|
60
|
+
* One-shot text the pi adapter emits as `onUpdate(...)` exactly once,
|
|
61
|
+
* **before** TypeBox validation runs. When unset (or absent on the tool),
|
|
62
|
+
* no pre-execute update is emitted. An empty string is treated as unset
|
|
63
|
+
* and produces no update. When the registered pi host does not supply an
|
|
64
|
+
* `onUpdate` callback at call time, the adapter silently no-ops.
|
|
65
|
+
*
|
|
66
|
+
* Held by reference at registration time; treat `pendingMessage` as
|
|
67
|
+
* immutable once attached to a tool definition.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* hostExtras: { pi: { pendingMessage: "Processing..." } }
|
|
71
|
+
*/
|
|
72
|
+
pendingMessage?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Short string blended into pi's system prompt to summarise when this tool
|
|
75
|
+
* should be called. Passed through verbatim to pi's `registerTool` call.
|
|
76
|
+
*
|
|
77
|
+
* Whether the installed pi SDK reads this field is the pi host's concern;
|
|
78
|
+
* bridgekit's contract is to pass it through unmodified when set.
|
|
79
|
+
*/
|
|
80
|
+
promptSnippet?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Longer-form guidance bullet points passed through to pi's `registerTool`
|
|
83
|
+
* call. Each entry is one bullet. Held by reference — treat as immutable
|
|
84
|
+
* once attached to a tool definition.
|
|
85
|
+
*/
|
|
86
|
+
promptGuidelines?: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* MCP-specific entries on `PortableTool.hostExtras`. Read only by the MCP
|
|
90
|
+
* adapter; the pi adapter ignores this namespace.
|
|
91
|
+
*
|
|
92
|
+
* @see PortableToolHostExtras
|
|
93
|
+
*/
|
|
94
|
+
export interface McpHostExtras {
|
|
95
|
+
/**
|
|
96
|
+
* MCP tool annotations attached to `tools/list` entries. Hints clients may
|
|
97
|
+
* surface to users; do not affect validation or execution.
|
|
98
|
+
*
|
|
99
|
+
* The annotations object is shallow-cloned at `createMcpServer`
|
|
100
|
+
* construction; post-construction mutation of the original object does
|
|
101
|
+
* not affect subsequent `tools/list` responses. Consumers do not need to
|
|
102
|
+
* defensively clone before passing.
|
|
103
|
+
*
|
|
104
|
+
* An empty annotations object (`{}`) is treated as semantically identical
|
|
105
|
+
* to omitting the field — the resulting `Tool` entry has no `annotations`
|
|
106
|
+
* key on the wire.
|
|
107
|
+
*
|
|
108
|
+
* @see https://modelcontextprotocol.io/specification (Tool annotations)
|
|
109
|
+
*/
|
|
110
|
+
annotations?: {
|
|
111
|
+
title?: string;
|
|
112
|
+
readOnlyHint?: boolean;
|
|
113
|
+
destructiveHint?: boolean;
|
|
114
|
+
idempotentHint?: boolean;
|
|
115
|
+
openWorldHint?: boolean;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Opaque per-host metadata attached to a portable tool definition. Adapters
|
|
120
|
+
* read the keys they recognise and ignore the rest. Tools that omit
|
|
121
|
+
* `hostExtras` see no behavior change and pay no runtime cost — every adapter
|
|
122
|
+
* short-circuits on `tool.hostExtras?.<host>` being `undefined`.
|
|
123
|
+
*
|
|
124
|
+
* The shape is **module-augmentable** for custom hosts:
|
|
125
|
+
*
|
|
126
|
+
* ```ts
|
|
127
|
+
* declare module "@feniix/bridgekit" {
|
|
128
|
+
* interface PortableToolHostExtras {
|
|
129
|
+
* "custom-runtime"?: { something: string };
|
|
130
|
+
* }
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* The augmentation must be in scope wherever a tool sets
|
|
135
|
+
* `hostExtras["custom-runtime"]`. bridgekit guarantees the type slot; the
|
|
136
|
+
* consumer is responsible for adapter dispatch.
|
|
137
|
+
*/
|
|
138
|
+
export interface PortableToolHostExtras {
|
|
139
|
+
pi?: PiHostExtras;
|
|
140
|
+
mcp?: McpHostExtras;
|
|
141
|
+
}
|
|
46
142
|
export interface PortableTool<TParams extends TSchema = TSchema, THost extends string = PortableToolBuiltInHost> {
|
|
47
143
|
name: string;
|
|
48
144
|
title: string;
|
|
49
145
|
description: string;
|
|
50
146
|
parameters: TParams;
|
|
51
147
|
execute: (args: Static<TParams>, ctx: PortableToolContext<THost>) => PortableToolResult | Promise<PortableToolResult>;
|
|
148
|
+
/**
|
|
149
|
+
* Optional per-host metadata. Adapters consume the keys they recognise;
|
|
150
|
+
* unknown host namespaces are ignored. Absent → no behavior change.
|
|
151
|
+
*
|
|
152
|
+
* @see PortableToolHostExtras
|
|
153
|
+
* @see docs/rfc-host-extras.md for design rationale (admission criteria,
|
|
154
|
+
* why a top-level field beats a sidecar map, closure rules for future
|
|
155
|
+
* additions).
|
|
156
|
+
*/
|
|
157
|
+
hostExtras?: PortableToolHostExtras;
|
|
52
158
|
}
|
|
53
159
|
export declare function definePortableTool<TParams extends TSchema, THost extends string = PortableToolBuiltInHost>(tool: PortableTool<TParams, THost>): PortableTool<TParams, THost>;
|
|
@@ -15,6 +15,11 @@ function fieldFromPath(instancePath) {
|
|
|
15
15
|
* property key, descending into the matched sub-schema — and returns the
|
|
16
16
|
* actual leaf key. Array nodes consume a single numeric segment via `items`.
|
|
17
17
|
*
|
|
18
|
+
* `allOf` nodes (TypeBox's `Intersect` lowering) probe each branch in turn
|
|
19
|
+
* with the same remaining path and return the first successful match. This
|
|
20
|
+
* is what lets `Type.Intersect([Object({"a/b": String()}), Object({c: ...})])`
|
|
21
|
+
* resolve a `/a/b` instancePath to the `"a/b"` key in the first branch.
|
|
22
|
+
*
|
|
18
23
|
* Returns `undefined` if the walk gets stuck (the schema does not model the
|
|
19
24
|
* data path — e.g. `additionalProperties` content). The caller then falls
|
|
20
25
|
* back to the string-split `fieldFromPath`, preserving prior behavior.
|
|
@@ -29,52 +34,60 @@ function fieldFromSchemaWalk(schema, instancePath) {
|
|
|
29
34
|
const segments = instancePath.split("/").filter(Boolean);
|
|
30
35
|
if (segments.length === 0)
|
|
31
36
|
return undefined;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
current = node.items;
|
|
50
|
-
}
|
|
51
|
-
lastKey = segments[i];
|
|
52
|
-
i++;
|
|
53
|
-
continue;
|
|
37
|
+
return walkSegments(schema, segments, 0);
|
|
38
|
+
}
|
|
39
|
+
function walkSegments(node, segments, i) {
|
|
40
|
+
if (i >= segments.length)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (!node || typeof node !== "object")
|
|
43
|
+
return undefined;
|
|
44
|
+
const obj = node;
|
|
45
|
+
// Array descent: numeric segment against an `items` schema. Tuple-form
|
|
46
|
+
// `items` (array) indexes positionally; schema-form descends uniformly.
|
|
47
|
+
if (obj.items !== undefined && /^\d+$/.test(segments[i])) {
|
|
48
|
+
const idx = Number(segments[i]);
|
|
49
|
+
let next;
|
|
50
|
+
if (Array.isArray(obj.items)) {
|
|
51
|
+
if (idx >= obj.items.length)
|
|
52
|
+
return undefined;
|
|
53
|
+
next = obj.items[idx];
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
55
|
+
else {
|
|
56
|
+
next = obj.items;
|
|
57
|
+
}
|
|
58
|
+
if (i + 1 === segments.length)
|
|
59
|
+
return segments[i];
|
|
60
|
+
return walkSegments(next, segments, i + 1);
|
|
61
|
+
}
|
|
62
|
+
// Object descent: greedy longest-prefix match against `properties` keys.
|
|
63
|
+
// This is what lets `a/b` resolve to a single key when the schema
|
|
64
|
+
// declares it, even though the instancePath segments it as ["a", "b"].
|
|
65
|
+
if (obj.properties && typeof obj.properties === "object") {
|
|
66
|
+
const props = obj.properties;
|
|
67
|
+
for (let j = segments.length; j > i; j--) {
|
|
68
|
+
const candidate = segments.slice(i, j).join("/");
|
|
69
|
+
if (Object.hasOwn(props, candidate)) {
|
|
70
|
+
if (j === segments.length)
|
|
71
|
+
return candidate;
|
|
72
|
+
const child = walkSegments(props[candidate], segments, j);
|
|
73
|
+
if (child !== undefined)
|
|
74
|
+
return child;
|
|
69
75
|
}
|
|
70
|
-
if (matched === -1)
|
|
71
|
-
return undefined;
|
|
72
|
-
i = matched;
|
|
73
|
-
continue;
|
|
74
76
|
}
|
|
75
77
|
return undefined;
|
|
76
78
|
}
|
|
77
|
-
|
|
79
|
+
// allOf descent: TypeBox's Intersect lowering. Each branch sees the full
|
|
80
|
+
// remaining path; return the first branch that resolves. This is the
|
|
81
|
+
// schema-side mirror of how Intersect merges property contributions.
|
|
82
|
+
if (Array.isArray(obj.allOf)) {
|
|
83
|
+
for (const branch of obj.allOf) {
|
|
84
|
+
const matched = walkSegments(branch, segments, i);
|
|
85
|
+
if (matched !== undefined)
|
|
86
|
+
return matched;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
78
91
|
}
|
|
79
92
|
function fieldFromError(schema, error) {
|
|
80
93
|
return fieldFromSchemaWalk(schema, error.instancePath) ?? fieldFromPath(error.instancePath);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { definePortableTool, type PortableTool, type PortableToolBuiltInHost, type PortableToolContext, type PortableToolErrorDetails, type PortableToolHost, type PortableToolResult, type PortableValidationError, } from "./core/define-tool.js";
|
|
1
|
+
export { definePortableTool, type McpHostExtras, type PiHostExtras, type PortableTool, type PortableToolBuiltInHost, type PortableToolContext, type PortableToolErrorDetails, type PortableToolHost, 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
|
@@ -81,6 +81,42 @@ Best practices shown here:
|
|
|
81
81
|
- The `createTools()` factory gives stateful tools a fresh runtime per host instance; stateless packages may still return the same definitions.
|
|
82
82
|
- The file has no import-time registration or server startup.
|
|
83
83
|
|
|
84
|
+
### Composing object schemas with `Type.Intersect`
|
|
85
|
+
|
|
86
|
+
When two object schemas describe different facets of the same input — common
|
|
87
|
+
properties plus a feature-specific extension — compose them with
|
|
88
|
+
`Type.Intersect`. Both branches must be `Type.Object(...)`; the MCP adapter
|
|
89
|
+
synthesises `type: "object"` on the wire so existing MCP SDK clients accept
|
|
90
|
+
the composed schema unchanged. Other top-level shapes (`Type.Union(...)`,
|
|
91
|
+
`Type.String()`) throw at `createMcpServer` construction.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { type Static, Type } from "typebox";
|
|
95
|
+
import { definePortableTool } from "@feniix/bridgekit";
|
|
96
|
+
|
|
97
|
+
const idParams = Type.Object({ id: Type.String({ description: "Record id." }) });
|
|
98
|
+
const updateParams = Type.Object({
|
|
99
|
+
name: Type.Optional(Type.String({ description: "New display name." })),
|
|
100
|
+
archived: Type.Optional(Type.Boolean({ description: "Archive flag." })),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const params = Type.Intersect([idParams, updateParams]);
|
|
104
|
+
type Params = Static<typeof params>; // { id: string; name?: string; archived?: boolean }
|
|
105
|
+
|
|
106
|
+
export const updateRecordTool = definePortableTool({
|
|
107
|
+
name: "update_record",
|
|
108
|
+
title: "Update Record",
|
|
109
|
+
description: "Update a record by id.",
|
|
110
|
+
parameters: params,
|
|
111
|
+
execute(args: Params) {
|
|
112
|
+
return {
|
|
113
|
+
text: `Updated ${args.id}`,
|
|
114
|
+
structuredContent: { id: args.id, name: args.name, archived: args.archived },
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
84
120
|
---
|
|
85
121
|
|
|
86
122
|
## 2. Register tools in a pi extension
|
|
@@ -293,7 +329,124 @@ Use `PortableToolHost<CustomHost>` for values that can be either a built-in host
|
|
|
293
329
|
|
|
294
330
|
---
|
|
295
331
|
|
|
296
|
-
## 5.
|
|
332
|
+
## 5. Per-host metadata via `hostExtras`
|
|
333
|
+
|
|
334
|
+
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.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { Type } from "typebox";
|
|
338
|
+
import { definePortableTool } from "@feniix/bridgekit";
|
|
339
|
+
|
|
340
|
+
export const summariseTool = definePortableTool({
|
|
341
|
+
name: "summarise",
|
|
342
|
+
title: "Summarise",
|
|
343
|
+
description: "Generate a short summary of a block of text.",
|
|
344
|
+
parameters: Type.Object({ text: Type.String() }),
|
|
345
|
+
execute(args) {
|
|
346
|
+
return { text: args.text.slice(0, 80) };
|
|
347
|
+
},
|
|
348
|
+
hostExtras: {
|
|
349
|
+
pi: {
|
|
350
|
+
// Fires once before TypeBox validation runs — surfaces a "Processing..."
|
|
351
|
+
// signal to the pi host without a custom registration wrapper.
|
|
352
|
+
pendingMessage: "Summarising...",
|
|
353
|
+
promptSnippet: "Use this tool when the user asks for a short summary.",
|
|
354
|
+
promptGuidelines: ["Prefer < 80 chars.", "Strip markdown."],
|
|
355
|
+
},
|
|
356
|
+
mcp: {
|
|
357
|
+
// Advisory hints clients may surface. Do not affect validation or
|
|
358
|
+
// execution; round-trip verbatim on `tools/list` entries.
|
|
359
|
+
annotations: { readOnlyHint: true },
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Best practices for `hostExtras`:
|
|
366
|
+
|
|
367
|
+
- Keep tool *behavior* host-neutral. `hostExtras` carries *data* the adapter reads on the tool's behalf; do not embed callbacks or runtime logic.
|
|
368
|
+
- Set only the fields the adapter recognises; unrecognised keys are silently ignored, but they add noise to the definition.
|
|
369
|
+
- For custom-host adapters, extend `PortableToolHostExtras` via `declare module "@feniix/bridgekit"` so consumers of your adapter get type safety on the new namespace.
|
|
370
|
+
|
|
371
|
+
See `docs/rfc-host-extras.md` for the full design rationale (which fields qualify, why a top-level field beats a sidecar map, the closure rule for future additions).
|
|
372
|
+
|
|
373
|
+
### Migrating from a custom registration loop
|
|
374
|
+
|
|
375
|
+
Before `hostExtras`, packages that needed pi-specific per-tool metadata
|
|
376
|
+
typically kept a sidecar map plus a custom `toPiTool` wrapper that bypassed
|
|
377
|
+
`registerPiTools`. The before-shape drifts out of sync the first time a tool
|
|
378
|
+
is added without its sidecar entry; the after-shape co-locates the metadata
|
|
379
|
+
with the tool, removes the wrapper, and uses the canonical adapter directly.
|
|
380
|
+
|
|
381
|
+
**Before** — sidecar `PENDING_MESSAGES` plus a custom registration loop:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// extensions/index.ts (before)
|
|
385
|
+
import { executePortableTool } from "@feniix/bridgekit";
|
|
386
|
+
import { createTools } from "./tools.js";
|
|
387
|
+
|
|
388
|
+
const PENDING_MESSAGES: Record<string, string> = {
|
|
389
|
+
generate_summary: "Generating summary...",
|
|
390
|
+
rewrite_paragraph: "Rewriting...",
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export default function extension(pi: { registerTool: (tool: unknown) => void }) {
|
|
394
|
+
for (const tool of createTools()) {
|
|
395
|
+
pi.registerTool({
|
|
396
|
+
name: tool.name,
|
|
397
|
+
label: tool.title,
|
|
398
|
+
description: tool.description,
|
|
399
|
+
parameters: tool.parameters,
|
|
400
|
+
async execute(_id: string, params: unknown, signal: AbortSignal, onUpdate?: (u: unknown) => void) {
|
|
401
|
+
onUpdate?.({
|
|
402
|
+
content: [{ type: "text", text: PENDING_MESSAGES[tool.name] ?? "" }],
|
|
403
|
+
details: { status: "pending" },
|
|
404
|
+
});
|
|
405
|
+
const result = await executePortableTool(tool, params, { host: "pi", signal });
|
|
406
|
+
return { content: [{ type: "text", text: result.text }], details: result.structuredContent ?? {} };
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**After** — `pendingMessage` carried on each tool, `registerPiTools` used directly:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
// extensions/tools.ts (after — each tool declares its own pending message)
|
|
417
|
+
import { Type } from "typebox";
|
|
418
|
+
import { definePortableTool } from "@feniix/bridgekit";
|
|
419
|
+
|
|
420
|
+
export const generateSummaryTool = definePortableTool({
|
|
421
|
+
name: "generate_summary",
|
|
422
|
+
title: "Generate Summary",
|
|
423
|
+
description: "Summarise a block of text.",
|
|
424
|
+
parameters: Type.Object({ text: Type.String() }),
|
|
425
|
+
execute(args) {
|
|
426
|
+
return { text: args.text.slice(0, 80) };
|
|
427
|
+
},
|
|
428
|
+
hostExtras: {
|
|
429
|
+
pi: { pendingMessage: "Generating summary..." },
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// extensions/index.ts (after — sidecar map deleted, custom loop deleted)
|
|
434
|
+
import { registerPiTools } from "@feniix/bridgekit/pi";
|
|
435
|
+
import { createTools } from "./tools.js";
|
|
436
|
+
|
|
437
|
+
export default function extension(pi: Parameters<typeof registerPiTools>[0]) {
|
|
438
|
+
registerPiTools(pi, createTools());
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The sidecar map deletes entirely, the per-tool wrapper deletes, and the
|
|
443
|
+
remaining wiring is the same three-line shape every other consumer uses.
|
|
444
|
+
See `docs/rfc-host-extras.md` §5 for the canonical motivation and the
|
|
445
|
+
per-consumer migration deltas.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## 6. Package checklist
|
|
297
450
|
|
|
298
451
|
For publishable tool packages:
|
|
299
452
|
|
package/llms.txt
CHANGED
|
@@ -55,7 +55,7 @@ export function createTools() {
|
|
|
55
55
|
## Best practices
|
|
56
56
|
|
|
57
57
|
- Keep portable tools host-neutral. Do not import pi or MCP SDKs from files that define tool behavior.
|
|
58
|
-
-
|
|
58
|
+
- 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.
|
|
59
59
|
- Return `{ text, structuredContent }` for successful results.
|
|
60
60
|
- Use `isError: true` for domain/tool-level failures that should reach the model as tool output.
|
|
61
61
|
- Throw only for unexpected programmer, adapter, or runtime failures.
|
|
@@ -76,6 +76,7 @@ export function createTools() {
|
|
|
76
76
|
- 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.
|
|
77
77
|
- The result guards take a `PortableToolResult` — the value `executePortableTool` returns at the seam between BridgeKit and a host. They are not designed to be called on the pi adapter's wire object (`{ content, details, isError }`), which exposes `details` instead of `structuredContent`.
|
|
78
78
|
- `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.
|
|
79
|
+
- `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 verbatim to pi's `registerTool` call; `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.
|
|
79
80
|
|
|
80
81
|
### Result-guard usage
|
|
81
82
|
|