auxilo-mcp 0.9.16 → 0.9.17

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/bin/auxilo-cli.js CHANGED
@@ -553,7 +553,20 @@ async function cmdStatus() {
553
553
  const reg = c.mcp
554
554
  ? (c.registered ? 'MCP registered' : 'detected, MCP NOT registered')
555
555
  : 'poll-based source (no MCP config)';
556
- console.log(` ${c.name}: ${reg}`);
556
+ // MCP-SERVER-STALENESS (0.9.17): show the pin written into this client's
557
+ // config alongside the installed runner version, so a stale pin (or a
558
+ // pre-0.9.17 unpinned entry) is visible without opening the config file.
559
+ let pinNote = '';
560
+ if (c.mcp && c.registered) {
561
+ if (c.mcpPin === 'unpinned') {
562
+ pinNote = ' (pin: unpinned — run `npx auxilo setup` to pin)';
563
+ } else if (c.mcpPin) {
564
+ pinNote = c.mcpPinStale
565
+ ? ` (pin: v${c.mcpPin}, runner: v${s.runnerVersion} — STALE, run \`npx auxilo setup\`)`
566
+ : ` (pin: v${c.mcpPin})`;
567
+ }
568
+ }
569
+ console.log(` ${c.name}: ${reg}${pinNote}`);
557
570
  }
558
571
 
