formlab-mcp 0.6.58 → 0.6.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,9 +124,9 @@ The config sets a single env var, which switches the server into cloud mode:
124
124
  | `FORMLAB_MCP_TOKEN` | a dedicated, **read-only**, revocable token (`flmcp_…`) |
125
125
  | `FORMLAB_SUPABASE_URL` | (optional) override the backend URL — defaults to production |
126
126
  | `FORMLAB_SUPABASE_ANON_KEY` | (optional) override the publishable key — defaults to production |
127
- | `FORMLAB_REFRESH_SECONDS` | (optional) poll interval, default `60` |
127
+ | `FORMLAB_REFRESH_SECONDS` | (optional) how old the copy may be when a tool is called before it's fetched again, default `300` (5 minutes). Nothing is fetched while no tool is called |
128
128
 
129
- When `FORMLAB_MCP_TOKEN` is present, the server POSTs it to FormLab's `mcp-data` Edge Function, which returns your workspace scoped to you by row-level security and re-fetches every `FORMLAB_REFRESH_SECONDS`. It's **read-only** — enforced at the database (a dedicated `mcp_readonly` Postgres role with `SELECT`-only grants), not by trust.
129
+ When `FORMLAB_MCP_TOKEN` is present, the server POSTs it to FormLab's `mcp-data` Edge Function, which returns your workspace scoped to you by row-level security. It's fetched once at start, then again only when a tool is called and the copy is older than `FORMLAB_REFRESH_SECONDS` (5 minutes by default); an idle server sends nothing. It's **read-only** — enforced at the database (a dedicated `mcp_readonly` Postgres role with `SELECT`-only grants), not by trust.
130
130
 
131
131
  > **Security.** The token grants **read-only** access to one workspace and holds **no account session** — only a SHA-256 hash is stored server-side, and it never rotates. Treat the config like a password (don't share or commit it). **Revoke or re-mint any time** from *Settings → Account → AI tools (MCP) → Connect*.
132
132
 
@@ -189,7 +189,7 @@ claude mcp add formlab npx formlab-mcp
189
189
 
190
190
  **Cloud mode** (`FORMLAB_MCP_TOKEN` set):
191
191
 
192
- - The server sends your token to FormLab's `mcp-data` Edge Function and receives your workspace in return, re-read every 60 seconds (`FORMLAB_REFRESH_SECONDS`).
192
+ - The server sends your token to FormLab's `mcp-data` Edge Function and receives your workspace in return, re-read when a tool is called and the copy is more than 5 minutes old (`FORMLAB_REFRESH_SECONDS`); nothing while idle.
193
193
  - The function reads through a dedicated `mcp_readonly` Postgres role with `SELECT`-only grants, and row-level security limits it to the workspace the token was made for.
194
194
  - FormLab stores only a SHA-256 hash of the token. It holds no account session and you can revoke it at any time.
195
195
 
package/cloud.js CHANGED
@@ -56,32 +56,41 @@ async function _fetchDb({ url, anonKey, token }) {
56
56
  return body;
57
57
  }
58
58
 
59
- // Bootstrap: one load up front, then refresh on an interval. Returns
60
- // { workspaceId, refresh, stop } to mirror the old v1 signature.
61
- export async function startCloud({ url, anonKey, token, refreshSeconds = 60, onReload } = {}) {
59
+ // Bootstrap: one load up front (it also checks the token), then fetched AGAIN ONLY WHEN A TOOL IS CALLED and the
60
+ // copy is older than `maxAgeSeconds` (FORMLAB_REFRESH_SECONDS, default 300). Until 0.6.60 this re-fetched the
61
+ // whole workspace on a 60 s timer for as long as the server ran, asked or not: two idle copies in Claude Desktop
62
+ // pulled ~6 GB a day from production and took the Supabase project over its egress quota (19 Oct 2026). Idle
63
+ // now costs nothing. Concurrent tool calls share one fetch; a failed refresh keeps the last copy and says so.
64
+ // Returns { workspaceId, refresh, ensureFresh, stop }.
65
+ export async function startCloud({ url, anonKey, token, refreshSeconds = 300, onReload, now = () => Date.now() } = {}) {
62
66
  url = url || DEFAULT_SUPABASE_URL;
63
67
  anonKey = anonKey || DEFAULT_ANON_KEY;
64
68
  if (!token) {
65
69
  throw new Error('Cloud mode needs FORMLAB_MCP_TOKEN (mint it from FormLab → Connect to MCP).');
66
70
  }
67
71
 
68
- let workspaceId = null;
72
+ let workspaceId = null, loadedAt = 0, inflight = null;
69
73
  const refresh = async () => {
70
74
  const body = await _fetchDb({ url, anonKey, token });
71
75
  workspaceId = body.workspaceId || workspaceId;
76
+ loadedAt = now();
72
77
  setStoreFromDb(body.db, { source: 'cloud', workspaceId, schemaName: 'formlab-live', loadedAt: body.loadedAt || new Date().toISOString() });
73
78
  if (onReload) { try { onReload(body.db); } catch (_) {} }
74
79
  return body.db;
75
80
  };
81
+ // Before a tool answers: fresh enough → nothing; else one fetch, shared by every call waiting on it.
82
+ const maxAgeMs = Math.max(0, Number(refreshSeconds) || 0) * 1000;
83
+ const ensureFresh = async () => {
84
+ if (now() - loadedAt < maxAgeMs) return false;
85
+ if (!inflight) {
86
+ inflight = refresh().then(() => true, (e) => {
87
+ process.stderr.write(`[formlab-mcp] cloud refresh failed, answering from the copy loaded ${Math.round((now() - loadedAt) / 1000)} s ago: ${e.message}\n`);
88
+ return false;
89
+ }).finally(() => { inflight = null; });
90
+ }
91
+ return inflight;
92
+ };
76
93
 
77
- await refresh(); // initial synchronous load before tools accept calls
78
-
79
- let timer = null;
80
- if (refreshSeconds > 0) {
81
- timer = setInterval(() => {
82
- refresh().catch(e => process.stderr.write(`[formlab-mcp] cloud refresh failed: ${e.message}\n`));
83
- }, refreshSeconds * 1000);
84
- if (timer.unref) timer.unref();
85
- }
86
- return { workspaceId, refresh, stop: () => { if (timer) clearInterval(timer); } };
94
+ await refresh(); // initial load before tools accept calls (and an early, clear error for a bad token)
95
+ return { workspaceId, refresh, ensureFresh, stop: () => {} };
87
96
  }
package/index.js CHANGED
@@ -47,20 +47,24 @@ import * as documents from './tools/documents.js';
47
47
  // FORMLAB_SUPABASE_URL / _ANON_KEY are optional overrides — cloud.js defaults to
48
48
  // the public production values, so the config only NEEDS FORMLAB_MCP_TOKEN.
49
49
  const CLOUD = !!process.env.FORMLAB_MCP_TOKEN;
50
+ // How old the cloud copy may be when a tool is called before it's fetched again (seconds). Nothing is fetched
51
+ // while no tool is called (0.6.60: no background polling).
52
+ const MAX_AGE_SECONDS = Number(process.env.FORMLAB_REFRESH_SECONDS || 300);
53
+ let cloud = null;
50
54
 
51
55
  if (CLOUD) {
52
56
  try {
53
- const h = await startCloud({
57
+ const h = cloud = await startCloud({
54
58
  url: process.env.FORMLAB_SUPABASE_URL || undefined,
55
59
  anonKey: process.env.FORMLAB_SUPABASE_ANON_KEY || undefined,
56
60
  token: process.env.FORMLAB_MCP_TOKEN,
57
- refreshSeconds: Number(process.env.FORMLAB_REFRESH_SECONDS || 60),
61
+ refreshSeconds: MAX_AGE_SECONDS,
58
62
  });
59
63
  const s = getStore();
60
64
  process.stderr.write(
61
65
  `[formlab-mcp] LIVE cloud mode · workspace ${h.workspaceId} · ` +
62
66
  `${(s.db.ingredients||[]).length} ingredients · ${(s.db.formulations||[]).length} formulas · ` +
63
- `refresh every ${Number(process.env.FORMLAB_REFRESH_SECONDS || 60)}s\n`);
67
+ `fetched again on use when older than ${MAX_AGE_SECONDS}s (no background polling)\n`);
64
68
  } catch (e) {
65
69
  process.stderr.write(`[formlab-mcp] cloud mode failed: ${e.message}\n`);
66
70
  process.exit(1);
@@ -157,6 +161,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
157
161
  };
158
162
  }
159
163
  try {
164
+ if (cloud) await cloud.ensureFresh(); // cloud mode: refetch only if the copy is older than FORMLAB_REFRESH_SECONDS
160
165
  const result = await tool.handler(args || {});
161
166
  // All tool handlers return either a plain object/array (auto-
162
167
  // serialized as JSON text) or a pre-formatted string. The MCP
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.58",
3
+ "version": "0.6.60",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab. Lets Claude and other MCP clients read and analyze your FormLab data, from a local export file or your live cloud workspace.",
6
6
  "homepage": "https://formvix.com/mcp",
7
7
  "type": "module",
8
8
  "bin": {
9
- "formlab-mcp": "./index.js"
9
+ "formlab-mcp": "index.js"
10
10
  },
11
11
  "main": "index.js",
12
12
  "files": [
package/server.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
4
4
  "description": "Read-only MCP server for FormLab formulation data: a local export file or your live cloud workspace.",
5
- "version": "0.6.58",
5
+ "version": "0.6.60",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "formlab-mcp",
11
- "version": "0.6.58",
11
+ "version": "0.6.60",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"
@@ -123,7 +123,7 @@ function _trimMeasurement(m, t, db) {
123
123
  binCount: Array.isArray(m.bins) ? m.bins.length : 0,
124
124
  ...complex,
125
125
  acceptanceCriteria: m.acceptanceCriteria || null,
126
- // Effective spec + where it came from (override | template | parameter | none)
126
+ // Effective spec + where it came from (override | template | parameter | result: a Pass / Fail text result judged as written | none)
127
127
  // and the app's pass/fail verdict under it.
128
128
  ...(() => { const r = _resolveSpec(m, t && t.templateId, db); return { effectiveSpec: r.spec || null, specSource: r.source, passFail: _evalPassFail(m, t && t.templateId, db) }; })(),
129
129
  // Captured run conditions this reading was measured under (e.g.
@@ -822,8 +822,19 @@ function _resolveSpec(m, templateId, db) {
822
822
  const lib = (store.parameters || []).find(p => p && p.id === m.parameterId);
823
823
  if (lib && lib.acceptanceCriteria) return { spec: lib.acceptanceCriteria, source: 'parameter' };
824
824
  }
825
+ // As the app (24b): no spec anywhere, a text result that is itself a verdict (Pass / Fail) is judged as written.
826
+ if (_measType(m) === 'categorical' && /^(pass|fail)$/.test(_catNorm(m.value))) return { spec: { requiredValue: 'pass', fromResult: true }, source: 'result' };
825
827
  return { spec: null, source: 'none' };
826
828
  }
829
+ // Text results, as the app (js/views/testing.js, 24b): a spec may accept several results
830
+ // ("=Clear | Slightly hazy"); Pass / Passed and Fail / Failed read as one word each.
831
+ function _catNorm(v) {
832
+ const s = String(v == null ? '' : v).trim().toLowerCase();
833
+ return /^pass(ed)?$/.test(s) ? 'pass' : /^fail(ed)?$/.test(s) ? 'fail' : s;
834
+ }
835
+ function _catPasses(required, value) {
836
+ return String(required == null ? '' : required).split('|').map(_catNorm).filter(Boolean).includes(_catNorm(value));
837
+ }
827
838
 
828
839
  const _MEAS_TYPES = ['scalar', 'ordinal', 'categorical', 'time_series', 'distribution'];
829
840
  function _measType(m) {
@@ -867,7 +878,7 @@ function _evalPassFail(m, templateId, db) {
867
878
  if (type === 'categorical') {
868
879
  if (!raw) return 'untested';
869
880
  if (!crit.requiredValue) return 'no-criteria';
870
- return raw.toLowerCase() === String(crit.requiredValue).toLowerCase() ? 'pass' : 'fail';
881
+ return _catPasses(crit.requiredValue, raw) ? 'pass' : 'fail';
871
882
  }
872
883
  // As the app (12y): one representative value against the range — a curve's
873
884
  // FINAL point, a distribution's statistic named by the spec (crit.stat:
package/vendor/cost.js CHANGED
@@ -1993,7 +1993,7 @@ function renderWeighoutPanel(w, actuals, formula, copyOnclick, noun) {
1993
1993
  const _woVolTarget = (typeof isVolUnit === 'function') && isVolUnit(w.unit);
1994
1994
  const qsTag = r => (!r.qs) ? '' :
1995
1995
  ` <span style="font-size:9.5px;color:var(--dk-sky-fg, #0369a1);font-weight:600;background:var(--dk-blue-bg, #eff6ff);border:1px solid var(--dk-blue-line, #bfdbfe);border-radius:4px;padding:0 3px;vertical-align:middle;white-space:nowrap" title="${_woVolTarget ? `Balance ingredient — add it up to the ${w.qty} ${esc(w.unit)} mark rather than to this number, then log what actually went in` : 'Balance ingredient, set to hold the batch at its target size'}">${_woVolTarget ? `q.s. to ${w.qty} ${esc(w.unit)}` : 'q.s.'}</span>`;
1996
- const dColor = rel => rel == null ? '#94a3b8' : (rel < 0.02 ? '#15803d' : rel < 0.10 ? '#b45309' : '#b91c1c');
1996
+ const dColor = rel => rel == null ? 'var(--dk-faint, #94a3b8)' : (rel < 0.02 ? 'var(--dk-green-fg, #15803d)' : rel < 0.10 ? 'var(--dk-amber-fg, #b45309)' : 'var(--dk-red-fg, #b91c1c)');
1997
1997
  // Tint a whole row when it deviates, so off-plan ingredients pop at a glance
1998
1998
  // (matches the composition-% table's row tinting). On-target rows stay white.
1999
1999
  const dRowBg = rel => (rel == null || rel < 0.02) ? '' : (rel < 0.10 ? 'background:var(--dk-amber-bg, #fffbeb)' : 'background:var(--dk-red-bg, #fef2f2)');
@@ -2590,7 +2590,7 @@ function entityCostValueHtml(o) {
2590
2590
  const msg = (why === 'needs-density')
2591
2591
  ? `⚠ add a density to the formula to price ${esc(o.unit || 'this unit')} by mass`
2592
2592
  : `can't convert ${esc(o.unit || 'this unit')} to mass`;
2593
- const col = (why === 'needs-density') ? '#b45309' : 'var(--text-muted)';
2593
+ const col = (why === 'needs-density') ? 'var(--dk-amber-fg, #b45309)' : 'var(--text-muted)';
2594
2594
  return `<span style="color:var(--text-muted)">—</span> <span style="font-size:11px;color:${col}">${msg}</span>${editBtn}`;
2595
2595
  }
2596
2596
  const breakdown = `<span style="font-size:11px;color:var(--text-muted);margin-left:6px">${esc(String(o.qty))} ${esc(o.unit || '')} × ${money(perKg)}/kg</span>`;