@heuresis/mcp 1.0.0-rc.14 → 1.0.0-rc.16

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 CHANGED
@@ -1,189 +1,189 @@
1
- # @heuresis/mcp
2
-
3
- A Model Context Protocol (MCP) server that exposes a Heuresis workspace
4
- to any MCP-capable client (Claude Desktop, Claude Code, Cursor,
5
- Windsurf, custom agents). The server logs into the user's Heuresis
6
- account, talks to the same Supabase project the webapp talks to, and
7
- respects the same RLS. Webapp and MCP are two front-ends to one cloud
8
- workspace.
9
-
10
- Current version: `1.0.0-rc.13`.
11
-
12
- ## Install
13
-
14
- ```bash
15
- npm install -g @heuresis/mcp
16
- # or on demand without installing:
17
- npx -y @heuresis/mcp
18
- ```
19
-
20
- > **Package name vs. command name.** The npm package is `@heuresis/mcp`; the
21
- > command it installs is `heuresis-mcp`. A bare `npx -y @heuresis/mcp` (no
22
- > subcommand) starts the MCP server fine, but `npx @heuresis/mcp login` can
23
- > fail with `heuresis-mcp: not found` because npx derives the command name
24
- > from the scope-stripped package name (`mcp`), which doesn't match. To run a
25
- > subcommand reliably on every npm/OS, name the binary explicitly with `-p`:
26
- >
27
- > ```bash
28
- > npx -y -p @heuresis/mcp heuresis-mcp login
29
- > ```
30
-
31
- ## Quickstart
32
-
33
- ### 1. Link this machine to your Heuresis account
34
-
35
- ```bash
36
- npx -y -p @heuresis/mcp heuresis-mcp login
37
- ```
38
-
39
- The CLI prints a device code and a one-click URL of the form
40
- `https://heuresis.app/device?code=XXXX-XXXX`. Open it in your browser,
41
- sign in if you aren't already, and confirm the device. The CLI polls
42
- in the background and writes credentials to
43
- `~/.heuresis/credentials.json` (chmod 600 on POSIX) the moment you
44
- confirm. Subsequent runs of the MCP are silent.
45
-
46
- The login flow rides three Supabase Edge Functions:
47
- `mcp-device-init`, `mcp-device-grant`, and `mcp-device-poll`.
48
-
49
- To unlink a machine: `npx -y -p @heuresis/mcp heuresis-mcp logout`, or open
50
- Settings ▸ Connected devices in the webapp to revoke remotely.
51
-
52
- `npx -y -p @heuresis/mcp heuresis-mcp whoami` confirms which account a machine
53
- is currently linked to.
54
-
55
- ### 2. Point your MCP client at it
56
-
57
- **Claude Desktop.** Edit
58
- `~/Library/Application Support/Claude/claude_desktop_config.json` on
59
- macOS, or `%APPDATA%/Claude/claude_desktop_config.json` on Windows:
60
-
61
- ```json
62
- {
63
- "mcpServers": {
64
- "heuresis": { "command": "npx", "args": ["-y", "@heuresis/mcp"] }
65
- }
66
- }
67
- ```
68
-
69
- **Claude Code / Cursor / Windsurf.** Drop a `.mcp.json` in the
70
- workspace root:
71
-
72
- ```json
73
- {
74
- "mcpServers": {
75
- "heuresis": { "command": "npx", "args": ["-y", "@heuresis/mcp"] }
76
- }
77
- }
78
- ```
79
-
80
- Restart the client. The Heuresis tools appear in the tool menu.
81
-
82
- ### 3. CLI subcommands
83
-
84
- ```bash
85
- npx -y -p @heuresis/mcp heuresis-mcp whoami # show the linked account + device
86
- npx -y -p @heuresis/mcp heuresis-mcp logout # delete the credentials file
87
- npx -y -p @heuresis/mcp heuresis-mcp --help # all options
88
- npx -y @heuresis/mcp --no-realtime # boot the server with live sync off (persisted)
89
- npx -y @heuresis/mcp --realtime # re-enable live sync
90
- ```
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
-
122
- ## Live sync
123
-
124
- When the MCP boots in cloud mode it subscribes to the workspace over
125
- Supabase Realtime and notifies the client whenever a `nodes`, `edges`,
126
- `projects`, or `ideas` row changes. Edits made in the webapp show up
127
- in the agent's view without a manual refresh, and writes from one
128
- MCP-connected client reach any other connected client the same way.
129
- Pass `--no-realtime` to disable the subscription (useful if the
130
- chatter is noisy or the client logs every notification). The
131
- preference is saved to `~/.heuresis/config.json` so the flag only
132
- needs to be passed once.
133
-
134
- ## Tools
135
-
136
- 34 tools total: 31 data tools against the cloud workspace, plus 3
137
- operator tools that drive the same ideation operators the webapp uses.
138
-
139
- **Reads (10).** `get_workspace_summary`, `list_projects`,
140
- `get_project_graph`, `list_concepts`, `list_edges`, `get_subtree`,
141
- `get_concept`, `search_concepts`, `find_concepts`,
142
- `list_recent_decisions`. Most agent sessions start with
143
- `get_workspace_summary` or `list_projects`.
144
-
145
- **Writes (21).** Concepts: `add_concept`, `update_concept`,
146
- `bulk_add_concepts`, `set_parent`, `validate_concept`, `set_standing`,
147
- `archive_concept`, `unarchive_concept`, `star_concept`,
148
- `remove_concept`. Edges: `link_concepts`, `add_kref`. Ideas:
149
- `create_idea`, `rename_idea`, `recolor_idea`, `set_idea_members`,
150
- `add_to_idea`, `delete_idea`. Projects: `create_project`,
151
- `update_project`, `delete_project`. Every write stamps a row in
152
- `public.provenance` with `origin='mcp'` so the webapp's session log
153
- shows which surface made the change.
154
-
155
- **Operator runs (3).** `run_operator` (generate candidates with
156
- Branch / Matrix / ASIT / TRIZ / Combine / Free / Contradiction),
157
- `run_operator_and_commit` (same, plus commit the result in one
158
- round-trip), and `expand_concept` (recursive Branch, capped at depth ×
159
- breadth ≤ 60).
160
-
161
- Tool input shapes mirror their counterparts in the webapp's
162
- `src/agent/tools.ts`, so an agent that uses both surfaces sees a
163
- uniform contract.
164
-
165
- Wave-shipping: `find_in_files` (in-browser embedding search) is in the
166
- webapp but not yet on the MCP.
167
-
168
- ## Legacy snapshot mode (deprecated)
169
-
170
- The original read-only snapshot reader still works as a fallback while
171
- users migrate to cloud auth. With no `~/.heuresis/credentials.json`
172
- and the `HEURESIS_SNAPSHOT` env var set, the server reads a JSON
173
- export from disk and exposes the original read-only tool set
174
- (`get_workspace_summary`, `list_projects`, `search_concepts`,
175
- `get_concept`, `get_subtree`, `get_project_graph`,
176
- `list_recent_decisions`).
177
-
178
- ```bash
179
- export HEURESIS_SNAPSHOT="/absolute/path/to/your-export.json"
180
- npx @heuresis/mcp
181
- ```
182
-
183
- This path is deprecated and will be removed in a later release. It is
184
- here so existing setups keep working through the migration to cloud
185
- auth.
186
-
187
- ## License
188
-
189
- AGPL-3.0-or-later.
1
+ # @heuresis/mcp
2
+
3
+ A Model Context Protocol (MCP) server that exposes a Heuresis workspace
4
+ to any MCP-capable client (Claude Desktop, Claude Code, Cursor,
5
+ Windsurf, custom agents). The server logs into the user's Heuresis
6
+ account, talks to the same Supabase project the webapp talks to, and
7
+ respects the same RLS. Webapp and MCP are two front-ends to one cloud
8
+ workspace.
9
+
10
+ Current version: `1.0.0-rc.13`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install -g @heuresis/mcp
16
+ # or on demand without installing:
17
+ npx -y @heuresis/mcp
18
+ ```
19
+
20
+ > **Package name vs. command name.** The npm package is `@heuresis/mcp`; the
21
+ > command it installs is `heuresis-mcp`. A bare `npx -y @heuresis/mcp` (no
22
+ > subcommand) starts the MCP server fine, but `npx @heuresis/mcp login` can
23
+ > fail with `heuresis-mcp: not found` because npx derives the command name
24
+ > from the scope-stripped package name (`mcp`), which doesn't match. To run a
25
+ > subcommand reliably on every npm/OS, name the binary explicitly with `-p`:
26
+ >
27
+ > ```bash
28
+ > npx -y -p @heuresis/mcp heuresis-mcp login
29
+ > ```
30
+
31
+ ## Quickstart
32
+
33
+ ### 1. Link this machine to your Heuresis account
34
+
35
+ ```bash
36
+ npx -y -p @heuresis/mcp heuresis-mcp login
37
+ ```
38
+
39
+ The CLI prints a device code and a one-click URL of the form
40
+ `https://heuresis.app/device?code=XXXX-XXXX`. Open it in your browser,
41
+ sign in if you aren't already, and confirm the device. The CLI polls
42
+ in the background and writes credentials to
43
+ `~/.heuresis/credentials.json` (chmod 600 on POSIX) the moment you
44
+ confirm. Subsequent runs of the MCP are silent.
45
+
46
+ The login flow rides three Supabase Edge Functions:
47
+ `mcp-device-init`, `mcp-device-grant`, and `mcp-device-poll`.
48
+
49
+ To unlink a machine: `npx -y -p @heuresis/mcp heuresis-mcp logout`, or open
50
+ Settings ▸ Connected devices in the webapp to revoke remotely.
51
+
52
+ `npx -y -p @heuresis/mcp heuresis-mcp whoami` confirms which account a machine
53
+ is currently linked to.
54
+
55
+ ### 2. Point your MCP client at it
56
+
57
+ **Claude Desktop.** Edit
58
+ `~/Library/Application Support/Claude/claude_desktop_config.json` on
59
+ macOS, or `%APPDATA%/Claude/claude_desktop_config.json` on Windows:
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "heuresis": { "command": "npx", "args": ["-y", "@heuresis/mcp"] }
65
+ }
66
+ }
67
+ ```
68
+
69
+ **Claude Code / Cursor / Windsurf.** Drop a `.mcp.json` in the
70
+ workspace root:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "heuresis": { "command": "npx", "args": ["-y", "@heuresis/mcp"] }
76
+ }
77
+ }
78
+ ```
79
+
80
+ Restart the client. The Heuresis tools appear in the tool menu.
81
+
82
+ ### 3. CLI subcommands
83
+
84
+ ```bash
85
+ npx -y -p @heuresis/mcp heuresis-mcp whoami # show the linked account + device
86
+ npx -y -p @heuresis/mcp heuresis-mcp logout # delete the credentials file
87
+ npx -y -p @heuresis/mcp heuresis-mcp --help # all options
88
+ npx -y @heuresis/mcp --no-realtime # boot the server with live sync off (persisted)
89
+ npx -y @heuresis/mcp --realtime # re-enable live sync
90
+ ```
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
+
122
+ ## Live sync
123
+
124
+ When the MCP boots in cloud mode it subscribes to the workspace over
125
+ Supabase Realtime and notifies the client whenever a `nodes`, `edges`,
126
+ `projects`, or `ideas` row changes. Edits made in the webapp show up
127
+ in the agent's view without a manual refresh, and writes from one
128
+ MCP-connected client reach any other connected client the same way.
129
+ Pass `--no-realtime` to disable the subscription (useful if the
130
+ chatter is noisy or the client logs every notification). The
131
+ preference is saved to `~/.heuresis/config.json` so the flag only
132
+ needs to be passed once.
133
+
134
+ ## Tools
135
+
136
+ 34 tools total: 31 data tools against the cloud workspace, plus 3
137
+ operator tools that drive the same ideation operators the webapp uses.
138
+
139
+ **Reads (10).** `get_workspace_summary`, `list_projects`,
140
+ `get_project_graph`, `list_concepts`, `list_edges`, `get_subtree`,
141
+ `get_concept`, `search_concepts`, `find_concepts`,
142
+ `list_recent_decisions`. Most agent sessions start with
143
+ `get_workspace_summary` or `list_projects`.
144
+
145
+ **Writes (21).** Concepts: `add_concept`, `update_concept`,
146
+ `bulk_add_concepts`, `set_parent`, `validate_concept`, `set_standing`,
147
+ `archive_concept`, `unarchive_concept`, `star_concept`,
148
+ `remove_concept`. Edges: `link_concepts`, `add_kref`. Ideas:
149
+ `create_idea`, `rename_idea`, `recolor_idea`, `set_idea_members`,
150
+ `add_to_idea`, `delete_idea`. Projects: `create_project`,
151
+ `update_project`, `delete_project`. Every write stamps a row in
152
+ `public.provenance` with `origin='mcp'` so the webapp's session log
153
+ shows which surface made the change.
154
+
155
+ **Operator runs (3).** `run_operator` (generate candidates with
156
+ Branch / Matrix / ASIT / TRIZ / Combine / Free / Contradiction),
157
+ `run_operator_and_commit` (same, plus commit the result in one
158
+ round-trip), and `expand_concept` (recursive Branch, capped at depth ×
159
+ breadth ≤ 60).
160
+
161
+ Tool input shapes mirror their counterparts in the webapp's
162
+ `src/agent/tools.ts`, so an agent that uses both surfaces sees a
163
+ uniform contract.
164
+
165
+ Wave-shipping: `find_in_files` (in-browser embedding search) is in the
166
+ webapp but not yet on the MCP.
167
+
168
+ ## Legacy snapshot mode (deprecated)
169
+
170
+ The original read-only snapshot reader still works as a fallback while
171
+ users migrate to cloud auth. With no `~/.heuresis/credentials.json`
172
+ and the `HEURESIS_SNAPSHOT` env var set, the server reads a JSON
173
+ export from disk and exposes the original read-only tool set
174
+ (`get_workspace_summary`, `list_projects`, `search_concepts`,
175
+ `get_concept`, `get_subtree`, `get_project_graph`,
176
+ `list_recent_decisions`).
177
+
178
+ ```bash
179
+ export HEURESIS_SNAPSHOT="/absolute/path/to/your-export.json"
180
+ npx @heuresis/mcp
181
+ ```
182
+
183
+ This path is deprecated and will be removed in a later release. It is
184
+ here so existing setups keep working through the migration to cloud
185
+ auth.
186
+
187
+ ## License
188
+
189
+ AGPL-3.0-or-later.
@@ -28,6 +28,7 @@
28
28
  // Cost preview — every run_operator response carries an `estimated_cost:
