@feniix/bridgekit 0.10.0 → 0.11.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
@@ -4,7 +4,36 @@ All notable changes to `@feniix/bridgekit` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [0.10.0] - Unreleased
7
+ ## [0.11.0] - 2026-05-27
8
+
9
+ ### Added
10
+
11
+ - New subpath export `@feniix/bridgekit/bin-wrapper` shipping
12
+ `runBinWrapper({ metaUrl, mcpEntry, buildScript, ... })`. Eliminates
13
+ the ~25-line "resolve dist path → spawn build if missing → import
14
+ and run" boilerplate that mixed source-loaded pi + compiled MCP
15
+ packages have been copy-pasting. Three downstream consumers
16
+ (pi-sequential-thinking, pi-exa, pi-code-reasoning) carry hand-rolled
17
+ versions today and can migrate in one-line replacements. Tested
18
+ against four canonical scenarios (entry present; entry built on
19
+ demand; build fails non-zero; build exits 0 with file still missing)
20
+ plus a negative for entry modules missing the required `runServer`
21
+ export. Resolves
22
+ [#6](https://github.com/feniix/bridgekit/issues/6).
23
+
24
+ ### Changed
25
+
26
+ - `signalFromExtra` (internal helper at `src/adapters/mcp-signal.ts`) removed.
27
+ The MCP SDK exposes `RequestHandlerExtra<ServerRequest, ServerNotification>`
28
+ with a guaranteed non-optional `signal: AbortSignal` field; the duck-typing
29
+ helper existed only because the contract was undocumented. `createMcpServer`'s
30
+ `tools/call` handler now reads `extra.signal` directly with a typed `extra`
31
+ parameter. No behavior change; cancellation propagation is unchanged. An
32
+ adversarial type-level pin in `src/adapters/mcp.typecheck.ts` fails closed
33
+ if the SDK ever changes `signal`'s type. Resolves
34
+ [#3](https://github.com/feniix/bridgekit/issues/3).
35
+
36
+ ## [0.10.0] - 2026-05-27
8
37
 
9
38
  ### Removed (breaking)
10
39
 
package/README.md CHANGED
@@ -85,11 +85,13 @@ import {
85
85
  } from "@feniix/bridgekit";
86
86
  import { registerPiTools } from "@feniix/bridgekit/pi";
87
87
  import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp";
88
+ import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper";
88
89
  ```
89
90
 
90
91
  - Root entrypoint: host-neutral tool definitions, validation, and execution helpers.
91
92
  - `/pi`: pi adapter only.
92
93
  - `/mcp`: MCP server adapter only.
94
+ - `/bin-wrapper`: optional helper for npm `bin` scripts that need to build a local compiled MCP entry on first invocation.
93
95
 
94
96
  Do not deep-import from `dist/` or `src/` in consuming packages.
95
97
 
@@ -2,7 +2,6 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { executePortableTool } from "../core/execute-tool.js";
5
- import { signalFromExtra } from "./mcp-signal.js";
6
5
  function toMcpResult(result) {
7
6
  return {
8
7
  content: [{ type: "text", text: result.text }],
@@ -197,7 +196,7 @@ export function createMcpServer(options) {
197
196
  try {
198
197
  const result = await executePortableTool(tool, request.params.arguments ?? {}, {
199
198
  host: "mcp",
200
- signal: signalFromExtra(extra),
199
+ signal: extra.signal,
201
200
  });
202
201
  return toMcpResult(result);
203
202
  }
@@ -0,0 +1,39 @@
1
+ import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";
2
+ export type BinWrapperSpawnSync = (command: string, args: readonly string[], options: SpawnSyncOptions) => SpawnSyncReturns<Buffer>;
3
+ export interface BinWrapperOptions {
4
+ /**
5
+ * `import.meta.url` of the bin script. Used to locate the package root
6
+ * (the bin's parent directory). Passing the helper's own `import.meta.url`
7
+ * would resolve the wrong package, which is why the caller must supply this.
8
+ */
9
+ metaUrl: string;
10
+ /**
11
+ * Path to the compiled MCP entry, relative to the package root.
12
+ *
13
+ * Must be a literal in the caller's source. The helper does not contain
14
+ * arbitrary paths — sourcing this from CLI args or env vars exposes the
15
+ * dynamic `import()` to attacker-controlled file paths.
16
+ *
17
+ * @example "dist/extensions/mcp-server.js"
18
+ */
19
+ mcpEntry: string;
20
+ /**
21
+ * npm script to invoke when the entry is missing.
22
+ *
23
+ * Must be a literal in the caller's source. On Windows the helper sets
24
+ * `shell: true` for `spawnSync`, which makes `&`, `|`, and `^` shell
25
+ * metacharacters; sourcing this from CLI args or env vars opens a
26
+ * command-injection vector on Windows.
27
+ *
28
+ * @example "build:mcp"
29
+ */
30
+ buildScript: string;
31
+ buildTimeoutMs?: number;
32
+ logPrefix?: string;
33
+ }
34
+ export interface BinWrapperDeps {
35
+ spawnSync: BinWrapperSpawnSync;
36
+ exit: (code: number) => never;
37
+ }
38
+ export declare const defaultBinWrapperDeps: BinWrapperDeps;
39
+ export declare function runBinWrapperWithDeps(options: BinWrapperOptions, deps: BinWrapperDeps): Promise<void>;
@@ -0,0 +1,55 @@
1
+ // Internal implementation of bin-wrapper. Ships in the published tarball
2
+ // (the public `bin-wrapper.js` imports from it at runtime) but is not
3
+ // reachable via package.json#exports — deep-importing this module by name
4
+ // fails with ERR_PACKAGE_PATH_NOT_EXPORTED (pinned by scripts/smoke-package.mjs
5
+ // under inv-deep-imports-fail).
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ const DEFAULT_BUILD_TIMEOUT_MS = 60_000;
11
+ const DEFAULT_LOG_PREFIX = "bridgekit-bin";
12
+ export const defaultBinWrapperDeps = {
13
+ spawnSync,
14
+ exit: process.exit,
15
+ };
16
+ export async function runBinWrapperWithDeps(options, deps) {
17
+ const packageRoot = resolve(dirname(fileURLToPath(options.metaUrl)), "..");
18
+ const entryPath = join(packageRoot, options.mcpEntry);
19
+ if (!existsSync(entryPath)) {
20
+ const timeoutMs = options.buildTimeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS;
21
+ const build = deps.spawnSync("npm", ["run", options.buildScript, "--silent"], {
22
+ cwd: packageRoot,
23
+ stdio: "inherit",
24
+ shell: process.platform === "win32",
25
+ timeout: timeoutMs,
26
+ });
27
+ // File presence is the load-bearing signal: a build that exits non-zero
28
+ // *after* emitting the entry (e.g. tsc with a post-emit diagnostic, or a
29
+ // build pipeline whose final step is non-fatal lint/test) is still
30
+ // recoverable. Only bail if the artifact we need is actually missing.
31
+ if (!existsSync(entryPath)) {
32
+ const prefix = options.logPrefix ?? DEFAULT_LOG_PREFIX;
33
+ // A child killed by the timeout returns status: null + signal set.
34
+ // Distinguish that from a build error so the operator sees the actual
35
+ // cause and knows about buildTimeoutMs.
36
+ if (build.signal !== null) {
37
+ console.error(`[${prefix}] Build timed out after ${timeoutMs}ms (signal ${build.signal}). ` +
38
+ `Raise buildTimeoutMs or run \`npm run ${options.buildScript}\` manually.`);
39
+ deps.exit(1);
40
+ }
41
+ else {
42
+ console.error(`[${prefix}] Failed to build the local MCP server. Run \`npm run ${options.buildScript}\` and try again.`);
43
+ // Propagate the build's non-zero status if present; fall back to 1
44
+ // when status===0 with missing entry or status===null without signal.
45
+ deps.exit(build.status !== null && build.status !== 0 ? build.status : 1);
46
+ }
47
+ }
48
+ }
49
+ const mod = (await import(pathToFileURL(entryPath).href));
50
+ if (typeof mod.runServer !== "function") {
51
+ throw new Error(`[bridgekit/bin-wrapper] entry "${options.mcpEntry}" does not export a runServer() function. ` +
52
+ `Make sure your MCP server module exports \`export async function runServer()\` at the top level.`);
53
+ }
54
+ await mod.runServer();
55
+ }
@@ -0,0 +1,25 @@
1
+ import { type BinWrapperOptions } from "./bin-wrapper-internal.js";
2
+ export type { BinWrapperOptions } from "./bin-wrapper-internal.js";
3
+ /**
4
+ * Helper for an npm `bin` script that needs to load a compiled MCP entrypoint
5
+ * and build it on first invocation (workspace/local execution) when the
6
+ * compiled output is missing.
7
+ *
8
+ * Replaces the ~25-line "resolve dist path -> spawn build if missing ->
9
+ * import and run" boilerplate shipped in `examples/README.md` since 0.4.x.
10
+ *
11
+ * The MCP entry module must export `runServer(): Promise<void>`. Consumers
12
+ * with a different name can re-export under that alias.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * #!/usr/bin/env node
17
+ * import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper";
18
+ * await runBinWrapper({
19
+ * metaUrl: import.meta.url,
20
+ * mcpEntry: "dist/extensions/mcp-server.js",
21
+ * buildScript: "build:mcp",
22
+ * });
23
+ * ```
24
+ */
25
+ export declare function runBinWrapper(options: BinWrapperOptions): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { defaultBinWrapperDeps, runBinWrapperWithDeps } from "./bin-wrapper-internal.js";
2
+ /**
3
+ * Helper for an npm `bin` script that needs to load a compiled MCP entrypoint
4
+ * and build it on first invocation (workspace/local execution) when the
5
+ * compiled output is missing.
6
+ *
7
+ * Replaces the ~25-line "resolve dist path -> spawn build if missing ->
8
+ * import and run" boilerplate shipped in `examples/README.md` since 0.4.x.
9
+ *
10
+ * The MCP entry module must export `runServer(): Promise<void>`. Consumers
11
+ * with a different name can re-export under that alias.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * #!/usr/bin/env node
16
+ * import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper";
17
+ * await runBinWrapper({
18
+ * metaUrl: import.meta.url,
19
+ * mcpEntry: "dist/extensions/mcp-server.js",
20
+ * buildScript: "build:mcp",
21
+ * });
22
+ * ```
23
+ */
24
+ export async function runBinWrapper(options) {
25
+ return runBinWrapperWithDeps(options, defaultBinWrapperDeps);
26
+ }
@@ -241,36 +241,23 @@ For mixed source-loaded pi + compiled MCP packages, keep the pi source entrypoin
241
241
  }
