@feniix/bridgekit 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,54 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.18.0] - 2026-10-06
10
+
11
+ ### Added
12
+
13
+ - The MCP adapter forwards `ctx.progress?.(update)` as `notifications/progress` when the
14
+ `tools/call` request carries `_meta.progressToken`. `progress` is a per-call counter
15
+ starting at 1, `message` is `update.text`, `total` is omitted. Notifications are flushed
16
+ before the result and skipped once the request is cancelled; a failed send never fails
17
+ the call. Without a token `ctx.progress` stays `undefined`, matching previous behaviour.
18
+ - `outputSchema` accepts any JSON Schema root except a top-level `$ref`: arrays, primitives,
19
+ `null` and unions join objects and object intersections. The MCP SDK projects non-object
20
+ values for legacy-era clients (`{ result: value }` on both `tools/list` and `tools/call`)
21
+ while modern `2026-07-28` clients receive the natural value. Pi forwards the raw value on
22
+ `structuredContent` and wraps non-object values as `details.result`. `definePortableTool`
23
+ checks array and primitive roots at compile time without the open-record intersection.
24
+
25
+ ### Changed
26
+
27
+ - `PortableToolResult<TStructured>` drops the `Record<string, unknown>` constraint on its
28
+ generic (the default is unchanged), and bare `PortableTool` now defaults its result generic
29
+ to `PortableToolResult<unknown>` so adapter tool lists accept non-object outputs. A tool
30
+ annotated as bare `PortableTool` / `PortableTool<TSchema>` therefore yields
31
+ `structuredContent: unknown` from `executePortableTool` where it was `Record<string, unknown>`;
32
+ keep the inferred type from `definePortableTool` or annotate the result generic explicitly.
33
+ `PortableToolContext.progress`, the result guards and `PortableToolExecutionError` accept
34
+ `PortableToolResult<unknown>`. Custom adapters that declared `progress` with the narrower
35
+ object-only parameter need to widen it. Both adapters now test `structuredContent !== undefined`
36
+ instead of `??`, so a `null` structured value no longer falls back to `details`.
37
+ - `BRIDGEKIT_MCP_NON_OBJECT_OUTPUT_SCHEMA` is gone; only `BRIDGEKIT_MCP_REF_OUTPUT_SCHEMA`
38
+ (and `BRIDGEKIT_REF_OUTPUT_SCHEMA` at the portable seam) remain.
39
+
40
+ - Development, CI, and release installs use pnpm instead of npm. `package.json#packageManager`
41
+ pins `pnpm@12.9.1`, `pnpm-lock.yaml` replaces `package-lock.json`, and CI installs with
42
+ `pnpm install --frozen-lockfile`. The production audit gate is now
43
+ `pnpm audit --prod --audit-level high`. Publishing still uses
44
+ `npm publish --access public --provenance` for OIDC trusted publishing.
45
+ The lockfile resolves `typebox` to 1.3.35 rather than 1.3.36, because pnpm's default
46
+ `minimumReleaseAge` skips same-day releases; both satisfy `^1.1.31`.
47
+ - The package smoke test packs with `pnpm pack` and installs the tarball into a strict pnpm
48
+ consumer, which now declares `@modelcontextprotocol/server` explicitly for the `Server` type.
49
+ A new invariant fails when `packageManager` is unpinned or an npm/yarn lockfile reappears.
50
+ - CI and all release jobs run on Node 22 only, matching pi's workflows: the `22.19`/`24`
51
+ CI matrix is gone and the release no longer uses Node 24. The publish job upgrades to
52
+ `npm@11.16.0` (as pi does) because Node 22's bundled npm 10.x cannot do trusted publishing.
53
+ The `engines.node` floor stays `>=22.19.0`.
54
+ - No runtime change: `runBinWrapper` still builds missing output with `npm run <buildScript>`,
55
+ since downstream consumers own their package manager.
56
+
9
57
  ## [0.17.0] - 2026-10-06
10
58
 
11
59
  ### Changed
package/README.md CHANGED
@@ -111,8 +111,8 @@ Do not deep-import from `dist/` or `src/` in consuming packages.
111
111
 
112
112
  #### Declared structured output
113
113
 
