formlab-mcp 0.4.1 → 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.
- package/cloud.js +63 -90
- package/index.js +13 -12
- package/package.json +4 -5
package/cloud.js
CHANGED
|
@@ -1,104 +1,77 @@
|
|
|
1
1
|
// ============================================================
|
|
2
|
-
// CLOUD MODE — read the LIVE FormLab workspace
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
23
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
['ingredients', 'ingredients'],
|
|
22
|
-
['formulations', 'formulations'],
|
|
23
|
-
['batches', 'batches'],
|
|
24
|
-
['samples', 'samples'],
|
|
25
|
-
['test_results', 'testResults'],
|
|
26
|
-
['templates', 'templates'],
|
|
27
|
-
['procedure_templates', 'procedureTemplates'],
|
|
28
|
-
['parameters', 'parameters'],
|
|
29
|
-
['equipment', 'equipment'],
|
|
30
|
-
['stock_movements', 'stockMovements'],
|
|
31
|
-
['lots', 'lots'],
|
|
32
|
-
['formula_versions', 'formulaVersions'],
|
|
33
|
-
['attachments', 'attachments'],
|
|
34
|
-
['notebook_entries', 'notebookEntries'],
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const PAGE = 1000;
|
|
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';
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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}`);
|
|
56
47
|
}
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (error) throw new Error(`workspace_members: ${error.message}`);
|
|
67
|
-
const rows = data || [];
|
|
68
|
-
if (!rows.length) throw new Error('No workspaces found for this account.');
|
|
69
|
-
const owned = rows.find(r => r.role === 'owner');
|
|
70
|
-
return (owned || rows[0]).workspace_id; // default to your OWN workspace
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function _loadDb(supabase, workspaceId) {
|
|
74
|
-
const db = {};
|
|
75
|
-
for (const [table, key] of TABLES) db[key] = await _fetchAll(supabase, table, workspaceId);
|
|
76
|
-
return db;
|
|
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.');
|
|
52
|
+
}
|
|
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 : ''}`);
|
|
55
|
+
}
|
|
56
|
+
return body;
|
|
77
57
|
}
|
|
78
58
|
|
|
79
|
-
// Bootstrap
|
|
80
|
-
//
|
|
81
|
-
export async function startCloud({ url, anonKey,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
auth: { persistSession: false, autoRefreshToken: true, detectSessionInUrl: false },
|
|
87
|
-
});
|
|
88
|
-
// refreshSession with a bare refresh token mints a fresh session and sets it on
|
|
89
|
-
// 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.'}`);
|
|
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).');
|
|
93
66
|
}
|
|
94
|
-
const userId = sess.session.user.id;
|
|
95
|
-
const ws = await _resolveWorkspace(supabase, userId, workspaceId);
|
|
96
67
|
|
|
68
|
+
let workspaceId = null;
|
|
97
69
|
const refresh = async () => {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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;
|
|
102
75
|
};
|
|
103
76
|
|
|
104
77
|
await refresh(); // initial synchronous load before tools accept calls
|
|
@@ -108,7 +81,7 @@ export async function startCloud({ url, anonKey, refreshToken, workspaceId, refr
|
|
|
108
81
|
timer = setInterval(() => {
|
|
109
82
|
refresh().catch(e => process.stderr.write(`[formlab-mcp] cloud refresh failed: ${e.message}\n`));
|
|
110
83
|
}, refreshSeconds * 1000);
|
|
111
|
-
if (timer.unref) timer.unref();
|
|
84
|
+
if (timer.unref) timer.unref();
|
|
112
85
|
}
|
|
113
|
-
return { workspaceId
|
|
86
|
+
return { workspaceId, refresh, stop: () => { if (timer) clearInterval(timer); } };
|
|
114
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
|
|
39
|
-
// FormLab → Connect to MCP). Otherwise we fall back to
|
|
40
|
-
|
|
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:
|
|
46
|
-
anonKey:
|
|
47
|
-
|
|
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>
|
|
65
|
-
' or: set
|
|
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:
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.juliu1980/formlab-mcp",
|
|
5
|
-
"description": "Read-only Model Context Protocol server for FormLab
|
|
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
|
+
}
|