formlab-mcp 0.4.2 → 0.5.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.
Files changed (4) hide show
  1. package/cloud.js +63 -119
  2. package/index.js +13 -12
  3. package/package.json +4 -5
  4. package/token-store.js +0 -58
package/cloud.js CHANGED
@@ -1,133 +1,77 @@
1
1
  // ============================================================
2
- // CLOUD MODE — read the LIVE FormLab workspace from Supabase instead of a
3
- // static export file. The tools are unchanged: we assemble the same `db` shape
4
- // (each synced table stores the full record in a `data` JSONB column) and set it
5
- // via data.js setStoreFromDb, then re-fetch on an interval so it stays "live".
2
+ // CLOUD MODE — read the LIVE FormLab workspace via the `mcp-data` Edge Function.
3
+ // ------------------------------------------------------------
4
+ // v2 (formlab-mcp ≥0.5.0). The old v1 flow authenticated with the user's account
5
+ // REFRESH TOKEN directly against Supabase — which was broken by design: the
6
+ // refresh token is the full account session AND Supabase rotates it on every
7
+ // use, so the browser and this server fought over one rotation chain and the
8
+ // configured token worked exactly once ("Server disconnected" on the next start).
6
9
  //
7
- // v1 auth: the user's session REFRESH TOKEN (from FormLab's "Connect to MCP"
8
- // panel). Read-only by usage; RLS scopes every query to the user's own
9
- // workspaces. The refresh token is the full account session, though — a
10
- // dedicated, revocable, read-only MCP token is the planned hardening (needs a
11
- // small backend: a tokens table + an Edge Function to mint/validate).
10
+ // Now we send a dedicated, revocable, READ-ONLY token (`flmcp_…`, minted from
11
+ // FormLab → Connect to MCP) to the `mcp-data` Edge Function, which:
12
+ // * connects to Postgres as the SELECT-only `mcp_readonly` role (read-only
13
+ // enforced by the DB, not by trust),
14
+ // * authenticates the token and scopes every query to the bound workspace via
15
+ // the existing RLS,
16
+ // * returns the same `db` shape we then feed to setStoreFromDb.
17
+ //
18
+ // The token is STATIC — it never rotates — so there is nothing to persist and no
19
+ // way for this server to invalidate itself. Revoke it from FormLab if needed.
12
20
  // ============================================================
13
21
 
14
- import { createClient } from '@supabase/supabase-js';
15
22
  import { setStoreFromDb } from './data.js';
16
- // Supabase rotates the refresh token on every use, and this client runs with
17
- // persistSession:false — see token-store.js for why we bank the rotated token
18
- // ourselves and what breaks when we don't.
19
- import { readStoredToken as _readStoredToken, saveToken as _saveToken } from './token-store.js';
20
-
21
- // Supabase table → db array key (mirrors the app's ENT.ENTITIES + db.js arrays).
22
- // Safety/regulatory ride the ingredient record's `data`, so they come for free.
23
- const TABLES = [
24
- ['projects', 'projects'],
25
- ['ingredients', 'ingredients'],
26
- ['formulations', 'formulations'],
27
- ['batches', 'batches'],
28
- ['samples', 'samples'],
29
- ['test_results', 'testResults'],
30
- ['templates', 'templates'],
31
- ['procedure_templates', 'procedureTemplates'],
32
- ['parameters', 'parameters'],
33
- ['equipment', 'equipment'],
34
- ['stock_movements', 'stockMovements'],
35
- ['lots', 'lots'],
36
- ['formula_versions', 'formulaVersions'],
37
- ['attachments', 'attachments'],
38
- ['notebook_entries', 'notebookEntries'],
39
- ];
40
-
41
- const PAGE = 1000;
42
-
43
- async function _fetchAll(supabase, table, workspaceId) {
44
- const out = [];
45
- for (let from = 0; ; from += PAGE) {
46
- const { data, error } = await supabase
47
- .from(table)
48
- .select('data')
49
- .eq('workspace_id', workspaceId)
50
- .is('deleted_at', null)
51
- .range(from, from + PAGE - 1);
52
- if (error) {
53
- // A table the backend hasn't migrated yet shouldn't sink the whole load.
54
- if (/relation|does not exist|schema cache|find the table/i.test(error.message || '')) return out;
55
- throw new Error(`${table}: ${error.message}`);
56
- }
57
- const rows = data || [];
58
- for (const r of rows) if (r && r.data) out.push(r.data);
59
- if (rows.length < PAGE) break;
60
- }
61
- return out;
62
- }
63
-
64
- async function _resolveWorkspace(supabase, userId, preferred) {
65
- if (preferred) return preferred;
66
- const { data, error } = await supabase
67
- .from('workspace_members')
68
- .select('workspace_id, role')
69
- .eq('user_id', userId);
70
- if (error) throw new Error(`workspace_members: ${error.message}`);
71
- const rows = data || [];
72
- if (!rows.length) throw new Error('No workspaces found for this account.');
73
- const owned = rows.find(r => r.role === 'owner');
74
- return (owned || rows[0]).workspace_id; // default to your OWN workspace
75
- }
76
23
 
