heartcode 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lsp.js ADDED
@@ -0,0 +1,207 @@
1
+ // After HeartCode writes a file, a language server for it (when one is installed) says what is now
2
+ // wrong, and the errors go back to the model with the edit's result: it fixes a type error right
3
+ // away instead of finding it at the end, or never.
4
+ // Servers, each found on PATH or in the project's node_modules/.bin: typescript-language-server,
5
+ // gopls, pyright (or basedpyright), rust-analyzer, clangd, dart. One per language and project,
6
+ // started at the first edit and kept until HeartCode exits. The settings page (General → Features) turns it off.
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { spawn } from 'node:child_process'
10
+ import { pathToFileURL } from 'node:url'
11
+ import { log } from './log.js'
12
+
13
+ const SERVERS = [
14
+ { name: 'typescript', ext: /\.(m?[jt]sx?|cjs|cts)$/, cmds: [['typescript-language-server', '--stdio']], id: (f) => (/\.[cm]?tsx?$/.test(f) ? (f.endsWith('x') ? 'typescriptreact' : 'typescript') : f.endsWith('x') ? 'javascriptreact' : 'javascript') },
15
+ { name: 'go', ext: /\.go$/, cmds: [['gopls']], id: () => 'go' },
16
+ { name: 'python', ext: /\.pyi?$/, cmds: [['pyright-langserver', '--stdio'], ['basedpyright-langserver', '--stdio']], id: () => 'python' },
17
+ { name: 'rust', ext: /\.rs$/, cmds: [['rust-analyzer']], id: () => 'rust' },
18
+ { name: 'c', ext: /\.(c|cc|cpp|cxx|h|hh|hpp)$/, cmds: [['clangd']], id: (f) => (/\.(c|h)$/.test(f) ? 'c' : 'cpp') },
19
+ { name: 'dart', ext: /\.dart$/, cmds: [['dart', 'language-server', '--client-id', 'heartcode']], id: () => 'dart' },
20
+ ]
21
+
22
+ // Extra servers for tests or the user: { name, ext, cmds, id }; tried before the built-in ones.
23
+ export const extraServers = []
24
+
25
+ function find(root, cmd) {
26
+ const local = path.join(root, 'node_modules', '.bin', cmd)
27
+ if (fs.existsSync(local)) return local
28
+ if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null
29
+ for (const d of (process.env.PATH ?? '').split(path.delimiter)) if (d && fs.existsSync(path.join(d, cmd))) return path.join(d, cmd)
30
+ return null
31
+ }
32
+
33
+ class Server {
34
+ constructor(spec, argv, root) {
35
+ Object.assign(this, { spec, root, next: 1, pending: new Map(), docs: new Map(), diags: new Map(), waiters: new Set(), dead: false })
36
+ this.child = spawn(argv[0], argv.slice(1), { cwd: root, stdio: ['pipe', 'pipe', 'ignore'], detached: process.platform !== 'win32' })
37
+ let buf = Buffer.alloc(0)
38
+ this.child.stdout.on('data', (d) => {
39
+ buf = Buffer.concat([buf, d])
40
+ for (;;) {
41
+ const head = buf.indexOf('\r\n\r\n')
42
+ if (head === -1) return
43
+ const len = Number(/content-length:\s*(\d+)/i.exec(buf.subarray(0, head).toString())?.[1])
44
+ if (!(len >= 0)) return this.#die('a message without Content-Length')
45
+ if (buf.length < head + 4 + len) return
46
+ const body = buf.subarray(head + 4, head + 4 + len).toString()
47
+ buf = buf.subarray(head + 4 + len)
48
+ try {
49
+ this.#receive(JSON.parse(body))
50
+ } catch (e) {
51
+ log('lsp', `${spec.name}: unreadable message: ${e.message}`)
52
+ }
53
+ }
54
+ })
55
+ this.child.on('error', (e) => this.#die(e.message))
56
+ this.child.on('exit', (code) => this.#die(`exited (${code})`))
57
+ this.child.stdin.on('error', () => {})
58
+ // it never keeps HeartCode running: stopped on exit (below), waited on only while checking
59
+ this.child.unref()
60
+ this.child.stdin.unref?.()
61
+ this.child.stdout.unref?.()
62
+ const uri = pathToFileURL(root).href
63
+ this.ready = this.request('initialize', {
64
+ processId: process.pid,
65
+ rootUri: uri,
66
+ rootPath: root,
67
+ workspaceFolders: [{ uri, name: path.basename(root) }],
68
+ capabilities: {
69
+ textDocument: { synchronization: { didSave: true }, publishDiagnostics: { versionSupport: true } },
70
+ workspace: { configuration: true, workspaceFolders: true },
71
+ window: { workDoneProgress: true },
72
+ },
73
+ }).then(() => this.notify('initialized', {}))
74
+ }
75
+
76
+ #die(why) {
77
+ if (this.dead) return
78
+ this.dead = true
79
+ log('lsp', `${this.spec.name} stopped: ${why}`)
80
+ for (const { reject } of this.pending.values()) reject(new Error(`${this.spec.name} language server ${why}`))
81
+ this.pending.clear()
82
+ for (const w of this.waiters) w()
83
+ }
84
+
85
+ #send(msg) {
86
+ if (this.dead) return
87
+ const body = JSON.stringify({ jsonrpc: '2.0', ...msg })
88
+ this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
89
+ }
90
+
91
+ request(method, params) {
92
+ const id = this.next++
93
+ return new Promise((resolve, reject) => {
94
+ if (this.dead) return reject(new Error(`${this.spec.name} language server is not running`))
95
+ this.pending.set(id, { resolve, reject })
96
+ this.#send({ id, method, params })
97
+ })
98
+ }
99
+
100
+ notify(method, params) {
101
+ this.#send({ method, params })
102
+ }
103
+
104
+ #receive(msg) {
105
+ if (msg.id !== undefined && !msg.method) {
106
+ const p = this.pending.get(msg.id)
107
+ this.pending.delete(msg.id)
108
+ return msg.error ? p?.reject(new Error(msg.error.message)) : p?.resolve(msg.result)
109
+ }
110
+ if (msg.id !== undefined) {
111
+ // the server asks something: its settings (none given: its defaults), progress tokens, registrations
112
+ const result = msg.method === 'workspace/configuration' ? (msg.params?.items ?? []).map(() => null) : msg.method === 'workspace/workspaceFolders' ? [{ uri: pathToFileURL(this.root).href, name: path.basename(this.root) }] : null
113
+ return this.#send({ id: msg.id, result })
114
+ }
115
+ if (msg.method === 'textDocument/publishDiagnostics') {
116
+ this.diags.set(msg.params.uri, { at: Date.now(), list: msg.params.diagnostics ?? [] })
117
+ for (const w of this.waiters) w()
118
+ }
119
+ }
120
+
121
+ // The file's errors after this change: waits for the server's report on it, then for it to go
122
+ // quiet (servers often send a fast report, then a full one).
123
+ async check(abs, text, wait) {
124
+ await this.ready
125
+ const uri = pathToFileURL(abs).href
126
+ const since = Date.now()
127
+ const version = (this.docs.get(uri) ?? 0) + 1
128
+ if (version === 1) this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.spec.id(abs), version, text } })
129
+ else this.notify('textDocument/didChange', { textDocument: { uri, version }, contentChanges: [{ text }] })
130
+ this.notify('textDocument/didSave', { textDocument: { uri }, text })
131
+ this.docs.set(uri, version)
132
+ const fresh = () => (this.diags.get(uri)?.at ?? 0) >= since
133
+ const end = Date.now() + wait
134
+ while (!this.dead) {
135
+ const until = Math.min(end, fresh() ? this.diags.get(uri).at + 700 : end) // 0.7s without a newer report
136
+ if (Date.now() >= until) break
137
+ await new Promise((resolve) => {
138
+ const w = () => (this.waiters.delete(w), clearTimeout(t), resolve())
139
+ const t = setTimeout(w, until - Date.now())
140
+ this.waiters.add(w)
141
+ })
142
+ }
143
+ return fresh() ? this.diags.get(uri).list : null
144
+ }
145
+
146
+ stop() {
147
+ if (this.dead) return
148
+ this.dead = true
149
+ try {
150
+ if (process.platform !== 'win32') process.kill(-this.child.pid, 'SIGTERM')
151
+ else this.child.kill()
152
+ } catch {}
153
+ }
154
+ }
155
+
156
+ const running = new Map() // root + server name → Server, or null (none installed / it died)
157
+ process.on('exit', () => {
158
+ for (const s of running.values()) s?.stop()
159
+ })
160
+
161
+ function serverFor(root, rel) {
162
+ const spec = [...extraServers, ...SERVERS].find((x) => x.ext.test(rel))
163
+ if (!spec) return null
164
+ const key = `${root}\0${spec.name}`
165
+ if (!running.has(key)) {
166
+ let server = null
167
+ for (const [cmd, ...args] of spec.cmds) {
168
+ const bin = find(root, cmd)
169
+ if (!bin) continue
170
+ server = new Server(spec, [bin, ...args], root)
171
+ log('lsp', `started ${spec.name}: ${bin} ${args.join(' ')}`)
172
+ break
173
+ }
174
+ running.set(key, server)
175
+ }
176
+ const s = running.get(key)
177
+ return s && !s.dead ? s : null
178
+ }
179
+
180
+ const SEVERITY = { 1: 'error', 2: 'warning' }
181
+
182
+ // What's wrong in the file now, as a note for the model, or '' (no server, nothing wrong, or no
183
+ // report in time). Errors only, unless there are none: the last check's errors are fixed is said too.
184
+ export async function diagnose(s, rel, text) {
185
+ const server = serverFor(s.root, rel)
186
+ if (!server) return ''
187
+ const abs = path.join(s.root, rel)
188
+ let list
189
+ const keep = setInterval(() => {}, 1000) // the server is unref'd: this keeps HeartCode up while it answers
190
+ try {
191
+ list = await server.check(abs, text, server.checked ? 6000 : 30_000) // the first check waits for the server to start
192
+ server.checked = true
193
+ } catch (e) {
194
+ log('lsp', `${rel}: ${e.message}`)
195
+ return ''
196
+ } finally {
197
+ clearInterval(keep)
198
+ }
199
+ if (!list) return ''
200
+ const errors = list.filter((d) => (d.severity ?? 1) === 1)
201
+ const had = (s.lspErrors ??= new Map()).get(rel)
202
+ s.lspErrors.set(rel, errors.length)
203
+ log('lsp', `${rel}: ${errors.length} error(s)`, errors.slice(0, 20))
204
+ if (!errors.length) return had ? `The ${server.spec.name} language server reports no errors in ${rel} now.` : ''
205
+ const lines = errors.slice(0, 10).map((d) => `${rel}:${d.range.start.line + 1}:${d.range.start.character + 1}: ${SEVERITY[d.severity ?? 1]}: ${String(d.message).split('\n')[0]}${d.source ? ` (${d.source})` : ''}`)
206
+ return `The ${server.spec.name} language server reports ${errors.length} error${errors.length > 1 ? 's' : ''} in ${rel} now:\n${lines.join('\n')}${errors.length > 10 ? `\n… ${errors.length - 10} more` : ''}`
207
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,376 @@
1
+ // MCP servers (Model Context Protocol), JSON-RPC 2.0. Configured like Claude Code / Cursor, in
2
+ // ~/.heart/mcp.json and <project>/.heartcode/mcp.json:
3
+ // { "mcpServers": { "name": { "command": "npx", "args": [...], "env": {...}, "description": "...", "disabled": false } } }
4
+ // or a remote one: { "url": "https://…/mcp", "headers": { … }, "type": "http" | "sse", "oauth": { "clientId": …, "scope": … } }
5
+ // A local server talks over its stdin/stdout, one message per line. A remote one over Streamable
6
+ // HTTP (or the older HTTP+SSE: "type": "sse", a URL ending in /sse, or when HTTP is refused), and
7
+ // signs in with OAuth when it asks (oauth.js). ${VAR} in a URL or header is the environment's VAR.
8
+ // A server's tools are offered to the model as tools of their own (mcp__server__tool), from the
9
+ // list it gave the last time it ran (kept in ~/.heart/mcp-tools.json, with its instructions): so a
10
+ // server starts the first time the model uses it, or once to say its tools; it stops when
11
+ // HeartCode exits.
12
+ import { spawn } from 'node:child_process'
13
+ import crypto from 'node:crypto'
14
+ import path from 'node:path'
15
+ import { pathToFileURL } from 'node:url'
16
+ import { HOME, readJson, readConfig, writeJson, VERSION } from './config.js'
17
+ import { log } from './log.js'
18
+ import { savedToken, signIn } from './oauth.js'
19
+
20
+ // Built in; a config entry with the same name changes it, "disabled": true turns it off.
21
+ export const DEFAULT_SERVERS = {
22
+ 'chrome-devtools': {
23
+ command: 'npx',
24
+ args: ['-y', 'chrome-devtools-mcp@latest'],
25
+ description: 'a Chrome browser: open pages, click, fill forms, read the console and network requests, take screenshots',
26
+ },
27
+ }
28
+
29
+ // extra: more configs from the caller (--mcp-config: a file path or JSON text); strict: only those
30
+ // (no built-in or configured servers), like Claude Code's --strict-mcp-config.
31
+ export function loadServers(root, { extra = [], strict = false } = {}) {
32
+ const merged = strict ? {} : structuredClone(DEFAULT_SERVERS) // yours (below) win on a name
33
+ const own = strict ? [] : [path.join(HOME, 'mcp.json'), root && path.join(root, '.heartcode', 'mcp.json')].filter(Boolean).map((f) => readConfig(f, {}))
34
+ const given = extra.map((c) => {
35
+ const data = /^\s*\{/.test(c) ? JSON.parse(c) : readJson(path.resolve(c), null)
36
+ if (!data) throw new Error(`can't read the MCP config ${c}`)
37
+ return data
38
+ })
39
+ for (const data of [...own, ...given]) {
40
+ for (const [name, cfg] of Object.entries(data.mcpServers ?? {})) merged[name] = { ...merged[name], ...cfg }
41
+ }
42
+ return Object.fromEntries(Object.entries(merged).filter(([, cfg]) => (cfg.command || cfg.url) && !cfg.disabled))
43
+ }
44
+
45
+ const expand = (v) => String(v).replace(/\$\{(\w+)\}/g, (m, k) => process.env[k] ?? '')
46
+
47
+ // A server's server-sent events: { event, data }.
48
+ async function* sseEvents(body) {
49
+ const dec = new TextDecoder()
50
+ let buf = ''
51
+ let ev = { event: 'message', data: [] }
52
+ for await (const chunk of body) {
53
+ buf += dec.decode(chunk, { stream: true })
54
+ let nl
55
+ while ((nl = buf.indexOf('\n')) !== -1) {
56
+ const line = buf.slice(0, nl).replace(/\r$/, '')
57
+ buf = buf.slice(nl + 1)
58
+ if (!line) {
59
+ if (ev.data.length) yield { event: ev.event, data: ev.data.join('\n') }
60
+ ev = { event: 'message', data: [] }
61
+ } else if (!line.startsWith(':')) {
62
+ const i = line.indexOf(':')
63
+ const [field, value] = i < 0 ? [line, ''] : [line.slice(0, i), line.slice(i + 1).replace(/^ /, '')]
64
+ if (field === 'event') ev.event = value
65
+ else if (field === 'data') ev.data.push(value)
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ class Server {
72
+ constructor(name, cfg, root, hub) {
73
+ Object.assign(this, { name, cfg, root, hub, next: 1, pending: new Map(), child: null, ready: null, tools: null })
74
+ }
75
+
76
+ get url() {
77
+ return this.cfg.url && expand(this.cfg.url)
78
+ }
79
+
80
+ start(signal) {
81
+ this.ready ??= (async () => {
82
+ const init = () => this.request('initialize', { protocolVersion: '2025-06-18', capabilities: { roots: {} }, clientInfo: { name: 'heartcode', version: VERSION } }, this.url ? 600_000 : 120_000, signal) // remote: time to sign in
83
+ let r
84
+ if (this.url) {
85
+ const type = this.cfg.type ?? this.cfg.transport
86
+ log('mcp', `connecting to ${this.name}: ${this.url}`)
87
+ this.sse = type === 'sse' || (!type && /\/sse\/?$/.test(new URL(this.url).pathname))
88
+ if (this.sse) {
89
+ try {
90
+ await this.#openSse(signal)
91
+ } catch (e) {
92
+ if (type || !(e.status >= 400 && e.status < 500)) throw e
93
+ log('mcp', `${this.name}: no HTTP+SSE (${e.status}); trying Streamable HTTP`)
94
+ this.sse = false // only guessed from the URL
95
+ this.noSse = true
96
+ }
97
+ }
98
+ try {
99
+ r = await init()
100
+ } catch (e) {
101
+ if (type || this.sse || this.noSse || ![400, 404, 405].includes(e.status)) throw e
102
+ log('mcp', `${this.name}: no Streamable HTTP (${e.status}); trying HTTP+SSE`)
103
+ this.sse = true
104
+ await this.#openSse(signal)
105
+ r = await init()
106
+ }
107
+ } else r = await this.#spawn(init)
108
+ this.protocol = r?.protocolVersion
109
+ this.instructions = typeof r?.instructions === 'string' ? r.instructions.trim() : ''
110
+ this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' })
111
+ })().catch((e) => {
112
+ this.stop() // failed to start: the next use tries again
113
+ throw e
114
+ })
115
+ return this.ready
116
+ }
117
+
118
+ async #spawn(init) {
119
+ const { command, args = [], env = {} } = this.cfg
120
+ log('mcp', `starting ${this.name}: ${command} ${args.join(' ')}`)
121
+ const child = (this.child = spawn(command, args, { cwd: this.root ?? undefined, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' })) // in the project, so a relative path in its config works
122
+ let buf = ''
123
+ child.stdout.on('data', (d) => {
124
+ buf += d
125
+ let nl
126
+ while ((nl = buf.indexOf('\n')) !== -1) {
127
+ const line = buf.slice(0, nl).trim()
128
+ buf = buf.slice(nl + 1)
129
+ if (line) this.#receive(line)
130
+ }
131
+ })
132
+ child.stderr.on('data', (d) => log('mcp', `${this.name} stderr: ${String(d).trim()}`))
133
+ const gone = new Promise((_, reject) => {
134
+ child.on('error', (e) => reject(new Error(`${this.name} could not start: ${e.message}`)))
135
+ child.on('exit', (code) => {
136
+ this.ready = null
137
+ this.#failAll(new Error(`${this.name} stopped (exit ${code})`))
138
+ reject(new Error(`${this.name} stopped (exit ${code})`))
139
+ })
140
+ })
141
+ gone.catch(() => {})
142
+ return Promise.race([init(), gone])
143
+ }
144
+
145
+ #failAll(err) {
146
+ for (const { reject } of this.pending.values()) reject(err)
147
+ this.pending.clear()
148
+ }
149
+
150
+ // An HTTP request to the server, signed in: a saved token goes with it; a 401 refreshes it, or
151
+ // signs in (the browser opens) once.
152
+ async #http(url, init, signal) {
153
+ this.token ??= await savedToken(this.url)
154
+ for (let attempt = 0; ; attempt++) {
155
+ const headers = { ...Object.fromEntries(Object.entries(this.cfg.headers ?? {}).map(([k, v]) => [k, expand(v)])), ...init.headers, ...(this.token && { authorization: `Bearer ${this.token}` }) }
156
+ const res = await fetch(url, { ...init, headers, signal: init.signal ?? signal })
157
+ if (res.status === 401 && attempt === 0) {
158
+ const how = res.headers.get('www-authenticate') ?? ''
159
+ await res.body?.cancel()
160
+ this.token = (this.token && (await savedToken(this.url, { force: true }))) || (await this.#signIn(how, signal))
161
+ continue
162
+ }
163
+ if (!res.ok) {
164
+ const text = await res.text().catch(() => '')
165
+ throw Object.assign(new Error(`${this.name}: ${res.status} from ${url}${text ? `: ${text.slice(0, 200)}` : ''}`), { status: res.status })
166
+ }
167
+ return res
168
+ }
169
+ }
170
+
171
+ #signIn(how, signal) {
172
+ const open = this.hub.onSignIn
173
+ if (!open) throw new Error(`${this.name} needs you to sign in: start heartcode and run /login_mcp ${this.name}`)
174
+ return signIn(this.url, how, (link) => open(this.name, link), { signal, oauth: this.cfg.oauth })
175
+ }
176
+
177
+ // HTTP+SSE (the older transport): a stream of events from the server; the first names where
178
+ // to POST messages, the rest are its messages.
179
+ async #openSse(signal) {
180
+ this.abort?.abort()
181
+ this.abort = new AbortController()
182
+ const res = await this.#http(this.url, { method: 'GET', headers: { accept: 'text/event-stream' }, signal: this.abort.signal }, signal)
183
+ const events = sseEvents(res.body)
184
+ for (;;) {
185
+ const { value, done } = await events.next()
186
+ if (done) throw new Error(`${this.name} closed its event stream before saying where to send messages`)
187
+ if (value.event === 'endpoint') {
188
+ this.endpoint = new URL(value.data, this.url).href
189
+ break
190
+ }
191
+ }
192
+ ;(async () => {
193
+ for await (const ev of events) if (ev.event === 'message') this.#receive(ev.data)
194
+ })()
195
+ .catch((e) => log('mcp', `${this.name} event stream: ${e.message}`))
196
+ .finally(() => {
197
+ if (this.abort?.signal.aborted) return
198
+ this.ready = null
199
+ this.#failAll(new Error(`${this.name} closed the connection`))
200
+ })
201
+ }
202
+
203
+ // Streamable HTTP: each message is a POST; the answer is JSON or a stream of events.
204
+ // (HTTP+SSE: answers come on the event stream instead.)
205
+ async #post(msg) {
206
+ const headers = { accept: 'application/json, text/event-stream', 'content-type': 'application/json', ...(this.session && { 'mcp-session-id': this.session }), ...(this.protocol && { 'mcp-protocol-version': this.protocol }) }
207
+ const res = await this.#http(this.sse ? this.endpoint : this.url, { method: 'POST', headers, body: JSON.stringify(msg) })
208
+ this.session = res.headers.get('mcp-session-id') ?? this.session
209
+ const type = res.headers.get('content-type') ?? ''
210
+ if (this.sse || res.status === 202 || !res.body) return void (await res.body?.cancel())
211
+ if (type.includes('text/event-stream')) {
212
+ for await (const ev of sseEvents(res.body)) if (ev.event === 'message') this.#receive(ev.data)
213
+ } else {
214
+ const text = await res.text()
215
+ if (text.trim()) for (const m of [JSON.parse(text)].flat()) this.#receive(m)
216
+ }
217
+ }
218
+
219
+ #send(msg) {
220
+ if (!this.url) return this.child.stdin.write(JSON.stringify(msg) + '\n')
221
+ this.#post(msg).catch((e) => {
222
+ const p = this.pending.get(msg.id)
223
+ if (!p) return log('mcp', `${this.name}: ${e.message}`)
224
+ this.pending.delete(msg.id)
225
+ p.reject(e)
226
+ })
227
+ }
228
+
229
+ #receive(line) {
230
+ let msg = line
231
+ if (typeof line === 'string') {
232
+ try {
233
+ msg = JSON.parse(line)
234
+ } catch {
235
+ return log('mcp', `${this.name} sent a non-JSON line: ${line.slice(0, 200)}`)
236
+ }
237
+ }
238
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error) && this.pending.has(msg.id)) {
239
+ const { resolve, reject } = this.pending.get(msg.id)
240
+ this.pending.delete(msg.id)
241
+ return msg.error ? reject(new Error(`${this.name}: ${msg.error.message ?? JSON.stringify(msg.error)}`)) : resolve(msg.result)
242
+ }
243
+ // requests from the server: answer the common ones, decline the rest
244
+ if (msg.id !== undefined && msg.method) {
245
+ if (msg.method === 'ping') return this.#send({ jsonrpc: '2.0', id: msg.id, result: {} })
246
+ // the project folder: servers that read or write files (screenshots, traces) may use it
247
+ if (msg.method === 'roots/list') return this.#send({ jsonrpc: '2.0', id: msg.id, result: { roots: this.root ? [{ uri: pathToFileURL(this.root).href, name: path.basename(this.root) }] : [] } })
248
+ return this.#send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `HeartCode does not support ${msg.method}` } })
249
+ }
250
+ }
251
+
252
+ request(method, params, timeout = 120_000, signal) {
253
+ return new Promise((resolve, reject) => {
254
+ const id = this.next++
255
+ const timer = setTimeout(() => {
256
+ this.pending.delete(id)
257
+ reject(new Error(`${this.name} did not answer ${method} within ${timeout / 1000}s`))
258
+ }, timeout)
259
+ const done = (fn) => (v) => {
260
+ clearTimeout(timer)
261
+ signal?.removeEventListener('abort', onAbort)
262
+ fn(v)
263
+ }
264
+ const onAbort = () => {
265
+ this.pending.delete(id)
266
+ done(reject)(Object.assign(new Error('stopped'), { name: 'AbortError' }))
267
+ }
268
+ signal?.addEventListener('abort', onAbort, { once: true })
269
+ this.pending.set(id, { resolve: done(resolve), reject: done(reject) })
270
+ this.#send({ jsonrpc: '2.0', id, method, params })
271
+ })
272
+ }
273
+
274
+ async listTools(signal) {
275
+ await this.start(signal)
276
+ if (!this.tools) {
277
+ this.tools = []
278
+ let cursor
279
+ do {
280
+ const r = await this.request('tools/list', cursor ? { cursor } : {})
281
+ this.tools.push(...(r.tools ?? []))
282
+ cursor = r.nextCursor
283
+ } while (cursor)
284
+ this.hub.remember(this)
285
+ }
286
+ return this.tools
287
+ }
288
+
289
+ async call(tool, args, signal) {
290
+ await this.start(signal)
291
+ if (!this.tools) this.listTools().catch(() => {}) // started from the saved list: what it has now, for next time
292
+ return this.request('tools/call', { name: tool, arguments: args ?? {} }, 300_000, signal)
293
+ }
294
+
295
+ stop() {
296
+ if (this.url) {
297
+ this.abort?.abort()
298
+ Object.assign(this, { ready: null, session: null, endpoint: null })
299
+ return
300
+ }
301
+ if (!this.child) return
302
+ try {
303
+ if (this.child.pid && process.platform !== 'win32') process.kill(-this.child.pid, 'SIGTERM')
304
+ else this.child.kill()
305
+ } catch {}
306
+ this.child = null
307
+ this.ready = null
308
+ }
309
+ }
310
+
311
+ const TOOLS_FILE = path.join(HOME, 'mcp-tools.json')
312
+ const keyOf = (cfg) => crypto.createHash('sha256').update(JSON.stringify(cfg)).digest('hex').slice(0, 16)
313
+
314
+ // All configured servers for a project; nothing runs until a server is first used.
315
+ // onSignIn(name, url): set by the screen, to show a remote server's sign-in page (none headless).
316
+ export class Mcp {
317
+ constructor(root, opts) {
318
+ this.servers = new Map(Object.entries(loadServers(root, opts)).map(([name, cfg]) => [name, new Server(name, cfg, root, this)]))
319
+ this.onExit = () => this.stopAll()
320
+ process.on('exit', this.onExit)
321
+ }
322
+
323
+ // Done with it (replaced after the config changed): servers stop, the exit hook goes.
324
+ dispose() {
325
+ this.stopAll()
326
+ process.off('exit', this.onExit)
327
+ }
328
+
329
+ get names() {
330
+ return [...this.servers.keys()]
331
+ }
332
+
333
+ get(name) {
334
+ return this.servers.get(name)
335
+ }
336
+
337
+ // Each server's { tools, instructions }, for the model: the list saved the last time it ran
338
+ // (with the same config), else asked for now, waiting at most `wait` ms; a server that hasn't
339
+ // answered by then (it may still be starting: npx downloads it the first time) or failed to
340
+ // start isn't in the result. It's tried again next time, and its list is saved once it answers.
341
+ async catalog(signal, wait = 15_000) {
342
+ const saved = readJson(TOOLS_FILE, {})
343
+ const out = new Map()
344
+ const asks = []
345
+ for (const [name, sv] of this.servers) {
346
+ const hit = sv.tools ? { tools: sv.tools, instructions: sv.instructions ?? '' } : saved[name]?.key === keyOf(sv.cfg) ? saved[name] : null
347
+ if (hit) out.set(name, { tools: hit.tools, instructions: hit.instructions ?? '' })
348
+ else if (!sv.failed) asks.push(sv.listTools(signal).then((tools) => out.set(name, { tools, instructions: sv.instructions ?? '' }), (e) => {
349
+ if (e.name === 'AbortError') return
350
+ sv.failed = true // not waited for again in this run; still usable through the mcp tool
351
+ log('mcp', `${name}: could not list its tools: ${e.message}`)
352
+ }))
353
+ }
354
+ if (asks.length) {
355
+ let timer
356
+ await Promise.race([Promise.all(asks), new Promise((r) => (timer = setTimeout(r, wait)))])
357
+ clearTimeout(timer)
358
+ }
359
+ return out
360
+ }
361
+
362
+ // A server's tools, saved for the next run (the config's hash, not the config: it may hold keys).
363
+ remember(sv) {
364
+ const saved = readJson(TOOLS_FILE, {})
365
+ saved[sv.name] = { key: keyOf(sv.cfg), tools: sv.tools, instructions: sv.instructions ?? '', at: new Date().toISOString() }
366
+ try {
367
+ writeJson(TOOLS_FILE, saved)
368
+ } catch (e) {
369
+ log('mcp', `could not save ${sv.name}'s tools: ${e.message}`)
370
+ }
371
+ }
372
+
373
+ stopAll() {
374
+ for (const s of this.servers.values()) s.stop()
375
+ }
376
+ }