formlab-mcp 0.4.1 → 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 +32 -3
- package/index.js +1 -1
- package/package.json +1 -1
- package/token-store.js +58 -0
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.4.
|
|
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
|
+
}
|