Skip to content

feat(pack): add mcpb bundling support with cvm manifest conventions#4

Open
abhayguptas wants to merge 8 commits into
ContextVM:mainfrom
abhayguptas:feat/mcpb-bundling-support
Open

feat(pack): add mcpb bundling support with cvm manifest conventions#4
abhayguptas wants to merge 8 commits into
ContextVM:mainfrom
abhayguptas:feat/mcpb-bundling-support

Conversation

@abhayguptas

Copy link
Copy Markdown

Resolves #3

Description

This PR introduces the pack command, enabling developers to package local MCP servers into distributable .mcpb bundles. It also extends the serve command to seamlessly execute these bundles over the Nostr network.

The implementation fully adheres to Anthropic's MCPB specification while extending it with a _meta.com.contextvm namespace to carry CVM-specific metadata (relays, public mode, encryption, etc.) without breaking compatibility with other hosts.

Changes Made

  • Manifest Conventions: Added a robust Zod schema (src/pack/cvm-manifest.ts) to strictly type and validate the _meta.com.contextvm namespace inside manifest.json.
  • cvmi pack: Introduced a new subcommand that packages a project directory into an .mcpb zip archive with maximum compression.
  • Interactive Init: If a user runs cvmi pack and no manifest exists, an interactive wizard prompts them for server details and CVM-specific defaults (relays, encryption mode) to generate a valid manifest.json.
  • cvmi serve <bundle.mcpb>: Upgraded the serve command to accept an .mcpb file. It extracts the bundle into a secure temporary directory, reads the custom metadata to override gateway defaults, and runs the server over Nostr.
  • Cleanup Routine: The temporary extracted bundle directories are automatically wiped upon a graceful shutdown (e.g., Ctrl+C).
  • CLI Wiring: Registered the new command in cli.ts and updated AGENTS.md docs.

Verification

  • Tested packaging both Node and Python servers using the wizard.
  • Verified globbing ignores .git, .DS_Store, and node_modules/.cache while preserving necessary dotfiles (dot: true).
  • Validated that cvmi serve correctly extracts the bundle, parses the CVM config, applies runtime overrides via CLI flags, and successfully exposes the server.
  • Passes all existing tests, code formatting, and pnpm type-check with 0 errors.

- Add transport field (stdio/cvm) for dual-mode bundle execution
- Add env_mapping contract for native CVM server config injection
- Restructure CVM meta to use nested defaults object
- Add docker server type with image and compose_file support
- Implement CVM transport mode in serve (spawn with env vars, no Gateway)
- Update pack-init wizard with transport, docker, and env_mapping prompts
- Remove announce/pricing fields (not needed for initial scope)
- Fix duplicate --help line in serve help text
@abhayguptas abhayguptas marked this pull request as draft June 11, 2026 13:13
@abhayguptas abhayguptas force-pushed the feat/mcpb-bundling-support branch 2 times, most recently from 47192d5 to c8dd4c0 Compare June 16, 2026 04:09
- Fix encryption enum: use 'required' instead of 'nip44' to match SDK EncryptionMode
- Make display_name optional per MCPB spec
- Add mcp_config.env support with __dirname substitution
- Add 'uv' server type for Python UV dependency management (MCPB v0.4+)
@abhayguptas abhayguptas force-pushed the feat/mcpb-bundling-support branch from c8dd4c0 to 457b8df Compare June 16, 2026 04:09
@abhayguptas abhayguptas marked this pull request as ready for review June 16, 2026 05:50
@ContextVM-org

Copy link
Copy Markdown

This PR adds .cvmb bundle packing, signing, verification, and serving support per the cvmb-bundling.md spec. The architecture is sound and closely follows the spec's packing/verification flow. However, there are several issues ranging from a verification-breaking bug (mismatched ignore lists) to an unnecessary dependency and a destructive side-effect that permanently mutates the user's source manifest.json. Below is the detailed review organized by severity.


🔴 CRITICAL

1. Mismatched content_hash ignore lists between pack and serve → verification WILL fail

The ignore lists passed to computeDirectoryContentHash() differ between the two call sites:

Call site Ignore list
pack.ts:72-78 ['.git', 'node_modules', '.DS_Store', '.env', '.cvmb']
serve.ts:150-157 ['.git', 'node_modules', '.DS_Store', '.env', '.cvmb', '.mcpb']