29
29
  // { credits, dollars }` chip from the rate card in `docs/credits.md` §2.
30
30
  // Informational only; BYO-key runs don't bill against managed credits.
31
+ import { randomUUID } from 'node:crypto';
31
32
  import { z } from 'zod';
32
33
  import { unwrap } from './cloudClient.js';
33
34
  import { ASIT_OPERATORS } from './operators/asit.js';
@@ -205,6 +206,10 @@ export const runOperatorInput = z
205
206
  .record(z.unknown())
206
207
  .optional()
207
208
  .describe("Family-specific extras: { angle?: string } for free/explore/combine, { improving: number, worsening: number } for contradiction, { combineWithIds: string[] } for combine. Optional { provider: 'anthropic' | 'openai' | 'openrouter' | 'google' } overrides the default provider."),
209
+ force: z
210
+ .boolean()
211
+ .optional()
212
+ .describe('Bypass idempotent reuse and force a fresh run even if an identical run is already in flight or recently finished.'),
208
213
  })
209
214
  .strict();
210
215
  export async function runOperator(client, args) {
@@ -502,28 +507,175 @@ export async function expandConcept(client, args) {
502
507
  },
503
508
  };
504
509
  }
505
- // ---------------------------------------------------------------------------
506
- // OPERATOR_TOOLS export index.ts splices this onto CLOUD_TOOLS and wires
507
- // each entry's `handler(client, args)` to the lazy auth handshake.
508
- // ---------------------------------------------------------------------------
510
+ const FAST_PATH_MS = 8_000; // return inline if the run settles this fast
511
+ const REUSE_MS = 10 * 60_000; // idempotent-reuse window for completed runs
512
+ const MAX_RUNS = 200; // cap the in-memory registry
513
+ const MAX_CONCURRENT_OPS = 2; // heavy LLM bodies allowed in flight at once
514
+ const runsById = new Map();
515
+ const runIdByOpKey = new Map();
516
+ let activeOps = 0;
517
+ const opQueue = [];
518
+ function acquireOpSlot() {
519
+ if (activeOps < MAX_CONCURRENT_OPS) {
520
+ activeOps += 1;
521
+ return Promise.resolve();
522
+ }
523
+ return new Promise((resolve) => opQueue.push(resolve));
524
+ }
525
+ function releaseOpSlot() {
526
+ const next = opQueue.shift();
527
+ if (next)
528
+ next();
529
+ else
530
+ activeOps -= 1;
531
+ }
532
+ function stableKey(v) {
533
+ if (Array.isArray(v))
534
+ return v.map(stableKey);
535
+ if (v && typeof v === 'object') {
536
+ const o = v;
537
+ return Object.keys(o)
538
+ .sort()
539
+ .reduce((acc, k) => {
540
+ if (k !== 'force')
541
+ acc[k] = stableKey(o[k]); // `force` never affects identity
542
+ return acc;
543
+ }, {});
544
+ }
545
+ return v;
546
+ }
547
+ function makeOpKey(tool, args) {
548
+ return JSON.stringify([tool, stableKey(args)]);
549
+ }
550
+ function startOperatorRun(tool, args, work, force = false) {
551
+ const opKey = makeOpKey(tool, args);
552
+ if (!force) {
553
+ const prevId = runIdByOpKey.get(opKey);
554
+ const prev = prevId ? runsById.get(prevId) : undefined;
555
+ if (prev &&
556
+ (prev.status === 'running' ||
557
+ (prev.status === 'done' &&
558
+ prev.finishedAt !== undefined &&
559
+ Date.now() - prev.finishedAt < REUSE_MS))) {
560
+ return prev; // idempotent reuse — no duplicate run/commit
561
+ }
562
+ }
563
+ const run = {
564
+ runId: randomUUID(),
565
+ opKey,
566
+ tool,
567
+ status: 'running',
568
+ startedAt: Date.now(),
569
+ };
570
+ runsById.set(run.runId, run);
571
+ runIdByOpKey.set(opKey, run.runId);
572
+ if (runsById.size > MAX_RUNS) {
573
+ let oldest;
574
+ for (const r of runsById.values())
575
+ if (!oldest || r.startedAt < oldest.startedAt)
576
+ oldest = r;
577
+ if (oldest) {
578
+ runsById.delete(oldest.runId);
579
+ if (runIdByOpKey.get(oldest.opKey) === oldest.runId)
580
+ runIdByOpKey.delete(oldest.opKey);
581
+ }
582
+ }
583
+ void (async () => {
584
+ await acquireOpSlot();
585
+ try {
586
+ run.result = await work();
587
+ run.status = 'done';
588
+ }
589
+ catch (err) {
590
+ run.status = 'error';
591
+ run.error = err instanceof Error ? err.message : String(err);
592
+ }
593
+ finally {
594
+ run.finishedAt = Date.now();
595
+ releaseOpSlot();
596
+ }
597
+ })();
598
+ return run;
599
+ }
600
+ async function settleWithin(run, ms) {
601
+ const deadline = Date.now() + ms;
602
+ while (run.status === 'running' && Date.now() < deadline) {
603
+ await new Promise((r) => setTimeout(r, 200));
604
+ }
605
+ }
606
+ function runView(run) {
607
+ if (run.status === 'done')
608
+ return { runId: run.runId, status: 'done', result: run.result };
609
+ if (run.status === 'error')
610
+ return { runId: run.runId, status: 'error', error: run.error };
611
+ return {
612
+ runId: run.runId,
613
+ status: 'running',
614
+ hint: `Operator still running (a heavy model can take 1-2 min). Call get_run with runId "${run.runId}" — it returns the result the moment status is "done".`,
615
+ };
616
+ }
617
+ /**
618
+ * Run an operator asynchronously: start it (or reuse an identical in-flight /
619
+ * recent run), return inline if it settles within FAST_PATH_MS, otherwise hand
620
+ * back a runId for the agent to poll via get_run.
621
+ */
622
+ async function runAsync(tool, args, work) {
623
+ const force = Boolean(args?.force);
624
+ const run = startOperatorRun(tool, args, work, force);
625
+ await settleWithin(run, FAST_PATH_MS);
626
+ return runView(run);
627
+ }
628
+ export const getRunInput = z
629
+ .object({
630
+ runId: z
631
+ .string()
632
+ .min(1)
633
+ .describe('The runId returned by run_operator / run_operator_and_commit / expand_concept.'),
634
+ })
635
+ .strict();
509
636
  export const OPERATOR_TOOLS = [
510
637
  {
511
638
  name: 'run_operator',
512
639
  description: "Run an ASIT / TRIZ / Contradiction / Free / Combine / Explore operator against one concept (the anchor). Returns CANDIDATE concepts WITHOUT committing them — call `bulk_add_concepts` (or `run_operator_and_commit`) to persist. Use this when you want the agent to vet the candidates before writing. The anchor must already live in a project; the operator pulls in ancestry + validated knowledge in the same project to ground the prompt.",
513
640
  inputSchema: runOperatorInput,
514
- handler: async (client, raw) => runOperator(client, runOperatorInput.parse(raw)),
641
+ handler: async (client, raw) => {
642
+ const args = runOperatorInput.parse(raw);
643
+ return runAsync('run_operator', args, () => runOperator(client, args));
644
+ },
515
645
  },
516
646
  {
517
647
  name: 'run_operator_and_commit',
518
648
  description: "Same as `run_operator`, but immediately writes every top-level candidate as a child of the anchor with a partition edge and a provenance row stamped origin='mcp'. Use when the agent is confident the candidates should land directly on the canvas (e.g. inside an autonomous expand loop). Children-of-children proposed in `partitions[].children` are NOT auto-committed — request them via a separate run.",
519
649
  inputSchema: runOperatorAndCommitInput,
520
- handler: async (client, raw) => runOperatorAndCommit(client, runOperatorAndCommitInput.parse(raw)),
650
+ handler: async (client, raw) => {
651
+ const args = runOperatorAndCommitInput.parse(raw);
652
+ return runAsync('run_operator_and_commit', args, () => runOperatorAndCommit(client, args));
653
+ },
521
654
  },
522
655
  {
523
656
  name: 'expand_concept',
524
657
  description: 'Recursive Branch — expand a concept by `breadth` children at each of `depth` levels (depth*breadth ≤ 60). Commits each level immediately so the webapp shows partial results as the run progresses. Optionally takes an `angle` hint that biases the underlying EXPLORE operator. Best when the anchor has few or no children yet; on a dense subtree consider `run_operator(family="explore")` so the operator can read existing children and propose complementary partitions.',
525
658
  inputSchema: expandConceptInput,
526
- handler: async (client, raw) => expandConcept(client, expandConceptInput.parse(raw)),
659
+ handler: async (client, raw) => {
660
+ const args = expandConceptInput.parse(raw);
661
+ return runAsync('expand_concept', args, () => expandConcept(client, args));
662
+ },
663
+ },
664
+ {
665
+ name: 'get_run',
666
+ description: 'Fetch an async operator run by its runId. Returns { status: "running" | "done" | "error" }; when "done", the `result` field holds the operator output (candidates / committedIds). Waits up to ~8s for completion before returning, so a simple poll loop converges fast. run_operator / run_operator_and_commit / expand_concept hand back a runId whenever their work exceeds the ~8s fast-path window.',
667
+ inputSchema: getRunInput,
668
+ handler: async (_client, raw) => {
669
+ const { runId } = getRunInput.parse(raw);
670
+ const run = runsById.get(runId);
671
+ if (!run) {
672
+ return {
673
+ error: `No run with id ${runId}. Operator runs are kept in memory for the session; it may have been evicted (200-run cap) or the server restarted.`,
674
+ };
675
+ }
676
+ await settleWithin(run, FAST_PATH_MS);
677
+ return runView(run);
678
+ },
527
679
  },
528
680
  ];