114
- Add an optional object-shaped `outputSchema` to a portable tool to describe its
115
- successful `structuredContent` for both Pi and MCP:
114
+ Add an optional `outputSchema` to a portable tool to describe its successful
115
+ `structuredContent` for both Pi and MCP:
116
116
 
117
117
  ```ts
118
118
  const echoTool = definePortableTool({
@@ -125,9 +125,13 @@ const echoTool = definePortableTool({
125
125
  });
126
126
  ```
127
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
128
+ Any JSON Schema root is accepted except a top-level `$ref` (`Type.Ref` /
129
+ `Type.Cyclic`): objects, intersections of objects, arrays, primitives, `null` and
130
+ unions all work. Success calls must return matching `structuredContent`; legacy
131
+ `details` alone does not satisfy the schema. Non-object values reach modern MCP
132
+ clients as-is; for legacy-era clients the MCP SDK wraps both the listed schema and
133
+ the value as `{ result: ... }`. Pi receives the raw value on `structuredContent`
134
+ and `{ result: value }` on renderer-facing `details`. Missing/invalid output throws a tool-attributed `TypeError` at the
131
135
  portable seam and becomes an error result through the adapters. Argument/domain
132
136
  failures are exempt from success schemas. Without `outputSchema`, existing result
133
137
  behavior is unchanged.