If a source directory contains any .mcpb file, the pack-time hash will include it in the Merkle tree, but the verify-time hash will exclude it. The hashes won't match, and cvmi serve will reject a valid bundle with:

Content hash verification failed! The bundle contents have been modified.

Fix: Extract the ignore list to a single shared constant and use it in both locations, or at minimum add '.mcpb' to the pack-time ignore list.

2. cvmi pack destructively modifies the user's source manifest.json

pack.ts:127 writes the manifest with injected _meta.com.contextvm.content_hash and _sig back to disk:

writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));

This permanently mutates the source file before archiving. If the user's manifest.json is version-controlled, every cvmi pack produces a dirty working tree. The content hash and signature should only exist inside the ZIP archive, not in the source copy.

Fix: Add the signed manifest to the archive from an in-memory buffer instead of writing it to disk first. The archive phase already uses archiver, which supports adding content from strings/buffers via archive.append().

3. Redundant @noble/curves direct dependency

package.json:98 declares:

"@noble/curves": "^2.2.0",

nostr-tools (^2.23.3) already depends on and re-exports @noble/curves internally. Adding it as a direct dependency:

  • Duplicates a transitive dependency in the lockfile
  • Risks version mismatches (e.g., nostr-tools expecting @noble/curves@2.1.x but hoisted to 2.2.x)
  • Adds unnecessary weight to node_modules

src/pack/crypto.ts:4 uses schnorr.sign() and schnorr.verify() from the noble import, but nostr-tools already exposes signing via its finalizeEvent / getSignature APIs. Alternatively, the Schnorr primitives can be accessed from nostr-tools's transitive dependency without listing it directly.

Fix: Remove @noble/curves from dependencies and use nostr-tools's signing utilities, or at minimum remove it from direct deps since it's already available transitively.


🟠 HIGH

4. Hash ignores entire node_modules/ but archive bundles it (minus .cache)

The content hash explicitly excludes all of node_modules (per spec), but the archive includes node_modules/ (only excluding .cache). This means:

  • The hash does not cover node_modules/ at all
  • A tampered node_modules/ in a .cvmb bundle would pass verification undetected

The spec (line 329) says to exclude node_modules from the hash — so this is technically spec-compliant. However, it's a security consideration worth flagging: the bundle's trust boundary stops at package.json/pyproject.toml but doesn't extend to the actual dependency code.

Recommendation: Add a comment documenting that node_modules integrity relies on package-lock.json/pnpm-lock.yaml/yarn.lock being in the hash, and that users should audit dependencies separately. Or consider including node_modules in the hash for node type servers.

5. manifest_version: '1.0' in pack-init.ts is misleading

pack-init.ts:162 hardcodes:

manifest_version: '1.0',

But the spec is still a draft with version "0.3" (spec line 48). Using "1.0" implies a stable, finalized specification that doesn't exist yet. The MCPB schema files shipped in the repo (e.g., mcpb/schemas/mcpb-manifest-v0.3.schema.json) follow 0.x versioning convention.

Fix: Use "0.3" or track the actual spec version.

6. description is required per spec but optional in zod schema

Spec line 51 lists description as a required field. But cvm-manifest.ts:38 declares:

description: z.string().optional(),

This silently allows bundles with no description through validation.


🟡 MEDIUM

7. No semver validation on version

cvm-manifest.ts:37 accepts any string:

version: z.string(),

The spec (line 50) says "Semantic version (semver)". Invalid version strings ("latest", "abc") pass validation.

Fix: Add .regex(/^\d+\.\d+\.\d+/) or use a semver parsing refinement.

8. Regex injection risk in user_config variable substitution

serve.ts:222-225 builds a RegExp from unescaped config key names:

new RegExp(`\\$\\{user_config\\.${key}\\}`, 'g')

If a user_config key contains regex metacharacters (., $, *, etc.), the regex will behave unexpectedly. For example, a key named api.key would match ${user_config.apiXkey} due to the unescaped ..

Fix: Escape the key before inserting it into the regex pattern.

9. No tests for the pack/crypto/manifest modules

There are zero test files for:

