codexport 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  the master serves a content-hashed bundle from its `~/.codex` directory. followers pin the master's fingerprint on join, fetch updates over a Tailscale-reachable HTTP address, and apply updates at Codex `SessionStart` through a short best-effort hook.
14
14
 
15
+ MCPs are exported as full definitions, including env needed by supported tools. command-based MCPs are written through a managed launcher, so followers run `npx -y codexport@latest mcp run <name>` and let `codexport` translate master-local paths into repairable package, Python tool, or source-built launchers.
16
+
15
17
  [npm](https://www.npmjs.com/package/codexport) | [github](https://github.com/Microck/codexport)
16
18
 
17
19
  ## why
@@ -21,6 +23,8 @@ if you keep a carefully tuned Codex setup on one machine and want the same defau
21
23
  - keep the master as the canonical Codex configuration source
22
24
  - let followers preserve local MCPs, local skills, trust entries, and path overrides
23
25
  - sync auth-bearing files through the private Tailscale path instead of a plaintext GitHub commit
26
+ - export every master MCP definition instead of dropping machine-local entries
27
+ - repair known MCP launchers on followers through npm, uvx, released binaries, or source builds
24
28
  - refresh followers at Codex session startup without interrupting active sessions
25
29
  - use content-hash revisions and pinned master fingerprints instead of blind file copies
26
30
 
@@ -113,6 +117,28 @@ present.
113
117
  runtime state such as logs, caches, sessions, history, compact handoffs, and
114
118
  SQLite databases is excluded.
115
119
 
120
+ ## MCP export and repair
121
+
122
+ all master MCP definitions are exported into `~/.codexport/mcp-manifest.json` on followers. generated command MCP entries in `~/.codex/config.toml` point at the managed launcher:
123
+
124
+ ```toml
125
+ [mcp_servers.example]
126
+ command = "npx"
127
+ args = [ "-y", "codexport@latest", "mcp", "run", "example" ]
128
+ ```
129
+
130
+ when Codex starts an MCP, `codexport mcp run` reads the original manifest entry, restores transferred environment values, rewrites master paths to follower paths, and chooses a runnable target:
131
+
132
+ | source shape | follower action |
133
+ | --- | --- |
134
+ | npm-backed MCPs | run with `npx -y <package>` |
135
+ | Python MCP tools | run with `uvx --from <package> <binary>` and install `uv` when missing |
136
+ | FFF MCP | download the matching upstream release binary when missing |
137
+ | GitQuarry MCP | build `gitquarry-mcp` from its public source when missing and repair `GITQUARRY_CLI_PATH` |
138
+ | URL MCPs | keep the URL config unchanged |
139
+
140
+ unsupported local binaries are still kept in the manifest and generated config. if a follower cannot repair one, startup fails with the missing tool and repair step instead of silently removing the MCP.
141
+
116
142
  ## local follower state
117
143
 
118
144
  follower-local state lives under `~/.codexport`:
@@ -150,6 +176,7 @@ the follower's `local.toml` before writing the generated `~/.codex/config.toml`.
150
176
  | `codexport follower join` | enroll a follower from a join link or explicit master URL |
151
177
  | `codexport sync` | fetch the latest master bundle |
152
178
  | `codexport apply` | apply the last staged bundle |
179
+ | `codexport mcp run <name>` | run or repair a synced command MCP from the follower manifest |
153
180
  | `codexport hook install` | install the follower-only Codex `SessionStart` hook |
154
181
  | `codexport status` | report role, master URL, fingerprint, revision, and reachability |
155
182
 
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { homedir, platform } from "node:os";
11
11
  import path from "node:path";
12
12
  import { spawn } from "node:child_process";
13
13
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
14
- const VERSION = "0.2.0";
14
+ const VERSION = "0.3.1";
15
15
  const DEFAULT_PORT = 17342;
16
16
  const DEFAULT_TIMEOUT_MS = 5_000;
17
17
  const CODEXPORT_DIR = ".codexport";
@@ -21,6 +21,8 @@ const MCPS_LOCAL_FILE = "mcps.local.toml";
21
21
  const LAST_BUNDLE_FILE = "last-bundle.json";
22
22
  const CACHE_BUNDLE_FILE = "bundle.json";
23
23
  const APPLIED_FILES_FILE = "applied-files.json";
24
+ const MCP_MANIFEST_FILE = "mcp-manifest.json";
25
+ const MANAGED_MCP_PACKAGE = "codexport@latest";
24
26
  const INCLUDE_ROOTS = [
25
27
  "AGENTS.md",
26
28
  "RTK.md",
@@ -410,10 +412,31 @@ function rewritePortableConfig(canonical, sourceRoot, sourceEnv = {}) {
410
412
  continue;
411
413
  const server = rawServer;
412
414
  mergeSourceEnvForMcp(name, server, sourceEnv);
413
- rewritePortableMcpServer(name, server, sourceRoot, sourceHome);
415
+ rewriteManagedMcpServer(name, server, sourceRoot, sourceHome);
414
416
  }
415
417
  return stringifyToml(parsed);
416
418
  }
419
+ function buildMcpManifest(canonical, sourceRoot, sourceEnv = {}) {
420
+ let parsed;
421
+ try {
422
+ parsed = parseTomlObject(canonical, "canonical config.toml");
423
+ }
424
+ catch {
425
+ return undefined;
426
+ }
427
+ const mcpServers = parsed.mcp_servers;
428
+ if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers))
429
+ return undefined;
430
+ const servers = {};
431
+ for (const [name, rawServer] of Object.entries(mcpServers)) {
432
+ if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer))
433
+ continue;
434
+ const server = structuredClone(rawServer);
435
+ mergeSourceEnvForMcp(name, server, sourceEnv);
436
+ servers[name] = server;
437
+ }
438
+ return { version: 1, sourceRoot, sourceEnv, servers };
439
+ }
417
440
  function mergeSourceEnvForMcp(name, server, sourceEnv) {
418
441
  if (name !== "kagi-mcp")
419
442
  return;
@@ -439,25 +462,12 @@ function rewritePortableTableKeys(table, sourceRoot, sourceHome) {
439
462
  table[key] = value;
440
463
  }
441
464
  }
