@heuresis/mcp 1.0.0-rc.12 → 1.0.0-rc.14
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 +31 -1
- package/dist/cli.js +37 -11
- package/dist/cloudClient.js +92 -21
- package/dist/gotrue.js +96 -14
- package/dist/httpRetry.js +64 -0
- package/dist/index.js +68 -26
- package/dist/prompt/compose.js +75 -75
- package/dist/proxy.js +56 -22
- package/dist/zod-to-json-schema.js +26 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ account, talks to the same Supabase project the webapp talks to, and
|
|
|
7
7
|
respects the same RLS. Webapp and MCP are two front-ends to one cloud
|
|
8
8
|
workspace.
|
|
9
9
|
|
|
10
|
-
Current version: `1.0.0-rc.
|
|
10
|
+
Current version: `1.0.0-rc.13`.
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -89,6 +89,36 @@ npx -y @heuresis/mcp --no-realtime # boot the server with live sync o
|
|
|
89
89
|
npx -y @heuresis/mcp --realtime # re-enable live sync
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
## Headless mode (CI, cloud agents, disposable containers)
|
|
93
|
+
|
|
94
|
+
Device pairing writes a **refresh token** to disk. That works great on a
|
|
95
|
+
personal machine, but it does **not** survive disposable/ephemeral
|
|
96
|
+
environments (CI runners, cloud agent containers, "Claude Code on the web"):
|
|
97
|
+
the filesystem is wiped between runs, and a Supabase refresh token is
|
|
98
|
+
**single-use under rotation** — so a token baked into config dies after the
|
|
99
|
+
first session.
|
|
100
|
+
|
|
101
|
+
For those environments, skip pairing and let the server **sign in fresh on
|
|
102
|
+
every boot** from your account email + password (a password is not consumed on
|
|
103
|
+
use, so it works forever with no re-pairing). Set three env vars:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
HEURESIS_EMAIL=you@example.com # your Heuresis account email
|
|
107
|
+
HEURESIS_PASSWORD=your-account-password # secret — store it in a secrets manager
|
|
108
|
+
HEURESIS_ANON_KEY=sb_publishable_... # project anon/publishable key (public, not a secret)
|
|
109
|
+
# optional: HEURESIS_SUPABASE_URL=... # defaults to the production project
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When `HEURESIS_EMAIL` + `HEURESIS_PASSWORD` are present they take precedence
|
|
113
|
+
over any `credentials.json`, and the MCP server authenticates per boot — no
|
|
114
|
+
device link required. Requirements:
|
|
115
|
+
|
|
116
|
+
- Email + password sign-in must be enabled for the Supabase project, and the
|
|
117
|
+
account must have a password set (passwordless / magic-link-only accounts
|
|
118
|
+
need a password added first).
|
|
119
|
+
- Treat `HEURESIS_PASSWORD` as a secret. Prefer a dedicated account if your
|
|
120
|
+
environment can only expose env vars that are visible to its users.
|
|
121
|
+
|
|
92
122
|
## Live sync
|
|
93
123
|
|
|
94
124
|
When the MCP boots in cloud mode it subscribes to the workspace over
|
package/dist/cli.js
CHANGED
|
@@ -35,7 +35,7 @@ import { ensureProxyAgent } from './proxy.js';
|
|
|
35
35
|
// allow HEURESIS_SUPABASE_URL to override which Supabase project the CLI
|
|
36
36
|
// talks to (e.g. a staging instance). Both default to production.
|
|
37
37
|
const DEFAULT_DEVICE_BASE_URL = 'https://heuresis.app';
|
|
38
|
-
const DEFAULT_SUPABASE_URL = 'https://wpgniquyuppljeqkedqh.supabase.co';
|
|
38
|
+
export const DEFAULT_SUPABASE_URL = 'https://wpgniquyuppljeqkedqh.supabase.co';
|
|
39
39
|
const POLL_INTERVAL_MS = 5_000;
|
|
40
40
|
const POLL_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
41
41
|
function log(...args) {
|
|
@@ -101,21 +101,47 @@ function parseLoginFlags(argv) {
|
|
|
101
101
|
function sleep(ms) {
|
|
102
102
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
// POST JSON with bounded retry + a per-attempt timeout. The device-pairing
|
|
105
|
+
// endpoints sit behind the same Supabase edge as the auth token endpoint, so
|
|
106
|
+
// they hit the same intermittently-dropped-TLS-handshake problem (see
|
|
107
|
+
// gotrue.ts/postWithRetry): a lone fetch fails with "fetch failed" even though
|
|
108
|
+
// a retry moments later lands. We retry transient transport errors and 5xx/429
|
|
109
|
+
// here too; 4xx and the poll's own 202/410 signals are returned to the caller
|
|
110
|
+
// unchanged. Throws the last transport error only after every attempt fails.
|
|
111
|
+
async function postJson(url, body, { attempts = 4, perAttemptTimeoutMs = 8000 } = {}) {
|
|
105
112
|
await ensureProxyAgent(log);
|
|
106
|
-
const
|
|
113
|
+
const init = {
|
|
107
114
|
method: 'POST',
|
|
108
115
|
headers: { 'Content-Type': 'application/json' },
|
|
109
116
|
body: JSON.stringify(body),
|
|
110
|
-
}
|
|
111
|
-
let
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
};
|
|
118
|
+
let lastErr;
|
|
119
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(perAttemptTimeoutMs) });
|
|
122
|
+
if (res.status === 429 || (res.status >= 500 && res.status <= 599)) {
|
|
123
|
+
lastErr = new Error(`HTTP ${res.status}`); // transient — retry
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
let data = null;
|
|
127
|
+
try {
|
|
128
|
+
data = await res.json();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* leave null */
|
|
132
|
+
}
|
|
133
|
+
return { status: res.status, data };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
lastErr = err; // network drop / TLS reset / per-attempt timeout
|
|
138
|
+
}
|
|
139
|
+
if (attempt < attempts) {
|
|
140
|
+
// Backoff: 250ms, 500ms, 1000ms, … capped at 2s.
|
|
141
|
+
await sleep(Math.min(250 * 2 ** (attempt - 1), 2000));
|
|
142
|
+
}
|
|
117
143
|
}
|
|
118
|
-
|
|
144
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
119
145
|
}
|
|
120
146
|
export async function loginCommand(argv = []) {
|
|
121
147
|
const opts = parseLoginFlags(argv);
|
package/dist/cloudClient.js
CHANGED
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
// Polyfill global WebSocket on Node < 22 before any Supabase client is built.
|
|
20
20
|
import './wsPolyfill.js';
|
|
21
21
|
import { createClient } from '@supabase/supabase-js';
|
|
22
|
-
import {
|
|
22
|
+
import { writeCredentials } from './credentials.js';
|
|
23
|
+
import { exchangeRefreshToken, signInWithPassword } from './gotrue.js';
|
|
24
|
+
import { makeRetryingFetch } from './httpRetry.js';
|
|
25
|
+
// Shared across every Supabase client we build: PostgREST queries and auth-js's
|
|
26
|
+
// background refresh both go through this, so a flaky route can't hang or
|
|
27
|
+
// one-shot-fail a data call (see httpRetry.ts).
|
|
28
|
+
const retryingFetch = makeRetryingFetch();
|
|
23
29
|
let cached = null;
|
|
24
30
|
export class CloudAuthError extends Error {
|
|
25
31
|
constructor(msg) {
|
|
@@ -28,14 +34,12 @@ export class CloudAuthError extends Error {
|
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* token
|
|
37
|
+
* Create a headless Supabase client and seed it with an already-obtained
|
|
38
|
+
* GoTrue session, so every subsequent PostgREST call carries the user's JWT
|
|
39
|
+
* and supabase-js keeps the in-memory access token alive. Caches the result.
|
|
34
40
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return cached;
|
|
38
|
-
const client = createClient(creds.supabase_url, creds.anon_key, {
|
|
41
|
+
async function seedClient(supabaseUrl, anonKey, session, userId) {
|
|
42
|
+
const client = createClient(supabaseUrl, anonKey, {
|
|
39
43
|
auth: {
|
|
40
44
|
// Headless: no localStorage, no URL detection, no auto-refresh
|
|
41
45
|
// listeners writing to disk. The library still auto-refreshes the
|
|
@@ -44,29 +48,96 @@ export async function getCloudClient(creds) {
|
|
|
44
48
|
autoRefreshToken: true,
|
|
45
49
|
detectSessionInUrl: false,
|
|
46
50
|
},
|
|
51
|
+
// Harden every PostgREST query / background refresh against the flaky
|
|
52
|
+
// route to the Supabase edge: bounded timeout + retry instead of an
|
|
53
|
+
// unbounded hang on a dropped TLS handshake.
|
|
54
|
+
global: { fetch: retryingFetch },
|
|
47
55
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
const { error } = await client.auth.setSession({
|
|
57
|
+
access_token: session.access_token,
|
|
58
|
+
refresh_token: session.refresh_token,
|
|
59
|
+
});
|
|
60
|
+
if (error) {
|
|
61
|
+
throw new CloudAuthError(`Failed to seed Heuresis session: ${error.message}.`);
|
|
62
|
+
}
|
|
63
|
+
cached = { client, userId };
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Persist a rotated refresh token back to ~/.heuresis/credentials.json.
|
|
68
|
+
*
|
|
69
|
+
* GoTrue rotates the refresh token on every exchange and invalidates the old
|
|
70
|
+
* one. The bootstrap exchange — and supabase-js's later silent auto-refreshes —
|
|
71
|
+
* therefore make the on-disk token stale the moment we use it. If we never
|
|
72
|
+
* write the replacement back, the NEXT process start reads a spent token and
|
|
73
|
+
* fails with "Refresh Token Not Found", forcing a needless re-login (the MCP
|
|
74
|
+
* server is restarted on every Claude reconnect, so this bites constantly).
|
|
75
|
+
* Writing the new token back keeps the stored credential usable across
|
|
76
|
+
* restarts. Best-effort: a failed disk write must never break the live
|
|
77
|
+
* in-memory session, which is already valid for this process.
|
|
78
|
+
*/
|
|
79
|
+
async function persistRotatedToken(creds, newToken) {
|
|
80
|
+
if (!newToken || newToken === creds.refresh_token)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
await writeCredentials({ ...creds, refresh_token: newToken });
|
|
84
|
+
creds.refresh_token = newToken; // keep the in-memory copy in sync
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* non-fatal — the in-memory session stays valid for this process */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Build (or return cached) a Supabase client bound to the credentials on
|
|
92
|
+
* disk. Bootstraps by exchanging the stored (rotating) refresh token, then
|
|
93
|
+
* persists the rotated replacement back to disk and keeps it in sync as
|
|
94
|
+
* supabase-js silently re-refreshes over the process lifetime — so the stored
|
|
95
|
+
* credential survives restarts. Throws CloudAuthError if the refresh token has
|
|
96
|
+
* been revoked/rotated away.
|
|
97
|
+
*/
|
|
98
|
+
export async function getCloudClient(creds) {
|
|
99
|
+
if (cached)
|
|
100
|
+
return cached;
|
|
52
101
|
try {
|
|
53
102
|
const session = await exchangeRefreshToken(creds.supabase_url, creds.anon_key, creds.refresh_token);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
103
|
+
// The exchange just consumed the on-disk token and rotated in a new one;
|
|
104
|
+
// persist it before anything else so a crash here can't strand us.
|
|
105
|
+
await persistRotatedToken(creds, session.refresh_token);
|
|
106
|
+
const result = await seedClient(creds.supabase_url, creds.anon_key, session, creds.user_id);
|
|
107
|
+
// Keep disk current as supabase-js auto-refreshes the token while we run.
|
|
108
|
+
result.client.auth.onAuthStateChange((event, s) => {
|
|
109
|
+
if ((event === 'TOKEN_REFRESHED' || event === 'SIGNED_IN') && s?.refresh_token) {
|
|
110
|
+
void persistRotatedToken(creds, s.refresh_token);
|
|
111
|
+
}
|
|
57
112
|
});
|
|
58
|
-
|
|
59
|
-
throw new CloudAuthError(`Failed to seed Heuresis session: ${error.message}. ` +
|
|
60
|
-
'Run `npx -y -p @heuresis/mcp heuresis-mcp login` to re-authenticate.');
|
|
61
|
-
}
|
|
113
|
+
return result;
|
|
62
114
|
}
|
|
63
115
|
catch (err) {
|
|
64
116
|
if (err instanceof CloudAuthError)
|
|
65
117
|
throw err;
|
|
66
118
|
throw new CloudAuthError(`Failed to refresh Heuresis session: ${err instanceof Error ? err.message : String(err)}. Run \`npx -y -p @heuresis/mcp heuresis-mcp login\` to re-authenticate.`);
|
|
67
119
|
}
|
|
68
|
-
|
|
69
|
-
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build (or return cached) a Supabase client by signing in fresh with an
|
|
123
|
+
* email + password. Because a password is not consumed on use, this works
|
|
124
|
+
* durably across disposable/ephemeral sessions that re-authenticate on every
|
|
125
|
+
* boot — no persisted, rotating refresh token required. Throws CloudAuthError
|
|
126
|
+
* on bad credentials or if password sign-in is disabled for the project.
|
|
127
|
+
*/
|
|
128
|
+
export async function getCloudClientFromPassword(supabaseUrl, anonKey, email, password) {
|
|
129
|
+
if (cached)
|
|
130
|
+
return cached;
|
|
131
|
+
try {
|
|
132
|
+
const session = await signInWithPassword(supabaseUrl, anonKey, email, password);
|
|
133
|
+
const userId = session.user?.id ?? '(unknown)';
|
|
134
|
+
return await seedClient(supabaseUrl, anonKey, session, userId);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err instanceof CloudAuthError)
|
|
138
|
+
throw err;
|
|
139
|
+
throw new CloudAuthError(`Headless email/password sign-in failed: ${err instanceof Error ? err.message : String(err)}. Check HEURESIS_EMAIL / HEURESIS_PASSWORD / HEURESIS_ANON_KEY.`);
|
|
140
|
+
}
|
|
70
141
|
}
|
|
71
142
|
/** Clear the cached client. Used after logout. */
|
|
72
143
|
export function resetCloudClient() {
|
package/dist/gotrue.js
CHANGED
|
@@ -28,6 +28,43 @@ export class RefreshTokenError extends Error {
|
|
|
28
28
|
this.name = 'RefreshTokenError';
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* POST to a GoTrue token endpoint with bounded retry + a per-attempt timeout.
|
|
33
|
+
*
|
|
34
|
+
* Some networks have a flaky route to the Supabase edge: the TCP connect
|
|
35
|
+
* succeeds but the TLS handshake is intermittently dropped, so a single
|
|
36
|
+
* `fetch` fails ("fetch failed") even though a retry moments later lands. A
|
|
37
|
+
* lone request with no timeout/retry turns that transient drop into a fatal
|
|
38
|
+
* auth failure. We retry transient *transport* errors (the fetch throw) and
|
|
39
|
+
* 5xx/429 responses with short backoff; we do NOT retry 4xx (bad/expired
|
|
40
|
+
* token — retrying can't fix it), returning that Response to the caller for
|
|
41
|
+
* its normal error handling. Throws `RefreshTokenError` only after every
|
|
42
|
+
* attempt has failed to reach the endpoint.
|
|
43
|
+
*/
|
|
44
|
+
async function postWithRetry(url, init, { attempts = 4, perAttemptTimeoutMs = 8000 } = {}) {
|
|
45
|
+
let lastErr;
|
|
46
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(perAttemptTimeoutMs) });
|
|
49
|
+
// Retry only on transient server-side statuses; hand everything else back.
|
|
50
|
+
if (res.status === 429 || (res.status >= 500 && res.status <= 599)) {
|
|
51
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
return res;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
lastErr = err; // network drop / TLS reset / per-attempt timeout
|
|
59
|
+
}
|
|
60
|
+
if (attempt < attempts) {
|
|
61
|
+
// Backoff: 250ms, 500ms, 1000ms, ... capped at 2s.
|
|
62
|
+
const delay = Math.min(250 * 2 ** (attempt - 1), 2000);
|
|
63
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new RefreshTokenError(`Could not reach the auth endpoint at ${url} after ${attempts} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
67
|
+
}
|
|
31
68
|
/**
|
|
32
69
|
* Exchange a refresh token for a fresh session via the GoTrue token endpoint:
|
|
33
70
|
*
|
|
@@ -45,21 +82,64 @@ export async function exchangeRefreshToken(supabaseUrl, anonKey, refreshToken) {
|
|
|
45
82
|
'The pairing response did not carry a usable token.');
|
|
46
83
|
}
|
|
47
84
|
const url = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/token?grant_type=refresh_token`;
|
|
48
|
-
|
|
85
|
+
const res = await postWithRetry(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
apikey: anonKey,
|
|
89
|
+
Authorization: `Bearer ${anonKey}`,
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
93
|
+
});
|
|
94
|
+
let payload = null;
|
|
49
95
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
});
|
|
96
|
+
payload = await res.json();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* leave null — handled below */
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const err = payload;
|
|
103
|
+
const detail = err?.error_description ?? err?.msg ?? err?.error ?? err?.message ?? `HTTP ${res.status}`;
|
|
104
|
+
throw new RefreshTokenError(`Token refresh failed (HTTP ${res.status}): ${detail}`);
|
|
105
|
+
}
|
|
106
|
+
const session = payload;
|
|
107
|
+
if (!session || !session.access_token || !session.refresh_token) {
|
|
108
|
+
const keys = session && typeof session === 'object'
|
|
109
|
+
? Object.keys(session).join(', ') || '(empty object)'
|
|
110
|
+
: '(no JSON body)';
|
|
111
|
+
throw new RefreshTokenError(`Token refresh succeeded (HTTP ${res.status}) but the response is missing ` +
|
|
112
|
+
`access_token/refresh_token. Response keys: [${keys}].`);
|
|
59
113
|
}
|
|
60
|
-
|
|
61
|
-
|
|
114
|
+
return session;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Sign in with an email + password directly against the GoTrue token endpoint:
|
|
118
|
+
*
|
|
119
|
+
* POST `${supabaseUrl}/auth/v1/token?grant_type=password`
|
|
120
|
+
* headers: { apikey, Authorization: Bearer <anon>, Content-Type: application/json }
|
|
121
|
+
* body: { email, password }
|
|
122
|
+
*
|
|
123
|
+
* Unlike a refresh token, a password is NOT consumed on use, so this is the
|
|
124
|
+
* right primitive for headless, ephemeral environments (e.g. cloud agent
|
|
125
|
+
* containers) that re-authenticate from scratch on every boot. Returns the
|
|
126
|
+
* full session (access_token + refresh_token + user). Throws
|
|
127
|
+
* `RefreshTokenError` with an actionable message on any failure.
|
|
128
|
+
*/
|
|
129
|
+
export async function signInWithPassword(supabaseUrl, anonKey, email, password) {
|
|
130
|
+
if (!email || !password) {
|
|
131
|
+
throw new RefreshTokenError('Headless sign-in needs both an email and a password (HEURESIS_EMAIL / HEURESIS_PASSWORD).');
|
|
62
132
|
}
|
|
133
|
+
const url = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/token?grant_type=password`;
|
|
134
|
+
const res = await postWithRetry(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
apikey: anonKey,
|
|
138
|
+
Authorization: `Bearer ${anonKey}`,
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({ email, password }),
|
|
142
|
+
});
|
|
63
143
|
let payload = null;
|
|
64
144
|
try {
|
|
65
145
|
payload = await res.json();
|
|
@@ -70,14 +150,16 @@ export async function exchangeRefreshToken(supabaseUrl, anonKey, refreshToken) {
|
|
|
70
150
|
if (!res.ok) {
|
|
71
151
|
const err = payload;
|
|
72
152
|
const detail = err?.error_description ?? err?.msg ?? err?.error ?? err?.message ?? `HTTP ${res.status}`;
|
|
73
|
-
throw new RefreshTokenError(`
|
|
153
|
+
throw new RefreshTokenError(`Email/password sign-in failed (HTTP ${res.status}): ${detail}. ` +
|
|
154
|
+
'Check HEURESIS_EMAIL / HEURESIS_PASSWORD, and that email+password sign-in is ' +
|
|
155
|
+
'enabled for the Supabase project.');
|
|
74
156
|
}
|
|
75
157
|
const session = payload;
|
|
76
158
|
if (!session || !session.access_token || !session.refresh_token) {
|
|
77
159
|
const keys = session && typeof session === 'object'
|
|
78
160
|
? Object.keys(session).join(', ') || '(empty object)'
|
|
79
161
|
: '(no JSON body)';
|
|
80
|
-
throw new RefreshTokenError(`
|
|
162
|
+
throw new RefreshTokenError(`Sign-in succeeded (HTTP ${res.status}) but the response is missing ` +
|
|
81
163
|
`access_token/refresh_token. Response keys: [${keys}].`);
|
|
82
164
|
}
|
|
83
165
|
return session;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Heuresis MCP — a retrying, timeout-bounded fetch for flaky networks.
|
|
2
|
+
//
|
|
3
|
+
// Some networks have an intermittently-broken route to the Supabase edge: the
|
|
4
|
+
// TCP connect succeeds but the TLS handshake is sporadically dropped, so a lone
|
|
5
|
+
// request fails with "fetch failed" (or, worse, a plain fetch with no timeout
|
|
6
|
+
// hangs indefinitely) even though an identical request moments later lands.
|
|
7
|
+
// This wrapper bounds each attempt with a timeout and retries transient
|
|
8
|
+
// failures with exponential backoff. It is handed to supabase-js as its global
|
|
9
|
+
// `fetch`, so every PostgREST query and auth-js background refresh inherits the
|
|
10
|
+
// resilience — mirroring what gotrue.ts/postWithRetry and cli.ts/postJson do
|
|
11
|
+
// for the calls they own.
|
|
12
|
+
/**
|
|
13
|
+
* Build a `fetch`-compatible function with bounded retry + per-attempt timeout.
|
|
14
|
+
*
|
|
15
|
+
* Retry policy is idempotency-aware so we never risk double-applying a write:
|
|
16
|
+
* - Transport errors (no response: TLS reset, connection drop, per-attempt
|
|
17
|
+
* timeout) are retried for ANY method — the request almost certainly never
|
|
18
|
+
* reached the server.
|
|
19
|
+
* - 429 / 5xx are retried ONLY for idempotent methods (GET/HEAD). A POST/
|
|
20
|
+
* PATCH/DELETE that got a 5xx may have been applied server-side, so we hand
|
|
21
|
+
* that response back unretried and let the caller decide.
|
|
22
|
+
* A caller-supplied AbortSignal is honored: if it aborts, we stop immediately
|
|
23
|
+
* and never retry (the caller asked to cancel).
|
|
24
|
+
*/
|
|
25
|
+
export function makeRetryingFetch(opts = {}) {
|
|
26
|
+
const attempts = opts.attempts ?? 4;
|
|
27
|
+
const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 10_000;
|
|
28
|
+
return (async (input, init) => {
|
|
29
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
30
|
+
const idempotent = method === 'GET' || method === 'HEAD';
|
|
31
|
+
const callerSignal = init?.signal ?? undefined;
|
|
32
|
+
let lastErr;
|
|
33
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
34
|
+
if (callerSignal?.aborted)
|
|
35
|
+
throw callerSignal.reason ?? new Error('Aborted');
|
|
36
|
+
// Per-attempt timeout, combined with any caller signal so a caller-side
|
|
37
|
+
// cancel still propagates.
|
|
38
|
+
const timeout = AbortSignal.timeout(perAttemptTimeoutMs);
|
|
39
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(input, { ...init, signal });
|
|
42
|
+
if (res.status === 429 || (res.status >= 500 && res.status <= 599)) {
|
|
43
|
+
if (!idempotent)
|
|
44
|
+
return res; // don't retry a non-idempotent server error
|
|
45
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
return res;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
// A caller-initiated abort is terminal — surface it, don't retry.
|
|
53
|
+
if (callerSignal?.aborted)
|
|
54
|
+
throw err;
|
|
55
|
+
lastErr = err; // transport error / per-attempt timeout — retry
|
|
56
|
+
}
|
|
57
|
+
if (attempt < attempts) {
|
|
58
|
+
// Backoff: 250ms, 500ms, 1000ms, … capped at 2s.
|
|
59
|
+
await new Promise((r) => setTimeout(r, Math.min(250 * 2 ** (attempt - 1), 2000)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
63
|
+
});
|
|
64
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -23,9 +23,9 @@ import { HeuresisStore } from './store.js';
|
|
|
23
23
|
import { getConcept as legacyGetConcept, getConceptInput as legacyGetConceptInput, getProjectGraph as legacyGetProjectGraph, getProjectGraphInput as legacyGetProjectGraphInput, getSubtree as legacyGetSubtree, getSubtreeInput as legacyGetSubtreeInput, getWorkspaceSummary as legacyGetWorkspaceSummary, getWorkspaceSummaryInput as legacyGetWorkspaceSummaryInput, listProjects as legacyListProjects, listProjectsInput as legacyListProjectsInput, listRecentDecisions as legacyListRecentDecisions, listRecentDecisionsInput as legacyListRecentDecisionsInput, searchConcepts as legacySearchConcepts, searchConceptsInput as legacySearchConceptsInput, } from './tools.js';
|
|
24
24
|
import { CLOUD_TOOLS } from './cloudTools.js';
|
|
25
25
|
import { readCredentials } from './credentials.js';
|
|
26
|
-
import { CloudAuthError, getCloudClient } from './cloudClient.js';
|
|
26
|
+
import { CloudAuthError, getCloudClient, getCloudClientFromPassword } from './cloudClient.js';
|
|
27
27
|
import { ensureProxyAgent } from './proxy.js';
|
|
28
|
-
import { helpCommand, loginCommand, logoutCommand, whoamiCommand, } from './cli.js';
|
|
28
|
+
import { DEFAULT_SUPABASE_URL, helpCommand, loginCommand, logoutCommand, whoamiCommand, } from './cli.js';
|
|
29
29
|
import { readRealtimeFlag, resolveSubscriptionWorkspaceId, startRealtimeSubscription, stripRealtimeFlags, } from './realtime.js';
|
|
30
30
|
const VERSION = '0.2.0-alpha';
|
|
31
31
|
const MAX_RESULT_CHARS = 50_000;
|
|
@@ -46,6 +46,21 @@ function makeCloudTools(getClient, operatorTools) {
|
|
|
46
46
|
handler: async (args) => t.handler(await getClient(), args),
|
|
47
47
|
}));
|
|
48
48
|
}
|
|
49
|
+
// Phase 19.5 — try to load LLM-backed Operator tools. The module may be absent
|
|
50
|
+
// or export nothing; in that case we fall back to just the Phase 19.4 parity
|
|
51
|
+
// set. Wrapping the dynamic import in try/catch keeps the server starting
|
|
52
|
+
// cleanly either way.
|
|
53
|
+
async function loadOperatorTools() {
|
|
54
|
+
try {
|
|
55
|
+
const mod = (await import('./cloudOperators.js').catch(() => null));
|
|
56
|
+
if (mod && Array.isArray(mod.OPERATOR_TOOLS))
|
|
57
|
+
return mod.OPERATOR_TOOLS;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* fall through to empty */
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
49
64
|
function makeLegacySnapshotTools(store) {
|
|
50
65
|
// LEGACY FALLBACK — removed after 19.7. Read-only, no auth, snapshot file.
|
|
51
66
|
return [
|
|
@@ -99,38 +114,59 @@ async function runServer() {
|
|
|
99
114
|
await ensureProxyAgent(console.error);
|
|
100
115
|
const creds = await readCredentials();
|
|
101
116
|
const snapshotEnv = process.env.HEURESIS_SNAPSHOT;
|
|
117
|
+
// Headless credential (durable across ephemeral/disposable sessions): when
|
|
118
|
+
// HEURESIS_EMAIL + HEURESIS_PASSWORD are set, the server signs in fresh on
|
|
119
|
+
// every boot. Unlike a persisted refresh token — which is single-use under
|
|
120
|
+
// Supabase rotation and dies after one session — a password is not consumed,
|
|
121
|
+
// so this survives container resets with zero re-pairing. It takes
|
|
122
|
+
// precedence over a (possibly stale) credentials.json.
|
|
123
|
+
const headlessEmail = process.env.HEURESIS_EMAIL?.trim();
|
|
124
|
+
const headlessPassword = process.env.HEURESIS_PASSWORD;
|
|
102
125
|
let tools;
|
|
103
126
|
let modeBanner;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
127
|
+
// Single cloud client getter, shared by the tool handlers and the realtime
|
|
128
|
+
// subscription. null in legacy snapshot / unconfigured modes.
|
|
129
|
+
let cloudGetClient = null;
|
|
130
|
+
if (headlessEmail && headlessPassword) {
|
|
131
|
+
// CLOUD mode — headless email/password sign-in (recommended for cloud /
|
|
132
|
+
// disposable containers).
|
|
133
|
+
const supabaseUrl = process.env.HEURESIS_SUPABASE_URL?.trim() || DEFAULT_SUPABASE_URL;
|
|
134
|
+
const anonKey = process.env.HEURESIS_ANON_KEY?.trim();
|
|
135
|
+
if (!anonKey) {
|
|
136
|
+
console.error([
|
|
137
|
+
'[heuresis-mcp] HEURESIS_EMAIL/HEURESIS_PASSWORD are set but HEURESIS_ANON_KEY is missing.',
|
|
138
|
+
'Set HEURESIS_ANON_KEY to your project anon/publishable key (it is public, not a secret).',
|
|
139
|
+
].join('\n'));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
cloudGetClient = async () => {
|
|
107
143
|
try {
|
|
108
|
-
const { client } = await
|
|
144
|
+
const { client } = await getCloudClientFromPassword(supabaseUrl, anonKey, headlessEmail, headlessPassword);
|
|
109
145
|
return client;
|
|
110
146
|
}
|
|
111
147
|
catch (err) {
|
|
112
|
-
if (err instanceof CloudAuthError)
|
|
148
|
+
if (err instanceof CloudAuthError)
|
|
113
149
|
throw new Error(err.message);
|
|
114
|
-
}
|
|
115
150
|
throw err;
|
|
116
151
|
}
|
|
117
152
|
};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
operatorTools = mod.OPERATOR_TOOLS;
|
|
153
|
+
tools = makeCloudTools(cloudGetClient, await loadOperatorTools());
|
|
154
|
+
modeBanner = `cloud-authenticated (headless ${headlessEmail}; ${tools.length} tools)`;
|
|
155
|
+
}
|
|
156
|
+
else if (creds) {
|
|
157
|
+
// CLOUD mode — persisted device credential (refresh-token bootstrap).
|
|
158
|
+
cloudGetClient = async () => {
|
|
159
|
+
try {
|
|
160
|
+
const { client } = await getCloudClient(creds);
|
|
161
|
+
return client;
|
|
128
162
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (err instanceof CloudAuthError)
|
|
165
|
+
throw new Error(err.message);
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
tools = makeCloudTools(cloudGetClient, await loadOperatorTools());
|
|
134
170
|
modeBanner = `cloud-authenticated (user_id ${creds.user_id}, device ${creds.device_name}; ${tools.length} tools)`;
|
|
135
171
|
}
|
|
136
172
|
else if (snapshotEnv || hasDefaultSnapshot()) {
|
|
@@ -144,9 +180,15 @@ async function runServer() {
|
|
|
144
180
|
console.error([
|
|
145
181
|
'[heuresis-mcp] Not configured.',
|
|
146
182
|
'',
|
|
147
|
-
'To use cloud mode (
|
|
183
|
+
'To use cloud mode on a personal machine (device pairing):',
|
|
148
184
|
' npx -y -p @heuresis/mcp heuresis-mcp login',
|
|
149
185
|
'',
|
|
186
|
+
'To use cloud mode headlessly (CI / cloud agents / disposable containers),',
|
|
187
|
+
'set these env vars so the server signs in fresh on every boot:',
|
|
188
|
+
' HEURESIS_EMAIL your Heuresis account email',
|
|
189
|
+
' HEURESIS_PASSWORD your Heuresis account password',
|
|
190
|
+
' HEURESIS_ANON_KEY your project anon/publishable key (public, not a secret)',
|
|
191
|
+
'',
|
|
150
192
|
'To use legacy snapshot mode (deprecated, removed after 19.7):',
|
|
151
193
|
' HEURESIS_SNAPSHOT=/path/to/export.json npx @heuresis/mcp',
|
|
152
194
|
'',
|
|
@@ -210,7 +252,7 @@ async function runServer() {
|
|
|
210
252
|
console.error(`[heuresis-mcp ${VERSION}] ready - ${modeBanner}`);
|
|
211
253
|
// Phase 19.8 - Supabase Realtime CDC subscription. Cloud mode only; legacy
|
|
212
254
|
// snapshot mode has no live source to subscribe to.
|
|
213
|
-
if (
|
|
255
|
+
if (cloudGetClient) {
|
|
214
256
|
const realtimeOn = await readRealtimeFlag();
|
|
215
257
|
if (!realtimeOn) {
|
|
216
258
|
console.error('[heuresis-mcp] realtime: disabled (--no-realtime or config).');
|
|
@@ -220,7 +262,7 @@ async function runServer() {
|
|
|
220
262
|
// client (Supabase) is not reachable, the error surfaces on stderr.
|
|
221
263
|
void (async () => {
|
|
222
264
|
try {
|
|
223
|
-
const
|
|
265
|
+
const client = await cloudGetClient();
|
|
224
266
|
const wsId = await resolveSubscriptionWorkspaceId(client);
|
|
225
267
|
if (!wsId) {
|
|
226
268
|
console.error('[heuresis-mcp] realtime: no workspace visible; skipping subscription.');
|
package/dist/prompt/compose.js
CHANGED
|
@@ -8,31 +8,31 @@
|
|
|
8
8
|
// target, knowledge pool, operator, plus the operator-specific inputs block.
|
|
9
9
|
// File-context retrieval is a separate tool (find_in_files) that ships in
|
|
10
10
|
// Agent B's tool-parity wave; not folded in here.
|
|
11
|
-
const RESPONSE_TEMPLATE = `{
|
|
12
|
-
"partitions": [
|
|
13
|
-
{
|
|
14
|
-
"label": "STANDALONE concept title — 2–5 words, ≤ 60 chars, NO parent prefix, no trailing period",
|
|
15
|
-
"description": "1–2 sentences, ≤ 280 chars",
|
|
16
|
-
"partitionAttribute": "≤ 5 words for the distinguishing attribute",
|
|
17
|
-
"rationale": "1–3 sentences citing the operator and any K used",
|
|
18
|
-
"kReferences": ["k_id_or_empty"],
|
|
19
|
-
"selfCritique": "main weakness or assumption",
|
|
20
|
-
"children": [
|
|
21
|
-
{
|
|
22
|
-
"label": "STANDALONE sub-concept title — same rules; do NOT prefix with this partition's label either",
|
|
23
|
-
"description": "1–2 sentences, ≤ 280 chars",
|
|
24
|
-
"partitionAttribute": "≤ 5 words",
|
|
25
|
-
"rationale": "1–3 sentences",
|
|
26
|
-
"kReferences": [],
|
|
27
|
-
"selfCritique": "main weakness or assumption"
|
|
28
|
-
}
|
|
29
|
-
]
|
|
30
|
-
}
|
|
31
|
-
],
|
|
32
|
-
"newKnowledgeProposed": [
|
|
33
|
-
{ "title": "fact title", "body": "1–2 sentences", "tags": ["tag1"] }
|
|
34
|
-
],
|
|
35
|
-
"operatorNotes": "one line on how the operator fit (optional)"
|
|
11
|
+
const RESPONSE_TEMPLATE = `{
|
|
12
|
+
"partitions": [
|
|
13
|
+
{
|
|
14
|
+
"label": "STANDALONE concept title — 2–5 words, ≤ 60 chars, NO parent prefix, no trailing period",
|
|
15
|
+
"description": "1–2 sentences, ≤ 280 chars",
|
|
16
|
+
"partitionAttribute": "≤ 5 words for the distinguishing attribute",
|
|
17
|
+
"rationale": "1–3 sentences citing the operator and any K used",
|
|
18
|
+
"kReferences": ["k_id_or_empty"],
|
|
19
|
+
"selfCritique": "main weakness or assumption",
|
|
20
|
+
"children": [
|
|
21
|
+
{
|
|
22
|
+
"label": "STANDALONE sub-concept title — same rules; do NOT prefix with this partition's label either",
|
|
23
|
+
"description": "1–2 sentences, ≤ 280 chars",
|
|
24
|
+
"partitionAttribute": "≤ 5 words",
|
|
25
|
+
"rationale": "1–3 sentences",
|
|
26
|
+
"kReferences": [],
|
|
27
|
+
"selfCritique": "main weakness or assumption"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"newKnowledgeProposed": [
|
|
33
|
+
{ "title": "fact title", "body": "1–2 sentences", "tags": ["tag1"] }
|
|
34
|
+
],
|
|
35
|
+
"operatorNotes": "one line on how the operator fit (optional)"
|
|
36
36
|
}`;
|
|
37
37
|
function pathBlock(path) {
|
|
38
38
|
return path
|
|
@@ -70,11 +70,11 @@ function contradictionBlock(c) {
|
|
|
70
70
|
const principles = c.principles
|
|
71
71
|
.map((p) => ` - #${p.num} ${p.name}: ${p.doctrine}`)
|
|
72
72
|
.join('\n');
|
|
73
|
-
return `<contradiction>
|
|
74
|
-
improving: ${c.improvingName}
|
|
75
|
-
worsening: ${c.worseningName}
|
|
76
|
-
matrix_principles:
|
|
77
|
-
${principles}
|
|
73
|
+
return `<contradiction>
|
|
74
|
+
improving: ${c.improvingName}
|
|
75
|
+
worsening: ${c.worseningName}
|
|
76
|
+
matrix_principles:
|
|
77
|
+
${principles}
|
|
78
78
|
</contradiction>`;
|
|
79
79
|
}
|
|
80
80
|
export function composePrompt(input) {
|
|
@@ -92,50 +92,50 @@ export function composePrompt(input) {
|
|
|
92
92
|
const contradictionXml = operator.family === 'CONTRADICTION' && contradiction
|
|
93
93
|
? `\n${contradictionBlock(contradiction)}\n`
|
|
94
94
|
: '';
|
|
95
|
-
return `You are assisting an inventive design session structured by C-K theory. The user is growing a graph of concepts (C) drawing on a pool of validated knowledge (K). You will generate a set of new partitions of the TARGET concept by applying the requested operator from ASIT/TRIZ.
|
|
96
|
-
|
|
97
|
-
<brief>
|
|
98
|
-
${project.brief}
|
|
99
|
-
</brief>
|
|
100
|
-
|
|
101
|
-
<concept_path_root_to_target>
|
|
102
|
-
${pathBlock(ancestry)}
|
|
103
|
-
</concept_path_root_to_target>
|
|
104
|
-
|
|
105
|
-
<target_concept>
|
|
106
|
-
id: ${target.id}
|
|
107
|
-
label: ${target.label}
|
|
108
|
-
description: ${target.description || '(no description)'}
|
|
109
|
-
notes: ${target.notes || '(none)'}
|
|
110
|
-
</target_concept>
|
|
111
|
-
|
|
112
|
-
<knowledge_pool>
|
|
113
|
-
${knowledgeBlock(knowledge)}
|
|
114
|
-
</knowledge_pool>
|
|
115
|
-
|
|
116
|
-
<operator>
|
|
117
|
-
family: ${operator.family}
|
|
118
|
-
key: ${operator.key}
|
|
119
|
-
name: ${operator.name}
|
|
120
|
-
doctrine: ${operator.doctrine}
|
|
121
|
-
</operator>
|
|
122
|
-
${inputsXml}${branchXml}${contradictionXml}${angleBlock}
|
|
123
|
-
<instructions>
|
|
124
|
-
${operator.promptFragment}
|
|
125
|
-
|
|
126
|
-
Rules:
|
|
127
|
-
- Produce 3–5 partitions at the top level, each genuinely distinct, each adding a clear new attribute to the TARGET concept. (The optional \`children\` array below adds depth-2 nodes; it does NOT count toward the 3–5 top-level requirement.)
|
|
128
|
-
- Labels MUST be STANDALONE concept titles. Do NOT prefix labels with the parent concept's label. For example, if the parent is "Test", do NOT write labels like "Test by destruction" or "Test for X" — just write "Destruction" or "X". The label should make sense on its own; the parent context is implicit from the graph structure. This rule applies to EVERY label in the response, including children (a child's label must not contain its immediate parent partition's label either).
|
|
129
|
-
- Labels MUST be short: 2–5 words, ≤ 60 characters, no trailing punctuation. The label is a concept title, not a sentence. Put long-form prose in description/rationale, not in label.
|
|
130
|
-
- Each partition MAY optionally include a \`children\` array of 1–4 sub-partitions, when the partition naturally decomposes further into a clearly distinct sub-axis. Children follow the same shape (label, description, partitionAttribute, rationale, kReferences, selfCritique). Do NOT nest beyond one level — a child must NEVER have its own \`children\` array. Omit \`children\` entirely when no useful sub-decomposition exists; do not pad.
|
|
131
|
-
- Stay faithful to the operator's doctrine. If the operator forbids alien components (ASIT closed-world), do not introduce them.
|
|
132
|
-
- For each partition, cite by id any knowledge item from <knowledge_pool> you actually used in kReferences. Empty array if none.
|
|
133
|
-
- Use selfCritique to surface the strongest assumption or risk in that partition (do not flatter the idea).
|
|
134
|
-
- If you needed a fact you did not have, propose it via newKnowledgeProposed (1–3 items max). Do NOT invent specific numbers as facts; phrase as questions to verify.
|
|
135
|
-
- Output ONLY a single JSON object, matching this shape exactly. No prose before or after, no markdown fences.
|
|
136
|
-
</instructions>
|
|
137
|
-
|
|
138
|
-
<response_shape>
|
|
139
|
-
${RESPONSE_TEMPLATE}
|
|
95
|
+
return `You are assisting an inventive design session structured by C-K theory. The user is growing a graph of concepts (C) drawing on a pool of validated knowledge (K). You will generate a set of new partitions of the TARGET concept by applying the requested operator from ASIT/TRIZ.
|
|
96
|
+
|
|
97
|
+
<brief>
|
|
98
|
+
${project.brief}
|
|
99
|
+
</brief>
|
|
100
|
+
|
|
101
|
+
<concept_path_root_to_target>
|
|
102
|
+
${pathBlock(ancestry)}
|
|
103
|
+
</concept_path_root_to_target>
|
|
104
|
+
|
|
105
|
+
<target_concept>
|
|
106
|
+
id: ${target.id}
|
|
107
|
+
label: ${target.label}
|
|
108
|
+
description: ${target.description || '(no description)'}
|
|
109
|
+
notes: ${target.notes || '(none)'}
|
|
110
|
+
</target_concept>
|
|
111
|
+
|
|
112
|
+
<knowledge_pool>
|
|
113
|
+
${knowledgeBlock(knowledge)}
|
|
114
|
+
</knowledge_pool>
|
|
115
|
+
|
|
116
|
+
<operator>
|
|
117
|
+
family: ${operator.family}
|
|
118
|
+
key: ${operator.key}
|
|
119
|
+
name: ${operator.name}
|
|
120
|
+
doctrine: ${operator.doctrine}
|
|
121
|
+
</operator>
|
|
122
|
+
${inputsXml}${branchXml}${contradictionXml}${angleBlock}
|
|
123
|
+
<instructions>
|
|
124
|
+
${operator.promptFragment}
|
|
125
|
+
|
|
126
|
+
Rules:
|
|
127
|
+
- Produce 3–5 partitions at the top level, each genuinely distinct, each adding a clear new attribute to the TARGET concept. (The optional \`children\` array below adds depth-2 nodes; it does NOT count toward the 3–5 top-level requirement.)
|
|
128
|
+
- Labels MUST be STANDALONE concept titles. Do NOT prefix labels with the parent concept's label. For example, if the parent is "Test", do NOT write labels like "Test by destruction" or "Test for X" — just write "Destruction" or "X". The label should make sense on its own; the parent context is implicit from the graph structure. This rule applies to EVERY label in the response, including children (a child's label must not contain its immediate parent partition's label either).
|
|
129
|
+
- Labels MUST be short: 2–5 words, ≤ 60 characters, no trailing punctuation. The label is a concept title, not a sentence. Put long-form prose in description/rationale, not in label.
|
|
130
|
+
- Each partition MAY optionally include a \`children\` array of 1–4 sub-partitions, when the partition naturally decomposes further into a clearly distinct sub-axis. Children follow the same shape (label, description, partitionAttribute, rationale, kReferences, selfCritique). Do NOT nest beyond one level — a child must NEVER have its own \`children\` array. Omit \`children\` entirely when no useful sub-decomposition exists; do not pad.
|
|
131
|
+
- Stay faithful to the operator's doctrine. If the operator forbids alien components (ASIT closed-world), do not introduce them.
|
|
132
|
+
- For each partition, cite by id any knowledge item from <knowledge_pool> you actually used in kReferences. Empty array if none.
|
|
133
|
+
- Use selfCritique to surface the strongest assumption or risk in that partition (do not flatter the idea).
|
|
134
|
+
- If you needed a fact you did not have, propose it via newKnowledgeProposed (1–3 items max). Do NOT invent specific numbers as facts; phrase as questions to verify.
|
|
135
|
+
- Output ONLY a single JSON object, matching this shape exactly. No prose before or after, no markdown fences.
|
|
136
|
+
</instructions>
|
|
137
|
+
|
|
138
|
+
<response_shape>
|
|
139
|
+
${RESPONSE_TEMPLATE}
|
|
140
140
|
</response_shape>`;
|
|
141
141
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -1,43 +1,77 @@
|
|
|
1
|
-
// Heuresis MCP —
|
|
1
|
+
// Heuresis MCP — global fetch dispatcher wiring (proxy + IPv4 preference).
|
|
2
2
|
//
|
|
3
3
|
// Node 18-22's undici does NOT auto-honor HTTPS_PROXY / HTTP_PROXY (Node 24+
|
|
4
4
|
// does). Without this, every outbound fetch fails with "fetch failed" on
|
|
5
5
|
// networks that require an egress proxy — both the device-pairing + GoTrue
|
|
6
6
|
// refresh calls in the CLI and the SupabaseClient's PostgREST queries in the
|
|
7
|
-
// running MCP server. We install an undici ProxyAgent as the global
|
|
8
|
-
//
|
|
7
|
+
// running MCP server. We install an undici ProxyAgent as the global dispatcher
|
|
8
|
+
// once, at process start.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// When NO proxy is set we STILL install a custom global dispatcher: an Agent
|
|
11
|
+
// that resolves hostnames IPv4-only. On networks with a dead / black-holed
|
|
12
|
+
// IPv6 DNS resolver (e.g. a stale ISP IPv6 nameserver handed out over DHCPv6),
|
|
13
|
+
// a default dual-stack getaddrinfo issues BOTH an A and a AAAA query and waits
|
|
14
|
+
// for both — the AAAA query stalls ~4-8s or times out against the dead resolver
|
|
15
|
+
// before the (instant) A answer can be used, so every fetch intermittently
|
|
16
|
+
// hangs even though IPv4 connectivity is perfectly fine. Forcing family:4 skips
|
|
17
|
+
// the AAAA query entirely. This is safe for this client: every endpoint it
|
|
18
|
+
// talks to (Supabase behind Cloudflare) is IPv4-reachable — in fact the
|
|
19
|
+
// Supabase host publishes no AAAA record at all.
|
|
20
|
+
//
|
|
21
|
+
// Idempotent across the whole process (module-level flag) and safe to call from
|
|
22
|
+
// every entry point.
|
|
12
23
|
//
|
|
13
24
|
// NOTE: this only covers `fetch` (PostgREST + auth). The Realtime websocket
|
|
14
|
-
// uses a separate transport that the global dispatcher does not touch
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let proxyAgentInstalled = false;
|
|
25
|
+
// uses a separate transport that the global dispatcher does not touch.
|
|
26
|
+
import { lookup } from 'node:dns';
|
|
27
|
+
let dispatcherInstalled = false;
|
|
18
28
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
29
|
+
* Install the process-wide undici dispatcher for Node's global `fetch`:
|
|
30
|
+
* a ProxyAgent when HTTPS_PROXY/HTTP_PROXY is set, otherwise an IPv4-preferring
|
|
31
|
+
* Agent (see header for why). Pass a logger to surface the routing decision
|
|
32
|
+
* (the CLI logs to stderr; the server passes console.error). Idempotent.
|
|
22
33
|
*/
|
|
23
34
|
export async function ensureProxyAgent(log = () => { }) {
|
|
24
|
-
if (
|
|
35
|
+
if (dispatcherInstalled)
|
|
25
36
|
return;
|
|
26
|
-
|
|
37
|
+
dispatcherInstalled = true;
|
|
27
38
|
const proxyUrl = process.env.HTTPS_PROXY ||
|
|
28
39
|
process.env.https_proxy ||
|
|
29
40
|
process.env.HTTP_PROXY ||
|
|
30
41
|
process.env.http_proxy;
|
|
31
|
-
if (!proxyUrl)
|
|
32
|
-
return;
|
|
33
42
|
try {
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
const { ProxyAgent, setGlobalDispatcher } = await import('undici');
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
// undici is a direct dependency, so this resolves. Dynamic import keeps it
|
|
44
|
+
// off the cold-start path until we actually configure the dispatcher.
|
|
45
|
+
const { Agent, ProxyAgent, setGlobalDispatcher } = await import('undici');
|
|
46
|
+
if (proxyUrl) {
|
|
47
|
+
// Behind a proxy the target's DNS is resolved by the proxy, so the IPv6
|
|
48
|
+
// stall does not apply locally — just route through the proxy.
|
|
49
|
+
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
50
|
+
log(`(routing through proxy ${proxyUrl})`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// No proxy: pin IPv4 to dodge two distinct flaky-network failure modes
|
|
54
|
+
// that both manifest as intermittent ~4-8s stalls to the Supabase host:
|
|
55
|
+
// 1. A dead/stalling IPv6 DNS resolver — a default dual-stack lookup
|
|
56
|
+
// issues a AAAA query that hangs before the instant A answer is used.
|
|
57
|
+
// 2. Node/undici's Happy-Eyeballs (autoSelectFamily) racing the
|
|
58
|
+
// connection, which on some networks wedges the handshake ~50% of
|
|
59
|
+
// the time to this host even when each IP is individually healthy
|
|
60
|
+
// (verified: both Cloudflare IPs 6/6 when pinned, but 3/6 with the
|
|
61
|
+
// race on). Disabling it + forcing family:4 is reliably 8/8.
|
|
62
|
+
setGlobalDispatcher(new Agent({
|
|
63
|
+
connect: {
|
|
64
|
+
autoSelectFamily: false,
|
|
65
|
+
// Pin the address family to IPv4 (also skips the AAAA query) while
|
|
66
|
+
// preserving undici's other lookup options (e.g. `all`). The cast
|
|
67
|
+
// sidesteps dns.lookup's overload union — we faithfully forward
|
|
68
|
+
// undici's own callback, whatever result shape it expects.
|
|
69
|
+
lookup: (hostname, options, callback) => lookup(hostname, { ...options, family: 4 }, callback),
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
39
73
|
}
|
|
40
74
|
catch (err) {
|
|
41
|
-
log(`(could not configure
|
|
75
|
+
log(`(could not configure fetch dispatcher: ${err instanceof Error ? err.message : String(err)})`);
|
|
42
76
|
}
|
|
43
77
|
}
|
|
@@ -69,7 +69,32 @@ function leafSchema(schema) {
|
|
|
69
69
|
return out;
|
|
70
70
|
}
|
|
71
71
|
export function zodToJsonSchema(schema) {
|
|
72
|
-
|
|
72
|
+
// Peel wrappers to reach the underlying object whose `.shape` we can
|
|
73
|
+
// introspect: `.refine()`/`.transform()`/`.superRefine()` produce a
|
|
74
|
+
// ZodEffects (no `.shape`), and optional/default/nullable wrap the root too.
|
|
75
|
+
// Without this, a tool whose inputSchema is a ZodEffects (e.g. expand_concept)
|
|
76
|
+
// makes `Object.entries(undefined)` throw — which previously took down the
|
|
77
|
+
// ENTIRE tools/list response and caused MCP clients to drop the server.
|
|
78
|
+
let root = schema;
|
|
79
|
+
while (root && root._def) {
|
|
80
|
+
if (root instanceof z.ZodEffects) {
|
|
81
|
+
root = root._def.schema;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (root instanceof z.ZodOptional ||
|
|
85
|
+
root instanceof z.ZodDefault ||
|
|
86
|
+
root instanceof z.ZodNullable) {
|
|
87
|
+
root = root._def.innerType;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
const shape = root?.shape;
|
|
93
|
+
if (!shape || typeof shape !== 'object') {
|
|
94
|
+
// Not an object schema we can introspect — expose a permissive object so
|
|
95
|
+
// the tool still lists and still accepts its arguments.
|
|
96
|
+
return { type: 'object', additionalProperties: true };
|
|
97
|
+
}
|
|
73
98
|
const properties = {};
|
|
74
99
|
const required = [];
|
|
75
100
|
for (const [key, value] of Object.entries(shape)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heuresis/mcp",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.14",
|
|
4
4
|
"mcpName": "io.github.ToremLabs/heuresis",
|
|
5
5
|
"description": "Cloud-authenticated Model Context Protocol server for a Heuresis workspace. Logs into the user's Heuresis account and lets any MCP client (Claude Desktop, Claude Code, Cursor, custom agents) read and write the same workspace the webapp uses. 31 data tools, 3 operator tools (Branch/Matrix/C-K/ASIT/TRIZ/Free/Combine/Explore), and live Realtime change subscriptions.",
|
|
6
6
|
"type": "module",
|