The sign/verify and hash computation logic is cryptographic in nature and strongly needs test coverage — especially given the mismatched ignore list bug above.

10. Dynamic import() in serve.ts:144-145 is unnecessary

const { computeDirectoryContentHash, verifyManifestSignature } =
  await import('./pack/crypto.ts');

This dynamic import has no conditionality — it's inside an if (target.endsWith('.cvmb')) block but always executed when the condition is true. A static import at the top of the file would work identically and be simpler.


🟢 LOW / CODE SMELLS

11. Magic ignore-list arrays duplicated across files

The file/directory ignore patterns appear in three places, each slightly different:

Location Patterns
pack.ts:72-78 (hash) .git, node_modules, .DS_Store, .env, .cvmb
pack.ts:151-159 (archive glob) .git/**, node_modules/.cache/**, .DS_Store, .env, *.cvmb, *.mcpb, outFileName
serve.ts:150-157 (hash verify) .git, node_modules, .DS_Store, .env, .cvmb, .mcpb
crypto.ts:91 (function default) .git, node_modules, .DS_Store, .env

Extract a shared const BUNDLE_IGNORE_PATTERNS and a separate const CONTENT_HASH_IGNORE_PATTERNS to a constants file.

12. ignoreList uses relPath.includes() which is too broad

crypto.ts:103:

if (relPath.includes(ignore) || relPath.startsWith(ignore)) return false;

.includes() for .git would also match my-strategy.ts (contains "git"). .startsWith() handles the directory case. Use path-segment-aware matching instead.

13. pack-init.ts hardcodes manifest_version: '1.0' rather than referencing a constant

The spec version string should live in a single exported constant that both pack-init.ts and cvm-manifest.ts reference.

14. extractBundle() parameter named mcpbPath but function handles .cvmb

extract.ts:9 — the parameter name mcpbPath is a leftover from the MCPB heritage. Should be bundlePath or cvmbPath.

15. user_config example in pack-init.ts injects CVM_RELAYS env var unconditionally

pack-init.ts:144-146 always adds:

mcpConfig.env = {
  CVM_RELAYS: '${user_config.relays}',
};

This overwrites any env that might have been set for mcp_config and is CVM-specific. It should only be added for transport: 'cvm' or be configurable.


✅ What's Good

  • Spec compliance: The packing flow (validate → hash → sign → archive) and verification flow (extract → verify hash → verify sig → resolve config → spawn) correctly implement the spec's 4-phase pipeline
  • Canonicalization: Correct use of canonicalize (RFC 8785) with _sig removal before canonicalization
  • Schnorr signing: _sig structure matches the spec exactly (pubkey, id, signature, created_at)
  • User config prompting: serve.ts correctly iterates user_config, checks env vars first, differentiates boolean vs text prompts, and handles sensitive: true with p.password
  • Variable substitution: ${__dirname} and ${user_config.X} resolution works in both command/args and env
  • Interactive pack init: The wizard covers all server types with sensible defaults
  • Transport support: Both stdio (Gateway) and cvm (native) transports are properly handled in serve.ts

@ContextVM-org

Copy link
Copy Markdown

Core fix: nostr-tools-only signing (removed @noble/curves)

nostr-tools@2.x no longer exports raw schnorr, so the correct way to "let nostr-tools handle signing" is to make _sig a real Nostr signing event. The canonical manifest (RFC 8785, _sig removed) becomes the event's content, and signing/verification go through finalizeEvent/verifyEvent.

src/pack/crypto.ts — rewrote:

  • signManifest: builds { kind, tags: [], content: canonical, created_at }finalizeEvent(...) → returns { pubkey, id, signature, created_at }
  • verifyManifestSignature: reconstructs the event from _sig + recomputed canonical → verifyEvent
  • Removed @noble/curves import, getPublicKey, computeManifestId, and all manual Buffer/byte wrangling
  • _sig wire format is unchanged (pubkey/id/signature/created_at), so pack.ts/serve.ts needed no edits

src/pack/constants.ts — added MANIFEST_SIGNATURE_KIND (documented as an arbitrary, never-published container).

package.json — removed @noble/curves entirely (was a devDep). It survives only as a transitive dep of nostr-tools, which now owns all curve math. Build no longer emits ⚠️ Could not resolve '@noble/curves/...'.

Important nostr-tools gotcha handled: verifyEvent caches its result via the verifiedSymbol symbol property, and object spread copies symbol keys — so I verified (with a runtime probe) that building the event fresh from _sig fields forces a real re-check. E2E confirmed: valid → pass, manifest-tamper → reject, sig-tamper → reject.

Other nits addressed

  • src/pack/cvm-manifest.ts: manifest_version is now required (dropped the silent .default() that could mask malformed manifests); semver regex end-anchored (/^\d+\.\d+\.\d+$/) so 1.2.3foo/1.2.3.4 are rejected.
  • src/pack/constants.ts: removed the redundant .cvmb/.mcpb from CONTENT_HASH_IGNORE_PATTERNS (the extension check in computeDirectoryContentHash already covers them) and clarified the comment.
  • src/pack/crypto.test.ts: rewrote for the new API — added a _sig shape test and an unsigned-manifest case; tamper cases now assert the single unified error message. (7 tests, +1 vs before → 181 total passing.)
  • cvmb-bundling.md: rewrote the Signing and Verification section to describe the nostr-tools event-based flow (signing event structure, finalizeEvent/verifyEvent, updated _sig field semantics where id is now the NIP-01 event id committing to the canonical manifest).

Also defined a kind for the manifest 9501 so eventually publishing these are straightforward

@abhayguptas

Copy link
Copy Markdown
Author

Awesome work! Everything looks perfect. I've pulled the latest commit locally and verified that the build is clean without the @noble/curves warnings, and all tests pass with the new native nostr-tools event signing flow.

I also verified that the node_modules exclusion logic and the stricter manifest_version validations are fully working. The architecture feels much more robust now. Thanks for setting up the 9501 kind for straightforward manifest publishing later!

@1amKhush

1amKhush commented Jun 20, 2026

Copy link
Copy Markdown

Cool work! Switching to native nostr-tools signing (real NIP-01 events with finalizeEvent/verifyEvent) and appending the manifest in-memory without mutating the source are solid improvements.

There are a few leftovers or hygiene items that i thought should be cleaned up before merging (lmk your thoughts on these)

  1. Revert ctxcn Submodule Deletion
    The PR deletes the ctxcn git submodule (deleted file mode 160000). This appears to be an accidental/unrelated destructive change. Please revert this deletion.

  2. Residual .mcpb References in CLI/Help Texts
    Since cvmi pack now produces .cvmb files, several user-facing strings still refer to .mcpb:

    • cli.ts: The banner and help examples (Package a server into an MCPB bundle / cvmi serve my-server-1.0.0.mcpb).
    • serve.ts: The <bundle.mcpb> argument description.
    • Let's change these to consistently reference .cvmb so users aren't confused.
  3. CJS Bridge (createRequire) in pack.ts

    import { createRequire } from 'module';
    const require = createRequire(import.meta.url);
    const archiver = require('archiver');

archiver supports ESM default imports. Try replacing this bridge with a standard import archiver from 'archiver'; to preserve type safety and avoid CJS interop hooks.

  1. Temp Directory Leak on Crash : The cleanup code for the extracted bundle directory in serve.ts is only triggered on graceful shutdown signals (SIGINT/SIGTERM). If the process crashes or gets killed, the temporary directory in /tmp leaks.
    • Consider wrapping the run in a try...finally block, registering a synchronous process.on('exit') hook, or setting the temp directory mode to 0o700 during creation to prevent multi-user read access in shared environments.

Some further improvements we could consider:

  • @types/extract-zip Stub: This is a deprecated package stub because extract-zip now ships its own types. You can safely remove @types/extract-zip from your devDependencies.
  • Zod .passthrough() during Packaging: While .passthrough() is great for parsing bundles in serve.ts to allow future schema extensions, using it in pack.ts means structural typos (like descrption: "...") will silently pass. Consider validating strictly (.strict()) during packaging and using .passthrough() only when extracting/serving.
  • Double canonicalize Dependency: The lockfile now lists both canonicalize@2.1.0 (from nostr-tools) and canonicalize@3.0.0. To avoid subtle canonicalization differences or bloat, check if pinning to canonicalize@2 works fine for the manifest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Study usage of MCPB for bundling

3 participants