442
- function rewritePortableMcpServer(_name, server, sourceRoot, sourceHome) {
465
+ function rewriteManagedMcpServer(name, server, sourceRoot, sourceHome) {
443
466
  if (typeof server.url === "string")
444
467
  return;
445
- const command = typeof server.command === "string" ? server.command : undefined;
446
468
  const args = Array.isArray(server.args) ? server.args : [];
447
- const launcher = command && mcpHasRequiredPortableEnv(_name, command, server) ? portableMcpLauncher(_name, command, args, sourceHome, server) : undefined;
448
- if (launcher) {
449
- server.command = launcher.command;
450
- server.args = launcher.args.map((arg) => rewritePortablePath(arg, sourceRoot, sourceHome));
451
- }
452
- else if (command && isAbsoluteAnyPlatform(command)) {
453
- server.enabled = false;
454
- }
455
- else if (command) {
456
- server.command = rewritePortableCommand(command, sourceRoot);
457
- }
458
- if (!launcher && args.length) {
459
- server.args = args.map((arg) => typeof arg === "string" ? rewritePortablePath(arg, sourceRoot, sourceHome) : arg);
460
- }
469
+ server.command = "npx";
470
+ server.args = ["-y", MANAGED_MCP_PACKAGE, "mcp", "run", name];
461
471
  if (server.env && typeof server.env === "object" && !Array.isArray(server.env)) {
462
472
  for (const [key, value] of Object.entries(server.env)) {
463
473
  if (typeof value === "string") {
@@ -489,9 +499,46 @@ function portableMcpLauncher(name, command, args, sourceHome, server) {
489
499
  }
490
500
  const npmPackage = npmPackageForPortableMcp(name, commandName);
491
501
  if (npmPackage) {
492
- const remainingArgs = allStrings(args) ? args : [];
502
+ const remainingArgs = packageLauncherArgs(commandName, args);
493
503
  return { command: "npx", args: ["-y", npmPackage, ...remainingArgs] };
494
504
  }
505
+ const uvTool = uvToolForPortableMcp(name, commandName);
506
+ if (uvTool) {
507
+ const remainingArgs = allStrings(args) ? args : [];
508
+ return {
509
+ command: "uvx",
510
+ args: ["--from", uvTool.packageName, uvTool.binaryName, ...remainingArgs],
511
+ repair: {
512
+ whenMissing: "uvx",
513
+ command: platform() === "win32" ? "py" : "python3",
514
+ args: ["-m", "pip", "install", "--user", "uv"]
515
+ }
516
+ };
517
+ }
518
+ if (name === "fff" || commandName === "fff-mcp") {
519
+ const remainingArgs = allStrings(args) ? args : [];
520
+ return {
521
+ command: "fff-mcp",
522
+ args: remainingArgs,
523
+ repair: {
524
+ whenMissing: "fff-mcp",
525
+ command: "__codexport_install_fff_mcp",
526
+ args: []
527
+ }
528
+ };
529
+ }
530
+ if (name === "gitquarry-mcp" || commandName === "gitquarry-mcp") {
531
+ const remainingArgs = allStrings(args) ? args : [];
532
+ return {
533
+ command: "gitquarry-mcp",
534
+ args: remainingArgs,
535
+ repair: {
536
+ whenMissing: "gitquarry-mcp",
537
+ command: "cargo",
538
+ args: ["install", "--git", "https://github.com/Microck/gitquarry-mcp.git", "--locked"]
539
+ }
540
+ };
541
+ }
495
542
  return undefined;
496
543
  }
497
544
  function mcpHasRequiredPortableEnv(name, command, server) {
@@ -513,16 +560,44 @@ function ensurePortablePathEnv(server) {
513
560
  }
514
561
  function npmPackageForPortableMcp(name, commandName) {
515
562
  const knownPackages = {
563
+ "camofox-browser-mcp": "camofox-browser-mcp",
516
564
  "dora": "@butttons/dora",
565
+ "grep-app": "@247arjun/mcp-grep",
517
566
  "kagi-mcp": "kagi-mcp",
567
+ "keywords-everywhere": "mcp-keywords-everywhere",
568
+ "mcp-grep": "@247arjun/mcp-grep",
569
+ "mcp-vnc": "@hrrrsn/mcp-vnc",
518
570
  "opensrc-mcp": "opensrc-mcp",
519
571
  "opensrc-mcp-stdio": "opensrc-mcp",
520
572
  "perplexity-webui": "perplexity-webui-mcp",
521
573
  "perplexity-webui-mcp": "perplexity-webui-mcp",
522
- "reddit-mcp-buddy": "reddit-mcp-buddy"
574
+ "qmd": "qmd-cli",
575
+ "reddit-mcp-buddy": "reddit-mcp-buddy",
576
+ "xcodebuildmcp": "xcodebuildmcp"
523
577
  };
524
578
  return knownPackages[name] ?? knownPackages[commandName];
525
579
  }
580
+ function packageLauncherArgs(commandName, args) {
581
+ if (!allStrings(args))
582
+ return [];
583
+ const remainingArgs = args;
584
+ const [entrypoint, ...afterEntrypoint] = remainingArgs;
585
+ if ((commandName === "node" || commandName === "bun") && typeof entrypoint === "string") {
586
+ const normalized = normalizePathForCompare(entrypoint);
587
+ if (isAbsoluteAnyPlatform(entrypoint) || normalized.endsWith(".js") || normalized.endsWith(".mjs") || normalized.endsWith(".ts")) {
588
+ return afterEntrypoint;
589
+ }
590
+ }
591
+ return remainingArgs;
592
+ }
593
+ function uvToolForPortableMcp(name, commandName) {
594
+ const knownTools = {
595
+ "discord-py-self": { packageName: "discord-py-self-mcp", binaryName: "discord-py-self-mcp" },
596
+ "discord-py-self-mcp": { packageName: "discord-py-self-mcp", binaryName: "discord-py-self-mcp" },
597
+ "markitdown-mcp": { packageName: "markitdown-mcp", binaryName: "markitdown-mcp" }
598
+ };
599
+ return knownTools[name] ?? knownTools[commandName];
600
+ }
526
601
  function rewritePortableCommand(command, sourceRoot) {
527
602
  const sourceRelative = rewriteSourceRootPath(command, sourceRoot);
528
603
  if (sourceRelative !== command)
@@ -691,6 +766,10 @@ async function applyBundle(ctx, bundle) {
691
766
  if (configEntry) {
692
767
  const canonicalConfig = decodeFile(configEntry).toString("utf8");
693
768
  const localMcpText = await readTextIfExists(path.join(ctx.stateDir, MCPS_LOCAL_FILE));
769
+ const manifest = buildMcpManifest(canonicalConfig, bundle.sourceRoot, bundle.sourceEnv);
770
+ if (manifest) {
771
+ await writeJsonAtomic(path.join(ctx.stateDir, MCP_MANIFEST_FILE), manifest);
772
+ }
694
773
  const generated = mergeTomlText(canonicalConfig, localMcpText, { ...localConfig, codexDir: ctx.codexDir }, bundle.sourceRoot, bundle.sourceEnv);
695
774
  const configPath = path.join(ctx.codexDir, "config.toml");
696
775
  if (await pathExists(configPath)) {
@@ -708,6 +787,147 @@ async function applyBundle(ctx, bundle) {
708
787
  await writeLocalConfig(ctx, { ...localConfig, lastRevision: bundle.revision, codexDir: ctx.codexDir });
709
788
  await writeJsonAtomic(path.join(ctx.stateDir, APPLIED_FILES_FILE), bundle.files.map((file) => file.path));
710
789
  }
790
+ async function commandMcpRun(ctx, name) {
791
+ const manifest = await readJsonIfExists(path.join(ctx.stateDir, MCP_MANIFEST_FILE));
792
+ if (!manifest?.servers?.[name]) {
793
+ throw new CliError(`No managed MCP named ${name}. Run codexport sync --apply first.`, 1);
794
+ }
795
+ const localConfig = await readLocalConfig(ctx);
796
+ const sourceHome = inferHomeFromCodexDir(manifest.sourceRoot);
797
+ const server = structuredClone(manifest.servers[name]);
798
+ mergeSourceEnvForMcp(name, server, manifest.sourceEnv ?? {});
799
+ rewritePortableTableKeys(server, manifest.sourceRoot, sourceHome);
800
+ if (typeof server.url === "string") {
801
+ throw new CliError(`MCP ${name} is URL-based and should not be launched through codexport mcp run.`, 2);
802
+ }
803
+ const command = typeof server.command === "string" ? expandPathVariables(server.command, { ...localConfig, codexDir: ctx.codexDir }) : undefined;
804
+ const args = Array.isArray(server.args)
805
+ ? server.args.map((arg) => typeof arg === "string" ? expandPathVariables(rewritePortablePath(arg, manifest.sourceRoot, sourceHome), { ...localConfig, codexDir: ctx.codexDir }) : String(arg))
806
+ : [];
807
+ if (!command)
808
+ throw new CliError(`MCP ${name} has no command.`, 1);
809
+ const launcher = mcpHasRequiredPortableEnv(name, command, server)
810
+ ? portableMcpLauncher(name, command, args, sourceHome, server)
811
+ : undefined;
812
+ const runCommandName = launcher?.command ?? rewritePortableCommand(command, manifest.sourceRoot);
813
+ const runArgs = launcher?.args ?? args;
814
+ const childEnv = { ...process.env, ...portableServerEnv(server, manifest.sourceRoot, sourceHome, { ...localConfig, codexDir: ctx.codexDir }) };
815
+ await repairMcpLauncherIfNeeded(launcher, childEnv);
816
+ await repairGitquarryEnvIfNeeded(name, childEnv);
817
+ await runCommandWithEnv(runCommandName, runArgs, childEnv);
818
+ }
819
+ function portableServerEnv(server, sourceRoot, sourceHome, localConfig) {
820
+ const env = server.env && typeof server.env === "object" && !Array.isArray(server.env) ? server.env : {};
821
+ const out = {};
822
+ for (const [key, value] of Object.entries(env)) {
823
+ if (typeof value === "string") {
824
+ out[key] = expandPathVariables(rewritePortablePath(value, sourceRoot, sourceHome), localConfig);
825
+ }
826
+ }
827
+ return out;
828
+ }
829
+ async function repairMcpLauncherIfNeeded(launcher, env) {
830
+ if (!launcher?.repair)
831
+ return;
832
+ if (await executableExists(launcher.repair.whenMissing, env))
833
+ return;
834
+ if (launcher.repair.command === "__codexport_install_fff_mcp") {
835
+ await installFffMcp(env);
836
+ }
837
+ else {
838
+ await runCommandWithEnv(launcher.repair.command, launcher.repair.args, env);
839
+ }
840
+ if (!(await executableExists(launcher.repair.whenMissing, env))) {
841
+ throw new CliError(`MCP repair completed but ${launcher.repair.whenMissing} is still not on PATH.`, 1);
842
+ }
843
+ }
844
+ async function installFffMcp(env) {
845
+ const target = fffReleaseTarget();
846
+ const releasesResponse = await fetch("https://api.github.com/repos/dmtrKovalenko/fff.nvim/releases");
847
+ if (!releasesResponse.ok) {
848
+ throw new CliError(`Failed to fetch FFF MCP releases: HTTP ${releasesResponse.status}.`, 1);
849
+ }
850
+ const releases = await releasesResponse.json();
851
+ const assetName = `fff-mcp-${target}${platform() === "win32" ? ".exe" : ""}`;
852
+ const release = releases.find((item) => item.assets?.some((asset) => asset.name === assetName));
853
+ const asset = release?.assets?.find((item) => item.name === assetName);
854
+ if (!asset?.browser_download_url) {
855
+ throw new CliError(`No FFF MCP release asset found for ${target}.`, 1);
856
+ }
857
+ const binaryResponse = await fetch(asset.browser_download_url);
858
+ if (!binaryResponse.ok) {
859
+ throw new CliError(`Failed to download ${assetName}: HTTP ${binaryResponse.status}.`, 1);
860
+ }
861
+ const installHome = env.HOME ?? env.USERPROFILE ?? homedir();
862
+ const installDir = path.join(installHome, ".local", "bin");
863
+ const binaryPath = path.join(installDir, platform() === "win32" ? "fff-mcp.exe" : "fff-mcp");
864
+ await ensureDir(installDir);
865
+ await writeFileReplacingExisting(binaryPath, Buffer.from(await binaryResponse.arrayBuffer()), { mode: 0o755 });
866
+ if (platform() !== "win32")
867
+ await chmod(binaryPath, 0o755);
868
+ const pathValue = env.PATH ?? "";
869
+ if (!pathValue.split(path.delimiter).includes(installDir)) {
870
+ env.PATH = [installDir, pathValue].filter(Boolean).join(path.delimiter);
871
+ }
872
+ }
873
+ function fffReleaseTarget() {
874
+ const os = platform();
875
+ const arch = process.arch;
876
+ if (os === "linux") {
877
+ if (arch === "x64")
878
+ return "x86_64-unknown-linux-musl";
879
+ if (arch === "arm64")
880
+ return "aarch64-unknown-linux-musl";
881
+ }
882
+ if (os === "darwin") {
883
+ if (arch === "x64")
884
+ return "x86_64-apple-darwin";
885
+ if (arch === "arm64")
886
+ return "aarch64-apple-darwin";
887
+ }
888
+ if (os === "win32") {
889
+ if (arch === "x64")
890
+ return "x86_64-pc-windows-msvc";
891
+ if (arch === "arm64")
892
+ return "aarch64-pc-windows-msvc";
893
+ }
894
+ throw new CliError(`Unsupported FFF MCP platform: ${os}/${arch}.`, 1);
895
+ }
896
+ async function repairGitquarryEnvIfNeeded(name, env) {
897
+ if (name !== "gitquarry-mcp")
898
+ return;
899
+ const current = env.GITQUARRY_CLI_PATH;
900
+ if (current && await executableExists(current, env))
901
+ return;
902
+ if (!(await executableExists("gitquarry", env))) {
903
+ await runCommandWithEnv("npm", ["install", "-g", "gitquarry"], env);
904
+ }
905
+ const resolved = await resolveExecutable("gitquarry", env);
906
+ if (!resolved) {
907
+ throw new CliError("MCP repair could not find gitquarry after installing the gitquarry npm package.", 1);
908
+ }
909
+ env.GITQUARRY_CLI_PATH = resolved;
910
+ }
911
+ async function executableExists(command, env) {
912
+ return Boolean(await resolveExecutable(command, env));
913
+ }
914
+ async function resolveExecutable(command, env) {
915
+ if (isAbsoluteAnyPlatform(command) || command.includes(path.sep) || command.includes("/") || command.includes("\\")) {
916
+ return await pathExists(command) ? command : undefined;
917
+ }
918
+ const pathValue = env.PATH ?? process.env.PATH ?? "";
919
+ const extensions = platform() === "win32"
920
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
921
+ : [""];
922
+ for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
923
+ for (const extension of extensions) {
924
+ const candidate = path.join(dir, `${command}${extension}`);
925
+ if (await pathExists(candidate))
926
+ return candidate;
927
+ }
928
+ }
929
+ return undefined;
930
+ }
711
931
  async function copyDirectory(source, target) {
712
932
  await ensureDir(target);
713
933
  for (const entry of await readdir(source, { withFileTypes: true })) {
@@ -749,6 +969,23 @@ function runCommand(command, args) {
749
969
  });
750
970
  });
751
971
  }
972
+ function runCommandWithEnv(command, args, env) {
973
+ return new Promise((resolve, reject) => {
974
+ const child = spawn(command, args, { stdio: "inherit", env });
975
+ child.on("error", (error) => {
976
+ const message = error.code === "ENOENT"
977
+ ? `MCP launcher program not found: ${command}. Install it on this follower or add it to PATH.`
978
+ : asError(error).message;
979
+ reject(new CliError(message, 1));
980
+ });
981
+ child.on("exit", (code) => {
982
+ if (code === 0)
983
+ resolve();
984
+ else
985
+ reject(new CliError(`${command} ${args.join(" ")} exited with ${code}`, code ?? 1));
986
+ });
987
+ });
988
+ }
752
989
  async function installMasterService(ctx, port, dryRun) {
753
990
  const command = `${process.execPath} ${realpathSync(fileURLToPath(import.meta.url))} master serve --port ${port}`;
754
991
  if (platform() === "linux") {
@@ -1016,6 +1253,10 @@ async function main(argv) {
1016
1253
  program.command("apply")
1017
1254
  .description("Apply the last staged bundle.")
1018
1255
  .action(async (_options, command) => commandApply(contextFromCommand(command)));
1256
+ const mcp = program.command("mcp").description("Run managed follower MCP launchers.");
1257
+ mcp.command("run <name>")
1258
+ .description("Run a synced MCP through codexport's managed launcher.")
1259
+ .action(async (name, _options, command) => commandMcpRun(contextFromCommand(command), name));
1019
1260
  const hook = program.command("hook").description("Manage follower Codex hooks.");
1020
1261
  hook.command("install")
1021
1262
  .description("Install a follower-only Codex SessionStart sync hook.")
@@ -1047,4 +1288,4 @@ if (isCliEntrypoint()) {
1047
1288
  process.exit(exitCode);
1048
1289
  });
1049
1290
  }
1050
- export { applyBundle, buildBundle, buildJoinLink, computeRevision, defaultContext, installHook, mergeTomlText, parseJoinLink, verifyBundle };
1291
+ export { applyBundle, buildBundle, buildJoinLink, computeRevision, defaultContext, installHook, mergeTomlText, parseJoinLink, portableMcpLauncher, verifyBundle };
package/docs/prd.md CHANGED
@@ -161,10 +161,20 @@ should make them portable through:
161
161
  - path variables such as home and workspace root
162
162
  - secret transfer over the Tailscale bundle
163
163
  - local overlays for follower-specific additions
164
+ - a managed follower launcher that can repair known package, Python, binary,
165
+ and source-built MCPs
164
166
 
165
167
  Canonical MCP names are reserved. Local MCPs may add new names. A same-name
166
168
  local MCP should fail by default unless an explicit override is configured.
167
169
 
170
+ The product goal is full export, not best-effort pruning. Command MCPs should
171
+ remain present on followers even when their master command path is local to the
172
+ master. Followers should route command MCPs through `codexport mcp run <name>`,
173
+ store the original definitions in `~/.codexport/mcp-manifest.json`, and let the
174
+ managed launcher translate or repair the runtime. If an MCP cannot be repaired,
175
+ the failure should be explicit and actionable rather than silently disabling the
176
+ MCP.
177
+
168
178
  ### Skills
169
179
 
170
180
  Master skills are canonical and synced by default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexport",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "sync a canonical Codex setup from one master machine to follower machines",
5
5
  "author": "Microck <contact@micr.dev>",
6
6
  "license": "MIT",