242
242
  ```
243
243
 
244
- The wrapper should resolve the generated MCP server relative to the installed package, build it when missing in local/workspace execution, and preserve build failures:
244
+ The wrapper should resolve the generated MCP server relative to the installed package, build it when missing in local/workspace execution, and preserve build failures. BridgeKit ships this as a built-in helper under the `/bin-wrapper` subpath:
245
245
 
246
246
  ```js
247
247
  #!/usr/bin/env node
248
- import { existsSync } from "node:fs";
249
- import { spawnSync } from "node:child_process";
250
- import { dirname, join, resolve } from "node:path";
251
- import { fileURLToPath, pathToFileURL } from "node:url";
252
-
253
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
254
- const serverPath = join(packageRoot, "dist", "extensions", "mcp-server.js");
255
-
256
- if (!existsSync(serverPath)) {
257
- const build = spawnSync("npm", ["run", "build:mcp", "--silent"], {
258
- cwd: packageRoot,
259
- stdio: "inherit",
260
- shell: process.platform === "win32",
261
- timeout: 60_000,
262
- });
263
-
264
- if (build.status !== 0 || !existsSync(serverPath)) {
265
- console.error("[my-tools] Failed to build the local MCP server. Run `npm run build:mcp` and try again.");
266
- process.exit(build.status && build.status !== 0 ? build.status : 1);
267
- }
268
- }
248
+ import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper";
269
249
 
