🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@nimblebrain/mpak-sdk

Package Overview
Dependencies
Maintainers
2
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@nimblebrain/mpak-sdk - npm Package Compare versions

Comparing version
0.1.3
to
0.2.0
+866
-175
dist/index.cjs
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;

@@ -20,10 +18,2 @@ var __export = (target, all) => {

};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

@@ -34,12 +24,33 @@

__export(index_exports, {
Mpak: () => Mpak,
MpakBundleCache: () => MpakBundleCache,
MpakCacheCorruptedError: () => MpakCacheCorruptedError,
MpakClient: () => MpakClient,
MpakConfigCorruptedError: () => MpakConfigCorruptedError,
MpakConfigError: () => MpakConfigError,
MpakConfigManager: () => MpakConfigManager,
MpakError: () => MpakError,
MpakIntegrityError: () => MpakIntegrityError,
MpakInvalidBundleError: () => MpakInvalidBundleError,
MpakNetworkError: () => MpakNetworkError,
MpakNotFoundError: () => MpakNotFoundError
MpakNotFoundError: () => MpakNotFoundError,
parsePackageSpec: () => parsePackageSpec
});
module.exports = __toCommonJS(index_exports);
// src/mpakSDK.ts
var import_node_child_process2 = require("child_process");
var import_node_fs4 = require("fs");
var import_node_path4 = require("path");
var import_mpak_schemas2 = require("@nimblebrain/mpak-schemas");
// src/cache.ts
var import_node_crypto3 = require("crypto");
var import_node_fs2 = require("fs");
var import_node_os = require("os");
var import_node_path2 = require("path");
var import_mpak_schemas = require("@nimblebrain/mpak-schemas");
// src/client.ts
var import_crypto = require("crypto");
var import_node_crypto = require("crypto");

@@ -79,2 +90,35 @@ // src/errors.ts

};
var MpakConfigCorruptedError = class extends MpakError {
constructor(message, configPath, cause) {
super(message, "CONFIG_CORRUPTED");
this.configPath = configPath;
this.cause = cause;
this.name = "MpakConfigCorruptedError";
}
};
var MpakCacheCorruptedError = class extends MpakError {
constructor(message, filePath, cause) {
super(message, "CACHE_CORRUPTED");
this.filePath = filePath;
this.cause = cause;
this.name = "MpakCacheCorruptedError";
}
};
var MpakInvalidBundleError = class extends MpakError {
constructor(message, bundlePath, cause) {
super(message, "INVALID_BUNDLE");
this.bundlePath = bundlePath;
this.cause = cause;
this.name = "MpakInvalidBundleError";
}
};
var MpakConfigError = class extends MpakError {
constructor(packageName, missingFields) {
const fieldNames = missingFields.map((f) => f.title).join(", ");
super(`Missing required config for ${packageName}: ${fieldNames}`, "CONFIG_MISSING");
this.packageName = packageName;
this.missingFields = missingFields;
this.name = "MpakConfigError";
}
};

@@ -84,3 +128,3 @@ // src/client.ts

var DEFAULT_TIMEOUT = 3e4;
var MpakClient = class {
var MpakClient = class _MpakClient {
registryUrl;

@@ -197,3 +241,2 @@ timeout;

if (params.category) searchParams.set("category", params.category);
if (params.surface) searchParams.set("surface", params.surface);
if (params.sort) searchParams.set("sort", params.sort);

@@ -262,176 +305,52 @@ if (params.limit) searchParams.set("limit", String(params.limit));

}
// ===========================================================================
// Download Methods
// ===========================================================================
/**
* Download skill content and verify integrity
* Download content from a URL and verify its SHA-256 integrity.
*
* @throws {MpakIntegrityError} If expectedSha256 is provided and doesn't match (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
async downloadSkillContent(downloadUrl, expectedSha256) {
const response = await this.fetchWithTimeout(downloadUrl);
async downloadContent(url, sha256) {
const response = await this.fetchWithTimeout(url);
if (!response.ok) {
throw new MpakNetworkError(`Failed to download skill: HTTP ${response.status}`);
throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`);
}
const content = await response.text();
if (expectedSha256) {
const actualHash = this.computeSha256(content);
if (actualHash !== expectedSha256) {
throw new MpakIntegrityError(expectedSha256, actualHash);
}
return { content, verified: true };
const downloadedRawData = new Uint8Array(await response.arrayBuffer());
const computedHash = this.computeSha256(downloadedRawData);
if (computedHash !== sha256) {
throw new MpakIntegrityError(sha256, computedHash);
}
return { content, verified: false };
return downloadedRawData;
}
/**
* Resolve a skill reference to actual content
* Download a bundle by name, with optional version and platform.
* Defaults to latest version and auto-detected platform.
*
* Supports mpak, github, and url sources. This is the main method for
* fetching skill content from any supported source.
*
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If integrity check fails (fail-closed)
* @throws {MpakNotFoundError} If bundle not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*
* @example
* ```typescript
* // Resolve from mpak registry
* const skill = await client.resolveSkillRef({
* source: 'mpak',
* name: '@nimblebraininc/folk-crm',
* version: '1.3.0',
* });
*
* // Resolve from GitHub
* const skill = await client.resolveSkillRef({
* source: 'github',
* name: '@example/my-skill',
* version: 'v1.0.0',
* repo: 'owner/repo',
* path: 'skills/my-skill/SKILL.md',
* });
*
* // Resolve from URL
* const skill = await client.resolveSkillRef({
* source: 'url',
* name: '@example/custom',
* version: '1.0.0',
* url: 'https://example.com/skill.md',
* });
* ```
*/
async resolveSkillRef(ref) {
switch (ref.source) {
case "mpak":
return this.resolveMpakSkill(ref);
case "github":
return this.resolveGithubSkill(ref);
case "url":
return this.resolveUrlSkill(ref);
default: {
const _exhaustive = ref;
throw new Error(`Unknown skill source: ${_exhaustive.source}`);
}
}
async downloadBundle(name, version, platform) {
const resolvedPlatform = platform ?? _MpakClient.detectPlatform();
const resolvedVersion = version ?? "latest";
const downloadInfo = await this.getBundleDownload(name, resolvedVersion, resolvedPlatform);
const data = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256);
return { data, metadata: downloadInfo.bundle };
}
/**
* Resolve a skill from mpak registry
* Download a skill bundle by name, with optional version.
* Defaults to latest version.
*
* The API returns a ZIP bundle containing SKILL.md and metadata.
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
async resolveMpakSkill(ref) {
const url = `${this.registryUrl}/v1/skills/${ref.name}/versions/${ref.version}/download`;
const response = await this.fetchWithTimeout(url);
if (response.status === 404) {
throw new MpakNotFoundError(`${ref.name}@${ref.version}`);
}
if (!response.ok) {
throw new MpakNetworkError(`Failed to fetch skill: HTTP ${response.status}`);
}
const zipBuffer = await response.arrayBuffer();
const content = await this.extractSkillFromZip(zipBuffer, ref.name);
if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return { content, version: ref.version, source: "mpak", verified: true };
}
return { content, version: ref.version, source: "mpak", verified: false };
async downloadSkillBundle(name, version) {
const resolvedVersion = version ?? "latest";
const downloadInfo = await this.getSkillVersionDownload(name, resolvedVersion);
const data = await this.downloadContent(downloadInfo.url, downloadInfo.skill.sha256);
return { data, metadata: downloadInfo.skill };
}
/**
* Resolve a skill from GitHub releases
*/
async resolveGithubSkill(ref) {
const url = `https://github.com/${ref.repo}/releases/download/${ref.version}/${ref.path}`;
const response = await this.fetchWithTimeout(url);
if (!response.ok) {
throw new MpakNotFoundError(`github:${ref.repo}/${ref.path}@${ref.version}`);
}
const content = await response.text();
if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return {
content,
version: ref.version,
source: "github",
verified: true
};
}
return {
content,
version: ref.version,
source: "github",
verified: false
};
}
/**
* Resolve a skill from a direct URL
*/
async resolveUrlSkill(ref) {
const response = await this.fetchWithTimeout(ref.url);
if (!response.ok) {
throw new MpakNotFoundError(`url:${ref.url}`);
}
const content = await response.text();
if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return { content, version: ref.version, source: "url", verified: true };
}
return { content, version: ref.version, source: "url", verified: false };
}
/**
* Extract SKILL.md content from a skill bundle ZIP
*/
async extractSkillFromZip(zipBuffer, skillName) {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(zipBuffer);
const folderName = skillName.split("/").pop() ?? skillName;
const skillPath = `${folderName}/SKILL.md`;
const skillFile = zip.file(skillPath);
if (!skillFile) {
const altFile = zip.file("SKILL.md");
if (!altFile) {
throw new MpakNotFoundError(`SKILL.md not found in bundle for ${skillName}`);
}
return altFile.async("string");
}
return skillFile.async("string");
}
/**
* Verify content integrity and throw if mismatch (fail-closed)
*/
verifyIntegrityOrThrow(content, integrity) {
const expectedHash = this.extractHash(integrity);
const actualHash = this.computeSha256(content);
if (actualHash !== expectedHash) {
throw new MpakIntegrityError(expectedHash, actualHash);
}
}
/**
* Extract hash from integrity string (removes prefix)
*/
extractHash(integrity) {
if (integrity.startsWith("sha256:")) {
return integrity.slice(7);
}
if (integrity.startsWith("sha256-")) {
return integrity.slice(7);
}
return integrity;
}
// ===========================================================================

@@ -477,3 +396,3 @@ // Utility Methods

computeSha256(content) {
return (0, import_crypto.createHash)("sha256").update(content, "utf8").digest("hex");
return (0, import_node_crypto.createHash)("sha256").update(content).digest("hex");
}

