@feniix/bridgekit
Advanced tools
| import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process"; | ||
| export type BinWrapperSpawnSync = (command: string, args: readonly string[], options: SpawnSyncOptions) => SpawnSyncReturns<Buffer>; | ||
| export interface BinWrapperOptions { | ||
| /** | ||
| * `import.meta.url` of the bin script. Used to locate the package root | ||
| * (the bin's parent directory). Passing the helper's own `import.meta.url` | ||
| * would resolve the wrong package, which is why the caller must supply this. | ||
| */ | ||
| metaUrl: string; | ||
| /** | ||
| * Path to the compiled MCP entry, relative to the package root. | ||
| * | ||
| * Must be a literal in the caller's source. The helper does not contain | ||
| * arbitrary paths — sourcing this from CLI args or env vars exposes the | ||
| * dynamic `import()` to attacker-controlled file paths. | ||
| * | ||
| * @example "dist/extensions/mcp-server.js" | ||
| */ | ||
| mcpEntry: string; | ||
| /** | ||
| * npm script to invoke when the entry is missing. | ||
| * | ||
| * Must be a literal in the caller's source. On Windows the helper sets | ||
| * `shell: true` for `spawnSync`, which makes `&`, `|`, and `^` shell | ||
| * metacharacters; sourcing this from CLI args or env vars opens a | ||
| * command-injection vector on Windows. | ||
| * | ||
| * @example "build:mcp" | ||
| */ | ||
| buildScript: string; | ||
| buildTimeoutMs?: number; | ||
| logPrefix?: string; | ||
| } | ||
| export interface BinWrapperDeps { | ||
| spawnSync: BinWrapperSpawnSync; | ||
| exit: (code: number) => never; | ||
| } | ||
| export declare const defaultBinWrapperDeps: BinWrapperDeps; | ||
| export declare function runBinWrapperWithDeps(options: BinWrapperOptions, deps: BinWrapperDeps): Promise<void>; |
| // Internal implementation of bin-wrapper. Ships in the published tarball | ||
| // (the public `bin-wrapper.js` imports from it at runtime) but is not | ||
| // reachable via package.json#exports — deep-importing this module by name | ||
| // fails with ERR_PACKAGE_PATH_NOT_EXPORTED (pinned by scripts/smoke-package.mjs | ||
| // under inv-deep-imports-fail). | ||
| import { spawnSync } from "node:child_process"; | ||
| import { existsSync } from "node:fs"; | ||
| import { dirname, join, resolve } from "node:path"; | ||
| import { fileURLToPath, pathToFileURL } from "node:url"; | ||
| const DEFAULT_BUILD_TIMEOUT_MS = 60_000; | ||
| const DEFAULT_LOG_PREFIX = "bridgekit-bin"; | ||
| export const defaultBinWrapperDeps = { | ||
| spawnSync, | ||
| exit: process.exit, | ||
| }; | ||
| export async function runBinWrapperWithDeps(options, deps) { | ||
| const packageRoot = resolve(dirname(fileURLToPath(options.metaUrl)), ".."); | ||
| const entryPath = join(packageRoot, options.mcpEntry); | ||
| if (!existsSync(entryPath)) { | ||
| const timeoutMs = options.buildTimeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS; | ||
| const build = deps.spawnSync("npm", ["run", options.buildScript, "--silent"], { | ||
| cwd: packageRoot, | ||
| stdio: "inherit", | ||
| shell: process.platform === "win32", | ||
| timeout: timeoutMs, | ||
| }); | ||
| // File presence is the load-bearing signal: a build that exits non-zero | ||
| // *after* emitting the entry (e.g. tsc with a post-emit diagnostic, or a | ||
| // build pipeline whose final step is non-fatal lint/test) is still | ||
| // recoverable. Only bail if the artifact we need is actually missing. | ||
| if (!existsSync(entryPath)) { | ||
| const prefix = options.logPrefix ?? DEFAULT_LOG_PREFIX; | ||
| // A child killed by the timeout returns status: null + signal set. | ||
| // Distinguish that from a build error so the operator sees the actual | ||
| // cause and knows about buildTimeoutMs. | ||
| if (build.signal !== null) { | ||
| console.error(`[${prefix}] Build timed out after ${timeoutMs}ms (signal ${build.signal}). ` + | ||
| `Raise buildTimeoutMs or run \`npm run ${options.buildScript}\` manually.`); | ||
| deps.exit(1); | ||
| } | ||
| else { | ||
| console.error(`[${prefix}] Failed to build the local MCP server. Run \`npm run ${options.buildScript}\` and try again.`); | ||
| // Propagate the build's non-zero status if present; fall back to 1 | ||
| // when status===0 with missing entry or status===null without signal. | ||
| deps.exit(build.status !== null && build.status !== 0 ? build.status : 1); | ||
| } | ||
| } | ||
| } | ||
| const mod = (await import(pathToFileURL(entryPath).href)); | ||
| if (typeof mod.runServer !== "function") { | ||
| throw new Error(`[bridgekit/bin-wrapper] entry "${options.mcpEntry}" does not export a runServer() function. ` + | ||
| `Make sure your MCP server module exports \`export async function runServer()\` at the top level.`); | ||
| } | ||
| await mod.runServer(); | ||
| } |
| import { type BinWrapperOptions } from "./bin-wrapper-internal.js"; | ||
| export type { BinWrapperOptions } from "./bin-wrapper-internal.js"; | ||
| /** | ||
| * Helper for an npm `bin` script that needs to load a compiled MCP entrypoint | ||
| * and build it on first invocation (workspace/local execution) when the | ||
| * compiled output is missing. | ||
| * | ||
| * Replaces the ~25-line "resolve dist path -> spawn build if missing -> | ||
| * import and run" boilerplate shipped in `examples/README.md` since 0.4.x. | ||
| * | ||
| * The MCP entry module must export `runServer(): Promise<void>`. Consumers | ||
| * with a different name can re-export under that alias. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * #!/usr/bin/env node | ||
| * import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; | ||
| * await runBinWrapper({ | ||
| * metaUrl: import.meta.url, | ||
| * mcpEntry: "dist/extensions/mcp-server.js", | ||
| * buildScript: "build:mcp", | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export declare function runBinWrapper(options: BinWrapperOptions): Promise<void>; |
| import { defaultBinWrapperDeps, runBinWrapperWithDeps } from "./bin-wrapper-internal.js"; | ||
| /** | ||
| * Helper for an npm `bin` script that needs to load a compiled MCP entrypoint | ||
| * and build it on first invocation (workspace/local execution) when the | ||
| * compiled output is missing. | ||
| * | ||
| * Replaces the ~25-line "resolve dist path -> spawn build if missing -> | ||
| * import and run" boilerplate shipped in `examples/README.md` since 0.4.x. | ||
| * | ||
| * The MCP entry module must export `runServer(): Promise<void>`. Consumers | ||
| * with a different name can re-export under that alias. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * #!/usr/bin/env node | ||
| * import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; | ||
| * await runBinWrapper({ | ||
| * metaUrl: import.meta.url, | ||
| * mcpEntry: "dist/extensions/mcp-server.js", | ||
| * buildScript: "build:mcp", | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export async function runBinWrapper(options) { | ||
| return runBinWrapperWithDeps(options, defaultBinWrapperDeps); | ||
| } |
+30
-1
@@ -7,4 +7,33 @@ # Changelog | ||
| ## [0.10.0] - Unreleased | ||
| ## [0.11.0] - 2026-05-27 | ||
| ### Added | ||
| - New subpath export `@feniix/bridgekit/bin-wrapper` shipping | ||
| `runBinWrapper({ metaUrl, mcpEntry, buildScript, ... })`. Eliminates | ||
| the ~25-line "resolve dist path → spawn build if missing → import | ||
| and run" boilerplate that mixed source-loaded pi + compiled MCP | ||
| packages have been copy-pasting. Three downstream consumers | ||
| (pi-sequential-thinking, pi-exa, pi-code-reasoning) carry hand-rolled | ||
| versions today and can migrate in one-line replacements. Tested | ||
| against four canonical scenarios (entry present; entry built on | ||
| demand; build fails non-zero; build exits 0 with file still missing) | ||
| plus a negative for entry modules missing the required `runServer` | ||
| export. Resolves | ||
| [#6](https://github.com/feniix/bridgekit/issues/6). | ||
| ### Changed | ||
| - `signalFromExtra` (internal helper at `src/adapters/mcp-signal.ts`) removed. | ||
| The MCP SDK exposes `RequestHandlerExtra<ServerRequest, ServerNotification>` | ||
| with a guaranteed non-optional `signal: AbortSignal` field; the duck-typing | ||
| helper existed only because the contract was undocumented. `createMcpServer`'s | ||
| `tools/call` handler now reads `extra.signal` directly with a typed `extra` | ||
| parameter. No behavior change; cancellation propagation is unchanged. An | ||
| adversarial type-level pin in `src/adapters/mcp.typecheck.ts` fails closed | ||
| if the SDK ever changes `signal`'s type. Resolves | ||
| [#3](https://github.com/feniix/bridgekit/issues/3). | ||
| ## [0.10.0] - 2026-05-27 | ||
| ### Removed (breaking) | ||
@@ -11,0 +40,0 @@ |
@@ -5,3 +5,2 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { executePortableTool } from "../core/execute-tool.js"; | ||
| import { signalFromExtra } from "./mcp-signal.js"; | ||
| function toMcpResult(result) { | ||
@@ -201,3 +200,3 @@ return { | ||
| host: "mcp", | ||
| signal: signalFromExtra(extra), | ||
| signal: extra.signal, | ||
| }); | ||
@@ -204,0 +203,0 @@ return toMcpResult(result); |
+10
-23
@@ -244,32 +244,19 @@ # BridgeKit examples | ||
| 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: | ||
| 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: | ||
| ```js | ||
| #!/usr/bin/env node | ||
| import { existsSync } from "node:fs"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { dirname, join, resolve } from "node:path"; | ||
| import { fileURLToPath, pathToFileURL } from "node:url"; | ||
| import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; | ||
| const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); | ||
| const serverPath = join(packageRoot, "dist", "extensions", "mcp-server.js"); | ||
| await runBinWrapper({ | ||
| metaUrl: import.meta.url, | ||
| mcpEntry: "dist/extensions/mcp-server.js", | ||
| buildScript: "build:mcp", | ||
| }); | ||
| ``` | ||
| if (!existsSync(serverPath)) { | ||
| const build = spawnSync("npm", ["run", "build:mcp", "--silent"], { | ||
| cwd: packageRoot, | ||
| stdio: "inherit", | ||
| shell: process.platform === "win32", | ||
| timeout: 60_000, | ||
| }); | ||
| `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. | ||
| if (build.status !== 0 || !existsSync(serverPath)) { | ||
| console.error("[my-tools] Failed to build the local MCP server. Run `npm run build:mcp` and try again."); | ||
| process.exit(build.status && build.status !== 0 ? build.status : 1); | ||
| } | ||
| } | ||
| 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. | ||
| const { runServer } = await import(pathToFileURL(serverPath).href); | ||
| await runServer(); | ||
| ``` | ||
| 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. | ||
@@ -276,0 +263,0 @@ |
+2
-0
@@ -23,2 +23,3 @@ # @feniix/bridgekit | ||
| import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp"; | ||
| import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; // optional: built-in bin wrapper for mixed source-loaded pi + compiled MCP packages | ||
| ``` | ||
@@ -29,2 +30,3 @@ | ||
| - `/mcp`: MCP server adapter only. | ||
| - `/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). | ||
@@ -31,0 +33,0 @@ 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. |
+5
-1
| { | ||
| "name": "@feniix/bridgekit", | ||
| "version": "0.10.0", | ||
| "version": "0.11.0", | ||
| "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.", | ||
@@ -40,2 +40,6 @@ "keywords": [ | ||
| }, | ||
| "./bin-wrapper": { | ||
| "types": "./dist/src/bin-wrapper.d.ts", | ||
| "import": "./dist/src/bin-wrapper.js" | ||
| }, | ||
| "./package.json": "./package.json" | ||
@@ -42,0 +46,0 @@ }, |
+2
-0
@@ -88,2 +88,3 @@ # BridgeKit | ||
| import { createMcpServer, runMcpStdioServer } from "@feniix/bridgekit/mcp"; | ||
| import { runBinWrapper } from "@feniix/bridgekit/bin-wrapper"; | ||
| ``` | ||
@@ -94,2 +95,3 @@ | ||
| - `/mcp`: MCP server adapter only. | ||
| - `/bin-wrapper`: optional helper for npm `bin` scripts that need to build a local compiled MCP entry on first invocation. | ||
@@ -96,0 +98,0 @@ Do not deep-import from `dist/` or `src/` in consuming packages. |
| /** | ||
| * Defensively pulls an `AbortSignal` out of MCP request `extra`. The MCP SDK | ||
| * does not formally type a `signal` on `extra`, so the shape is sniffed | ||
| * structurally. | ||
| * | ||
| * Validated against `@modelcontextprotocol/sdk` v1.x. Revalidate on any SDK | ||
| * major bump in case the cancellation channel moves or gains a typed surface. | ||
| */ | ||
| export declare function signalFromExtra(extra: unknown): AbortSignal | undefined; |
| /** | ||
| * Defensively pulls an `AbortSignal` out of MCP request `extra`. The MCP SDK | ||
| * does not formally type a `signal` on `extra`, so the shape is sniffed | ||
| * structurally. | ||
| * | ||
| * Validated against `@modelcontextprotocol/sdk` v1.x. Revalidate on any SDK | ||
| * major bump in case the cancellation channel moves or gains a typed surface. | ||
| */ | ||
| export function signalFromExtra(extra) { | ||
| if (!extra || typeof extra !== "object" || !("signal" in extra)) { | ||
| return undefined; | ||
| } | ||
| const signal = extra.signal; | ||
| return signal instanceof AbortSignal ? signal : undefined; | ||
| } |
126541
6.7%26
8.33%1315
10.04%277
0.73%