529
681
  // Re-exports kept narrow on purpose: index.ts only needs `makeOperatorTools`,
@@ -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/index.js CHANGED
@@ -209,7 +209,7 @@ async function runServer() {
209
209
  inputSchema: zodToJsonSchema(t.inputSchema),
210
210
  })),
211
211
  }));
212
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
212
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
213
213
  const tool = tools.find((t) => t.name === req.params.name);
214
214
  if (!tool) {
215
215
  return {
@@ -219,7 +219,38 @@ async function runServer() {
219
219
  ],
220
220
  };
221
221
  }
222
+ // Heartbeat — emit progress notifications so MCP clients that honor them
223
+ // keep resetting their request timeout. Operator/LLM runs routinely exceed
224
+ // the 60s default, where the response would time out even though the work
225
+ // already committed (which also drove duplicate retries). No-op when the
226
+ // client supplied no progressToken.
227
+ const progressToken = req.params._meta?.progressToken;
228
+ let heartbeat;
229
+ if (progressToken !== undefined) {
230
+ let ticks = 0;
231
+ heartbeat = setInterval(() => {
232
+ ticks += 1;
233
+ try {
234
+ void extra
235
+ .sendNotification({
236
+ method: 'notifications/progress',
237
+ params: {
238
+ progressToken,
239
+ progress: ticks,
240
+ message: `still working… (~${ticks * 15}s)`,
241
+ },
242
+ })
243
+ .catch(() => { });
244
+ }
245
+ catch {
246
+ /* a heartbeat failure must never break the call */
247
+ }
248
+ }, 15_000);
249
+ }
222
250
  try {
251
+ // Operator tools are async now: they return a runId fast and run in the
252
+ // background with their own concurrency control (cloudOperators.ts), so no
253
+ // per-call serialization is needed here.
223
254
  const result = await tool.handler(req.params.arguments ?? {});
224
255
  const text = JSON.stringify(result, null, 2);
225
256
  if (text.length > MAX_RESULT_CHARS) {
@@ -246,6 +277,10 @@ async function runServer() {
246
277
  content: [{ type: 'text', text: `Error: ${msg}` }],
247
278
  };
248
279
  }
280
+ finally {
281
+ if (heartbeat)
282
+ clearInterval(heartbeat);
283
+ }
249
284
  });