@@ -485,3 +404,6 @@ /**

if (!name.startsWith("@")) {
throw new Error("Package name must be scoped (e.g., @scope/package-name)");
throw new MpakError(
"Package name must be scoped (e.g., @scope/package-name)",
"INVALID_SPEC"
);
}

@@ -519,10 +441,779 @@ }

};
// src/helpers.ts
var import_node_child_process = require("child_process");
var import_node_crypto2 = require("crypto");
var import_node_fs = require("fs");
var import_node_path = require("path");
var MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024;
var UPDATE_CHECK_TTL_MS = 60 * 60 * 1e3;
function isSemverEqual(a, b) {
return a.replace(/^v/, "") === b.replace(/^v/, "");
}
function extractZip(zipPath, destDir) {
try {
const listOutput = (0, import_node_child_process.execFileSync)("unzip", ["-l", zipPath], {
stdio: "pipe",
encoding: "utf8"
});
const totalMatch = listOutput.match(/^\s*(\d+)\s+\d+\s+files?$/m);
if (totalMatch) {
const totalSize = parseInt(totalMatch[1] ?? "0", 10);
if (totalSize > MAX_UNCOMPRESSED_SIZE) {
throw new MpakCacheCorruptedError(
`Bundle uncompressed size (${Math.round(totalSize / 1024 / 1024)}MB) exceeds maximum allowed (${MAX_UNCOMPRESSED_SIZE / (1024 * 1024)}MB)`,
zipPath
);
}
}
} catch (error) {
if (error instanceof MpakCacheCorruptedError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new MpakCacheCorruptedError(
`Cannot verify bundle size before extraction: ${message}`,
zipPath,
error instanceof Error ? error : void 0
);
}
(0, import_node_fs.mkdirSync)(destDir, { recursive: true });
(0, import_node_child_process.execFileSync)("unzip", ["-o", "-q", zipPath, "-d", destDir], {
stdio: "pipe"
});
}
function hashBundlePath(bundlePath) {
return (0, import_node_crypto2.createHash)("md5").update((0, import_node_path.resolve)(bundlePath)).digest("hex").slice(0, 12);
}
function localBundleNeedsExtract(bundlePath, cacheDir) {
const metaPath = (0, import_node_path.join)(cacheDir, ".mpak-local-meta.json");
if (!(0, import_node_fs.existsSync)(metaPath)) return true;
try {
const meta = JSON.parse((0, import_node_fs.readFileSync)(metaPath, "utf8"));
if (!meta.extractedAt) return true;
const bundleStat = (0, import_node_fs.statSync)(bundlePath);
return bundleStat.mtimeMs > new Date(meta.extractedAt).getTime();
} catch {
return true;
}
}
function readJsonFromFile(filePath, schema) {
if (!(0, import_node_fs.existsSync)(filePath)) {
throw new MpakError(`File does not exist: ${filePath}`, "FILE_NOT_FOUND");
}
let raw;
try {
raw = JSON.parse((0, import_node_fs.readFileSync)(filePath, "utf8"));
} catch {
throw new MpakError(`File is not valid JSON: ${filePath}`, "INVALID_JSON");
}
const result = schema.safeParse(raw);
if (!result.success) {
throw new MpakError(
`File failed validation: ${filePath} \u2014 ${result.error.issues[0]?.message ?? "unknown error"}`,
"VALIDATION_FAILED"
);
}
return result.data;
}
// src/cache.ts
var MpakBundleCache = class {
cacheHome;
mpakClient;
constructor(client, options) {
this.mpakClient = client;
this.cacheHome = (0, import_node_path2.join)(options?.mpakHome ?? (0, import_node_path2.join)((0, import_node_os.homedir)(), ".mpak"), "cache");
}
/**
* Compute the cache path for a package. Does not create the directory.
* @example getPackageCachePath('@scope/name') => '<cacheBase>/scope-name'
*/
getBundleCacheDirName(packageName) {
const safeName = packageName.replace("@", "").replace("/", "-");
return (0, import_node_path2.join)(this.cacheHome, safeName);
}
/**
* Read and validate cache metadata for a package.
* Returns `null` if the package does not exist in the cache.
* throws Error if metadata is corrupt
*/
getBundleMetadata(packageName) {
const packageCacheDir = this.getBundleCacheDirName(packageName);
if (!(0, import_node_fs2.existsSync)(packageCacheDir)) {
return null;
}
const metaPath = (0, import_node_path2.join)(packageCacheDir, ".mpak-meta.json");
try {
return readJsonFromFile(metaPath, import_mpak_schemas.CacheMetadataSchema);
} catch (err) {
throw new MpakCacheCorruptedError(
err instanceof Error ? err.message : String(err),
metaPath,
err instanceof Error ? err : void 0
);
}
}
/**
* Read and validate the MCPB manifest from a cached package.
* Returns `null` if the package is not cached (directory doesn't exist).
*
* @throws {MpakCacheCorruptedError} If the cache directory exists but
* `manifest.json` is missing, contains invalid JSON, or fails schema validation.
*/
getBundleManifest(packageName) {
const dir = this.getBundleCacheDirName(packageName);
if (!(0, import_node_fs2.existsSync)(dir)) {
return null;
}
const manifestPath = (0, import_node_path2.join)(dir, "manifest.json");
try {
return readJsonFromFile(manifestPath, import_mpak_schemas.McpbManifestSchema);
} catch (err) {
throw new MpakCacheCorruptedError(
err instanceof Error ? err.message : String(err),
manifestPath,
err instanceof Error ? err : void 0
);
}
}
/**
* Scan the cache directory and return metadata for every cached registry bundle.
* Skips the `_local/` directory (local dev bundles) and entries with
* missing/corrupt metadata or manifests.
*/
listCachedBundles() {
if (!(0, import_node_fs2.existsSync)(this.cacheHome)) return [];
const entries = (0, import_node_fs2.readdirSync)(this.cacheHome, { withFileTypes: true });
const bundles = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "_local") continue;
const cacheDir = (0, import_node_path2.join)(this.cacheHome, entry.name);
try {
const manifest = readJsonFromFile((0, import_node_path2.join)(cacheDir, "manifest.json"), import_mpak_schemas.McpbManifestSchema);
const meta = this.getBundleMetadata(manifest.name);
if (!meta) continue;
bundles.push({
name: manifest.name,
version: meta.version,
pulledAt: meta.pulledAt,
cacheDir
});
} catch {
}
}
return bundles;
}
/**
* Remove a cached bundle from disk.
* @returns `true` if the bundle was cached and removed, `false` if it wasn't cached.
*/
removeCachedBundle(packageName) {
const dir = this.getBundleCacheDirName(packageName);
if (!(0, import_node_fs2.existsSync)(dir)) return false;
(0, import_node_fs2.rmSync)(dir, { recursive: true, force: true });
return true;
}
/**
* Load a bundle into the local cache, downloading from the registry only
* if the cache is missing or stale. Returns the cache directory and version.
*
* Requires an `MpakClient` to be provided at construction time.
*
* @param name - Scoped package name (e.g. `@scope/bundle`)
* @param options.version - Specific version to load. Omit for "latest".
* @param options.force - Skip cache checks and always re-download.
*
* @returns `cacheDir` — path to the extracted bundle on disk,
* `version` — the resolved version string,
* `pulled` — whether a download actually occurred.
*
* @throws If no `MpakClient` was provided at construction time.
*/
async loadBundle(name, options) {
const { version: requestedVersion, force = false } = options ?? {};
const cacheDir = this.getBundleCacheDirName(name);
let cachedMeta = null;
try {
cachedMeta = this.getBundleMetadata(name);
} catch {
}
if (!options?.force && !!cachedMeta && (!requestedVersion || isSemverEqual(cachedMeta.version, requestedVersion))) {
return { cacheDir, version: cachedMeta.version, pulled: false };
}
const platform = MpakClient.detectPlatform();
const downloadInfo = await this.mpakClient.getBundleDownload(
name,
requestedVersion ?? "latest",
platform
);
if (!force && cachedMeta && isSemverEqual(cachedMeta.version, downloadInfo.bundle.version)) {
this.writeCacheMetadata(name, {
...cachedMeta,
lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
});
return { cacheDir, version: cachedMeta.version, pulled: false };
}
await this.downloadAndExtract(name, downloadInfo);
return { cacheDir, version: downloadInfo.bundle.version, pulled: true };
}
/**
* Fire-and-forget background check for bundle updates.
* Return the latest version string if an update is available, null otherwise (not cached, skipped, up-to-date, or error).
* The caller can just check `if (result) { console.log("update available: " + result) }`
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
*/
async checkForUpdate(packageName, options) {
const cachedMeta = this.getBundleMetadata(packageName);
if (!cachedMeta) return null;
if (!options?.force && cachedMeta.lastCheckedAt) {
const elapsed = Date.now() - new Date(cachedMeta.lastCheckedAt).getTime();
if (elapsed < UPDATE_CHECK_TTL_MS) return null;
}
try {
const detail = await this.mpakClient.getBundle(packageName);
this.writeCacheMetadata(packageName, {
...cachedMeta,
lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
});
if (!isSemverEqual(detail.latest_version, cachedMeta.version)) {
return detail.latest_version;
}
return null;
} catch {
return null;
}
}
// ===========================================================================
// Private methods
// ===========================================================================
/**
* Write cache metadata for a package.
* @throws If the metadata fails schema validation.
*/
writeCacheMetadata(packageName, metadata) {
const metaPath = (0, import_node_path2.join)(this.getBundleCacheDirName(packageName), ".mpak-meta.json");
(0, import_node_fs2.writeFileSync)(metaPath, JSON.stringify(metadata, null, 2));
}
/**
* Download a bundle using pre-resolved download info, extract it into
* the cache, and write metadata.
*/
async downloadAndExtract(name, downloadInfo) {
const bundle = downloadInfo.bundle;
const cacheDir = this.getBundleCacheDirName(name);
const tempPath = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `mpak-${Date.now()}-${(0, import_node_crypto3.randomUUID)().slice(0, 8)}.mcpb`);
try {
const data = await this.mpakClient.downloadContent(downloadInfo.url, bundle.sha256);
(0, import_node_fs2.writeFileSync)(tempPath, data);
if ((0, import_node_fs2.existsSync)(cacheDir)) {
(0, import_node_fs2.rmSync)(cacheDir, { recursive: true, force: true });
}
extractZip(tempPath, cacheDir);
this.writeCacheMetadata(name, {
version: bundle.version,
pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
platform: bundle.platform
});
} finally {
(0, import_node_fs2.rmSync)(tempPath, { force: true });
}
}
};
// src/config-manager.ts
var import_node_fs3 = require("fs");
var import_node_os2 = require("os");
var import_node_path3 = require("path");
var import_zod = require("zod");
var CONFIG_VERSION = "1.0.0";
var PackageConfigSchema = import_zod.z.record(import_zod.z.string(), import_zod.z.string());
var MpakConfigSchema = import_zod.z.object({
version: import_zod.z.string(),
lastUpdated: import_zod.z.string(),
registryUrl: import_zod.z.string().optional(),
packages: import_zod.z.record(import_zod.z.string(), PackageConfigSchema).optional()
}).strict();
var MpakConfigManager = class {
mpakHome;
configFile;
config = null;
constructor(options) {
this.mpakHome = (0, import_node_path3.resolve)(options?.mpakHome ?? (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".mpak"));
this.configFile = (0, import_node_path3.join)(this.mpakHome, "config.json");
if (options?.registryUrl !== void 0) {
this.setRegistryUrl(options.registryUrl);
}
}
// ===========================================================================
// Public methods
// ===========================================================================
/**
* Resolve the registry URL with a 2-tier fallback:
* 1. Saved value in config file
* 2. Default: `https://registry.mpak.dev`
*
* @returns The resolved registry URL
*/
getRegistryUrl() {
const config = this.loadConfig();
return config.registryUrl || "https://registry.mpak.dev";
}
/**
* Get all stored user config values for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns The key-value map, or `undefined` if the package has no stored config
*/
getPackageConfig(packageName) {
const config = this.loadConfig();
return config.packages?.[packageName];
}
/**
* Store a user config value for a package. Creates the package entry if needed.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key (e.g. `api_key`)
* @param value - The value to store
*/
setPackageConfigValue(packageName, key, value) {
const config = this.loadConfig();
if (!config.packages) {
config.packages = {};
}
if (!config.packages[packageName]) {
config.packages[packageName] = {};
}
config.packages[packageName][key] = value;
this.saveConfig();
}
/**
* Remove all stored config for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns `true` if the package had config that was removed, `false` if it didn't exist
*/
clearPackageConfig(packageName) {
const config = this.loadConfig();
if (config.packages?.[packageName]) {
delete config.packages[packageName];
this.saveConfig();
return true;
}
return false;
}
/**
* Remove a single config value for a package. If this was the last key,
* the package entry is cleaned up entirely.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key to remove
* @returns `true` if the key existed and was removed, `false` otherwise
*/
clearPackageConfigValue(packageName, key) {
const config = this.loadConfig();
if (config.packages?.[packageName]?.[key] !== void 0) {
delete config.packages[packageName][key];
if (Object.keys(config.packages[packageName]).length === 0) {
delete config.packages[packageName];
}
this.saveConfig();
return true;
}
return false;
}
/**
* List all package names that have stored user config.
*
* @returns Array of scoped package names (e.g. `['@scope/pkg1', '@scope/pkg2']`)
*/
getPackageNames() {
const config = this.loadConfig();
return Object.keys(config.packages || {});
}
// ===========================================================================
// Private methods
// ===========================================================================
/**
* Load the config from disk, or create a fresh one if the file doesn't exist yet.
* The result is cached — subsequent calls return the in-memory copy without
* re-reading the file.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file exists but contains invalid JSON or fails schema validation
*/
loadConfig() {
if (this.config) {
return this.config;
}
if (!(0, import_node_fs3.existsSync)(this.configFile)) {
this.config = {
version: CONFIG_VERSION,
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
};
return this.config;
}
this.config = this.readAndValidateConfig();
return this.config;
}
/**
* Read the config file from disk, parse JSON, and validate against the schema.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file can't be read, contains invalid JSON,
* or doesn't match the expected schema
*/
readAndValidateConfig() {
let configJson;
try {
configJson = (0, import_node_fs3.readFileSync)(this.configFile, "utf8");
} catch (err) {
throw new MpakConfigCorruptedError(
`Failed to read config file: ${err instanceof Error ? err.message : String(err)}`,
this.configFile,
err instanceof Error ? err : void 0
);
}
let parsed;
try {
parsed = JSON.parse(configJson);
} catch (err) {
throw new MpakConfigCorruptedError(
`Config file contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
this.configFile,
err instanceof Error ? err : void 0
);
}
const result = MpakConfigSchema.safeParse(parsed);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid config";
throw new MpakConfigCorruptedError(message, this.configFile);
}
return result.data;
}
/**
* Flush the in-memory config to disk. Creates the config directory if needed,
* validates against the schema, updates `lastUpdated`, and writes
* with mode `0o600` (owner read/write only — config may contain secrets).
*
* @throws {MpakConfigCorruptedError} If the in-memory config fails schema validation
*/
saveConfig() {
if (!this.config) {
throw new MpakConfigCorruptedError(
`saveConfig called before config was loaded`,
this.configFile
);
}
if (!(0, import_node_fs3.existsSync)(this.mpakHome)) {
(0, import_node_fs3.mkdirSync)(this.mpakHome, { recursive: true, mode: 448 });
}
this.config.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
const result = MpakConfigSchema.safeParse(this.config);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid config";
throw new MpakConfigCorruptedError(message, this.configFile);
}
const configJson = JSON.stringify(result.data, null, 2);
(0, import_node_fs3.writeFileSync)(this.configFile, configJson, { mode: 384 });
}
/**
* Persist a custom registry URL to the config file.
*
* @param url - The registry URL to save (e.g. `https://registry.example.com`)
*/
setRegistryUrl(url) {
const config = this.loadConfig();
config.registryUrl = url;
this.saveConfig();
}
};
// src/mpakSDK.ts
var Mpak = class _Mpak {
/** User configuration manager (`config.json`). */
configManager;
/** Registry API client. */
client;
/** Local bundle cache. */
bundleCache;
constructor(options) {
const configOptions = {};
if (options?.mpakHome !== void 0) configOptions.mpakHome = options.mpakHome;
if (options?.registryUrl !== void 0) configOptions.registryUrl = options.registryUrl;
this.configManager = new MpakConfigManager(configOptions);
const clientConfig = {
registryUrl: this.configManager.getRegistryUrl()
};
if (options?.timeout !== void 0) clientConfig.timeout = options.timeout;
if (options?.userAgent !== void 0) clientConfig.userAgent = options.userAgent;
this.client = new MpakClient(clientConfig);
this.bundleCache = new MpakBundleCache(this.client, {
mpakHome: this.configManager.mpakHome
});
}
/**
* Prepare a bundle for execution.
*
* Accepts either a registry spec (`{ name, version? }`) or a local bundle
* spec (`{ local }`). Downloads/extracts as needed, reads the manifest,
* validates user config, and resolves the command, args, and env needed
* to spawn the MCP server process.
*
* @param spec - Which bundle to prepare. See {@link PrepareServerSpec}.
* @param options - Force re-download/re-extract, extra env, and workspace dir.
*
* @throws {MpakConfigError} If required user config values are missing.
* @throws {MpakCacheCorruptedError} If the manifest is missing or corrupt after download.
*/
async prepareServer(spec, options) {
let cacheDir;
let name;
let version;
let manifest;
if ("local" in spec) {
({ cacheDir, name, version, manifest } = await this.prepareLocalBundle(spec.local, options));
} else {
({ cacheDir, name, version, manifest } = await this.prepareRegistryBundle(
spec.name,
spec.version,
options
));
}
const userConfigValues = this.gatherUserConfig(name, manifest);
const { command, args, env } = this.resolveCommand(manifest, cacheDir, userConfigValues);
env["MPAK_WORKSPACE"] = options?.workspaceDir ?? (0, import_node_path4.join)(process.cwd(), ".mpak");
if (options?.env) {
Object.assign(env, options.env);
}
return { command, args, env, cwd: cacheDir, name, version };
}
// ===========================================================================
// Private helpers
// ===========================================================================
/**
* Load a registry bundle into cache and read its manifest.
*/
async prepareRegistryBundle(packageName, version, options) {
const loadOptions = {};
if (version !== void 0) loadOptions.version = version;
if (options?.force !== void 0) loadOptions.force = options.force;
const loadResult = await this.bundleCache.loadBundle(packageName, loadOptions);
const manifest = this.bundleCache.getBundleManifest(packageName);
if (!manifest) {
throw new MpakCacheCorruptedError(
`Manifest file missing for ${packageName}`,
(0, import_node_path4.join)(this.bundleCache.cacheHome, packageName)
);
}
return {
cacheDir: loadResult.cacheDir,
name: packageName,
version: loadResult.version,
manifest
};
}
/**
* Extract a local `.mcpb` bundle (if stale) and read its manifest.
* Local bundles are cached under `<cacheHome>/_local/<hash>`.
*
* The caller is responsible for validating that `bundlePath` exists
* and has a `.mcpb` extension before calling this method.
*/
async prepareLocalBundle(bundlePath, options) {
const absolutePath = (0, import_node_path4.resolve)(bundlePath);
const hash = hashBundlePath(absolutePath);
const cacheDir = (0, import_node_path4.join)(this.bundleCache.cacheHome, "_local", hash);
const needsExtract = options?.force || localBundleNeedsExtract(absolutePath, cacheDir);
if (needsExtract) {
if ((0, import_node_fs4.existsSync)(cacheDir)) {
(0, import_node_fs4.rmSync)(cacheDir, { recursive: true, force: true });
}
try {
extractZip(absolutePath, cacheDir);
} catch (err) {
throw new MpakInvalidBundleError(
err instanceof Error ? err.message : String(err),
absolutePath,
err instanceof Error ? err : void 0
);
}
(0, import_node_fs4.writeFileSync)(
(0, import_node_path4.join)(cacheDir, ".mpak-local-meta.json"),
JSON.stringify({
localPath: absolutePath,
extractedAt: (/* @__PURE__ */ new Date()).toISOString()
})
);
}
let manifest;
try {
manifest = readJsonFromFile((0, import_node_path4.join)(cacheDir, "manifest.json"), import_mpak_schemas2.McpbManifestSchema);
} catch (err) {
throw new MpakInvalidBundleError(
err instanceof Error ? err.message : String(err),
absolutePath,
err instanceof Error ? err : void 0
);
}
return { cacheDir, name: manifest.name, version: manifest.version, manifest };
}
/**
* Gather stored user config values and validate that all required fields are present.
* @throws If required config values are missing.
*/
gatherUserConfig(packageName, manifest) {
if (!manifest.user_config || Object.keys(manifest.user_config).length === 0) {
return {};
}
const storedConfig = this.configManager.getPackageConfig(packageName) ?? {};
const result = {};
const missingFields = [];
for (const [fieldName, fieldData] of Object.entries(manifest.user_config)) {
const storedValue = storedConfig[fieldName];
if (storedValue !== void 0) {
result[fieldName] = storedValue;
} else if (fieldData.default !== void 0 && fieldData.default !== null) {
result[fieldName] = String(fieldData.default);
} else if (fieldData.required) {
const field = {
key: fieldName,
title: fieldData.title ?? fieldName,
sensitive: fieldData.sensitive ?? false
};
if (fieldData.description !== void 0) {
field.description = fieldData.description;
}
missingFields.push(field);
}
}
if (missingFields.length > 0) {
throw new MpakConfigError(packageName, missingFields);
}
return result;
}
/**
* Resolve the manifest's `server` block into a spawnable command, args, and env.
*
* Handles three server types:
* - **binary** — runs the compiled executable at `entry_point`, chmod'd +x.
* - **node** — runs `mcp_config.command` (default `"node"`) with `mcp_config.args`,
* or falls back to `node <entry_point>` when args are empty.
* - **python** — like node, but resolves `python3`/`python` at runtime and
* prepends `<cacheDir>/deps` to `PYTHONPATH` for bundled dependencies.
*
* All `${__dirname}` placeholders in args are replaced with `cacheDir`.
* All `${user_config.*}` placeholders in env are replaced with gathered user values.
*
* @throws For unsupported server types.
*/
resolveCommand(manifest, cacheDir, userConfigValues) {
const { type, entry_point, mcp_config } = manifest.server;
const env = _Mpak.substituteEnvVars(mcp_config.env, userConfigValues);
let command;
let args;
switch (type) {
case "binary": {
command = (0, import_node_path4.join)(cacheDir, entry_point);
args = _Mpak.resolveArgs(mcp_config.args ?? [], cacheDir);
try {
(0, import_node_fs4.chmodSync)(command, 493);
} catch {
}
break;
}
case "node": {
command = mcp_config.command || "node";
args = mcp_config.args.length > 0 ? _Mpak.resolveArgs(mcp_config.args, cacheDir) : [(0, import_node_path4.join)(cacheDir, entry_point)];
break;
}
case "python": {
command = mcp_config.command === "python" ? _Mpak.findPythonCommand() : mcp_config.command || _Mpak.findPythonCommand();
args = mcp_config.args.length > 0 ? _Mpak.resolveArgs(mcp_config.args, cacheDir) : [(0, import_node_path4.join)(cacheDir, entry_point)];
const depsDir = (0, import_node_path4.join)(cacheDir, "deps");
env["PYTHONPATH"] = env["PYTHONPATH"] ? `${depsDir}:${env["PYTHONPATH"]}` : depsDir;
break;
}
case "uv": {
command = mcp_config.command || "uv";
args = mcp_config.args.length > 0 ? _Mpak.resolveArgs(mcp_config.args, cacheDir) : ["run", (0, import_node_path4.join)(cacheDir, entry_point)];
break;
}
default: {
const _exhaustive = type;
throw new MpakCacheCorruptedError(
`Unsupported server type "${_exhaustive}" in manifest for ${manifest.name}`,
cacheDir
);
}
}
return { command, args, env };
}
/**
* Substitute `${__dirname}` placeholders in args.
*/
static resolveArgs(args, cacheDir) {
return args.map((arg) => arg.replace(/\$\{__dirname\}/g, cacheDir));
}
/**
* Substitute `${user_config.*}` placeholders in env vars.
*/
static substituteEnvVars(env, userConfigValues) {
if (!env) return {};
const result = {};
for (const [key, value] of Object.entries(env)) {
result[key] = value.replace(
/\$\{user_config\.([^}]+)\}/g,
(match, configKey) => userConfigValues[configKey] ?? match
);
}
return result;
}
/**
* Find a working Python executable. Tries `python3` first, falls back to `python`.
*/
static findPythonCommand() {
const result = (0, import_node_child_process2.spawnSync)("python3", ["--version"], { stdio: "pipe" });
if (result.status === 0) {
return "python3";
}
return "python";
}
};
// src/utils.ts
function parsePackageSpec(spec) {
const lastAtIndex = spec.lastIndexOf("@");
let name;
let version;
if (lastAtIndex > 0) {
name = spec.substring(0, lastAtIndex);
version = spec.substring(lastAtIndex + 1);
} else {
name = spec;
}
if (!name.startsWith("@") || !name.includes("/")) {
throw new MpakError(
`Invalid package spec: "${spec}". Expected scoped format: @scope/name`,
"INVALID_SPEC"
);
}
return version ? { name, version } : { name };
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Mpak,
MpakBundleCache,
MpakCacheCorruptedError,
MpakClient,
MpakConfigCorruptedError,
MpakConfigError,
MpakConfigManager,
MpakError,
MpakIntegrityError,
MpakInvalidBundleError,
MpakNetworkError,
MpakNotFoundError
MpakNotFoundError,
parsePackageSpec
});
//# sourceMappingURL=index.cjs.map
+482
-157

@@ -1,3 +0,3 @@

import { VersionDetail, DownloadInfo, VersionsResponse, PlatformInfo, FullProvenance, SkillSummary, SkillDetail, BundleSearchResponse, BundleDetail, SkillSearchResponse, SkillDownloadInfo } from '@nimblebrain/mpak-schemas';
export { Bundle, BundleDetail, BundleDetail as BundleDetailResponse, DownloadInfo as BundleDownloadResponse, BundleSearchResponse, VersionDetail as BundleVersionResponse, VersionsResponse as BundleVersionsResponse, Pagination, SkillDetail, SkillDetail as SkillDetailResponse, SkillDownloadInfo, SkillDownloadInfo as SkillDownloadResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas';
import { BundleSearchParamsInput, BundleSearchResponse, BundleDetail, VersionsResponse, VersionDetail, PlatformInfo, DownloadInfo, SkillSearchParamsInput, SkillSearchResponse, SkillDetail, SkillDownloadInfo, CacheMetadata, McpbManifest, CachedBundleInfo } from '@nimblebrain/mpak-schemas';
import { z } from 'zod';

@@ -7,46 +7,6 @@ /**

*
* API response types are re-exported from @nimblebrain/mpak-schemas.
* This file contains SDK-specific types (client config, skill references, etc.)
* API response types should be imported directly from @nimblebrain/mpak-schemas.
* This file contains only SDK-specific types (client config).
*/
/** Version in versions listing */
type BundleVersion = VersionsResponse['versions'][number];
/** Artifact in version detail */
type BundleArtifact = VersionDetail['artifacts'][number];
/** Download info alias */
type BundleDownloadInfo = DownloadInfo;
/** Skill summary in search results */
type Skill = SkillSummary;
/** Skill version in detail */
type SkillVersion = SkillDetail['versions'][number];
/** Platform identifier (os + arch) */
type Platform = PlatformInfo;
/** Provenance information for verified packages */
type Provenance = FullProvenance;
/** Author information */
interface Author {
name: string;
url?: string;
email?: string;
}
/** Query parameters for bundle search */
interface BundleSearchParams {
q?: string;
type?: string;
sort?: 'downloads' | 'recent' | 'name';
limit?: number;
offset?: number;
}
/** Query parameters for skill search */
interface SkillSearchParams {
q?: string;
tags?: string;
category?: string;
surface?: string;
sort?: 'downloads' | 'recent' | 'name';
limit?: number;
offset?: number;
}
/**

@@ -72,54 +32,2 @@ * Configuration options for MpakClient

}
/**
* Base fields shared by all skill reference types
*/
interface SkillReferenceBase {
/** Skill artifact identifier (e.g., '@nimbletools/folk-crm') */
name: string;
/** Semver version (e.g., '1.0.0') or 'latest' */
version: string;
/** SHA256 integrity hash (format: 'sha256-hexdigest') */
integrity?: string;
}
/**
* Skill reference from mpak registry
*/
interface MpakSkillReference extends SkillReferenceBase {
source: 'mpak';
}
/**
* Skill reference from GitHub repository
*/
interface GithubSkillReference extends SkillReferenceBase {
source: 'github';
/** GitHub repository (owner/repo) */
repo: string;
/** Path to skill file in repo */
path: string;
}
/**
* Skill reference from direct URL
*/
interface UrlSkillReference extends SkillReferenceBase {
source: 'url';
/** Direct download URL */
url: string;
}
/**
* Discriminated union of skill reference types
*/
type SkillReference = MpakSkillReference | GithubSkillReference | UrlSkillReference;
/**
* Result of resolving a skill reference
*/
interface ResolvedSkill {
/** The markdown content of the skill */
content: string;
/** Version that was resolved */
version: string;
/** Source the skill was fetched from */
source: 'mpak' | 'github' | 'url';
/** Whether integrity was verified */
verified: boolean;
}

@@ -130,3 +38,2 @@ /**

* Requires Node.js 18+ for native fetch support.
* Uses jszip for skill bundle extraction.
*/

@@ -141,3 +48,3 @@ declare class MpakClient {

*/
searchBundles(params?: BundleSearchParams): Promise<BundleSearchResponse>;
searchBundles(params?: BundleSearchParamsInput): Promise<BundleSearchResponse>;
/**

@@ -158,7 +65,7 @@ * Get bundle details

*/
getBundleDownload(name: string, version: string, platform?: Platform): Promise<DownloadInfo>;
getBundleDownload(name: string, version: string, platform?: PlatformInfo): Promise<DownloadInfo>;
/**
* Search for skills
*/
searchSkills(params?: SkillSearchParams): Promise<SkillSearchResponse>;
searchSkills(params?: SkillSearchParamsInput): Promise<SkillSearchResponse>;
/**

@@ -177,93 +84,453 @@ * Get skill details

/**
* Download skill content and verify integrity
* Download content from a URL and verify its SHA-256 integrity.
*
* @throws {MpakIntegrityError} If expectedSha256 is provided and doesn't match (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
downloadSkillContent(downloadUrl: string, expectedSha256?: string): Promise<{
content: string;
verified: boolean;
downloadContent(url: string, sha256: string): Promise<Uint8Array>;
/**
* Download a bundle by name, with optional version and platform.
* Defaults to latest version and auto-detected platform.
*
* @throws {MpakNotFoundError} If bundle not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
downloadBundle(name: string, version?: string, platform?: PlatformInfo): Promise<{
data: Uint8Array;
metadata: DownloadInfo['bundle'];
}>;
/**
* Resolve a skill reference to actual content
* Download a skill bundle by name, with optional version.
* Defaults to latest version.
*
* Supports mpak, github, and url sources. This is the main method for
* fetching skill content from any supported source.
*
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If integrity check fails (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
downloadSkillBundle(name: string, version?: string): Promise<{
data: Uint8Array;
metadata: SkillDownloadInfo['skill'];
}>;
/**
* Detect the current platform
*/
static detectPlatform(): PlatformInfo;
/**
* Compute SHA256 hash of content
*/
private computeSha256;
/**
* Validate that a name is scoped (@scope/name)
*/
private validateScopedName;
/**
* Fetch with timeout support
*/
private fetchWithTimeout;
}
interface MpakBundleCacheOptions {
mpakHome?: string;
}
/**
* Manages the local bundle cache (`~/.mpak/cache/`).
*
* Handles downloading, extracting, and tracking cached bundles.
* The cache directory is derived from `mpakHome` — the root directory
* for all mpak state. Consumers can wire this to `MpakConfigManager.mpakHome`
* for a shared base, or pass any directory.
*
* Requires an `MpakClient` for registry operations (download, update checks).
*
* @example
* ```ts
* // Via MpakSDK facade (recommended)
* const mpak = new MpakSDK();
* await mpak.cache.loadBundle('@scope/name');
*
* // Standalone
* const client = new MpakClient();
* const cache = new MpakBundleCache(client, { mpakHome: '/path/to/.mpak' });
* await cache.loadBundle('@scope/name');
* ```
*/
declare class MpakBundleCache {
readonly cacheHome: string;
private readonly mpakClient;
constructor(client: MpakClient, options?: MpakBundleCacheOptions);
/**
* Compute the cache path for a package. Does not create the directory.
* @example getPackageCachePath('@scope/name') => '<cacheBase>/scope-name'
*/
getBundleCacheDirName(packageName: string): string;
/**
* Read and validate cache metadata for a package.
* Returns `null` if the package does not exist in the cache.
* throws Error if metadata is corrupt
*/
getBundleMetadata(packageName: string): CacheMetadata | null;
/**
* Read and validate the MCPB manifest from a cached package.
* Returns `null` if the package is not cached (directory doesn't exist).
*
* @example
* ```typescript
* // Resolve from mpak registry
* const skill = await client.resolveSkillRef({
* source: 'mpak',
* name: '@nimblebraininc/folk-crm',
* version: '1.3.0',
* });
* @throws {MpakCacheCorruptedError} If the cache directory exists but
* `manifest.json` is missing, contains invalid JSON, or fails schema validation.
*/
getBundleManifest(packageName: string): McpbManifest | null;
/**
* Scan the cache directory and return metadata for every cached registry bundle.
* Skips the `_local/` directory (local dev bundles) and entries with
* missing/corrupt metadata or manifests.
*/
listCachedBundles(): CachedBundleInfo[];
/**
* Remove a cached bundle from disk.
* @returns `true` if the bundle was cached and removed, `false` if it wasn't cached.
*/
removeCachedBundle(packageName: string): boolean;
/**
* Load a bundle into the local cache, downloading from the registry only
* if the cache is missing or stale. Returns the cache directory and version.
*
* // Resolve from GitHub
* const skill = await client.resolveSkillRef({
* source: 'github',
* name: '@example/my-skill',
* version: 'v1.0.0',
* repo: 'owner/repo',
* path: 'skills/my-skill/SKILL.md',
* });
* Requires an `MpakClient` to be provided at construction time.
*
* // Resolve from URL
* const skill = await client.resolveSkillRef({
* source: 'url',
* name: '@example/custom',
* version: '1.0.0',
* url: 'https://example.com/skill.md',
* });
* ```
* @param name - Scoped package name (e.g. `@scope/bundle`)
* @param options.version - Specific version to load. Omit for "latest".
* @param options.force - Skip cache checks and always re-download.
*
* @returns `cacheDir` — path to the extracted bundle on disk,
* `version` — the resolved version string,
* `pulled` — whether a download actually occurred.
*
* @throws If no `MpakClient` was provided at construction time.
*/
resolveSkillRef(ref: SkillReference): Promise<ResolvedSkill>;
loadBundle(name: string, options?: {
version?: string;
force?: boolean;
}): Promise<{
cacheDir: string;
version: string;
pulled: boolean;
}>;
/**
* Resolve a skill from mpak registry
* Fire-and-forget background check for bundle updates.
* Return the latest version string if an update is available, null otherwise (not cached, skipped, up-to-date, or error).
* The caller can just check `if (result) { console.log("update available: " + result) }`
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
*/
checkForUpdate(packageName: string, options?: {
force?: boolean;
}): Promise<string | null>;
/**
* Write cache metadata for a package.
* @throws If the metadata fails schema validation.
*/
private writeCacheMetadata;
/**
* Download a bundle using pre-resolved download info, extract it into
* the cache, and write metadata.
*/
private downloadAndExtract;
}
/**
* Zod schema for per-package user configuration.
* Each key-value pair represents a user-supplied config value
* (e.g. API keys, workspace IDs) referenced via `${user_config.*}` in manifests.
*/
declare const PackageConfigSchema: z.ZodRecord<z.ZodString, z.ZodString>;
/**
* Per-package user configuration — a string-to-string map of values
* that bundles reference via `${user_config.*}` placeholders.
*/
type PackageConfig = z.infer<typeof PackageConfigSchema>;
/**
* Manages the mpak user configuration file (`config.json`).
*
* Handles:
* - **Registry URL** — custom registry endpoint with hardcoded default fallback
* - **Per-package config** — key-value pairs for `${user_config.*}` substitution
*
* The config is lazy-loaded on first access and cached in memory.
* The config directory is created lazily on first write — read-only usage
* never touches the filesystem.
* All writes are validated against the schema before flushing to disk
* with `0o600` permissions (owner read/write only).
*
* @example
* ```ts
* // Default: ~/.mpak/config.json
* const config = new MpakConfigManager();
*
* // Custom home and registry URL
* const config = new MpakConfigManager({ mpakHome: '/tmp/test-mpak', registryUrl: 'https://custom.registry.dev' });
*
* // Registry URL (config > default)
* config.getRegistryUrl();
*
* // Per-package user config for ${user_config.*} substitution
* config.setPackageConfigValue('@scope/bundle', 'api_key', 'sk-...');
* config.getPackageConfig('@scope/bundle'); // { api_key: 'sk-...' }
* ```
*/
interface MpakConfigManagerOptions {
mpakHome?: string;
registryUrl?: string;
}
declare class MpakConfigManager {
readonly mpakHome: string;
private configFile;
private config;
constructor(options?: MpakConfigManagerOptions);
/**
* Resolve the registry URL with a 2-tier fallback:
* 1. Saved value in config file
* 2. Default: `https://registry.mpak.dev`
*
* The API returns a ZIP bundle containing SKILL.md and metadata.
* @returns The resolved registry URL
*/
private resolveMpakSkill;
getRegistryUrl(): string;
/**
* Resolve a skill from GitHub releases
* Get all stored user config values for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns The key-value map, or `undefined` if the package has no stored config
*/
private resolveGithubSkill;
getPackageConfig(packageName: string): PackageConfig | undefined;
/**
* Resolve a skill from a direct URL
* Store a user config value for a package. Creates the package entry if needed.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key (e.g. `api_key`)
* @param value - The value to store
*/
private resolveUrlSkill;
setPackageConfigValue(packageName: string, key: string, value: string): void;
/**
* Extract SKILL.md content from a skill bundle ZIP
* Remove all stored config for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns `true` if the package had config that was removed, `false` if it didn't exist
*/
private extractSkillFromZip;
clearPackageConfig(packageName: string): boolean;
/**
* Verify content integrity and throw if mismatch (fail-closed)
* Remove a single config value for a package. If this was the last key,
* the package entry is cleaned up entirely.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key to remove
* @returns `true` if the key existed and was removed, `false` otherwise
*/
private verifyIntegrityOrThrow;
clearPackageConfigValue(packageName: string, key: string): boolean;
/**
* Extract hash from integrity string (removes prefix)
* List all package names that have stored user config.
*
* @returns Array of scoped package names (e.g. `['@scope/pkg1', '@scope/pkg2']`)
*/
private extractHash;
getPackageNames(): string[];
/**
* Detect the current platform
* Load the config from disk, or create a fresh one if the file doesn't exist yet.
* The result is cached — subsequent calls return the in-memory copy without
* re-reading the file.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file exists but contains invalid JSON or fails schema validation
*/
static detectPlatform(): Platform;
private loadConfig;
/**
* Compute SHA256 hash of content
* Read the config file from disk, parse JSON, and validate against the schema.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file can't be read, contains invalid JSON,
* or doesn't match the expected schema
*/
private computeSha256;
private readAndValidateConfig;
/**
* Validate that a name is scoped (@scope/name)
* Flush the in-memory config to disk. Creates the config directory if needed,
* validates against the schema, updates `lastUpdated`, and writes
* with mode `0o600` (owner read/write only — config may contain secrets).
*
* @throws {MpakConfigCorruptedError} If the in-memory config fails schema validation
*/
private validateScopedName;
private saveConfig;
/**
* Fetch with timeout support
* Persist a custom registry URL to the config file.
*
* @param url - The registry URL to save (e.g. `https://registry.example.com`)
*/
private fetchWithTimeout;
private setRegistryUrl;
}
/**
* Options for the {@link Mpak} facade.
*
* All fields are optional — sensible defaults are derived from
* `MpakConfigManager` (registry URL, mpakHome) when omitted.
*/
interface MpakOptions {
/** Root directory for mpak state. Defaults to `~/.mpak`. */
mpakHome?: string;
/** Registry URL override. Defaults to `MpakConfigManager.getRegistryUrl()`. */
registryUrl?: string;
/** Request timeout in milliseconds for the client. */
timeout?: number;
/** User-Agent string sent with every request. */
userAgent?: string;
}
/**
* Specifies which bundle to prepare.
*
* - `{ name, version? }` — registry bundle. Omit `version` for "latest".
* - `{ local }` — a local `.mcpb` file on disk. The caller is responsible for
* validating that the path exists and has a `.mcpb` extension before calling.
*/
type PrepareServerSpec = {
name: string;
version?: string;
} | {
local: string;
};
/**
* Options for {@link Mpak.prepareServer}.
*/
interface PrepareServerOptions {
/** Skip cache and re-download/re-extract. */
force?: boolean;
/** Extra environment variables merged on top of the manifest env. */
env?: Record<string, string>;
/**
* Directory for `MPAK_WORKSPACE` — where stateful bundles write
* project-local data (databases, logs, etc.). Defaults to `process.cwd()/.mpak`.
*/
workspaceDir?: string;
}
/**
* Fully resolved server configuration, ready to spawn.
*/
interface ServerCommand {
/** The executable command (e.g. `"node"`, `"python3"`, or an absolute binary path). */
command: string;
/** Arguments to pass to the command. */
args: string[];
/** Environment variables (manifest env + user config substitutions + caller overrides). */
env: Record<string, string>;
/** Working directory for the spawned process — the extracted bundle's cache directory. */
cwd: string;
/** The resolved package name. */
name: string;
/** The resolved version string. */
version: string;
}
/**
* Top-level facade that wires together the SDK's core components:
* `MpakConfigManager`, `MpakClient`, and `BundleCache`.
*
* Provides a single entry point for the common setup pattern,
* while still exposing each component for direct use.
*
* @example
* ```ts
* import { MpakSDK } from '@nimblebrain/mpak-sdk';
*
* const mpak = new MpakSDK();
*
* // Prepare a server for spawning
* const server = await mpak.prepareServer('@scope/pkg');
* const child = spawn(server.command, server.args, {
* env: { ...server.env, ...process.env },
* cwd: server.cwd,
* stdio: 'inherit',
* });
*
* // Access components directly
* mpak.config.setPackageConfigValue('@scope/pkg', 'api_key', 'sk-...');
* const bundles = await mpak.client.searchBundles({ q: 'mcp' });
* ```
*/
declare class Mpak {
/** User configuration manager (`config.json`). */
readonly configManager: MpakConfigManager;
/** Registry API client. */
readonly client: MpakClient;
/** Local bundle cache. */
readonly bundleCache: MpakBundleCache;
constructor(options?: MpakOptions);
/**
* Prepare a bundle for execution.
*
* Accepts either a registry spec (`{ name, version? }`) or a local bundle
* spec (`{ local }`). Downloads/extracts as needed, reads the manifest,
* validates user config, and resolves the command, args, and env needed
* to spawn the MCP server process.
*
* @param spec - Which bundle to prepare. See {@link PrepareServerSpec}.
* @param options - Force re-download/re-extract, extra env, and workspace dir.
*
* @throws {MpakConfigError} If required user config values are missing.
* @throws {MpakCacheCorruptedError} If the manifest is missing or corrupt after download.
*/
prepareServer(spec: PrepareServerSpec, options?: PrepareServerOptions): Promise<ServerCommand>;
/**
* Load a registry bundle into cache and read its manifest.
*/
private prepareRegistryBundle;
/**
* Extract a local `.mcpb` bundle (if stale) and read its manifest.
* Local bundles are cached under `<cacheHome>/_local/<hash>`.
*
* The caller is responsible for validating that `bundlePath` exists
* and has a `.mcpb` extension before calling this method.
*/
private prepareLocalBundle;
/**
* Gather stored user config values and validate that all required fields are present.
* @throws If required config values are missing.
*/
private gatherUserConfig;
/**
* Resolve the manifest's `server` block into a spawnable command, args, and env.
*
* Handles three server types:
* - **binary** — runs the compiled executable at `entry_point`, chmod'd +x.
* - **node** — runs `mcp_config.command` (default `"node"`) with `mcp_config.args`,
* or falls back to `node <entry_point>` when args are empty.
* - **python** — like node, but resolves `python3`/`python` at runtime and
* prepends `<cacheDir>/deps` to `PYTHONPATH` for bundled dependencies.
*
* All `${__dirname}` placeholders in args are replaced with `cacheDir`.
* All `${user_config.*}` placeholders in env are replaced with gathered user values.
*
* @throws For unsupported server types.
*/
private resolveCommand;
/**
* Substitute `${__dirname}` placeholders in args.
*/
private static resolveArgs;
/**
* Substitute `${user_config.*}` placeholders in env vars.
*/
private static substituteEnvVars;
/**
* Find a working Python executable. Tries `python3` first, falls back to `python`.
*/
private static findPythonCommand;
}
/**
* Parse a scoped package spec (`@scope/name` or `@scope/name@version`)
* into its name and optional version components.
*
* @throws {MpakError} If the spec is not a valid scoped package name.
*
* @example
* parsePackageSpec('@scope/name') // { name: '@scope/name' }
* parsePackageSpec('@scope/name@1.0.0') // { name: '@scope/name', version: '1.0.0' }
*/
declare function parsePackageSpec(spec: string): {
name: string;
version?: string;
};
/**
* Base error class for mpak SDK errors

@@ -297,3 +564,61 @@ */

}
/**
* Thrown when the config file cannot be read, parsed, or validated.
*
* @param message - Human-readable description of what went wrong
* @param configPath - Absolute path to the config file that failed
* @param cause - The underlying error (parse failure, read error, etc.)
*/
declare class MpakConfigCorruptedError extends MpakError {
readonly configPath: string;
readonly cause?: Error | undefined;
constructor(message: string, configPath: string, cause?: Error | undefined);
}
/**
* Thrown when required user config fields are missing for a package.
*
* @param packageName - The package that requires config
* @param missingFields - Structured list of missing fields
*/
/**
* Thrown when cache metadata or manifest is missing, corrupt, or fails validation.
*
* @param message - Human-readable description of what went wrong
* @param filePath - Absolute path to the file that failed
* @param cause - The underlying error (parse failure, validation error, etc.)
*/
declare class MpakCacheCorruptedError extends MpakError {
readonly filePath: string;
readonly cause?: Error | undefined;
constructor(message: string, filePath: string, cause?: Error | undefined);
}
/**
* Thrown when a local `.mcpb` bundle is invalid — e.g. manifest is missing,
* contains invalid JSON, or fails schema validation.
*
* @param message - Human-readable description of what went wrong
* @param bundlePath - Absolute path to the `.mcpb` file
* @param cause - The underlying error
*/
declare class MpakInvalidBundleError extends MpakError {
readonly bundlePath: string;
readonly cause?: Error | undefined;
constructor(message: string, bundlePath: string, cause?: Error | undefined);
}
declare class MpakConfigError extends MpakError {
readonly packageName: string;
readonly missingFields: Array<{
key: string;
title: string;
description?: string;
sensitive: boolean;
}>;
constructor(packageName: string, missingFields: Array<{
key: string;
title: string;
description?: string;
sensitive: boolean;
}>);
}
export { type Author, type BundleArtifact, type BundleDownloadInfo, type BundleSearchParams, type BundleVersion, type GithubSkillReference, MpakClient, type MpakClientConfig, MpakError, MpakIntegrityError, MpakNetworkError, MpakNotFoundError, type MpakSkillReference, type Platform, type Provenance, type ResolvedSkill, type Skill, type SkillReference, type SkillSearchParams, type SkillVersion, type UrlSkillReference };
export { Mpak, MpakBundleCache, type MpakBundleCacheOptions, MpakCacheCorruptedError, MpakClient, type MpakClientConfig, MpakConfigCorruptedError, MpakConfigError, MpakConfigManager, type MpakConfigManagerOptions, MpakError, MpakIntegrityError, MpakInvalidBundleError, MpakNetworkError, MpakNotFoundError, type MpakOptions, type PackageConfig, type PrepareServerOptions, type PrepareServerSpec, type ServerCommand, parsePackageSpec };

@@ -1,3 +0,3 @@

import { VersionDetail, DownloadInfo, VersionsResponse, PlatformInfo, FullProvenance, SkillSummary, SkillDetail, BundleSearchResponse, BundleDetail, SkillSearchResponse, SkillDownloadInfo } from '@nimblebrain/mpak-schemas';
export { Bundle, BundleDetail, BundleDetail as BundleDetailResponse, DownloadInfo as BundleDownloadResponse, BundleSearchResponse, VersionDetail as BundleVersionResponse, VersionsResponse as BundleVersionsResponse, Pagination, SkillDetail, SkillDetail as SkillDetailResponse, SkillDownloadInfo, SkillDownloadInfo as SkillDownloadResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas';
import { BundleSearchParamsInput, BundleSearchResponse, BundleDetail, VersionsResponse, VersionDetail, PlatformInfo, DownloadInfo, SkillSearchParamsInput, SkillSearchResponse, SkillDetail, SkillDownloadInfo, CacheMetadata, McpbManifest, CachedBundleInfo } from '@nimblebrain/mpak-schemas';
import { z } from 'zod';

@@ -7,46 +7,6 @@ /**

*
* API response types are re-exported from @nimblebrain/mpak-schemas.
* This file contains SDK-specific types (client config, skill references, etc.)
* API response types should be imported directly from @nimblebrain/mpak-schemas.
* This file contains only SDK-specific types (client config).
*/
/** Version in versions listing */
type BundleVersion = VersionsResponse['versions'][number];
/** Artifact in version detail */
type BundleArtifact = VersionDetail['artifacts'][number];
/** Download info alias */
type BundleDownloadInfo = DownloadInfo;
/** Skill summary in search results */
type Skill = SkillSummary;
/** Skill version in detail */
type SkillVersion = SkillDetail['versions'][number];
/** Platform identifier (os + arch) */
type Platform = PlatformInfo;
/** Provenance information for verified packages */
type Provenance = FullProvenance;
/** Author information */
interface Author {
name: string;
url?: string;
email?: string;
}
/** Query parameters for bundle search */
interface BundleSearchParams {
q?: string;
type?: string;
sort?: 'downloads' | 'recent' | 'name';
limit?: number;
offset?: number;
}
/** Query parameters for skill search */
interface SkillSearchParams {
q?: string;
tags?: string;
category?: string;
surface?: string;
sort?: 'downloads' | 'recent' | 'name';
limit?: number;
offset?: number;
}
/**

@@ -72,54 +32,2 @@ * Configuration options for MpakClient

}
/**
* Base fields shared by all skill reference types
*/
interface SkillReferenceBase {
/** Skill artifact identifier (e.g., '@nimbletools/folk-crm') */
name: string;
/** Semver version (e.g., '1.0.0') or 'latest' */
version: string;
/** SHA256 integrity hash (format: 'sha256-hexdigest') */
integrity?: string;
}
/**
* Skill reference from mpak registry
*/
interface MpakSkillReference extends SkillReferenceBase {
source: 'mpak';
}
/**
* Skill reference from GitHub repository
*/
interface GithubSkillReference extends SkillReferenceBase {
source: 'github';
/** GitHub repository (owner/repo) */
repo: string;
/** Path to skill file in repo */
path: string;
}
/**
* Skill reference from direct URL
*/
interface UrlSkillReference extends SkillReferenceBase {
source: 'url';
/** Direct download URL */
url: string;
}
/**
* Discriminated union of skill reference types
*/
type SkillReference = MpakSkillReference | GithubSkillReference | UrlSkillReference;
/**
* Result of resolving a skill reference
*/
interface ResolvedSkill {
/** The markdown content of the skill */
content: string;
/** Version that was resolved */
version: string;
/** Source the skill was fetched from */
source: 'mpak' | 'github' | 'url';
/** Whether integrity was verified */
verified: boolean;
}

@@ -130,3 +38,2 @@ /**

* Requires Node.js 18+ for native fetch support.
* Uses jszip for skill bundle extraction.
*/

@@ -141,3 +48,3 @@ declare class MpakClient {

*/
searchBundles(params?: BundleSearchParams): Promise<BundleSearchResponse>;
searchBundles(params?: BundleSearchParamsInput): Promise<BundleSearchResponse>;
/**

@@ -158,7 +65,7 @@ * Get bundle details

*/
getBundleDownload(name: string, version: string, platform?: Platform): Promise<DownloadInfo>;
getBundleDownload(name: string, version: string, platform?: PlatformInfo): Promise<DownloadInfo>;
/**
* Search for skills
*/
searchSkills(params?: SkillSearchParams): Promise<SkillSearchResponse>;
searchSkills(params?: SkillSearchParamsInput): Promise<SkillSearchResponse>;
/**

@@ -177,93 +84,453 @@ * Get skill details

/**
* Download skill content and verify integrity
* Download content from a URL and verify its SHA-256 integrity.
*
* @throws {MpakIntegrityError} If expectedSha256 is provided and doesn't match (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
downloadSkillContent(downloadUrl: string, expectedSha256?: string): Promise<{
content: string;
verified: boolean;
downloadContent(url: string, sha256: string): Promise<Uint8Array>;
/**
* Download a bundle by name, with optional version and platform.
* Defaults to latest version and auto-detected platform.
*
* @throws {MpakNotFoundError} If bundle not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
downloadBundle(name: string, version?: string, platform?: PlatformInfo): Promise<{
data: Uint8Array;
metadata: DownloadInfo['bundle'];
}>;
/**
* Resolve a skill reference to actual content
* Download a skill bundle by name, with optional version.
* Defaults to latest version.
*
* Supports mpak, github, and url sources. This is the main method for
* fetching skill content from any supported source.
*
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If integrity check fails (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
downloadSkillBundle(name: string, version?: string): Promise<{
data: Uint8Array;
metadata: SkillDownloadInfo['skill'];
}>;
/**
* Detect the current platform
*/
static detectPlatform(): PlatformInfo;
/**
* Compute SHA256 hash of content
*/
private computeSha256;
/**
* Validate that a name is scoped (@scope/name)
*/
private validateScopedName;
/**
* Fetch with timeout support
*/
private fetchWithTimeout;
}
interface MpakBundleCacheOptions {
mpakHome?: string;
}
/**
* Manages the local bundle cache (`~/.mpak/cache/`).
*
* Handles downloading, extracting, and tracking cached bundles.
* The cache directory is derived from `mpakHome` — the root directory
* for all mpak state. Consumers can wire this to `MpakConfigManager.mpakHome`
* for a shared base, or pass any directory.
*
* Requires an `MpakClient` for registry operations (download, update checks).
*
* @example
* ```ts
* // Via MpakSDK facade (recommended)
* const mpak = new MpakSDK();
* await mpak.cache.loadBundle('@scope/name');
*
* // Standalone
* const client = new MpakClient();
* const cache = new MpakBundleCache(client, { mpakHome: '/path/to/.mpak' });
* await cache.loadBundle('@scope/name');
* ```
*/
declare class MpakBundleCache {
readonly cacheHome: string;
private readonly mpakClient;
constructor(client: MpakClient, options?: MpakBundleCacheOptions);
/**
* Compute the cache path for a package. Does not create the directory.
* @example getPackageCachePath('@scope/name') => '<cacheBase>/scope-name'
*/
getBundleCacheDirName(packageName: string): string;
/**
* Read and validate cache metadata for a package.
* Returns `null` if the package does not exist in the cache.
* throws Error if metadata is corrupt
*/
getBundleMetadata(packageName: string): CacheMetadata | null;
/**
* Read and validate the MCPB manifest from a cached package.
* Returns `null` if the package is not cached (directory doesn't exist).
*
* @example
* ```typescript
* // Resolve from mpak registry
* const skill = await client.resolveSkillRef({
* source: 'mpak',
* name: '@nimblebraininc/folk-crm',
* version: '1.3.0',
* });
* @throws {MpakCacheCorruptedError} If the cache directory exists but
* `manifest.json` is missing, contains invalid JSON, or fails schema validation.
*/
getBundleManifest(packageName: string): McpbManifest | null;
/**
* Scan the cache directory and return metadata for every cached registry bundle.
* Skips the `_local/` directory (local dev bundles) and entries with
* missing/corrupt metadata or manifests.
*/
listCachedBundles(): CachedBundleInfo[];
/**
* Remove a cached bundle from disk.
* @returns `true` if the bundle was cached and removed, `false` if it wasn't cached.
*/
removeCachedBundle(packageName: string): boolean;
/**
* Load a bundle into the local cache, downloading from the registry only
* if the cache is missing or stale. Returns the cache directory and version.
*
* // Resolve from GitHub
* const skill = await client.resolveSkillRef({
* source: 'github',
* name: '@example/my-skill',
* version: 'v1.0.0',
* repo: 'owner/repo',
* path: 'skills/my-skill/SKILL.md',
* });
* Requires an `MpakClient` to be provided at construction time.
*
* // Resolve from URL
* const skill = await client.resolveSkillRef({
* source: 'url',
* name: '@example/custom',
* version: '1.0.0',
* url: 'https://example.com/skill.md',
* });
* ```
* @param name - Scoped package name (e.g. `@scope/bundle`)
* @param options.version - Specific version to load. Omit for "latest".
* @param options.force - Skip cache checks and always re-download.
*
* @returns `cacheDir` — path to the extracted bundle on disk,
* `version` — the resolved version string,
* `pulled` — whether a download actually occurred.
*
* @throws If no `MpakClient` was provided at construction time.
*/
resolveSkillRef(ref: SkillReference): Promise<ResolvedSkill>;
loadBundle(name: string, options?: {
version?: string;
force?: boolean;
}): Promise<{
cacheDir: string;
version: string;
pulled: boolean;
}>;
/**
* Resolve a skill from mpak registry
* Fire-and-forget background check for bundle updates.
* Return the latest version string if an update is available, null otherwise (not cached, skipped, up-to-date, or error).
* The caller can just check `if (result) { console.log("update available: " + result) }`
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
*/
checkForUpdate(packageName: string, options?: {
force?: boolean;
}): Promise<string | null>;
/**
* Write cache metadata for a package.
* @throws If the metadata fails schema validation.
*/
private writeCacheMetadata;
/**
* Download a bundle using pre-resolved download info, extract it into
* the cache, and write metadata.
*/
private downloadAndExtract;
}
/**
* Zod schema for per-package user configuration.
* Each key-value pair represents a user-supplied config value
* (e.g. API keys, workspace IDs) referenced via `${user_config.*}` in manifests.
*/
declare const PackageConfigSchema: z.ZodRecord<z.ZodString, z.ZodString>;
/**
* Per-package user configuration — a string-to-string map of values
* that bundles reference via `${user_config.*}` placeholders.
*/
type PackageConfig = z.infer<typeof PackageConfigSchema>;
/**
* Manages the mpak user configuration file (`config.json`).
*
* Handles:
* - **Registry URL** — custom registry endpoint with hardcoded default fallback
* - **Per-package config** — key-value pairs for `${user_config.*}` substitution
*
* The config is lazy-loaded on first access and cached in memory.
* The config directory is created lazily on first write — read-only usage
* never touches the filesystem.
* All writes are validated against the schema before flushing to disk
* with `0o600` permissions (owner read/write only).
*
* @example
* ```ts
* // Default: ~/.mpak/config.json
* const config = new MpakConfigManager();
*
* // Custom home and registry URL
* const config = new MpakConfigManager({ mpakHome: '/tmp/test-mpak', registryUrl: 'https://custom.registry.dev' });
*
* // Registry URL (config > default)
* config.getRegistryUrl();
*
* // Per-package user config for ${user_config.*} substitution
* config.setPackageConfigValue('@scope/bundle', 'api_key', 'sk-...');
* config.getPackageConfig('@scope/bundle'); // { api_key: 'sk-...' }
* ```
*/
interface MpakConfigManagerOptions {
mpakHome?: string;
registryUrl?: string;
}
declare class MpakConfigManager {
readonly mpakHome: string;
private configFile;
private config;
constructor(options?: MpakConfigManagerOptions);
/**
* Resolve the registry URL with a 2-tier fallback:
* 1. Saved value in config file
* 2. Default: `https://registry.mpak.dev`
*
* The API returns a ZIP bundle containing SKILL.md and metadata.
* @returns The resolved registry URL
*/
private resolveMpakSkill;
getRegistryUrl(): string;
/**
* Resolve a skill from GitHub releases
* Get all stored user config values for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns The key-value map, or `undefined` if the package has no stored config
*/
private resolveGithubSkill;
getPackageConfig(packageName: string): PackageConfig | undefined;
/**
* Resolve a skill from a direct URL
* Store a user config value for a package. Creates the package entry if needed.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key (e.g. `api_key`)
* @param value - The value to store
*/
private resolveUrlSkill;
setPackageConfigValue(packageName: string, key: string, value: string): void;
/**
* Extract SKILL.md content from a skill bundle ZIP
* Remove all stored config for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns `true` if the package had config that was removed, `false` if it didn't exist
*/
private extractSkillFromZip;
clearPackageConfig(packageName: string): boolean;
/**
* Verify content integrity and throw if mismatch (fail-closed)
* Remove a single config value for a package. If this was the last key,
* the package entry is cleaned up entirely.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key to remove
* @returns `true` if the key existed and was removed, `false` otherwise
*/
private verifyIntegrityOrThrow;
clearPackageConfigValue(packageName: string, key: string): boolean;
/**
* Extract hash from integrity string (removes prefix)
* List all package names that have stored user config.
*
* @returns Array of scoped package names (e.g. `['@scope/pkg1', '@scope/pkg2']`)
*/
private extractHash;
getPackageNames(): string[];
/**
* Detect the current platform
* Load the config from disk, or create a fresh one if the file doesn't exist yet.
* The result is cached — subsequent calls return the in-memory copy without
* re-reading the file.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file exists but contains invalid JSON or fails schema validation
*/
static detectPlatform(): Platform;
private loadConfig;
/**
* Compute SHA256 hash of content
* Read the config file from disk, parse JSON, and validate against the schema.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file can't be read, contains invalid JSON,
* or doesn't match the expected schema
*/
private computeSha256;
private readAndValidateConfig;
/**
* Validate that a name is scoped (@scope/name)
* Flush the in-memory config to disk. Creates the config directory if needed,
* validates against the schema, updates `lastUpdated`, and writes
* with mode `0o600` (owner read/write only — config may contain secrets).
*
* @throws {MpakConfigCorruptedError} If the in-memory config fails schema validation
*/
private validateScopedName;
private saveConfig;
/**
* Fetch with timeout support
* Persist a custom registry URL to the config file.
*
* @param url - The registry URL to save (e.g. `https://registry.example.com`)
*/
private fetchWithTimeout;
private setRegistryUrl;
}
/**
* Options for the {@link Mpak} facade.
*
* All fields are optional — sensible defaults are derived from
* `MpakConfigManager` (registry URL, mpakHome) when omitted.
*/
interface MpakOptions {
/** Root directory for mpak state. Defaults to `~/.mpak`. */
mpakHome?: string;
/** Registry URL override. Defaults to `MpakConfigManager.getRegistryUrl()`. */
registryUrl?: string;
/** Request timeout in milliseconds for the client. */
timeout?: number;
/** User-Agent string sent with every request. */
userAgent?: string;
}
/**
* Specifies which bundle to prepare.
*
* - `{ name, version? }` — registry bundle. Omit `version` for "latest".
* - `{ local }` — a local `.mcpb` file on disk. The caller is responsible for
* validating that the path exists and has a `.mcpb` extension before calling.
*/
type PrepareServerSpec = {
name: string;
version?: string;
} | {
local: string;
};
/**
* Options for {@link Mpak.prepareServer}.
*/
interface PrepareServerOptions {
/** Skip cache and re-download/re-extract. */
force?: boolean;
/** Extra environment variables merged on top of the manifest env. */
env?: Record<string, string>;
/**
* Directory for `MPAK_WORKSPACE` — where stateful bundles write
* project-local data (databases, logs, etc.). Defaults to `process.cwd()/.mpak`.
*/
workspaceDir?: string;
}
/**
* Fully resolved server configuration, ready to spawn.
*/
interface ServerCommand {
/** The executable command (e.g. `"node"`, `"python3"`, or an absolute binary path). */
command: string;
/** Arguments to pass to the command. */
args: string[];
/** Environment variables (manifest env + user config substitutions + caller overrides). */
env: Record<string, string>;
/** Working directory for the spawned process — the extracted bundle's cache directory. */
cwd: string;
/** The resolved package name. */
name: string;
/** The resolved version string. */
version: string;
}
/**
* Top-level facade that wires together the SDK's core components:
* `MpakConfigManager`, `MpakClient`, and `BundleCache`.
*
* Provides a single entry point for the common setup pattern,
* while still exposing each component for direct use.
*
* @example
* ```ts
* import { MpakSDK } from '@nimblebrain/mpak-sdk';
*
* const mpak = new MpakSDK();
*
* // Prepare a server for spawning
* const server = await mpak.prepareServer('@scope/pkg');
* const child = spawn(server.command, server.args, {
* env: { ...server.env, ...process.env },
* cwd: server.cwd,
* stdio: 'inherit',
* });
*
* // Access components directly
* mpak.config.setPackageConfigValue('@scope/pkg', 'api_key', 'sk-...');
* const bundles = await mpak.client.searchBundles({ q: 'mcp' });
* ```
*/
declare class Mpak {
/** User configuration manager (`config.json`). */
readonly configManager: MpakConfigManager;
/** Registry API client. */
readonly client: MpakClient;
/** Local bundle cache. */
readonly bundleCache: MpakBundleCache;
constructor(options?: MpakOptions);
/**
* Prepare a bundle for execution.
*
* Accepts either a registry spec (`{ name, version? }`) or a local bundle
* spec (`{ local }`). Downloads/extracts as needed, reads the manifest,
* validates user config, and resolves the command, args, and env needed
* to spawn the MCP server process.
*
* @param spec - Which bundle to prepare. See {@link PrepareServerSpec}.
* @param options - Force re-download/re-extract, extra env, and workspace dir.
*
* @throws {MpakConfigError} If required user config values are missing.
* @throws {MpakCacheCorruptedError} If the manifest is missing or corrupt after download.
*/
prepareServer(spec: PrepareServerSpec, options?: PrepareServerOptions): Promise<ServerCommand>;
/**
* Load a registry bundle into cache and read its manifest.
*/
private prepareRegistryBundle;
/**
* Extract a local `.mcpb` bundle (if stale) and read its manifest.
* Local bundles are cached under `<cacheHome>/_local/<hash>`.
*
* The caller is responsible for validating that `bundlePath` exists
* and has a `.mcpb` extension before calling this method.
*/
private prepareLocalBundle;
/**
* Gather stored user config values and validate that all required fields are present.
* @throws If required config values are missing.
*/
private gatherUserConfig;
/**
* Resolve the manifest's `server` block into a spawnable command, args, and env.
*
* Handles three server types:
* - **binary** — runs the compiled executable at `entry_point`, chmod'd +x.
* - **node** — runs `mcp_config.command` (default `"node"`) with `mcp_config.args`,
* or falls back to `node <entry_point>` when args are empty.
* - **python** — like node, but resolves `python3`/`python` at runtime and
* prepends `<cacheDir>/deps` to `PYTHONPATH` for bundled dependencies.
*
* All `${__dirname}` placeholders in args are replaced with `cacheDir`.
* All `${user_config.*}` placeholders in env are replaced with gathered user values.
*
* @throws For unsupported server types.
*/
private resolveCommand;
/**
* Substitute `${__dirname}` placeholders in args.
*/
private static resolveArgs;
/**
* Substitute `${user_config.*}` placeholders in env vars.
*/
private static substituteEnvVars;
/**
* Find a working Python executable. Tries `python3` first, falls back to `python`.
*/
private static findPythonCommand;
}
/**
* Parse a scoped package spec (`@scope/name` or `@scope/name@version`)
* into its name and optional version components.
*
* @throws {MpakError} If the spec is not a valid scoped package name.
*
* @example
* parsePackageSpec('@scope/name') // { name: '@scope/name' }
* parsePackageSpec('@scope/name@1.0.0') // { name: '@scope/name', version: '1.0.0' }
*/
declare function parsePackageSpec(spec: string): {
name: string;
version?: string;
};
/**
* Base error class for mpak SDK errors

@@ -297,3 +564,61 @@ */

}
/**
* Thrown when the config file cannot be read, parsed, or validated.
*
* @param message - Human-readable description of what went wrong
* @param configPath - Absolute path to the config file that failed
* @param cause - The underlying error (parse failure, read error, etc.)
*/
declare class MpakConfigCorruptedError extends MpakError {
readonly configPath: string;
readonly cause?: Error | undefined;
constructor(message: string, configPath: string, cause?: Error | undefined);
}
/**
* Thrown when required user config fields are missing for a package.
*
* @param packageName - The package that requires config
* @param missingFields - Structured list of missing fields
*/
/**
* Thrown when cache metadata or manifest is missing, corrupt, or fails validation.
*
* @param message - Human-readable description of what went wrong
* @param filePath - Absolute path to the file that failed
* @param cause - The underlying error (parse failure, validation error, etc.)
*/
declare class MpakCacheCorruptedError extends MpakError {
readonly filePath: string;
readonly cause?: Error | undefined;
constructor(message: string, filePath: string, cause?: Error | undefined);
}
/**
* Thrown when a local `.mcpb` bundle is invalid — e.g. manifest is missing,
* contains invalid JSON, or fails schema validation.
*
* @param message - Human-readable description of what went wrong
* @param bundlePath - Absolute path to the `.mcpb` file
* @param cause - The underlying error
*/
declare class MpakInvalidBundleError extends MpakError {
readonly bundlePath: string;
readonly cause?: Error | undefined;
constructor(message: string, bundlePath: string, cause?: Error | undefined);
}
declare class MpakConfigError extends MpakError {
readonly packageName: string;
readonly missingFields: Array<{
key: string;
title: string;
description?: string;
sensitive: boolean;
}>;
constructor(packageName: string, missingFields: Array<{
key: string;
title: string;
description?: string;
sensitive: boolean;
}>);
}
export { type Author, type BundleArtifact, type BundleDownloadInfo, type BundleSearchParams, type BundleVersion, type GithubSkillReference, MpakClient, type MpakClientConfig, MpakError, MpakIntegrityError, MpakNetworkError, MpakNotFoundError, type MpakSkillReference, type Platform, type Provenance, type ResolvedSkill, type Skill, type SkillReference, type SkillSearchParams, type SkillVersion, type UrlSkillReference };
export { Mpak, MpakBundleCache, type MpakBundleCacheOptions, MpakCacheCorruptedError, MpakClient, type MpakClientConfig, MpakConfigCorruptedError, MpakConfigError, MpakConfigManager, type MpakConfigManagerOptions, MpakError, MpakIntegrityError, MpakInvalidBundleError, MpakNetworkError, MpakNotFoundError, type MpakOptions, type PackageConfig, type PrepareServerOptions, type PrepareServerSpec, type ServerCommand, parsePackageSpec };

@@ -0,1 +1,14 @@

// src/mpakSDK.ts
import { spawnSync } from "child_process";
import { chmodSync, existsSync as existsSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
import { join as join4, resolve as resolve3 } from "path";
import { McpbManifestSchema as McpbManifestSchema2 } from "@nimblebrain/mpak-schemas";
// src/cache.ts
import { randomUUID } from "crypto";
import { existsSync as existsSync2, readdirSync, rmSync, writeFileSync } from "fs";
import { homedir, tmpdir } from "os";
import { join as join2 } from "path";
import { CacheMetadataSchema, McpbManifestSchema } from "@nimblebrain/mpak-schemas";
// src/client.ts

@@ -37,2 +50,35 @@ import { createHash } from "crypto";

};
var MpakConfigCorruptedError = class extends MpakError {
constructor(message, configPath, cause) {
super(message, "CONFIG_CORRUPTED");
this.configPath = configPath;
this.cause = cause;
this.name = "MpakConfigCorruptedError";
}
};
var MpakCacheCorruptedError = class extends MpakError {
constructor(message, filePath, cause) {
super(message, "CACHE_CORRUPTED");
this.filePath = filePath;
this.cause = cause;
this.name = "MpakCacheCorruptedError";
}
};
var MpakInvalidBundleError = class extends MpakError {
constructor(message, bundlePath, cause) {
super(message, "INVALID_BUNDLE");
this.bundlePath = bundlePath;
this.cause = cause;
this.name = "MpakInvalidBundleError";
}
};
var MpakConfigError = class extends MpakError {
constructor(packageName, missingFields) {
const fieldNames = missingFields.map((f) => f.title).join(", ");
super(`Missing required config for ${packageName}: ${fieldNames}`, "CONFIG_MISSING");
this.packageName = packageName;
this.missingFields = missingFields;
this.name = "MpakConfigError";
}
};

@@ -42,3 +88,3 @@ // src/client.ts

var DEFAULT_TIMEOUT = 3e4;
var MpakClient = class {
var MpakClient = class _MpakClient {
registryUrl;

@@ -155,3 +201,2 @@ timeout;

if (params.category) searchParams.set("category", params.category);
if (params.surface) searchParams.set("surface", params.surface);
if (params.sort) searchParams.set("sort", params.sort);

@@ -220,176 +265,52 @@ if (params.limit) searchParams.set("limit", String(params.limit));

}
// ===========================================================================
// Download Methods
// ===========================================================================
/**
* Download skill content and verify integrity
* Download content from a URL and verify its SHA-256 integrity.
*
* @throws {MpakIntegrityError} If expectedSha256 is provided and doesn't match (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
async downloadSkillContent(downloadUrl, expectedSha256) {
const response = await this.fetchWithTimeout(downloadUrl);
async downloadContent(url, sha256) {
const response = await this.fetchWithTimeout(url);
if (!response.ok) {
throw new MpakNetworkError(`Failed to download skill: HTTP ${response.status}`);
throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`);
}
const content = await response.text();
if (expectedSha256) {
const actualHash = this.computeSha256(content);
if (actualHash !== expectedSha256) {
throw new MpakIntegrityError(expectedSha256, actualHash);
}
return { content, verified: true };
const downloadedRawData = new Uint8Array(await response.arrayBuffer());
const computedHash = this.computeSha256(downloadedRawData);
if (computedHash !== sha256) {
throw new MpakIntegrityError(sha256, computedHash);
}
return { content, verified: false };
return downloadedRawData;
}
/**
* Resolve a skill reference to actual content
* Download a bundle by name, with optional version and platform.
* Defaults to latest version and auto-detected platform.
*
* Supports mpak, github, and url sources. This is the main method for
* fetching skill content from any supported source.
*
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If integrity check fails (fail-closed)
* @throws {MpakNotFoundError} If bundle not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*
* @example
* ```typescript
* // Resolve from mpak registry
* const skill = await client.resolveSkillRef({
* source: 'mpak',
* name: '@nimblebraininc/folk-crm',
* version: '1.3.0',
* });
*
* // Resolve from GitHub
* const skill = await client.resolveSkillRef({
* source: 'github',
* name: '@example/my-skill',
* version: 'v1.0.0',
* repo: 'owner/repo',
* path: 'skills/my-skill/SKILL.md',
* });
*
* // Resolve from URL
* const skill = await client.resolveSkillRef({
* source: 'url',
* name: '@example/custom',
* version: '1.0.0',
* url: 'https://example.com/skill.md',
* });
* ```
*/
async resolveSkillRef(ref) {
switch (ref.source) {
case "mpak":
return this.resolveMpakSkill(ref);
case "github":
return this.resolveGithubSkill(ref);
case "url":
return this.resolveUrlSkill(ref);
default: {
const _exhaustive = ref;
throw new Error(`Unknown skill source: ${_exhaustive.source}`);
}
}
async downloadBundle(name, version, platform) {
const resolvedPlatform = platform ?? _MpakClient.detectPlatform();
const resolvedVersion = version ?? "latest";
const downloadInfo = await this.getBundleDownload(name, resolvedVersion, resolvedPlatform);
const data = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256);
return { data, metadata: downloadInfo.bundle };
}
/**
* Resolve a skill from mpak registry
* Download a skill bundle by name, with optional version.
* Defaults to latest version.
*
* The API returns a ZIP bundle containing SKILL.md and metadata.
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
async resolveMpakSkill(ref) {
const url = `${this.registryUrl}/v1/skills/${ref.name}/versions/${ref.version}/download`;
const response = await this.fetchWithTimeout(url);
if (response.status === 404) {
throw new MpakNotFoundError(`${ref.name}@${ref.version}`);
}
if (!response.ok) {
throw new MpakNetworkError(`Failed to fetch skill: HTTP ${response.status}`);
}
const zipBuffer = await response.arrayBuffer();
const content = await this.extractSkillFromZip(zipBuffer, ref.name);
if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return { content, version: ref.version, source: "mpak", verified: true };
}
return { content, version: ref.version, source: "mpak", verified: false };
async downloadSkillBundle(name, version) {
const resolvedVersion = version ?? "latest";
const downloadInfo = await this.getSkillVersionDownload(name, resolvedVersion);
const data = await this.downloadContent(downloadInfo.url, downloadInfo.skill.sha256);
return { data, metadata: downloadInfo.skill };
}
/**
* Resolve a skill from GitHub releases
*/
async resolveGithubSkill(ref) {
const url = `https://github.com/${ref.repo}/releases/download/${ref.version}/${ref.path}`;
const response = await this.fetchWithTimeout(url);
if (!response.ok) {
throw new MpakNotFoundError(`github:${ref.repo}/${ref.path}@${ref.version}`);
}
const content = await response.text();
if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return {
content,
version: ref.version,
source: "github",
verified: true
};
}
return {
content,
version: ref.version,
source: "github",
verified: false
};
}
/**
* Resolve a skill from a direct URL
*/
async resolveUrlSkill(ref) {
const response = await this.fetchWithTimeout(ref.url);
if (!response.ok) {
throw new MpakNotFoundError(`url:${ref.url}`);
}
const content = await response.text();
if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return { content, version: ref.version, source: "url", verified: true };
}
return { content, version: ref.version, source: "url", verified: false };
}
/**
* Extract SKILL.md content from a skill bundle ZIP
*/
async extractSkillFromZip(zipBuffer, skillName) {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(zipBuffer);
const folderName = skillName.split("/").pop() ?? skillName;
const skillPath = `${folderName}/SKILL.md`;
const skillFile = zip.file(skillPath);
if (!skillFile) {
const altFile = zip.file("SKILL.md");
if (!altFile) {
throw new MpakNotFoundError(`SKILL.md not found in bundle for ${skillName}`);
}
return altFile.async("string");
}
return skillFile.async("string");
}
/**
* Verify content integrity and throw if mismatch (fail-closed)
*/
verifyIntegrityOrThrow(content, integrity) {
const expectedHash = this.extractHash(integrity);
const actualHash = this.computeSha256(content);
if (actualHash !== expectedHash) {
throw new MpakIntegrityError(expectedHash, actualHash);
}
}
/**
* Extract hash from integrity string (removes prefix)
*/
extractHash(integrity) {
if (integrity.startsWith("sha256:")) {
return integrity.slice(7);
}
if (integrity.startsWith("sha256-")) {
return integrity.slice(7);
}
return integrity;
}
// ===========================================================================

