lensmcp 1.17.4 → 1.18.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.
@@ -17435,9 +17435,14 @@ var MAX_ERRORS = 50;
17435
17435
  var STATE_BY_KIND = {
17436
17436
  "service-starting": "starting",
17437
17437
  "service-respawn": "starting",
17438
+ // A lens frontend's vite has been SPAWNED — started, not yet proven to serve.
17439
+ // The gateway's liveness probe emits `service-up` once its port accepts, so
17440
+ // this must claim `starting` and never `up`.
17441
+ "lens-app-up": "starting",
17438
17442
  "service-up": "up",
17439
17443
  "cold-start": "up",
17440
17444
  "service-down": "down",
17445
+ "idle-kill": "idle",
17441
17446
  "service-recycle": "wedged",
17442
17447
  "pod-recycle": "wedged",
17443
17448
  "service-crash-loop": "crash-looping"
@@ -17494,6 +17499,7 @@ var ClusterHealth = class {
17494
17499
  down: byState("down"),
17495
17500
  wedged: byState("wedged"),
17496
17501
  crashLooping: byState("crash-looping"),
17502
+ idle: byState("idle"),
17497
17503
  buildFailed: services.filter((s) => s.buildFailed).map((s) => s.project),
17498
17504
  restarts: services.reduce((n, s) => n + s.restarts, 0),
17499
17505
  errors: this.errors.slice()
package/bundled/main.js CHANGED
@@ -17437,9 +17437,14 @@ var MAX_ERRORS = 50;
17437
17437
  var STATE_BY_KIND = {
17438
17438
  "service-starting": "starting",
17439
17439
  "service-respawn": "starting",
17440
+ // A lens frontend's vite has been SPAWNED — started, not yet proven to serve.
17441
+ // The gateway's liveness probe emits `service-up` once its port accepts, so
17442
+ // this must claim `starting` and never `up`.
17443
+ "lens-app-up": "starting",
17440
17444
  "service-up": "up",
17441
17445
  "cold-start": "up",
17442
17446
  "service-down": "down",
17447
+ "idle-kill": "idle",
17443
17448
  "service-recycle": "wedged",
17444
17449
  "pod-recycle": "wedged",
17445
17450
  "service-crash-loop": "crash-looping"
@@ -17496,6 +17501,7 @@ var ClusterHealth = class {
17496
17501
  down: byState("down"),
17497
17502
  wedged: byState("wedged"),
17498
17503
  crashLooping: byState("crash-looping"),
17504
+ idle: byState("idle"),
17499
17505
  buildFailed: services.filter((s) => s.buildFailed).map((s) => s.project),
17500
17506
  restarts: services.reduce((n, s) => n + s.restarts, 0),
17501
17507
  errors: this.errors.slice()
@@ -21901,6 +21907,92 @@ function watchParentGateway(label) {
21901
21907
  timer.unref();
21902
21908
  }
21903
21909
 
21910
+ // servers/lensmcp-mcp/dist/shim/workspace.js
21911
+ import { existsSync as existsSync7, readFileSync as readFileSync5, realpathSync } from "node:fs";
21912
+ import { basename, dirname as dirname5, join as join5 } from "node:path";
21913
+ var BASE_PORTS = { dashboard: 4321, mcpHttp: 4500, bridge: 5747 };
21914
+ function readJson(path) {
21915
+ try {
21916
+ return JSON.parse(readFileSync5(path, "utf8"));
21917
+ } catch {
21918
+ return void 0;
21919
+ }
21920
+ }
21921
+ function hashToRange(s, range) {
21922
+ let h = 2166136261;
21923
+ for (let i = 0; i < s.length; i++) {
21924
+ h ^= s.charCodeAt(i);
21925
+ h = Math.imul(h, 16777619);
21926
+ }
21927
+ return Math.abs(h | 0) % range;
21928
+ }
21929
+ function slugify2(input) {
21930
+ return input.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "workspace";
21931
+ }
21932
+ function resolveWorkspaceKey(root) {
21933
+ const nx = readJson(join5(root, "nx.json"));
21934
+ if (nx?.lensmcp?.key)
21935
+ return slugify2(nx.lensmcp.key);
21936
+ const pkg = readJson(join5(root, "package.json"));
21937
+ if (pkg?.name)
21938
+ return slugify2(pkg.name);
21939
+ if (nx?.name)
21940
+ return slugify2(nx.name);
21941
+ return slugify2(basename(root));
21942
+ }
21943
+ function deriveMcpHttpPort(key) {
21944
+ return BASE_PORTS.mcpHttp + hashToRange(key, 200);
21945
+ }
21946
+ function findWorkspaceRoot(start) {
21947
+ let nxFallback;
21948
+ let dir = start;
21949
+ for (; ; ) {
21950
+ if (existsSync7(join5(dir, ".lensmcp", "config.json")))
21951
+ return dir;
21952
+ if (nxFallback === void 0 && existsSync7(join5(dir, "nx.json")))
21953
+ nxFallback = dir;
21954
+ const parent = dirname5(dir);
21955
+ if (parent === dir)
21956
+ break;
21957
+ dir = parent;
21958
+ }
21959
+ return nxFallback ?? start;
21960
+ }
21961
+ function resolveWorkspaceScope(start, env = process.env) {
21962
+ const root = findWorkspaceRoot(start);
21963
+ const file2 = readJson(join5(root, ".lensmcp", "config.json"));
21964
+ const key = file2?.key ? slugify2(file2.key) : resolveWorkspaceKey(root);
21965
+ const mcpHttpPort = typeof file2?.ports?.mcpHttp === "number" ? file2.ports.mcpHttp : deriveMcpHttpPort(key);
21966
+ return {
21967
+ root,
21968
+ key,
21969
+ mcpHttpPort,
21970
+ eventFile: env["LENSMCP_EVENT_FILE"] ?? join5(root, ".lensmcp", "events.jsonl")
21971
+ };
21972
+ }
21973
+ var IDENTITY_PATH = "/lensmcp/identity";
21974
+ var IDENTITY_KIND = "lensmcp-mcp-identity";
21975
+ function canonicalRoot(path) {
21976
+ try {
21977
+ return realpathSync(path);
21978
+ } catch {
21979
+ return path;
21980
+ }
21981
+ }
21982
+ function resolveServerIdentity(env = process.env, cwd = process.cwd()) {
21983
+ const scope = resolveWorkspaceScope(cwd, env);
21984
+ const root = canonicalRoot(env["LENSMCP_WORKSPACE_ROOT"] ?? scope.root);
21985
+ const key = env["LENSMCP_WORKSPACE_KEY"] ? slugify2(env["LENSMCP_WORKSPACE_KEY"]) : env["LENSMCP_WORKSPACE_ROOT"] ? resolveWorkspaceScope(root, env).key : scope.key;
21986
+ return {
21987
+ kind: IDENTITY_KIND,
21988
+ key,
21989
+ root,
21990
+ eventFile: scope.eventFile,
21991
+ pid: process.pid,
21992
+ port: env["LENSMCP_PORT"] ? Number(env["LENSMCP_PORT"]) : void 0
21993
+ };
21994
+ }
21995
+
21904
21996
  // servers/lensmcp-mcp/dist/main.js
21905
21997
  watchParentGateway("mcp");
21906
21998
  var { session, providers } = createLensSession();
@@ -21950,7 +22042,25 @@ if (transport === "stdio") {
21950
22042
  });
21951
22043
  } else {
21952
22044
  const entryPath = process.env["LENSMCP_HTTP_PATH"] ?? "/mcp";
21953
- const http = transport === "socket" ? { socketPath: process.env["LENSMCP_SOCKET_PATH"] ?? "/tmp/lensmcp-mcp.sock", entryPath } : { port: Number(process.env["LENSMCP_PORT"] ?? 3e3), entryPath };
22045
+ const identity = resolveServerIdentity();
22046
+ const identityRoute = {
22047
+ method: "GET",
22048
+ path: IDENTITY_PATH,
22049
+ handler: (_req, res) => {
22050
+ res.setHeader("content-type", "application/json");
22051
+ res.setHeader("cache-control", "no-store");
22052
+ res.end(JSON.stringify(identity));
22053
+ }
22054
+ };
22055
+ const http = transport === "socket" ? {
22056
+ socketPath: process.env["LENSMCP_SOCKET_PATH"] ?? "/tmp/lensmcp-mcp.sock",
22057
+ entryPath,
22058
+ routes: [identityRoute]
22059
+ } : {
22060
+ port: Number(process.env["LENSMCP_PORT"] ?? 3e3),
22061
+ entryPath,
22062
+ routes: [identityRoute]
22063
+ };
21954
22064
  void FrontMcpInstance.bootstrap({
21955
22065
  ...serverConfig,
21956
22066
  logging: { level: LogLevel.Info },
package/bundled/shim.js CHANGED
@@ -8485,8 +8485,17 @@ async function ensureSharedServer(deps) {
8485
8485
  const poll = deps.pollMs ?? READY_POLL_MS;
8486
8486
  const ttl = deps.lockTtlMs ?? SPAWN_LOCK_TTL_MS;
8487
8487
  const elapsed = () => deps.now() - started;
8488
+ const live = (outcome) => {
8489
+ const foreign = deps.verifyIdentity?.();
8490
+ if (foreign)
8491
+ return { outcome: "unavailable", reason: foreign, waitedMs: elapsed() };
8492
+ return { outcome, waitedMs: elapsed() };
8493
+ };
8488
8494
  if (await deps.probe())
8489
- return { outcome: "already-running", waitedMs: elapsed() };
8495
+ return live("already-running");
8496
+ const taken = await deps.foreignPortOwner?.();
8497
+ if (taken)
8498
+ return { outcome: "unavailable", reason: taken, waitedMs: elapsed() };
8490
8499
  let holdsLock = deps.tryCreateLock(JSON.stringify({ pid: process.pid, at: deps.now() }));
8491
8500
  if (!holdsLock) {
8492
8501
  const existing = parseSpawnLock(deps.readLock());
@@ -8507,7 +8516,7 @@ async function ensureSharedServer(deps) {
8507
8516
  while (elapsed() < timeout) {
8508
8517
  await deps.sleep(poll);
8509
8518
  if (await deps.probe())
8510
- return { outcome: "started", waitedMs: elapsed() };
8519
+ return live("started");
8511
8520
  if (deps.serverExited?.()) {
8512
8521
  return {
8513
8522
  outcome: "unavailable",
@@ -8528,7 +8537,7 @@ async function ensureSharedServer(deps) {
8528
8537
  while (elapsed() < timeout) {
8529
8538
  await deps.sleep(poll);
8530
8539
  if (await deps.probe())
8531
- return { outcome: "waited", waitedMs: elapsed() };
8540
+ return live("waited");
8532
8541
  const lock = parseSpawnLock(deps.readLock());
8533
8542
  if (!isSpawnLockHeld(lock, deps.now(), deps.isAlive, ttl) && !await deps.probe()) {
8534
8543
  return {
@@ -8590,7 +8599,9 @@ function startSharedServerWatched(opts) {
8590
8599
  LENSMCP_TRANSPORT: "http",
8591
8600
  LENSMCP_PORT: String(opts.port),
8592
8601
  LENSMCP_EVENT_FILE: opts.eventFile,
8593
- LENSMCP_SHARED_OWNER: "shim"
8602
+ LENSMCP_SHARED_OWNER: "shim",
8603
+ ...opts.workspaceKey ? { LENSMCP_WORKSPACE_KEY: opts.workspaceKey } : {},
8604
+ ...opts.workspaceRoot ? { LENSMCP_WORKSPACE_ROOT: opts.workspaceRoot } : {}
8594
8605
  };
8595
8606
  delete env["LENSMCP_PARENT_PID"];
8596
8607
  const child = spawn(process.execPath, [opts.serverEntry], {
@@ -8616,6 +8627,191 @@ function startSharedServerWatched(opts) {
8616
8627
  }
8617
8628
  }
8618
8629
 
8630
+ // servers/lensmcp-mcp/dist/shim/probe.js
8631
+ import { connect } from "node:net";
8632
+
8633
+ // servers/lensmcp-mcp/dist/shim/workspace.js
8634
+ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
8635
+ import { basename, dirname, join as join2 } from "node:path";
8636
+ var BASE_PORTS = { dashboard: 4321, mcpHttp: 4500, bridge: 5747 };
8637
+ function readJson(path) {
8638
+ try {
8639
+ return JSON.parse(readFileSync2(path, "utf8"));
8640
+ } catch {
8641
+ return void 0;
8642
+ }
8643
+ }
8644
+ function hashToRange(s, range) {
8645
+ let h = 2166136261;
8646
+ for (let i = 0; i < s.length; i++) {
8647
+ h ^= s.charCodeAt(i);
8648
+ h = Math.imul(h, 16777619);
8649
+ }
8650
+ return Math.abs(h | 0) % range;
8651
+ }
8652
+ function slugify2(input) {
8653
+ return input.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "workspace";
8654
+ }
8655
+ function resolveWorkspaceKey(root) {
8656
+ const nx = readJson(join2(root, "nx.json"));
8657
+ if (nx?.lensmcp?.key)
8658
+ return slugify2(nx.lensmcp.key);
8659
+ const pkg = readJson(join2(root, "package.json"));
8660
+ if (pkg?.name)
8661
+ return slugify2(pkg.name);
8662
+ if (nx?.name)
8663
+ return slugify2(nx.name);
8664
+ return slugify2(basename(root));
8665
+ }
8666
+ function deriveMcpHttpPort(key) {
8667
+ return BASE_PORTS.mcpHttp + hashToRange(key, 200);
8668
+ }
8669
+ function findWorkspaceRoot(start) {
8670
+ let nxFallback;
8671
+ let dir = start;
8672
+ for (; ; ) {
8673
+ if (existsSync2(join2(dir, ".lensmcp", "config.json")))
8674
+ return dir;
8675
+ if (nxFallback === void 0 && existsSync2(join2(dir, "nx.json")))
8676
+ nxFallback = dir;
8677
+ const parent = dirname(dir);
8678
+ if (parent === dir)
8679
+ break;
8680
+ dir = parent;
8681
+ }
8682
+ return nxFallback ?? start;
8683
+ }
8684
+ function resolveWorkspaceScope(start, env = process.env) {
8685
+ const root = findWorkspaceRoot(start);
8686
+ const file = readJson(join2(root, ".lensmcp", "config.json"));
8687
+ const key = file?.key ? slugify2(file.key) : resolveWorkspaceKey(root);
8688
+ const mcpHttpPort = typeof file?.ports?.mcpHttp === "number" ? file.ports.mcpHttp : deriveMcpHttpPort(key);
8689
+ return {
8690
+ root,
8691
+ key,
8692
+ mcpHttpPort,
8693
+ eventFile: env["LENSMCP_EVENT_FILE"] ?? join2(root, ".lensmcp", "events.jsonl")
8694
+ };
8695
+ }
8696
+ function sharedMcpUrl(port) {
8697
+ return `http://127.0.0.1:${port}/mcp`;
8698
+ }
8699
+ var IDENTITY_PATH = "/lensmcp/identity";
8700
+ var IDENTITY_KIND = "lensmcp-mcp-identity";
8701
+ function identityUrl(port) {
8702
+ return `http://127.0.0.1:${port}${IDENTITY_PATH}`;
8703
+ }
8704
+ function canonicalRoot(path) {
8705
+ try {
8706
+ return realpathSync(path);
8707
+ } catch {
8708
+ return path;
8709
+ }
8710
+ }
8711
+ function parseServerIdentity(value) {
8712
+ if (!value || typeof value !== "object")
8713
+ return void 0;
8714
+ const o = value;
8715
+ if (o["kind"] !== IDENTITY_KIND)
8716
+ return void 0;
8717
+ if (typeof o["key"] !== "string" || typeof o["root"] !== "string")
8718
+ return void 0;
8719
+ return {
8720
+ kind: IDENTITY_KIND,
8721
+ key: o["key"],
8722
+ root: o["root"],
8723
+ ...typeof o["eventFile"] === "string" ? { eventFile: o["eventFile"] } : {},
8724
+ ...typeof o["pid"] === "number" ? { pid: o["pid"] } : {},
8725
+ ...typeof o["port"] === "number" ? { port: o["port"] } : {}
8726
+ };
8727
+ }
8728
+ function describeIdentityMismatch(expected, actual) {
8729
+ if (!actual)
8730
+ return void 0;
8731
+ if (actual.key !== expected.key) {
8732
+ return `it serves workspace "${actual.key}", not "${expected.key}"`;
8733
+ }
8734
+ const ours = canonicalRoot(expected.root);
8735
+ const theirs = canonicalRoot(actual.root);
8736
+ if (ours !== theirs) {
8737
+ return `it serves another checkout of "${actual.key}" (${theirs}), not ${ours}`;
8738
+ }
8739
+ return void 0;
8740
+ }
8741
+
8742
+ // servers/lensmcp-mcp/dist/shim/probe.js
8743
+ async function probeMcp(url2, timeoutMs) {
8744
+ const ctrl = new AbortController();
8745
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
8746
+ try {
8747
+ const res = await fetch(url2, {
8748
+ method: "POST",
8749
+ signal: ctrl.signal,
8750
+ headers: {
8751
+ "content-type": "application/json",
8752
+ accept: "application/json, text/event-stream"
8753
+ },
8754
+ body: JSON.stringify({
8755
+ jsonrpc: "2.0",
8756
+ id: 1,
8757
+ method: "initialize",
8758
+ params: {
8759
+ protocolVersion: "2025-06-18",
8760
+ capabilities: {},
8761
+ clientInfo: { name: "lensmcp-shim-probe", version: "1" }
8762
+ }
8763
+ })
8764
+ });
8765
+ if (!res.ok)
8766
+ return false;
8767
+ const text = await res.text();
8768
+ return text.includes('"result"');
8769
+ } catch {
8770
+ return false;
8771
+ } finally {
8772
+ clearTimeout(timer);
8773
+ }
8774
+ }
8775
+ async function probeShared(port, timeoutMs = 2e3) {
8776
+ const ctrl = new AbortController();
8777
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
8778
+ let answered = false;
8779
+ try {
8780
+ const res = await fetch(identityUrl(port), {
8781
+ signal: ctrl.signal,
8782
+ headers: { accept: "application/json" }
8783
+ });
8784
+ answered = true;
8785
+ if (res.ok) {
8786
+ const identity = parseServerIdentity(await res.json().catch(() => void 0));
8787
+ if (identity)
8788
+ return { alive: true, identity, owner: "lensmcp" };
8789
+ } else {
8790
+ await res.text().catch(() => void 0);
8791
+ }
8792
+ } catch {
8793
+ } finally {
8794
+ clearTimeout(timer);
8795
+ }
8796
+ if (!answered)
8797
+ return { alive: false, owner: "none" };
8798
+ const legacy = await probeMcp(sharedMcpUrl(port), timeoutMs);
8799
+ return { alive: legacy, owner: legacy ? "lensmcp" : "foreign" };
8800
+ }
8801
+ function tcpAccepts(port, timeoutMs = 750) {
8802
+ return new Promise((resolve2) => {
8803
+ const socket = connect({ port, host: "127.0.0.1" });
8804
+ const settle = (accepted) => {
8805
+ socket.destroy();
8806
+ resolve2(accepted);
8807
+ };
8808
+ socket.setTimeout(timeoutMs);
8809
+ socket.once("connect", () => settle(true));
8810
+ socket.once("timeout", () => settle(false));
8811
+ socket.once("error", () => settle(false));
8812
+ });
8813
+ }
8814
+
8619
8815
  // servers/lensmcp-mcp/dist/shim/proxy.js
8620
8816
  function isRequest(m) {
8621
8817
  return typeof m.method === "string" && m.id !== void 0;
@@ -8769,73 +8965,6 @@ function startProxy(opts) {
8769
8965
  };
8770
8966
  }
8771
8967
 
8772
- // servers/lensmcp-mcp/dist/shim/workspace.js
8773
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
8774
- import { basename, dirname, join as join2 } from "node:path";
8775
- var BASE_PORTS = { dashboard: 4321, mcpHttp: 4500, bridge: 5747 };
8776
- function readJson(path) {
8777
- try {
8778
- return JSON.parse(readFileSync2(path, "utf8"));
8779
- } catch {
8780
- return void 0;
8781
- }
8782
- }
8783
- function hashToRange(s, range) {
8784
- let h = 2166136261;
8785
- for (let i = 0; i < s.length; i++) {
8786
- h ^= s.charCodeAt(i);
8787
- h = Math.imul(h, 16777619);
8788
- }
8789
- return Math.abs(h | 0) % range;
8790
- }
8791
- function slugify2(input) {
8792
- return input.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "workspace";
8793
- }
8794
- function resolveWorkspaceKey(root) {
8795
- const nx = readJson(join2(root, "nx.json"));
8796
- if (nx?.lensmcp?.key)
8797
- return slugify2(nx.lensmcp.key);
8798
- const pkg = readJson(join2(root, "package.json"));
8799
- if (pkg?.name)
8800
- return slugify2(pkg.name);
8801
- if (nx?.name)
8802
- return slugify2(nx.name);
8803
- return slugify2(basename(root));
8804
- }
8805
- function deriveMcpHttpPort(key) {
8806
- return BASE_PORTS.mcpHttp + hashToRange(key, 200);
8807
- }
8808
- function findWorkspaceRoot(start) {
8809
- let nxFallback;
8810
- let dir = start;
8811
- for (; ; ) {
8812
- if (existsSync2(join2(dir, ".lensmcp", "config.json")))
8813
- return dir;
8814
- if (nxFallback === void 0 && existsSync2(join2(dir, "nx.json")))
8815
- nxFallback = dir;
8816
- const parent = dirname(dir);
8817
- if (parent === dir)
8818
- break;
8819
- dir = parent;
8820
- }
8821
- return nxFallback ?? start;
8822
- }
8823
- function resolveWorkspaceScope(start, env = process.env) {
8824
- const root = findWorkspaceRoot(start);
8825
- const file = readJson(join2(root, ".lensmcp", "config.json"));
8826
- const key = file?.key ? slugify2(file.key) : resolveWorkspaceKey(root);
8827
- const mcpHttpPort = typeof file?.ports?.mcpHttp === "number" ? file.ports.mcpHttp : deriveMcpHttpPort(key);
8828
- return {
8829
- root,
8830
- key,
8831
- mcpHttpPort,
8832
- eventFile: env["LENSMCP_EVENT_FILE"] ?? join2(root, ".lensmcp", "events.jsonl")
8833
- };
8834
- }
8835
- function sharedMcpUrl(port) {
8836
- return `http://127.0.0.1:${port}/mcp`;
8837
- }
8838
-
8839
8968
  // servers/lensmcp-mcp/dist/shim.js
8840
8969
  function log(line) {
8841
8970
  process.stderr.write(`${line}
@@ -8848,43 +8977,12 @@ function argValue(flag) {
8848
8977
  function serverEntry() {
8849
8978
  return join3(dirname2(fileURLToPath(import.meta.url)), "main.js");
8850
8979
  }
8851
- async function probe(url2, timeoutMs = 2e3) {
8852
- const ctrl = new AbortController();
8853
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
8854
- try {
8855
- const res = await fetch(url2, {
8856
- method: "POST",
8857
- signal: ctrl.signal,
8858
- headers: {
8859
- "content-type": "application/json",
8860
- accept: "application/json, text/event-stream"
8861
- },
8862
- body: JSON.stringify({
8863
- jsonrpc: "2.0",
8864
- id: 1,
8865
- method: "initialize",
8866
- params: {
8867
- protocolVersion: "2025-06-18",
8868
- capabilities: {},
8869
- clientInfo: { name: "lensmcp-shim-probe", version: "1" }
8870
- }
8871
- })
8872
- });
8873
- if (!res.ok)
8874
- return false;
8875
- const text = await res.text();
8876
- return text.includes('"result"');
8877
- } catch {
8878
- return false;
8879
- } finally {
8880
- clearTimeout(timer);
8881
- }
8882
- }
8883
- function runEmbedded(cwd, why) {
8980
+ function runEmbedded(cwd, why, advise = true) {
8884
8981
  log(`[lensmcp] shared MCP server unavailable (${why}) \u2014 falling back to an embedded server.`);
8885
8982
  log("[lensmcp] the lens works for this session, but it is pinned to the bundle it");
8886
8983
  log("[lensmcp] starts with and will not pick up an upgrade until the session ends.");
8887
- log("[lensmcp] to get the shared server: `lensmcp gateway start`");
8984
+ if (advise)
8985
+ log("[lensmcp] to get the shared server: `lensmcp gateway start`");
8888
8986
  const env = { ...process.env };
8889
8987
  delete env["LENSMCP_MCP_MODE"];
8890
8988
  env["LENSMCP_TRANSPORT"] = "stdio";
@@ -8898,16 +8996,70 @@ async function main() {
8898
8996
  const scope = resolveWorkspaceScope(cwd);
8899
8997
  const url2 = sharedMcpUrl(scope.mcpHttpPort);
8900
8998
  log(`[lensmcp] shim \u2192 workspace "${scope.key}" (${scope.root}) \u2192 ${url2}`);
8999
+ let seen = { alive: false, owner: "none" };
9000
+ let warnedUnknown = false;
9001
+ let explained = false;
9002
+ const verifyIdentity = () => {
9003
+ const mismatch = describeIdentityMismatch(scope, seen.identity);
9004
+ if (!mismatch) {
9005
+ if (seen.alive && !seen.identity && !warnedUnknown) {
9006
+ warnedUnknown = true;
9007
+ log(`[lensmcp] note: the server on :${scope.mcpHttpPort} does not report a workspace (it predates this check), so it cannot be verified as "${scope.key}". Restart it \u2014 \`lensmcp gateway restart\` \u2014 to get the check.`);
9008
+ }
9009
+ return void 0;
9010
+ }
9011
+ const theirs = seen.identity;
9012
+ explained = true;
9013
+ log("");
9014
+ log(`[lensmcp] REFUSING the shared MCP server on 127.0.0.1:${scope.mcpHttpPort} \u2014 ${mismatch}.`);
9015
+ log(`[lensmcp] this session's workspace : "${scope.key}" ${scope.root}`);
9016
+ log(`[lensmcp] the server's workspace : "${theirs?.key}" ${theirs?.root}` + (theirs?.pid ? ` (pid ${theirs.pid})` : ""));
9017
+ log("[lensmcp] Two workspaces derive the same MCP port. Attaching would show you the");
9018
+ log("[lensmcp] OTHER project's events, builds and flows, so this session will not.");
9019
+ log("[lensmcp] Fix: pin a distinct port in this workspace's .lensmcp/config.json \u2014");
9020
+ log(`[lensmcp] { "ports": { "mcpHttp": ${scope.mcpHttpPort + 1} } }`);
9021
+ log("[lensmcp] then restart this session. Any free 4500-4699 port works.");
9022
+ log("");
9023
+ return `the shared MCP port is served by workspace "${theirs?.key}", not "${scope.key}"`;
9024
+ };
9025
+ const foreignPortOwner = async () => {
9026
+ const port = scope.mcpHttpPort;
9027
+ const nonHttp = seen.owner === "foreign" ? false : await tcpAccepts(port);
9028
+ if (seen.owner !== "foreign" && !nonHttp)
9029
+ return void 0;
9030
+ explained = true;
9031
+ log("");
9032
+ log(`[lensmcp] the shared MCP port 127.0.0.1:${port} is TAKEN by another process.`);
9033
+ log(`[lensmcp] It ${nonHttp ? "accepts connections but does not speak HTTP" : "answers HTTP but is not a lensmcp MCP server"}, so this session cannot attach to it.`);
9034
+ log("[lensmcp] Not starting a server on it: on macOS a second bind SUCCEEDS on the");
9035
+ log("[lensmcp] wildcard address while the kernel keeps routing 127.0.0.1 to the");
9036
+ log("[lensmcp] process already there \u2014 the new server would be unreachable, and");
9037
+ log("[lensmcp] would keep running after this session ends.");
9038
+ log("[lensmcp] Fix EITHER by freeing the port:");
9039
+ log(`[lensmcp] lsof -nP -iTCP:${port} -sTCP:LISTEN`);
9040
+ log(`[lensmcp] OR by pinning a different one in ${join3(scope.root, ".lensmcp", "config.json")}:`);
9041
+ log(`[lensmcp] { "ports": { "mcpHttp": ${port + 1} } }`);
9042
+ log("[lensmcp] then restart this session. Any free 4500-4699 port works.");
9043
+ log("");
9044
+ return `the shared MCP port ${port} is held by another process that is not a lensmcp server`;
9045
+ };
8901
9046
  const ensure = () => {
8902
9047
  let watch;
8903
9048
  return ensureSharedServer({
8904
- probe: () => probe(url2),
9049
+ probe: async () => {
9050
+ seen = await probeShared(scope.mcpHttpPort);
9051
+ return seen.alive;
9052
+ },
9053
+ verifyIdentity,
9054
+ foreignPortOwner,
8905
9055
  startServer: () => {
8906
9056
  const started = startSharedServerWatched({
8907
9057
  serverEntry: serverEntry(),
8908
9058
  port: scope.mcpHttpPort,
8909
9059
  eventFile: scope.eventFile,
8910
9060
  cwd: scope.root,
9061
+ workspaceKey: scope.key,
9062
+ workspaceRoot: scope.root,
8911
9063
  onLog: log
8912
9064
  });
8913
9065
  watch = started.exited;
@@ -8922,7 +9074,7 @@ async function main() {
8922
9074
  };
8923
9075
  const ensured = await ensure();
8924
9076
  if (ensured.outcome === "unavailable") {
8925
- runEmbedded(cwd, ensured.reason ?? "unknown");
9077
+ runEmbedded(cwd, ensured.reason ?? "unknown", !explained);
8926
9078
  return;
8927
9079
  }
8928
9080
  log(`[lensmcp] shim: shared server ${ensured.outcome} (${ensured.waitedMs}ms)`);
@@ -8936,8 +9088,17 @@ async function main() {
8936
9088
  }
8937
9089
  return new StreamableHTTPClientTransport(new URL(url2));
8938
9090
  },
8939
- // Consulted before acting on an error, so a blip does not cost a session.
8940
- isUpstreamHealthy: () => probe(url2, 1e3),
9091
+ // Filters blips so a transient error does not cost a live session — but a
9092
+ // server that has become a STRANGER is not a blip. Reporting it unhealthy
9093
+ // sends the pump into `connect()`, which re-ensures, refuses, and ends the
9094
+ // session cleanly; calling it healthy would instead leave the pump erroring
9095
+ // forever against a server that has never heard of our session id.
9096
+ isUpstreamHealthy: async () => {
9097
+ const now = await probeShared(scope.mcpHttpPort, 1e3);
9098
+ if (!now.alive)
9099
+ return false;
9100
+ return describeIdentityMismatch(scope, now.identity) === void 0;
9101
+ },
8941
9102
  log,
8942
9103
  onFatal: (reason) => log(`[lensmcp] shim: ${reason}`)
8943
9104
  });
package/lib/mcp-mode.d.ts CHANGED
@@ -36,14 +36,25 @@
36
36
  * spawns the server, waits, and if it still cannot get one it falls back to
37
37
  * today's embedded server so a session is NEVER left without the lens.
38
38
  *
39
- * The isolation boundary is the PORT
40
- * ----------------------------------
39
+ * The isolation boundary is the PORT — and the port is VERIFIED
40
+ * -------------------------------------------------------------
41
41
  * One daemon hosts many workspaces (foodguard + tetros), and their observability
42
42
  * data must never mix. Each workspace already reserves its own `ports.mcpHttp`
43
43
  * in `.lensmcp/config.json` (deterministic per workspace key). The shim resolves
44
44
  * that port from the workspace it was pointed at — NEVER a shared default. Two
45
45
  * workspaces landing on one port would cross-feed each other's events.
46
46
  *
47
+ * But the port is DERIVED (`4500 + fnv1a(key) % 200`), so two workspaces CAN
48
+ * hash onto one — and unlike the dashboard port it must not step up on
49
+ * collision, because the port IS the shim's contract. Two checkouts of one repo
50
+ * are worse: same key, same port, different event files. So the port alone is
51
+ * not proof of ownership. The shared server STATES which workspace it serves, on
52
+ * a `GET /lensmcp/identity` route mounted beside the MCP endpoint, and the shim
53
+ * REFUSES a server whose workspace is not its own — degrading to the embedded
54
+ * server rather than silently reading another project's events. It is checked
55
+ * before a single JSON-RPC frame is proxied, and costs nothing: that GET
56
+ * REPLACES the `initialize` probe the shim already had to make.
57
+ *
47
58
  * This module is pure (values in, values out) so every rule below is
48
59
  * unit-testable without a filesystem, a daemon, or an agent client.
49
60
  */
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-mode.d.ts","sourceRoot":"","sources":["../../src/lib/mcp-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,sDAAsD;AACtD,MAAM,MAAM,OAAO;AACjB,yFAAyF;AACvF,UAAU;AACZ,sFAAsF;GACpF,QAAQ,CAAC;AAEb,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,EAAE,OAAoB,CAAC;AAEpD,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB,SAAS,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,cAAc,CAAC;AAEnD,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,OAAO,CAE1D;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,MAAgC,GAAG,MAAM,CAEzF;AAED,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,GAAE,aAAkB,GAAG,OAAO,CAKlE;AAED,kFAAkF;AAClF,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAcD,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,OAAO,GAAG,QAAQ,CASnF"}
1
+ {"version":3,"file":"mcp-mode.d.ts","sourceRoot":"","sources":["../../src/lib/mcp-mode.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAEH,sDAAsD;AACtD,MAAM,MAAM,OAAO;AACjB,yFAAyF;AACvF,UAAU;AACZ,sFAAsF;GACpF,QAAQ,CAAC;AAEb,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,EAAE,OAAoB,CAAC;AAEpD,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB,SAAS,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,cAAc,CAAC;AAEnD,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,OAAO,CAE1D;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,MAAgC,GAAG,MAAM,CAEzF;AAED,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,GAAE,aAAkB,GAAG,OAAO,CAKlE;AAED,kFAAkF;AAClF,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAcD,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,OAAO,GAAG,QAAQ,CASnF"}
package/lib/mcp-mode.js CHANGED
@@ -36,14 +36,25 @@
36
36
  * spawns the server, waits, and if it still cannot get one it falls back to
37
37
  * today's embedded server so a session is NEVER left without the lens.
38
38
  *
39
- * The isolation boundary is the PORT
40
- * ----------------------------------
39
+ * The isolation boundary is the PORT — and the port is VERIFIED
40
+ * -------------------------------------------------------------
41
41
  * One daemon hosts many workspaces (foodguard + tetros), and their observability
42
42
  * data must never mix. Each workspace already reserves its own `ports.mcpHttp`
43
43
  * in `.lensmcp/config.json` (deterministic per workspace key). The shim resolves
44
44
  * that port from the workspace it was pointed at — NEVER a shared default. Two
45
45
  * workspaces landing on one port would cross-feed each other's events.
46
46
  *
47
+ * But the port is DERIVED (`4500 + fnv1a(key) % 200`), so two workspaces CAN
48
+ * hash onto one — and unlike the dashboard port it must not step up on
49
+ * collision, because the port IS the shim's contract. Two checkouts of one repo
50
+ * are worse: same key, same port, different event files. So the port alone is
51
+ * not proof of ownership. The shared server STATES which workspace it serves, on
52
+ * a `GET /lensmcp/identity` route mounted beside the MCP endpoint, and the shim
53
+ * REFUSES a server whose workspace is not its own — degrading to the embedded
54
+ * server rather than silently reading another project's events. It is checked
55
+ * before a single JSON-RPC frame is proxied, and costs nothing: that GET
56
+ * REPLACES the `initialize` probe the shim already had to make.
57
+ *
47
58
  * This module is pure (values in, values out) so every rule below is
48
59
  * unit-testable without a filesystem, a daemon, or an agent client.
49
60
  */
@@ -44,6 +44,20 @@ export interface LensConfig {
44
44
  * just because this field was added.
45
45
  */
46
46
  mcp?: LensMcpConfig;
47
+ /**
48
+ * Optional. Dirs removed from the source-set staleness signature + pod scope
49
+ * (e.g. `["docs"]` so a docs write does not recycle every vite pod). READ by
50
+ * `@lensmcp/cluster` (`runtime/scope.ts` `readSourceSetExclude`); declared here
51
+ * so this writer round-trips it instead of dropping it.
52
+ */
53
+ sourceSet?: {
54
+ exclude?: string[];
55
+ };
56
+ /**
57
+ * Any key this version does not know about. Preserved verbatim on write —
58
+ * see the round-trip contract on `readLensConfig`.
59
+ */
60
+ [key: string]: unknown;
47
61
  }
48
62
  /** Lowercase kebab slug; empty input → `workspace`. */
49
63
  export declare function slugify(input: string): string;
@@ -62,6 +76,14 @@ export declare function deriveConfig(root: string): LensConfig;
62
76
  * Read `<root>/.lensmcp/config.json` if present, else the derived defaults. A present
63
77
  * file OVERRIDES derived values field-by-field (so a user can pin a key/port without
64
78
  * losing the rest), and is the seam the gateway + CLI both read so they agree.
79
+ *
80
+ * ROUND-TRIP CONTRACT: every key on disk survives, including ones this version does
81
+ * not model. `ensureLensConfig` writes whatever this returns, so anything dropped
82
+ * here is silently DELETED from the user's file. That was a real bug: the result was
83
+ * built field-by-field from the four known keys, so a hand-set `sourceSet.exclude`
84
+ * (honored by `@lensmcp/cluster`, but not declared here) was stripped on every
85
+ * `lensmcp setup` — the docs-exclusion never took effect, and `docs/**` writes kept
86
+ * recycling every vite pod. Spread the file FIRST, then normalize; never enumerate.
65
87
  */
66
88
  export declare function readLensConfig(root: string): LensConfig;
67
89
  /** Persist the (derived/merged) config so it is visible + overridable. Idempotent;
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-scope.d.ts","sourceRoot":"","sources":["../../src/lib/workspace-scope.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,SAAS;IACxB,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,CAAC,CAAC;IACjB,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,GAAG,CAAC,EAAE,aAAa,CAAC;CACrB;AAaD,uDAAuD;AACvD,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOxD;AAOD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAOlD;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAGrD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAevD;AAED;yDACyD;AACzD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAQzD"}
1
+ {"version":3,"file":"workspace-scope.d.ts","sourceRoot":"","sources":["../../src/lib/workspace-scope.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,SAAS;IACxB,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,CAAC,CAAC;IACjB,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IACnC;;;OAGG;IACH,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAaD,uDAAuD;AACvD,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOxD;AAOD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAOlD;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAGrD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAiBvD;AAED;yDACyD;AACzD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAQzD"}
@@ -57,6 +57,14 @@ export function deriveConfig(root) {
57
57
  * Read `<root>/.lensmcp/config.json` if present, else the derived defaults. A present
58
58
  * file OVERRIDES derived values field-by-field (so a user can pin a key/port without
59
59
  * losing the rest), and is the seam the gateway + CLI both read so they agree.
60
+ *
61
+ * ROUND-TRIP CONTRACT: every key on disk survives, including ones this version does
62
+ * not model. `ensureLensConfig` writes whatever this returns, so anything dropped
63
+ * here is silently DELETED from the user's file. That was a real bug: the result was
64
+ * built field-by-field from the four known keys, so a hand-set `sourceSet.exclude`
65
+ * (honored by `@lensmcp/cluster`, but not declared here) was stripped on every
66
+ * `lensmcp setup` — the docs-exclusion never took effect, and `docs/**` writes kept
67
+ * recycling every vite pod. Spread the file FIRST, then normalize; never enumerate.
60
68
  */
61
69
  export function readLensConfig(root) {
62
70
  const derived = deriveConfig(root);
@@ -65,6 +73,8 @@ export function readLensConfig(root) {
65
73
  return derived;
66
74
  const key = file.key ? slugify(file.key) : derived.key;
67
75
  return {
76
+ // Unknown//future keys ride through untouched — the normalized fields below win.
77
+ ...file,
68
78
  schemaVersion: 1,
69
79
  key,
70
80
  ports: { ...derived.ports, ...(file.ports ?? {}) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lensmcp",
3
- "version": "1.17.4",
3
+ "version": "1.18.1",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -17,7 +17,7 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
- "@frontmcp/sdk": "^1.5.6",
20
+ "@frontmcp/sdk": "^1.5.7",
21
21
  "reflect-metadata": "^0.2.2",
22
22
  "tslib": "^2.3.0",
23
23
  "vectoriadb": "^2.2.0"
@@ -2,7 +2,7 @@
2
2
  "name": "lensmcp",
3
3
  "displayName": "LensMCP",
4
4
  "description": "The observability lens for coding agents. One command brings up the dev cluster gateway (every project.json `cluster` decl → its host on :443), the per-project lens dashboard at https://lensmcp.local/<project>/, and the MCP server your agent connects to — scoped automatically to whatever project you opened Claude Code in.",
5
- "version": "1.17.4",
5
+ "version": "1.18.1",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"