270
- const { runServer } = await import(pathToFileURL(serverPath).href);
271
- await runServer();
250
+ await runBinWrapper({
251
+ metaUrl: import.meta.url,
252
+ mcpEntry: "dist/extensions/mcp-server.js",
253
+ buildScript: "build:mcp",
254
+ });
272
255
  ```
273
256
 
257
+ `mcpEntry` and `buildScript` are the two options consumers typically vary. The MCP entry module must export `runServer(): Promise<void>` (the convention used throughout these examples). `buildTimeoutMs` (default `60_000`) and `logPrefix` (default `"bridgekit-bin"`) are available for tuning but rarely needed.
258
+
259
+ Both `mcpEntry` and `buildScript` must be **literal strings** in your bin source. The helper joins `mcpEntry` onto the resolved package root and dynamically `import()`s it, and it passes `buildScript` to `spawnSync` (with `shell: true` on Windows where `&`, `|`, and `^` are shell metacharacters). Sourcing either from CLI args, environment variables, or other runtime input exposes arbitrary-file import and Windows command injection — the trusted-literal expectation is the threat model.
260
+
274
261
  Commit the wrapper with executable mode (`chmod +x bin/my-tools-mcp.js`) and verify `npm pack --dry-run --json` includes it with executable mode.
275
262
 
276
263
  MCP behavior:
package/llms.txt CHANGED
@@ -20,11 +20,13 @@ import {
20
20
  } from "@feniix/bridgekit";
21
21
  import { registerPiTools } from "@feniix/bridgekit/pi";
22
22
  import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp";
23
+ import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; // optional: built-in bin wrapper for mixed source-loaded pi + compiled MCP packages
23
24
  ```
