Skip to content
This repository was archived by the owner on Apr 18, 2026. It is now read-only.

Commit b60fee3

Browse files
committed
Add --local flag for running local .mcpb bundles
- Add -l/--local option to run command for testing bundles before publishing - Extract to ~/.mpak/cache/_local/<hash>/ with mtime-based cache invalidation - Add getLocalCacheDir() and localBundleNeedsExtract() helpers - Make package argument optional when --local is provided - Add unit tests for new helper functions - Update docs with run command and Claude Code integration examples
1 parent 9555161 commit b60fee3

7 files changed

Lines changed: 231 additions & 58 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,18 @@ git push && git push --tags
142142
| `mpak search <query> --type bundle` | Search bundles only |
143143
| `mpak search <query> --type skill` | Search skills only |
144144

145+
### Run (Top-Level Alias)
146+
147+
| Command | Description |
148+
|---------|-------------|
149+
| `mpak run <package>` | Run an MCP server (alias for `bundle run`) |
150+
151+
This is the recommended way to integrate with Claude Code:
152+
153+
```bash
154+
claude mcp add --transport stdio echo -- mpak run @nimblebraininc/echo
155+
```
156+
145157
### Bundle Commands
146158

147159
| Command | Description |

README.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,8 @@ npm install -g @nimblebrain/mpak
2121
# Search for everything (bundles + skills)
2222
mpak search postgres
2323

24-
# Search bundles only
25-
mpak bundle search postgres
26-
2724
# Run an MCP server
28-
mpak bundle run @owner/my-server
25+
mpak run @owner/my-server
2926

3027
# Search skills only
3128
mpak skill search strategy
@@ -34,6 +31,24 @@ mpak skill search strategy
3431
mpak skill install @owner/my-skill
3532
```
3633

34+
## Claude Code Integration
35+
36+
Add any mpak bundle to Claude Code with a single command:
37+
38+
```bash
39+
claude mcp add --transport stdio echo -- mpak run @nimblebraininc/echo
40+
```
41+
42+
For bundles requiring API keys:
43+
44+
```bash
45+
# Set config once
46+
mpak config set @nimblebraininc/ipinfo api_key=your_token
47+
48+
# Then add to Claude Code
49+
claude mcp add --transport stdio ipinfo -- mpak run @nimblebraininc/ipinfo
50+
```
51+
3752
## Commands
3853

3954
### Unified Search
@@ -148,14 +163,22 @@ mpak bundle run @nimblebraininc/echo --update
148163
Options:
149164
- `--update` - Force re-download even if cached
150165

151-
**Claude Desktop Integration:**
166+
> **Tip:** Use `mpak run` as a shortcut for `mpak bundle run`.
167+
168+
**Claude Code:**
169+
170+
```bash
171+
claude mcp add --transport stdio echo -- mpak run @nimblebraininc/echo
172+
```
173+
174+
**Claude Desktop:**
152175

153176
```json
154177
{
155178
"mcpServers": {
156179
"echo": {
157180
"command": "mpak",
158-
"args": ["bundle", "run", "@nimblebraininc/echo"]
181+
"args": ["run", "@nimblebraininc/echo"]
159182
}
160183
}
161184
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@nimblebrain/mpak",
3-
"version": "0.0.2",
3+
"version": "0.1.0",
44
"description": "CLI for downloading MCPB bundles from the mpak registry",
55
"main": "dist/index.js",
66
"type": "module",

src/commands/packages/run.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { homedir } from 'os';
33
import { join } from 'path';
4-
import { parsePackageSpec, getCacheDir, resolveArgs, substituteUserConfig, substituteEnvVars } from './run.js';
4+
import { parsePackageSpec, getCacheDir, resolveArgs, substituteUserConfig, substituteEnvVars, getLocalCacheDir, localBundleNeedsExtract } from './run.js';
55

66
describe('parsePackageSpec', () => {
77
describe('scoped packages', () => {
@@ -220,3 +220,42 @@ describe('substituteEnvVars', () => {
220220
});
221221
});
222222
});
223+
224+
describe('getLocalCacheDir', () => {
225+
const expectedBase = join(homedir(), '.mpak', 'cache', '_local');
226+
227+
it('returns consistent hash for same path', () => {
228+
const dir1 = getLocalCacheDir('/path/to/bundle.mcpb');
229+
const dir2 = getLocalCacheDir('/path/to/bundle.mcpb');
230+
expect(dir1).toBe(dir2);
231+
});
232+
233+
it('returns different hash for different paths', () => {
234+
const dir1 = getLocalCacheDir('/path/to/bundle1.mcpb');
235+
const dir2 = getLocalCacheDir('/path/to/bundle2.mcpb');
236+
expect(dir1).not.toBe(dir2);
237+
});
238+
239+
it('includes _local in path', () => {
240+
const dir = getLocalCacheDir('/path/to/bundle.mcpb');
241+
expect(dir).toContain('_local');
242+
expect(dir.startsWith(expectedBase)).toBe(true);
243+
});
244+
245+
it('produces a 12-character hash suffix', () => {
246+
const dir = getLocalCacheDir('/path/to/bundle.mcpb');
247+
const hashPart = dir.split('/').pop();
248+
expect(hashPart).toHaveLength(12);
249+
});
250+
});
251+
252+
describe('localBundleNeedsExtract', () => {
253+
it('returns true when cache directory does not exist', () => {
254+
expect(localBundleNeedsExtract('/any/path.mcpb', '/nonexistent/cache')).toBe(true);
255+
});
256+
257+
it('returns true when meta file does not exist in cache dir', () => {
258+
// Using a directory that exists but has no .mpak-meta.json
259+
expect(localBundleNeedsExtract('/any/path.mcpb', '/tmp')).toBe(true);
260+
});
261+
});

src/commands/packages/run.ts

Lines changed: 131 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { spawn, spawnSync } from 'child_process';
22
import { createInterface } from 'readline';
3-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
3+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync, statSync } from 'fs';
4+
import { createHash } from 'crypto';
45
import { homedir } from 'os';
5-
import { join, dirname } from 'path';
6+
import { join, dirname, resolve, basename } from 'path';
67
import { RegistryClient } from '../../lib/api/registry-client.js';
78
import { ConfigManager } from '../../utils/config-manager.js';
89

