lensmcp 1.17.4 → 1.18.0

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/bundled/main.js CHANGED
@@ -21901,6 +21901,92 @@ function watchParentGateway(label) {
21901
21901
  timer.unref();
21902
21902
  }
21903
21903
 
21904
+ // servers/lensmcp-mcp/dist/shim/workspace.js
21905
+ import { existsSync as existsSync7, readFileSync as readFileSync5, realpathSync } from "node:fs";
21906
+ import { basename, dirname as dirname5, join as join5 } from "node:path";
21907
+ var BASE_PORTS = { dashboard: 4321, mcpHttp: 4500, bridge: 5747 };
21908
+ function readJson(path) {
21909
+ try {
21910
+ return JSON.parse(readFileSync5(path, "utf8"));
21911
+ } catch {
21912
+ return void 0;
21913
+ }
21914
+ }
21915
+ function hashToRange(s, range) {
21916
+ let h = 2166136261;
21917
+ for (let i = 0; i < s.length; i++) {
21918
+ h ^= s.charCodeAt(i);
21919
+ h = Math.imul(h, 16777619);
21920
+ }
21921
+ return Math.abs(h | 0) % range;
21922
+ }
21923
+ function slugify2(input) {
21924
+ return input.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "workspace";
21925
+ }
21926
+ function resolveWorkspaceKey(root) {
21927
+ const nx = readJson(join5(root, "nx.json"));
21928
+ if (nx?.lensmcp?.key)
21929
+ return slugify2(nx.lensmcp.key);
21930
+ const pkg = readJson(join5(root, "package.json"));
21931
+ if (pkg?.name)
21932
+ return slugify2(pkg.name);
21933
+ if (nx?.name)
21934
+ return slugify2(nx.name);
21935
+ return slugify2(basename(root));
21936
+ }
21937
+ function deriveMcpHttpPort(key) {
21938
+ return BASE_PORTS.mcpHttp + hashToRange(key, 200);
21939
+ }
21940
+ function findWorkspaceRoot(start) {
21941
+ let nxFallback;
21942
+ let dir = start;
21943
+ for (; ; ) {
21944
+ if (existsSync7(join5(dir, ".lensmcp", "config.json")))
21945
+ return dir;
21946
+ if (nxFallback === void 0 && existsSync7(join5(dir, "nx.json")))
21947
+ nxFallback = dir;
21948
+ const parent = dirname5(dir);
21949
+ if (parent === dir)
21950
+ break;
21951
+ dir = parent;
21952
+ }
21953
+ return nxFallback ?? start;
21954
+ }
21955
+ function resolveWorkspaceScope(start, env = process.env) {
21956
+ const root = findWorkspaceRoot(start);
21957
+ const file2 = readJson(join5(root, ".lensmcp", "config.json"));
21958
+ const key = file2?.key ? slugify2(file2.key) : resolveWorkspaceKey(root);
21959
+ const mcpHttpPort = typeof file2?.ports?.mcpHttp === "number" ? file2.ports.mcpHttp : deriveMcpHttpPort(key);
21960
+ return {
21961
+ root,
21962
+ key,
21963
+ mcpHttpPort,
21964
+ eventFile: env["LENSMCP_EVENT_FILE"] ?? join5(root, ".lensmcp", "events.jsonl")
21965
+ };
21966
+ }
21967
+ var IDENTITY_PATH = "/lensmcp/identity";
21968
+ var IDENTITY_KIND = "lensmcp-mcp-identity";
21969
+ function canonicalRoot(path) {
21970
+ try {
21971
+ return realpathSync(path);
21972
+ } catch {
21973
+ return path;
21974
+ }
21975
+ }
21976
+ function resolveServerIdentity(env = process.env, cwd = process.cwd()) {
21977
+ const scope = resolveWorkspaceScope(cwd, env);
21978
+ const root = canonicalRoot(env["LENSMCP_WORKSPACE_ROOT"] ?? scope.root);
21979
+ const key = env["LENSMCP_WORKSPACE_KEY"] ? slugify2(env["LENSMCP_WORKSPACE_KEY"]) : env["LENSMCP_WORKSPACE_ROOT"] ? resolveWorkspaceScope(root, env).key : scope.key;
21980
+ return {
21981
+ kind: IDENTITY_KIND,
21982
+ key,
21983
+ root,
21984
+ eventFile: scope.eventFile,
21985
+ pid: process.pid,
21986
+ port: env["LENSMCP_PORT"] ? Number(env["LENSMCP_PORT"]) : void 0
21987
+ };
21988
+ }
21989
+
21904
21990
  // servers/lensmcp-mcp/dist/main.js
