formlab-mcp 0.6.59 → 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.59",
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.59",
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.59",
11
+ "version": "0.6.60",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"