From fae216525897cd809be7e5208a61daf5e39d4655 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:18:52 -1000 Subject: [PATCH] feat: support version pinning for registry bundles (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime called mpak.prepareServer({ name }) without a version, so every startup and reconfigure resolved the latest published bundle. That broke two things deployments care about: - reproducibility: redeploying silently picked up new bundle versions - rollback: a broken release shipped to every tenant on next restart Add an optional `version` field to the registry-bundle variant of BundleRef and thread it through the three prepareServer call sites: - src/bundles/startup.ts — buildRegistrySource now passes ref.version - src/bundles/lifecycle.ts — installNamed accepts a version arg, uses it to prepareServer, and persists it in the nimblebrain.json entry so pins survive restart - src/tools/system-tools.ts — the manage_app tool gains an optional `version` input; installBundleInWorkspaceViaCtx threads it into the BundleRef and writes it into workspace.json Also expose `version` in: - src/config/nimblebrain-config.schema.json (cached copy) - src/cli/commands.ts Config type - README bundle config table and example The mpak SDK already supports PrepareServerSpec.version — no SDK changes. The canonical schema at schemas.nimblebrain.ai must be updated separately to match the cached copy. configureBundle (restart after credential change) still resolves via { name } only — restoring the pin on that path requires plumbing nimblebrain.json config into the tool handler, out of scope here. --- README.md | 2 ++ src/bundles/lifecycle.ts | 6 +++++- src/bundles/startup.ts | 5 ++++- src/bundles/types.ts | 7 +++++++ src/cli/commands.ts | 1 + src/config/nimblebrain-config.schema.json | 4 ++++ src/tools/system-tools.ts | 20 ++++++++++++++---- test/unit/workspace/workspace-store.test.ts | 23 +++++++++++++++++++++ 8 files changed, 62 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4d55e6ed..59c4da25 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ Create a `nimblebrain.json` in your working directory: "defaultModel": "claude-sonnet-4-5-20250929", "bundles": [ { "name": "@scope/bundle-name" }, + { "name": "@scope/bundle-name", "version": "0.2.0" }, { "name": "@scope/bundle-name", "env": { "API_KEY": "..." }, "trustScore": 92, "ui": { "name": "My App", "icon": "✓", "primaryView": { "resourceUri": "ui://myapp/main" } } }, { "path": "/local/path/to/bundle" } @@ -551,6 +552,7 @@ Config file: `nimblebrain.json`. Validated at startup against `src/config/nimble | Field | Type | Description | |-------|------|-------------| | `name` | string | Bundle name from mpak registry | +| `version` | string | Optional registry version to pin (e.g., `"0.2.0"`). Omit to resolve latest at startup. Required for reproducible deployments and rollback. Ignored with `path`/`url` | | `path` | string | Local filesystem path (resolved relative to config file) | | `env` | object | Environment variables passed to the bundle process | | `allowedEnv` | string[] | Host env vars this bundle may access | diff --git a/src/bundles/lifecycle.ts b/src/bundles/lifecycle.ts index 5288a02b..6a7669f0 100644 --- a/src/bundles/lifecycle.ts +++ b/src/bundles/lifecycle.ts @@ -86,6 +86,7 @@ export class BundleLifecycleManager { name: string, registry: ToolRegistry, env?: Record, + version?: string, ): Promise { const serverName = deriveServerName(name); validateServerName(serverName); @@ -111,7 +112,9 @@ export class BundleLifecycleManager { const nbWorkDir = process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain"); const bundleDataDir = join(nbWorkDir, "data", deriveBundleDataDir(name)); - const server = await mpak.prepareServer({ name }, { workspaceDir: bundleDataDir }); + const server = await mpak.prepareServer(version ? { name, version } : { name }, { + workspaceDir: bundleDataDir, + }); const source = new McpSource(serverName, { type: "stdio", @@ -146,6 +149,7 @@ export class BundleLifecycleManager { // Step 7 — Atomic config write if (this.configPath) { const entry: Record = { name }; + if (version) entry.version = version; if (instance.trustScore != null) entry.trustScore = instance.trustScore; if (instance.ui) entry.ui = instance.ui; atomicConfigAdd(this.configPath, entry); diff --git a/src/bundles/startup.ts b/src/bundles/startup.ts index 3a7b4449..878cddff 100644 --- a/src/bundles/startup.ts +++ b/src/bundles/startup.ts @@ -62,7 +62,10 @@ export async function startBundleSource( const mpakHome = process.env.MPAK_HOME ?? join(homedir(), ".mpak"); const mpak = getMpak(mpakHome); - const server = await mpak.prepareServer({ name: ref.name }, { workspaceDir: bundleDataDir }); + const server = await mpak.prepareServer( + ref.version ? { name: ref.name, version: ref.version } : { name: ref.name }, + { workspaceDir: bundleDataDir }, + ); // Read cached manifest for UI + briefing metadata const cachedManifest = mpak.bundleCache.getBundleManifest(ref.name) as Record< diff --git a/src/bundles/types.ts b/src/bundles/types.ts index fba500b4..061d94d5 100644 --- a/src/bundles/types.ts +++ b/src/bundles/types.ts @@ -60,6 +60,13 @@ export interface RemoteTransportConfig { export type BundleRef = | { name: string; + /** + * Pin to a specific registry version (e.g., "0.2.0"). When omitted, the + * mpak SDK resolves the latest published version at startup — which + * breaks reproducible deployments and rollback. Set this to lock the + * version in nimblebrain.json / workspace config. + */ + version?: string; env?: Record; allowedEnv?: string[]; protected?: boolean; diff --git a/src/cli/commands.ts b/src/cli/commands.ts index f1ae38ea..e460ee32 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -12,6 +12,7 @@ const RELOAD_SENTINEL = join(homedir(), ".nimblebrain", ".reload"); interface Config { bundles?: Array<{ name?: string; + version?: string; path?: string; url?: string; serverName?: string; diff --git a/src/config/nimblebrain-config.schema.json b/src/config/nimblebrain-config.schema.json index 43e82aea..7ab1ec6e 100644 --- a/src/config/nimblebrain-config.schema.json +++ b/src/config/nimblebrain-config.schema.json @@ -88,6 +88,10 @@ "type": "string", "description": "Bundle name from mpak registry (e.g., @nimblebraininc/echo)." }, + "version": { + "type": "string", + "description": "Optional registry version to pin (e.g., '0.2.0'). When omitted, the latest published version is resolved at startup. Ignored for path-based and url-based bundles." + }, "path": { "type": "string", "description": "Local filesystem path to bundle directory." }, "url": { "type": "string", "description": "Remote MCP server URL." }, "serverName": { diff --git a/src/tools/system-tools.ts b/src/tools/system-tools.ts index 5e4abe0e..11646147 100644 --- a/src/tools/system-tools.ts +++ b/src/tools/system-tools.ts @@ -159,12 +159,18 @@ export function createSystemTools( type: "string", description: "Bundle name (e.g., @nimblebraininc/ipinfo)", }, + version: { + type: "string", + description: + "Optional. Registry version to pin (e.g., '0.2.0'). Only used for install; ignored for uninstall and configure. Omit to install the latest published version.", + }, }, required: ["action", "name"], }, handler: async (input): Promise => { const action = String(input.action); const name = String(input.name); + const version = typeof input.version === "string" ? input.version : undefined; if (!lifecycle || !manageBundleCtx) { return { content: textContent("Bundle management requires lifecycle context"), @@ -185,6 +191,7 @@ export function createSystemTools( lifecycle, getRegistry(), manageBundleCtx, + version, ); } if (action === "uninstall") { @@ -1032,9 +1039,12 @@ async function installBundleInWorkspaceViaCtx( lifecycle: BundleLifecycleManager, registry: ToolRegistry, ctx: ManageBundleContext, + version?: string, ): Promise { try { - const bundleRef = { name } as import("../bundles/types.ts").BundleRef; + const bundleRef = ( + version ? { name, version } : { name } + ) as import("../bundles/types.ts").BundleRef; // Spawn the bundle process with plain server name in workspace registry const entry = await installBundleInWorkspace(wsId, bundleRef, registry, ctx.configDir, { @@ -1052,22 +1062,24 @@ async function installBundleInWorkspaceViaCtx( entry.dataDir, ); - // Add bundle to workspace.json + // Add bundle to workspace.json (preserving pinned version if supplied). const ws = await ctx.workspaceStore.get(wsId); if (ws) { const already = ws.bundles.some((b) => "name" in b && b.name === name); if (!already) { + const entryToPersist = version ? { name, version } : { name }; await ctx.workspaceStore.update(wsId, { - bundles: [...ws.bundles, { name }], + bundles: [...ws.bundles, entryToPersist], }); } } const tools = await registry.availableTools(); const count = tools.filter((t) => t.name.startsWith(`${entry.serverName}__`)).length; + const versionSuffix = version ? `@${version}` : ""; return { content: textContent( - `Installed ${name} in workspace ${wsId}. ${count} tools now available from ${entry.serverName}.`, + `Installed ${name}${versionSuffix} in workspace ${wsId}. ${count} tools now available from ${entry.serverName}.`, ), isError: false, }; diff --git a/test/unit/workspace/workspace-store.test.ts b/test/unit/workspace/workspace-store.test.ts index 19c028f2..45cc9e00 100644 --- a/test/unit/workspace/workspace-store.test.ts +++ b/test/unit/workspace/workspace-store.test.ts @@ -242,4 +242,27 @@ describe("WorkspaceStore extended fields", () => { const loaded = await store.get(ws.id); expect(loaded!.skillDirs).toEqual(skillDirs); }); + + test("workspace with pinned bundle version round-trips through disk", async () => { + const ws = await store.create("Pin Team"); + const bundles: Workspace["bundles"] = [ + { name: "@nimblebraininc/echo", version: "0.3.1" }, + { name: "@nimblebraininc/no-pin" }, + ]; + + const updated = await store.update(ws.id, { bundles }); + expect(updated).not.toBeNull(); + expect(updated!.bundles).toEqual(bundles); + + // Re-read to confirm persistence survives serialization. + const loaded = await store.get(ws.id); + expect(loaded!.bundles).toEqual(bundles); + + // The unpinned entry must NOT be silently coerced to include a version. + const unpinned = loaded!.bundles.find( + (b): b is { name: string } => "name" in b && b.name === "@nimblebraininc/no-pin", + ); + expect(unpinned).toBeDefined(); + expect((unpinned as { version?: string }).version).toBeUndefined(); + }); });