@heuresis/mcp 1.0.0-rc.13 → 1.0.0-rc.15

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/dist/cli.js CHANGED
@@ -101,21 +101,47 @@ function parseLoginFlags(argv) {
101
101
  function sleep(ms) {
102
102
  return new Promise((resolve) => setTimeout(resolve, ms));
103
103
  }
104
- async function postJson(url, body) {
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 res = await fetch(url, {
113
+ const init = {
107
114
  method: 'POST',
108
115
  headers: { 'Content-Type': 'application/json' },
109
116
  body: JSON.stringify(body),
110
- });
111
- let data = null;
112
- try {
113
- data = await res.json();
114
- }
115
- catch {
116
- /* leave null */
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
- return { status: res.status, data };
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);
@@ -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 { writeCredentials } from './credentials.js';
22
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) {
@@ -42,6 +48,10 @@ async function seedClient(supabaseUrl, anonKey, session, userId) {
42
48
  autoRefreshToken: true,
43
49
  detectSessionInUrl: false,
44
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 },
45
55
  });
46
56
  const { error } = await client.auth.setSession({
47
57
  access_token: session.access_token,
@@ -54,20 +64,53 @@ async function seedClient(supabaseUrl, anonKey, session, userId) {
54
64
  return cached;
55
65
  }
56
66
  /**
57
- * Build (or return cached) a Supabase client bound to the credentials on
58
- * disk. Bootstraps by exchanging the stored (rotating) refresh token. Throws
59
- * CloudAuthError if the refresh token has been revoked/rotated away.
67
+ * Persist a rotated refresh token back to ~/.heuresis/credentials.json.
60
68
  *
61
- * NOTE: a stored refresh token is single-use under Supabase rotation, so this
62
- * path is unsuitable for ephemeral environments that reuse the same persisted
63
- * credential across boots use getCloudClientFromPassword() for those.
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.
64
97
  */
65
98
  export async function getCloudClient(creds) {
66
99
  if (cached)
67
100
  return cached;
68
101
  try {
69
102
  const session = await exchangeRefreshToken(creds.supabase_url, creds.anon_key, creds.refresh_token);
70
- return await seedClient(creds.supabase_url, creds.anon_key, session, creds.user_id);
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
+ }
112
+ });
113
+ return result;
71
114
  }
72
115
  catch (err) {
73
116
  if (err instanceof CloudAuthError)
@@ -545,18 +545,29 @@ export async function linkConcepts(client, args) {
545
545
  if (args.fromId === args.toId) {
546
546
  return { error: 'Self-loop edges are not allowed.' };
547
547
  }
548
- const from = unwrap(await client
548
+ // These are .maybeSingle() lookups whose null result is MEANINGFUL ("not
549
+ // found" / "no duplicate") — do NOT wrap them in unwrap(). unwrap() throws on
550
+ // a null `data`, so the dup-check below blew up with "Empty result from
551
+ // cloud" on EVERY first-time link, meaning no non-partition edge (derived-
552
+ // from / k-ref / semantic-adjacency, incl. add_kref) could ever be created.
553
+ const fromRes = await client
549
554
  .from('nodes')
550
555
  .select('id, workspace_id')
551
556
  .eq('id', args.fromId)
552
- .maybeSingle());
557
+ .maybeSingle();
558
+ if (fromRes.error)
559
+ throw new Error(fromRes.error.message);
560
+ const from = fromRes.data;
553
561
  if (!from)
554
562
  return { error: `No concept with id ${args.fromId}` };
555
- const to = unwrap(await client
563
+ const toRes = await client
556
564
  .from('nodes')
557
565
  .select('id, workspace_id')
558
566
  .eq('id', args.toId)
559
- .maybeSingle());
567
+ .maybeSingle();
568
+ if (toRes.error)
569
+ throw new Error(toRes.error.message);
570
+ const to = toRes.data;
560
571
  if (!to)
561
572
  return { error: `No concept with id ${args.toId}` };
562
573
  if (from.workspace_id !== to.workspace_id) {
@@ -564,14 +575,18 @@ export async function linkConcepts(client, args) {
564
575
  error: 'Cannot link concepts from different workspaces.',
565
576
  };
566
577
  }
567
- // Reject duplicate edges of the same kind on the same pair.
568
- const dup = unwrap(await client
578
+ // Reject duplicate edges of the same kind on the same pair. A null here is
579
+ // the normal "no existing edge" case — handle it directly, never unwrap().
580
+ const dupRes = await client
569
581
  .from('edges')
570
582
  .select('id')
571
583
  .eq('from_id', from.id)
572
584
  .eq('to_id', to.id)
573
585
  .eq('kind', args.kind)
574
- .maybeSingle());
586
+ .maybeSingle();
587
+ if (dupRes.error)
588
+ throw new Error(dupRes.error.message);
589
+ const dup = dupRes.data;
575
590
  if (dup) {
576
591
  return { id: dup.id, fromId: from.id, toId: to.id, kind: args.kind, duplicate: true };
577
592
  }
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,15 @@ 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
- let res;
49
- try {
50
- res = await fetch(url, {
51
- method: 'POST',
52
- headers: {
53
- apikey: anonKey,
54
- Authorization: `Bearer ${anonKey}`,
55
- 'Content-Type': 'application/json',
56
- },
57
- body: JSON.stringify({ refresh_token: refreshToken }),
58
- });
59
- }
60
- catch (err) {
61
- throw new RefreshTokenError(`Could not reach the auth endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}`);
62
- }
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
+ });
63
94
  let payload = null;
64
95
  try {
65
96
  payload = await res.json();
@@ -100,21 +131,15 @@ export async function signInWithPassword(supabaseUrl, anonKey, email, password)
100
131
  throw new RefreshTokenError('Headless sign-in needs both an email and a password (HEURESIS_EMAIL / HEURESIS_PASSWORD).');
101
132
  }
102
133
  const url = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/token?grant_type=password`;
103
- let res;
104
- try {
105
- res = await fetch(url, {
106
- method: 'POST',
107
- headers: {
108
- apikey: anonKey,
109
- Authorization: `Bearer ${anonKey}`,
110
- 'Content-Type': 'application/json',
111
- },
112
- body: JSON.stringify({ email, password }),
113
- });
114
- }
115
- catch (err) {
116
- throw new RefreshTokenError(`Could not reach the auth endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}`);
117
- }
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
+ });
118
143
  let payload = null;
119
144
  try {
120
145
  payload = await res.json();
@@ -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
@@ -209,7 +209,22 @@ async function runServer() {
209
209
  inputSchema: zodToJsonSchema(t.inputSchema),
210
210
  })),
211
211
  }));
212
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
212
+ // The heavy operator/LLM tools are serialized: several fired in parallel
213
+ // overwhelm the backend and trip the 60s timeout (observed: parallel
214
+ // expand_concept runs timing out). Read/write tools still run concurrently.
215
+ const OPERATOR_TOOLS = new Set([
216
+ 'run_operator',
217
+ 'run_operator_and_commit',
218
+ 'expand_concept',
219
+ ]);
220
+ let operatorChain = Promise.resolve();
221
+ const runExclusive = (fn) => {
222
+ const result = operatorChain.then(fn, fn);
223
+ // Keep the chain alive regardless of this call's outcome.
224
+ operatorChain = result.then(() => undefined, () => undefined);
225
+ return result;
226
+ };
227
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
213
228
  const tool = tools.find((t) => t.name === req.params.name);
214
229
  if (!tool) {
215
230
  return {
@@ -219,8 +234,39 @@ async function runServer() {
219
234
  ],
220
235
  };
221
236
  }
237
+ // Heartbeat — emit progress notifications so MCP clients that honor them
238
+ // keep resetting their request timeout. Operator/LLM runs routinely exceed
239
+ // the 60s default, where the response would time out even though the work
240
+ // already committed (which also drove duplicate retries). No-op when the
241
+ // client supplied no progressToken.
242
+ const progressToken = req.params._meta?.progressToken;
243
+ let heartbeat;
244
+ if (progressToken !== undefined) {
245
+ let ticks = 0;
246
+ heartbeat = setInterval(() => {
247
+ ticks += 1;
248
+ try {
249
+ void extra
250
+ .sendNotification({
251
+ method: 'notifications/progress',
252
+ params: {
253
+ progressToken,
254
+ progress: ticks,
255
+ message: `still working… (~${ticks * 15}s)`,
256
+ },
257
+ })
258
+ .catch(() => { });
259
+ }
260
+ catch {
261
+ /* a heartbeat failure must never break the call */
262
+ }
263
+ }, 15_000);
264
+ }
222
265
  try {
223
- const result = await tool.handler(req.params.arguments ?? {});
266
+ const invoke = () => tool.handler(req.params.arguments ?? {});
267
+ const result = OPERATOR_TOOLS.has(tool.name)
268
+ ? await runExclusive(invoke)
269
+ : await invoke();
224
270
  const text = JSON.stringify(result, null, 2);
225
271
  if (text.length > MAX_RESULT_CHARS) {
226
272
  return {
@@ -246,6 +292,10 @@ async function runServer() {
246
292
  content: [{ type: 'text', text: `Error: ${msg}` }],
247
293
  };
248
294
  }
295
+ finally {
296
+ if (heartbeat)
297
+ clearInterval(heartbeat);
298
+ }
249
299
  });
250
300
  const transport = new StdioServerTransport();
251
301
  await server.connect(transport);
@@ -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 — proxy wiring for Node's global fetch dispatcher.
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
- // dispatcher once, at process start.
7
+ // running MCP server. We install an undici ProxyAgent as the global dispatcher
8
+ // once, at process start.
9
9
  //
10
- // Idempotent across the whole process (module-level flag) and a no-op when no
11
- // proxy var is set, so it is safe to call from every entry point.
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; live
15
- // sync behind a strict proxy is a known follow-up, but it is best-effort and
16
- // fire-and-forget, so it never blocks tool calls.
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
- * Route Node's global `fetch` through HTTPS_PROXY / HTTP_PROXY when set.
20
- * Pass a logger to surface the routing decision (the CLI logs to stderr; the
21
- * server passes console.error). Resolves once the dispatcher is in place.
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 (proxyAgentInstalled)
35
+ if (dispatcherInstalled)
25
36
  return;
26
- proxyAgentInstalled = true;
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
- // Dynamic import keeps undici off the cold-start path when no proxy is in
35
- // play. It's a direct dependency, so this resolves.
36
- const { ProxyAgent, setGlobalDispatcher } = await import('undici');
37
- setGlobalDispatcher(new ProxyAgent(proxyUrl));
38
- log(`(routing through proxy ${proxyUrl})`);
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 proxy ${proxyUrl}: ${err instanceof Error ? err.message : String(err)})`);
75
+ log(`(could not configure fetch dispatcher: ${err instanceof Error ? err.message : String(err)})`);
42
76
  }
43
77
  }
@@ -59,6 +59,16 @@ function leafSchema(schema) {
59
59
  // Nested object — recurse via the public path.
60
60
  return zodToJsonSchema(cur);
61
61
  }
62
+ else if (cur instanceof z.ZodRecord) {
63
+ // Open-ended string→value map (e.g. an operator's `args`). It MUST declare
64
+ // type:object — otherwise MCP clients don't JSON-parse the value, they send
65
+ // it as a raw string, and the server's validator rejects it ("Expected
66
+ // object, received string"). That silently disabled every parameterized
67
+ // operator (run_operator / run_operator_and_commit: free-text angle,
68
+ // contradiction improving/worsening, combine combineWithIds).
69
+ out.type = 'object';
70
+ out.additionalProperties = true;
71
+ }
62
72
  else {
63
73
  // Unknown / unsupported — fall back to "any".
64
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heuresis/mcp",
3
- "version": "1.0.0-rc.13",
3
+ "version": "1.0.0-rc.15",
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",