910
export interface RunOptions {
1011
update?: boolean;
12+
local?: string; // Path to local .mcpb file
1113
}
1214

1315
interface McpConfig {
@@ -169,6 +171,33 @@ export function substituteEnvVars(
169171
return result;
170172
}
171173

174+
/**
175+
* Get cache directory for a local bundle.
176+
* Uses hash of absolute path to avoid collisions.
177+
*/
178+
export function getLocalCacheDir(bundlePath: string): string {
179+
const absolutePath = resolve(bundlePath);
180+
const hash = createHash('md5').update(absolutePath).digest('hex').slice(0, 12);
181+
return join(homedir(), '.mpak', 'cache', '_local', hash);
182+
}
183+
184+
/**
185+
* Check if local bundle needs re-extraction.
186+
* Returns true if cache doesn't exist or bundle was modified after extraction.
187+
*/
188+
export function localBundleNeedsExtract(bundlePath: string, cacheDir: string): boolean {
189+
const metaPath = join(cacheDir, '.mpak-meta.json');
190+
if (!existsSync(metaPath)) return true;
191+
192+
try {
193+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
194+
const bundleStat = statSync(bundlePath);
195+
return bundleStat.mtimeMs > new Date(meta.extractedAt).getTime();
196+
} catch {
197+
return true;
198+
}
199+
}
200+
172201
/**
173202
* Prompt user for a config value (interactive terminal input)
174203
*/
@@ -295,69 +324,125 @@ function findPythonCommand(): string {
295324
}
296325

297326
/**
298-
* Run a package from the registry
327+
* Run a package from the registry or a local bundle file
299328
*/
300329
export async function handleRun(
301330
packageSpec: string,
302331
options: RunOptions = {}
303332
): Promise<void> {
304-
const { name, version: requestedVersion } = parsePackageSpec(packageSpec);
305-
const client = new RegistryClient();
306-
const platform = RegistryClient.detectPlatform();
307-
const cacheDir = getCacheDir(name);
308-
309-
let needsPull = true;
310-
let cachedMeta = getCacheMetadata(cacheDir);
311-
312-
// Check if we have a cached version
313-
if (cachedMeta && !options.update) {
314-
if (requestedVersion) {
315-
// Specific version requested - check if cached version matches
316-
needsPull = cachedMeta.version !== requestedVersion;
317-
} else {
318-
// Latest requested - use cache (user can --update to refresh)
319-
needsPull = false;
320-
}
333+
// Validate that either --local or package spec is provided
334+
if (!options.local && !packageSpec) {
335+
process.stderr.write(`=> Error: Either provide a package name or use --local <path>\n`);
336+
process.exit(1);
321337
}
322338

323-
if (needsPull) {
324-
// Fetch download info
325-
const downloadInfo = await client.getDownloadInfo(name, requestedVersion, platform);
326-
const bundle = downloadInfo.bundle;
339+
let cacheDir: string;
340+
let packageName: string;
341+
342+
if (options.local) {
343+
// === LOCAL BUNDLE MODE ===
344+
const bundlePath = resolve(options.local);
327345

328-
// Check if cached version is already the latest
329-
if (cachedMeta && cachedMeta.version === bundle.version && !options.update) {
330-
needsPull = false;
346+
// Validate bundle exists
347+
if (!existsSync(bundlePath)) {
348+
process.stderr.write(`=> Error: Bundle not found: ${bundlePath}\n`);
349+
process.exit(1);
331350
}
332351

333-
if (needsPull) {
334-
// Download to temp file
335-
const tempPath = join(homedir(), '.mpak', 'tmp', `${Date.now()}.mcpb`);
336-
mkdirSync(dirname(tempPath), { recursive: true });
352+
// Validate .mcpb extension
353+
if (!bundlePath.endsWith('.mcpb')) {
354+
process.stderr.write(`=> Error: Not an MCPB bundle: ${bundlePath}\n`);
355+
process.exit(1);
356+
}
337357

338-
process.stderr.write(`=> Pulling ${name}@${bundle.version}...\n`);
339-
await client.downloadBundle(downloadInfo.url, tempPath);
358+
cacheDir = getLocalCacheDir(bundlePath);
359+
const needsExtract = options.update || localBundleNeedsExtract(bundlePath, cacheDir);
340360

341-
// Clear old cache and extract
342-
const { rmSync } = await import('fs');
361+
if (needsExtract) {
362+
// Clear old extraction
343363
if (existsSync(cacheDir)) {
344364
rmSync(cacheDir, { recursive: true, force: true });
345365
}
346366
mkdirSync(cacheDir, { recursive: true });
347367

348-
await extractZip(tempPath, cacheDir);
368+
process.stderr.write(`=> Extracting ${basename(bundlePath)}...\n`);
369+
await extractZip(bundlePath, cacheDir);
370+
371+
// Write local metadata
372+
writeFileSync(
373+
join(cacheDir, '.mpak-meta.json'),
374+
JSON.stringify({
375+
localPath: bundlePath,
376+
extractedAt: new Date().toISOString(),
377+
})
378+
);
379+
}
349380

350-
// Write metadata
351-
writeCacheMetadata(cacheDir, {
352-
version: bundle.version,
353-
pulledAt: new Date().toISOString(),
354-
platform: bundle.platform,
355-
});
381+
// Read manifest to get package name for config lookup
382+
const manifest = readManifest(cacheDir);
383+
packageName = manifest.name;
384+
process.stderr.write(`=> Running ${packageName} (local)\n`);
385+
386+
} else {
387+
// === REGISTRY MODE ===
388+
const { name, version: requestedVersion } = parsePackageSpec(packageSpec);
389+
packageName = name;
390+
const client = new RegistryClient();
391+
const platform = RegistryClient.detectPlatform();
392+
cacheDir = getCacheDir(name);
393+
394+
let needsPull = true;
395+
let cachedMeta = getCacheMetadata(cacheDir);
396+
397+
// Check if we have a cached version
398+
if (cachedMeta && !options.update) {
399+
if (requestedVersion) {
400+
// Specific version requested - check if cached version matches
401+
needsPull = cachedMeta.version !== requestedVersion;
402+
} else {
403+
// Latest requested - use cache (user can --update to refresh)
404+
needsPull = false;
405+
}
406+
}
407+
408+
if (needsPull) {
409+
// Fetch download info
410+
const downloadInfo = await client.getDownloadInfo(name, requestedVersion, platform);
411+
const bundle = downloadInfo.bundle;
356412

357-
// Cleanup temp file
358-
rmSync(tempPath, { force: true });
413+
// Check if cached version is already the latest
414+
if (cachedMeta && cachedMeta.version === bundle.version && !options.update) {
415+
needsPull = false;
416+
}
359417

360-
process.stderr.write(`=> Cached ${name}@${bundle.version}\n`);
418+
if (needsPull) {
419+
// Download to temp file
420+
const tempPath = join(homedir(), '.mpak', 'tmp', `${Date.now()}.mcpb`);
421+
mkdirSync(dirname(tempPath), { recursive: true });
422+
423+
process.stderr.write(`=> Pulling ${name}@${bundle.version}...\n`);
424+
await client.downloadBundle(downloadInfo.url, tempPath);
425+
426+
// Clear old cache and extract
427+
if (existsSync(cacheDir)) {
428+
rmSync(cacheDir, { recursive: true, force: true });
429+
}
430+
mkdirSync(cacheDir, { recursive: true });
431+
432+
await extractZip(tempPath, cacheDir);
433+
434+
// Write metadata
435+
writeCacheMetadata(cacheDir, {
436+
version: bundle.version,
437+
pulledAt: new Date().toISOString(),
438+
platform: bundle.platform,
439+
});
440+
441+
// Cleanup temp file
442+
rmSync(tempPath, { force: true });
443+
444+
process.stderr.write(`=> Cached ${name}@${bundle.version}\n`);
445+
}
361446
}
362447
}
363448

@@ -369,7 +454,7 @@ export async function handleRun(
369454
let userConfigValues: Record<string, string> = {};
370455
if (manifest.user_config && Object.keys(manifest.user_config).length > 0) {
371456
const configManager = new ConfigManager();
372-
userConfigValues = await gatherUserConfigValues(name, manifest.user_config, configManager);
457+
userConfigValues = await gatherUserConfigValues(packageName, manifest.user_config, configManager);
373458
}
374459

375460
// Substitute user_config placeholders in env vars

0 commit comments

Comments
 (0)