@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 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.8.3] - Unreleased
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 { TObject } from "typebox";
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
- tools: readonly PortableTool<TObject>[];
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;
@@ -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;
@@ -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
- let current = schema;
33
- let lastKey;
34
- let i = 0;
35
- while (i < segments.length) {
36
- if (!current || typeof current !== "object")
37
- return undefined;
38
- const node = current;
39
- // Array descent: numeric segment against an `items` schema. Tuple-form
40
- // `items` (array) indexes positionally; schema-form descends uniformly.
41
- if (node.items !== undefined && /^\d+$/.test(segments[i])) {
42
- const idx = Number(segments[i]);
43
- if (Array.isArray(node.items)) {
44
- if (idx >= node.items.length)
45
- return undefined;
46
- current = node.items[idx];
47
- }
48
- else {
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
- // Object descent: greedy longest-prefix match against `properties` keys.
56
- // This is what lets `a/b` resolve to a single key when the schema
57
- // declares it, even though the instancePath segments it as ["a", "b"].
58
- if (node.properties && typeof node.properties === "object") {
59
- const props = node.properties;
60
- let matched = -1;
61
- for (let j = segments.length; j > i; j--) {
62
- const candidate = segments.slice(i, j).join("/");
63
- if (Object.hasOwn(props, candidate)) {
64
- matched = j;
65
- lastKey = candidate;
66
- current = props[candidate];
67
- break;
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
- return lastKey;
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);
@@ -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";
@@ -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. Package checklist
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
- - Use TypeBox `Type.Object(...)` schemas for MCP-compatible tools.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.",
5
5
  "keywords": [
6
6
  "pi",