formlab-mcp 0.4.0 → 0.4.2

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/cloud.js CHANGED
@@ -13,6 +13,10 @@
13
13
 
14
14
  import { createClient } from '@supabase/supabase-js';
15
15
  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';
16
20
 
17
21
  // Supabase table → db array key (mirrors the app's ENT.ENTITIES + db.js arrays).
18
22
  // Safety/regulatory ride the ingredient record's `data`, so they come for free.
@@ -85,12 +89,37 @@ export async function startCloud({ url, anonKey, refreshToken, workspaceId, refr
85
89
  const supabase = createClient(url, anonKey, {
86
90
  auth: { persistSession: false, autoRefreshToken: true, detectSessionInUrl: false },
87
91
  });
92
+
88
93
  // refreshSession with a bare refresh token mints a fresh session and sets it on
89
94
  // the client, so all subsequent queries carry the user's JWT (→ RLS).
90
- const { data: sess, error: sErr } = await supabase.auth.refreshSession({ refresh_token: refreshToken });
91
- if (sErr || !(sess && sess.session && sess.session.user)) {
92
- throw new Error(`auth failed: ${(sErr && sErr.message) || 'could not start a session from the refresh token — it may have expired; reconnect from FormLab.'}`);
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;
107
+ }
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.`);
93
110
  }
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 (_) {}
122
+
94
123
  const userId = sess.session.user.id;
95
124
  const ws = await _resolveWorkspace(supabase, userId, workspaceId);
96
125
 
package/index.js CHANGED
@@ -113,7 +113,7 @@ const TOOLS = [
113
113
  ];
114
114
 
115
115
  const server = new Server(
116
- { name: 'formlab-mcp', version: '0.4.0' },
116
+ { name: 'formlab-mcp', version: '0.4.2' },
117
117
  { capabilities: { tools: {} } }
118
118
  );
119
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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
  "type": "module",
package/token-store.js ADDED
@@ -0,0 +1,58 @@
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
+ }
@@ -46,6 +46,13 @@ function _trimIngredient(i) {
46
46
  family: i.family || '',
47
47
  supplier: i.supplier || '',
48
48
  casNumber: i.cas || i.casNumber || '',
49
+ grade: i.grade || '',
50
+ state: i.state || '',
51
+ function: i.function || '',
52
+ mw: i.mw != null && i.mw !== '' ? Number(i.mw) : null,
53
+ // % active for a technical/impure raw material (drives purity-corrected
54
+ // weigh-out). null when full-strength / unspecified.
55
+ purity: i.purity != null && i.purity !== '' ? Number(i.purity) : null,
49
56
  density: i.density != null ? i.density : null,
50
57
  densityUnit: i.densityUnit || (i.density != null ? 'kg/L' : null),
51
58
  cost: (cost && cost.value != null && cost.value !== '')