77
- async function _loadDb(supabase, workspaceId) {
78
- const db = {};
79
- for (const [table, key] of TABLES) db[key] = await _fetchAll(supabase, table, workspaceId);
80
- return db;
81
- }
24
+ // Public defaults — same values the app ships in its client (js/supabase.js).
25
+ // Overridable via env for a self-hosted / preview backend.
26
+ export const DEFAULT_SUPABASE_URL = 'https://ufizhypibwqqpavhxdwt.supabase.co';
27
+ export const DEFAULT_ANON_KEY = 'sb_publishable_z83O_XZCXsHo9wghb4o3aw_6Qf4ikMP';
82
28
 
83
- // Bootstrap a session from the refresh token, do an initial load, then refresh on
84
- // an interval. Returns { workspaceId, userId, refresh, stop }.
85
- export async function startCloud({ url, anonKey, refreshToken, workspaceId, refreshSeconds = 60, onReload } = {}) {
86
- if (!url || !anonKey || !refreshToken) {
87
- throw new Error('Cloud mode needs FORMLAB_SUPABASE_URL, FORMLAB_SUPABASE_ANON_KEY and FORMLAB_REFRESH_TOKEN (copy them from FormLab → Connect to MCP).');
29
+ // One fetch of the workspace `db` from the Edge Function.
30
+ async function _fetchDb({ url, anonKey, token }) {
31
+ const endpoint = `${url.replace(/\/+$/, '')}/functions/v1/mcp-data`;
32
+ let res;
33
+ try {
34
+ res = await fetch(endpoint, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ // The gateway still wants a project apikey; OUR credential is the token
39
+ // in the body, which the function checks itself (deployed --no-verify-jwt).
40
+ 'apikey': anonKey,
41
+ 'Authorization': `Bearer ${anonKey}`,
42
+ },
43
+ body: JSON.stringify({ token }),
44
+ });
45
+ } catch (e) {
46
+ throw new Error(`could not reach the FormLab MCP endpoint (${endpoint}): ${e.message}`);
88
47
  }
89
- const supabase = createClient(url, anonKey, {
90
- auth: { persistSession: false, autoRefreshToken: true, detectSessionInUrl: false },
91
- });
92
-
93
- // refreshSession with a bare refresh token mints a fresh session and sets it on
94
- // the client, so all subsequent queries carry the user's JWT (→ RLS).
95
- //
96
- // Try the PERSISTED token first (the live end of the rotation chain from our
97
- // last run), then fall back to the configured one. The fallback matters: after
98
- // the user reconnects from FormLab, the env token is the fresh one and any
99
- // stored descendant is dead.
100
- const stored = _readStoredToken(refreshToken);
101
- const candidates = stored && stored !== refreshToken ? [stored, refreshToken] : [refreshToken];
102
- let sess = null, lastErr = null;
103
- for (const token of candidates) {
104
- const { data, error } = await supabase.auth.refreshSession({ refresh_token: token });
105
- if (!error && data && data.session && data.session.user) { sess = data; break; }
106
- lastErr = error;
48
+ let body = null;
49
+ try { body = await res.json(); } catch { /* non-JSON error page */ }
50
+ if (res.status === 401) {
51
+ throw new Error('the MCP token was rejected (missing, revoked, or malformed). Reconnect from FormLab → Settings → Account → AI tools (MCP) → Connect, and paste the new FORMLAB_MCP_TOKEN.');
107
52
  }
108
- if (!sess) {
109
- throw new Error(`auth failed: ${(lastErr && lastErr.message) || 'could not start a session from the refresh token'} — the refresh token is single-use and may already be spent. Reconnect from FormLab → Settings → Account → AI tools (MCP) → Connect, and paste the new FORMLAB_REFRESH_TOKEN into your MCP config.`);
53
+ if (!res.ok || !body || body.ok !== true || !body.db) {
54
+ throw new Error(`MCP endpoint error (HTTP ${res.status})${body && body.error ? ': ' + body.error : ''}`);
110
55
  }
111
- // Bank the rotated token so the NEXT start has a live one.
112
- _saveToken(sess.session.refresh_token, refreshToken);
113
- // autoRefreshToken keeps rotating while we run — persist each new one too, so a
114
- // long-running server still leaves a usable token behind when it exits.
115
- try {
116
- supabase.auth.onAuthStateChange((event, session) => {
117
- if (event === 'TOKEN_REFRESHED' && session && session.refresh_token) {
118
- _saveToken(session.refresh_token, refreshToken);
119
- }
120
- });
121
- } catch (_) {}
56
+ return body;
57
+ }
122
58
 
123
- const userId = sess.session.user.id;
124
- const ws = await _resolveWorkspace(supabase, userId, workspaceId);
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 } = {}) {
62
+ url = url || DEFAULT_SUPABASE_URL;
63
+ anonKey = anonKey || DEFAULT_ANON_KEY;
64
+ if (!token) {
65
+ throw new Error('Cloud mode needs FORMLAB_MCP_TOKEN (mint it from FormLab → Connect to MCP).');
66
+ }
125
67
 
68
+ let workspaceId = null;
126
69
  const refresh = async () => {
127
- const db = await _loadDb(supabase, ws);
128
- setStoreFromDb(db, { source: 'cloud', workspaceId: ws, schemaName: 'formlab-live', loadedAt: new Date().toISOString() });
129
- if (onReload) { try { onReload(db); } catch (_) {} }
130
- return db;
70
+ const body = await _fetchDb({ url, anonKey, token });
71
+ workspaceId = body.workspaceId || workspaceId;
72
+ setStoreFromDb(body.db, { source: 'cloud', workspaceId, schemaName: 'formlab-live', loadedAt: body.loadedAt || new Date().toISOString() });
73
+ if (onReload) { try { onReload(body.db); } catch (_) {} }
74
+ return body.db;
131
75
  };
132
76
 
133
77
  await refresh(); // initial synchronous load before tools accept calls
@@ -137,7 +81,7 @@ export async function startCloud({ url, anonKey, refreshToken, workspaceId, refr
137
81
  timer = setInterval(() => {
138
82
  refresh().catch(e => process.stderr.write(`[formlab-mcp] cloud refresh failed: ${e.message}\n`));
139
83
  }, refreshSeconds * 1000);
140
- if (timer.unref) timer.unref(); // don't keep the process alive just for the timer
84
+ if (timer.unref) timer.unref();
141
85
  }
142
- return { workspaceId: ws, userId, refresh, stop: () => { if (timer) clearInterval(timer); } };
86
+ return { workspaceId, refresh, stop: () => { if (timer) clearInterval(timer); } };
143
87
  }
package/index.js CHANGED
@@ -35,17 +35,18 @@ import * as projects from './tools/projects.js';
35
35
  import * as notebook from './tools/notebook.js';
36
36
 
37
37
  // ----- Data source: LIVE cloud workspace OR a static export file -----
38
- // Cloud mode kicks in when the Supabase env vars are present (copy them from
39
- // FormLab → Connect to MCP). Otherwise we fall back to the export-file mode.
40
- const CLOUD = process.env.FORMLAB_REFRESH_TOKEN && process.env.FORMLAB_SUPABASE_URL && process.env.FORMLAB_SUPABASE_ANON_KEY;
38
+ // Cloud mode kicks in when a dedicated read-only MCP token is present (mint it
39
+ // from FormLab → Connect to MCP). Otherwise we fall back to export-file mode.
40
+ // FORMLAB_SUPABASE_URL / _ANON_KEY are optional overrides — cloud.js defaults to
41
+ // the public production values, so the config only NEEDS FORMLAB_MCP_TOKEN.
42
+ const CLOUD = !!process.env.FORMLAB_MCP_TOKEN;
41
43
 
42
44
  if (CLOUD) {
43
45
  try {
44
46
  const h = await startCloud({
45
- url: process.env.FORMLAB_SUPABASE_URL,
46
- anonKey: process.env.FORMLAB_SUPABASE_ANON_KEY,
47
- refreshToken: process.env.FORMLAB_REFRESH_TOKEN,
48
- workspaceId: process.env.FORMLAB_WORKSPACE_ID || null,
47
+ url: process.env.FORMLAB_SUPABASE_URL || undefined,
48
+ anonKey: process.env.FORMLAB_SUPABASE_ANON_KEY || undefined,
49
+ token: process.env.FORMLAB_MCP_TOKEN,
49
50
  refreshSeconds: Number(process.env.FORMLAB_REFRESH_SECONDS || 60),
50
51
  });
51
52
  const s = getStore();
@@ -61,12 +62,12 @@ if (CLOUD) {
61
62
  const filePath = process.argv[2] || process.env.FORMLAB_EXPORT;
62
63
  if (!filePath) {
63
64
  process.stderr.write([
64
- 'Usage: formlab-mcp <path-to-export.json> (file mode)',
65
- ' or: set FORMLAB_SUPABASE_URL / FORMLAB_SUPABASE_ANON_KEY /',
66
- ' FORMLAB_REFRESH_TOKEN (+ optional FORMLAB_WORKSPACE_ID) (LIVE cloud mode)',
65
+ 'Usage: formlab-mcp <path-to-export.json> (file mode)',
66
+ ' or: set FORMLAB_MCP_TOKEN (LIVE cloud mode)',
67
67
  '',
68
68
  'File mode: export via Settings → Import / Export → Export.',
69
- 'Cloud mode: copy the values from FormLab → Connect to MCP.',
69
+ 'Cloud mode: mint a read-only token in FormLab → Settings → Account →',
70
+ ' AI tools (MCP) → Connect, and set it as FORMLAB_MCP_TOKEN.',
70
71
  '',
71
72
  ].join('\n'));
72
73
  process.exit(1);
@@ -113,7 +114,7 @@ const TOOLS = [
113
114
  ];
114
115
 
115
116
  const server = new Server(
116
- { name: 'formlab-mcp', version: '0.4.2' },
117
+ { name: 'formlab-mcp', version: '0.5.0' },
117
118
  { capabilities: { tools: {} } }
118
119
  );
119
120
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
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.",
5
+ "description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "formlab-mcp": "./index.js"
@@ -16,8 +16,7 @@
16
16
  "node": ">=18"
17
17
  },
18
18
  "dependencies": {
19
- "@modelcontextprotocol/sdk": "^1.0.4",
20
- "@supabase/supabase-js": "^2.45.0"
19
+ "@modelcontextprotocol/sdk": "^1.0.4"
21
20
  },
22
21
  "optionalDependencies": {
23
22
  "@rdkit/rdkit": "^2024.9.1"
@@ -39,4 +38,4 @@
39
38
  "url": "https://github.com/juliu1980/FormLab",
40
39
  "directory": "mcp"
41
40
  }
42
- }
41
+ }
package/token-store.js DELETED
@@ -1,58 +0,0 @@
1
- // ============================================================
2
- // ROTATED-REFRESH-TOKEN STORE
3
- // ------------------------------------------------------------
4
- // Supabase ROTATES the refresh token on every use: refreshSession() invalidates
5
- // the token you passed and issues a new one. cloud.js creates its client with
6
- // persistSession:false, so before this module existed that new token only lived
7
- // in memory — and the next process start re-read the SAME (now spent) token from
8
- // the env and died with "auth failed", which Claude Desktop surfaces as
9
- // "Server disconnected". Net effect: the configured token worked exactly ONCE.
10
- //
11
- // So we bank the rotated token and prefer it on the next start. Entries are keyed
12
- // by a fingerprint of the ENV token: when the user reconnects from FormLab and
13
- // pastes a new token, the fingerprint changes and we drop the stale chain rather
14
- // than clinging to a dead descendant of the old one.
15
- //
16
- // The file holds a live account session — the same sensitivity as the Desktop
17
- // config that already stores it in plaintext — so it lives under the user's home,
18
- // dir 0700 / file 0600. Never log the token itself.
19
- // ============================================================
20
-
21
- import { createHash } from 'node:crypto';
22
- import { homedir } from 'node:os';
23
- import { join, dirname } from 'node:path';
24
- import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
25
-
26
- export const tokenFile = () => join(homedir(), '.formlab-mcp', 'session.json');
27
-
28
- // Short, non-reversible tag for "which configured token is this chain from".
29
- export const fingerprint = (t) => createHash('sha256').update(String(t || '')).digest('hex').slice(0, 16);
30
-
31
- // The live end of the rotation chain for `envToken`, or null when there isn't a
32
- // usable one (no file, unreadable, or it belongs to a different env token).
33
- export function readStoredToken(envToken) {
34
- try {
35
- const s = JSON.parse(readFileSync(tokenFile(), 'utf8'));
36
- if (!s || !s.refreshToken) return null;
37
- if (s.source !== fingerprint(envToken)) return null;
38
- return s.refreshToken;
39
- } catch (_) { return null; }
40
- }
41
-
42
- // Persistence is an optimisation, not a hard requirement: a read-only home must
43
- // not take the server down — worst case we're back to single-use behaviour.
44
- export function saveToken(refreshToken, envToken) {
45
- if (!refreshToken) return false;
46
- try {
47
- const f = tokenFile();
48
- mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
49
- writeFileSync(f, JSON.stringify({
50
- refreshToken, source: fingerprint(envToken), updatedAt: new Date().toISOString(),
51
- }, null, 2), { mode: 0o600 });
52
- chmodSync(f, 0o600); // enforce even if the file already existed
53
- return true;
54
- } catch (e) {
55
- process.stderr.write(`[formlab-mcp] could not persist refresh token: ${e.message}\n`);
56
- return false;
57
- }
58
- }