250
285
  const transport = new StdioServerTransport();
251
286
  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
  }
@@ -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,58 +1,58 @@
1
- {
2
- "name": "@heuresis/mcp",
3
- "version": "1.0.0-rc.14",
4
- "mcpName": "io.github.ToremLabs/heuresis",
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
- "type": "module",
7
- "bin": {
8
- "heuresis-mcp": "dist/index.js"
9
- },
10
- "files": [
11
- "dist",
12
- "README.md"
13
- ],
14
- "scripts": {
15
- "build": "tsc",
16
- "start": "node dist/index.js",
17
- "dev": "tsc --watch",
18
- "prepublishOnly": "npm run build"
19
- },
20
- "publishConfig": {
21
- "access": "public"
22
- },
23
- "homepage": "https://heuresis.app/mcp",
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/ToremLabs/Heuresis.git",
27
- "directory": "mcp-server"
28
- },
29
- "keywords": [
30
- "mcp",
31
- "model-context-protocol",
32
- "heuresis",
33
- "ideation",
34
- "knowledge-graph",
35
- "claude-code",
36
- "claude-desktop",
37
- "cursor"
38
- ],
39
- "dependencies": {
40
- "@anthropic-ai/sdk": "^0.40.0",
41
- "@google/generative-ai": "^0.21.0",
42
- "@modelcontextprotocol/sdk": "^1.0.0",
43
- "@supabase/supabase-js": "^2.45.0",
44
- "openai": "^4.71.0",
45
- "undici": "^6.25.0",
46
- "ws": "^8.18.0",
47
- "zod": "^3.23.0"
48
- },
49
- "devDependencies": {
50
- "@types/node": "^22.0.0",
51
- "@types/ws": "^8.5.13",
52
- "typescript": "^5.6.0"
53
- },
54
- "engines": {
55
- "node": ">=18"
56
- },
57
- "license": "AGPL-3.0-or-later"
58
- }
1
+ {
2
+ "name": "@heuresis/mcp",
3
+ "version": "1.0.0-rc.16",
4
+ "mcpName": "io.github.ToremLabs/heuresis",
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
+ "type": "module",
7
+ "bin": {
8
+ "heuresis-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "start": "node dist/index.js",
17
+ "dev": "tsc --watch",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "homepage": "https://heuresis.app/mcp",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/ToremLabs/Heuresis.git",
27
+ "directory": "mcp-server"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "heuresis",
33
+ "ideation",
34
+ "knowledge-graph",
35
+ "claude-code",
36
+ "claude-desktop",
37
+ "cursor"
38
+ ],
39
+ "dependencies": {
40
+ "@anthropic-ai/sdk": "^0.40.0",
41
+ "@google/generative-ai": "^0.21.0",
42
+ "@modelcontextprotocol/sdk": "^1.0.0",
43
+ "@supabase/supabase-js": "^2.45.0",
44
+ "openai": "^4.71.0",
45
+ "undici": "^6.25.0",
46
+ "ws": "^8.18.0",
47
+ "zod": "^3.23.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.0.0",
51
+ "@types/ws": "^8.5.13",
52
+ "typescript": "^5.6.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=18"
56
+ },
57
+ "license": "AGPL-3.0-or-later"
58
+ }