@@ -435,3 +356,3 @@ // Utility Methods

computeSha256(content) {
return createHash("sha256").update(content, "utf8").digest("hex");
return createHash("sha256").update(content).digest("hex");
}

@@ -443,3 +364,6 @@ /**

if (!name.startsWith("@")) {
throw new Error("Package name must be scoped (e.g., @scope/package-name)");
throw new MpakError(
"Package name must be scoped (e.g., @scope/package-name)",
"INVALID_SPEC"
);
}

@@ -477,9 +401,778 @@ }

};
// src/helpers.ts
import { execFileSync } from "child_process";
import { createHash as createHash2 } from "crypto";
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
import { resolve, join } from "path";
var MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024;
var UPDATE_CHECK_TTL_MS = 60 * 60 * 1e3;
function isSemverEqual(a, b) {
return a.replace(/^v/, "") === b.replace(/^v/, "");
}
function extractZip(zipPath, destDir) {
try {
const listOutput = execFileSync("unzip", ["-l", zipPath], {
stdio: "pipe",
encoding: "utf8"
});
const totalMatch = listOutput.match(/^\s*(\d+)\s+\d+\s+files?$/m);
if (totalMatch) {
const totalSize = parseInt(totalMatch[1] ?? "0", 10);
if (totalSize > MAX_UNCOMPRESSED_SIZE) {
throw new MpakCacheCorruptedError(
`Bundle uncompressed size (${Math.round(totalSize / 1024 / 1024)}MB) exceeds maximum allowed (${MAX_UNCOMPRESSED_SIZE / (1024 * 1024)}MB)`,
zipPath
);
}
}
} catch (error) {
if (error instanceof MpakCacheCorruptedError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new MpakCacheCorruptedError(
`Cannot verify bundle size before extraction: ${message}`,
zipPath,
error instanceof Error ? error : void 0
);
}
mkdirSync(destDir, { recursive: true });
execFileSync("unzip", ["-o", "-q", zipPath, "-d", destDir], {
stdio: "pipe"
});
}
function hashBundlePath(bundlePath) {
return createHash2("md5").update(resolve(bundlePath)).digest("hex").slice(0, 12);
}
function localBundleNeedsExtract(bundlePath, cacheDir) {
const metaPath = join(cacheDir, ".mpak-local-meta.json");
if (!existsSync(metaPath)) return true;
try {
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
if (!meta.extractedAt) return true;
const bundleStat = statSync(bundlePath);
return bundleStat.mtimeMs > new Date(meta.extractedAt).getTime();
} catch {
return true;
}
}
function readJsonFromFile(filePath, schema) {
if (!existsSync(filePath)) {
throw new MpakError(`File does not exist: ${filePath}`, "FILE_NOT_FOUND");
}
let raw;
try {
raw = JSON.parse(readFileSync(filePath, "utf8"));
} catch {
throw new MpakError(`File is not valid JSON: ${filePath}`, "INVALID_JSON");
}
const result = schema.safeParse(raw);
if (!result.success) {
throw new MpakError(
`File failed validation: ${filePath} \u2014 ${result.error.issues[0]?.message ?? "unknown error"}`,
"VALIDATION_FAILED"
);
}
return result.data;
}
// src/cache.ts
var MpakBundleCache = class {
cacheHome;
mpakClient;
constructor(client, options) {
this.mpakClient = client;
this.cacheHome = join2(options?.mpakHome ?? join2(homedir(), ".mpak"), "cache");
}
/**
* Compute the cache path for a package. Does not create the directory.
* @example getPackageCachePath('@scope/name') => '<cacheBase>/scope-name'
*/
getBundleCacheDirName(packageName) {
const safeName = packageName.replace("@", "").replace("/", "-");
return join2(this.cacheHome, safeName);
}
/**
* Read and validate cache metadata for a package.
* Returns `null` if the package does not exist in the cache.
* throws Error if metadata is corrupt
*/
getBundleMetadata(packageName) {
const packageCacheDir = this.getBundleCacheDirName(packageName);
if (!existsSync2(packageCacheDir)) {
return null;
}
const metaPath = join2(packageCacheDir, ".mpak-meta.json");
try {
return readJsonFromFile(metaPath, CacheMetadataSchema);
} catch (err) {
throw new MpakCacheCorruptedError(
err instanceof Error ? err.message : String(err),
metaPath,
err instanceof Error ? err : void 0
);
}
}
/**
* Read and validate the MCPB manifest from a cached package.
* Returns `null` if the package is not cached (directory doesn't exist).
*
* @throws {MpakCacheCorruptedError} If the cache directory exists but
* `manifest.json` is missing, contains invalid JSON, or fails schema validation.
*/
getBundleManifest(packageName) {
const dir = this.getBundleCacheDirName(packageName);
if (!existsSync2(dir)) {
return null;
}
const manifestPath = join2(dir, "manifest.json");
try {
return readJsonFromFile(manifestPath, McpbManifestSchema);
} catch (err) {
throw new MpakCacheCorruptedError(
err instanceof Error ? err.message : String(err),
manifestPath,
err instanceof Error ? err : void 0
);
}
}
/**
* Scan the cache directory and return metadata for every cached registry bundle.
* Skips the `_local/` directory (local dev bundles) and entries with
* missing/corrupt metadata or manifests.
*/
listCachedBundles() {
if (!existsSync2(this.cacheHome)) return [];
const entries = readdirSync(this.cacheHome, { withFileTypes: true });
const bundles = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "_local") continue;
const cacheDir = join2(this.cacheHome, entry.name);
try {
const manifest = readJsonFromFile(join2(cacheDir, "manifest.json"), McpbManifestSchema);
const meta = this.getBundleMetadata(manifest.name);
if (!meta) continue;
bundles.push({
name: manifest.name,
version: meta.version,
pulledAt: meta.pulledAt,
cacheDir
});
} catch {
}
}
return bundles;
}
/**
* Remove a cached bundle from disk.
* @returns `true` if the bundle was cached and removed, `false` if it wasn't cached.
*/
removeCachedBundle(packageName) {
const dir = this.getBundleCacheDirName(packageName);
if (!existsSync2(dir)) return false;
rmSync(dir, { recursive: true, force: true });
return true;
}
/**
* Load a bundle into the local cache, downloading from the registry only
* if the cache is missing or stale. Returns the cache directory and version.
*
* Requires an `MpakClient` to be provided at construction time.
*
* @param name - Scoped package name (e.g. `@scope/bundle`)
* @param options.version - Specific version to load. Omit for "latest".
* @param options.force - Skip cache checks and always re-download.
*
* @returns `cacheDir` — path to the extracted bundle on disk,
* `version` — the resolved version string,
* `pulled` — whether a download actually occurred.
*
* @throws If no `MpakClient` was provided at construction time.
*/
async loadBundle(name, options) {
const { version: requestedVersion, force = false } = options ?? {};
const cacheDir = this.getBundleCacheDirName(name);
let cachedMeta = null;
try {
cachedMeta = this.getBundleMetadata(name);
} catch {
}
if (!options?.force && !!cachedMeta && (!requestedVersion || isSemverEqual(cachedMeta.version, requestedVersion))) {
return { cacheDir, version: cachedMeta.version, pulled: false };
}
const platform = MpakClient.detectPlatform();
const downloadInfo = await this.mpakClient.getBundleDownload(
name,
requestedVersion ?? "latest",
platform
);
if (!force && cachedMeta && isSemverEqual(cachedMeta.version, downloadInfo.bundle.version)) {
this.writeCacheMetadata(name, {
...cachedMeta,
lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
});
return { cacheDir, version: cachedMeta.version, pulled: false };
}
await this.downloadAndExtract(name, downloadInfo);
return { cacheDir, version: downloadInfo.bundle.version, pulled: true };
}
/**
* Fire-and-forget background check for bundle updates.
* Return the latest version string if an update is available, null otherwise (not cached, skipped, up-to-date, or error).
* The caller can just check `if (result) { console.log("update available: " + result) }`
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
*/
async checkForUpdate(packageName, options) {
const cachedMeta = this.getBundleMetadata(packageName);
if (!cachedMeta) return null;
if (!options?.force && cachedMeta.lastCheckedAt) {
const elapsed = Date.now() - new Date(cachedMeta.lastCheckedAt).getTime();
if (elapsed < UPDATE_CHECK_TTL_MS) return null;
}
try {
const detail = await this.mpakClient.getBundle(packageName);
this.writeCacheMetadata(packageName, {
...cachedMeta,
lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
});
if (!isSemverEqual(detail.latest_version, cachedMeta.version)) {
return detail.latest_version;
}
return null;
} catch {
return null;
}
}
// ===========================================================================
// Private methods
// ===========================================================================
/**
* Write cache metadata for a package.
* @throws If the metadata fails schema validation.
*/
writeCacheMetadata(packageName, metadata) {
const metaPath = join2(this.getBundleCacheDirName(packageName), ".mpak-meta.json");
writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
}
/**
* Download a bundle using pre-resolved download info, extract it into
* the cache, and write metadata.
*/
async downloadAndExtract(name, downloadInfo) {
const bundle = downloadInfo.bundle;
const cacheDir = this.getBundleCacheDirName(name);
const tempPath = join2(tmpdir(), `mpak-${Date.now()}-${randomUUID().slice(0, 8)}.mcpb`);
try {
const data = await this.mpakClient.downloadContent(downloadInfo.url, bundle.sha256);
writeFileSync(tempPath, data);
if (existsSync2(cacheDir)) {
rmSync(cacheDir, { recursive: true, force: true });
}
extractZip(tempPath, cacheDir);
this.writeCacheMetadata(name, {
version: bundle.version,
pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
platform: bundle.platform
});
} finally {
rmSync(tempPath, { force: true });
}
}
};
// src/config-manager.ts
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
import { homedir as homedir2 } from "os";
import { join as join3, resolve as resolve2 } from "path";
import { z } from "zod";
var CONFIG_VERSION = "1.0.0";
var PackageConfigSchema = z.record(z.string(), z.string());
var MpakConfigSchema = z.object({
version: z.string(),
lastUpdated: z.string(),
registryUrl: z.string().optional(),
packages: z.record(z.string(), PackageConfigSchema).optional()
}).strict();
var MpakConfigManager = class {
mpakHome;
configFile;
config = null;
constructor(options) {
this.mpakHome = resolve2(options?.mpakHome ?? join3(homedir2(), ".mpak"));
this.configFile = join3(this.mpakHome, "config.json");
if (options?.registryUrl !== void 0) {
this.setRegistryUrl(options.registryUrl);
}
}
// ===========================================================================
// Public methods
// ===========================================================================
/**
* Resolve the registry URL with a 2-tier fallback:
* 1. Saved value in config file
* 2. Default: `https://registry.mpak.dev`
*
* @returns The resolved registry URL
*/
getRegistryUrl() {
const config = this.loadConfig();
return config.registryUrl || "https://registry.mpak.dev";
}
/**
* Get all stored user config values for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns The key-value map, or `undefined` if the package has no stored config
*/
getPackageConfig(packageName) {
const config = this.loadConfig();
return config.packages?.[packageName];
}
/**
* Store a user config value for a package. Creates the package entry if needed.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key (e.g. `api_key`)
* @param value - The value to store
*/
setPackageConfigValue(packageName, key, value) {
const config = this.loadConfig();
if (!config.packages) {
config.packages = {};
}
if (!config.packages[packageName]) {
config.packages[packageName] = {};
}
config.packages[packageName][key] = value;
this.saveConfig();
}
/**
* Remove all stored config for a package.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @returns `true` if the package had config that was removed, `false` if it didn't exist
*/
clearPackageConfig(packageName) {
const config = this.loadConfig();
if (config.packages?.[packageName]) {
delete config.packages[packageName];
this.saveConfig();
return true;
}
return false;
}
/**
* Remove a single config value for a package. If this was the last key,
* the package entry is cleaned up entirely.
*
* @param packageName - Scoped package name (e.g. `@scope/bundle`)
* @param key - The config key to remove
* @returns `true` if the key existed and was removed, `false` otherwise
*/
clearPackageConfigValue(packageName, key) {
const config = this.loadConfig();
if (config.packages?.[packageName]?.[key] !== void 0) {
delete config.packages[packageName][key];
if (Object.keys(config.packages[packageName]).length === 0) {
delete config.packages[packageName];
}
this.saveConfig();
return true;
}
return false;
}
/**
* List all package names that have stored user config.
*
* @returns Array of scoped package names (e.g. `['@scope/pkg1', '@scope/pkg2']`)
*/
getPackageNames() {
const config = this.loadConfig();
return Object.keys(config.packages || {});
}
// ===========================================================================
// Private methods
// ===========================================================================
/**
* Load the config from disk, or create a fresh one if the file doesn't exist yet.
* The result is cached — subsequent calls return the in-memory copy without
* re-reading the file.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file exists but contains invalid JSON or fails schema validation
*/
loadConfig() {
if (this.config) {
return this.config;
}
if (!existsSync3(this.configFile)) {
this.config = {
version: CONFIG_VERSION,
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
};
return this.config;
}
this.config = this.readAndValidateConfig();
return this.config;
}
/**
* Read the config file from disk, parse JSON, and validate against the schema.
*
* @returns The validated config object
* @throws {MpakConfigCorruptedError} If the file can't be read, contains invalid JSON,
* or doesn't match the expected schema
*/
readAndValidateConfig() {
let configJson;
try {
configJson = readFileSync2(this.configFile, "utf8");
} catch (err) {
throw new MpakConfigCorruptedError(
`Failed to read config file: ${err instanceof Error ? err.message : String(err)}`,
this.configFile,
err instanceof Error ? err : void 0
);
}
let parsed;
try {
parsed = JSON.parse(configJson);
} catch (err) {
throw new MpakConfigCorruptedError(
`Config file contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
this.configFile,
err instanceof Error ? err : void 0
);
}
const result = MpakConfigSchema.safeParse(parsed);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid config";
throw new MpakConfigCorruptedError(message, this.configFile);
}
return result.data;
}
/**
* Flush the in-memory config to disk. Creates the config directory if needed,
* validates against the schema, updates `lastUpdated`, and writes
* with mode `0o600` (owner read/write only — config may contain secrets).
*
* @throws {MpakConfigCorruptedError} If the in-memory config fails schema validation
*/
saveConfig() {
if (!this.config) {
throw new MpakConfigCorruptedError(
`saveConfig called before config was loaded`,
this.configFile
);
}
if (!existsSync3(this.mpakHome)) {
mkdirSync2(this.mpakHome, { recursive: true, mode: 448 });
}
this.config.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
const result = MpakConfigSchema.safeParse(this.config);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid config";
throw new MpakConfigCorruptedError(message, this.configFile);
}
const configJson = JSON.stringify(result.data, null, 2);
writeFileSync2(this.configFile, configJson, { mode: 384 });
}
/**
* Persist a custom registry URL to the config file.
*
* @param url - The registry URL to save (e.g. `https://registry.example.com`)
*/
setRegistryUrl(url) {
const config = this.loadConfig();
config.registryUrl = url;
this.saveConfig();
}
};
// src/mpakSDK.ts
var Mpak = class _Mpak {
/** User configuration manager (`config.json`). */
configManager;
/** Registry API client. */
client;
/** Local bundle cache. */
bundleCache;
constructor(options) {
const configOptions = {};
if (options?.mpakHome !== void 0) configOptions.mpakHome = options.mpakHome;
if (options?.registryUrl !== void 0) configOptions.registryUrl = options.registryUrl;
this.configManager = new MpakConfigManager(configOptions);
const clientConfig = {
registryUrl: this.configManager.getRegistryUrl()
};
if (options?.timeout !== void 0) clientConfig.timeout = options.timeout;
if (options?.userAgent !== void 0) clientConfig.userAgent = options.userAgent;
this.client = new MpakClient(clientConfig);
this.bundleCache = new MpakBundleCache(this.client, {
mpakHome: this.configManager.mpakHome
});
}
/**
* Prepare a bundle for execution.
*
* Accepts either a registry spec (`{ name, version? }`) or a local bundle
* spec (`{ local }`). Downloads/extracts as needed, reads the manifest,
* validates user config, and resolves the command, args, and env needed
* to spawn the MCP server process.
*
* @param spec - Which bundle to prepare. See {@link PrepareServerSpec}.
* @param options - Force re-download/re-extract, extra env, and workspace dir.
*
* @throws {MpakConfigError} If required user config values are missing.
* @throws {MpakCacheCorruptedError} If the manifest is missing or corrupt after download.
*/
async prepareServer(spec, options) {
let cacheDir;
let name;
let version;
let manifest;
if ("local" in spec) {
({ cacheDir, name, version, manifest } = await this.prepareLocalBundle(spec.local, options));
} else {
({ cacheDir, name, version, manifest } = await this.prepareRegistryBundle(
spec.name,
spec.version,
options
));
}
const userConfigValues = this.gatherUserConfig(name, manifest);
const { command, args, env } = this.resolveCommand(manifest, cacheDir, userConfigValues);
env["MPAK_WORKSPACE"] = options?.workspaceDir ?? join4(process.cwd(), ".mpak");
if (options?.env) {
Object.assign(env, options.env);
}
return { command, args, env, cwd: cacheDir, name, version };
}
// ===========================================================================
// Private helpers
// ===========================================================================
/**
* Load a registry bundle into cache and read its manifest.
*/
async prepareRegistryBundle(packageName, version, options) {
const loadOptions = {};
if (version !== void 0) loadOptions.version = version;
if (options?.force !== void 0) loadOptions.force = options.force;
const loadResult = await this.bundleCache.loadBundle(packageName, loadOptions);
const manifest = this.bundleCache.getBundleManifest(packageName);
if (!manifest) {
throw new MpakCacheCorruptedError(
`Manifest file missing for ${packageName}`,
join4(this.bundleCache.cacheHome, packageName)
);
}
return {
cacheDir: loadResult.cacheDir,
name: packageName,
version: loadResult.version,
manifest
};
}
/**
* Extract a local `.mcpb` bundle (if stale) and read its manifest.
* Local bundles are cached under `<cacheHome>/_local/<hash>`.
*
* The caller is responsible for validating that `bundlePath` exists
* and has a `.mcpb` extension before calling this method.
*/
async prepareLocalBundle(bundlePath, options) {
const absolutePath = resolve3(bundlePath);
const hash = hashBundlePath(absolutePath);
const cacheDir = join4(this.bundleCache.cacheHome, "_local", hash);
const needsExtract = options?.force || localBundleNeedsExtract(absolutePath, cacheDir);
if (needsExtract) {
if (existsSync4(cacheDir)) {
rmSync2(cacheDir, { recursive: true, force: true });
}
try {
extractZip(absolutePath, cacheDir);
} catch (err) {
throw new MpakInvalidBundleError(
err instanceof Error ? err.message : String(err),
absolutePath,
err instanceof Error ? err : void 0
);
}
writeFileSync3(
join4(cacheDir, ".mpak-local-meta.json"),
JSON.stringify({
localPath: absolutePath,
extractedAt: (/* @__PURE__ */ new Date()).toISOString()
})
);
}
let manifest;
try {
manifest = readJsonFromFile(join4(cacheDir, "manifest.json"), McpbManifestSchema2);
} catch (err) {
throw new MpakInvalidBundleError(
err instanceof Error ? err.message : String(err),
absolutePath,
err instanceof Error ? err : void 0
);
}
return { cacheDir, name: manifest.name, version: manifest.version, manifest };
}
/**
* Gather stored user config values and validate that all required fields are present.
* @throws If required config values are missing.
*/
gatherUserConfig(packageName, manifest) {
if (!manifest.user_config || Object.keys(manifest.user_config).length === 0) {
return {};
}
const storedConfig = this.configManager.getPackageConfig(packageName) ?? {};
const result = {};
const missingFields = [];
for (const [fieldName, fieldData] of Object.entries(manifest.user_config)) {
const storedValue = storedConfig[fieldName];
if (storedValue !== void 0) {
result[fieldName] = storedValue;
} else if (fieldData.default !== void 0 && fieldData.default !== null) {
result[fieldName] = String(fieldData.default);
} else if (fieldData.required) {
const field = {
key: fieldName,
title: fieldData.title ?? fieldName,
sensitive: fieldData.sensitive ?? false
};
if (fieldData.description !== void 0) {
field.description = fieldData.description;
}
missingFields.push(field);
}
}
if (missingFields.length > 0) {
throw new MpakConfigError(packageName, missingFields);
}
return result;
}
/**
* Resolve the manifest's `server` block into a spawnable command, args, and env.
*
* Handles three server types:
* - **binary** — runs the compiled executable at `entry_point`, chmod'd +x.
* - **node** — runs `mcp_config.command` (default `"node"`) with `mcp_config.args`,
* or falls back to `node <entry_point>` when args are empty.
* - **python** — like node, but resolves `python3`/`python` at runtime and
* prepends `<cacheDir>/deps` to `PYTHONPATH` for bundled dependencies.
*
* All `${__dirname}` placeholders in args are replaced with `cacheDir`.
* All `${user_config.*}` placeholders in env are replaced with gathered user values.
*
* @throws For unsupported server types.
*/
resolveCommand(manifest, cacheDir, userConfigValues) {
const { type, entry_point, mcp_config } = manifest.server;
const env = _Mpak.substituteEnvVars(mcp_config.env, userConfigValues);
let command;
let args;
switch (type) {
case "binary": {
command = join4(cacheDir, entry_point);
args = _Mpak.resolveArgs(mcp_config.args ?? [], cacheDir);
try {
chmodSync(command, 493);
} catch {
}
break;
}
case "node": {
command = mcp_config.command || "node";
args = mcp_config.args.length > 0 ? _Mpak.resolveArgs(mcp_config.args, cacheDir) : [join4(cacheDir, entry_point)];
break;
}
case "python": {
command = mcp_config.command === "python" ? _Mpak.findPythonCommand() : mcp_config.command || _Mpak.findPythonCommand();
args = mcp_config.args.length > 0 ? _Mpak.resolveArgs(mcp_config.args, cacheDir) : [join4(cacheDir, entry_point)];
const depsDir = join4(cacheDir, "deps");
env["PYTHONPATH"] = env["PYTHONPATH"] ? `${depsDir}:${env["PYTHONPATH"]}` : depsDir;
break;
}
case "uv": {
command = mcp_config.command || "uv";
args = mcp_config.args.length > 0 ? _Mpak.resolveArgs(mcp_config.args, cacheDir) : ["run", join4(cacheDir, entry_point)];
break;
}
default: {
const _exhaustive = type;
throw new MpakCacheCorruptedError(
`Unsupported server type "${_exhaustive}" in manifest for ${manifest.name}`,
cacheDir
);
}
}
return { command, args, env };
}
/**
* Substitute `${__dirname}` placeholders in args.
*/
static resolveArgs(args, cacheDir) {
return args.map((arg) => arg.replace(/\$\{__dirname\}/g, cacheDir));
}
/**
* Substitute `${user_config.*}` placeholders in env vars.
*/
static substituteEnvVars(env, userConfigValues) {
if (!env) return {};
const result = {};
for (const [key, value] of Object.entries(env)) {
result[key] = value.replace(
/\$\{user_config\.([^}]+)\}/g,
(match, configKey) => userConfigValues[configKey] ?? match
);
}
return result;
}
/**
* Find a working Python executable. Tries `python3` first, falls back to `python`.
*/
static findPythonCommand() {
const result = spawnSync("python3", ["--version"], { stdio: "pipe" });
if (result.status === 0) {
return "python3";
}
return "python";
}
};
// src/utils.ts
function parsePackageSpec(spec) {
const lastAtIndex = spec.lastIndexOf("@");
let name;
let version;
if (lastAtIndex > 0) {
name = spec.substring(0, lastAtIndex);
version = spec.substring(lastAtIndex + 1);
} else {
name = spec;
}
if (!name.startsWith("@") || !name.includes("/")) {
throw new MpakError(
`Invalid package spec: "${spec}". Expected scoped format: @scope/name`,
"INVALID_SPEC"
);
}
return version ? { name, version } : { name };
}
export {
Mpak,
MpakBundleCache,
MpakCacheCorruptedError,
MpakClient,
MpakConfigCorruptedError,
MpakConfigError,
MpakConfigManager,
MpakError,
MpakIntegrityError,
MpakInvalidBundleError,
MpakNetworkError,
MpakNotFoundError
MpakNotFoundError,
parsePackageSpec
};
//# sourceMappingURL=index.js.map
{
"name": "@nimblebrain/mpak-sdk",
"version": "0.1.3",
"version": "0.2.0",
"description": "TypeScript SDK for mpak registry - MCPB bundles and Agent Skills",

@@ -53,3 +53,3 @@ "author": "NimbleBrain Inc <engineering@mpak.dev>",

"dependencies": {
"jszip": "^3.10.1",
"zod": "^4.3.6",
"@nimblebrain/mpak-schemas": "0.1.0"

@@ -59,2 +59,3 @@ },

"@types/node": "^22.0.0",
"jszip": "^3.10.1",
"tsup": "^8.4.0",

@@ -61,0 +62,0 @@ "typescript": "^5.7.0",

+238
-112

@@ -9,3 +9,3 @@ # @nimblebrain/mpak-sdk

TypeScript SDK for the mpak registry - search, download, and resolve MCPB bundles and Agent Skills.
TypeScript SDK for the mpak registry — search, download, cache, configure, and run MCPB bundles and Agent Skills.

@@ -20,25 +20,108 @@ ## Installation

The `MpakSDK` facade is the primary entry point. It wires together the registry client, local cache, and config manager:
```typescript
import { MpakClient } from '@nimblebrain/mpak-sdk';
import { MpakSDK } from '@nimblebrain/mpak-sdk';
const client = new MpakClient();
const mpak = new MpakSDK();
// Search for bundles
const results = await client.searchBundles({ q: 'mcp', limit: 10 });
// Prepare a bundle for execution (downloads if not cached)
const server = await mpak.prepareServer('@nimblebraininc/echo');
// Spawn the MCP server
import { spawn } from 'child_process';
const child = spawn(server.command, server.args, {
env: { ...server.env, ...process.env },
cwd: server.cwd,
stdio: 'inherit',
});
```
## Usage
### Prepare and Run a Server
`prepareServer` handles the full lifecycle: download, cache, read manifest, validate config, and resolve the command:
```typescript
const mpak = new MpakSDK();
// Latest version
const server = await mpak.prepareServer('@scope/bundle');
// Pinned version (inline)
const server = await mpak.prepareServer('@scope/bundle@1.2.0');
// Pinned version (option) + force re-download
const server = await mpak.prepareServer('@scope/bundle', {
version: '1.2.0',
force: true,
});
// Custom workspace directory for stateful bundles
const server = await mpak.prepareServer('@scope/bundle', {
workspaceDir: '/path/to/project/.mpak',
});
// Extra env vars merged on top of manifest env
const server = await mpak.prepareServer('@scope/bundle', {
env: { DEBUG: 'true' },
});
```
The returned `ServerCommand` contains everything needed to spawn:
```typescript
server.command; // e.g. 'node', 'python3', or '/path/to/binary'
server.args; // e.g. ['/cache/dir/index.js']
server.env; // manifest env + user config substitutions + overrides
server.cwd; // extracted bundle cache directory
server.name; // resolved package name
server.version; // resolved version string
```
### User Config (per-package settings)
Bundles can declare required configuration (API keys, ports, etc.) in their manifest. Store values before running:
```typescript
const mpak = new MpakSDK();
// Set a config value
mpak.config.setPackageConfigValue('@scope/bundle', 'api_key', 'sk-...');
// Values are substituted into ${user_config.*} placeholders in the manifest env
// If required config is missing, prepareServer throws with a clear message
```
### Parse Package Specs
Validate and parse `@scope/name` or `@scope/name@version` strings:
```typescript
MpakSDK.parsePackageSpec('@scope/name');
// { name: '@scope/name' }
MpakSDK.parsePackageSpec('@scope/name@1.0.0');
// { name: '@scope/name', version: '1.0.0' }
MpakSDK.parsePackageSpec('invalid');
// throws: Invalid package spec
```
### Search Bundles
```typescript
const mpak = new MpakSDK();
const results = await mpak.client.searchBundles({ q: 'mcp', limit: 10 });
for (const bundle of results.bundles) {
console.log(`${bundle.name}@${bundle.latest_version}`);
}
// Get download info
const download = await client.getBundleDownload('@nimblebraininc/echo', 'latest');
console.log(`Download URL: ${download.url}`);
console.log(`SHA256: ${download.bundle.sha256}`);
```
## Usage
### Get Bundle Details
```typescript
const bundle = await client.getBundle('@nimblebraininc/echo');
const bundle = await mpak.client.getBundle('@nimblebraininc/echo');

@@ -52,7 +135,5 @@ console.log(bundle.description);

```typescript
// Detect current platform
const platform = MpakClient.detectPlatform();
// Get platform-specific download
const download = await client.getBundleDownload(
const download = await mpak.client.getBundleDownload(
'@nimblebraininc/echo',

@@ -64,62 +145,68 @@ '0.1.3',

### Search Skills
### Cache Operations
```typescript
const skills = await client.searchSkills({
q: 'crm',
surface: 'claude-code',
limit: 10,
});
const mpak = new MpakSDK();
for (const skill of skills.skills) {
console.log(`${skill.name}: ${skill.description}`);
}
// Download and cache a bundle
const result = await mpak.cache.loadBundle('@scope/name');
console.log(result.cacheDir); // path to extracted bundle
console.log(result.version); // resolved version
console.log(result.pulled); // true if downloaded, false if from cache
// Read a cached bundle's manifest
const manifest = mpak.cache.readManifest('@scope/name');
// List all cached bundles
const bundles = mpak.cache.listCachedBundles();
// Check for updates (fire-and-forget, logs via logger callback)
await mpak.cache.checkForUpdateAsync('@scope/name');
// Read/write cache metadata
const meta = mpak.cache.getCacheMetadata('@scope/name');
```
### Download Skill with Integrity Verification
### Config Manager
```typescript
// Get skill download info
const download = await client.getSkillDownload('@nimbletools/folk-crm');
const mpak = new MpakSDK();
// Download content with SHA256 verification (fail-closed)
const { content, verified } = await client.downloadSkillContent(
download.url,
download.skill.sha256 // If hash doesn't match, throws MpakIntegrityError
);
// Registry URL
mpak.config.getRegistryUrl();
mpak.config.setRegistryUrl('https://custom.registry.dev');
console.log(`Verified: ${verified}`);
console.log(content);
// Per-package config
mpak.config.setPackageConfigValue('@scope/name', 'api_key', 'sk-...');
mpak.config.getPackageConfigValue('@scope/name', 'api_key');
mpak.config.getPackageConfig('@scope/name'); // all values for a package
mpak.config.clearPackageConfig('@scope/name'); // remove all config for a package
mpak.config.clearPackageConfigValue('@scope/name', 'api_key'); // remove one key
mpak.config.listPackagesWithConfig(); // list configured packages
```
### Resolve Skill References
### Search & Download Skills
```typescript
import { MpakClient, SkillReference } from '@nimblebrain/mpak-sdk';
const mpak = new MpakSDK();
const client = new MpakClient();
const skills = await mpak.client.searchSkills({ q: 'crm', limit: 10 });
for (const skill of skills.skills) {
console.log(`${skill.name}: ${skill.description}`);
}
// Resolve from mpak registry
const skill = await client.resolveSkillRef({
source: 'mpak',
name: '@nimblebraininc/folk-crm',
version: '1.3.0',
});
console.log(skill.content);
// Download skill with SHA256 integrity verification
const download = await mpak.client.getSkillDownload('@nimbletools/folk-crm');
const data = await mpak.client.downloadContent(download.url, download.skill.sha256);
```
// Resolve from GitHub
const ghSkill = await client.resolveSkillRef({
source: 'github',
name: '@example/my-skill',
version: 'v1.0.0',
repo: 'owner/repo',
path: 'skills/my-skill/SKILL.md',
});
## Constructor Options
// Resolve from URL
const urlSkill = await client.resolveSkillRef({
source: 'url',
name: '@example/custom',
version: '1.0.0',
url: 'https://example.com/skill.md',
```typescript
const mpak = new MpakSDK({
mpakHome: '~/.mpak', // Root directory for config + cache
registryUrl: 'https://registry.mpak.dev', // Registry API URL
timeout: 30000, // Request timeout in ms
userAgent: 'my-app/1.0', // User-Agent header
logger: (msg) => console.error(msg), // Logger for cache operations
});

@@ -132,3 +219,3 @@ ```

import {
MpakClient,
MpakSDK,
MpakNotFoundError,

@@ -139,6 +226,4 @@ MpakIntegrityError,

const client = new MpakClient();
try {
const bundle = await client.getBundle('@nonexistent/bundle');
const server = await mpak.prepareServer('@nonexistent/bundle');
} catch (error) {

@@ -148,6 +233,5 @@ if (error instanceof MpakNotFoundError) {

} else if (error instanceof MpakIntegrityError) {
// CRITICAL: Content was NOT returned (fail-closed)
console.error('Integrity mismatch!');
console.error('Expected:', error.expected);
console.error('Actual:', error.actual);
// Content was NOT returned (fail-closed)
console.error('Expected SHA256:', error.expected);
console.error('Actual SHA256:', error.actual);
} else if (error instanceof MpakNetworkError) {

@@ -159,60 +243,105 @@ console.error('Network error:', error.message);

## Configuration
## API Reference
```typescript
const client = new MpakClient({
registryUrl: 'https://registry.mpak.dev', // Custom registry URL
timeout: 30000, // Request timeout in ms
});
```
### MpakSDK (facade)
## API Reference
| Method | Description |
|---|---|
| `prepareServer(packageName, options?)` | Resolve a bundle into a ready-to-spawn `ServerCommand` |
| `MpakSDK.parsePackageSpec(spec)` | Parse and validate a `@scope/name[@version]` string |
### MpakClient
Properties: `config` (ConfigManager), `client` (MpakClient), `cache` (BundleCache).
### MpakClient (`mpak.client`)
#### Bundle Methods
- `searchBundles(params?)` - Search for bundles
- `getBundle(name)` - Get bundle details
- `getBundleVersions(name)` - List all versions
- `getBundleVersion(name, version)` - Get specific version info
- `getBundleDownload(name, version, platform?)` - Get download URL
| Method | Description |
|---|---|
| `searchBundles(params?)` | Search for bundles |
| `getBundle(name)` | Get bundle details |
| `getBundleVersions(name)` | List all versions |
| `getBundleVersion(name, version)` | Get specific version info |
| `getBundleDownload(name, version, platform?)` | Get download URL and metadata |
| `downloadBundle(name, version?)` | Download bundle with integrity verification |
| `downloadContent(url, sha256)` | Download any content with SHA256 verification |
#### Skill Methods
- `searchSkills(params?)` - Search for skills
- `getSkill(name)` - Get skill details
- `getSkillDownload(name)` - Get latest version download
- `getSkillVersionDownload(name, version)` - Get specific version download
- `downloadSkillContent(url, expectedSha256?)` - Download with optional integrity check
- `resolveSkillRef(ref)` - Resolve a skill reference to content
| Method | Description |
|---|---|
| `searchSkills(params?)` | Search for skills |
| `getSkill(name)` | Get skill details |
| `getSkillDownload(name)` | Get latest version download info |
| `getSkillVersionDownload(name, version)` | Get specific version download info |
| `downloadSkillBundle(name, version?)` | Download skill bundle with integrity verification |
#### Static Methods
- `MpakClient.detectPlatform()` - Detect current OS/arch
| Method | Description |
|---|---|
| `MpakClient.detectPlatform()` | Detect current OS and architecture |
### Error Types
### BundleCache (`mpak.cache`)
- `MpakError` - Base error class
- `MpakNotFoundError` - Resource not found (404)
- `MpakIntegrityError` - Hash mismatch (content NOT returned)
- `MpakNetworkError` - Network failures, timeouts
| Method | Description |
|---|---|
| `loadBundle(name, options?)` | Download and cache a bundle (skips if cached) |
| `readManifest(packageName)` | Read and validate a cached bundle's `manifest.json` |
| `getCacheMetadata(packageName)` | Read cache metadata for a package |
| `writeCacheMetadata(packageName, metadata)` | Write cache metadata |
| `listCachedBundles()` | List all cached registry bundles |
| `getPackageCachePath(packageName)` | Get the cache directory path for a package |
| `checkForUpdateAsync(packageName)` | Fire-and-forget update check (logs result) |
## Development
#### Static Methods
```bash
# Install dependencies
pnpm install
| Method | Description |
|---|---|
| `BundleCache.extractZip(zipPath, destDir)` | Extract a ZIP with zip-bomb protection |
| `BundleCache.isSemverEqual(a, b)` | Compare semver strings (ignores `v` prefix) |
# Run unit tests
pnpm test
### ConfigManager (`mpak.config`)
# Run integration tests (hits real API)
pnpm test:integration
| Method | Description |
|---|---|
| `getRegistryUrl()` | Get the registry URL (respects `MPAK_REGISTRY_URL` env var) |
| `setRegistryUrl(url)` | Override the registry URL |
| `getPackageConfig(packageName)` | Get all stored config for a package |
| `getPackageConfigValue(packageName, key)` | Get a single config value |
| `setPackageConfigValue(packageName, key, value)` | Store a config value |
| `clearPackageConfig(packageName)` | Remove all config for a package |
| `clearPackageConfigValue(packageName, key)` | Remove a single config key |
| `listPackagesWithConfig()` | List packages that have stored config |
# Type check
pnpm typecheck
Property: `mpakHome` (readonly) — the root directory for mpak state.
# Build
pnpm build
### Error Types
| Class | Description |
|---|---|
| `MpakError` | Base error class |
| `MpakNotFoundError` | Resource not found (404) |
| `MpakIntegrityError` | SHA256 hash mismatch (content NOT returned) |
| `MpakNetworkError` | Network failures, timeouts |
### Types
| Type | Description |
|---|---|
| `MpakSDKOptions` | Constructor options for `MpakSDK` |
| `MpakClientConfig` | Constructor options for `MpakClient` |
| `PrepareServerOptions` | Options for `prepareServer` |
| `ServerCommand` | Return type of `prepareServer` |
| `McpbManifest` | Parsed MCPB manifest schema |
| `UserConfigField` | User config field definition from manifest |
## Development
```bash
pnpm install # Install dependencies
pnpm test # Run unit tests
pnpm test:integration # Run integration tests (hits live registry)
pnpm typecheck # Type check
pnpm build # Build
```

@@ -225,11 +354,8 @@

```bash
pnpm --filter @nimblebrain/mpak-sdk lint # lint
pnpm --filter @nimblebrain/mpak-sdk exec prettier --check "src/**/*.ts" "tests/**/*.ts" # format
pnpm --filter @nimblebrain/mpak-sdk typecheck # type check
pnpm --filter @nimblebrain/mpak-sdk test # unit tests
pnpm --filter @nimblebrain/mpak-sdk test:integration # integration tests (hits live registry)
pnpm --filter @nimblebrain/mpak-sdk lint
pnpm --filter @nimblebrain/mpak-sdk typecheck
pnpm --filter @nimblebrain/mpak-sdk test
pnpm --filter @nimblebrain/mpak-sdk test:integration
```
CI runs lint, format check, typecheck, and unit tests on every PR via [`sdk-typescript-ci.yml`](../../.github/workflows/sdk-typescript-ci.yml).
## Releasing

@@ -236,0 +362,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display