559
572
  console.log(`Auth: ${s.auth.credentialsFile
package/lib/installer.js CHANGED
@@ -29,9 +29,37 @@ const { hasAuxiloSessionEndHook } = require('./hook-status.js');
29
29
 
30
30
  // ─── Constants ──────────────────────────────────────────────────────────────
31
31
 
32
- /** MCP registration entry written into every client config (spec §LW-12 step 1). */
32
+ /**
33
+ * MCP registration entry written into every client config (spec §LW-12
34
+ * step 1). MCP-SERVER-STALENESS (0.9.17): the entry is now PINNED to an
35
+ * exact installed version (`npx auxilo-mcp@<version>`) rather than the bare
36
+ * package name — an unpinned `npx auxilo-mcp` follows whatever npx's local
37
+ * cache happens to resolve `latest` to, which only refreshes when that
38
+ * cache expires (the INSTALL-0828 incident: a 10-week-old cached build
39
+ * served silently). MCP_ENTRY itself stays the UNPINNED template (base
40
+ * command + package name) — see mcpEntry(version) below for the pinned
41
+ * shape every writer actually uses.
42
+ */
33
43
  const MCP_ENTRY = Object.freeze({ command: 'npx', args: ['auxilo-mcp'] });
34
44
 
45
+ /** Bare package name portion of MCP_ENTRY.args[0], for pin construction/parsing. */
46
+ const MCP_PACKAGE_NAME = MCP_ENTRY.args[0];
47
+
48
+ /** True when `args` is shaped like an Auxilo-owned MCP entry (pinned or not). */
49
+ function isOwnedMcpArgs(args) {
50
+ return Array.isArray(args) && args.length === 1 && typeof args[0] === 'string' &&
51
+ (args[0] === MCP_PACKAGE_NAME || args[0].startsWith(`${MCP_PACKAGE_NAME}@`));
52
+ }
53
+
54
+ /**
55
+ * Parse the version pin (or 'unpinned') out of an owned args array, or null
56
+ * when `args` isn't a recognizable Auxilo entry at all.
57
+ */
58
+ function parseMcpPin(args) {
59
+ if (!isOwnedMcpArgs(args)) return null;
60
+ return args[0] === MCP_PACKAGE_NAME ? 'unpinned' : args[0].slice(MCP_PACKAGE_NAME.length + 1);
61
+ }
62
+
35
63
  /** Default production API base (README.md / openapi.json servers[0]). */
36
64
  const DEFAULT_BASE_URL = 'https://auxilo.io';
37
65
 
@@ -418,11 +446,66 @@ function detectClients(homeDir, opts = {}) {
418
446
 
419
447
  // ─── MCP registration (spec §LW-12 step 1) ──────────────────────────────────
420
448
 
449
+ /**
450
+ * F3 (0.9.17 fix pass): a unique `<file>.tmp-<pid>-<rand>` path for atomic
451
+ * config writers, matching the RUNNER_STACK convention (installRunnerAtomic
452
+ * above) instead of a fixed `<file>.tmp` name — two concurrent writers
453
+ * (e.g. `auxilo setup` and a self-update's rewriteMcpPins racing) no longer
454
+ * collide on the same tmp path and lose one writer's update.
455
+ */
456
+ function uniqueTmpPath(filePath) {
457
+ return `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
458
+ }
459
+
460
+ /**
461
+ * F3 (0.9.17 fix pass): sweep leftover `<file>.tmp-<pid>-<rand>` files from
462
+ * a writer that crashed or was killed mid-write, before starting a new
463
+ * write to the same path. Scoped tightly: only this exact file's own tmp
464
+ * prefix, only that file's own directory, and only entries older than an
465
+ * hour — never a tmp file another concurrent writer just created moments
466
+ * ago. Best-effort throughout; a sweep failure must never block the real
467
+ * write.
468
+ */
469
+ function sweepStaleTmpFiles(filePath) {
470
+ const dir = path.dirname(filePath);
471
+ const prefix = `${path.basename(filePath)}.tmp-`;
472
+ const cutoffMs = Date.now() - 60 * 60 * 1000;
473
+ let names;
474
+ try {
475
+ names = fs.readdirSync(dir);
476
+ } catch {
477
+ return; // dir missing/unreadable — nothing to sweep
478
+ }
479
+ for (const name of names) {
480
+ if (!name.startsWith(prefix)) continue;
481
+ const full = path.join(dir, name);
482
+ try {
483
+ if (fs.statSync(full).mtimeMs < cutoffMs) fs.rmSync(full, { force: true });
484
+ } catch { /* best-effort */ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * F2 (0.9.17 fix pass): the existing file's mode (owner/group/other bits
490
+ * only), or null when the file doesn't exist yet (format's own default
491
+ * applies via the umask, same as before this fix).
492
+ */
493
+ function existingMode(filePath) {
494
+ try {
495
+ return fs.statSync(filePath).mode & 0o777;
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
421
501
  /** Atomic JSON write: tmp + rename (same convention as mcp-server.js setup). */
422
502
  function writeJsonAtomic(filePath, obj) {
423
- const tmp = `${filePath}.tmp`;
424
503
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
504
+ sweepStaleTmpFiles(filePath);
505
+ const mode = existingMode(filePath);
506
+ const tmp = uniqueTmpPath(filePath);
425
507
  fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
508
+ if (mode !== null) fs.chmodSync(tmp, mode); // F2: preserve the existing file's mode across re-pin
426
509
  fs.renameSync(tmp, filePath);
427
510
  }
428
511
 
@@ -443,9 +526,15 @@ function readClientConfig(configPath) {
443
526
  }
444
527
  }
445
528
 
446
- /** Fresh copy of the standard MCP server entry (never share the frozen one). */
447
- function mcpEntry() {
448
- return { command: MCP_ENTRY.command, args: [...MCP_ENTRY.args] };
529
+ /**
530
+ * Fresh MCP server entry object (never share the frozen MCP_ENTRY).
531
+ * MCP-SERVER-STALENESS (0.9.17): when `version` is given, the package spec
532
+ * is pinned EXACT (`auxilo-mcp@<version>`); omitted/falsy keeps the legacy
533
+ * unpinned shape (only used by parity checks below — every real writer call
534
+ * always passes a version, defaulting to packageVersion() in registerMcp).
535
+ */
536
+ function mcpEntry(version) {
537
+ return { command: MCP_ENTRY.command, args: [version ? `${MCP_PACKAGE_NAME}@${version}` : MCP_PACKAGE_NAME] };
449
538
  }
450
539
 
451
540
  /** Result helpers shared by every registerMcp format writer. */
@@ -456,12 +545,45 @@ function registeredResult(configPath) {
456
545
  return { changed: true, status: 'registered', configPath };
457
546
  }
458
547
 
459
- /** Default writer: Claude-Desktop-style `mcpServers` object (most clients). */
460
- function registerJsonMcpServers(client) {
548
+ /**
549
+ * Default writer: Claude-Desktop-style `mcpServers` object (most clients;
550
+ * also Continue.dev's drop-in file, which shares the identical shape — see
551
+ * registerJsonDropin below).
552
+ *
553
+ * MCP-SERVER-STALENESS (0.9.17): now UPSERTS instead of pure create-once.
554
+ * An absent entry is created pinned to `version`. An EXISTING entry is
555
+ * re-pinned only when it is recognizably OURS (isOwnedMcpArgs — command
556
+ * 'npx', a single `auxilo-mcp`/`auxilo-mcp@X.Y.Z` arg) and the pin differs;
557
+ * a foreign/hand-edited entry sharing the `auxilo` key is left completely
558
+ * untouched (never overwritten). F1 (0.9.17 fix pass): an owned entry is
559
+ * re-pinned by patching `command`/`args` IN PLACE on the existing object,
560
+ * not by replacing it wholesale — any other keys the user added to their
561
+ * own Auxilo entry (env, disabled, timeout, alwaysAllow, cwd, …) and their
562
+ * key order survive both `auxilo setup` re-runs and the self-update rewrite
563
+ * path, same as the foreign-entry case above.
564
+ */
565
+ function registerJsonMcpServers(client, version) {
461
566
  const config = readClientConfig(client.configPath);
462
- if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
463
- if (!config.mcpServers) config.mcpServers = {};
464
- config.mcpServers.auxilo = mcpEntry();
567
+ const existing = config.mcpServers && config.mcpServers.auxilo;
568
+ if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
569
+ return unchangedResult(client.configPath); // foreign entry — never touch
570
+ }
571
+ const target = mcpEntry(version);
572
+ if (existing && existing.command === target.command &&
573
+ JSON.stringify(existing.args) === JSON.stringify(target.args)) {
574
+ return unchangedResult(client.configPath); // already pinned correctly
575
+ }
576
+ if (existing) {
577
+ // F1 (0.9.17 fix pass): patch command/args IN PLACE on the existing
578
+ // object instead of replacing it wholesale — any other keys the user
579
+ // added to their own Auxilo entry (env, disabled, timeout, alwaysAllow,
580
+ // cwd, …) survive the re-pin, and existing key order is preserved.
581
+ existing.command = target.command;
582
+ existing.args = target.args;
583
+ } else {
584
+ if (!config.mcpServers) config.mcpServers = {};
585
+ config.mcpServers.auxilo = target;
586
+ }
465
587
  writeJsonAtomic(client.configPath, config);
466
588
  return registeredResult(client.configPath);
467
589
  }
@@ -469,60 +591,133 @@ function registerJsonMcpServers(client) {
469
591
  /** TOML section header used for Codex idempotency (substring probe — no TOML dep). */
470
592
  const CODEX_SECTION_HEADER = '[mcp_servers.auxilo]';
471
593
 
594
+ /** Bound our `[mcp_servers.auxilo]` section: header .. next top-level `[` at line-start, or EOF. */
595
+ function codexSectionBounds(raw, headerIdx) {
596
+ const afterHeader = headerIdx + CODEX_SECTION_HEADER.length;
597
+ const nextHeaderMatch = raw.slice(afterHeader).match(/\n\[/);
598
+ const end = nextHeaderMatch ? afterHeader + nextHeaderMatch.index : raw.length;
599
+ return { start: headerIdx, end };
600
+ }
601
+
472
602
  /**
473
- * Codex CLI writer: APPEND-ONLY into ~/.codex/config.toml. Existing TOML is
474
- * never parsed, rewritten, or reformatted we only ever append our own
475
- * section, and only when the `[mcp_servers.auxilo]` header is absent.
603
+ * Codex CLI writer: APPEND-ONLY into ~/.codex/config.toml when our section
604
+ * is absent. Existing TOML is never parsed or reformatted as a whole file
605
+ * MCP-SERVER-STALENESS (0.9.17): when our section IS present, ONLY its own
606
+ * `args = [...]` line is rewritten (via targeted string replace, scoped to
607
+ * our section's bounds so an unrelated `[mcp_servers.other]` table sharing
608
+ * the same key names is never touched), and only when that line is
609
+ * recognizably ours (isOwnedMcpArgs) — a hand-edited section is left
610
+ * byte-identical. Every other byte of the file, including comments and
611
+ * formatting inside or outside our section, survives untouched.
476
612
  */
477
- function registerCodexToml(client) {
613
+ function registerCodexToml(client, version) {
478
614
  const raw = fs.existsSync(client.configPath)
479
615
  ? fs.readFileSync(client.configPath, 'utf-8')
480
616
  : '';
481
- if (raw.includes(CODEX_SECTION_HEADER)) return unchangedResult(client.configPath);
617
+ const target = mcpEntry(version);
618
+ const headerIdx = raw.indexOf(CODEX_SECTION_HEADER);
619
+
620
+ if (headerIdx === -1) {
621
+ const section = `\n${CODEX_SECTION_HEADER}\n` +
622
+ `command = ${JSON.stringify(target.command)}\n` +
623
+ `args = ${JSON.stringify(target.args)}\n`;
624
+ fs.mkdirSync(path.dirname(client.configPath), { recursive: true });
625
+ sweepStaleTmpFiles(client.configPath);
626
+ const mode = existingMode(client.configPath);
627
+ const tmp = uniqueTmpPath(client.configPath);
628
+ fs.writeFileSync(tmp, raw + section);
629
+ if (mode !== null) fs.chmodSync(tmp, mode); // F2: preserve existing file's mode
630
+ fs.renameSync(tmp, client.configPath);
631
+ return registeredResult(client.configPath);
632
+ }
482
633
 
483
- const section = `\n${CODEX_SECTION_HEADER}\n` +
484
- `command = ${JSON.stringify(MCP_ENTRY.command)}\n` +
485
- `args = ${JSON.stringify(MCP_ENTRY.args)}\n`;
634
+ const { start, end } = codexSectionBounds(raw, headerIdx);
635
+ const section = raw.slice(start, end);
636
+ const cmdMatch = section.match(/^command = (".*")$/m);
637
+ const argsMatch = section.match(/^args = (\[.*\])$/m);
638
+ let currentCommand = null;
639
+ let currentArgs = null;
640
+ try {
641
+ currentCommand = cmdMatch ? JSON.parse(cmdMatch[1]) : null;
642
+ currentArgs = argsMatch ? JSON.parse(argsMatch[1]) : null;
643
+ } catch { /* malformed line — treated as not-owned below */ }
486
644
 
487
- const tmp = `${client.configPath}.tmp`;
488
- fs.mkdirSync(path.dirname(client.configPath), { recursive: true });
489
- fs.writeFileSync(tmp, raw + section);
645
+ const owned = currentCommand === target.command && isOwnedMcpArgs(currentArgs);
646
+ if (!owned) return unchangedResult(client.configPath); // foreign/hand-edited section — never touch
647
+
648
+ if (JSON.stringify(currentArgs) === JSON.stringify(target.args)) {
649
+ return unchangedResult(client.configPath); // already pinned correctly
650
+ }
651
+
652
+ const newSection = section.replace(/^args = \[.*\]$/m, `args = ${JSON.stringify(target.args)}`);
653
+ const newRaw = raw.slice(0, start) + newSection + raw.slice(end);
654
+ sweepStaleTmpFiles(client.configPath);
655
+ const mode = existingMode(client.configPath);
656
+ const tmp = uniqueTmpPath(client.configPath);
657
+ fs.writeFileSync(tmp, newRaw);
658
+ if (mode !== null) fs.chmodSync(tmp, mode); // F2: preserve existing file's mode
490
659
  fs.renameSync(tmp, client.configPath);
491
660
  return registeredResult(client.configPath);
492
661
  }
493
662
 
494
- /** Continue.dev writer: standalone drop-in file under ~/.continue/mcpServers/. */
495
- function registerJsonDropin(client) {
496
- const config = readClientConfig(client.configPath);
497
- if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
498
- if (!config.mcpServers) config.mcpServers = {};
499
- config.mcpServers.auxilo = mcpEntry();
500
- writeJsonAtomic(client.configPath, config);
501
- return registeredResult(client.configPath);
663
+ /** Continue.dev writer: standalone drop-in file under ~/.continue/mcpServers/ — identical shape to registerJsonMcpServers. */
664
+ function registerJsonDropin(client, version) {
665
+ return registerJsonMcpServers(client, version);
502
666
  }
503
667
 
504
668
  /** opencode writer: `mcp` key, entry shape {type:'local', command:[bin,...args]}. */
505
- function registerOpencode(client) {
669
+ function registerOpencode(client, version) {
506
670
  const config = readClientConfig(client.configPath);
507
- if (config.mcp && config.mcp.auxilo) return unchangedResult(client.configPath);
508
- if (!config.mcp) config.mcp = {};
509
- config.mcp.auxilo = { type: 'local', command: [MCP_ENTRY.command, ...MCP_ENTRY.args] };
671
+ const existing = config.mcp && config.mcp.auxilo;
672
+ const existingArgs = existing && Array.isArray(existing.command) ? existing.command.slice(1) : null;
673
+ const existingOwned = existing && existing.type === 'local' &&
674
+ Array.isArray(existing.command) && existing.command[0] === MCP_ENTRY.command &&
675
+ isOwnedMcpArgs(existingArgs);
676
+ if (existing && !existingOwned) return unchangedResult(client.configPath); // foreign entry — never touch
677
+
678
+ const targetCommand = [MCP_ENTRY.command, ...mcpEntry(version).args];
679
+ if (existing && JSON.stringify(existing.command) === JSON.stringify(targetCommand)) {
680
+ return unchangedResult(client.configPath); // already pinned correctly
681
+ }
682
+ if (existing) {
683
+ // F1 (0.9.17 fix pass): patch command IN PLACE — preserves any other
684
+ // keys the user added to their entry (env, cwd, …) and key order.
685
+ existing.command = targetCommand;
686
+ } else {
687
+ if (!config.mcp) config.mcp = {};
688
+ config.mcp.auxilo = { type: 'local', command: targetCommand };
689
+ }
510
690
  writeJsonAtomic(client.configPath, config);
511
691
  return registeredResult(client.configPath);
512
692
  }
513
693
 
514
694
  /** Amp writer: settings.json flat `amp.mcpServers` key (an object of servers). */
515
- function registerAmp(client) {
695
+ function registerAmp(client, version) {
516
696
  const config = readClientConfig(client.configPath);
517
697
  const servers = config['amp.mcpServers'];
518
- if (servers && servers.auxilo) return unchangedResult(client.configPath);
519
- config['amp.mcpServers'] = { ...(servers || {}), auxilo: mcpEntry() };
698
+ const existing = servers && servers.auxilo;
699
+ if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
700
+ return unchangedResult(client.configPath); // foreign entry — never touch
701
+ }
702
+ const target = mcpEntry(version);
703
+ if (existing && existing.command === target.command &&
704
+ JSON.stringify(existing.args) === JSON.stringify(target.args)) {
705
+ return unchangedResult(client.configPath); // already pinned correctly
706
+ }
707
+ if (existing) {
708
+ // F1 (0.9.17 fix pass): patch command/args IN PLACE — preserves any
709
+ // other keys the user added to their entry and key order.
710
+ existing.command = target.command;
711
+ existing.args = target.args;
712
+ } else {
713
+ config['amp.mcpServers'] = { ...(servers || {}), auxilo: target };
714
+ }
520
715
  writeJsonAtomic(client.configPath, config);
521
716
  return registeredResult(client.configPath);
522
717
  }
523
718
 
524
719
  /** OpenHands writer: `stdio_servers` ARRAY of {name, command, args}, keyed by name. */
525
- function registerOpenHands(client) {
720
+ function registerOpenHands(client, version) {
526
721
  const config = readClientConfig(client.configPath);
527
722
  // GOV-3 M1: a non-array stdio_servers means the schema isn't what we expect
528
723
  // (drift, or an object map) — skip loudly rather than clobbering the user's
@@ -531,8 +726,26 @@ function registerOpenHands(client) {
531
726
  throw new Error(`Unexpected stdio_servers shape in ${client.configPath} (not an array) — add the Auxilo entry manually`);
532
727
  }
533
728
  const servers = config.stdio_servers || [];
534
- if (servers.some((s) => s && s.name === 'auxilo')) return unchangedResult(client.configPath);
535
- config.stdio_servers = [...servers, { name: 'auxilo', ...mcpEntry() }];
729
+ const idx = servers.findIndex((s) => s && s.name === 'auxilo');
730
+ const existing = idx === -1 ? null : servers[idx];
731
+ if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
732
+ return unchangedResult(client.configPath); // foreign entry — never touch
733
+ }
734
+ const target = mcpEntry(version);
735
+ if (existing && existing.command === target.command &&
736
+ JSON.stringify(existing.args) === JSON.stringify(target.args)) {
737
+ return unchangedResult(client.configPath); // already pinned correctly
738
+ }
739
+ if (existing) {
740
+ // F1 (0.9.17 fix pass): patch command/args IN PLACE — preserves any
741
+ // other keys the user added to their entry (env, cwd, …) and array
742
+ // order/identity (existing === servers[idx] === config.stdio_servers[idx]).
743
+ existing.command = target.command;
744
+ existing.args = target.args;
745
+ config.stdio_servers = servers;
746
+ } else {
747
+ config.stdio_servers = [...servers, { name: 'auxilo', ...target }];
748
+ }
536
749
  writeJsonAtomic(client.configPath, config);
537
750
  return registeredResult(client.configPath);
538
751
  }
@@ -548,17 +761,28 @@ const MCP_WRITERS = Object.freeze({
548
761
  });
549
762
 
550
763
  /**
551
- * Register the Auxilo MCP server in one client's config file.
764
+ * Register (or re-pin) the Auxilo MCP server in one client's config file.
552
765
  * Dispatches on the client's `format` (default 'json-mcpServers'). All JSON
553
- * writers are read-modify-write with tmp+rename; existing keys preserved;
554
- * no-op (file untouched) when the auxilo entry is already present. The Codex
555
- * TOML writer is append-only and never touches existing content.
766
+ * writers are read-modify-write with tmp+rename; existing non-Auxilo keys
767
+ * preserved untouched; no-op (file byte-identical) when the auxilo entry is
768
+ * already pinned to `version`. An existing entry that doesn't look like ours
769
+ * (isOwnedMcpArgs) is left completely alone — this function only ever
770
+ * creates a fresh entry or re-pins one it recognizes as its own. The Codex
771
+ * TOML writer is append-only for a fresh section, and rewrites only its own
772
+ * `args =` line for an existing owned one.
773
+ *
774
+ * MCP-SERVER-STALENESS (0.9.17): `version` defaults to packageVersion() (the
775
+ * currently running package's own version) so every call — interactive
776
+ * setup and the programmatic rewriteMcpPins() path alike — pins to an EXACT
777
+ * version rather than writing the old bare `auxilo-mcp` (which npx resolves
778
+ * to whatever `latest` its local cache currently holds).
556
779
  *
557
780
  * @param {object} client Entry from clientRegistry (must have configPath).
781
+ * @param {string} [version] Exact version to pin; defaults to packageVersion().
558
782
  * @returns {{ changed: boolean, status: 'registered'|'already-registered', configPath: string }}
559
783
  * @throws on malformed existing JSON (B15 — caller skips that client loudly)
560
784
  */
561
- function registerMcp(client) {
785
+ function registerMcp(client, version) {
562
786
  if (!client || !client.configPath) {
563
787
  throw new Error(`registerMcp: client ${client && client.id} has no MCP config path`);
564
788
  }
@@ -566,7 +790,7 @@ function registerMcp(client) {
566
790
  if (!writer) {
567
791
  throw new Error(`registerMcp: client ${client.id} has unknown config format "${client.format}"`);
568
792
  }
569
- return writer(client);
793
+ return writer(client, version || packageVersion());
570
794
  }
571
795
 
572
796
  /**
@@ -596,6 +820,85 @@ function mcpRegistrationPresent(client) {
596
820
  }
597
821
  }
598
822
 
823
+ /**
824
+ * Read the pin currently written in a client's config: an exact version
825
+ * string, `'unpinned'` (legacy bare `auxilo-mcp` entry, pre-0.9.17), or
826
+ * `null` when not registered / not an owned shape / config unreadable.
827
+ * Read-only, never throws (status must never throw on local probes).
828
+ */
829
+ function mcpPinnedVersion(client) {
830
+ try {
831
+ if (client.format === 'toml-codex') {
832
+ const raw = fs.readFileSync(client.configPath, 'utf-8');
833
+ const headerIdx = raw.indexOf(CODEX_SECTION_HEADER);
834
+ if (headerIdx === -1) return null;
835
+ const { start, end } = codexSectionBounds(raw, headerIdx);
836
+ const argsMatch = raw.slice(start, end).match(/^args = (\[.*\])$/m);
837
+ return argsMatch ? parseMcpPin(JSON.parse(argsMatch[1])) : null;
838
+ }
839
+ const config = JSON.parse(fs.readFileSync(client.configPath, 'utf-8'));
840
+ switch (client.format || 'json-mcpServers') {
841
+ case 'opencode': {
842
+ const entry = config.mcp && config.mcp.auxilo;
843
+ return entry && Array.isArray(entry.command) ? parseMcpPin(entry.command.slice(1)) : null;
844
+ }
845
+ case 'amp': {
846
+ const entry = config['amp.mcpServers'] && config['amp.mcpServers'].auxilo;
847
+ return entry ? parseMcpPin(entry.args) : null;
848
+ }
849
+ case 'openhands-stdio': {
850
+ const entry = Array.isArray(config.stdio_servers) &&
851
+ config.stdio_servers.find((s) => s && s.name === 'auxilo');
852
+ return entry ? parseMcpPin(entry.args) : null;
853
+ }
854
+ default: {
855
+ const entry = config.mcpServers && config.mcpServers.auxilo;
856
+ return entry ? parseMcpPin(entry.args) : null;
857
+ }
858
+ }
859
+ } catch {
860
+ return null;
861
+ }
862
+ }
863
+
864
+ /**
865
+ * MCP-SERVER-STALENESS (0.9.17): rewrite the Auxilo MCP pin to `version` in
866
+ * every DETECTED client whose config ALREADY carries a registered Auxilo
867
+ * entry. Called by lib/runner-autoupdate.js's stageAndSwap after a
868
+ * successful self-update, so a client's `npx auxilo-mcp@<pin>` tracks the
869
+ * runner version the self-update just installed instead of drifting stale
870
+ * until the user re-runs `auxilo setup` by hand.
871
+ *
872
+ * Deliberately NEVER registers a client that isn't already registered —
873
+ * that would silently opt a client the user never selected during
874
+ * interactive `auxilo setup` into MCP registration from the unattended
875
+ * self-update path. Reuses registerMcp/MCP_WRITERS, so the same ownership
876
+ * check applies: an existing entry that isn't recognizably ours
877
+ * (isOwnedMcpArgs) is left completely untouched, byte-identical.
878
+ *
879
+ * @param {string} homeDir
880
+ * @param {string} version Target pin (the just-installed package version).
881
+ * @param {object} [opts] Forwarded to detectClients (platform/env for tests).
882
+ * @returns {Array<{id:string, name:string, configPath:string, changed:boolean, status:string, error?:string}>}
883
+ */
884
+ function rewriteMcpPins(homeDir, version, opts = {}) {
885
+ if (!homeDir) throw new Error('rewriteMcpPins: homeDir is required');
886
+ if (!version) throw new Error('rewriteMcpPins: version is required');
887
+ const results = [];
888
+ for (const client of detectClients(homeDir, opts).filter((c) => c.mcp)) {
889
+ if (!mcpRegistrationPresent(client)) continue; // not already registered — never first-register here
890
+ try {
891
+ const result = registerMcp(client, version);
892
+ results.push({ id: client.id, name: client.name, ...result });
893
+ } catch (err) {
894
+ // B15 discipline: malformed config — skip loudly, never overwrite,
895
+ // and never let one bad client config abort the rest of the rewrite.
896
+ results.push({ id: client.id, name: client.name, configPath: client.configPath, changed: false, status: 'error', error: err.message });
897
+ }
898
+ }
899
+ return results;
900
+ }
901
+
599
902
  // ─── Credentials (spec §LW-12 step 2) ───────────────────────────────────────
600
903
 
601
904
  function credentialsPath(homeDir) {
@@ -1837,11 +2140,22 @@ async function getStatus(homeDir, opts = {}) {
1837
2140
  if (!homeDir) throw new Error('getStatus: homeDir is required');
1838
2141
  const fetchImpl = opts.fetchImpl || fetch;
1839
2142
 
2143
+ // MCP-SERVER-STALENESS (0.9.17): installed runner version, for the
2144
+ // per-client pin-vs-runner comparison below. null when no runner is
2145
+ // installed yet (pre-setup) — mcpPinStale then stays false for everyone,
2146
+ // same as when the pin can't be read at all.
2147
+ const runnerVersion = installedRunnerVersion(homeDir);
2148
+
1840
2149
  // 1. Clients: detected + whether MCP registration is present
1841
2150
  const clients = detectClients(homeDir, opts).map((c) => {
1842
2151
  // null = not applicable (no MCP config); per-format probe otherwise (UC-0).
1843
2152
  const registered = c.mcp ? mcpRegistrationPresent(c) : null;
1844
2153
  const base = { id: c.id, name: c.name, mcp: c.mcp, registered };
2154
+ if (c.mcp && registered) {
2155
+ const pin = mcpPinnedVersion(c);
2156
+ base.mcpPin = pin; // exact version string, 'unpinned' (legacy), or null (foreign entry)
2157
+ base.mcpPinStale = Boolean(pin && pin !== 'unpinned' && runnerVersion && pin !== runnerVersion);
2158
+ }
1845
2159
  // UC-1: capture-hook probe (shim exists AND hook config references it).
1846
2160
  if (c.captureHook) {
1847
2161
  base.captureHook = true;
@@ -1915,6 +2229,7 @@ async function getStatus(homeDir, opts = {}) {
1915
2229
  accountMode,
1916
2230
  sentinel: sentinelPresent(homeDir),
1917
2231
  runnerInstalled,
2232
+ runnerVersion,
1918
2233
  hookInstalled,
1919
2234
  hookScriptInstalled: fs.existsSync(hookPath),
1920
2235
  hookRegistered,
@@ -2005,6 +2320,12 @@ module.exports = {
2005
2320
  detectClients,
2006
2321
  registerMcp,
2007
2322
  mcpRegistrationPresent,
2323
+ mcpPinnedVersion,
2324
+ rewriteMcpPins,
2325
+ writeJsonAtomic,
2326
+ isOwnedMcpArgs,
2327
+ parseMcpPin,
2328
+ MCP_PACKAGE_NAME,
2008
2329
  readClientConfig,
2009
2330
  RULES_MARKER_BEGIN,
2010
2331
  RULES_MARKER_END,
@@ -609,6 +609,27 @@ function stageAndSwap(homeDir, extractedPackageDir, installer) {
609
609
  }
610
610
  }
611
611
 
612
+ // MCP-SERVER-STALENESS (0.9.17): the runner stack install above just
613
+ // stamped binRoot/VERSION = result.version — rewrite every ALREADY
614
+ // REGISTERED client's Auxilo MCP pin to match, so `npx auxilo-mcp@<pin>`
615
+ // tracks the runner version this self-update just installed instead of
616
+ // drifting stale until the user re-runs `auxilo setup` by hand (the
617
+ // INSTALL-0828 vector, one level up the stack: an unpinned entry only
618
+ // refreshed when npx's own cache happened to expire).
619
+ //
620
+ // Uses the EXTRACTED tree's own installer (same B2 reasoning as
621
+ // installRunnerAtomic above — a newer release may register a client
622
+ // format this running copy doesn't know about yet) and is a BEST-EFFORT
623
+ // step: the runner swap itself already succeeded by this point and must
624
+ // not be reported as failed over a client-config quirk. Optional
625
+ // capability — an extracted tree that predates this function (or a test
626
+ // fixture's minimal installer stub) simply skips the rewrite.
627
+ if (typeof extractedInstaller.rewriteMcpPins === 'function') {
628
+ try {
629
+ extractedInstaller.rewriteMcpPins(homeDir, result.version);
630
+ } catch { /* best-effort — the runner swap itself already succeeded */ }
631
+ }
632
+
612
633
  return binRoot;
613
634
  }
614
635
 
package/mcp-server.js CHANGED
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
198
198
  }
199
199
 
200
200
  const server = new Server(
201
- { name: 'auxilo', version: '0.9.16' },
201
+ { name: 'auxilo', version: '0.9.17' },
202
202
  {
203
203
  capabilities: { tools: {} },
204
204
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -1037,6 +1037,25 @@ if (process.argv[2] === 'login') {
1037
1037
  }
1038
1038
  })();
1039
1039
  } else {
1040
+ // MCP-SERVER-STALENESS (0.9.17): ONE stderr notice, printed once at
1041
+ // startup, when this running package's own version differs from the
1042
+ // runner version installer.js last stamped into ~/.auxilo/bin/VERSION —
1043
+ // e.g. this process is a stale cached `npx auxilo-mcp` build while the
1044
+ // runner has already self-updated past it (or vice versa, right after a
1045
+ // fresh `auxilo setup`). No network call, no behaviour change — advisory
1046
+ // only, and silent when the VERSION stamp is absent (no runner installed
1047
+ // yet) or matches.
1048
+ try {
1049
+ const installer = require('./lib/installer.js');
1050
+ const ownVersion = installer.packageVersion();
1051
+ const installedVersion = installer.installedRunnerVersion(os.homedir());
1052
+ if (installedVersion && installedVersion !== ownVersion) {
1053
+ console.error(
1054
+ `[auxilo-mcp] Note: this MCP server is running v${ownVersion}, but the installed runner is v${installedVersion}. Run \`npx auxilo setup\` to re-pin.`
1055
+ );
1056
+ }
1057
+ } catch { /* advisory only — must never block startup */ }
1058
+
1040
1059
  // Normal MCP server startup
1041
1060
  async function main() {
1042
1061
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.16",
3
+ "version": "0.9.17",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",