21905
21991
  watchParentGateway("mcp");
21906
21992
  var { session, providers } = createLensSession();
@@ -21950,7 +22036,25 @@ if (transport === "stdio") {
21950
22036
  });
21951
22037
  } else {
21952
22038
  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 };
22039
+ const identity = resolveServerIdentity();
22040
+ const identityRoute = {
22041
+ method: "GET",
22042
+ path: IDENTITY_PATH,
22043
+ handler: (_req, res) => {
22044
+ res.setHeader("content-type", "application/json");
22045
+ res.setHeader("cache-control", "no-store");
22046
+ res.end(JSON.stringify(identity));
22047
+ }
22048
+ };
22049
+ const http = transport === "socket" ? {
22050
+ socketPath: process.env["LENSMCP_SOCKET_PATH"] ?? "/tmp/lensmcp-mcp.sock",
22051
+ entryPath,
22052
+ routes: [identityRoute]
22053
+ } : {
22054
+ port: Number(process.env["LENSMCP_PORT"] ?? 3e3),
22055
+ entryPath,
22056
+ routes: [identityRoute]
22057
+ };
21954
22058
  void FrontMcpInstance.bootstrap({
21955
22059
  ...serverConfig,
21956
22060
  logging: { level: LogLevel.Info },
package/bundled/shim.js CHANGED
@@ -8485,8 +8485,14 @@ 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");
8490
8496
  let holdsLock = deps.tryCreateLock(JSON.stringify({ pid: process.pid, at: deps.now() }));
8491
8497
  if (!holdsLock) {
8492
8498
  const existing = parseSpawnLock(deps.readLock());
@@ -8507,7 +8513,7 @@ async function ensureSharedServer(deps) {
8507
8513
  while (elapsed() < timeout) {
8508
8514
  await deps.sleep(poll);
8509
8515
  if (await deps.probe())
8510
- return { outcome: "started", waitedMs: elapsed() };
8516
+ return live("started");
8511
8517
  if (deps.serverExited?.()) {
8512
8518
  return {
8513
8519
  outcome: "unavailable",
@@ -8528,7 +8534,7 @@ async function ensureSharedServer(deps) {
8528
8534
  while (elapsed() < timeout) {
8529
8535
  await deps.sleep(poll);
8530
8536
  if (await deps.probe())
8531
- return { outcome: "waited", waitedMs: elapsed() };
8537
+ return live("waited");
8532
8538
  const lock = parseSpawnLock(deps.readLock());
8533
8539
  if (!isSpawnLockHeld(lock, deps.now(), deps.isAlive, ttl) && !await deps.probe()) {
8534
8540
  return {
@@ -8590,7 +8596,9 @@ function startSharedServerWatched(opts) {
8590
8596
  LENSMCP_TRANSPORT: "http",
8591
8597
  LENSMCP_PORT: String(opts.port),
8592
8598
  LENSMCP_EVENT_FILE: opts.eventFile,
8593
- LENSMCP_SHARED_OWNER: "shim"
8599
+ LENSMCP_SHARED_OWNER: "shim",
8600
+ ...opts.workspaceKey ? { LENSMCP_WORKSPACE_KEY: opts.workspaceKey } : {},
8601
+ ...opts.workspaceRoot ? { LENSMCP_WORKSPACE_ROOT: opts.workspaceRoot } : {}
8594
8602
  };
8595
8603
  delete env["LENSMCP_PARENT_PID"];
8596
8604
  const child = spawn(process.execPath, [opts.serverEntry], {
@@ -8616,6 +8624,174 @@ function startSharedServerWatched(opts) {
8616
8624
  }
8617
8625
  }
8618
8626
 
8627
+ // servers/lensmcp-mcp/dist/shim/workspace.js
8628
+ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
8629
+ import { basename, dirname, join as join2 } from "node:path";
8630
+ var BASE_PORTS = { dashboard: 4321, mcpHttp: 4500, bridge: 5747 };
8631
+ function readJson(path) {
8632
+ try {
8633
+ return JSON.parse(readFileSync2(path, "utf8"));
8634
+ } catch {
8635
+ return void 0;
8636
+ }
8637
+ }
8638
+ function hashToRange(s, range) {
8639
+ let h = 2166136261;
8640
+ for (let i = 0; i < s.length; i++) {
8641
+ h ^= s.charCodeAt(i);
8642
+ h = Math.imul(h, 16777619);
8643
+ }
8644
+ return Math.abs(h | 0) % range;
8645
+ }
8646
+ function slugify2(input) {
8647
+ return input.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "workspace";
8648
+ }
8649
+ function resolveWorkspaceKey(root) {
8650
+ const nx = readJson(join2(root, "nx.json"));
8651
+ if (nx?.lensmcp?.key)
8652
+ return slugify2(nx.lensmcp.key);
8653
+ const pkg = readJson(join2(root, "package.json"));
8654
+ if (pkg?.name)
8655
+ return slugify2(pkg.name);
8656
+ if (nx?.name)
8657
+ return slugify2(nx.name);
8658
+ return slugify2(basename(root));
8659
+ }
8660
+ function deriveMcpHttpPort(key) {
8661
+ return BASE_PORTS.mcpHttp + hashToRange(key, 200);
8662
+ }
8663
+ function findWorkspaceRoot(start) {
8664
+ let nxFallback;
8665
+ let dir = start;
8666
+ for (; ; ) {
8667
+ if (existsSync2(join2(dir, ".lensmcp", "config.json")))
8668
+ return dir;
8669
+ if (nxFallback === void 0 && existsSync2(join2(dir, "nx.json")))
8670
+ nxFallback = dir;
8671
+ const parent = dirname(dir);
8672
+ if (parent === dir)
8673
+ break;
8674
+ dir = parent;
8675
+ }
8676
+ return nxFallback ?? start;
8677
+ }
8678
+ function resolveWorkspaceScope(start, env = process.env) {
8679
+ const root = findWorkspaceRoot(start);
8680
+ const file = readJson(join2(root, ".lensmcp", "config.json"));
8681
+ const key = file?.key ? slugify2(file.key) : resolveWorkspaceKey(root);
8682
+ const mcpHttpPort = typeof file?.ports?.mcpHttp === "number" ? file.ports.mcpHttp : deriveMcpHttpPort(key);
8683
+ return {
8684
+ root,
8685
+ key,
8686
+ mcpHttpPort,
8687
+ eventFile: env["LENSMCP_EVENT_FILE"] ?? join2(root, ".lensmcp", "events.jsonl")
8688
+ };
8689
+ }
8690
+ function sharedMcpUrl(port) {
8691
+ return `http://127.0.0.1:${port}/mcp`;
8692
+ }
8693
+ var IDENTITY_PATH = "/lensmcp/identity";
8694
+ var IDENTITY_KIND = "lensmcp-mcp-identity";
8695
+ function identityUrl(port) {
8696
+ return `http://127.0.0.1:${port}${IDENTITY_PATH}`;
8697
+ }
8698
+ function canonicalRoot(path) {
8699
+ try {
8700
+ return realpathSync(path);
8701
+ } catch {
8702
+ return path;
8703
+ }
8704
+ }
8705
+ function parseServerIdentity(value) {
8706
+ if (!value || typeof value !== "object")
8707
+ return void 0;
8708
+ const o = value;
8709
+ if (o["kind"] !== IDENTITY_KIND)
8710
+ return void 0;
8711
+ if (typeof o["key"] !== "string" || typeof o["root"] !== "string")
8712
+ return void 0;
8713
+ return {
8714
+ kind: IDENTITY_KIND,
8715
+ key: o["key"],
8716
+ root: o["root"],
8717
+ ...typeof o["eventFile"] === "string" ? { eventFile: o["eventFile"] } : {},
8718
+ ...typeof o["pid"] === "number" ? { pid: o["pid"] } : {},
8719
+ ...typeof o["port"] === "number" ? { port: o["port"] } : {}
8720
+ };
8721
+ }
8722
+ function describeIdentityMismatch(expected, actual) {
8723
+ if (!actual)
8724
+ return void 0;
8725
+ if (actual.key !== expected.key) {
8726
+ return `it serves workspace "${actual.key}", not "${expected.key}"`;
8727
+ }
8728
+ const ours = canonicalRoot(expected.root);
8729
+ const theirs = canonicalRoot(actual.root);
8730
+ if (ours !== theirs) {
8731
+ return `it serves another checkout of "${actual.key}" (${theirs}), not ${ours}`;
8732
+ }
8733
+ return void 0;
8734
+ }
8735
+
8736
+ // servers/lensmcp-mcp/dist/shim/probe.js
8737
+ async function probeMcp(url2, timeoutMs) {
8738
+ const ctrl = new AbortController();
8739
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
8740
+ try {
8741
+ const res = await fetch(url2, {
8742
+ method: "POST",
8743
+ signal: ctrl.signal,
8744
+ headers: {
8745
+ "content-type": "application/json",
8746
+ accept: "application/json, text/event-stream"
8747
+ },
8748
+ body: JSON.stringify({
8749
+ jsonrpc: "2.0",
8750
+ id: 1,
8751
+ method: "initialize",
8752
+ params: {
8753
+ protocolVersion: "2025-06-18",
8754
+ capabilities: {},
8755
+ clientInfo: { name: "lensmcp-shim-probe", version: "1" }
8756
+ }
8757
+ })
8758
+ });
8759
+ if (!res.ok)
8760
+ return false;
8761
+ const text = await res.text();
8762
+ return text.includes('"result"');
8763
+ } catch {
8764
+ return false;
8765
+ } finally {
8766
+ clearTimeout(timer);
8767
+ }
8768
+ }
8769
+ async function probeShared(port, timeoutMs = 2e3) {
8770
+ const ctrl = new AbortController();
8771
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
8772
+ let answered = false;
8773
+ try {
8774
+ const res = await fetch(identityUrl(port), {
8775
+ signal: ctrl.signal,
8776
+ headers: { accept: "application/json" }
8777
+ });
8778
+ answered = true;
8779
+ if (res.ok) {
8780
+ const identity = parseServerIdentity(await res.json().catch(() => void 0));
8781
+ if (identity)
8782
+ return { alive: true, identity };
8783
+ } else {
8784
+ await res.text().catch(() => void 0);
8785
+ }
8786
+ } catch {
8787
+ } finally {
8788
+ clearTimeout(timer);
8789
+ }
8790
+ if (!answered)
8791
+ return { alive: false };
8792
+ return { alive: await probeMcp(sharedMcpUrl(port), timeoutMs) };
8793
+ }
8794
+
8619
8795
  // servers/lensmcp-mcp/dist/shim/proxy.js
8620
8796
  function isRequest(m) {
8621
8797
  return typeof m.method === "string" && m.id !== void 0;
@@ -8769,73 +8945,6 @@ function startProxy(opts) {
8769
8945
  };
8770
8946
  }
8771
8947
 
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
8948
  // servers/lensmcp-mcp/dist/shim.js
8840
8949
  function log(line) {
8841
8950
  process.stderr.write(`${line}
@@ -8848,43 +8957,12 @@ function argValue(flag) {
8848
8957
  function serverEntry() {
8849
8958
  return join3(dirname2(fileURLToPath(import.meta.url)), "main.js");
8850
8959
  }
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) {
8960
+ function runEmbedded(cwd, why, advise = true) {
8884
8961
  log(`[lensmcp] shared MCP server unavailable (${why}) \u2014 falling back to an embedded server.`);
8885
8962
  log("[lensmcp] the lens works for this session, but it is pinned to the bundle it");
8886
8963
  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`");
8964
+ if (advise)
8965
+ log("[lensmcp] to get the shared server: `lensmcp gateway start`");
8888
8966
  const env = { ...process.env };
8889
8967
  delete env["LENSMCP_MCP_MODE"];
8890
8968
  env["LENSMCP_TRANSPORT"] = "stdio";
@@ -8898,16 +8976,48 @@ async function main() {
8898
8976
  const scope = resolveWorkspaceScope(cwd);
8899
8977
  const url2 = sharedMcpUrl(scope.mcpHttpPort);
8900
8978
  log(`[lensmcp] shim \u2192 workspace "${scope.key}" (${scope.root}) \u2192 ${url2}`);
8979
+ let seen = { alive: false };
8980
+ let warnedUnknown = false;
8981
+ let refused = false;
8982
+ const verifyIdentity = () => {
8983
+ const mismatch = describeIdentityMismatch(scope, seen.identity);
8984
+ if (!mismatch) {
8985
+ if (seen.alive && !seen.identity && !warnedUnknown) {
8986
+ warnedUnknown = true;
8987
+ 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.`);
8988
+ }
8989
+ return void 0;
8990
+ }
8991
+ const theirs = seen.identity;
8992
+ refused = true;
8993
+ log("");
8994
+ log(`[lensmcp] REFUSING the shared MCP server on 127.0.0.1:${scope.mcpHttpPort} \u2014 ${mismatch}.`);
8995
+ log(`[lensmcp] this session's workspace : "${scope.key}" ${scope.root}`);
8996
+ log(`[lensmcp] the server's workspace : "${theirs?.key}" ${theirs?.root}` + (theirs?.pid ? ` (pid ${theirs.pid})` : ""));
8997
+ log("[lensmcp] Two workspaces derive the same MCP port. Attaching would show you the");
8998
+ log("[lensmcp] OTHER project's events, builds and flows, so this session will not.");
8999
+ log("[lensmcp] Fix: pin a distinct port in this workspace's .lensmcp/config.json \u2014");
9000
+ log(`[lensmcp] { "ports": { "mcpHttp": ${scope.mcpHttpPort + 1} } }`);
9001
+ log("[lensmcp] then restart this session. Any free 4500-4699 port works.");
9002
+ log("");
9003
+ return `the shared MCP port is served by workspace "${theirs?.key}", not "${scope.key}"`;
9004
+ };
8901
9005
  const ensure = () => {
8902
9006
  let watch;
8903
9007
  return ensureSharedServer({
8904
- probe: () => probe(url2),
9008
+ probe: async () => {
9009
+ seen = await probeShared(scope.mcpHttpPort);
9010
+ return seen.alive;
9011
+ },
9012
+ verifyIdentity,
8905
9013
  startServer: () => {
8906
9014
  const started = startSharedServerWatched({
8907
9015
  serverEntry: serverEntry(),
8908
9016
  port: scope.mcpHttpPort,
8909
9017
  eventFile: scope.eventFile,
8910
9018
  cwd: scope.root,
9019
+ workspaceKey: scope.key,
9020
+ workspaceRoot: scope.root,
8911
9021
  onLog: log
8912
9022
  });
8913
9023
  watch = started.exited;
@@ -8922,7 +9032,7 @@ async function main() {
8922
9032
  };
8923
9033
  const ensured = await ensure();
8924
9034
  if (ensured.outcome === "unavailable") {
8925
- runEmbedded(cwd, ensured.reason ?? "unknown");
9035
+ runEmbedded(cwd, ensured.reason ?? "unknown", !refused);
8926
9036
  return;
8927
9037
  }
8928
9038
  log(`[lensmcp] shim: shared server ${ensured.outcome} (${ensured.waitedMs}ms)`);
@@ -8936,8 +9046,17 @@ async function main() {
8936
9046
  }
8937
9047
  return new StreamableHTTPClientTransport(new URL(url2));
8938
9048
  },
8939
- // Consulted before acting on an error, so a blip does not cost a session.
8940
- isUpstreamHealthy: () => probe(url2, 1e3),
9049
+ // Filters blips so a transient error does not cost a live session — but a
9050
+ // server that has become a STRANGER is not a blip. Reporting it unhealthy
9051
+ // sends the pump into `connect()`, which re-ensures, refuses, and ends the
9052
+ // session cleanly; calling it healthy would instead leave the pump erroring
9053
+ // forever against a server that has never heard of our session id.
9054
+ isUpstreamHealthy: async () => {
9055
+ const now = await probeShared(scope.mcpHttpPort, 1e3);
9056
+ if (!now.alive)
9057
+ return false;
9058
+ return describeIdentityMismatch(scope, now.identity) === void 0;
9059
+ },
8941
9060
  log,
8942
9061
  onFatal: (reason) => log(`[lensmcp] shim: ${reason}`)
8943
9062
  });
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.0",
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.0",
6
6
  "author": {
7
7
  "name": "David Antoon",
8
8
  "email": "davidmantoon@gmail.com"