@feniix/bridgekit 0.17.0 → 0.19.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,108 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.19.0] - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - Forward tool `icons` and `_meta` from `hostExtras.mcp`, with construction-time
14
+ snapshots and no new wire keys when absent (#128).
15
+ - Add `createMcpHttpHandler` for Web-standard HTTP serving of modern MCP.
16
+ Listener and security policy remain application-owned
17
+ (#126). HTTP options and handler types are exported from the MCP entrypoint.
18
+
19
+ - Export `createMcpServerFactory(options)` from the MCP entrypoint: validates and
20
+ snapshots tools once and returns a per-call `Server` factory for the SDK's
21
+ `createMcpHandler` / `serveStdio`, replacing `createMcpServer` for custom
22
+ serving.
23
+
24
+ ### Changed (breaking)
25
+
26
+ - Serve only the modern MCP `2026-07-28` era. `runMcpStdioServer` and
27
+ `createMcpHttpHandler` reject legacy-era openings (`2025-11-25` and earlier)
28
+ with `-32022` naming the supported revisions, and log one `[bridgekit-mcp]`
29
+ stderr line per rejected request. Clients limited to the legacy era (SDK v1, or
30
+ v2 in `legacy` negotiation mode) can no longer connect. The SDK v2 client
31
+ defaults to `versionNegotiation.mode: "legacy"`, so a default-constructed
32
+ client is rejected: pin `{ mode: { pin: "2026-07-28" } }` or use `"auto"`.
33
+ `legacy` is removed from `CreateMcpHttpHandlerOptions` and ignored at runtime
34
+ for untyped callers. `createMcpServer` is deprecated as a direct
35
+ `Server.connect` target (a directly connected `Server` is legacy-only); use the
36
+ new `createMcpServerFactory(options)` with the SDK's `createMcpHandler` to
37
+ compose custom serving. Both the `tools/list` `outputSchema` and the
38
+ `tools/call` `structuredContent` of non-object outputs are no longer
39
+ `{ result }`-wrapped for any served client. The SDK v1 dev dependency and its
40
+ stdio interop test were removed (#131 retired). See `docs/mcp-v2-migration.md`.
41
+ - `runMcpStdioServer` now returns `Promise<McpStdioServerHandle>` with an
42
+ idempotent `close()` that aborts pending requests (#129). Awaiting startup
43
+ means stdio wiring is ready, not that a client has connected. Callers that
44
+ explicitly annotated `Promise<void>` must update their annotation; existing
45
+ callers that ignore the resolved value need no changes.
46
+
47
+ ### Fixed
48
+
49
+ - Snapshot validated tool definitions once for HTTP and stdio serving, including
50
+ discarded discovery probes; later caller array/metadata mutations cannot leak onto the wire.
51
+ - Reject invalid MCP icon shapes at construction with tool-attributed
52
+ `BRIDGEKIT_MCP_INVALID_ICONS` errors; HTTP serving defaults to stderr diagnostics.
53
+ - Clarify that stdio `close()` aborts requests without awaiting asynchronous tool cleanup.
54
+
55
+ ### Documentation
56
+
57
+ - Record the official conformance suite's HTTP-only server interface and the
58
+ resulting blocker for direct stdio conformance testing (#130).
59
+ - Record four passing official HTTP scenarios and the pinned harness's rejection
60
+ of `2026-07-28`; direct stdio/full modern conformance remains unverified (#130).
61
+ Those legacy-filtered results predate modern-only serving and no longer reproduce.
62
+
63
+ ## [0.18.0] - 2026-10-06
64
+
65
+ ### Added
66
+
67
+ - The MCP adapter forwards `ctx.progress?.(update)` as `notifications/progress` when the
68
+ `tools/call` request carries `_meta.progressToken`. `progress` is a per-call counter
69
+ starting at 1, `message` is `update.text`, `total` is omitted. Notifications are flushed
70
+ before the result and skipped once the request is cancelled; a failed send never fails
71
+ the call. Without a token `ctx.progress` stays `undefined`, matching previous behaviour.
72
+ - `outputSchema` accepts any JSON Schema root except a top-level `$ref`: arrays, primitives,
73
+ `null` and unions join objects and object intersections. The MCP SDK projects non-object
74
+ values for legacy-era clients (`{ result: value }` on both `tools/list` and `tools/call`)
75
+ while modern `2026-07-28` clients receive the natural value. Pi forwards the raw value on
76
+ `structuredContent` and wraps non-object values as `details.result`. `definePortableTool`
77
+ checks array and primitive roots at compile time without the open-record intersection.
78
+
79
+ ### Changed
80
+
81
+ - `PortableToolResult<TStructured>` drops the `Record<string, unknown>` constraint on its
82
+ generic (the default is unchanged), and bare `PortableTool` now defaults its result generic
83
+ to `PortableToolResult<unknown>` so adapter tool lists accept non-object outputs. A tool
84
+ annotated as bare `PortableTool` / `PortableTool<TSchema>` therefore yields
85
+ `structuredContent: unknown` from `executePortableTool` where it was `Record<string, unknown>`;
86
+ keep the inferred type from `definePortableTool` or annotate the result generic explicitly.
87
+ `PortableToolContext.progress`, the result guards and `PortableToolExecutionError` accept
88
+ `PortableToolResult<unknown>`. Custom adapters that declared `progress` with the narrower
89
+ object-only parameter need to widen it. Both adapters now test `structuredContent !== undefined`
90
+ instead of `??`, so a `null` structured value no longer falls back to `details`.
91
+ - `BRIDGEKIT_MCP_NON_OBJECT_OUTPUT_SCHEMA` is gone; only `BRIDGEKIT_MCP_REF_OUTPUT_SCHEMA`
92
+ (and `BRIDGEKIT_REF_OUTPUT_SCHEMA` at the portable seam) remain.
93
+
94
+ - Development, CI, and release installs use pnpm instead of npm. `package.json#packageManager`
95
+ pins `pnpm@12.9.1`, `pnpm-lock.yaml` replaces `package-lock.json`, and CI installs with
96
+ `pnpm install --frozen-lockfile`. The production audit gate is now
97
+ `pnpm audit --prod --audit-level high`. Publishing still uses
98
+ `npm publish --access public --provenance` for OIDC trusted publishing.
99
+ The lockfile resolves `typebox` to 1.3.35 rather than 1.3.36, because pnpm's default
100
+ `minimumReleaseAge` skips same-day releases; both satisfy `^1.1.31`.
101
+ - The package smoke test packs with `pnpm pack` and installs the tarball into a strict pnpm
102
+ consumer, which now declares `@modelcontextprotocol/server` explicitly for the `Server` type.
103
+ A new invariant fails when `packageManager` is unpinned or an npm/yarn lockfile reappears.
104
+ - CI and all release jobs run on Node 22 only, matching pi's workflows: the `22.19`/`24`
105
+ CI matrix is gone and the release no longer uses Node 24. The publish job upgrades to
106
+ `npm@11.16.0` (as pi does) because Node 22's bundled npm 10.x cannot do trusted publishing.
107
+ The `engines.node` floor stays `>=22.19.0`.
108
+ - No runtime change: `runBinWrapper` still builds missing output with `npm run <buildScript>`,
109
+ since downstream consumers own their package manager.
110
+
9
111
  ## [0.17.0] - 2026-10-06
10
112
 
11
113
  ### Changed
package/README.md CHANGED
@@ -94,7 +94,7 @@ import {
94
94
  type PortableValidationError,
95
95
  } from "@feniix/bridgekit";
96
96
  import { registerPiTools } from "@feniix/bridgekit/pi";
97
- import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp";
97
+ import { createMcpHttpHandler, runMcpStdioServer } from "@feniix/bridgekit/mcp";
98
98
  import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper";
99
99
  ```
100
100
 
@@ -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,12 @@ 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 MCP
132
+ clients as-is (the served modern era; legacy-era clients are not served). Pi receives the raw value on `structuredContent`
133
+ and `{ result: value }` on renderer-facing `details`. Missing/invalid output throws a tool-attributed `TypeError` at the
131
134
  portable seam and becomes an error result through the adapters. Argument/domain
132
135
  failures are exempt from success schemas. Without `outputSchema`, existing result
133
136
  behavior is unchanged.
@@ -248,6 +251,8 @@ export const generateSummaryTool = definePortableTool({
248
251
  },
249
252
  mcp: {
250
253
  annotations: { readOnlyHint: true },
254
+ icons: [{ src: "https://example.com/summary.svg", mimeType: "image/svg+xml" }],
255
+ _meta: { "example.com/category": "text" },
251
256
  },
252
257
  },
253
258
  });
@@ -255,10 +260,20 @@ export const generateSummaryTool = definePortableTool({
255
260
 
256
261
  `renderCall` is invoked by pi before execution to render the call line. `renderResult` is invoked by pi after execution to render the result; its `options.expanded` flag tracks the user's Ctrl+O collapsed/expanded toggle. BridgeKit forwards both functions by identity to `pi.registerTool`.
257
262
 
263
+ MCP `icons` and `_meta` are forwarded only when supplied, including explicit empty
264
+ arrays/objects. At server construction, BridgeKit copies icons (including their
265
+ `sizes` arrays) and shallow-copies `_meta`; nested metadata values must remain
266
+ immutable. BridgeKit never fetches icon URLs. The pi adapter ignores both fields.
267
+
258
268
  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"`.
259
269
 
260
270
  ### MCP adapter
261
271
 
272
+ `await runMcpStdioServer(options)` returns a `McpStdioServerHandle` with
273
+ idempotent `close(): Promise<void>` for explicit shutdown and cancellation of
274
+ pending requests. Resolution signals startup wiring, not client negotiation or
275
+ shutdown. Stdin EOF also cleans up the server.
276
+
262
277
  ```ts
263
278
  import { realpathSync } from "node:fs";
264
279
  import { resolve } from "node:path";
@@ -298,6 +313,8 @@ Tool `parameters` must resolve to a JSON-Schema object at the top level. `Type.O
298
313
 
299
314
  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
315
 
316
+ 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. Upstream: [typescript-sdk#2580](https://github.com/modelcontextprotocol/typescript-sdk/issues/2580) (fix pending in [typescript-sdk#2967](https://github.com/modelcontextprotocol/typescript-sdk/pull/2967)), tracked in [#132](https://github.com/feniix/bridgekit/issues/132).
317
+
301
318
  The two adapters return `{ isError: true }` for argument/domain failures by default.
302
319
  Use result guards on portable values; both wire formats now preserve `structuredContent`,
303
320
  and Pi also exposes its renderer-facing `details`.
@@ -305,23 +322,55 @@ The guards match error/data shape, not provenance, so Pi wire failures also matc
305
322
  at runtime. Raw Pi values still lack portable `text`; do not cast them to
306
323
  `PortableToolResult` to use the typed guards.
307
324
 
325
+ #### Streamable HTTP
326
+
327
+ ```ts
328
+ import { createMcpHttpHandler } from "@feniix/bridgekit/mcp";
329
+
330
+ const handler = createMcpHttpHandler(createMcpServerOptions(), {
331
+ responseMode: "auto", // preserves progress via SSE when needed
332
+ });
333
+ // Mount handler.fetch(request) in your Web-standard framework.
334
+ // Await handler.close() when the application shuts down.
335
+ ```
336
+
337
+ The handler serves the modern `2026-07-28` era only; legacy-era clients (`2025-11-25` and earlier)
338
+ receive an unsupported-protocol-version error (`-32022`, HTTP 400) naming the
339
+ supported revisions. It does not start a listener or install
340
+ authentication, authorization, Host/Origin validation, CORS, or TLS. **Enforce
341
+ those in your application before calling `fetch`**, especially before exposing a
342
+ local server to a network. SDK HTTP options are accepted as the second argument.
343
+ `responseMode: "json"` drops mid-call notifications; use `"auto"` for progress.
344
+
308
345
  #### MCP SDK v2 and modern stdio
309
346
 
310
- `runMcpStdioServer(options)` serves both legacy MCP revisions and modern
311
- `2026-07-28` through SDK v2's `serveStdio`. Its `Promise<void>` resolves after
312
- startup wiring, not at shutdown. Stdin EOF closes the connection; long-running
313
- tools must honor `ctx.signal` and release their own resources.
347
+ `runMcpStdioServer(options)` serves modern `2026-07-28` through SDK v2's
348
+ `serveStdio`; `2025-11-25` openings are answered with `-32022`. Its `Promise<McpStdioServerHandle>`
349
+ resolves after startup wiring, not at shutdown. Stdin EOF or `handle.close()`
350
+ closes the connection; long-running
351
+ tools must honor `ctx.signal` and release their own resources. `close()` aborts
352
+ requests but does **not** await handler completion or asynchronous abort cleanup;
353
+ coordinate tool cleanup before disposing shared application resources.
314
354
 
315
355
  `createMcpServer` now returns `@modelcontextprotocol/server` SDK v2's low-level
316
356
  `Server`. This is a source-level breaking change for consumers using SDK methods
317
- or types, even though legacy clients work over the wire. A direct
318
- `server.connect(StdioServerTransport)` remains legacy-only; use the runner for
319
- modern serving. See [migration guidance](docs/mcp-v2-migration.md).
320
-
321
- Some SDK v1 clients validate error data against cached success output schemas
322
- in their convenience `callTool` method; their generic request API can preserve
323
- that data. SDK v2 clients correctly exempt error results. HTTP and tasks remain
324
- outside BridgeKit's runner support.
357
+ or types. `createMcpServer` is deprecated: a directly connected `Server`
358
+ can only speak the legacy era, which BridgeKit no longer serves. Use
359
+ `runMcpStdioServer` or `createMcpHttpHandler`. See [migration guidance](docs/mcp-v2-migration.md).
360
+
361
+ **Legacy clients are not served.** Clients limited to the legacy era
362
+ (`2025-11-25` and earlier: SDK v1 clients, or v2 clients in `legacy` negotiation
363
+ mode) cannot connect. The SDK v2 client defaults to
364
+ `versionNegotiation.mode: "legacy"`, so a default-constructed client is rejected;
365
+ pin `versionNegotiation: { mode: { pin: "2026-07-28" } }` or use `"auto"`.
366
+ HTTP is exposed through `createMcpHttpHandler`; tasks remain pending.
367
+
368
+ HTTP option and return types are available as `CreateMcpHttpHandlerOptions` and
369
+ `McpHttpHandler` from `@feniix/bridgekit/mcp`; consumers need not import SDK types.
370
+ HTTP errors default to `[bridgekit-mcp]` diagnostics on stderr; `onerror` overrides
371
+ that reporter. Tool definitions are static (`tools.listChanged: false`); the SDK
372
+ handler's `notify.toolsChanged()` is not supported by BridgeKit's static tools.
373
+ Modern HTTP and stdio support request cancellation normally.
325
374
 
326
375
  ### bin-wrapper (since 0.11.0)
327
376
 
@@ -344,7 +393,7 @@ Options:
344
393
 
345
394
  - `metaUrl` (required): `import.meta.url` of the bin script. Used to locate the package root (the bin's parent directory).
346
395
  - `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 `-`.
396
+ - `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
397
  - `buildTimeoutMs` (optional, default `60_000`): timeout for the build subprocess. Distinct "Build timed out…" diagnostic fires when exceeded.
349
398
  - `logPrefix` (optional, default `"bridgekit-bin"`): prefix on the "Failed to build…" diagnostic.
350
399
  - `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 +438,7 @@ Package and release checklist:
389
438
  - 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
439
  - 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
440
  - 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.
441
+ - 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
442
  - Treat `docs/releasing.md` as the release handoff; publishing is manual via the Release workflow and npm trusted publishing.
394
443
 
395
444
  See `examples/README.md` for complete copyable examples.
@@ -0,0 +1,15 @@
1
+ import { type CreateMcpHandlerOptions, type McpHttpHandler } from "@modelcontextprotocol/server";
2
+ import { type CreateMcpServerOptions } from "./mcp.js";
3
+ /**
4
+ * SDK handler options minus `legacy`: BridgeKit serves the modern
5
+ * `2026-07-28` era only, so legacy-era requests are always rejected.
6
+ */
7
+ export type CreateMcpHttpHandlerOptions = Omit<CreateMcpHandlerOptions, "legacy">;
8
+ export type { McpHttpHandler };
9
+ /**
10
+ * Web-standard HTTP serving; no listener, authentication, or origin policy is
11
+ * installed. Applications must enforce those before calling `handler.fetch`.
12
+ * Serves the modern `2026-07-28` era only; `2025-11-25` clients receive the
13
+ * unsupported-protocol-version error naming the supported revisions.
14
+ */
15
+ export declare function createMcpHttpHandler(options: CreateMcpServerOptions, httpOptions?: CreateMcpHttpHandlerOptions): McpHttpHandler;
@@ -0,0 +1,16 @@
1
+ import { createMcpHandler } from "@modelcontextprotocol/server";
2
+ import { createMcpServerFactory } from "./mcp.js";
3
+ /**
4
+ * Web-standard HTTP serving; no listener, authentication, or origin policy is
5
+ * installed. Applications must enforce those before calling `handler.fetch`.
6
+ * Serves the modern `2026-07-28` era only; `2025-11-25` clients receive the
7
+ * unsupported-protocol-version error naming the supported revisions.
8
+ */
9
+ export function createMcpHttpHandler(options, httpOptions = {}) {
10
+ const factory = createMcpServerFactory(options);
11
+ return createMcpHandler(factory, {
12
+ ...httpOptions,
13
+ legacy: "reject",
14
+ onerror: httpOptions.onerror ?? ((error) => process.stderr.write(`[bridgekit-mcp] ${error.message}\n`)),
15
+ });
16
+ }
@@ -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,8 +17,39 @@ 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
+ /**
24
+ * Validate and snapshot the tools once, then return a factory that builds an
25
+ * isolated SDK `Server` per call. Pass it to the SDK's `createMcpHandler` or
26
+ * `serveStdio` for custom serving; unlike `createMcpServer`, validation and the
27
+ * tool snapshot happen at this call, not once per server.
28
+ */
29
+ export declare function createMcpServerFactory(options: CreateMcpServerOptions): () => Server;
30
+ /**
31
+ * Build a single low-level SDK `Server`.
32
+ *
33
+ * @deprecated A directly connected `Server` (`server.connect(transport)`) can
34
+ * only speak the legacy era, which BridgeKit's serving entrypoints no longer
35
+ * support. Use `runMcpStdioServer` or `createMcpHttpHandler`, or for custom
36
+ * serving pass `createMcpServerFactory(options)` to the SDK's
37
+ * `createMcpHandler(factory, { legacy: "reject" })`. Do not wrap this function
38
+ * in a factory: it revalidates and re-snapshots the tools on every call.
39
+ */
23
40
  export declare function createMcpServer(options: CreateMcpServerOptions): Server;
24
- export declare function runMcpStdioServer(options: CreateMcpServerOptions): Promise<void>;
41
+ export interface McpStdioServerHandle {
42
+ /**
43
+ * Close the transport and abort in-flight requests. Safe to call repeatedly.
44
+ * Does not await tool handlers: asynchronous abort cleanup may outlive close().
45
+ * Coordinate shared-resource disposal with your tools before ending pools.
46
+ */
47
+ close(): Promise<void>;
48
+ }
49
+ /**
50
+ * Install modern-era (`2026-07-28`) stdio serving and return a close handle.
51
+ * `2025-11-25` openings are answered with the unsupported-protocol-version
52
+ * error. Resolution means transport wiring is ready, not that a client has
53
+ * connected or shutdown.
54
+ */
55
+ export declare function runMcpStdioServer(options: CreateMcpServerOptions): Promise<McpStdioServerHandle>;
@@ -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 so
34
+ * existing `Type.Intersect` outputs keep their wire shape (the SDK's legacy-era
35
+ * `{ result: ... }` wrap, reachable only through a directly connected
36
+ * deprecated `createMcpServer`, keys purely on the advertised root `type`).
37
+ * Every other root (array, primitive, union) passes through by reference. The
38
+ * 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
@@ -35,6 +52,24 @@ function toMcpObjectSchema(schema) {
35
52
  const ERROR_CODE_NON_OBJECT_PARAMETERS = "BRIDGEKIT_MCP_NON_OBJECT_PARAMETERS";
36
53
  const ERROR_CODE_REF_PARAMETERS = "BRIDGEKIT_MCP_REF_PARAMETERS";
37
54
  const ERROR_CODE_DUPLICATE_TOOL_NAME = "BRIDGEKIT_MCP_DUPLICATE_TOOL_NAME";
55
+ function assertToolIcons(tool) {
56
+ const icons = tool.hostExtras?.mcp?.icons;
57
+ if (icons === undefined)
58
+ return;
59
+ if (!Array.isArray(icons) ||
60
+ icons.some((icon) => typeof icon !== "object" ||
61
+ icon === null ||
62
+ !("src" in icon) ||
63
+ typeof icon.src !== "string" ||
64
+ ("mimeType" in icon && icon.mimeType !== undefined && typeof icon.mimeType !== "string") ||
65
+ ("sizes" in icon &&
66
+ icon.sizes !== undefined &&
67
+ (!Array.isArray(icon.sizes) || icon.sizes.some((size) => typeof size !== "string"))) ||
68
+ ("theme" in icon && icon.theme !== undefined && icon.theme !== "light" && icon.theme !== "dark"))) {
69
+ throwWithCode(`createMcpServer: tool "${tool.name}" has invalid MCP icons; expected an array of icons with string src, ` +
70
+ "optional string mimeType, string[] sizes, and light or dark theme.", "BRIDGEKIT_MCP_INVALID_ICONS");
71
+ }
72
+ }
38
73
  function assertObjectShapedParameters(tools) {
39
74
  for (const tool of tools) {
40
75
  if (!isObjectSchema(tool.parameters)) {
@@ -81,17 +116,32 @@ function assertUniqueToolNames(tools) {
81
116
  seen.add(tool.name);
82
117
  }
83
118
  }
84
- export function createMcpServer(options) {
85
- for (const tool of options.tools)
119
+ /**
120
+ * Validate and snapshot the tools once, then return a factory that builds an
121
+ * isolated SDK `Server` per call. Pass it to the SDK's `createMcpHandler` or
122
+ * `serveStdio` for custom serving; unlike `createMcpServer`, validation and the
123
+ * tool snapshot happen at this call, not once per server.
124
+ */
125
+ export function createMcpServerFactory(options) {
126
+ const { name, version, instructions } = options;
127
+ for (const tool of options.tools) {
86
128
  assertPortableOutputSchema(tool, "createMcpServer", "BRIDGEKIT_MCP");
129
+ assertToolIcons(tool);
130
+ }
87
131
  assertObjectShapedParameters(options.tools);
88
132
  assertUniqueToolNames(options.tools);
89
133
  // Build the dispatch map and the listing payload at construction so
90
134
  // `tools/list` returns a pre-computed array and post-construction mutations
91
135
  // 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) => {
136
+ const entries = options.tools.map((tool) => ({
137
+ tool: { ...tool },
138
+ outputSchema: tool.outputSchema !== undefined ? toMcpOutputSchema(tool.outputSchema) : undefined,
139
+ }));
140
+ const byName = new Map(entries.map((entry) => [entry.tool.name, entry]));
141
+ const mcpTools = entries.map(({ tool, outputSchema }) => {
94
142
  const annotations = tool.hostExtras?.mcp?.annotations;
143
+ const icons = tool.hostExtras?.mcp?.icons;
144
+ const meta = tool.hostExtras?.mcp?._meta;
95
145
  // MCP advisory hints from `hostExtras.mcp.annotations`. Two gates:
96
146
  // 1. `annotations !== undefined` — a tool without hostExtras builds a
97
147
  // Tool entry whose own-property keys are byte-identical to 0.8.x.
@@ -111,45 +161,104 @@ export function createMcpServer(options) {
111
161
  title: tool.title,
112
162
  description: tool.description,
113
163
  inputSchema: toMcpObjectSchema(tool.parameters),
114
- ...(tool.outputSchema !== undefined && { outputSchema: toMcpObjectSchema(tool.outputSchema) }),
164
+ ...(outputSchema !== undefined && { outputSchema }),
115
165
  ...(hasAnnotations ? { annotations: { ...annotations } } : {}),
166
+ ...(icons !== undefined
167
+ ? { icons: icons.map(({ sizes, ...icon }) => ({ ...icon, ...(sizes !== undefined && { sizes: [...sizes] }) })) }
168
+ : {}),
169
+ ...(meta !== undefined ? { _meta: { ...meta } } : {}),
116
170
  };
117
171
  });
118
- const server = new Server({ name: options.name, version: options.version }, {
119
- capabilities: { tools: { listChanged: false } },
120
- ...(options.instructions !== undefined && { instructions: options.instructions }),
121
- });
122
- server.setRequestHandler("tools/list", async () => ({ tools: mcpTools }));
123
- server.setRequestHandler("tools/call", async (request, ctx) => {
124
- const tool = byName.get(request.params.name);
125
- if (!tool) {
126
- return {
127
- content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
128
- isError: true,
129
- };
130
- }
131
- try {
132
- const result = await executePortableTool(tool, request.params.arguments ?? {}, {
133
- host: "mcp",
134
- signal: ctx.mcpReq.signal,
135
- });
136
- return server.projectCallToolResult(toMcpResult(result), tool.outputSchema !== undefined ? toMcpObjectSchema(tool.outputSchema) : undefined);
137
- }
138
- catch (error) {
139
- const message = error instanceof Error ? error.message : String(error);
140
- return {
141
- content: [{ type: "text", text: message }],
142
- isError: true,
143
- };
144
- }
145
- });
146
- return server;
172
+ return () => {
173
+ const server = new Server({ name, version }, {
174
+ capabilities: { tools: { listChanged: false } },
175
+ ...(instructions !== undefined && { instructions }),
176
+ });
177
+ server.setRequestHandler("tools/list", async () => ({ tools: mcpTools }));
178
+ server.setRequestHandler("tools/call", async (request, ctx) => {
179
+ const entry = byName.get(request.params.name);
180
+ if (!entry) {
181
+ return {
182
+ content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
183
+ isError: true,
184
+ };
185
+ }
186
+ // `ctx.progress` is only wired when the client asked for progress by
187
+ // sending `_meta.progressToken`; otherwise the context is byte-identical
188
+ // to the pre-progress shape. `progress` is a monotonic per-call counter
189
+ // (the spec requires it to increase) and `message` is the update text.
190
+ // Sends are skipped once the request is cancelled and awaited before the
191
+ // result goes out, so a client never sees a notification for a token it
192
+ // has already released. A failed send never fails the tool call.
193
+ const progressToken = ctx.mcpReq._meta?.progressToken;
194
+ const pendingNotifications = [];
195
+ let progressCount = 0;
196
+ const progress = progressToken === undefined
197
+ ? undefined
198
+ : (update) => {
199
+ if (ctx.mcpReq.signal.aborted)
200
+ return;
201
+ progressCount += 1;
202
+ pendingNotifications.push(ctx.mcpReq
203
+ .notify({
204
+ method: "notifications/progress",
205
+ params: { progressToken, progress: progressCount, message: update.text },
206
+ })
207
+ .catch(() => undefined));
208
+ };
209
+ try {
210
+ const result = await executePortableTool(entry.tool, request.params.arguments ?? {}, {
211
+ host: "mcp",
212
+ signal: ctx.mcpReq.signal,
213
+ ...(progress !== undefined && { progress }),
214
+ });
215
+ await Promise.all(pendingNotifications);
216
+ return server.projectCallToolResult(toMcpResult(result), entry.outputSchema);
217
+ }
218
+ catch (error) {
219
+ await Promise.all(pendingNotifications);
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ return {
222
+ content: [{ type: "text", text: message }],
223
+ isError: true,
224
+ };
225
+ }
226
+ });
227
+ return server;
228
+ };
229
+ }
230
+ /**
231
+ * Build a single low-level SDK `Server`.
232
+ *
233
+ * @deprecated A directly connected `Server` (`server.connect(transport)`) can
234
+ * only speak the legacy era, which BridgeKit's serving entrypoints no longer
235
+ * support. Use `runMcpStdioServer` or `createMcpHttpHandler`, or for custom
236
+ * serving pass `createMcpServerFactory(options)` to the SDK's
237
+ * `createMcpHandler(factory, { legacy: "reject" })`. Do not wrap this function
238
+ * in a factory: it revalidates and re-snapshots the tools on every call.
239
+ */
240
+ export function createMcpServer(options) {
241
+ return createMcpServerFactory(options)();
147
242
  }
243
+ /**
244
+ * Install modern-era (`2026-07-28`) stdio serving and return a close handle.
245
+ * `2025-11-25` openings are answered with the unsupported-protocol-version
246
+ * error. Resolution means transport wiring is ready, not that a client has
247
+ * connected or shutdown.
248
+ */
148
249
  export async function runMcpStdioServer(options) {
149
- // Validate eagerly, but each discarded probe/connection must own its server:
150
- // a modern probe installs era-specific handlers before it can be discarded.
151
- createMcpServer(options);
152
- serveStdio(() => createMcpServer(options), {
250
+ // Validate eagerly; the SDK asks the factory for a fresh server per
251
+ // discovery probe/connection, so none share handler state.
252
+ const factory = createMcpServerFactory(options);
253
+ const handle = serveStdio(factory, {
254
+ legacy: "reject",
153
255
  onerror: (error) => process.stderr.write(`[bridgekit-mcp] ${error.message}\n`),
154
256
  });
257
+ let closing;
258
+ return {
259
+ close: () => {
260
+ closing ??= handle.close();
261
+ return closing;
262
+ },
263
+ };
155
264
  }
@@ -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 (BridgeKit
10
+ * serves only the modern era) and are wrapped as `{ result: value }` in pi's
11
+ * 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).
@@ -106,6 +118,21 @@ export interface PiHostExtras {
106
118
  * @see PortableToolHostExtras
107
119
  */
108
120
  export interface McpHostExtras {
121
+ /**
122
+ * Tool icons forwarded to tools/list. The array, icon objects, and sizes
123
+ * arrays are copied at server construction. BridgeKit does not fetch icons.
124
+ */
125
+ icons?: readonly {
126
+ src: string;
127
+ mimeType?: string;
128
+ sizes?: readonly string[];
129
+ theme?: "light" | "dark";
130
+ }[];
131
+ /**
132
+ * Opaque MCP tool metadata. Shallow-copied at server construction;
133
+ * nested values remain caller-owned and must be treated as immutable.
134
+ */
135
+ _meta?: Record<string, unknown>;
109
136
  /**
110
137
  * MCP tool annotations attached to `tools/list` entries. Hints clients may
111
138
  * surface to users; do not affect validation or execution.
@@ -153,23 +180,31 @@ export interface PortableToolHostExtras {
153
180
  pi?: PiHostExtras;
154
181
  mcp?: McpHostExtras;
155
182
  }
183
+ /**
184
+ * Object-rooted outputs keep the open-record intersection so handlers may
185
+ * return extra keys beyond the schema; array and primitive roots are used as-is
186
+ * (neither is assignable to `Record<string, unknown>`).
187
+ */
188
+ type SchemaOutput<T> = [T] extends [readonly unknown[]] ? T : [T] extends [object] ? T & Record<string, unknown> : T;
156
189
  /** 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>;
190
+ type SchemaResult<TOutput extends TSchema> = (PortableToolResult<unknown> & {
191
+ structuredContent: SchemaOutput<Exclude<Static<TOutput>, undefined>>;
159
192
  isError?: false;
160
- }) | (PortableToolResult & {
193
+ }) | (PortableToolResult<unknown> & {
161
194
  isError: true;
162
195
  });
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> {
196
+ type CheckedResult<TResult extends PortableToolResult<unknown>, TOutput extends TSchema | undefined> = [
197
+ TOutput
198
+ ] extends [TSchema] ? TResult & SchemaResult<NoInfer<Extract<TOutput, TSchema>>> : TResult;
199
+ export interface PortableTool<TParams extends TSchema = TSchema, TResult extends PortableToolResult<unknown> = PortableToolResult<unknown>, TOutput extends TSchema | undefined = TSchema | undefined> {
167
200
  name: string;
168
201
  title: string;
169
202
  description: string;
170
203
  parameters: TParams;
171
204
  /**
172
- * Object-shaped TypeBox schema for successful structuredContent.
205
+ * TypeBox schema for successful structuredContent. Any JSON Schema root is
206
+ * accepted except a top-level `$ref` (`Type.Ref` / `Type.Cyclic`); objects,
207
+ * object intersections, arrays, primitives and unions are all valid.
173
208
  * Literal isError:true results are exempt. Treat this schema and parameters
174
209
  * as immutable after registration, including replacing either schema object.
175
210
  */
@@ -190,14 +225,14 @@ export declare function definePortableTool<TParams extends TSchema, TOutput exte
190
225
  outputSchema: TOutput;
191
226
  execute: TExecute;
192
227
  }): PortableTool<TParams, Awaited<ReturnType<TExecute>>, TOutput>;
193
- export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult>(tool: PortableTool<TParams, TResult> & {
228
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult<unknown>>(tool: PortableTool<TParams, TResult> & {
194
229
  outputSchema?: undefined;
195
230
  }): PortableTool<TParams, TResult, undefined>;
196
231
  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
232
  outputSchema?: TOutput;
198
233
  execute: TExecute;
199
234
  }): PortableTool<TParams, Awaited<ReturnType<TExecute>>, TOutput>;
200
- export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult, TTool extends {
235
+ export declare function definePortableTool<TParams extends TSchema, TResult extends PortableToolResult<unknown>, TTool extends {
201
236
  outputSchema?: TSchema | undefined;
202
237
  } = PortableTool<TParams, TResult>>(tool: PortableTool<TParams, TResult> & TTool & (TSchema extends NonNullable<NoInfer<TTool>["outputSchema"]> ? unknown : never)): TTool;
203
238
  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); modern MCP clients receive non-object roots as-is. 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); modern MCP clients receive non-object roots as-is. 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;
package/dist/src/mcp.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { type CreateMcpServerOptions, createMcpServer, runMcpStdioServer, } from "./adapters/mcp.js";
1
+ export { type CreateMcpServerOptions, createMcpServer, createMcpServerFactory, type McpStdioServerHandle, runMcpStdioServer, } from "./adapters/mcp.js";
2
+ export { type CreateMcpHttpHandlerOptions, createMcpHttpHandler, type McpHttpHandler, } from "./adapters/mcp-http.js";
package/dist/src/mcp.js CHANGED
@@ -1 +1,2 @@
1
- export { createMcpServer, runMcpStdioServer, } from "./adapters/mcp.js";
1
+ export { createMcpServer, createMcpServerFactory, runMcpStdioServer, } from "./adapters/mcp.js";
2
+ export { createMcpHttpHandler, } from "./adapters/mcp-http.js";
@@ -270,10 +270,15 @@ 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.
275
- - The stdio runner serves legacy and modern `2026-07-28` clients through SDK v2.
276
- It retains `Promise<void>` startup behavior; stdin EOF shuts down the connection.
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 reach MCP clients as-is and are wrapped as `{ result }` on pi `details`.
276
+ - `ctx.progress?.(...)` becomes `notifications/progress` when the client sent a
277
+ `progressToken`; otherwise `ctx.progress` is `undefined`.
278
+ - The stdio runner serves modern `2026-07-28` clients through SDK v2; legacy-era clients (`2025-11-25` and earlier) are rejected.
279
+ It returns `Promise<McpStdioServerHandle>` after wiring is installed; stdin EOF or
280
+ `await handle.close()` shuts down the connection and aborts in-flight requests.
281
+ `close()` does not await asynchronous tool cleanup.
277
282
  - The module stays import-passive and testable: tests can import `createMcpServerOptions()` without starting stdio.
278
283
 
279
284
  ---
@@ -418,5 +423,5 @@ For publishable tool packages:
418
423
  - 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
424
  - 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
425
  - 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.
426
+ - 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
427
  - Keep imports side-effect free; registration and server startup should happen only in explicit entrypoints.
package/llms.txt CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  isValidationFailure,
20
20
  } from "@feniix/bridgekit";
21
21
  import { registerPiTools } from "@feniix/bridgekit/pi";
22
- import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp";
22
+ import { createMcpHttpHandler, runMcpStdioServer } from "@feniix/bridgekit/mcp";
23
23
  import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; // optional: built-in bin wrapper for mixed source-loaded pi + compiled MCP packages
24
24
  ```
25
25
 
@@ -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
+ passed as-is to MCP clients and wrapped as `{ result }` 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.
@@ -96,12 +100,42 @@ export function createTools() {
96
100
  Their runtime predicates are shape-based, so Pi structured failures also
97
101
  match; that does not make a raw Pi result a valid portable typed value.
98
102
  - MCP uses SDK v2's low-level `Server` and method-string handlers. Cancellation
99
- reads `ctx.mcpReq.signal`. `runMcpStdioServer` serves legacy and modern
100
- `2026-07-28` stdio; direct `Server.connect` remains legacy-only. The runner's
101
- `Promise<void>` resolves at startup, not EOF. Migration details:
103
+ reads `ctx.mcpReq.signal`. `runMcpStdioServer` serves modern
104
+ `2026-07-28` stdio only (legacy-era openings are rejected with `-32022`);
105
+ `createMcpServer` is deprecated as a direct `Server.connect` target (legacy-only). The runner's
106
+ `Promise<McpStdioServerHandle>` resolves at startup, not EOF; call the
107
+ handle's idempotent `close()` to tear down explicitly. Migration details:
102
108
  `docs/mcp-v2-migration.md`.
109
+ - `createMcpHttpHandler(options, httpOptions?)` returns the SDK's Web-standard
110
+ HTTP handler (`fetch`, `close`, `notify`, `bus`), modern era only.
111
+ It starts no listener. Applications must enforce authentication, authorization,
112
+ Host/Origin validation, TLS, and CORS before calling `fetch`.
103
113
  - `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.
104
- - `PortableTool.hostExtras` (0.9+) carries optional per-host metadata in namespaced sub-objects (`pi`, `mcp`, plus module-augmentable custom hosts). Each field is optional and zero-cost when absent — a tool without `hostExtras` produces byte-identical pi registration and MCP `tools/list` payloads to 0.8.x. `hostExtras.pi.pendingMessage` fires `onUpdate({ content, details: { status: "pending" } })` exactly once before TypeBox validation; `hostExtras.pi.promptSnippet` / `promptGuidelines` pass through to pi's `registerTool`; `hostExtras.pi.renderCall` / `renderResult` are opaque TUI callbacks typed by exported `PiToolCallRenderer` / `PiToolResultRenderer` aliases and forwarded by identity; `hostExtras.mcp.annotations` (`title` / `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`) attaches MCP advisory hints. Tool definitions stay host-neutral — host-specific metadata lives in its namespace, not on the tool's top-level fields.
114
+ - `PortableTool.hostExtras` (0.9+) carries optional per-host metadata in namespaced sub-objects (`pi`, `mcp`, plus module-augmentable custom hosts). Each field is optional and zero-cost when absent — a tool without `hostExtras` produces byte-identical pi registration and MCP `tools/list` payloads to 0.8.x. `hostExtras.pi.pendingMessage` fires `onUpdate({ content, details: { status: "pending" } })` exactly once before TypeBox validation; `hostExtras.pi.promptSnippet` / `promptGuidelines` pass through to pi's `registerTool`; `hostExtras.pi.renderCall` / `renderResult` are opaque TUI callbacks typed by exported `PiToolCallRenderer` / `PiToolResultRenderer` aliases and forwarded by identity; `hostExtras.mcp.annotations` (`title` / `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`) attaches MCP advisory hints. `hostExtras.mcp.icons` carries `src`, optional `mimeType`, `sizes`, and `theme` (`light`/`dark`); malformed icons fail at construction with `BRIDGEKIT_MCP_INVALID_ICONS`. `hostExtras.mcp._meta` carries opaque MCP metadata. Both serving factories snapshot these fields once at construction: icons and sizes are copied, `_meta` is shallow-copied (nested values remain shared). Empty icons/`_meta` are forwarded, while empty annotations are omitted. Tool definitions stay host-neutral — host-specific metadata lives in its namespace, not on the tool's top-level fields.
115
+
116
+ ### Migrating from dual-era MCP serving
117
+
118
+ BridgeKit serves only the modern MCP `2026-07-28` era. Legacy-era openings
119
+ (`2025-11-25` and earlier) are rejected with `-32022`
120
+ (`data.supported: ["2026-07-28"]`); stdio stays open for a modern opening.
121
+
122
+ 1. Pin clients: the SDK v2 client defaults to `versionNegotiation.mode: "legacy"`
123
+ and is rejected. Use `{ mode: { pin: "2026-07-28" } }` or `"auto"`.
124
+ 2. SDK v1 clients and v2 clients in `legacy` mode can no longer connect; there is
125
+ no compatibility switch.
126
+ 3. Replace `createMcpServer(options)` + `server.connect(transport)` with
127
+ `runMcpStdioServer(options)` or `createMcpHttpHandler(options, httpOptions?)`.
128
+ `createMcpServer` is deprecated for direct connection; for custom serving pass
129
+ `createMcpServerFactory(options)` to the SDK's `createMcpHandler(factory,
130
+ { legacy: "reject" })`. Do not wrap `createMcpServer` in a factory: it
131
+ revalidates and re-snapshots tools on every call.
132
+ 4. Remove `legacy` from HTTP options (removed from the type, ignored at runtime).
133
+ 5. Non-object structured output reaches clients as-is, not `{ result }`-wrapped.
134
+ 6. Each rejected request logs one `[bridgekit-mcp]` stderr line by default. The
135
+ HTTP handler's `onerror` option overrides it; `runMcpStdioServer` has no
136
+ reporter option and always writes to stderr.
137
+
138
+ Details: `docs/mcp-v2-migration.md` ("Dropping legacy-era serving").
105
139
 
106
140
  ### Result-guard usage
107
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",
@@ -75,10 +75,11 @@
75
75
  "devDependencies": {
76
76
  "@biomejs/biome": "2.5.15",
77
77
  "@modelcontextprotocol/client": "^2.3.1",
78
- "@modelcontextprotocol/sdk": "^1.30.1",
78
+ "@modelcontextprotocol/node": "2.1.1",
79
79
  "@total-typescript/shoehorn": "^0.1.2",
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
  }