@@ -298,6 +302,8 @@ Tool `parameters` must resolve to a JSON-Schema object at the top level. `Type.O
298
302
 
299
303
  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.
300
304
 
305
+ When a client sends `_meta.progressToken` on `tools/call`, each `ctx.progress?.(update)` becomes a `notifications/progress` notification: `progress` is a per-call counter starting at 1 (the spec requires it to increase), `message` is `update.text`, and `total` is omitted. Without a token `ctx.progress` is `undefined`, so tools should keep calling it optionally. Notifications are flushed before the result and skipped once the request is cancelled. Caveat: the official SDK clients defer notification handlers while handling responses synchronously, so a burst of progress calls issued synchronously right before returning can be dropped by the client when it arrives in the same stdio chunk as the result. BridgeKit writes them in order regardless; tools that emit progress over time are unaffected.
306
+
301
307
  The two adapters return `{ isError: true }` for argument/domain failures by default.
302
308
  Use result guards on portable values; both wire formats now preserve `structuredContent`,
303
309
  and Pi also exposes its renderer-facing `details`.
@@ -344,7 +350,7 @@ Options:
344
350
 
345
351
  - `metaUrl` (required): `import.meta.url` of the bin script. Used to locate the package root (the bin's parent directory).
346
352
  - `mcpEntry` (required): path to the compiled MCP entry, relative to the package root (e.g. `"dist/extensions/mcp-server.js"`). Absolute paths, `..` path segments, and NUL bytes are rejected.
347
- - `buildScript` (required): npm script to invoke when the entry is missing (e.g. `"build:mcp"`). Names must be literal script identifiers containing only letters, numbers, `:`, `_`, `@`, `.`, or `-`, and must not start with `-`.
353
+ - `buildScript` (required): npm script to invoke when the entry is missing (e.g. `"build:mcp"`). The wrapper runs it with `npm run <buildScript> --silent` regardless of which package manager installed your package. Names must be literal script identifiers containing only letters, numbers, `:`, `_`, `@`, `.`, or `-`, and must not start with `-`.
348
354
  - `buildTimeoutMs` (optional, default `60_000`): timeout for the build subprocess. Distinct "Build timed out…" diagnostic fires when exceeded.
349
355
  - `logPrefix` (optional, default `"bridgekit-bin"`): prefix on the "Failed to build…" diagnostic.
350
356
  - `buildStdio` (optional, default `"inherit"`): `stdio` mode passed to `spawnSync` when the build script runs. **MCP stdio server bins should pass `["ignore", "inherit", "inherit"]`** so the build subprocess's stdout cannot contaminate the parent's JSON-RPC framing channel (`process.stdout`). stderr stays inherited so build diagnostics remain visible.
@@ -389,7 +395,7 @@ Package and release checklist:
389
395
  - If an npm-launched bin depends on generated output, use `runBinWrapper` from `@feniix/bridgekit/bin-wrapper` (since 0.11.0) — it resolves the package-local generated entry, runs the package-local build when output is missing in workspace/local execution, preserves build failures, and distinguishes timeout from build error in its diagnostic. The bin script becomes a three-line invocation; no hand-rolled wrapper needed.
390
396
  - If a package keeps a source-loaded host entrypoint (for example a pi extension source file), use a package-local MCP build behind that wrapper and narrow the build to the MCP entrypoint plus shared host-neutral modules.
391
397
  - Declare a compatible Node engine (`>=22.19.0`) in downstream packages that expose BridgeKit-powered MCP bins.
392
- - Run `npm run check`, `npm test`, `npm run pack:dry-run`, `npm run package-smoke`, and `npm audit --omit=dev --audit-level=high` before publishing.
398
+ - BridgeKit itself is developed with pnpm (pinned via `packageManager`): run `pnpm run check`, `pnpm test`, `pnpm run pack:dry-run`, `pnpm run package-smoke`, and `pnpm audit --prod --audit-level high` before publishing.
393
399
  - Treat `docs/releasing.md` as the release handoff; publishing is manual via the Release workflow and npm trusted publishing.
394
400
 
395
401
  See `examples/README.md` for complete copyable examples.
@@ -1,6 +1,6 @@
1
1
  import { Server } from "@modelcontextprotocol/server";
2
2
  import type { TSchema } from "typebox";
3
- import type { PortableTool } from "../core/define-tool.js";
3
+ import type { PortableTool, PortableToolResult } from "../core/define-tool.js";
4
4
  export interface CreateMcpServerOptions {
5
5
  name: string;
6
6
  version: string;
@@ -17,7 +17,7 @@ export interface CreateMcpServerOptions {
17
17
  * reference, not deep-cloned — treat `tool.parameters` and `tool.outputSchema` as immutable once
18
18
  * `createMcpServer` returns.
19
19
  */
20
- tools: readonly PortableTool<TSchema>[];
20
+ tools: readonly PortableTool<TSchema, PortableToolResult<unknown>>[];
21
21
  instructions?: string;
22
22
  }
23
23
  export declare function createMcpServer(options: CreateMcpServerOptions): Server;
@@ -3,9 +3,10 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
3
  import { executePortableTool } from "../core/execute-tool.js";
4
4
  import { assertPortableOutputSchema, isObjectSchema, schemaTypeLabel, throwWithCode } from "../core/output-schema.js";
5
5
  function toMcpResult(result) {
6
+ // `!== undefined` rather than `??`: `null` is a legal structured value.
6
7
  return {
7
8
  content: [{ type: "text", text: result.text }],
8
- structuredContent: result.structuredContent ?? result.details,
9
+ structuredContent: result.structuredContent !== undefined ? result.structuredContent : result.details,
9
10
  isError: result.isError ?? false,
10
11
  };
11
12
  }
@@ -27,6 +28,22 @@ function toMcpObjectSchema(schema) {
27
28
  }
28
29
  return { type: "object", ...schema };
29
30
  }
31
+ /**
32
+ * Render an output schema for `tools/list` and for `projectCallToolResult`.
33
+ * All-object `allOf` intersections still get `type: "object"` synthesized:
34
+ * the SDK's legacy-era `{ result: ... }` wrap keys purely on the advertised
35
+ * root `type`, so an unlowered intersection would silently change the wire
36
+ * shape existing `Type.Intersect` outputs have today. Every other root
37
+ * (array, primitive, union) passes through by reference and the SDK projects
38
+ * it per era. The same object must feed both the listing and the projection.
39
+ */
40
+ function toMcpOutputSchema(schema) {
41
+ const candidate = schema;
42
+ if (candidate.type === undefined && isObjectSchema(schema)) {
43
+ return { type: "object", ...candidate };
44
+ }
45
+ return candidate;
46
+ }
30
47
  /**
31
48
  * Stable `error.code` values attached to `createMcpServer` construction
32
49
  * failures so consumers have a non-string anchor (the message text is
@@ -89,8 +106,12 @@ export function createMcpServer(options) {
89
106
  // Build the dispatch map and the listing payload at construction so
90
107
  // `tools/list` returns a pre-computed array and post-construction mutations
91
108
  // to the caller's array cannot leak unvalidated schemas onto the wire.
92
- const byName = new Map(options.tools.map((tool) => [tool.name, tool]));
93
- const mcpTools = options.tools.map((tool) => {
109
+ const entries = options.tools.map((tool) => ({
110
+ tool,
111
+ outputSchema: tool.outputSchema !== undefined ? toMcpOutputSchema(tool.outputSchema) : undefined,
112
+ }));
113
+ const byName = new Map(entries.map((entry) => [entry.tool.name, entry]));
114
+ const mcpTools = entries.map(({ tool, outputSchema }) => {
94
115
  const annotations = tool.hostExtras?.mcp?.annotations;
95
116
  // MCP advisory hints from `hostExtras.mcp.annotations`. Two gates:
96
117
  // 1. `annotations !== undefined` — a tool without hostExtras builds a
@@ -111,7 +132,7 @@ export function createMcpServer(options) {
111
132
  title: tool.title,
112
133
  description: tool.description,
113
134
  inputSchema: toMcpObjectSchema(tool.parameters),
114
- ...(tool.outputSchema !== undefined && { outputSchema: toMcpObjectSchema(tool.outputSchema) }),
135
+ ...(outputSchema !== undefined && { outputSchema }),
115
136
  ...(hasAnnotations ? { annotations: { ...annotations } } : {}),
116
137
  };
117
138
  });
@@ -121,21 +142,47 @@ export function createMcpServer(options) {
121
142
  });
122
143
  server.setRequestHandler("tools/list", async () => ({ tools: mcpTools }));
123
144
  server.setRequestHandler("tools/call", async (request, ctx) => {
124
- const tool = byName.get(request.params.name);
125
- if (!tool) {
145
+ const entry = byName.get(request.params.name);
146
+ if (!entry) {
126
147
  return {
127
148
  content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
128
149
  isError: true,
129
150
  };
130
151
  }
152
+ // `ctx.progress` is only wired when the client asked for progress by
153
+ // sending `_meta.progressToken`; otherwise the context is byte-identical
154
+ // to the pre-progress shape. `progress` is a monotonic per-call counter
155
+ // (the spec requires it to increase) and `message` is the update text.
156
+ // Sends are skipped once the request is cancelled and awaited before the
157
+ // result goes out, so a client never sees a notification for a token it
158
+ // has already released. A failed send never fails the tool call.
159
+ const progressToken = ctx.mcpReq._meta?.progressToken;
160
+ const pendingNotifications = [];
161
+ let progressCount = 0;
162
+ const progress = progressToken === undefined
163
+ ? undefined
164
+ : (update) => {
165
+ if (ctx.mcpReq.signal.aborted)
166
+ return;
167
+ progressCount += 1;
168
+ pendingNotifications.push(ctx.mcpReq
169
+ .notify({
170
+ method: "notifications/progress",
171
+ params: { progressToken, progress: progressCount, message: update.text },
172
+ })
173
+ .catch(() => undefined));
174
+ };
131
175
  try {
132
- const result = await executePortableTool(tool, request.params.arguments ?? {}, {
176
+ const result = await executePortableTool(entry.tool, request.params.arguments ?? {}, {
133
177
  host: "mcp",
134
178
  signal: ctx.mcpReq.signal,
179
+ ...(progress !== undefined && { progress }),
135
180
  });
136
- return server.projectCallToolResult(toMcpResult(result), tool.outputSchema !== undefined ? toMcpObjectSchema(tool.outputSchema) : undefined);
181
+ await Promise.all(pendingNotifications);
182
+ return server.projectCallToolResult(toMcpResult(result), entry.outputSchema);
137
183
  }
138
184
  catch (error) {
185
+ await Promise.all(pendingNotifications);
139
186
  const message = error instanceof Error ? error.message : String(error);
140
187
  return {
141
188
  content: [{ type: "text", text: message }],
@@ -11,7 +11,7 @@ type PiToolUpdate = {
11
11
  type PiToolResult = {
12
12
  content: PiContent[];
13
13
  details: Record<string, unknown>;
14
- structuredContent?: Record<string, unknown>;
14
+ structuredContent?: unknown;
15
15
  isError?: boolean;
16
16
  };
17
17
  type PiToolDefinition = {
@@ -55,7 +55,7 @@ export interface RegisterPiToolsOptions {
55
55
  }
56
56
  export declare class PortableToolExecutionError extends Error {
57
57
  readonly details: PortableToolErrorDetails;
58
- constructor(result: PortableToolResult);
58
+ constructor(result: PortableToolResult<unknown>);
59
59
  }
60
60
  export declare function isPortableToolExecutionError(error: unknown): error is PortableToolExecutionError;
61
61
  /**
@@ -75,5 +75,5 @@ export declare function isPortableToolExecutionError(error: unknown): error is P
75
75
  * // Deprecated, but available through 0.x.
76
76
  * registerPiTools(pi, tools, { errorHandling: "throw" });
77
77
  */
78
- export declare function registerPiTools(pi: PiToolRegistration, tools: readonly PortableTool<TSchema>[], options?: RegisterPiToolsOptions): void;
78
+ export declare function registerPiTools(pi: PiToolRegistration, tools: readonly PortableTool<TSchema, PortableToolResult<unknown>>[], options?: RegisterPiToolsOptions): void;
79
79
  export {};
@@ -1,8 +1,19 @@
1
1
  import { executePortableTool } from "../core/execute-tool.js";
2
2
  import { assertPortableOutputSchema } from "../core/output-schema.js";
3
3
  import { isValidationFailure } from "../core/result-guards.js";
4
+ function isRecord(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ /**
8
+ * Renderer-facing `details` is always an object. Non-object structured
9
+ * content (array, primitive, `null`) is wrapped as `{ result: value }`,
10
+ * mirroring the MCP SDK's legacy-era projection of the same values.
11
+ */
4
12
  function toPiDetails(result) {
5
- return result.structuredContent ?? result.details ?? {};
13
+ if (result.structuredContent !== undefined) {
14
+ return isRecord(result.structuredContent) ? result.structuredContent : { result: result.structuredContent };
15
+ }
16
+ return result.details ?? {};
6
17
  }
7
18
  function toPortableToolErrorDetails(result) {
8
19
  if (isValidationFailure(result)) {
@@ -1,8 +1,15 @@
1
1
  import type { Static, TSchema } from "typebox";
2
- export interface PortableToolResult<TStructured extends Record<string, unknown> = Record<string, unknown>> {
2
+ export interface PortableToolResult<TStructured = Record<string, unknown>> {
3
3
  /** Plain text sent back to the model in every host. */
4
4
  text: string;
5
- /** Structured data for hosts that support it. Preferred by both pi and MCP adapters. */
5
+ /**
6
+ * Structured data for hosts that support it. Preferred by both pi and MCP
7
+ * adapters. Any JSON value is accepted (object, array, primitive, `null`);
8
+ * the default type parameter keeps the common object case ergonomic.
9
+ * Non-object values reach MCP `structuredContent` directly on the modern
10
+ * era, are wrapped as `{ result: value }` by the SDK for legacy clients,
11
+ * and are wrapped the same way in pi's renderer-facing `details`.
12
+ */
6
13
  structuredContent?: TStructured;
7
14
  /**
8
15
  * Legacy/adapter debug details used only when `structuredContent` is absent.
@@ -40,7 +47,12 @@ export type PortableToolBuiltInHost = "pi" | "mcp" | "test";
40
47
  export interface PortableToolContext {
41
48
  host: PortableToolBuiltInHost;
42
49
  signal?: AbortSignal;
43
- progress?: (update: PortableToolResult) => void;
50
+ /**
51
+ * Host progress channel. Pi maps each update to `onUpdate`; MCP maps it to
52
+ * `notifications/progress` when the request carried a `progressToken`
53
+ * (`progress` is a monotonic per-call counter and `message` is `update.text`).
54
+ */
55
+ progress?: (update: PortableToolResult<unknown>) => void;
44
56
  }
45
57
  /**
46
58
  * Optional TUI renderer for the tool call line (before execution).
@@ -153,23 +165,31 @@ export interface PortableToolHostExtras {
153
165
  pi?: PiHostExtras;
154
166
  mcp?: McpHostExtras;
155
167
  }
168
+ /**
169
+ * Object-rooted outputs keep the open-record intersection so handlers may
170
+ * return extra keys beyond the schema; array and primitive roots are used as-is
171
+ * (neither is assignable to `Record<string, unknown>`).
172
+ */
173
+ type SchemaOutput<T> = [T] extends [readonly unknown[]] ? T : [T] extends [object] ? T & Record<string, unknown> : T;
156
174
  /** 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>;
175
+ type SchemaResult<TOutput extends TSchema> = (PortableToolResult<unknown> & {
176
+ structuredContent: SchemaOutput<Exclude<Static<TOutput>, undefined>>;
159
177
  isError?: false;
160
- }) | (PortableToolResult & {
178
+ }) | (PortableToolResult<unknown> & {
161
179
  isError: true;
162
180
  });
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> {
181
+ type CheckedResult<TResult extends PortableToolResult<unknown>, TOutput extends TSchema | undefined> = [
182
+ TOutput
183
+ ] extends [TSchema] ? TResult & SchemaResult<NoInfer<Extract<TOutput, TSchema>>> : TResult;
184
+ export interface PortableTool<TParams extends TSchema = TSchema, TResult extends PortableToolResult<unknown> = PortableToolResult<unknown>, TOutput extends TSchema | undefined = TSchema | undefined> {
167
185
  name: string;
168
186
  title: string;
169
187
  description: string;
170
188
  parameters: TParams;
171
189
  /**
172
- * Object-shaped TypeBox schema for successful structuredContent.
190
+ * TypeBox schema for successful structuredContent. Any JSON Schema root is
191
+ * accepted except a top-level `$ref` (`Type.Ref` / `Type.Cyclic`); objects,
192
+ * object intersections, arrays, primitives and unions are all valid.
173
193
  * Literal isError:true results are exempt. Treat this schema and parameters
174
194
  * as immutable after registration, including replacing either schema object.
175
195
  */
@@ -190,14 +210,14 @@ export declare function definePortableTool<TParams extends TSchema, TOutput exte
190
210
  outputSchema: TOutput;
191
211
  execute: TExecute;
192
212
  }): PortableTool<TParams, Awaited<ReturnType<TExecute>>, TOutput>;
193
- export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult> & {
213
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult<unknown>>(tool: PortableTool<TParams, TResult> & {
194
214
  outputSchema?: undefined;
195
215
  }): PortableTool<TParams, TResult, undefined>;
196
216
  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
217
  outputSchema?: TOutput;
198
218
  execute: TExecute;
199
219
  }): PortableTool<TParams, Awaited<ReturnType<TExecute>>, TOutput>;
200
- export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult, TTool extends {
220
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult<unknown>, TTool extends {
201
221
  outputSchema?: TSchema | undefined;
202
222
  } = PortableTool<TParams, TResult>>(tool: PortableTool<TParams, TResult> & TTool & (TSchema extends NonNullable<NoInfer<TTool>["outputSchema"]> ? unknown : never)): TTool;
203
223
  export {};
@@ -1,7 +1,7 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { PortableTool, PortableToolContext, PortableToolResult, PortableValidationError } from "./define-tool.js";
3
3
  import type { PortableValidationFailure } from "./result-guards.js";
4
- type PortableToolSuccess<TResult extends PortableToolResult> = TResult & {
4
+ type PortableToolSuccess<TResult extends PortableToolResult<unknown>> = TResult & {
5
5
  details?: Record<string, unknown>;
6
6
  isError?: boolean;
7
7
  } & (TResult extends {
@@ -11,12 +11,12 @@ type PortableToolSuccess<TResult extends PortableToolResult> = TResult & {
11
11
  } : {
12
12
  structuredContent?: Record<string, unknown>;
13
13
  });
14
- export declare function validatePortableToolArgs<TParams extends TSchema>(tool: PortableTool<TParams>, args: unknown): {
14
+ export declare function validatePortableToolArgs<TParams extends TSchema>(tool: PortableTool<TParams, PortableToolResult<unknown>>, args: unknown): {
15
15
  ok: true;
16
16
  args: Static<TParams>;
17
17
  } | {
18
18
  ok: false;
19
19
  errors: PortableValidationError[];
20
20
  };
21
- export declare function executePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult>, args: unknown, ctx: PortableToolContext): Promise<PortableToolSuccess<TResult> | PortableValidationFailure>;
21
+ export declare function executePortableTool<TParams extends TSchema, TResult extends PortableToolResult<unknown>>(tool: PortableTool<TParams, TResult>, args: unknown, ctx: PortableToolContext): Promise<PortableToolSuccess<TResult> | PortableValidationFailure>;
22
22
  export {};
@@ -1,7 +1,14 @@
1
- import type { PortableTool } from "./define-tool.js";
1
+ import type { TSchema } from "typebox";
2
+ import type { PortableTool, PortableToolResult } from "./define-tool.js";
2
3
  /** Follow JSON Schema structure rather than TypeBox-specific symbols. */
3
4
  export declare function isObjectSchema(schema: unknown): boolean;
4
5
  /** Identify the first unsupported root/branch for an actionable schema diagnostic. */
5
6
  export declare function schemaTypeLabel(schema: unknown): string;
6
7
  export declare function throwWithCode(message: string, code: string, ErrorType?: ErrorConstructor): never;
7
- export declare function assertPortableOutputSchema(tool: PortableTool, context?: string, codePrefix?: string): void;
8
+ /**
9
+ * Output schemas may use any JSON Schema root (object, array, primitive,
10
+ * union); the MCP SDK projects non-object roots for legacy clients. Only a
11
+ * top-level `$ref` is rejected: `tools/list` ships schemas by value and clients
12
+ * do not resolve references.
13
+ */
14
+ export declare function assertPortableOutputSchema(tool: PortableTool<TSchema, PortableToolResult<unknown>>, context?: string, codePrefix?: string): void;
@@ -38,16 +38,16 @@ export function throwWithCode(message, code, ErrorType = Error) {
38
38
  error.code = code;
39
39
  throw error;
40
40
  }
41
+ /**
42
+ * Output schemas may use any JSON Schema root (object, array, primitive,
43
+ * union); the MCP SDK projects non-object roots for legacy clients. Only a
44
+ * top-level `$ref` is rejected: `tools/list` ships schemas by value and clients
45
+ * do not resolve references.
46
+ */
41
47
  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);
48
+ const schema = tool.outputSchema;
49
+ if (schema !== undefined && typeof schema.$ref === "string") {
50
+ throwWithCode(`${context ? `${context}: ` : ""}Invalid outputSchema for ${tool.name} (type="$ref"): ` +
51
+ "Top-level $ref / Type.Cyclic is unsupported; inline the referenced shape or split recursive shapes.", `${codePrefix}_REF_OUTPUT_SCHEMA`, TypeError);
52
52
  }
53
53
  }
@@ -26,10 +26,10 @@ export type PortableValidationFailure = PortableToolResult & {
26
26
  * (The pi adapter's `PortableToolExecutionError.details` does synthesize that
27
27
  * discriminator for the deprecated `errorHandling: "throw"` path.)
28
28
  */
29
- export type PortableDomainFailure = PortableToolResult & {
29
+ export type PortableDomainFailure = PortableToolResult<unknown> & {
30
30
  isError: true;
31
31
  };
32
- export declare function isValidationFailure(result: PortableToolResult): result is PortableValidationFailure;
32
+ export declare function isValidationFailure(result: PortableToolResult<unknown>): result is PortableValidationFailure;
33
33
  /**
34
34
  * Type guard for handler-emitted `isError: true` results that are not
35
35
  * validation failures. Use after `result.isError` to branch between
@@ -41,4 +41,4 @@ export declare function isValidationFailure(result: PortableToolResult): result
41
41
  * metrics.increment("tool.domain_error", { tool: tool.name });
42
42
  * }
43
43
  */
44
- export declare function isDomainFailure(result: PortableToolResult): result is PortableDomainFailure;
44
+ export declare function isDomainFailure(result: PortableToolResult<unknown>): result is PortableDomainFailure;
@@ -270,8 +270,11 @@ MCP behavior:
270
270
  - `tools/call` validates arguments before invoking handlers.
271
271
  - Invalid arguments and portable `isError: true` results return MCP tool results with `isError: true`.
272
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.
273
+ - Optional `outputSchema` (any root except top-level `$ref`) is listed for successful
274
+ structured results; matching `structuredContent` is required and validated. Error
275
+ results are exempt. Non-object values are wrapped as `{ result }` for legacy clients.
276
+ - `ctx.progress?.(...)` becomes `notifications/progress` when the client sent a
277
+ `progressToken`; otherwise `ctx.progress` is `undefined`.
275
278
  - The stdio runner serves legacy and modern `2026-07-28` clients through SDK v2.
276
279
  It retains `Promise<void>` startup behavior; stdin EOF shuts down the connection.
277
280
  - The module stays import-passive and testable: tests can import `createMcpServerOptions()` without starting stdio.
@@ -418,5 +421,5 @@ For publishable tool packages:
418
421
  - When a bin depends on generated output, prefer a checked-in wrapper under `bin/` over pointing directly at `dist/`; for MCP stdio bins set `buildStdio: ["ignore", "inherit", "inherit"]`; test existing output, missing output, failed builds, and successful builds that omit the expected file.
419
422
  - If only the MCP bin needs compiled output, narrow its tsconfig to the MCP entrypoint and shared host-neutral modules instead of compiling unrelated host adapters.
420
423
  - Add a packed-install smoke test that installs tarballs into a temporary project.
421
- - For BridgeKit itself, run `npm run check`, `npm run test`, `npm run pack:dry-run`, `npm run package-smoke`, and `npm audit --omit=dev --audit-level=high` before release.
424
+ - For BridgeKit itself (a pnpm project), run `pnpm run check`, `pnpm test`, `pnpm run pack:dry-run`, `pnpm run package-smoke`, and `pnpm audit --prod --audit-level high` before release.
422
425
  - Keep imports side-effect free; registration and server startup should happen only in explicit entrypoints.
package/llms.txt CHANGED
@@ -59,9 +59,11 @@ 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.
62
+ - Optional `outputSchema` declares successful structured data for both hosts: any
63
+ TypeBox schema except a top-level `$ref` (objects, intersections, arrays,
64
+ primitives, unions). Missing/invalid successful `structuredContent` throws
65
+ `TypeError`; argument/domain error results are exempt. Non-object values are
66
+ wrapped as `{ result }` for legacy MCP clients by the SDK and on pi `details`.
65
67
  Legacy `details` alone does not satisfy a declared output schema.
66
68
  `definePortableTool` infers/checks schema-linked success data at compile time;
67
69
  use literal `isError: true` for exempt domain failures. A third
@@ -75,6 +77,8 @@ export function createTools() {
75
77
  - Throw only for unexpected programmer, adapter, or runtime failures.
76
78
  - Respect `ctx.signal` in long-running tools.
77
79
  - Use `ctx.progress?.(...)` for incremental progress updates when the host supports them.
80
+ Pi maps them to `onUpdate`; MCP emits `notifications/progress` (counter + `update.text`)
81
+ only when the client sent `_meta.progressToken`, otherwise `ctx.progress` is `undefined`.
78
82
  - Wire hosts explicitly: pi registration and MCP server startup are separate adapter calls.
79
83
  - Keep modules import-passive. Do not register tools, start servers, read package metadata, read env vars, or touch files at import time.
80
84
  - For stateful tools, export a `createTools()` factory instead of a module-level `tools` singleton so each pi extension instance or MCP stdio process gets isolated state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -46,15 +46,15 @@
46
46
  "scripts": {
47
47
  "lint": "biome check .",
48
48
  "lint:fix": "biome check --write .",
49
- "check": "npm run lint && npm run typecheck",
49
+ "check": "pnpm run lint && pnpm run typecheck",
50
50
  "clean": "node scripts/clean-package-dist.mjs",
51
51
  "typecheck": "tsc -b tsconfig.json --pretty false",
52
- "build": "npm run clean && tsc -b tsconfig.json",
53
- "test": "npm run build && node scripts/run-built-tests.mjs",
54
- "test:coverage": "npm run build && node scripts/run-built-tests-coverage.mjs",
55
- "pack:dry-run": "npm pack --dry-run --json",
52
+ "build": "pnpm run clean && tsc -b tsconfig.json",
53
+ "test": "pnpm run build && node scripts/run-built-tests.mjs",
54
+ "test:coverage": "pnpm run build && node scripts/run-built-tests-coverage.mjs",
55
+ "pack:dry-run": "pnpm pack --dry-run --json",
56
56
  "package-smoke": "node scripts/smoke-package.mjs",
57
- "prepack": "npm run build"
57
+ "prepack": "pnpm run build"
58
58
  },
59
59
  "files": [
60
60
  "dist/**/*.js",
@@ -80,5 +80,6 @@
80
80
  "@types/node": "^22.20.5",
81
81
  "typescript": "^6.0.3"
82
82
  },
83
- "license": "MIT"
83
+ "license": "MIT",
84
+ "packageManager": "pnpm@12.9.1"
84
85
  }