@heuresis/mcp 1.0.0-rc.1

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 ADDED
@@ -0,0 +1,152 @@
1
+ # @heuresis/mcp
2
+
3
+ A Model Context Protocol (MCP) server that turns the user's Heuresis
4
+ workspace into a thinking substrate any MCP-capable agent (Claude
5
+ Desktop, Claude Code, Cursor, Windsurf, custom agents) can read and
6
+ write — the webapp and the MCP become two front-ends to one cloud
7
+ workspace. The MCP logs into the user's Heuresis account, talks to the
8
+ same Supabase project the webapp talks to, and respects the same RLS.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g @heuresis/mcp
14
+ # or run on demand without installing:
15
+ npx -y @heuresis/mcp@latest
16
+ ```
17
+
18
+ > Not yet published. While in alpha you can run it locally:
19
+ >
20
+ > ```bash
21
+ > cd mcp-server
22
+ > npm install
23
+ > npm run build
24
+ > node dist/index.js --help
25
+ > ```
26
+
27
+ ## Quickstart
28
+
29
+ ### 1. Link this machine to your Heuresis account
30
+
31
+ ```bash
32
+ npx @heuresis/mcp login
33
+ ```
34
+
35
+ The CLI prints a short device code (`XXXX-XXXX`) and a URL. Open the URL in your browser, sign into Heuresis if you aren't already, paste the code, and confirm the device. The CLI polls in the background and writes your credentials to `~/.heuresis/credentials.json` (chmod 600 on POSIX) the moment you confirm. Subsequent runs of the MCP are silent.
36
+
37
+ To unlink a machine, run `npx @heuresis/mcp logout` locally, or open Settings ▸ Connected devices in the webapp to revoke remotely.
38
+
39
+ `npx @heuresis/mcp whoami` confirms which account a machine is currently linked to.
40
+
41
+ ### 2. Point your MCP client at it
42
+
43
+ **Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json`
44
+ on macOS, or `%APPDATA%/Claude/claude_desktop_config.json` on Windows:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "heuresis": { "command": "npx", "args": ["-y", "@heuresis/mcp"] }
50
+ }
51
+ }
52
+ ```
53
+
54
+ **Claude Code / Cursor / Windsurf** — drop a `.mcp.json` in the
55
+ workspace root:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "heuresis": { "command": "npx", "args": ["-y", "@heuresis/mcp"] }
61
+ }
62
+ }
63
+ ```
64
+
65
+ Restart the client. The Heuresis tools appear in the tool menu.
66
+
67
+ ### 3. Optional CLI subcommands
68
+
69
+ ```bash
70
+ npx @heuresis/mcp whoami # show the linked account + device
71
+ npx @heuresis/mcp logout # delete the credentials file
72
+ npx @heuresis/mcp --help # all options
73
+ npx @heuresis/mcp --no-realtime # boot once with live sync turned off (persisted)
74
+ npx @heuresis/mcp --realtime # re-enable live sync
75
+ ```
76
+
77
+ ## Live sync
78
+
79
+ When the MCP boots in cloud mode it subscribes to your workspace over
80
+ Supabase Realtime and notifies the client whenever a `nodes`, `edges`,
81
+ `projects`, or `ideas` row changes. Edits you make in the webapp show
82
+ up in your agent's view without a manual refresh, and writes from one
83
+ MCP-connected client reach any other connected client the same way.
84
+ Pass `--no-realtime` to disable the subscription (useful if the chatter
85
+ is noisy or your client logs every notification). The preference is
86
+ saved to `~/.heuresis/config.json` so you only have to pass the flag
87
+ once.
88
+
89
+ ## Tools
90
+
91
+ This is the **Phase 19.1** tool surface. The remaining tools from
92
+ `docs/mcp-cloud.md` §4 ship in rolling waves under Phase 19.4.
93
+
94
+ ### Reads
95
+
96
+ | Tool | Input |
97
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
98
+ | `list_projects` | `{}` — every project in the workspace with brief, direction, lifecycle, and node count. |
99
+ | `get_project_graph` | `{ projectId, includeArchived?, detail? }` — every node + edge inside one project. |
100
+ | `get_subtree` | `{ rootId, depth?, detail? }` — a node and its descendants up to `depth` generations. |
101
+ | `get_concept` | `{ id, includeAncestry?, includeChildren?, includeIdeaMemberships? }` — one concept with full detail. |
102
+ | `search_concepts` | `{ query, limit?, projectId?, status?, detail? }` — substring search across label / description / partition / tags. Text-only; semantic search lands in 19.4. |
103
+
104
+ ### Writes
105
+
106
+ | Tool | Input |
107
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
108
+ | `add_concept` | `{ label, description?, parentId?, projectId?, tags? }` — create a node; partition edge auto-created if parented. |
109
+ | `update_concept` | `{ id, label?, description?, tags?, partitionAttribute?, rationale?, status? }` — patch a node. |
110
+ | `link_concepts` | `{ fromId, toId, kind }` where `kind ∈ { 'k-ref', 'derived-from', 'semantic-adjacency' }` — create a non-partition edge. |
111
+
112
+ Each tool's input shape mirrors its counterpart in the webapp's
113
+ `src/agent/tools.ts`, so an agent that uses both surfaces sees a
114
+ uniform contract.
115
+
116
+ ## Status
117
+
118
+ **Cloud-authenticated alpha (v0.2.0-alpha, Phase 19.1).**
119
+
120
+ - The 8 tools above hit Supabase live, against the same workspace your
121
+ webapp sees, under the same RLS.
122
+ - Full tool parity (the other 19 tools — operators, k-ref bulk ops,
123
+ validation, standing, ideas, projects-as-targets) ships in waves
124
+ under Phase 19.4 and is **not** included in this build.
125
+ - The `mcp-device-poll` / `mcp-device-grant` Edge Functions that make
126
+ `login` automatic ship in Phase 19.3. Until then the `login`
127
+ subcommand uses the manual paste workaround described above.
128
+ - The Settings ▸ Account ▸ Connected devices UI (where you'd revoke
129
+ this device) ships in Phase 19.6. Until then, revoke at the database
130
+ level.
131
+
132
+ ## Legacy snapshot mode (deprecated)
133
+
134
+ The v0 read-only snapshot reader still works as a fallback while users
135
+ migrate. With no `~/.heuresis/credentials.json` and the
136
+ `HEURESIS_SNAPSHOT` env var set, the server reads a JSON export from
137
+ disk and exposes the v0 read-only tool set (`get_workspace_summary`,
138
+ `list_projects`, `search_concepts`, `get_concept`, `get_subtree`,
139
+ `get_project_graph`, `list_recent_decisions`).
140
+
141
+ ```bash
142
+ export HEURESIS_SNAPSHOT="/absolute/path/to/your-export.json"
143
+ npx @heuresis/mcp
144
+ ```
145
+
146
+ This path is **deprecated** and will be removed after Phase 19.7
147
+ (cloud-auth mandatory). It's here so the current install path keeps
148
+ working through the migration.
149
+
150
+ ## License
151
+
152
+ AGPL-3.0-or-later.
package/dist/cli.js ADDED
@@ -0,0 +1,276 @@
1
+ // Heuresis MCP — CLI subcommand handlers.
2
+ //
3
+ // `npx @heuresis/mcp` with no subcommand → start the MCP stdio server
4
+ // (run by Claude Desktop / Claude Code / etc.). With a subcommand it's a
5
+ // one-shot CLI:
6
+ // login — pair this machine with the user's Heuresis account
7
+ // logout — delete ~/.heuresis/credentials.json
8
+ // whoami — print the linked email + workspace
9
+ // --help — usage
10
+ //
11
+ // AUTH UX (Phase 19.3 — device-code poll flow).
12
+ // ----------------------------------------------------------
13
+ // 1. POST to the `mcp-device-init` Edge Function with the chosen device name.
14
+ // Receive a short XXXX-XXXX code + an expiry.
15
+ // 2. Tell the user to open https://heuresis.app/device (overridable via
16
+ // HEURESIS_DEVICE_BASE_URL for staging / self-hosted setups) and enter
17
+ // the code.
18
+ // 3. Poll `mcp-device-poll` every 5s until status: ok (claim accepted) or
19
+ // 410 (expired / already-used), or 15-minute timeout.
20
+ // 4. On success, write ~/.heuresis/credentials.json with the returned
21
+ // refresh_token + supabase_url + anon_key + user_id + device_name and
22
+ // print "Linked to <email>".
23
+ //
24
+ // The webapp `/device` page calls a third Edge Function `mcp-device-grant`
25
+ // to attach the user's identity to the pending grant row.
26
+ import { createInterface } from 'node:readline/promises';
27
+ import { stdin as input, stdout as output } from 'node:process';
28
+ import { createClient } from '@supabase/supabase-js';
29
+ import { credentialsPath, defaultDeviceName, deleteCredentials, readCredentials, writeCredentials, } from './credentials.js';
30
+ // Where the device pairing UI lives. Production default; can be overridden
31
+ // for staging / self-hosted deploys via HEURESIS_DEVICE_BASE_URL. We also
32
+ // allow HEURESIS_SUPABASE_URL to override which Supabase project the CLI
33
+ // talks to (e.g. a staging instance). Both default to production.
34
+ const DEFAULT_DEVICE_BASE_URL = 'https://heuresis.app';
35
+ const DEFAULT_SUPABASE_URL = 'https://heuresis.supabase.co';
36
+ const POLL_INTERVAL_MS = 5_000;
37
+ const POLL_TIMEOUT_MS = 15 * 60 * 1_000;
38
+ function log(...args) {
39
+ // Use stderr so we don't confuse MCP-client stdout parsers when this CLI
40
+ // is misconfigured into the MCP slot. stderr is always safe.
41
+ console.error(...args);
42
+ }
43
+ function printHelp() {
44
+ log([
45
+ 'heuresis-mcp — Heuresis MCP server (cloud-authenticated, alpha)',
46
+ '',
47
+ 'Usage:',
48
+ ' npx @heuresis/mcp Start the MCP stdio server (run by Claude Desktop, Cursor, etc.)',
49
+ ' npx @heuresis/mcp login Link this machine to your Heuresis account',
50
+ ' --device-name <name> Override the default device name (hostname-shortRand).',
51
+ ' npx @heuresis/mcp logout Remove the saved credentials',
52
+ ' npx @heuresis/mcp whoami Show the linked account',
53
+ ' npx @heuresis/mcp --help Show this message',
54
+ '',
55
+ 'Credentials are stored at:',
56
+ ` ${credentialsPath()}`,
57
+ '',
58
+ 'Environment overrides:',
59
+ ' HEURESIS_DEVICE_BASE_URL Webapp origin (default https://heuresis.app)',
60
+ ' HEURESIS_SUPABASE_URL Supabase project URL (default the heuresis.app project)',
61
+ '',
62
+ 'Legacy snapshot mode (deprecated, removed after 19.7):',
63
+ ' HEURESIS_SNAPSHOT=/path/to/export.json npx @heuresis/mcp',
64
+ ' …falls back to read-only behavior against a JSON export.',
65
+ ].join('\n'));
66
+ }
67
+ async function prompt(question) {
68
+ const rl = createInterface({ input, output, terminal: true });
69
+ try {
70
+ return (await rl.question(question)).trim();
71
+ }
72
+ finally {
73
+ rl.close();
74
+ }
75
+ }
76
+ /** Parse `npx @heuresis/mcp login [--device-name <name>]`. */
77
+ function parseLoginFlags(argv) {
78
+ const opts = {};
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const flag = argv[i];
81
+ if (flag === '--device-name' || flag === '--device') {
82
+ const v = argv[i + 1];
83
+ if (!v) {
84
+ log(`Missing value for ${flag}`);
85
+ process.exit(2);
86
+ }
87
+ opts.deviceName = v;
88
+ i++;
89
+ }
90
+ else {
91
+ log(`Unknown flag: ${flag}`);
92
+ process.exit(2);
93
+ }
94
+ }
95
+ return opts;
96
+ }
97
+ /** Sleep `ms` milliseconds. Resolves only — no rejection path. */
98
+ function sleep(ms) {
99
+ return new Promise((resolve) => setTimeout(resolve, ms));
100
+ }
101
+ async function postJson(url, body) {
102
+ const res = await fetch(url, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify(body),
106
+ });
107
+ let data = null;
108
+ try {
109
+ data = await res.json();
110
+ }
111
+ catch {
112
+ /* leave null */
113
+ }
114
+ return { status: res.status, data };
115
+ }
116
+ export async function loginCommand(argv = []) {
117
+ const opts = parseLoginFlags(argv);
118
+ const deviceName = opts.deviceName ?? defaultDeviceName();
119
+ const deviceBaseUrl = process.env.HEURESIS_DEVICE_BASE_URL ?? DEFAULT_DEVICE_BASE_URL;
120
+ const supabaseUrl = process.env.HEURESIS_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
121
+ log('');
122
+ log('Heuresis MCP — device link (19.3)');
123
+ log('─'.repeat(50));
124
+ // 1. Init — allocate a pairing code.
125
+ const initUrl = `${supabaseUrl}/functions/v1/mcp-device-init`;
126
+ let initRes;
127
+ try {
128
+ initRes = await postJson(initUrl, { device_name: deviceName });
129
+ }
130
+ catch (err) {
131
+ log('');
132
+ log(`Could not reach Heuresis at ${initUrl}.`);
133
+ log(`Error: ${err instanceof Error ? err.message : String(err)}`);
134
+ log('If you are on a private / staging Supabase project, set HEURESIS_SUPABASE_URL.');
135
+ process.exit(1);
136
+ }
137
+ if (initRes.status !== 200) {
138
+ log('');
139
+ log(`Failed to start the pairing flow (HTTP ${initRes.status}).`);
140
+ const data = initRes.data;
141
+ if (data?.error)
142
+ log(` ${data.error}${data.detail ? ` — ${data.detail}` : ''}`);
143
+ process.exit(1);
144
+ }
145
+ const init = initRes.data;
146
+ if (!init?.code) {
147
+ log('Pairing init returned no code. Aborting.');
148
+ process.exit(1);
149
+ }
150
+ // 2. Tell the user where to go.
151
+ log('');
152
+ log(`1. Open this page in your browser:`);
153
+ log(` ${deviceBaseUrl}/device`);
154
+ log('');
155
+ log(`2. Enter this code (case-insensitive):`);
156
+ log(` ${init.code}`);
157
+ log('');
158
+ log(`3. This window will finish on its own once you confirm. The code`);
159
+ log(` expires in 15 minutes.`);
160
+ log('');
161
+ log(`Waiting for confirmation…`);
162
+ // 3. Poll until ok / 410 / timeout.
163
+ const pollUrl = `${supabaseUrl}/functions/v1/mcp-device-poll`;
164
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
165
+ let success = null;
166
+ while (Date.now() < deadline) {
167
+ await sleep(POLL_INTERVAL_MS);
168
+ let pollRes;
169
+ try {
170
+ pollRes = await postJson(pollUrl, { code: init.code });
171
+ }
172
+ catch (err) {
173
+ // Transient network errors don't kill the loop — log once and keep polling.
174
+ log(` (network blip: ${err instanceof Error ? err.message : String(err)}; retrying)`);
175
+ continue;
176
+ }
177
+ if (pollRes.status === 410) {
178
+ log('');
179
+ log('That code expired or was already used. Run `npx @heuresis/mcp login` again to start over.');
180
+ process.exit(1);
181
+ }
182
+ if (pollRes.status === 202) {
183
+ // Still pending — wait for the next tick.
184
+ continue;
185
+ }
186
+ if (pollRes.status === 200) {
187
+ const data = pollRes.data;
188
+ if (data && data.status === 'ok') {
189
+ success = data;
190
+ break;
191
+ }
192
+ // Unexpected 200 shape — keep trying until timeout rather than fail
193
+ // catastrophically; the next poll will likely clarify.
194
+ continue;
195
+ }
196
+ // Anything else: log and keep polling. The function may transiently 5xx.
197
+ log(` (poll returned HTTP ${pollRes.status}; retrying)`);
198
+ }
199
+ if (!success) {
200
+ log('');
201
+ log('Timed out waiting for confirmation. Run `npx @heuresis/mcp login` again.');
202
+ process.exit(1);
203
+ }
204
+ // 4. Verify the refresh token works + fetch the user's email to print.
205
+ const client = createClient(success.supabase_url, success.anon_key, {
206
+ auth: {
207
+ persistSession: false,
208
+ autoRefreshToken: false,
209
+ detectSessionInUrl: false,
210
+ },
211
+ });
212
+ let email = '(no email on record)';
213
+ try {
214
+ const { data, error } = await client.auth.setSession({
215
+ access_token: '',
216
+ refresh_token: success.refresh_token,
217
+ });
218
+ if (error || !data.session || !data.user) {
219
+ log('');
220
+ log(`Pairing returned a token, but it failed to refresh: ${error?.message ?? 'no session'}`);
221
+ log('Run `npx @heuresis/mcp login` again to retry.');
222
+ process.exit(1);
223
+ }
224
+ email = data.user.email ?? email;
225
+ // Use whatever supabase-js handed us back — it may have already rotated
226
+ // the refresh token at the first refresh.
227
+ success.refresh_token = data.session.refresh_token ?? success.refresh_token;
228
+ }
229
+ catch (err) {
230
+ log(`Verification of the new refresh token failed: ${err instanceof Error ? err.message : String(err)}`);
231
+ process.exit(1);
232
+ }
233
+ const creds = {
234
+ supabase_url: success.supabase_url,
235
+ anon_key: success.anon_key,
236
+ refresh_token: success.refresh_token,
237
+ user_id: success.user_id,
238
+ device_name: success.device_name || deviceName,
239
+ created_at: new Date().toISOString(),
240
+ };
241
+ const path = await writeCredentials(creds);
242
+ log('');
243
+ log(`Linked to ${email} as device "${creds.device_name}".`);
244
+ log(`Credentials saved to ${path} (chmod 600 on POSIX).`);
245
+ log('You can now point Claude Desktop / Claude Code at @heuresis/mcp.');
246
+ log('');
247
+ }
248
+ export async function logoutCommand() {
249
+ const removed = await deleteCredentials();
250
+ if (removed) {
251
+ log('Heuresis credentials removed.');
252
+ }
253
+ else {
254
+ log('No Heuresis credentials were found.');
255
+ }
256
+ }
257
+ export async function whoamiCommand() {
258
+ const creds = await readCredentials();
259
+ if (!creds) {
260
+ log('Not linked. Run `npx @heuresis/mcp login` to pair this machine.');
261
+ process.exit(1);
262
+ }
263
+ log(`Heuresis MCP — linked`);
264
+ log(` device: ${creds.device_name}`);
265
+ log(` user_id: ${creds.user_id}`);
266
+ log(` supabase_url: ${creds.supabase_url}`);
267
+ log(` created_at: ${creds.created_at}`);
268
+ log(` credentials: ${credentialsPath()}`);
269
+ }
270
+ export function helpCommand() {
271
+ printHelp();
272
+ }
273
+ // The unused `prompt` helper would only be used if we re-introduced any
274
+ // interactive form; export it so future subcommands can pick it up without
275
+ // re-implementing readline plumbing.
276
+ export { prompt };
@@ -0,0 +1,71 @@
1
+ // Heuresis MCP — Supabase client wrapper.
2
+ //
3
+ // One SupabaseClient per MCP process. We DON'T let supabase-js persist the
4
+ // session to localStorage (no such thing in Node) — instead we manage the
5
+ // session manually:
6
+ //
7
+ // 1. At process start: read ~/.heuresis/credentials.json → call
8
+ // `client.auth.setSession({ access_token: '', refresh_token })`.
9
+ // supabase-js immediately refreshes the access token from the refresh
10
+ // token and stores both in memory.
11
+ // 2. supabase-js handles silent re-refresh in the background while the
12
+ // process runs. We don't have to do anything per-tool-call.
13
+ // 3. If the refresh fails (revoked, expired), tool calls 401 — the wrapper
14
+ // catches that and surfaces a "re-run `npx @heuresis/mcp login`" message.
15
+ //
16
+ // Phase 19.3 will swap the refresh-token issuance to come from the
17
+ // `mcp-device-poll` Edge Function, but the client-side shape doesn't change.
18
+ import { createClient } from '@supabase/supabase-js';
19
+ let cached = null;
20
+ export class CloudAuthError extends Error {
21
+ constructor(msg) {
22
+ super(msg);
23
+ this.name = 'CloudAuthError';
24
+ }
25
+ }
26
+ /**
27
+ * Build (or return cached) a Supabase client bound to the credentials on
28
+ * disk. Throws CloudAuthError if no credentials exist or if the refresh
29
+ * token has been revoked.
30
+ */
31
+ export async function getCloudClient(creds) {
32
+ if (cached)
33
+ return cached;
34
+ const client = createClient(creds.supabase_url, creds.anon_key, {
35
+ auth: {
36
+ // Headless: no localStorage, no URL detection, no auto-refresh
37
+ // listeners writing to disk. The library still auto-refreshes the
38
+ // in-memory access token from the refresh token, which is all we want.
39
+ persistSession: false,
40
+ autoRefreshToken: true,
41
+ detectSessionInUrl: false,
42
+ },
43
+ });
44
+ // Bootstrap the session from the refresh token. setSession with an empty
45
+ // access_token forces an immediate refresh against the refresh_token.
46
+ const { data, error } = await client.auth.setSession({
47
+ access_token: '',
48
+ refresh_token: creds.refresh_token,
49
+ });
50
+ if (error || !data.session) {
51
+ throw new CloudAuthError(`Failed to refresh Heuresis session: ${error?.message ?? 'no session returned'}. Run \`npx @heuresis/mcp login\` to re-authenticate.`);
52
+ }
53
+ cached = { client, userId: creds.user_id };
54
+ return cached;
55
+ }
56
+ /** Clear the cached client. Used after logout. */
57
+ export function resetCloudClient() {
58
+ cached = null;
59
+ }
60
+ /**
61
+ * Convenience wrapper that surfaces Postgres / auth errors as a readable
62
+ * MCP tool error. supabase-js returns `{ data, error }` everywhere; this
63
+ * unwraps it.
64
+ */
65
+ export function unwrap(res) {
66
+ if (res.error)
67
+ throw new Error(res.error.message);
68
+ if (res.data === null)
69
+ throw new Error('Empty result from cloud.');
70
+ return res.data;
71
+ }