From ff4bfe6d89719b6975ba4a4e07aa52a0fb67492c Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Tue, 9 Jun 2026 19:58:51 +0530 Subject: [PATCH 01/10] feat(pack): add mcpb bundling support with cvm manifest conventions --- AGENTS.md | 19 +- package.json | 4 + pnpm-lock.yaml | 650 +++++++++++++++++++++++++++++++++++++++ src/cli.ts | 22 ++ src/pack.ts | 159 ++++++++++ src/pack/cvm-manifest.ts | 56 ++++ src/pack/extract.ts | 36 +++ src/pack/pack-init.ts | 164 ++++++++++ src/serve.ts | 53 +++- 9 files changed, 1153 insertions(+), 10 deletions(-) create mode 100644 src/pack.ts create mode 100644 src/pack/cvm-manifest.ts create mode 100644 src/pack/extract.ts create mode 100644 src/pack/pack-init.ts diff --git a/AGENTS.md b/AGENTS.md index e34869d12..d861250ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,15 @@ This file provides guidance to AI coding agents working on the `cvmi` CLI codeba ## Commands -| Command | Description | -| -------------------- | --------------------------------------------------- | -| `cvmi` | Show banner with available commands | -| `cvmi add ` | Install skills from git repos, URLs, or local paths | -| `cvmi check` | Check for available skill updates | -| `cvmi update` | Update all skills to latest versions | -| `cvmi pn` / `cn` | Compile a server to TypeScript code | -| `cvmi generate-lock` | Match installed skills to sources via API | +| Command | Description | +| -------------------- | --------------------------------------------------------- | +| `cvmi` | Show banner with available commands | +| `cvmi add ` | Install skills from git repos, URLs, or local paths | +| `cvmi pack` | Package an MCP server into a distributable `.mcpb` bundle | +| `cvmi check` | Check for available skill updates | +| `cvmi update` | Update all skills to latest versions | +| `cvmi pn` / `cn` | Compile a server to TypeScript code | +| `cvmi generate-lock` | Match installed skills to sources via API | Aliases: `cvmi a`, `cvmi i`, `cvmi install` all work for `add`. @@ -25,6 +26,8 @@ Aliases: `cvmi a`, `cvmi i`, `cvmi install` all work for `add`. src/ ├── cli.ts # Main entry point, command routing, init/check/update ├── cli.test.ts # CLI tests +├── pack.ts # Pack command implementation +├── pack/ # Pack utilities (extract, cvm-manifest, pack-init) ├── add.ts # Core add command logic ├── add.test.ts # Add command tests ├── cn/ # Client generation (ctxcn) module diff --git a/package.json b/package.json index 1acb78c2c..69a48e601 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,8 @@ "dependencies": { "@contextvm/sdk": "^0.11.14", "@modelcontextprotocol/sdk": "^1.27.1", + "archiver": "^8.0.0", + "extract-zip": "^2.0.1", "json-schema-to-typescript": "15.0.4", "nostr-tools": "^2.23.3", "xdg-basedir": "^5.1.0", @@ -103,7 +105,9 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@clack/prompts": "^0.11.0", + "@types/archiver": "^8.0.0", "@types/bun": "latest", + "@types/extract-zip": "^2.0.3", "@types/node": "^22.19.15", "gray-matter": "^4.0.3", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0a45209..65d8c1edc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,12 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + archiver: + specifier: ^8.0.0 + version: 8.0.0 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1 json-schema-to-typescript: specifier: 15.0.4 version: 15.0.4 @@ -32,9 +38,15 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@types/archiver': + specifier: ^8.0.0 + version: 8.0.0 '@types/bun': specifier: latest version: 1.3.11 + '@types/extract-zip': + specifier: ^2.0.3 + version: 2.0.3 '@types/node': specifier: ^22.19.15 version: 22.19.15 @@ -1251,6 +1263,12 @@ packages: integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, } + '@types/archiver@8.0.0': + resolution: + { + integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==, + } + '@types/bun@1.3.11': resolution: { @@ -1275,6 +1293,13 @@ packages: integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, } + '@types/extract-zip@2.0.3': + resolution: + { + integrity: sha512-yrO7h+0qOIGxHCmBeL5fKFzR+PBafh9LG6sOLBFFi2JuN+Hj663TAxfnqJh5vkQn963VimrhBF1GZzea3A+4Ig==, + } + deprecated: This is a stub types definition. extract-zip provides its own type definitions, so you do not need this installed. + '@types/jsesc@2.5.1': resolution: { @@ -1305,6 +1330,18 @@ packages: integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==, } + '@types/readdir-glob@1.1.5': + resolution: + { + integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==, + } + + '@types/yauzl@2.10.3': + resolution: + { + integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==, + } + '@vitest/expect@4.1.0': resolution: { @@ -1355,6 +1392,13 @@ packages: integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==, } + abort-controller@3.0.0: + resolution: + { + integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, + } + engines: { node: '>=6.5' } + accepts@2.0.0: resolution: { @@ -1426,6 +1470,13 @@ packages: integrity: sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==, } + archiver@8.0.0: + resolution: + { + integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==, + } + engines: { node: '>=18' } + argparse@1.0.10: resolution: { @@ -1466,6 +1517,12 @@ packages: } engines: { node: '>=20.19.0' } + async@3.2.6: + resolution: + { + integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, + } + atomic-sleep@1.0.0: resolution: { @@ -1473,6 +1530,89 @@ packages: } engines: { node: '>=8.0.0' } + b4a@1.8.1: + resolution: + { + integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==, + } + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, + } + engines: { node: 18 || 20 || >=22 } + + bare-events@2.9.1: + resolution: + { + integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==, + } + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: + { + integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==, + } + engines: { bare: '>=1.16.0' } + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: + { + integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==, + } + engines: { bare: '>=1.14.0' } + + bare-path@3.0.1: + resolution: + { + integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==, + } + + bare-stream@2.13.1: + resolution: + { + integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==, + } + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: + { + integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==, + } + + base64-js@1.5.1: + resolution: + { + integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, + } + better-path-resolve@1.0.0: resolution: { @@ -1493,6 +1633,13 @@ packages: } engines: { node: '>=18' } + brace-expansion@5.0.6: + resolution: + { + integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==, + } + engines: { node: 18 || 20 || >=22 } + braces@3.0.3: resolution: { @@ -1500,6 +1647,25 @@ packages: } engines: { node: '>=8' } + buffer-crc32@0.2.13: + resolution: + { + integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==, + } + + buffer-crc32@1.0.0: + resolution: + { + integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==, + } + engines: { node: '>=8.0.0' } + + buffer@6.0.3: + resolution: + { + integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, + } + bun-types@1.3.11: resolution: { @@ -1603,6 +1769,13 @@ packages: integrity: sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA==, } + compress-commons@7.0.1: + resolution: + { + integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==, + } + engines: { node: '>=18' } + confbox@0.2.4: resolution: { @@ -1650,6 +1823,12 @@ packages: } engines: { node: '>= 0.6' } + core-util-is@1.0.3: + resolution: + { + integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, + } + cors@2.8.6: resolution: { @@ -1657,6 +1836,21 @@ packages: } engines: { node: '>= 0.10' } + crc-32@1.2.2: + resolution: + { + integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, + } + engines: { node: '>=0.8' } + hasBin: true + + crc32-stream@7.0.1: + resolution: + { + integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==, + } + engines: { node: '>=18' } + cross-spawn@7.0.6: resolution: { @@ -1754,6 +1948,12 @@ packages: } engines: { node: '>= 0.8' } + end-of-stream@1.4.5: + resolution: + { + integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, + } + enquirer@2.4.1: resolution: { @@ -1830,12 +2030,32 @@ packages: } engines: { node: '>= 0.6' } + event-target-shim@5.0.1: + resolution: + { + integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, + } + engines: { node: '>=6' } + eventemitter3@5.0.4: resolution: { integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==, } + events-universal@1.0.1: + resolution: + { + integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==, + } + + events@3.3.0: + resolution: + { + integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==, + } + engines: { node: '>=0.8.x' } + eventsource-parser@3.0.6: resolution: { @@ -1892,12 +2112,26 @@ packages: integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==, } + extract-zip@2.0.1: + resolution: + { + integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==, + } + engines: { node: '>= 10.17.0' } + hasBin: true + fast-deep-equal@3.1.3: resolution: { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, } + fast-fifo@1.3.2: + resolution: + { + integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==, + } + fast-glob@3.3.3: resolution: { @@ -1917,6 +2151,12 @@ packages: integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==, } + fd-slicer@1.1.0: + resolution: + { + integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==, + } + fdir@6.5.0: resolution: { @@ -2013,6 +2253,13 @@ packages: } engines: { node: '>= 0.4' } + get-stream@5.2.0: + resolution: + { + integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==, + } + engines: { node: '>=8' } + get-tsconfig@4.13.7: resolution: { @@ -2109,6 +2356,12 @@ packages: } engines: { node: '>=0.10.0' } + ieee754@1.2.1: + resolution: + { + integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, + } + ignore@5.3.2: resolution: { @@ -2177,6 +2430,13 @@ packages: integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, } + is-stream@4.0.1: + resolution: + { + integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==, + } + engines: { node: '>=18' } + is-subdir@1.2.0: resolution: { @@ -2191,6 +2451,12 @@ packages: } engines: { node: '>=0.10.0' } + isarray@1.0.0: + resolution: + { + integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==, + } + isexe@2.0.0: resolution: { @@ -2258,6 +2524,13 @@ packages: } engines: { node: '>=0.10.0' } + lazystream@1.0.1: + resolution: + { + integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==, + } + engines: { node: '>= 0.6.3' } + lightningcss-android-arm64@1.32.0: resolution: { @@ -2467,6 +2740,13 @@ packages: } engines: { node: '>=18' } + minimatch@10.2.5: + resolution: + { + integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==, + } + engines: { node: 18 || 20 || >=22 } + minimist@1.2.8: resolution: { @@ -2515,6 +2795,13 @@ packages: } engines: { node: '>= 0.6' } + normalize-path@3.0.0: + resolution: + { + integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, + } + engines: { node: '>=0.10.0' } + nostr-tools@2.18.2: resolution: { @@ -2702,6 +2989,12 @@ packages: integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, } + pend@1.2.0: + resolution: + { + integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==, + } + picocolors@1.1.1: resolution: { @@ -2791,12 +3084,25 @@ packages: } engines: { node: '>=20' } + process-nextick-args@2.0.1: + resolution: + { + integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==, + } + process-warning@5.0.0: resolution: { integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==, } + process@0.11.10: + resolution: + { + integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==, + } + engines: { node: '>= 0.6.0' } + proxy-addr@2.0.7: resolution: { @@ -2804,6 +3110,12 @@ packages: } engines: { node: '>= 0.10' } + pump@3.0.4: + resolution: + { + integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==, + } + qs@6.15.0: resolution: { @@ -2856,6 +3168,26 @@ packages: } engines: { node: '>=6' } + readable-stream@2.3.8: + resolution: + { + integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==, + } + + readable-stream@4.7.0: + resolution: + { + integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + readdir-glob@3.0.0: + resolution: + { + integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==, + } + engines: { node: '>=18' } + real-require@0.2.0: resolution: { @@ -2977,6 +3309,18 @@ packages: integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==, } + safe-buffer@5.1.2: + resolution: + { + integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==, + } + + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, + } + safe-stable-stringify@2.5.0: resolution: { @@ -3206,6 +3550,12 @@ packages: integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==, } + streamx@2.27.0: + resolution: + { + integrity: sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==, + } + string-argv@0.3.2: resolution: { @@ -3227,6 +3577,18 @@ packages: } engines: { node: '>=20' } + string_decoder@1.1.1: + resolution: + { + integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==, + } + + string_decoder@1.3.0: + resolution: + { + integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, + } + strip-ansi@6.0.1: resolution: { @@ -3255,6 +3617,18 @@ packages: } engines: { node: '>=4' } + tar-stream@3.2.0: + resolution: + { + integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==, + } + + teex@1.0.1: + resolution: + { + integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==, + } + term-size@2.2.1: resolution: { @@ -3262,6 +3636,12 @@ packages: } engines: { node: '>=8' } + text-decoder@1.2.7: + resolution: + { + integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==, + } + thread-stream@4.0.0: resolution: { @@ -3359,6 +3739,12 @@ packages: } engines: { node: '>= 0.8' } + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, + } + vary@1.1.2: resolution: { @@ -3509,6 +3895,19 @@ packages: engines: { node: '>= 14.6' } hasBin: true + yauzl@2.10.0: + resolution: + { + integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==, + } + + zip-stream@7.0.5: + resolution: + { + integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==, + } + engines: { node: '>=18' } + zod-to-json-schema@3.25.1: resolution: { @@ -4180,6 +4579,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/archiver@8.0.0': + dependencies: + '@types/node': 22.19.15 + '@types/readdir-glob': 1.1.5 + '@types/bun@1.3.11': dependencies: bun-types: 1.3.11 @@ -4193,6 +4597,12 @@ snapshots: '@types/estree@1.0.8': {} + '@types/extract-zip@2.0.3': + dependencies: + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -4205,6 +4615,15 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 22.19.15 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.15 + optional: true + '@vitest/expect@4.1.0': dependencies: '@standard-schema/spec': 1.1.0 @@ -4246,6 +4665,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4297,6 +4720,22 @@ snapshots: - supports-color - typescript + archiver@8.0.0: + dependencies: + async: 3.2.6 + buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + readdir-glob: 3.0.0 + tar-stream: 3.2.0 + zip-stream: 7.0.5 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -4315,8 +4754,48 @@ snapshots: estree-walker: 3.0.3 pathe: 2.0.3 + async@3.2.6: {} + atomic-sleep@1.0.0: {} + b4a@1.8.1: {} + + balanced-match@4.0.4: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.1(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.9.1): + dependencies: + streamx: 2.27.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.0.1 + + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -4337,10 +4816,23 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 + buffer-crc32@0.2.13: {} + + buffer-crc32@1.0.0: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bun-types@1.3.11: dependencies: '@types/node': 22.19.15 @@ -4387,6 +4879,14 @@ snapshots: commenting@1.1.0: {} + compress-commons@7.0.1: + dependencies: + crc-32: 1.2.2 + crc32-stream: 7.0.1 + is-stream: 4.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + confbox@0.2.4: {} consola@3.4.2: {} @@ -4401,11 +4901,20 @@ snapshots: cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + crc-32@1.2.2: {} + + crc32-stream@7.0.1: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4444,6 +4953,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -4500,8 +5013,18 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -4556,8 +5079,20 @@ snapshots: extendable-error@0.1.7: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4572,6 +5107,10 @@ snapshots: dependencies: reusify: 1.1.0 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4637,6 +5176,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -4691,6 +5234,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} inherits@2.0.4: {} @@ -4715,12 +5260,16 @@ snapshots: is-promise@4.0.0: {} + is-stream@4.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-windows@1.0.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} jose@6.2.2: {} @@ -4758,6 +5307,10 @@ snapshots: kind-of@6.0.3: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lightningcss-android-arm64@1.32.0: optional: true @@ -4866,6 +5419,10 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimist@1.2.8: {} moment@2.30.1: {} @@ -4880,6 +5437,8 @@ snapshots: negotiator@1.0.0: {} + normalize-path@3.0.0: {} + nostr-tools@2.18.2(typescript@5.9.3): dependencies: '@noble/ciphers': 0.5.3 @@ -5001,6 +5560,8 @@ snapshots: pathe@2.0.3: {} + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5049,13 +5610,22 @@ snapshots: pretty-bytes@7.1.0: {} + process-nextick-args@2.0.1: {} + process-warning@5.0.0: {} + process@0.11.10: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -5087,6 +5657,28 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@3.0.0: + dependencies: + minimatch: 10.2.5 + real-require@0.2.0: {} require-from-string@2.0.2: {} @@ -5226,6 +5818,10 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} @@ -5372,6 +5968,15 @@ snapshots: std-env@4.0.0: {} + streamx@2.27.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-argv@0.3.2: {} string-width@7.2.0: @@ -5385,6 +5990,14 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -5397,8 +6010,32 @@ snapshots: strip-bom@3.0.0: {} + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.27.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.27.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + term-size@2.2.1: {} + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thread-stream@4.0.0: dependencies: real-require: 0.2.0 @@ -5443,6 +6080,8 @@ snapshots: unpipe@1.0.0: {} + util-deprecate@1.0.2: {} + vary@1.1.2: {} vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3): @@ -5510,6 +6149,17 @@ snapshots: yaml@2.8.3: {} + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + zip-stream@7.0.5: + dependencies: + compress-commons: 7.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/src/cli.ts b/src/cli.ts index bf7e54546..7352087b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; import { track } from './telemetry.ts'; import { serve, showServeHelp } from './serve.ts'; +import { pack, showPackHelp, parsePackArgs } from './pack.ts'; import { showUseHelp, use } from './use.ts'; import { call, parseCallArgs, showCallHelp } from './call.ts'; import { discover, parseDiscoverArgs, showDiscoverHelp } from './discover.ts'; @@ -64,6 +65,7 @@ function showBanner(): void { console.log(); const entries: [string, string][] = [ ['npx cvmi add [options]', 'Install ContextVM skills'], + ['npx cvmi pack [options]', 'Package a server into an MCPB bundle'], ['npx cvmi serve [options] -- ', 'Expose MCP server over Nostr'], ['npx cvmi use ', 'Connect to Nostr MCP server'], ['npx cvmi config ', 'Manage saved server aliases'], @@ -93,6 +95,7 @@ ${BOLD}Commands:${RESET} remove, rm, r Remove installed skills list, ls List installed skills init [name] Initialize a new skill + pack Package an MCP server into an MCPB bundle sync Sync skills from node_modules serve Expose an MCP server over Nostr use Connect to a remote Nostr MCP server @@ -117,7 +120,9 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi add ${DIM}# install embedded ContextVM skills${RESET} ${DIM}$${RESET} cvmi add --skill overview ${DIM}# install a specific skill${RESET} ${DIM}$${RESET} cvmi remove ${DIM}# remove an installed skill${RESET} + ${DIM}$${RESET} cvmi pack ${DIM}# pack a server into .mcpb bundle${RESET} ${DIM}$${RESET} cvmi serve -- ${DIM}# start gateway, expose an already existing server (stdio or http) over nostr${RESET} + ${DIM}$${RESET} cvmi serve my-server.mcpb ${DIM}# serve a packed mcpb bundle over nostr${RESET} ${DIM}$${RESET} cvmi use ${DIM}# connect to remote MCP server, expose it as stdio${RESET} ${DIM}$${RESET} cvmi discover ${DIM}# find public ContextVM servers${RESET} ${DIM}$${RESET} cvmi call ${DIM}# list remote capabilities${RESET} @@ -957,6 +962,23 @@ async function main(): Promise { case 'upgrade': runUpdate(); break; + case 'pack': { + const parsed = parsePackArgs(restArgs); + + if (parsed.unknownFlags.length > 0) { + console.error(`Unknown flag(s): ${parsed.unknownFlags.join(', ')}`); + console.error(`Run 'cvmi pack --help' for usage.`); + process.exit(1); + } + + if (parsed.help) { + showPackHelp(); + break; + } + + await pack(parsed.targetDir, parsed.options); + break; + } case 'serve': { ensureRelayRuntime(); // Check for --help or -h flag (only before `--` separator) diff --git a/src/pack.ts b/src/pack.ts new file mode 100644 index 000000000..6c73ee3af --- /dev/null +++ b/src/pack.ts @@ -0,0 +1,159 @@ +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const archiver = require('archiver'); +import { createWriteStream, existsSync, readFileSync } from 'fs'; +import { join, resolve } from 'path'; +import * as p from '@clack/prompts'; +import { runPackInit } from './pack/pack-init.ts'; +import { validateManifest, type McpbManifest } from './pack/cvm-manifest.ts'; +import { BOLD, DIM, RESET } from './constants/ui.ts'; + +export interface PackOptions { + output?: string; + manifest?: string; + noValidate?: boolean; + verbose?: boolean; +} + +export async function pack(targetDir: string = '.', options: PackOptions = {}): Promise { + const dir = resolve(targetDir); + + if (!existsSync(dir)) { + p.log.error(`Directory not found: ${dir}`); + process.exit(1); + } + + const manifestPath = options.manifest ? resolve(options.manifest) : join(dir, 'manifest.json'); + + if (!existsSync(manifestPath)) { + p.log.info(`Manifest not found at ${manifestPath}`); + const initialized = await runPackInit(dir); + if (!initialized) { + process.exit(1); + } + } + + let manifest: McpbManifest; + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); + if (!options.noValidate) { + manifest = validateManifest(raw); + } else { + manifest = raw as McpbManifest; + } + } catch (error) { + p.log.error(`Invalid manifest: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + + const outFileName = options.output || `${manifest.name}-${manifest.version}.mcpb`; + const outPath = resolve(outFileName); + + p.log.info(`Packing ${manifest.name} v${manifest.version}...`); + + if (manifest.server.type === 'node') { + if (!existsSync(join(dir, 'node_modules'))) { + p.log.warn( + 'No node_modules directory found. Node.js servers usually require bundled dependencies.' + ); + } + } + + await new Promise((resolvePromise, rejectPromise) => { + const output = createWriteStream(outPath); + const archive = archiver('zip', { + zlib: { level: 9 }, // maximum compression + }); + + output.on('close', () => { + p.log.success(`Created bundle: ${outPath} (${archive.pointer()} bytes)`); + resolvePromise(); + }); + + archive.on('error', (err: Error) => { + rejectPromise(err); + }); + + archive.pipe(output); + + // Add all files from directory, excluding some common things we don't want + archive.glob('**/*', { + cwd: dir, + dot: true, + ignore: ['.git/**', 'node_modules/.cache/**', '.DS_Store', '.env', '*.mcpb', outFileName], + }); + + archive.finalize(); + }); +} + +export function showPackHelp(): void { + console.log(` +${BOLD}Usage:${RESET} + cvmi pack [directory] [options] + +${BOLD}Description:${RESET} + Package a local MCP server into a distributable MCPB bundle (.mcpb). + If no manifest.json exists, an interactive wizard will help you create one + with ContextVM-specific extensions (relays, public mode, encryption). + +${BOLD}Options:${RESET} + --output, -o Custom output file name + --manifest, -m Custom manifest path (default: manifest.json) + --no-validate Skip manifest validation + --verbose Enable verbose logging + --help, -h Show this help message + +${BOLD}Examples:${RESET} + ${DIM}$${RESET} cvmi pack ${DIM}# package current directory${RESET} + ${DIM}$${RESET} cvmi pack ./my-server ${DIM}# package specific directory${RESET} + ${DIM}$${RESET} cvmi pack -o custom-name.mcpb ${DIM}# custom output name${RESET} + `); +} + +export function parsePackArgs(args: string[]): { + targetDir: string; + options: PackOptions; + help: boolean; + unknownFlags: string[]; +} { + const result = { + targetDir: '.', + options: {} as PackOptions, + help: false, + unknownFlags: [] as string[], + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ''; + + const consumeValue = (flagName: string): string | undefined => { + const nextIndex = ++i; + const value = args[nextIndex]; + if (value === undefined || value.startsWith('-')) { + result.unknownFlags.push(`${flagName} (missing value)`); + if (value?.startsWith('-')) i--; + return undefined; + } + return value; + }; + + if (arg === '--help' || arg === '-h') { + result.help = true; + } else if (arg === '--verbose') { + result.options.verbose = true; + } else if (arg === '--no-validate') { + result.options.noValidate = true; + } else if (arg === '--output' || arg === '-o') { + result.options.output = consumeValue(arg); + } else if (arg === '--manifest' || arg === '-m') { + result.options.manifest = consumeValue(arg); + } else if (arg.startsWith('-')) { + result.unknownFlags.push(arg); + } else { + result.targetDir = arg; + } + } + + return result; +} diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts new file mode 100644 index 000000000..974abb9f2 --- /dev/null +++ b/src/pack/cvm-manifest.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; +import { DEFAULT_RELAYS } from '../config/index.ts'; + +export const CVMMetaSchema = z.object({ + public: z.boolean().default(false), + default_relays: z.array(z.string()).default(DEFAULT_RELAYS), + encryption: z.enum(['nip44', 'optional', 'disabled']).default('optional'), + announce: z.boolean().default(true), + pricing: z.any().nullable().default(null), +}); + +export type CVMMeta = z.infer; + +// The full manifest including MCPB and CVM extension +export const McpbManifestSchema = z + .object({ + manifest_version: z.string(), + name: z.string(), + display_name: z.string(), + version: z.string(), + description: z.string().optional(), + author: z.object({ + name: z.string(), + email: z.string().optional(), + url: z.string().optional(), + }), + server: z.object({ + type: z.enum(['node', 'python', 'binary']), + entry_point: z.string(), + mcp_config: z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + }), + }), + user_config: z.record(z.string(), z.any()).optional(), + _meta: z + .object({ + 'com.contextvm': CVMMetaSchema.optional(), + }) + .optional(), + }) + .passthrough(); + +export type McpbManifest = z.infer; + +export function validateManifest(data: unknown): McpbManifest { + return McpbManifestSchema.parse(data); +} + +export const DEFAULT_CVM_META: CVMMeta = { + public: false, + default_relays: DEFAULT_RELAYS, + encryption: 'optional', + announce: true, + pricing: null, +}; diff --git a/src/pack/extract.ts b/src/pack/extract.ts new file mode 100644 index 000000000..47cadcb94 --- /dev/null +++ b/src/pack/extract.ts @@ -0,0 +1,36 @@ +import extractZip from 'extract-zip'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import os from 'os'; +import { validateManifest, type McpbManifest } from './cvm-manifest.ts'; +import { randomBytes } from 'crypto'; + +export async function extractBundle( + mcpbPath: string +): Promise<{ dir: string; manifest: McpbManifest }> { + // Use a unique temp directory for extraction + const extractDir = join(os.tmpdir(), `cvmi-bundle-${randomBytes(8).toString('hex')}`); + + try { + await extractZip(mcpbPath, { dir: extractDir }); + } catch (err) { + throw new Error( + `Failed to extract bundle: ${err instanceof Error ? err.message : String(err)}` + ); + } + + const manifestPath = join(extractDir, 'manifest.json'); + if (!existsSync(manifestPath)) { + throw new Error('Invalid bundle: manifest.json not found inside the archive.'); + } + + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); + const manifest = validateManifest(raw); + return { dir: extractDir, manifest }; + } catch (error) { + throw new Error( + `Invalid manifest in bundle: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/src/pack/pack-init.ts b/src/pack/pack-init.ts new file mode 100644 index 000000000..753655cf7 --- /dev/null +++ b/src/pack/pack-init.ts @@ -0,0 +1,164 @@ +import * as p from '@clack/prompts'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, basename } from 'path'; +import { DEFAULT_RELAYS } from '../config/index.ts'; +import type { EncryptionMode } from '@contextvm/sdk'; + +export async function runPackInit(dir: string): Promise { + const manifestPath = join(dir, 'manifest.json'); + if (existsSync(manifestPath)) { + p.log.info('manifest.json already exists.'); + return true; + } + + p.log.info("No manifest.json found. Let's create one."); + + let defaultName = basename(dir); + let defaultVersion = '1.0.0'; + let defaultDescription = ''; + + const pkgJsonPath = join(dir, 'package.json'); + if (existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); + if (pkg.name) defaultName = pkg.name; + if (pkg.version) defaultVersion = pkg.version; + if (pkg.description) defaultDescription = pkg.description; + } catch {} + } + + const result = await p.group( + { + name: () => + p.text({ + message: 'Server name', + initialValue: defaultName, + validate: (value) => { + if (!value) return 'Please enter a name.'; + if (!/^[a-z0-9-]+$/.test(value)) + return 'Name can only contain lowercase letters, numbers, and dashes.'; + }, + }), + displayName: ({ results }) => + p.text({ + message: 'Display name', + initialValue: results.name, + }), + version: () => + p.text({ + message: 'Version', + initialValue: defaultVersion, + }), + description: () => + p.text({ + message: 'Description', + initialValue: defaultDescription, + }), + author: () => + p.text({ + message: 'Author name', + validate: (value) => { + if (!value) return 'Please enter an author name.'; + }, + }), + type: () => + p.select({ + message: 'Server type', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'binary', label: 'Binary' }, + ], + initialValue: 'node', + }), + entryPoint: ({ results }) => { + let initial = 'index.js'; + if (results.type === 'node') initial = 'build/index.js'; + if (results.type === 'python') initial = 'src/server.py'; + if (results.type === 'binary') initial = 'bin/server'; + return p.text({ + message: 'Entry point path', + initialValue: initial, + }); + }, + command: ({ results }) => { + let initial = 'node'; + if (results.type === 'python') initial = 'python'; + if (results.type === 'binary') initial = `\${__dirname}/${results.entryPoint}`; + return p.text({ + message: 'Command to run (mcp_config)', + initialValue: initial, + }); + }, + public: () => + p.confirm({ + message: 'Should this server be public? (accept connections from any pubkey)', + initialValue: false, + }), + relays: () => + p.text({ + message: 'Default relays (comma-separated)', + initialValue: DEFAULT_RELAYS.join(', '), + }), + encryption: () => + p.select({ + message: 'Encryption mode', + options: [ + { value: 'nip44', label: 'NIP-44 (Required)' }, + { value: 'optional', label: 'Optional (Fallback to unencrypted)' }, + { value: 'disabled', label: 'Disabled (Unencrypted)' }, + ], + initialValue: 'optional' as EncryptionMode, + }), + announce: () => + p.confirm({ + message: 'Announce server on Nostr? (publish kind 11316-11320)', + initialValue: true, + }), + }, + { + onCancel: () => { + p.cancel('Operation cancelled.'); + process.exit(0); + }, + } + ); + + const relaysList = result.relays + .split(',') + .map((r) => r.trim()) + .filter((r) => r); + + const manifest = { + manifest_version: '0.3', + name: result.name, + display_name: result.displayName, + version: result.version, + description: result.description, + author: { + name: result.author, + }, + server: { + type: result.type, + entry_point: result.entryPoint, + mcp_config: { + command: result.command, + args: result.type === 'binary' ? [] : [`\${__dirname}/${result.entryPoint}`], + }, + }, + _meta: { + 'com.contextvm': { + public: result.public, + default_relays: relaysList, + encryption: result.encryption, + announce: result.announce, + pricing: null, + }, + }, + }; + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + p.log.success(`Created manifest.json in ${dir}`); + + return true; +} diff --git a/src/serve.ts b/src/serve.ts index b3210943c..d08eedfd6 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -12,6 +12,9 @@ import { NostrMCPGateway, PrivateKeySigner, EncryptionMode } from '@contextvm/sd import { loadConfig, getServeConfig, DEFAULT_RELAYS } from './config/index.ts'; import { generatePrivateKey, normalizePrivateKey } from './utils/crypto.ts'; import { waitForShutdownSignal } from './utils/process.ts'; +import { extractBundle } from './pack/extract.ts'; +import { DEFAULT_CVM_META } from './pack/cvm-manifest.ts'; +import fs from 'fs'; import { BOLD, DIM, RESET } from './constants/ui.ts'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { savePrivateKeyToEnv } from './config/loader.ts'; @@ -120,15 +123,52 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis // Priority: // - CLI args (positional) override config entirely // - otherwise config.url (remote Streamable HTTP) wins over config.command/config.args - const target = + let target = serverArgs.length > 0 ? serverArgs[0] : serveConfig.url ? serveConfig.url : serveConfig.command; - const targetArgs = serverArgs.length > 0 ? serverArgs.slice(1) : (serveConfig.args ?? []); + let targetArgs = serverArgs.length > 0 ? serverArgs.slice(1) : (serveConfig.args ?? []); if (!target) { showServeHelp(); process.exit(1); } + let cleanupPath: string | undefined; + + // Handle .mcpb bundle execution + if (target.endsWith('.mcpb')) { + p.log.info(`Extracting bundle ${target}...`); + try { + const { dir, manifest } = await extractBundle(target); + cleanupPath = dir; + + // Load default CVM config from manifest + const meta = manifest._meta?.['com.contextvm'] || DEFAULT_CVM_META; + + // Override serveConfig with manifest defaults (CLI flags already win due to precedence) + if (options.relays === undefined && !config.serve?.relays) { + serveConfig.relays = meta.default_relays; + } + if (options.public === undefined && !config.serve?.public) { + serveConfig.public = meta.public; + } + if (options.encryption === undefined && !config.serve?.encryption) { + serveConfig.encryption = meta.encryption as EncryptionMode; + } + // TODO: Handle 'announce' and 'pricing' if/when NostrMCPGateway supports them directly + + target = manifest.server.mcp_config.command.replace(/\$\{__dirname\}/g, dir); + const rawArgs = manifest.server.mcp_config.args || []; + // Replace ${__dirname} with the extracted directory + targetArgs = rawArgs.map((arg) => arg.replace(/\$\{__dirname\}/g, dir)); + + // Merge env vars from user_config if needed (stub for future user_config support) + // mcpEnv = { ...mcpEnv, ... }; + } catch (error) { + p.log.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + } + // Auto-generate private key if not provided let privateKey = serveConfig.privateKey; if (!privateKey) { @@ -227,6 +267,11 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis p.log.message(`\n${signal} received. Shutting down...`); await gateway.stop(); + if (cleanupPath && fs.existsSync(cleanupPath)) { + p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + process.exit(0); } @@ -246,6 +291,8 @@ ${BOLD}Arguments:${RESET} Can also be specified in config file under serve.command If the first argument is an http(s) URL, cvmi will treat it as a Streamable HTTP MCP server and connect via HTTP instead of spawning a local process. + If the first argument is an .mcpb file, cvmi will extract the bundle, + read the manifest, apply CVM config defaults, and spawn the server. ${BOLD}Config keys:${RESET} serve.url Optional remote MCP server URL (Streamable HTTP). If set, it is used when no CLI target @@ -304,6 +351,8 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi serve https://mcp.server.com ${DIM}# expose a remote Streamable HTTP MCP server over Nostr${RESET} ${DIM}$${RESET} cvmi serve npx -y @modelcontextprotocol/server-prompt-generator --public ${DIM}# public server${RESET} ${DIM}$${RESET} cvmi serve python /path/to/server.py --relays wss://my-relay.com ${DIM}# custom relay${RESET} + ${DIM}$${RESET} cvmi serve my-server-1.0.0.mcpb ${DIM}# run an MCPB bundle over Nostr${RESET} + ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} `); } From cedda337658933ad71673673d09c9543e213ae2e Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Wed, 10 Jun 2026 17:08:55 +0530 Subject: [PATCH 02/10] refactor(pack): align manifest schema with mentor feedback - 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 --- src/pack.ts | 6 +++ src/pack/cvm-manifest.ts | 43 ++++++++++----- src/pack/pack-init.ts | 113 ++++++++++++++++++++++++++++----------- src/serve.ts | 97 +++++++++++++++++++++++++++------ 4 files changed, 198 insertions(+), 61 deletions(-) diff --git a/src/pack.ts b/src/pack.ts index 6c73ee3af..f1f59f3b9 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -59,6 +59,12 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): } } + if (manifest.server.type === 'docker' && !manifest.server.image) { + p.log.warn( + 'Docker server type detected but no "image" field found in manifest. Bundle may not work.' + ); + } + await new Promise((resolvePromise, rejectPromise) => { const output = createWriteStream(outPath); const archive = archiver('zip', { diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts index 974abb9f2..496941f08 100644 --- a/src/pack/cvm-manifest.ts +++ b/src/pack/cvm-manifest.ts @@ -1,12 +1,27 @@ import { z } from 'zod'; import { DEFAULT_RELAYS } from '../config/index.ts'; -export const CVMMetaSchema = z.object({ - public: z.boolean().default(false), - default_relays: z.array(z.string()).default(DEFAULT_RELAYS), +// CVM-specific defaults for a bundle +export const CVMDefaultsSchema = z.object({ + relays: z.array(z.string()).default(DEFAULT_RELAYS), encryption: z.enum(['nip44', 'optional', 'disabled']).default('optional'), - announce: z.boolean().default(true), - pricing: z.any().nullable().default(null), + public: z.boolean().default(false), +}); + +export type CVMDefaults = z.infer; + +// The env_mapping contract: maps config keys to env var names +export const CVMEnvMappingSchema = z + .record(z.enum(['relays', 'encryption', 'public', 'private_key']), z.string()) + .optional(); + +export type CVMEnvMapping = z.infer; + +// Top-level CVM meta namespace +export const CVMMetaSchema = z.object({ + transport: z.enum(['stdio', 'cvm']).default('stdio'), + env_mapping: CVMEnvMappingSchema, + defaults: CVMDefaultsSchema.optional(), }); export type CVMMeta = z.infer; @@ -25,8 +40,10 @@ export const McpbManifestSchema = z url: z.string().optional(), }), server: z.object({ - type: z.enum(['node', 'python', 'binary']), - entry_point: z.string(), + type: z.enum(['node', 'python', 'binary', 'docker']), + entry_point: z.string().optional(), + image: z.string().optional(), + compose_file: z.string().optional(), mcp_config: z.object({ command: z.string(), args: z.array(z.string()).optional(), @@ -48,9 +65,11 @@ export function validateManifest(data: unknown): McpbManifest { } export const DEFAULT_CVM_META: CVMMeta = { - public: false, - default_relays: DEFAULT_RELAYS, - encryption: 'optional', - announce: true, - pricing: null, + transport: 'stdio', + env_mapping: undefined, + defaults: { + relays: DEFAULT_RELAYS, + encryption: 'optional', + public: false, + }, }; diff --git a/src/pack/pack-init.ts b/src/pack/pack-init.ts index 753655cf7..ced8eaaaa 100644 --- a/src/pack/pack-init.ts +++ b/src/pack/pack-init.ts @@ -2,7 +2,6 @@ import * as p from '@clack/prompts'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, basename } from 'path'; import { DEFAULT_RELAYS } from '../config/index.ts'; -import type { EncryptionMode } from '@contextvm/sdk'; export async function runPackInit(dir: string): Promise { const manifestPath = join(dir, 'manifest.json'); @@ -68,10 +67,21 @@ export async function runPackInit(dir: string): Promise { { value: 'node', label: 'Node.js' }, { value: 'python', label: 'Python' }, { value: 'binary', label: 'Binary' }, + { value: 'docker', label: 'Docker' }, ], initialValue: 'node', }), + image: ({ results }) => { + if (results.type !== 'docker') return Promise.resolve(undefined); + return p.text({ + message: 'Docker image (e.g., ghcr.io/dev/my-server:1.0.0)', + validate: (value) => { + if (!value) return 'Please enter a Docker image reference.'; + }, + }); + }, entryPoint: ({ results }) => { + if (results.type === 'docker') return Promise.resolve(undefined); let initial = 'index.js'; if (results.type === 'node') initial = 'build/index.js'; if (results.type === 'python') initial = 'src/server.py'; @@ -81,16 +91,22 @@ export async function runPackInit(dir: string): Promise { initialValue: initial, }); }, - command: ({ results }) => { - let initial = 'node'; - if (results.type === 'python') initial = 'python'; - if (results.type === 'binary') initial = `\${__dirname}/${results.entryPoint}`; - return p.text({ - message: 'Command to run (mcp_config)', - initialValue: initial, - }); - }, - public: () => + transport: () => + p.select({ + message: 'Transport mode', + options: [ + { + value: 'stdio', + label: 'stdio (Gateway wraps the server, simplest)', + }, + { + value: 'cvm', + label: 'cvm (Server uses CVM SDK directly, advanced)', + }, + ], + initialValue: 'stdio', + }), + isPublic: () => p.confirm({ message: 'Should this server be public? (accept connections from any pubkey)', initialValue: false, @@ -108,12 +124,7 @@ export async function runPackInit(dir: string): Promise { { value: 'optional', label: 'Optional (Fallback to unencrypted)' }, { value: 'disabled', label: 'Disabled (Unencrypted)' }, ], - initialValue: 'optional' as EncryptionMode, - }), - announce: () => - p.confirm({ - message: 'Announce server on Nostr? (publish kind 11316-11320)', - initialValue: true, + initialValue: 'optional', }), }, { @@ -129,6 +140,57 @@ export async function runPackInit(dir: string): Promise { .map((r) => r.trim()) .filter((r) => r); + // Build mcp_config based on server type + let mcpConfig: { command: string; args: string[] }; + if (result.type === 'docker') { + mcpConfig = { + command: 'docker', + args: ['run', '--rm', '-i', result.image as string], + }; + } else if (result.type === 'binary') { + mcpConfig = { + command: `\${__dirname}/${result.entryPoint}`, + args: [], + }; + } else { + const cmd = result.type === 'python' ? 'python' : 'node'; + mcpConfig = { + command: cmd, + args: [`\${__dirname}/${result.entryPoint}`], + }; + } + + // Build server section + const server: Record = { + type: result.type, + mcp_config: mcpConfig, + }; + if (result.type === 'docker') { + server.image = result.image; + } else { + server.entry_point = result.entryPoint; + } + + // Build CVM meta + const cvmMeta: Record = { + transport: result.transport, + defaults: { + relays: relaysList, + encryption: result.encryption, + public: result.isPublic, + }, + }; + + // For native CVM transport, add default env_mapping + if (result.transport === 'cvm') { + cvmMeta.env_mapping = { + relays: 'CVM_RELAYS', + encryption: 'CVM_ENCRYPTION', + public: 'CVM_PUBLIC', + private_key: 'CVM_PRIVATE_KEY', + }; + } + const manifest = { manifest_version: '0.3', name: result.name, @@ -138,22 +200,9 @@ export async function runPackInit(dir: string): Promise { author: { name: result.author, }, - server: { - type: result.type, - entry_point: result.entryPoint, - mcp_config: { - command: result.command, - args: result.type === 'binary' ? [] : [`\${__dirname}/${result.entryPoint}`], - }, - }, + server, _meta: { - 'com.contextvm': { - public: result.public, - default_relays: relaysList, - encryption: result.encryption, - announce: result.announce, - pricing: null, - }, + 'com.contextvm': cvmMeta, }, }; diff --git a/src/serve.ts b/src/serve.ts index d08eedfd6..2c1161a91 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -141,28 +141,92 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis const { dir, manifest } = await extractBundle(target); cleanupPath = dir; - // Load default CVM config from manifest + // Load CVM config from manifest const meta = manifest._meta?.['com.contextvm'] || DEFAULT_CVM_META; + const defaults = meta.defaults || DEFAULT_CVM_META.defaults!; + const transport = meta.transport || 'stdio'; - // Override serveConfig with manifest defaults (CLI flags already win due to precedence) - if (options.relays === undefined && !config.serve?.relays) { - serveConfig.relays = meta.default_relays; - } - if (options.public === undefined && !config.serve?.public) { - serveConfig.public = meta.public; - } - if (options.encryption === undefined && !config.serve?.encryption) { - serveConfig.encryption = meta.encryption as EncryptionMode; - } - // TODO: Handle 'announce' and 'pricing' if/when NostrMCPGateway supports them directly - + // Resolve command and args from manifest target = manifest.server.mcp_config.command.replace(/\$\{__dirname\}/g, dir); const rawArgs = manifest.server.mcp_config.args || []; - // Replace ${__dirname} with the extracted directory targetArgs = rawArgs.map((arg) => arg.replace(/\$\{__dirname\}/g, dir)); - // Merge env vars from user_config if needed (stub for future user_config support) - // mcpEnv = { ...mcpEnv, ... }; + if (transport === 'cvm') { + // ── Native CVM transport ── + // The server uses the CVM SDK directly (NostrServerTransport). + // We inject config as environment variables per the env_mapping contract. + // No Gateway is used. + + const envMapping = meta.env_mapping; + + // Resolve final config values (CLI flags > config file > manifest defaults) + const resolvedRelays = options.relays ?? serveConfig.relays ?? defaults.relays; + const resolvedEncryption = + options.encryption ?? serveConfig.encryption ?? defaults.encryption; + const resolvedPublic = options.public ?? serveConfig.public ?? defaults.public; + const resolvedPrivateKey = serveConfig.privateKey ?? generatePrivateKey(); + + // Build env vars from the mapping + const cvmEnv: Record = {}; + if (envMapping?.relays && resolvedRelays) { + cvmEnv[envMapping.relays] = Array.isArray(resolvedRelays) + ? resolvedRelays.join(',') + : resolvedRelays; + } + if (envMapping?.encryption && resolvedEncryption) { + cvmEnv[envMapping.encryption] = resolvedEncryption; + } + if (envMapping?.public) { + cvmEnv[envMapping.public] = String(resolvedPublic ?? false); + } + if (envMapping?.private_key) { + cvmEnv[envMapping.private_key] = normalizePrivateKey(resolvedPrivateKey); + } + + p.log.info(`Transport: cvm (native CVM server, no Gateway)`); + if (options.verbose) { + p.log.message(`Injected env vars: ${Object.keys(cvmEnv).join(', ')}`); + } + + // Spawn the server process directly with injected env vars + const { spawn } = await import('child_process'); + const normalized = normalizeCommandAndArgs(target, targetArgs); + const child = spawn(normalized.command, normalized.args, { + stdio: 'inherit', + env: { + ...process.env, + ...cvmEnv, + ...(serveConfig.env || {}), + }, + }); + + p.outro(pc.green('CVM native server started. Press Ctrl+C to stop.')); + + const signal = await waitForShutdownSignal(); + p.log.message(`\n${signal} received. Shutting down...`); + child.kill('SIGTERM'); + + if (cleanupPath && fs.existsSync(cleanupPath)) { + p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + + process.exit(0); + } else { + // ── stdio transport (default) ── + // Gateway wraps the process. Apply manifest defaults to serveConfig. + if (options.relays === undefined && !config.serve?.relays) { + serveConfig.relays = defaults.relays; + } + if (options.public === undefined && !config.serve?.public) { + serveConfig.public = defaults.public; + } + if (options.encryption === undefined && !config.serve?.encryption) { + serveConfig.encryption = defaults.encryption as EncryptionMode; + } + + p.log.info(`Transport: stdio (Gateway wraps the server)`); + } } catch (error) { p.log.error(error instanceof Error ? error.message : String(error)); process.exit(1); @@ -353,6 +417,5 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi serve python /path/to/server.py --relays wss://my-relay.com ${DIM}# custom relay${RESET} ${DIM}$${RESET} cvmi serve my-server-1.0.0.mcpb ${DIM}# run an MCPB bundle over Nostr${RESET} ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} - ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} `); } From e2d707f2b5d4387d5c41ff2bf3565de3330cfc8f Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Thu, 11 Jun 2026 09:07:18 +0530 Subject: [PATCH 03/10] fix(pack): address review feedback from code review - 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+) --- src/pack/cvm-manifest.ts | 7 ++++--- src/pack/pack-init.ts | 10 ++++++++-- src/serve.ts | 12 ++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts index 496941f08..95a07aeef 100644 --- a/src/pack/cvm-manifest.ts +++ b/src/pack/cvm-manifest.ts @@ -4,7 +4,7 @@ import { DEFAULT_RELAYS } from '../config/index.ts'; // CVM-specific defaults for a bundle export const CVMDefaultsSchema = z.object({ relays: z.array(z.string()).default(DEFAULT_RELAYS), - encryption: z.enum(['nip44', 'optional', 'disabled']).default('optional'), + encryption: z.enum(['required', 'optional', 'disabled']).default('optional'), public: z.boolean().default(false), }); @@ -31,7 +31,7 @@ export const McpbManifestSchema = z .object({ manifest_version: z.string(), name: z.string(), - display_name: z.string(), + display_name: z.string().optional(), version: z.string(), description: z.string().optional(), author: z.object({ @@ -40,13 +40,14 @@ export const McpbManifestSchema = z url: z.string().optional(), }), server: z.object({ - type: z.enum(['node', 'python', 'binary', 'docker']), + type: z.enum(['node', 'python', 'uv', 'binary', 'docker']), entry_point: z.string().optional(), image: z.string().optional(), compose_file: z.string().optional(), mcp_config: z.object({ command: z.string(), args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), }), }), user_config: z.record(z.string(), z.any()).optional(), diff --git a/src/pack/pack-init.ts b/src/pack/pack-init.ts index ced8eaaaa..acf71840b 100644 --- a/src/pack/pack-init.ts +++ b/src/pack/pack-init.ts @@ -66,6 +66,7 @@ export async function runPackInit(dir: string): Promise { options: [ { value: 'node', label: 'Node.js' }, { value: 'python', label: 'Python' }, + { value: 'uv', label: 'Python (UV)' }, { value: 'binary', label: 'Binary' }, { value: 'docker', label: 'Docker' }, ], @@ -84,7 +85,7 @@ export async function runPackInit(dir: string): Promise { if (results.type === 'docker') return Promise.resolve(undefined); let initial = 'index.js'; if (results.type === 'node') initial = 'build/index.js'; - if (results.type === 'python') initial = 'src/server.py'; + if (results.type === 'python' || results.type === 'uv') initial = 'src/server.py'; if (results.type === 'binary') initial = 'bin/server'; return p.text({ message: 'Entry point path', @@ -120,7 +121,7 @@ export async function runPackInit(dir: string): Promise { p.select({ message: 'Encryption mode', options: [ - { value: 'nip44', label: 'NIP-44 (Required)' }, + { value: 'required', label: 'Required (NIP-44 encryption)' }, { value: 'optional', label: 'Optional (Fallback to unencrypted)' }, { value: 'disabled', label: 'Disabled (Unencrypted)' }, ], @@ -147,6 +148,11 @@ export async function runPackInit(dir: string): Promise { command: 'docker', args: ['run', '--rm', '-i', result.image as string], }; + } else if (result.type === 'uv') { + mcpConfig = { + command: 'uv', + args: ['run', `\${__dirname}/${result.entryPoint}`], + }; } else if (result.type === 'binary') { mcpConfig = { command: `\${__dirname}/${result.entryPoint}`, diff --git a/src/serve.ts b/src/serve.ts index 2c1161a91..76a1d00b5 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -151,6 +151,18 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis const rawArgs = manifest.server.mcp_config.args || []; targetArgs = rawArgs.map((arg) => arg.replace(/\$\{__dirname\}/g, dir)); + // Merge mcp_config.env into spawn environment (apply ${__dirname} substitution) + const manifestEnv = manifest.server.mcp_config.env; + if (manifestEnv) { + const resolvedManifestEnv: Record = {}; + for (const [key, val] of Object.entries(manifestEnv)) { + resolvedManifestEnv[key] = val.replace(/\$\{__dirname\}/g, dir); + } + Object.assign(serveConfig, { + env: { ...(serveConfig.env || {}), ...resolvedManifestEnv }, + }); + } + if (transport === 'cvm') { // ── Native CVM transport ── // The server uses the CVM SDK directly (NostrServerTransport). From 457b8df4bfeb5ac3f88d60e65b04e934354a467a Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Tue, 16 Jun 2026 09:32:25 +0530 Subject: [PATCH 04/10] feat: implement cvmb bundle format with schnorr offline signing and user config --- package.json | 2 + pnpm-lock.yaml | 27 ++++++ src/pack.ts | 89 ++++++++++++++++--- src/pack/crypto.ts | 143 ++++++++++++++++++++++++++++++ src/pack/cvm-manifest.ts | 58 ++++++------ src/pack/extract.ts | 4 +- src/pack/pack-init.ts | 65 ++++---------- src/serve.ts | 187 +++++++++++++++++++++++++++------------ 8 files changed, 422 insertions(+), 153 deletions(-) create mode 100644 src/pack/crypto.ts diff --git a/package.json b/package.json index 69a48e601..94db3f395 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,9 @@ "dependencies": { "@contextvm/sdk": "^0.11.14", "@modelcontextprotocol/sdk": "^1.27.1", + "@noble/curves": "^2.2.0", "archiver": "^8.0.0", + "canonicalize": "^3.0.0", "extract-zip": "^2.0.1", "json-schema-to-typescript": "15.0.4", "nostr-tools": "^2.23.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65d8c1edc..55c5750ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,9 +13,15 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + '@noble/curves': + specifier: ^2.2.0 + version: 2.2.0 archiver: specifier: ^8.0.0 version: 8.0.0 + canonicalize: + specifier: ^3.0.0 + version: 3.0.0 extract-zip: specifier: ^2.0.1 version: 2.0.1 @@ -654,6 +660,13 @@ packages: } engines: { node: '>= 20.19.0' } + '@noble/curves@2.2.0': + resolution: + { + integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==, + } + engines: { node: '>= 20.19.0' } + '@noble/hashes@1.3.1': resolution: { @@ -1723,6 +1736,14 @@ packages: } hasBin: true + canonicalize@3.0.0: + resolution: + { + integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==, + } + engines: { node: '>=18' } + hasBin: true + chai@6.2.2: resolution: { @@ -4343,6 +4364,10 @@ snapshots: dependencies: '@noble/hashes': 2.0.1 + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + '@noble/hashes@1.3.1': {} '@noble/hashes@1.3.2': {} @@ -4860,6 +4885,8 @@ snapshots: canonicalize@2.1.0: {} + canonicalize@3.0.0: {} + chai@6.2.2: {} chardet@2.1.1: {} diff --git a/src/pack.ts b/src/pack.ts index f1f59f3b9..d104ebee8 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -1,11 +1,12 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const archiver = require('archiver'); -import { createWriteStream, existsSync, readFileSync } from 'fs'; +import { createWriteStream, existsSync, readFileSync, writeFileSync } from 'fs'; import { join, resolve } from 'path'; import * as p from '@clack/prompts'; import { runPackInit } from './pack/pack-init.ts'; -import { validateManifest, type McpbManifest } from './pack/cvm-manifest.ts'; +import { validateManifest, type CvmbManifest } from './pack/cvm-manifest.ts'; +import { computeDirectoryContentHash, signManifest } from './pack/crypto.ts'; import { BOLD, DIM, RESET } from './constants/ui.ts'; export interface PackOptions { @@ -33,20 +34,20 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): } } - let manifest: McpbManifest; + let manifest: CvmbManifest; try { const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); if (!options.noValidate) { manifest = validateManifest(raw); } else { - manifest = raw as McpbManifest; + manifest = raw as CvmbManifest; } } catch (error) { p.log.error(`Invalid manifest: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } - const outFileName = options.output || `${manifest.name}-${manifest.version}.mcpb`; + const outFileName = options.output || `${manifest.name}-${manifest.version}.cvmb`; const outPath = resolve(outFileName); p.log.info(`Packing ${manifest.name} v${manifest.version}...`); @@ -65,6 +66,67 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): ); } + // 1. Cryptography Phase: Hashing + const sHash = p.spinner(); + sHash.start('Computing Merkle content hash...'); + const contentHash = await computeDirectoryContentHash(dir, [ + '.git', + 'node_modules', + '.DS_Store', + '.env', + '.cvmb', + ]); + sHash.stop(`Content hash computed: ${contentHash.slice(0, 16)}...`); + + if (!manifest._meta) manifest._meta = {}; + if (!manifest._meta['com.contextvm']) manifest._meta['com.contextvm'] = {}; + manifest._meta['com.contextvm'].content_hash = contentHash; + + // Remove existing signature to prevent invalidation + delete manifest._sig; + + // 2. Cryptography Phase: Signing + const shouldSign = await p.confirm({ + message: 'Do you want to cryptographically sign this bundle with a Nostr key?', + initialValue: true, + }); + + if (p.isCancel(shouldSign)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + if (shouldSign) { + const privateKeyHex = await p.password({ + message: 'Enter your Nostr private key (hex) to sign the bundle:', + validate: (value) => { + if (!value) return 'Private key is required to sign.'; + if (!/^[0-9a-fA-F]{64}$/.test(value)) return 'Must be a 64-character hex string.'; + }, + }); + + if (p.isCancel(privateKeyHex)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + try { + manifest._sig = signManifest(manifest, privateKeyHex as string); + p.log.success(`Signed bundle successfully (pubkey: ${manifest._sig.pubkey.slice(0, 8)}...)`); + } catch (err) { + p.log.error(`Signing failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + } else { + p.log.warn( + 'Creating unsigned bundle. This bundle will trigger warnings when users install it.' + ); + } + + // Save the modified manifest back to disk so the zip includes the hash/sig + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + // 3. Archive Phase await new Promise((resolvePromise, rejectPromise) => { const output = createWriteStream(outPath); const archive = archiver('zip', { @@ -86,7 +148,15 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): archive.glob('**/*', { cwd: dir, dot: true, - ignore: ['.git/**', 'node_modules/.cache/**', '.DS_Store', '.env', '*.mcpb', outFileName], + ignore: [ + '.git/**', + 'node_modules/.cache/**', + '.DS_Store', + '.env', + '*.cvmb', + '*.mcpb', + outFileName, + ], }); archive.finalize(); @@ -99,9 +169,8 @@ ${BOLD}Usage:${RESET} cvmi pack [directory] [options] ${BOLD}Description:${RESET} - Package a local MCP server into a distributable MCPB bundle (.mcpb). - If no manifest.json exists, an interactive wizard will help you create one - with ContextVM-specific extensions (relays, public mode, encryption). + Package a local MCP server into a distributable ContextVM bundle (.cvmb). + If no manifest.json exists, an interactive wizard will help you create one. ${BOLD}Options:${RESET} --output, -o Custom output file name @@ -113,7 +182,7 @@ ${BOLD}Options:${RESET} ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi pack ${DIM}# package current directory${RESET} ${DIM}$${RESET} cvmi pack ./my-server ${DIM}# package specific directory${RESET} - ${DIM}$${RESET} cvmi pack -o custom-name.mcpb ${DIM}# custom output name${RESET} + ${DIM}$${RESET} cvmi pack -o custom-name.cvmb ${DIM}# custom output name${RESET} `); } diff --git a/src/pack/crypto.ts b/src/pack/crypto.ts new file mode 100644 index 000000000..f399a9119 --- /dev/null +++ b/src/pack/crypto.ts @@ -0,0 +1,143 @@ +import canonicalize from 'canonicalize'; +import { createHash } from 'crypto'; +import { getPublicKey } from 'nostr-tools'; +import { schnorr } from '@noble/curves/secp256k1.js'; +import { readdir, readFile } from 'fs/promises'; +import { join, relative } from 'path'; +import type { CvmbManifest } from './cvm-manifest.ts'; + +/** + * Canonicalizes a manifest object according to RFC 8785. + * If the manifest contains a `_sig` field, it is removed before canonicalization. + */ +export function canonicalizeManifest(manifest: CvmbManifest): string { + // Create a copy without the _sig field + const { _sig, ...manifestWithoutSig } = manifest; + const canonical = canonicalize(manifestWithoutSig); + if (!canonical) { + throw new Error('Failed to canonicalize manifest'); + } + return canonical; +} + +/** + * Computes the SHA-256 ID of the canonicalized manifest. + */ +export function computeManifestId(manifest: CvmbManifest): string { + const canonical = canonicalizeManifest(manifest); + return createHash('sha256').update(canonical).digest('hex'); +} + +/** + * Signs a manifest using a Nostr private key (hex format). + * Returns the complete `_sig` object to be injected into the manifest. + */ +export function signManifest(manifest: CvmbManifest, privateKeyHex: string) { + const id = computeManifestId(manifest); + + // Convert hex strings to Uint8Arrays for @noble/curves + const msgBytes = Uint8Array.from(Buffer.from(id, 'hex')); + const privBytes = Uint8Array.from(Buffer.from(privateKeyHex, 'hex')); + + const signature = schnorr.sign(msgBytes, privBytes); + const pubkey = getPublicKey(Uint8Array.from(Buffer.from(privateKeyHex, 'hex'))); + + // @noble/curves schnorr.sign returns a Uint8Array, we need hex + const signatureHex = Buffer.from(signature).toString('hex'); + + return { + pubkey, + id, + signature: signatureHex, + created_at: Math.floor(Date.now() / 1000), + }; +} + +/** + * Verifies the `_sig` block of a manifest. + * Throws an error if the signature is missing or invalid. + */ +export function verifyManifestSignature(manifest: CvmbManifest): boolean { + if (!manifest._sig) { + throw new Error('Manifest is not signed'); + } + + const expectedId = computeManifestId(manifest); + if (manifest._sig.id !== expectedId) { + throw new Error('Manifest ID mismatch. The manifest has been modified after signing.'); + } + + const sigBytes = Uint8Array.from(Buffer.from(manifest._sig.signature, 'hex')); + const msgBytes = Uint8Array.from(Buffer.from(manifest._sig.id, 'hex')); + const pubBytes = Uint8Array.from(Buffer.from(manifest._sig.pubkey, 'hex')); + + const isValid = schnorr.verify(sigBytes, msgBytes, pubBytes); + + if (!isValid) { + throw new Error('Invalid Schnorr signature'); + } + + return true; +} + +/** + * Computes a Merkle-style hash over a directory's contents. + * 1. Hashes each file's contents (excluding manifest.json and ignored patterns). + * 2. Sorts paths alphabetically. + * 3. Concatenates path:hash\n and hashes the result. + */ +export async function computeDirectoryContentHash( + dir: string, + ignoreList: string[] = ['.git', 'node_modules', '.DS_Store', '.env'] +): Promise { + const allFiles = await getFilesRecursive(dir); + + // Filter out manifest and ignored patterns + const filteredFiles = allFiles.filter((file) => { + const relPath = relative(dir, file).replace(/\\/g, '/'); + if (relPath === 'manifest.json') return false; + if (relPath.endsWith('.cvmb') || relPath.endsWith('.mcpb')) return false; + + // Simple ignore logic + for (const ignore of ignoreList) { + if (relPath.includes(ignore) || relPath.startsWith(ignore)) return false; + } + return true; + }); + + const fileHashes: Array<{ path: string; hash: string }> = []; + + for (const file of filteredFiles) { + const content = await readFile(file); + const hash = createHash('sha256').update(content).digest('hex'); + const relPath = relative(dir, file).replace(/\\/g, '/'); // Normalize path separators + fileHashes.push({ path: relPath, hash }); + } + + // Sort alphabetically by path + fileHashes.sort((a, b) => a.path.localeCompare(b.path)); + + // Concatenate path:hash\n + const manifestContent = fileHashes.map((f) => `${f.path}:${f.hash}`).join('\n'); + + return 'sha256:' + createHash('sha256').update(manifestContent).digest('hex'); +} + +/** + * Helper to recursively get all files in a directory. + */ +async function getFilesRecursive(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const res = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await getFilesRecursive(res))); + } else { + files.push(res); + } + } + + return files; +} diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts index 95a07aeef..982f39db6 100644 --- a/src/pack/cvm-manifest.ts +++ b/src/pack/cvm-manifest.ts @@ -1,33 +1,35 @@ import { z } from 'zod'; -import { DEFAULT_RELAYS } from '../config/index.ts'; -// CVM-specific defaults for a bundle -export const CVMDefaultsSchema = z.object({ - relays: z.array(z.string()).default(DEFAULT_RELAYS), - encryption: z.enum(['required', 'optional', 'disabled']).default('optional'), - public: z.boolean().default(false), +export const UserConfigFieldSchema = z.object({ + type: z.enum(['string', 'number', 'boolean', 'directory', 'file']), + title: z.string().optional(), + description: z.string().optional(), + required: z.boolean().optional(), + default: z.any().optional(), + multiple: z.boolean().optional(), + sensitive: z.boolean().optional(), + min: z.number().optional(), + max: z.number().optional(), }); -export type CVMDefaults = z.infer; +export type UserConfigField = z.infer; -// The env_mapping contract: maps config keys to env var names -export const CVMEnvMappingSchema = z - .record(z.enum(['relays', 'encryption', 'public', 'private_key']), z.string()) - .optional(); +export const CVMSigSchema = z.object({ + pubkey: z.string(), + id: z.string(), + signature: z.string(), + created_at: z.number(), +}); -export type CVMEnvMapping = z.infer; +export type CVMSig = z.infer; -// Top-level CVM meta namespace export const CVMMetaSchema = z.object({ - transport: z.enum(['stdio', 'cvm']).default('stdio'), - env_mapping: CVMEnvMappingSchema, - defaults: CVMDefaultsSchema.optional(), + content_hash: z.string().optional(), }); export type CVMMeta = z.infer; -// The full manifest including MCPB and CVM extension -export const McpbManifestSchema = z +export const CvmbManifestSchema = z .object({ manifest_version: z.string(), name: z.string(), @@ -44,33 +46,25 @@ export const McpbManifestSchema = z entry_point: z.string().optional(), image: z.string().optional(), compose_file: z.string().optional(), + transport: z.enum(['stdio', 'cvm']).default('stdio'), mcp_config: z.object({ command: z.string(), args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), }), }), - user_config: z.record(z.string(), z.any()).optional(), + user_config: z.record(z.string(), UserConfigFieldSchema).optional(), _meta: z .object({ 'com.contextvm': CVMMetaSchema.optional(), }) .optional(), + _sig: CVMSigSchema.optional(), }) .passthrough(); -export type McpbManifest = z.infer; +export type CvmbManifest = z.infer; -export function validateManifest(data: unknown): McpbManifest { - return McpbManifestSchema.parse(data); +export function validateManifest(data: unknown): CvmbManifest { + return CvmbManifestSchema.parse(data); } - -export const DEFAULT_CVM_META: CVMMeta = { - transport: 'stdio', - env_mapping: undefined, - defaults: { - relays: DEFAULT_RELAYS, - encryption: 'optional', - public: false, - }, -}; diff --git a/src/pack/extract.ts b/src/pack/extract.ts index 47cadcb94..b9c57c483 100644 --- a/src/pack/extract.ts +++ b/src/pack/extract.ts @@ -2,12 +2,12 @@ import extractZip from 'extract-zip'; import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import os from 'os'; -import { validateManifest, type McpbManifest } from './cvm-manifest.ts'; +import { validateManifest, type CvmbManifest } from './cvm-manifest.ts'; import { randomBytes } from 'crypto'; export async function extractBundle( mcpbPath: string -): Promise<{ dir: string; manifest: McpbManifest }> { +): Promise<{ dir: string; manifest: CvmbManifest }> { // Use a unique temp directory for extraction const extractDir = join(os.tmpdir(), `cvmi-bundle-${randomBytes(8).toString('hex')}`); diff --git a/src/pack/pack-init.ts b/src/pack/pack-init.ts index acf71840b..0380677c0 100644 --- a/src/pack/pack-init.ts +++ b/src/pack/pack-init.ts @@ -1,7 +1,6 @@ import * as p from '@clack/prompts'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, basename } from 'path'; -import { DEFAULT_RELAYS } from '../config/index.ts'; export async function runPackInit(dir: string): Promise { const manifestPath = join(dir, 'manifest.json'); @@ -107,26 +106,6 @@ export async function runPackInit(dir: string): Promise { ], initialValue: 'stdio', }), - isPublic: () => - p.confirm({ - message: 'Should this server be public? (accept connections from any pubkey)', - initialValue: false, - }), - relays: () => - p.text({ - message: 'Default relays (comma-separated)', - initialValue: DEFAULT_RELAYS.join(', '), - }), - encryption: () => - p.select({ - message: 'Encryption mode', - options: [ - { value: 'required', label: 'Required (NIP-44 encryption)' }, - { value: 'optional', label: 'Optional (Fallback to unencrypted)' }, - { value: 'disabled', label: 'Disabled (Unencrypted)' }, - ], - initialValue: 'optional', - }), }, { onCancel: () => { @@ -136,13 +115,8 @@ export async function runPackInit(dir: string): Promise { } ); - const relaysList = result.relays - .split(',') - .map((r) => r.trim()) - .filter((r) => r); - // Build mcp_config based on server type - let mcpConfig: { command: string; args: string[] }; + let mcpConfig: { command: string; args: string[]; env?: Record }; if (result.type === 'docker') { mcpConfig = { command: 'docker', @@ -166,39 +140,26 @@ export async function runPackInit(dir: string): Promise { }; } + // Example of using user_config for CVM relays mapping + mcpConfig.env = { + CVM_RELAYS: '${user_config.relays}', + }; + // Build server section const server: Record = { type: result.type, + transport: result.transport, mcp_config: mcpConfig, }; + if (result.type === 'docker') { server.image = result.image; } else { server.entry_point = result.entryPoint; } - // Build CVM meta - const cvmMeta: Record = { - transport: result.transport, - defaults: { - relays: relaysList, - encryption: result.encryption, - public: result.isPublic, - }, - }; - - // For native CVM transport, add default env_mapping - if (result.transport === 'cvm') { - cvmMeta.env_mapping = { - relays: 'CVM_RELAYS', - encryption: 'CVM_ENCRYPTION', - public: 'CVM_PUBLIC', - private_key: 'CVM_PRIVATE_KEY', - }; - } - const manifest = { - manifest_version: '0.3', + manifest_version: '1.0', name: result.name, display_name: result.displayName, version: result.version, @@ -207,8 +168,12 @@ export async function runPackInit(dir: string): Promise { name: result.author, }, server, - _meta: { - 'com.contextvm': cvmMeta, + user_config: { + relays: { + type: 'string', + title: 'Relays (comma separated)', + default: 'wss://relay.contextvm.org', + }, }, }; diff --git a/src/serve.ts b/src/serve.ts index 76a1d00b5..50793c878 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -13,7 +13,6 @@ import { loadConfig, getServeConfig, DEFAULT_RELAYS } from './config/index.ts'; import { generatePrivateKey, normalizePrivateKey } from './utils/crypto.ts'; import { waitForShutdownSignal } from './utils/process.ts'; import { extractBundle } from './pack/extract.ts'; -import { DEFAULT_CVM_META } from './pack/cvm-manifest.ts'; import fs from 'fs'; import { BOLD, DIM, RESET } from './constants/ui.ts'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; @@ -134,82 +133,142 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis let cleanupPath: string | undefined; - // Handle .mcpb bundle execution - if (target.endsWith('.mcpb')) { + // Handle .cvmb / .mcpb bundle execution + if (target.endsWith('.cvmb') || target.endsWith('.mcpb')) { p.log.info(`Extracting bundle ${target}...`); try { const { dir, manifest } = await extractBundle(target); cleanupPath = dir; - // Load CVM config from manifest - const meta = manifest._meta?.['com.contextvm'] || DEFAULT_CVM_META; - const defaults = meta.defaults || DEFAULT_CVM_META.defaults!; - const transport = meta.transport || 'stdio'; + // Import crypto for verification + const { computeDirectoryContentHash, verifyManifestSignature } = + await import('./pack/crypto.ts'); + + // 1. Content Hash Verification + const expectedHash = manifest._meta?.['com.contextvm']?.content_hash; + if (expectedHash) { + const actualHash = await computeDirectoryContentHash(dir, [ + '.git', + 'node_modules', + '.DS_Store', + '.env', + '.cvmb', + '.mcpb', + ]); + if (actualHash !== expectedHash) { + throw new Error( + 'Content hash verification failed! The bundle contents have been modified.' + ); + } + } else { + p.log.warn('No content hash found in manifest. Bundle integrity cannot be verified.'); + } + + // 2. Signature Verification + if (manifest._sig) { + verifyManifestSignature(manifest); + p.log.success(`Signature verified. Author: ${manifest._sig.pubkey.slice(0, 8)}...`); + } else { + p.log.warn('No cryptographic signature found. You are running unverified code.'); + const proceed = await p.confirm({ + message: 'Do you want to proceed anyway?', + initialValue: false, + }); + if (!proceed) process.exit(1); + } + + // 3. User Configuration Prompting + const userConfigValues: Record = {}; + if (manifest.user_config) { + for (const [key, field] of Object.entries(manifest.user_config)) { + // Check if env var already satisfies this + const existingEnv = process.env[key.toUpperCase()]; + if (existingEnv !== undefined) { + userConfigValues[key] = existingEnv; + continue; + } + + if (field.type === 'boolean') { + userConfigValues[key] = await p.confirm({ + message: field.title || key, + initialValue: field.default ?? false, + }); + } else { + const promptMethod = field.sensitive ? p.password : p.text; + userConfigValues[key] = await promptMethod({ + message: `${field.title || key}${field.description ? ` (${field.description})` : ''}`, + initialValue: field.default?.toString(), + validate: (val) => { + if (field.required && !val) return 'This field is required.'; + }, + }); + } + + if (p.isCancel(userConfigValues[key])) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + } + } - // Resolve command and args from manifest - target = manifest.server.mcp_config.command.replace(/\$\{__dirname\}/g, dir); + // 4. Resolve command and args from manifest + target = manifest.server.mcp_config.command.replace(/\\$\\{__dirname\\}/g, dir); const rawArgs = manifest.server.mcp_config.args || []; - targetArgs = rawArgs.map((arg) => arg.replace(/\$\{__dirname\}/g, dir)); + targetArgs = rawArgs.map((arg: string) => { + let resolved = arg.replace(/\\$\\{__dirname\\}/g, dir); + // Replace ${user_config.X} in args + for (const [key, val] of Object.entries(userConfigValues)) { + resolved = resolved.replace( + new RegExp(`\\$\\{user_config\\.${key}\\}`, 'g'), + String(val) + ); + } + return resolved; + }); - // Merge mcp_config.env into spawn environment (apply ${__dirname} substitution) + // 5. Merge mcp_config.env into spawn environment const manifestEnv = manifest.server.mcp_config.env; + const resolvedManifestEnv: Record = {}; if (manifestEnv) { - const resolvedManifestEnv: Record = {}; for (const [key, val] of Object.entries(manifestEnv)) { - resolvedManifestEnv[key] = val.replace(/\$\{__dirname\}/g, dir); + let resolved = (val as string).replace(/\\$\\{__dirname\\}/g, dir); + // Replace ${user_config.X} + for (const [cfgKey, cfgVal] of Object.entries(userConfigValues)) { + resolved = resolved.replace( + new RegExp(`\\$\\{user_config\\.${cfgKey}\\}`, 'g'), + String(cfgVal) + ); + } + resolvedManifestEnv[key] = resolved; } Object.assign(serveConfig, { env: { ...(serveConfig.env || {}), ...resolvedManifestEnv }, }); } + const transport = manifest.server.transport || 'stdio'; + if (transport === 'cvm') { // ── Native CVM transport ── - // The server uses the CVM SDK directly (NostrServerTransport). - // We inject config as environment variables per the env_mapping contract. - // No Gateway is used. - - const envMapping = meta.env_mapping; - - // Resolve final config values (CLI flags > config file > manifest defaults) - const resolvedRelays = options.relays ?? serveConfig.relays ?? defaults.relays; - const resolvedEncryption = - options.encryption ?? serveConfig.encryption ?? defaults.encryption; - const resolvedPublic = options.public ?? serveConfig.public ?? defaults.public; - const resolvedPrivateKey = serveConfig.privateKey ?? generatePrivateKey(); - - // Build env vars from the mapping - const cvmEnv: Record = {}; - if (envMapping?.relays && resolvedRelays) { - cvmEnv[envMapping.relays] = Array.isArray(resolvedRelays) - ? resolvedRelays.join(',') - : resolvedRelays; - } - if (envMapping?.encryption && resolvedEncryption) { - cvmEnv[envMapping.encryption] = resolvedEncryption; - } - if (envMapping?.public) { - cvmEnv[envMapping.public] = String(resolvedPublic ?? false); - } - if (envMapping?.private_key) { - cvmEnv[envMapping.private_key] = normalizePrivateKey(resolvedPrivateKey); - } - p.log.info(`Transport: cvm (native CVM server, no Gateway)`); + + // We ensure standard CVM vars are set if user_config resolved them + const cvmEnv = { + ...process.env, + ...(serveConfig.env || {}), + }; + if (options.verbose) { - p.log.message(`Injected env vars: ${Object.keys(cvmEnv).join(', ')}`); + p.log.message( + `Injected env vars from bundle: ${Object.keys(resolvedManifestEnv).join(', ')}` + ); } - // Spawn the server process directly with injected env vars const { spawn } = await import('child_process'); const normalized = normalizeCommandAndArgs(target, targetArgs); const child = spawn(normalized.command, normalized.args, { stdio: 'inherit', - env: { - ...process.env, - ...cvmEnv, - ...(serveConfig.env || {}), - }, + env: cvmEnv, }); p.outro(pc.green('CVM native server started. Press Ctrl+C to stop.')); @@ -226,18 +285,28 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis process.exit(0); } else { // ── stdio transport (default) ── - // Gateway wraps the process. Apply manifest defaults to serveConfig. - if (options.relays === undefined && !config.serve?.relays) { - serveConfig.relays = defaults.relays; + p.log.info(`Transport: stdio (Gateway wraps the server)`); + + // If the bundle user_config provided typical CVM fields, use them to configure gateway + if (options.relays === undefined && !config.serve?.relays && userConfigValues.relays) { + serveConfig.relays = String(userConfigValues.relays) + .split(',') + .map((r) => r.trim()); } - if (options.public === undefined && !config.serve?.public) { - serveConfig.public = defaults.public; + if ( + options.public === undefined && + !config.serve?.public && + userConfigValues.public !== undefined + ) { + serveConfig.public = Boolean(userConfigValues.public); } - if (options.encryption === undefined && !config.serve?.encryption) { - serveConfig.encryption = defaults.encryption as EncryptionMode; + if ( + options.encryption === undefined && + !config.serve?.encryption && + userConfigValues.encryption + ) { + serveConfig.encryption = userConfigValues.encryption as EncryptionMode; } - - p.log.info(`Transport: stdio (Gateway wraps the server)`); } } catch (error) { p.log.error(error instanceof Error ? error.message : String(error)); From 23be09d61336136ba6611e72c816c93876746ab3 Mon Sep 17 00:00:00 2001 From: ContextVM Date: Tue, 16 Jun 2026 13:07:31 +0200 Subject: [PATCH 05/10] docs: add CVM Bundle format specification --- ctxcn | 1 - cvmb-bundling.md | 488 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+), 1 deletion(-) delete mode 160000 ctxcn create mode 100644 cvmb-bundling.md diff --git a/ctxcn b/ctxcn deleted file mode 160000 index f0604f2d2..000000000 --- a/ctxcn +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f0604f2d22bc38b43641026a6035db2c43d634aa diff --git a/cvmb-bundling.md b/cvmb-bundling.md new file mode 100644 index 000000000..a9cbb09f1 --- /dev/null +++ b/cvmb-bundling.md @@ -0,0 +1,488 @@ +# CVM Bundle Format (.cvmb) + +**Status**: Draft + +## Summary + +Defines the `.cvmb` (CVM Bundle) packaging format for distributing ContextVM MCP servers. A `.cvmb` file is a ZIP archive containing server code, dependencies, and a `manifest.json`. The format is inspired by the [MCPB specification](https://github.com/modelcontextprotocol/mcpb) but is not bound by MCPB host compatibility — `.cvmb` bundles are designed for the ContextVM ecosystem and are consumed by `cvmi serve`. + +## Key Points + +- ZIP archive with `manifest.json` at the root; file extension `.cvmb` +- Two transport modes: `stdio` (Gateway-wrapped) and `cvm` (native Nostr transport) +- Reuses `user_config` and `mcp_config.env` for typed configuration and environment injection +- Nostr Schnorr signatures (`_sig`) for authorship verification — no external PKI required +- Merkle-tree content hashing (`content_hash`) binds all bundle files to the manifest +- RFC 8785 canonicalization for deterministic signing and verification +- Secrets are never shipped in plaintext; declared via `user_config` with `sensitive: true` +- Docker support as a first-class server type with image references (not bundled images) +- Fully offline-operable: signing, verification, and hashing require no network access + +--- + +## File Format + +A `.cvmb` bundle is a ZIP archive (maximum compression) containing: + +``` +my-server.cvmb +├── manifest.json # Required: bundle metadata and configuration +├── server/ # Server code (for node, python, uv, binary types) +│ └── ... +├── node_modules/ # Bundled dependencies (node type) +├── pyproject.toml # Dependencies declaration (uv type) +├── docker-compose.yml # Multi-container orchestration (docker type, optional) +└── assets/ # Icons, screenshots, etc. +``` + +Excluded from the bundle: `.git`, `.cache`, `.DS_Store`, `.env`, `node_modules/.cache`, and any existing `.cvmb` or `.mcpb` files. + +--- + +## Manifest Schema + +### Required Fields + +| Field | Type | Description | +| ------------------ | ------ | ------------------------------------------------------------------------- | +| `manifest_version` | string | Spec version this manifest conforms to (e.g., `"0.3"`) | +| `name` | string | Machine-readable name (used for CLI, APIs) | +| `version` | string | Semantic version (semver) | +| `description` | string | Brief description of the server | +| `author` | object | Author information with required `name` field; optional `email` and `url` | +| `server` | object | Server configuration (see below) | + +### Optional Fields + +| Field | Type | Description | +| ------------------- | -------- | ----------------------------------------------------------------- | +| `display_name` | string | Human-friendly name for UI display | +| `long_description` | string | Detailed markdown description | +| `repository` | object | Source code repository (`type` and `url`) | +| `homepage` | string | Project homepage URL | +| `documentation` | string | Documentation URL | +| `support` | string | Support/issues URL | +| `icon` | string | Path to a PNG icon file | +| `icons` | array | Array of icon descriptors (`src`, `size`, optional `theme`) | +| `screenshots` | string[] | Array of screenshot paths | +| `tools` | array | Declared tools the server provides | +| `tools_generated` | boolean | Server generates additional tools at runtime (default: `false`) | +| `prompts` | array | Declared prompts the server provides | +| `prompts_generated` | boolean | Server generates additional prompts at runtime (default: `false`) | +| `keywords` | string[] | Search keywords | +| `license` | string | License identifier (e.g., `"MIT"`) | +| `privacy_policies` | string[] | URLs to privacy policies for external services | +| `compatibility` | object | Platform and runtime requirements | +| `user_config` | object | User-configurable options (see User Configuration) | +| `_meta` | object | Reverse-DNS namespaced metadata (see CVM Metadata) | +| `_sig` | object | Nostr Schnorr signature (see Signing and Verification) | + +### Server Configuration + +The `server` object defines how to run the MCP server: + +```json +{ + "server": { + "type": "node", + "entry_point": "server/index.js", + "transport": "stdio", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/server/index.js"], + "env": { + "API_KEY": "${user_config.api_key}" + } + } + } +} +``` + +| Field | Type | Required | Description | +| -------------- | ------ | ----------- | ------------------------------------------------------------------------------------- | +| `type` | enum | Yes | Server type: `"node"`, `"python"`, `"uv"`, `"binary"`, `"docker"` | +| `entry_point` | string | See notes | Path to the main server file. Required for node/python/uv/binary; optional for docker | +| `transport` | enum | No | Transport mode: `"stdio"` (default) or `"cvm"` | +| `image` | string | docker only | Docker image reference (e.g., `"ghcr.io/dev/my-server:1.0.0"`) | +| `compose_file` | string | docker only | Path to `docker-compose.yml` within the bundle for multi-container setups | +| `mcp_config` | object | Yes | Process spawn configuration | + +#### Server Types + +| Type | Description | Dependencies | +| -------- | ------------------------------ | ----------------------------------------------- | +| `node` | Node.js server | Bundled in `node_modules/` | +| `python` | Python server | Bundled in `server/lib/` or `server/venv/` | +| `uv` | Python server using UV runtime | Declared in `pyproject.toml`, installed by host | +| `binary` | Pre-compiled executable | Self-contained | +| `docker` | Docker container | Image referenced, pulled at runtime | + +#### MCP Configuration + +The `mcp_config` object defines the process spawn command: + +| Field | Type | Description | +| --------- | -------- | --------------------------------------- | +| `command` | string | Command to execute | +| `args` | string[] | Arguments passed to the command | +| `env` | object | Environment variables (string → string) | + +Variable substitution is supported in `command`, `args`, and `env` values: + +- `${__dirname}` — Absolute path to the extracted bundle directory +- `${HOME}`, `${DESKTOP}`, `${DOCUMENTS}`, `${DOWNLOADS}` — Standard user directories +- `${user_config.KEY}` — User-provided configuration value + +--- + +## Transport Modes + +### `stdio` (default) + +The server communicates over stdin/stdout. `cvmi serve` spawns the process and wraps it with the Gateway. The Gateway owns all CVM configuration (relays, encryption, announcements, payments). The server is completely transport-agnostic. + +``` +┌──────────┐ stdio ┌─────────┐ Nostr ┌────────┐ +│ Server │◄──────────►│ Gateway │◄──────────►│ Relays │ +│ Process │ │ │ │ │ +└──────────┘ └─────────┘ └────────┘ +``` + +### `cvm` + +The server uses the CVM SDK's `NostrServerTransport` directly. `cvmi serve` spawns the process without the Gateway. CVM configuration (relays, encryption, public mode) is injected as environment variables via `mcp_config.env`. The server manages its own transport. + +``` +┌──────────┐ Nostr ┌────────┐ +│ Server │◄──────────►│ Relays │ +│ Process │ │ │ +└──────────┘ └────────┘ +``` + +Use `cvm` mode when the server needs SDK-specific features: + +- `injectClientPubkey` — per-client authentication and authorization +- Dynamic authorization callbacks (`isPubkeyAllowed`) +- Direct transport-level capabilities not available through the Gateway + +**Environment variables are available for both transport modes.** `stdio` servers can also declare `mcp_config.env` for API keys, config paths, or any other runtime values the Gateway doesn't own. + +### Transport Selection + +The `transport` field declares the intended mode. At runtime, `cvmi serve` honors this but can override via CLI flags: + +```bash +# Force Gateway wrap even for cvm bundles +cvmi serve --transport stdio my-server.cvmb + +# Force direct spawn even for stdio bundles +cvmi serve --transport cvm my-server.cvmb +``` + +--- + +## User Configuration + +The `user_config` field follows the MCPB convention for typed, user-facing configuration. Each key defines a configuration option with type, validation, and sensitivity: + +```json +{ + "user_config": { + "api_key": { + "type": "string", + "title": "API Key", + "description": "Your API key for authentication", + "sensitive": true, + "required": true + }, + "max_file_size": { + "type": "number", + "title": "Maximum File Size (MB)", + "description": "Maximum file size to process", + "default": 10, + "min": 1, + "max": 100 + }, + "allowed_directories": { + "type": "directory", + "title": "Allowed Directories", + "description": "Directories the server can access", + "multiple": true, + "required": true, + "default": ["${HOME}/Desktop"] + } + } +} +``` + +### Configuration Types + +| Type | UI Control | `multiple` Support | `sensitive` Support | +| ----------- | ---------------- | ----------------------------- | ------------------- | +| `string` | Text input | No | Yes (masks input) | +| `number` | Numeric input | No | No | +| `boolean` | Checkbox/toggle | No | No | +| `directory` | Directory picker | Yes (array expansion in args) | No | +| `file` | File picker | Yes (array expansion in args) | No | + +### Secrets Handling + +Fields marked `sensitive: true` are never stored in plaintext. `cvmi serve` prompts for them on first run or reads them from environment variables. They are never included in `_meta.com.contextvm.defaults`. + +### Variable Substitution + +User config values are injected through `mcp_config` using `${user_config.KEY}`: + +```json +{ + "mcp_config": { + "env": { + "API_KEY": "${user_config.api_key}", + "BASE_URL": "${user_config.base_url}" + }, + "args": ["${user_config.allowed_directories}"] + } +} +``` + +When `multiple: true`, array values are expanded as separate arguments. + +--- + +## CVM Metadata (`_meta.com.contextvm`) + +CVM-specific configuration lives under the `_meta.com.contextvm` namespace: + +```json +{ + "_meta": { + "com.contextvm": { + "content_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + } +} +``` + +| Field | Type | Description | +| -------------- | ------ | ---------------------------------------------------------------- | +| `content_hash` | string | SHA-256 Merkle hash of all bundle files, prefixed with `sha256:` | + +The `content_hash` is computed during packing and included in the manifest before signing. This cryptographically binds all bundle contents to the manifest signature. + +Future CVM-specific fields (relay defaults, encryption preferences, pricing configuration) may be added to this namespace as the design evolves. + +--- + +## Signing and Verification + +Bundles are signed using the author's Nostr keypair. The signature lives in the `_sig` field: + +```json +{ + "_sig": { + "pubkey": "abc123...", + "id": "sha256 of canonical manifest (without _sig)", + "signature": "86f25c...", + "created_at": 1718123456 + } +} +``` + +### `_sig` Fields + +| Field | Type | Description | +| ------------ | ------ | --------------------------------------------------------------------- | +| `pubkey` | string | Author's Nostr public key (hex) | +| `id` | string | SHA-256 of the canonical manifest JSON (with `_sig` removed) | +| `signature` | string | Schnorr signature of `id` using the author's private key (hex) | +| `created_at` | number | Unix timestamp of when the signature was created (informational only) | + +### Signing Flow + +1. Compute `content_hash` over all bundle files (see Content Integrity) +2. Insert `content_hash` into `_meta.com.contextvm` +3. Remove `_sig` from the manifest (if present) +4. Canonicalize the manifest per RFC 8785 (sorted keys, no whitespace) +5. Compute `id = SHA-256(canonical_manifest)` — this covers `content_hash` +6. Sign `id` with the author's Nostr private key: `signature = schnorr_sign(id, nsec)` +7. Insert `_sig` with `pubkey`, `id`, `signature`, `created_at` +8. Pack the ZIP + +### Verification Flow + +1. Extract the ZIP +2. Verify `content_hash` matches actual bundle files (see Content Integrity) +3. Remove `_sig` from manifest, canonicalize per RFC 8785, compute SHA-256 → must equal `_sig.id` +4. Verify Schnorr signature: `schnorr_verify(_sig.id, _sig.signature, _sig.pubkey)` must pass + +All checks pass → valid. Any check fails → invalid. + +The bundle author's Nostr identity is the root of trust. The same keypair can sign multiple servers, giving the author a stable identity across their catalog without any CA or certificate infrastructure. Verification is a pure cryptographic operation — no network access required. + +--- + +## Content Integrity + +Bundle file integrity is verified using a Merkle-style hash tree: + +1. Recursively list all files in the bundle directory +2. Exclude `manifest.json`, any `.cvmb`/`.mcpb` files, and ignored patterns (`.git`, `node_modules`, `.DS_Store`, `.env`) +3. Compute `SHA-256(file_contents)` for each remaining file +4. Sort entries alphabetically by relative path (using `/` as separator) +5. Concatenate `path:hash\n` for each entry +6. Compute `SHA-256(concatenation)` and prefix with `sha256:` + +``` +Files: + server/index.js → sha256:aaa... + server/utils.js → sha256:bbb... + package.json → sha256:ccc... + +Sorted concatenation: + "package.json:ccc...\nserver/index.js:aaa...\nserver/utils.js:bbb..." + +content_hash = "sha256:" + SHA-256(concatenation) +``` + +This approach enables: + +- **Single-hash verification**: one hash covers all files +- **Deterministic ordering**: alphabetical sort ensures reproducible hashes +- **Path binding**: file paths are part of the hash, preventing file relocation attacks + +--- + +## Docker Support + +Docker is a first-class server type for complex deployments requiring databases, caches, or other services alongside the MCP server. + +### Single Container + +```json +{ + "server": { + "type": "docker", + "image": "ghcr.io/developer/my-cvm-server:1.0.0", + "transport": "stdio", + "mcp_config": { + "command": "docker", + "args": ["run", "--rm", "-i", "ghcr.io/developer/my-cvm-server:1.0.0"] + } + } +} +``` + +The container exposes stdio MCP. `cvmi serve` spawns `docker run --rm -i ` and communicates over stdin/stdout, then wraps with the Gateway. The image is pulled from the registry on first run. + +### Multi-Container (Docker Compose) + +```json +{ + "server": { + "type": "docker", + "compose_file": "docker-compose.yml", + "transport": "stdio", + "mcp_config": { + "command": "docker", + "args": ["compose", "-f", "${__dirname}/docker-compose.yml", "run", "--rm", "-i", "server"] + } + } +} +``` + +The `compose_file` references a `docker-compose.yml` bundled inside the `.cvmb`. This handles orchestration of the server with its dependencies (database, cache, etc.). + +### Docker Considerations + +- Docker images are **referenced, not bundled** — the `.cvmb` contains only the manifest reference +- Images are pulled at runtime on first `cvmi serve` +- Docker must be installed on the host system +- The container communicates over stdio; `cvmi serve` wraps it with the Gateway + +--- + +## Packing Flow (`cvmi pack`) + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 1. Validate │────►│ 2. Hash │────►│ 3. Sign │────►│ 4. Archive │ +│ manifest │ │ contents │ │ manifest │ │ .cvmb ZIP │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +1. **Validate manifest** against the schema; ensure all required fields are present and types are correct +2. **Compute `content_hash`** over all bundle files (Merkle tree, see Content Integrity) +3. **Insert `content_hash`** into `_meta.com.contextvm` +4. **Sign the manifest**: canonicalize (RFC 8785), compute `id`, sign with author's Nostr private key, insert `_sig` +5. **Archive**: create ZIP with maximum compression, excluding dev files and ignored patterns + +--- + +## Serving Flow (`cvmi serve`) + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ 1. Extract │────►│ 2. Verify │────►│ 3. Resolve │────►│ 4. Spawn │ +│ .cvmb ZIP │ │ signature │ │ user_config │ │ process │ +└──────────────┘ └──────────────┘ └──────────────────┘ └──────────────┘ +``` + +1. **Extract** the `.cvmb` to a temporary or persistent directory +2. **Verify** the manifest signature and `content_hash` (see Verification Flow) +3. **Resolve configuration** using the standard precedence chain: + - CLI flags (highest priority) + - Custom config file (`--config `) + - Project-level `./.cvmi.json` + - Global `~/.cvmi/config.json` + - Environment variables + - Manifest defaults (lowest priority) +4. **Prompt for missing `user_config`** values (especially `sensitive: true` fields) +5. **Spawn the process** according to `transport` mode: + - `stdio`: spawn process → wrap with Gateway → Gateway manages Nostr transport + - `cvm`: spawn process directly → inject resolved env vars → server manages its own transport + +--- + +## Canonicalization + +Manifest canonicalization follows [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (JSON Canonicalization Scheme): + +- Object keys are sorted lexicographically +- No whitespace outside string values +- Unicode characters are escaped per the RFC +- Numbers are serialized without insignificant digits + +The canonical form is used for computing `_sig.id` and must be reproduced identically by any implementation. The `canonicalize` npm package provides a conformant implementation. + +--- + +## Design Decisions + +### No MCPB Host Compatibility + +`.cvmb` bundles are designed exclusively for the ContextVM ecosystem. They are not intended to run in generic MCPB hosts (like Claude Desktop). MCPB is designed for local stdio servers installed into desktop applications — a different use case from distributing CVM servers that run over Nostr. Maintaining MCPB spec compatibility would add constraints (server type enums, `mcp_config` assumptions) without benefit. + +The format borrows MCPB's manifest shape where it makes sense (`user_config`, `mcp_config`, variable substitution) but defines its own server types, transport modes, and signing mechanism. + +### Secrets Never in Plaintext + +Private keys, API keys, and any sensitive values are never shipped in the bundle or stored in `_meta.com.contextvm.defaults`. They are declared via `user_config` with `sensitive: true` and resolved by `cvmi serve` at runtime through prompting or environment variables. + +### No PKI — Nostr Identity as Root of Trust + +X.509 certificates and CA infrastructure are unnecessary when the server already has a Nostr identity (keypair). Signing with the same keypair used for protocol operation and announcements provides a unified identity model. Verification is a pure Schnorr signature check — no network access, no certificate chains, no expiration. + +### Merkle Hashing Enables Integrity Without Extraction + +The Merkle tree structure means the `content_hash` can be verified incrementally. Future tooling could verify individual files without re-hashing the entire bundle, and partial updates could be validated against a known root hash. + +--- + +## References + +- [MCPB Specification](https://github.com/modelcontextprotocol/mcpb) — base manifest format inspiration +- [MCPB MANIFEST.md](https://github.com/anthropics/mcpb/blob/main/MANIFEST.md) — field definitions and user_config spec +- [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) — JSON Canonicalization Scheme +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Nostr event signing (Schnorr signatures) +- [CEP-6: Public Server Announcements](ceps.md) — server discovery events (kind 11316–11320) +- [CEP-4: Encryption Support](ceps.md) — `support_encryption` tag and NIP-44 From 2a5cabc173042c988da0586016e113a28e29ed92 Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Tue, 16 Jun 2026 18:01:30 +0530 Subject: [PATCH 06/10] fix: address PR review feedback for CVM bundling --- package.json | 2 +- pnpm-lock.yaml | 17 ++----- src/pack.ts | 25 ++++------- src/pack/constants.ts | 30 +++++++++++++ src/pack/crypto.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++ src/pack/crypto.ts | 11 +++-- src/pack/cvm-manifest.ts | 7 +-- src/pack/extract.ts | 4 +- src/pack/pack-init.ts | 22 +++++---- src/serve.ts | 23 ++++------ 10 files changed, 175 insertions(+), 62 deletions(-) create mode 100644 src/pack/constants.ts create mode 100644 src/pack/crypto.test.ts diff --git a/package.json b/package.json index 94db3f395..e476d2f64 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "dependencies": { "@contextvm/sdk": "^0.11.14", "@modelcontextprotocol/sdk": "^1.27.1", - "@noble/curves": "^2.2.0", "archiver": "^8.0.0", "canonicalize": "^3.0.0", "extract-zip": "^2.0.1", @@ -107,6 +106,7 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@clack/prompts": "^0.11.0", + "@noble/curves": "2.0.1", "@types/archiver": "^8.0.0", "@types/bun": "latest", "@types/extract-zip": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c5750ef..f0ee157eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,9 +13,6 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) - '@noble/curves': - specifier: ^2.2.0 - version: 2.2.0 archiver: specifier: ^8.0.0 version: 8.0.0 @@ -44,6 +41,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@noble/curves': + specifier: 2.0.1 + version: 2.0.1 '@types/archiver': specifier: ^8.0.0 version: 8.0.0 @@ -660,13 +660,6 @@ packages: } engines: { node: '>= 20.19.0' } - '@noble/curves@2.2.0': - resolution: - { - integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==, - } - engines: { node: '>= 20.19.0' } - '@noble/hashes@1.3.1': resolution: { @@ -4364,10 +4357,6 @@ snapshots: dependencies: '@noble/hashes': 2.0.1 - '@noble/curves@2.2.0': - dependencies: - '@noble/hashes': 2.2.0 - '@noble/hashes@1.3.1': {} '@noble/hashes@1.3.2': {} diff --git a/src/pack.ts b/src/pack.ts index d104ebee8..90ad734bc 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -1,13 +1,14 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const archiver = require('archiver'); -import { createWriteStream, existsSync, readFileSync, writeFileSync } from 'fs'; +import { createWriteStream, existsSync, readFileSync } from 'fs'; import { join, resolve } from 'path'; import * as p from '@clack/prompts'; import { runPackInit } from './pack/pack-init.ts'; import { validateManifest, type CvmbManifest } from './pack/cvm-manifest.ts'; import { computeDirectoryContentHash, signManifest } from './pack/crypto.ts'; import { BOLD, DIM, RESET } from './constants/ui.ts'; +import { BUNDLE_IGNORE_PATTERNS, CONTENT_HASH_IGNORE_PATTERNS } from './pack/constants.ts'; export interface PackOptions { output?: string; @@ -69,13 +70,7 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): // 1. Cryptography Phase: Hashing const sHash = p.spinner(); sHash.start('Computing Merkle content hash...'); - const contentHash = await computeDirectoryContentHash(dir, [ - '.git', - 'node_modules', - '.DS_Store', - '.env', - '.cvmb', - ]); + const contentHash = await computeDirectoryContentHash(dir, CONTENT_HASH_IGNORE_PATTERNS); sHash.stop(`Content hash computed: ${contentHash.slice(0, 16)}...`); if (!manifest._meta) manifest._meta = {}; @@ -123,9 +118,6 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): ); } - // Save the modified manifest back to disk so the zip includes the hash/sig - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - // 3. Archive Phase await new Promise((resolvePromise, rejectPromise) => { const output = createWriteStream(outPath); @@ -144,17 +136,16 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): archive.pipe(output); + // Add the signed/hashed manifest directly from memory + archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' }); + // Add all files from directory, excluding some common things we don't want archive.glob('**/*', { cwd: dir, dot: true, ignore: [ - '.git/**', - 'node_modules/.cache/**', - '.DS_Store', - '.env', - '*.cvmb', - '*.mcpb', + ...BUNDLE_IGNORE_PATTERNS, + 'manifest.json', // Excluded so we don't add the unsigned source file outFileName, ], }); diff --git a/src/pack/constants.ts b/src/pack/constants.ts new file mode 100644 index 000000000..9c5675170 --- /dev/null +++ b/src/pack/constants.ts @@ -0,0 +1,30 @@ +/** + * Ignored files/directories when calculating the bundle's Merkle content hash. + * node_modules is ignored to avoid hashing hundreds of megabytes of dependencies, + * meaning node_modules integrity relies on lockfiles being in the hash. + */ +export const CONTENT_HASH_IGNORE_PATTERNS = [ + '.git', + 'node_modules', + '.DS_Store', + '.env', + '.cvmb', + '.mcpb', +]; + +/** + * Ignored glob patterns when building the final ZIP archive. + */ +export const BUNDLE_IGNORE_PATTERNS = [ + '.git/**', + 'node_modules/.cache/**', + '.DS_Store', + '.env', + '*.cvmb', + '*.mcpb', +]; + +/** + * Current version of the CVM/MCPB manifest specification. + */ +export const CVM_MANIFEST_VERSION = '0.3'; diff --git a/src/pack/crypto.test.ts b/src/pack/crypto.test.ts new file mode 100644 index 000000000..97838ad36 --- /dev/null +++ b/src/pack/crypto.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + canonicalizeManifest, + computeManifestId, + signManifest, + verifyManifestSignature, +} from './crypto.ts'; +import type { CvmbManifest } from './cvm-manifest.ts'; +import { generatePrivateKey } from '../utils/crypto.ts'; // assuming this exists and returns hex + +describe('crypto', () => { + const dummyManifest: CvmbManifest = { + manifest_version: '0.3', + name: 'test-server', + version: '1.0.0', + description: 'A test server', + author: { name: 'Alice' }, + server: { + type: 'node', + transport: 'stdio', + mcp_config: { + command: 'node', + args: ['index.js'], + }, + }, + _meta: { + 'com.contextvm': { + content_hash: 'sha256:dummyhash', + }, + }, + }; + + it('should canonicalize manifest consistently', () => { + const json1 = canonicalizeManifest(dummyManifest); + // Keys should be ordered alphabetically, no whitespace + expect(json1).toContain('{"_meta":{"com.contextvm":{"content_hash":"sha256:dummyhash"}}'); + expect(json1).toContain('"author":{"name":"Alice"}'); + }); + + it('should omit _sig when canonicalizing', () => { + const signedManifest = { + ...dummyManifest, + _sig: { + pubkey: 'dummy', + id: 'dummy', + signature: 'dummy', + created_at: 12345, + }, + }; + const json1 = canonicalizeManifest(dummyManifest); + const json2 = canonicalizeManifest(signedManifest); + expect(json1).toBe(json2); + }); + + it('should compute consistent manifest ID', () => { + const id1 = computeManifestId(dummyManifest); + const id2 = computeManifestId(dummyManifest); + expect(id1).toBe(id2); + expect(id1).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex + }); + + it('should successfully sign and verify a manifest', () => { + const privKey = generatePrivateKey(); // generates a 64-char hex + const signedManifest = { ...dummyManifest }; + + signedManifest._sig = signManifest(signedManifest, privKey); + + expect(signedManifest._sig.pubkey).toMatch(/^[a-f0-9]{64}$/); + expect(signedManifest._sig.signature).toMatch(/^[a-f0-9]{128}$/); + + // Verification should pass without throwing + expect(verifyManifestSignature(signedManifest)).toBe(true); + }); + + it('should fail verification if manifest is tampered', () => { + const privKey = generatePrivateKey(); + const signedManifest = { ...dummyManifest }; + signedManifest._sig = signManifest(signedManifest, privKey); + + // Tamper with the manifest + signedManifest.version = '1.0.1'; + + expect(() => verifyManifestSignature(signedManifest)).toThrow('Manifest ID mismatch'); + }); + + it('should fail verification if signature is tampered', () => { + const privKey = generatePrivateKey(); + const signedManifest = { ...dummyManifest }; + signedManifest._sig = signManifest(signedManifest, privKey); + + // Tamper with signature + signedManifest._sig.signature = signedManifest._sig.signature.replace(/0/g, '1'); + + expect(() => verifyManifestSignature(signedManifest)).toThrow('Invalid Schnorr signature'); + }); +}); diff --git a/src/pack/crypto.ts b/src/pack/crypto.ts index f399a9119..27ac548b7 100644 --- a/src/pack/crypto.ts +++ b/src/pack/crypto.ts @@ -5,6 +5,7 @@ import { schnorr } from '@noble/curves/secp256k1.js'; import { readdir, readFile } from 'fs/promises'; import { join, relative } from 'path'; import type { CvmbManifest } from './cvm-manifest.ts'; +import { CONTENT_HASH_IGNORE_PATTERNS } from './constants.ts'; /** * Canonicalizes a manifest object according to RFC 8785. @@ -85,10 +86,13 @@ export function verifyManifestSignature(manifest: CvmbManifest): boolean { * 1. Hashes each file's contents (excluding manifest.json and ignored patterns). * 2. Sorts paths alphabetically. * 3. Concatenates path:hash\n and hashes the result. + * + * Note: node_modules is ignored to avoid hashing hundreds of megabytes of dependencies. + * Therefore, node_modules integrity relies on lockfiles (package-lock.json, etc.) being in the hash. */ export async function computeDirectoryContentHash( dir: string, - ignoreList: string[] = ['.git', 'node_modules', '.DS_Store', '.env'] + ignoreList: string[] = CONTENT_HASH_IGNORE_PATTERNS ): Promise { const allFiles = await getFilesRecursive(dir); @@ -98,9 +102,10 @@ export async function computeDirectoryContentHash( if (relPath === 'manifest.json') return false; if (relPath.endsWith('.cvmb') || relPath.endsWith('.mcpb')) return false; - // Simple ignore logic + // Path-segment-aware ignore logic + const segments = relPath.split(/[/\\]/); for (const ignore of ignoreList) { - if (relPath.includes(ignore) || relPath.startsWith(ignore)) return false; + if (segments.includes(ignore)) return false; } return true; }); diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts index 982f39db6..152052573 100644 --- a/src/pack/cvm-manifest.ts +++ b/src/pack/cvm-manifest.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CVM_MANIFEST_VERSION } from './constants.ts'; export const UserConfigFieldSchema = z.object({ type: z.enum(['string', 'number', 'boolean', 'directory', 'file']), @@ -31,11 +32,11 @@ export type CVMMeta = z.infer; export const CvmbManifestSchema = z .object({ - manifest_version: z.string(), + manifest_version: z.string().default(CVM_MANIFEST_VERSION), name: z.string(), display_name: z.string().optional(), - version: z.string(), - description: z.string().optional(), + version: z.string().regex(/^\d+\.\d+\.\d+/, 'Must be a valid semver version'), + description: z.string(), author: z.object({ name: z.string(), email: z.string().optional(), diff --git a/src/pack/extract.ts b/src/pack/extract.ts index b9c57c483..0a19d9b3a 100644 --- a/src/pack/extract.ts +++ b/src/pack/extract.ts @@ -6,13 +6,13 @@ import { validateManifest, type CvmbManifest } from './cvm-manifest.ts'; import { randomBytes } from 'crypto'; export async function extractBundle( - mcpbPath: string + bundlePath: string ): Promise<{ dir: string; manifest: CvmbManifest }> { // Use a unique temp directory for extraction const extractDir = join(os.tmpdir(), `cvmi-bundle-${randomBytes(8).toString('hex')}`); try { - await extractZip(mcpbPath, { dir: extractDir }); + await extractZip(bundlePath, { dir: extractDir }); } catch (err) { throw new Error( `Failed to extract bundle: ${err instanceof Error ? err.message : String(err)}` diff --git a/src/pack/pack-init.ts b/src/pack/pack-init.ts index 0380677c0..9e750f42c 100644 --- a/src/pack/pack-init.ts +++ b/src/pack/pack-init.ts @@ -1,6 +1,7 @@ import * as p from '@clack/prompts'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, basename } from 'path'; +import { CVM_MANIFEST_VERSION } from './constants.ts'; export async function runPackInit(dir: string): Promise { const manifestPath = join(dir, 'manifest.json'); @@ -141,9 +142,11 @@ export async function runPackInit(dir: string): Promise { } // Example of using user_config for CVM relays mapping - mcpConfig.env = { - CVM_RELAYS: '${user_config.relays}', - }; + if (result.transport === 'cvm') { + mcpConfig.env = { + CVM_RELAYS: '${user_config.relays}', + }; + } // Build server section const server: Record = { @@ -158,8 +161,8 @@ export async function runPackInit(dir: string): Promise { server.entry_point = result.entryPoint; } - const manifest = { - manifest_version: '1.0', + const manifest: Record = { + manifest_version: CVM_MANIFEST_VERSION, name: result.name, display_name: result.displayName, version: result.version, @@ -168,14 +171,17 @@ export async function runPackInit(dir: string): Promise { name: result.author, }, server, - user_config: { + }; + + if (result.transport === 'cvm') { + manifest.user_config = { relays: { type: 'string', title: 'Relays (comma separated)', default: 'wss://relay.contextvm.org', }, - }, - }; + }; + } writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); p.log.success(`Created manifest.json in ${dir}`); diff --git a/src/serve.ts b/src/serve.ts index 50793c878..835288032 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -18,6 +18,12 @@ import { BOLD, DIM, RESET } from './constants/ui.ts'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { savePrivateKeyToEnv } from './config/loader.ts'; import { normalizeCommandAndArgs, splitCommandString } from './utils/command.ts'; +import { computeDirectoryContentHash, verifyManifestSignature } from './pack/crypto.ts'; +import { CONTENT_HASH_IGNORE_PATTERNS } from './pack/constants.ts'; + +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} function isHttpUrl(value: string): boolean { try { @@ -140,21 +146,10 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis const { dir, manifest } = await extractBundle(target); cleanupPath = dir; - // Import crypto for verification - const { computeDirectoryContentHash, verifyManifestSignature } = - await import('./pack/crypto.ts'); - // 1. Content Hash Verification const expectedHash = manifest._meta?.['com.contextvm']?.content_hash; if (expectedHash) { - const actualHash = await computeDirectoryContentHash(dir, [ - '.git', - 'node_modules', - '.DS_Store', - '.env', - '.cvmb', - '.mcpb', - ]); + const actualHash = await computeDirectoryContentHash(dir, CONTENT_HASH_IGNORE_PATTERNS); if (actualHash !== expectedHash) { throw new Error( 'Content hash verification failed! The bundle contents have been modified.' @@ -219,7 +214,7 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis // Replace ${user_config.X} in args for (const [key, val] of Object.entries(userConfigValues)) { resolved = resolved.replace( - new RegExp(`\\$\\{user_config\\.${key}\\}`, 'g'), + new RegExp(`\\$\\{user_config\\.${escapeRegExp(key)}\\}`, 'g'), String(val) ); } @@ -235,7 +230,7 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis // Replace ${user_config.X} for (const [cfgKey, cfgVal] of Object.entries(userConfigValues)) { resolved = resolved.replace( - new RegExp(`\\$\\{user_config\\.${cfgKey}\\}`, 'g'), + new RegExp(`\\$\\{user_config\\.${escapeRegExp(cfgKey)}\\}`, 'g'), String(cfgVal) ); } From 5a2cc15470bb4965819947758e3b3d1c85162bdf Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Tue, 16 Jun 2026 18:48:21 +0530 Subject: [PATCH 07/10] fix: correct regex for __dirname replacement in cvmi serve --- src/serve.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/serve.ts b/src/serve.ts index 835288032..516670f41 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -207,10 +207,10 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis } // 4. Resolve command and args from manifest - target = manifest.server.mcp_config.command.replace(/\\$\\{__dirname\\}/g, dir); + target = manifest.server.mcp_config.command.replace(/\$\{__dirname\}/g, dir); const rawArgs = manifest.server.mcp_config.args || []; targetArgs = rawArgs.map((arg: string) => { - let resolved = arg.replace(/\\$\\{__dirname\\}/g, dir); + let resolved = arg.replace(/\$\{__dirname\}/g, dir); // Replace ${user_config.X} in args for (const [key, val] of Object.entries(userConfigValues)) { resolved = resolved.replace( @@ -226,7 +226,7 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis const resolvedManifestEnv: Record = {}; if (manifestEnv) { for (const [key, val] of Object.entries(manifestEnv)) { - let resolved = (val as string).replace(/\\$\\{__dirname\\}/g, dir); + let resolved = (val as string).replace(/\$\{__dirname\}/g, dir); // Replace ${user_config.X} for (const [cfgKey, cfgVal] of Object.entries(userConfigValues)) { resolved = resolved.replace( From 63165145a1299c82818c9d67beb660c594046aaf Mon Sep 17 00:00:00 2001 From: ContextVM Date: Fri, 19 Jun 2026 16:07:13 +0200 Subject: [PATCH 08/10] feat(bundling): delegate manifest signing to nostr-tools, drop @noble/curves --- cvmb-bundling.md | 47 ++++++++++++++------- package.json | 1 - pnpm-lock.yaml | 3 -- src/pack/constants.ts | 21 ++++++---- src/pack/crypto.test.ts | 48 ++++++++++----------- src/pack/crypto.ts | 91 +++++++++++++++++++++------------------- src/pack/cvm-manifest.ts | 5 +-- 7 files changed, 119 insertions(+), 97 deletions(-) diff --git a/cvmb-bundling.md b/cvmb-bundling.md index a9cbb09f1..6ee4fec0a 100644 --- a/cvmb-bundling.md +++ b/cvmb-bundling.md @@ -275,45 +275,64 @@ Future CVM-specific fields (relay defaults, encryption preferences, pricing conf ## Signing and Verification -Bundles are signed using the author's Nostr keypair. The signature lives in the `_sig` field: +Bundles are signed using the author's Nostr keypair. **All cryptography is delegated to [`nostr-tools`](https://github.com/nbd-wtf/nostr-tools)** (`finalizeEvent` / `verifyEvent`) — `cvmi` performs no manual curve operations and does not depend on `@noble/curves` directly. The signature lives in the `_sig` field: ```json { "_sig": { "pubkey": "abc123...", - "id": "sha256 of canonical manifest (without _sig)", + "id": "def456...", "signature": "86f25c...", "created_at": 1718123456 } } ``` +### The Signing Event + +The signature is a real Nostr event (never published to relays) whose `content` is the canonical manifest. Using `finalizeEvent` / `verifyEvent` binds the author's Nostr identity to the exact manifest bytes without any manual curve math: + +```json +{ + "kind": "", + "tags": [], + "content": "", + "created_at": 1718123456, + "pubkey": "abc123...", + "id": "def456...", + "sig": "86f25c..." +} +``` + +`MANIFEST_SIGNATURE_KIND` is a fixed constant defined by the tooling. Its value is arbitrary (the event is never relayed); it only needs to be identical at sign and verify time. Only `pubkey`, `id`, `sig`, and `created_at` are stored in the manifest's `_sig`; the rest is reconstructed during verification. + ### `_sig` Fields -| Field | Type | Description | -| ------------ | ------ | --------------------------------------------------------------------- | -| `pubkey` | string | Author's Nostr public key (hex) | -| `id` | string | SHA-256 of the canonical manifest JSON (with `_sig` removed) | -| `signature` | string | Schnorr signature of `id` using the author's private key (hex) | -| `created_at` | number | Unix timestamp of when the signature was created (informational only) | +| Field | Type | Description | +| ------------ | ------ | ------------------------------------------------------------------------------------------------- | +| `pubkey` | string | Author's Nostr public key (hex, x-only) | +| `id` | string | NIP-01 event id: SHA-256 of the serialized signing event, which commits to the canonical manifest | +| `signature` | string | Schnorr (BIP-340) signature of `id`, produced by `nostr-tools` `finalizeEvent` (hex) | +| `created_at` | number | Unix timestamp of when the signature was created (informational only) | ### Signing Flow 1. Compute `content_hash` over all bundle files (see Content Integrity) 2. Insert `content_hash` into `_meta.com.contextvm` 3. Remove `_sig` from the manifest (if present) -4. Canonicalize the manifest per RFC 8785 (sorted keys, no whitespace) -5. Compute `id = SHA-256(canonical_manifest)` — this covers `content_hash` -6. Sign `id` with the author's Nostr private key: `signature = schnorr_sign(id, nsec)` -7. Insert `_sig` with `pubkey`, `id`, `signature`, `created_at` +4. Canonicalize the manifest per RFC 8785 (sorted keys, no whitespace) → `content` +5. Build a Nostr signing event `{ kind, tags: [], content, created_at }` +6. Sign it with the author's Nostr private key via `finalizeEvent` → `{ pubkey, id, sig }` +7. Insert `_sig` with `pubkey`, `id`, `signature` (= the event's `sig`), `created_at` 8. Pack the ZIP ### Verification Flow 1. Extract the ZIP 2. Verify `content_hash` matches actual bundle files (see Content Integrity) -3. Remove `_sig` from manifest, canonicalize per RFC 8785, compute SHA-256 → must equal `_sig.id` -4. Verify Schnorr signature: `schnorr_verify(_sig.id, _sig.signature, _sig.pubkey)` must pass +3. Remove `_sig` from the manifest, canonicalize per RFC 8785 → `content` +4. Reconstruct the signing event `{ kind, tags: [], content, pubkey: _sig.pubkey, id: _sig.id, sig: _sig.signature, created_at: _sig.created_at }` +5. Verify with `verifyEvent` — this checks both the NIP-01 event `id` and the Schnorr signature All checks pass → valid. Any check fails → invalid. diff --git a/package.json b/package.json index e476d2f64..17143dc12 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,6 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@clack/prompts": "^0.11.0", - "@noble/curves": "2.0.1", "@types/archiver": "^8.0.0", "@types/bun": "latest", "@types/extract-zip": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0ee157eb..af25c8141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 - '@noble/curves': - specifier: 2.0.1 - version: 2.0.1 '@types/archiver': specifier: ^8.0.0 version: 8.0.0 diff --git a/src/pack/constants.ts b/src/pack/constants.ts index 9c5675170..fddbea43f 100644 --- a/src/pack/constants.ts +++ b/src/pack/constants.ts @@ -2,15 +2,11 @@ * Ignored files/directories when calculating the bundle's Merkle content hash. * node_modules is ignored to avoid hashing hundreds of megabytes of dependencies, * meaning node_modules integrity relies on lockfiles being in the hash. + * + * Note: `.cvmb`/`.mcpb` bundle artifacts are excluded separately by extension + * in `computeDirectoryContentHash`, so they are not listed here. */ -export const CONTENT_HASH_IGNORE_PATTERNS = [ - '.git', - 'node_modules', - '.DS_Store', - '.env', - '.cvmb', - '.mcpb', -]; +export const CONTENT_HASH_IGNORE_PATTERNS = ['.git', 'node_modules', '.DS_Store', '.env']; /** * Ignored glob patterns when building the final ZIP archive. @@ -28,3 +24,12 @@ export const BUNDLE_IGNORE_PATTERNS = [ * Current version of the CVM/MCPB manifest specification. */ export const CVM_MANIFEST_VERSION = '0.3'; + +/** + * Nostr event kind used as an opaque local signing container for manifest + * signatures. The signing event is never published to relays — it exists only + * so we can sign/verify the canonical manifest via nostr-tools (`finalizeEvent` + * / `verifyEvent`) without performing manual curve operations. The value is + * arbitrary but MUST be identical at sign and verify time. + */ +export const MANIFEST_SIGNATURE_KIND = 9501; diff --git a/src/pack/crypto.test.ts b/src/pack/crypto.test.ts index 97838ad36..c8a11787e 100644 --- a/src/pack/crypto.test.ts +++ b/src/pack/crypto.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { - canonicalizeManifest, - computeManifestId, - signManifest, - verifyManifestSignature, -} from './crypto.ts'; +import { canonicalizeManifest, signManifest, verifyManifestSignature } from './crypto.ts'; import type { CvmbManifest } from './cvm-manifest.ts'; -import { generatePrivateKey } from '../utils/crypto.ts'; // assuming this exists and returns hex +import { generatePrivateKey } from '../utils/crypto.ts'; // returns 64-char hex describe('crypto', () => { const dummyManifest: CvmbManifest = { @@ -52,45 +47,48 @@ describe('crypto', () => { expect(json1).toBe(json2); }); - it('should compute consistent manifest ID', () => { - const id1 = computeManifestId(dummyManifest); - const id2 = computeManifestId(dummyManifest); - expect(id1).toBe(id2); - expect(id1).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex - }); + it('should produce a well-formed _sig block', () => { + const privKey = generatePrivateKey(); // 64-char hex + const sig = signManifest(dummyManifest, privKey); - it('should successfully sign and verify a manifest', () => { - const privKey = generatePrivateKey(); // generates a 64-char hex - const signedManifest = { ...dummyManifest }; + expect(sig.pubkey).toMatch(/^[a-f0-9]{64}$/); // Nostr pubkey (x-only) + expect(sig.id).toMatch(/^[a-f0-9]{64}$/); // NIP-01 event id (sha256) + expect(sig.signature).toMatch(/^[a-f0-9]{128}$/); // Schnorr signature + expect(typeof sig.created_at).toBe('number'); + }); + it('should successfully sign and verify a manifest', async () => { + const privKey = generatePrivateKey(); + const signedManifest: CvmbManifest = { ...dummyManifest }; signedManifest._sig = signManifest(signedManifest, privKey); - expect(signedManifest._sig.pubkey).toMatch(/^[a-f0-9]{64}$/); - expect(signedManifest._sig.signature).toMatch(/^[a-f0-9]{128}$/); - // Verification should pass without throwing - expect(verifyManifestSignature(signedManifest)).toBe(true); + await expect(Promise.resolve(verifyManifestSignature(signedManifest))).resolves.toBe(true); }); it('should fail verification if manifest is tampered', () => { const privKey = generatePrivateKey(); - const signedManifest = { ...dummyManifest }; + const signedManifest: CvmbManifest = { ...dummyManifest } as CvmbManifest; signedManifest._sig = signManifest(signedManifest, privKey); - // Tamper with the manifest + // Tamper with the manifest after signing signedManifest.version = '1.0.1'; - expect(() => verifyManifestSignature(signedManifest)).toThrow('Manifest ID mismatch'); + expect(() => verifyManifestSignature(signedManifest)).toThrow('Invalid manifest signature'); }); it('should fail verification if signature is tampered', () => { const privKey = generatePrivateKey(); - const signedManifest = { ...dummyManifest }; + const signedManifest: CvmbManifest = { ...dummyManifest } as CvmbManifest; signedManifest._sig = signManifest(signedManifest, privKey); // Tamper with signature signedManifest._sig.signature = signedManifest._sig.signature.replace(/0/g, '1'); - expect(() => verifyManifestSignature(signedManifest)).toThrow('Invalid Schnorr signature'); + expect(() => verifyManifestSignature(signedManifest)).toThrow('Invalid manifest signature'); + }); + + it('should fail verification if unsigned', () => { + expect(() => verifyManifestSignature({ ...dummyManifest })).toThrow('Manifest is not signed'); }); }); diff --git a/src/pack/crypto.ts b/src/pack/crypto.ts index 27ac548b7..0ee02c6f2 100644 --- a/src/pack/crypto.ts +++ b/src/pack/crypto.ts @@ -1,18 +1,18 @@ import canonicalize from 'canonicalize'; import { createHash } from 'crypto'; -import { getPublicKey } from 'nostr-tools'; -import { schnorr } from '@noble/curves/secp256k1.js'; +import { finalizeEvent, verifyEvent } from 'nostr-tools'; +import { hexToBytes } from 'nostr-tools/utils'; import { readdir, readFile } from 'fs/promises'; import { join, relative } from 'path'; import type { CvmbManifest } from './cvm-manifest.ts'; -import { CONTENT_HASH_IGNORE_PATTERNS } from './constants.ts'; +import { CONTENT_HASH_IGNORE_PATTERNS, MANIFEST_SIGNATURE_KIND } from './constants.ts'; /** * Canonicalizes a manifest object according to RFC 8785. - * If the manifest contains a `_sig` field, it is removed before canonicalization. + * The `_sig` field is removed before canonicalization so the digest is stable + * across signing and verification. */ export function canonicalizeManifest(manifest: CvmbManifest): string { - // Create a copy without the _sig field const { _sig, ...manifestWithoutSig } = manifest; const canonical = canonicalize(manifestWithoutSig); if (!canonical) { @@ -22,60 +22,65 @@ export function canonicalizeManifest(manifest: CvmbManifest): string { } /** - * Computes the SHA-256 ID of the canonicalized manifest. - */ -export function computeManifestId(manifest: CvmbManifest): string { - const canonical = canonicalizeManifest(manifest); - return createHash('sha256').update(canonical).digest('hex'); -} - -/** - * Signs a manifest using a Nostr private key (hex format). + * Signs a manifest using the author's Nostr private key (64-char hex). + * + * The canonical manifest becomes the `content` of a Nostr signing event, which + * is signed with nostr-tools' `finalizeEvent`. This binds the author's Nostr + * identity to the exact manifest bytes while delegating all curve math to + * nostr-tools (no direct use of @noble/curves). + * * Returns the complete `_sig` object to be injected into the manifest. */ export function signManifest(manifest: CvmbManifest, privateKeyHex: string) { - const id = computeManifestId(manifest); - - // Convert hex strings to Uint8Arrays for @noble/curves - const msgBytes = Uint8Array.from(Buffer.from(id, 'hex')); - const privBytes = Uint8Array.from(Buffer.from(privateKeyHex, 'hex')); - - const signature = schnorr.sign(msgBytes, privBytes); - const pubkey = getPublicKey(Uint8Array.from(Buffer.from(privateKeyHex, 'hex'))); - - // @noble/curves schnorr.sign returns a Uint8Array, we need hex - const signatureHex = Buffer.from(signature).toString('hex'); + const content = canonicalizeManifest(manifest); + const event = finalizeEvent( + { + kind: MANIFEST_SIGNATURE_KIND, + tags: [], + content, + created_at: Math.floor(Date.now() / 1000), + }, + hexToBytes(privateKeyHex) + ); return { - pubkey, - id, - signature: signatureHex, - created_at: Math.floor(Date.now() / 1000), + pubkey: event.pubkey, + id: event.id, + signature: event.sig, + created_at: event.created_at, }; } /** * Verifies the `_sig` block of a manifest. - * Throws an error if the signature is missing or invalid. + * + * Reconstructs the signing event from the canonical manifest plus the `_sig` + * fields and delegates to nostr-tools' `verifyEvent`, which checks both the + * NIP-01 event id and the Schnorr signature. + * + * Throws an error if the manifest is unsigned or the signature is invalid. */ export function verifyManifestSignature(manifest: CvmbManifest): boolean { - if (!manifest._sig) { + const sig = manifest._sig; + if (!sig) { throw new Error('Manifest is not signed'); } - const expectedId = computeManifestId(manifest); - if (manifest._sig.id !== expectedId) { - throw new Error('Manifest ID mismatch. The manifest has been modified after signing.'); - } - - const sigBytes = Uint8Array.from(Buffer.from(manifest._sig.signature, 'hex')); - const msgBytes = Uint8Array.from(Buffer.from(manifest._sig.id, 'hex')); - const pubBytes = Uint8Array.from(Buffer.from(manifest._sig.pubkey, 'hex')); - - const isValid = schnorr.verify(sigBytes, msgBytes, pubBytes); + const content = canonicalizeManifest(manifest); + const event = { + kind: MANIFEST_SIGNATURE_KIND, + tags: [], + content, + pubkey: sig.pubkey, + id: sig.id, + sig: sig.signature, + created_at: sig.created_at, + }; - if (!isValid) { - throw new Error('Invalid Schnorr signature'); + if (!verifyEvent(event)) { + throw new Error( + 'Invalid manifest signature: the manifest was modified after signing or the signature is corrupt.' + ); } return true; diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts index 152052573..9a7793662 100644 --- a/src/pack/cvm-manifest.ts +++ b/src/pack/cvm-manifest.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; -import { CVM_MANIFEST_VERSION } from './constants.ts'; export const UserConfigFieldSchema = z.object({ type: z.enum(['string', 'number', 'boolean', 'directory', 'file']), @@ -32,10 +31,10 @@ export type CVMMeta = z.infer; export const CvmbManifestSchema = z .object({ - manifest_version: z.string().default(CVM_MANIFEST_VERSION), + manifest_version: z.string(), name: z.string(), display_name: z.string().optional(), - version: z.string().regex(/^\d+\.\d+\.\d+/, 'Must be a valid semver version'), + version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be a valid semver version (e.g. 1.0.0)'), description: z.string(), author: z.object({ name: z.string(), From 63510681dd1dff9bf070360ba77b997fe5d6c5b6 Mon Sep 17 00:00:00 2001 From: Abhay Gupta Date: Tue, 23 Jun 2026 16:47:00 +0530 Subject: [PATCH 09/10] chore: address hygiene improvements from review feedback --- ctxcn | 1 + package.json | 3 +- pnpm-lock.yaml | 30 ++--------------- src/cli.ts | 8 ++--- src/pack.ts | 2 +- src/pack/cvm-manifest.ts | 70 +++++++++++++++++++++------------------- src/pack/extract.ts | 4 ++- src/serve.ts | 9 ++++-- 8 files changed, 56 insertions(+), 71 deletions(-) create mode 160000 ctxcn diff --git a/ctxcn b/ctxcn new file mode 160000 index 000000000..f0604f2d2 --- /dev/null +++ b/ctxcn @@ -0,0 +1 @@ +Subproject commit f0604f2d22bc38b43641026a6035db2c43d634aa diff --git a/package.json b/package.json index 17143dc12..7224589e2 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@contextvm/sdk": "^0.11.14", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^8.0.0", - "canonicalize": "^3.0.0", + "canonicalize": "^2.1.0", "extract-zip": "^2.0.1", "json-schema-to-typescript": "15.0.4", "nostr-tools": "^2.23.3", @@ -108,7 +108,6 @@ "@clack/prompts": "^0.11.0", "@types/archiver": "^8.0.0", "@types/bun": "latest", - "@types/extract-zip": "^2.0.3", "@types/node": "^22.19.15", "gray-matter": "^4.0.3", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af25c8141..4558706b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,8 +17,8 @@ importers: specifier: ^8.0.0 version: 8.0.0 canonicalize: - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^2.1.0 + version: 2.1.0 extract-zip: specifier: ^2.0.1 version: 2.0.1 @@ -47,9 +47,6 @@ importers: '@types/bun': specifier: latest version: 1.3.11 - '@types/extract-zip': - specifier: ^2.0.3 - version: 2.0.3 '@types/node': specifier: ^22.19.15 version: 22.19.15 @@ -1296,13 +1293,6 @@ packages: integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, } - '@types/extract-zip@2.0.3': - resolution: - { - integrity: sha512-yrO7h+0qOIGxHCmBeL5fKFzR+PBafh9LG6sOLBFFi2JuN+Hj663TAxfnqJh5vkQn963VimrhBF1GZzea3A+4Ig==, - } - deprecated: This is a stub types definition. extract-zip provides its own type definitions, so you do not need this installed. - '@types/jsesc@2.5.1': resolution: { @@ -1726,14 +1716,6 @@ packages: } hasBin: true - canonicalize@3.0.0: - resolution: - { - integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==, - } - engines: { node: '>=18' } - hasBin: true - chai@6.2.2: resolution: { @@ -4608,12 +4590,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/extract-zip@2.0.3': - dependencies: - extract-zip: 2.0.1 - transitivePeerDependencies: - - supports-color - '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -4871,8 +4847,6 @@ snapshots: canonicalize@2.1.0: {} - canonicalize@3.0.0: {} - chai@6.2.2: {} chardet@2.1.1: {} diff --git a/src/cli.ts b/src/cli.ts index 7352087b9..875847e15 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -65,7 +65,7 @@ function showBanner(): void { console.log(); const entries: [string, string][] = [ ['npx cvmi add [options]', 'Install ContextVM skills'], - ['npx cvmi pack [options]', 'Package a server into an MCPB bundle'], + ['npx cvmi pack [options]', 'Package a server into a CVMB bundle'], ['npx cvmi serve [options] -- ', 'Expose MCP server over Nostr'], ['npx cvmi use ', 'Connect to Nostr MCP server'], ['npx cvmi config ', 'Manage saved server aliases'], @@ -95,7 +95,7 @@ ${BOLD}Commands:${RESET} remove, rm, r Remove installed skills list, ls List installed skills init [name] Initialize a new skill - pack Package an MCP server into an MCPB bundle + pack Package an MCP server into a CVMB bundle sync Sync skills from node_modules serve Expose an MCP server over Nostr use Connect to a remote Nostr MCP server @@ -120,9 +120,9 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi add ${DIM}# install embedded ContextVM skills${RESET} ${DIM}$${RESET} cvmi add --skill overview ${DIM}# install a specific skill${RESET} ${DIM}$${RESET} cvmi remove ${DIM}# remove an installed skill${RESET} - ${DIM}$${RESET} cvmi pack ${DIM}# pack a server into .mcpb bundle${RESET} + ${DIM}$${RESET} cvmi pack ${DIM}# pack a server into .cvmb bundle${RESET} ${DIM}$${RESET} cvmi serve -- ${DIM}# start gateway, expose an already existing server (stdio or http) over nostr${RESET} - ${DIM}$${RESET} cvmi serve my-server.mcpb ${DIM}# serve a packed mcpb bundle over nostr${RESET} + ${DIM}$${RESET} cvmi serve my-server.cvmb ${DIM}# serve a packed cvmb bundle over nostr${RESET} ${DIM}$${RESET} cvmi use ${DIM}# connect to remote MCP server, expose it as stdio${RESET} ${DIM}$${RESET} cvmi discover ${DIM}# find public ContextVM servers${RESET} ${DIM}$${RESET} cvmi call ${DIM}# list remote capabilities${RESET} diff --git a/src/pack.ts b/src/pack.ts index 90ad734bc..bbb7c2ad4 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -39,7 +39,7 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): try { const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')); if (!options.noValidate) { - manifest = validateManifest(raw); + manifest = validateManifest(raw, true); } else { manifest = raw as CvmbManifest; } diff --git a/src/pack/cvm-manifest.ts b/src/pack/cvm-manifest.ts index 9a7793662..914cfec86 100644 --- a/src/pack/cvm-manifest.ts +++ b/src/pack/cvm-manifest.ts @@ -29,42 +29,46 @@ export const CVMMetaSchema = z.object({ export type CVMMeta = z.infer; -export const CvmbManifestSchema = z - .object({ - manifest_version: z.string(), +export const CvmbManifestSchema = z.object({ + manifest_version: z.string(), + name: z.string(), + display_name: z.string().optional(), + version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be a valid semver version (e.g. 1.0.0)'), + description: z.string(), + author: z.object({ name: z.string(), - display_name: z.string().optional(), - version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be a valid semver version (e.g. 1.0.0)'), - description: z.string(), - author: z.object({ - name: z.string(), - email: z.string().optional(), - url: z.string().optional(), + email: z.string().optional(), + url: z.string().optional(), + }), + server: z.object({ + type: z.enum(['node', 'python', 'uv', 'binary', 'docker']), + entry_point: z.string().optional(), + image: z.string().optional(), + compose_file: z.string().optional(), + transport: z.enum(['stdio', 'cvm']).default('stdio'), + mcp_config: z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), }), - server: z.object({ - type: z.enum(['node', 'python', 'uv', 'binary', 'docker']), - entry_point: z.string().optional(), - image: z.string().optional(), - compose_file: z.string().optional(), - transport: z.enum(['stdio', 'cvm']).default('stdio'), - mcp_config: z.object({ - command: z.string(), - args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), - }), - }), - user_config: z.record(z.string(), UserConfigFieldSchema).optional(), - _meta: z - .object({ - 'com.contextvm': CVMMetaSchema.optional(), - }) - .optional(), - _sig: CVMSigSchema.optional(), - }) - .passthrough(); + }), + user_config: z.record(z.string(), UserConfigFieldSchema).optional(), + _meta: z + .object({ + 'com.contextvm': CVMMetaSchema.optional(), + }) + .optional(), + _sig: CVMSigSchema.optional(), +}); export type CvmbManifest = z.infer; -export function validateManifest(data: unknown): CvmbManifest { - return CvmbManifestSchema.parse(data); +export const CvmbManifestSchemaStrict = CvmbManifestSchema.strict(); +export const CvmbManifestSchemaPassthrough = CvmbManifestSchema.passthrough(); + +export function validateManifest(data: unknown, strict = false): CvmbManifest { + if (strict) { + return CvmbManifestSchemaStrict.parse(data); + } + return CvmbManifestSchemaPassthrough.parse(data); } diff --git a/src/pack/extract.ts b/src/pack/extract.ts index 0a19d9b3a..ccbe53a2d 100644 --- a/src/pack/extract.ts +++ b/src/pack/extract.ts @@ -1,5 +1,5 @@ import extractZip from 'extract-zip'; -import { readFileSync, existsSync } from 'fs'; +import { readFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import os from 'os'; import { validateManifest, type CvmbManifest } from './cvm-manifest.ts'; @@ -10,6 +10,8 @@ export async function extractBundle( ): Promise<{ dir: string; manifest: CvmbManifest }> { // Use a unique temp directory for extraction const extractDir = join(os.tmpdir(), `cvmi-bundle-${randomBytes(8).toString('hex')}`); + // Ensure the directory is only readable/writable by the current user + mkdirSync(extractDir, { mode: 0o700, recursive: true }); try { await extractZip(bundlePath, { dir: extractDir }); diff --git a/src/serve.ts b/src/serve.ts index 516670f41..e5fcd358c 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -145,6 +145,11 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis try { const { dir, manifest } = await extractBundle(target); cleanupPath = dir; + process.on('exit', () => { + if (cleanupPath && fs.existsSync(cleanupPath)) { + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + }); // 1. Content Hash Verification const expectedHash = manifest._meta?.['com.contextvm']?.content_hash; @@ -431,7 +436,7 @@ ${BOLD}Arguments:${RESET} Can also be specified in config file under serve.command If the first argument is an http(s) URL, cvmi will treat it as a Streamable HTTP MCP server and connect via HTTP instead of spawning a local process. - If the first argument is an .mcpb file, cvmi will extract the bundle, + If the first argument is an .cvmb file, cvmi will extract the bundle, read the manifest, apply CVM config defaults, and spawn the server. ${BOLD}Config keys:${RESET} @@ -491,7 +496,7 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} cvmi serve https://mcp.server.com ${DIM}# expose a remote Streamable HTTP MCP server over Nostr${RESET} ${DIM}$${RESET} cvmi serve npx -y @modelcontextprotocol/server-prompt-generator --public ${DIM}# public server${RESET} ${DIM}$${RESET} cvmi serve python /path/to/server.py --relays wss://my-relay.com ${DIM}# custom relay${RESET} - ${DIM}$${RESET} cvmi serve my-server-1.0.0.mcpb ${DIM}# run an MCPB bundle over Nostr${RESET} + ${DIM}$${RESET} cvmi serve my-server-1.0.0.cvmb ${DIM}# run a CVMB bundle over Nostr${RESET} ${DIM}$${RESET} cvmi serve --help ${DIM}# show this help${RESET} `); } From ea91f3d991f3fe6999077013fdb9aa0fe992f48c Mon Sep 17 00:00:00 2001 From: ContextVM Date: Tue, 23 Jun 2026 13:46:57 +0200 Subject: [PATCH 10/10] fix: migrate archiver to v6 API, correct extension, improve cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: fix typo .mcpb → .cvmb - pack.ts: switch from `require('archiver')` to named `ZipArchive` import, use `new ZipArchive(opts)` for v6 compatibility - serve.ts: use `handedOffToGateway` flag and `finally` block for proper temporary bundle cleanup, avoiding premature removal when running via stdio transport --- .changeset/feat-cvmb-bundling.md | 5 +++++ AGENTS.md | 2 +- src/pack.ts | 6 ++---- src/serve.ts | 24 +++++++++++++++++++----- 4 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 .changeset/feat-cvmb-bundling.md diff --git a/.changeset/feat-cvmb-bundling.md b/.changeset/feat-cvmb-bundling.md new file mode 100644 index 000000000..832192628 --- /dev/null +++ b/.changeset/feat-cvmb-bundling.md @@ -0,0 +1,5 @@ +--- +'cvmi': minor +--- + +Added `.cvmb` (CVM Bundle) support: a new `cvmi pack` command packages an MCP server into a signed `.cvmb` bundle (ZIP archive with a `manifest.json`), and `cvmi serve ` extracts, verifies, and runs it. Bundles support both `stdio` (Gateway-wrapped) and `cvm` (native Nostr transport) modes, typed `user_config` with secrets handling, Merkle-tree `content_hash` integrity binding, and Nostr Schnorr manifest signatures via `nostr-tools`. diff --git a/AGENTS.md b/AGENTS.md index d861250ac..fc3751a6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This file provides guidance to AI coding agents working on the `cvmi` CLI codeba | -------------------- | --------------------------------------------------------- | | `cvmi` | Show banner with available commands | | `cvmi add ` | Install skills from git repos, URLs, or local paths | -| `cvmi pack` | Package an MCP server into a distributable `.mcpb` bundle | +| `cvmi pack` | Package an MCP server into a distributable `.cvmb` bundle | | `cvmi check` | Check for available skill updates | | `cvmi update` | Update all skills to latest versions | | `cvmi pn` / `cn` | Compile a server to TypeScript code | diff --git a/src/pack.ts b/src/pack.ts index bbb7c2ad4..701ca6d3c 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -1,6 +1,4 @@ -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); -const archiver = require('archiver'); +import { ZipArchive } from 'archiver'; import { createWriteStream, existsSync, readFileSync } from 'fs'; import { join, resolve } from 'path'; import * as p from '@clack/prompts'; @@ -121,7 +119,7 @@ export async function pack(targetDir: string = '.', options: PackOptions = {}): // 3. Archive Phase await new Promise((resolvePromise, rejectPromise) => { const output = createWriteStream(outPath); - const archive = archiver('zip', { + const archive = new ZipArchive({ zlib: { level: 9 }, // maximum compression }); diff --git a/src/serve.ts b/src/serve.ts index e5fcd358c..0a695ac09 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -142,9 +142,15 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis // Handle .cvmb / .mcpb bundle execution if (target.endsWith('.cvmb') || target.endsWith('.mcpb')) { p.log.info(`Extracting bundle ${target}...`); + // Tracks whether the extracted dir has been handed off to the long-lived + // gateway (stdio transport). When true, the `finally` below must NOT remove + // it — end-of-function cleanup + the `exit` hook own its lifecycle instead. + let handedOffToGateway = false; try { const { dir, manifest } = await extractBundle(target); cleanupPath = dir; + // Synchronous backstop: guarantees cleanup on crashes / unexpected exits + // that bypass the `finally` (e.g. uncaught throws, process.exit()). process.on('exit', () => { if (cleanupPath && fs.existsSync(cleanupPath)) { fs.rmSync(cleanupPath, { recursive: true, force: true }); @@ -277,11 +283,7 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis p.log.message(`\n${signal} received. Shutting down...`); child.kill('SIGTERM'); - if (cleanupPath && fs.existsSync(cleanupPath)) { - p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); - fs.rmSync(cleanupPath, { recursive: true, force: true }); - } - + // The `finally` block cleans up the extracted bundle dir on exit. process.exit(0); } else { // ── stdio transport (default) ── @@ -308,9 +310,21 @@ export async function serve(serverArgs: string[], options: ServeOptions): Promis serveConfig.encryption = userConfigValues.encryption as EncryptionMode; } } + + // The extracted bundle dir is now owned by the gateway spawned below; it + // must persist until the gateway shuts down, so keep `finally` from + // removing it prematurely. (The cvm branch exits before reaching here.) + handedOffToGateway = true; } catch (error) { p.log.error(error instanceof Error ? error.message : String(error)); process.exit(1); + } finally { + // Guaranteed cleanup for the cvm runtime path and any setup error. + // The stdio path is skipped (dir handed off to the gateway below). + if (cleanupPath && !handedOffToGateway) { + p.log.message(`Cleaning up temporary bundle at ${cleanupPath}`); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } } }