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 +3 -3
- package/cloud.js +23 -14
- package/index.js +8 -3
- package/package.json +2 -2
- package/server.json +2 -2
- package/tools/analytics.js +13 -2
- package/vendor/cost.js +2 -2
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)
|
|
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
|
|
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
|
|
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
|
|
60
|
-
//
|
|
61
|
-
|
|
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
|
|
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:
|
|
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
|
-
`
|
|
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.
|
|
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": "
|
|
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.
|
|
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.
|
|
11
|
+
"version": "0.6.60",
|
|
12
12
|
"runtimeHint": "npx",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -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
|
|
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>`;
|