Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 5 additions & 1 deletion src/bundles/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class BundleLifecycleManager {
name: string,
registry: ToolRegistry,
env?: Record<string, string>,
version?: string,
): Promise<BundleInstance> {
const serverName = deriveServerName(name);
validateServerName(serverName);
Expand All @@ -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",
Expand Down Expand Up @@ -146,6 +149,7 @@ export class BundleLifecycleManager {
// Step 7 — Atomic config write
if (this.configPath) {
const entry: Record<string, unknown> = { 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);
Expand Down
5 changes: 4 additions & 1 deletion src/bundles/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
7 changes: 7 additions & 0 deletions src/bundles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
allowedEnv?: string[];
protected?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/config/nimblebrain-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
20 changes: 16 additions & 4 deletions src/tools/system-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolResult> => {
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"),
Expand All @@ -185,6 +191,7 @@ export function createSystemTools(
lifecycle,
getRegistry(),
manageBundleCtx,
version,
);
}
if (action === "uninstall") {
Expand Down Expand Up @@ -1032,9 +1039,12 @@ async function installBundleInWorkspaceViaCtx(
lifecycle: BundleLifecycleManager,
registry: ToolRegistry,
ctx: ManageBundleContext,
version?: string,
): Promise<ToolResult> {
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, {
Expand All @@ -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,
};
Expand Down
23 changes: 23 additions & 0 deletions test/unit/workspace/workspace-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});