24
25
 
25
26
  - Root entrypoint: host-neutral tool definitions, validation, and execution helpers.
26
27
  - `/pi`: pi adapter only.
27
28
  - `/mcp`: MCP server adapter only.
29
+ - `/bin-wrapper`: optional helper for npm `bin` scripts that build a local compiled MCP entry on first invocation. `mcpEntry` and `buildScript` must be literal strings in the caller's source (they're the dynamic-import target and the `spawnSync` arg; sourcing them from env or CLI is an arbitrary-file-import and Windows command-injection vector).
28
30
 
29
31
  Do not deep-import from `dist/` or `src/` in consumer code. Reading published declarations for documentation is fine; imports should use the package entrypoints above.
30
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -37,6 +37,10 @@
37
37
  "types": "./dist/src/mcp.d.ts",
38
38
  "import": "./dist/src/mcp.js"
39
39
  },
40
+ "./bin-wrapper": {
41
+ "types": "./dist/src/bin-wrapper.d.ts",
42
+ "import": "./dist/src/bin-wrapper.js"
43
+ },
40
44
  "./package.json": "./package.json"
41
45
  },
42
46
  "scripts": {
@@ -1,9 +0,0 @@
1
- /**
2
- * Defensively pulls an `AbortSignal` out of MCP request `extra`. The MCP SDK
3
- * does not formally type a `signal` on `extra`, so the shape is sniffed
4
- * structurally.
5
- *
6
- * Validated against `@modelcontextprotocol/sdk` v1.x. Revalidate on any SDK
7
- * major bump in case the cancellation channel moves or gains a typed surface.
8
- */
9
- export declare function signalFromExtra(extra: unknown): AbortSignal | undefined;
@@ -1,15 +0,0 @@
1
- /**
2
- * Defensively pulls an `AbortSignal` out of MCP request `extra`. The MCP SDK
3
- * does not formally type a `signal` on `extra`, so the shape is sniffed
4
- * structurally.
5
- *
6
- * Validated against `@modelcontextprotocol/sdk` v1.x. Revalidate on any SDK
7
- * major bump in case the cancellation channel moves or gains a typed surface.
8
- */
9
- export function signalFromExtra(extra) {
10
- if (!extra || typeof extra !== "object" || !("signal" in extra)) {
11
- return undefined;
12
- }
13
- const signal = extra.signal;
14
- return signal instanceof AbortSignal ? signal : undefined;
15
- }