heartcode 0.0.0-stage → 0.1.0

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,330 @@
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 starts the first time the model uses it, and stops when HeartCode exits.
9
+ import { spawn } from 'node:child_process'
10
+ import path from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+ import { HOME, readJson, readConfig } from './config.js'
13
+ import { log } from './log.js'
14
+ import { savedToken, signIn } from './oauth.js'
15
+
16
+ // Built in; a config entry with the same name changes it, "disabled": true turns it off.
17
+ export const DEFAULT_SERVERS = {
18
+ 'chrome-devtools': {
19
+ command: 'npx',
20
+ args: ['-y', 'chrome-devtools-mcp@latest'],
21
+ description: 'a Chrome browser: open pages, click, fill forms, read the console and network requests, take screenshots',
22
+ },
23
+ }
24
+
25
+ // extra: more configs from the caller (--mcp-config: a file path or JSON text); strict: only those
26
+ // (no built-in or configured servers), like Claude Code's --strict-mcp-config.
27
+ export function loadServers(root, { extra = [], strict = false } = {}) {
28
+ const merged = strict ? {} : structuredClone(DEFAULT_SERVERS) // yours (below) win on a name
29
+ const own = strict ? [] : [path.join(HOME, 'mcp.json'), root && path.join(root, '.heartcode', 'mcp.json')].filter(Boolean).map((f) => readConfig(f, {}))
30
+ const given = extra.map((c) => {
31
+ const data = /^\s*\{/.test(c) ? JSON.parse(c) : readJson(path.resolve(c), null)
32
+ if (!data) throw new Error(`can't read the MCP config ${c}`)
33
+ return data
34
+ })
35
+ for (const data of [...own, ...given]) {
36
+ for (const [name, cfg] of Object.entries(data.mcpServers ?? {})) merged[name] = { ...merged[name], ...cfg }
37
+ }
38
+ return Object.fromEntries(Object.entries(merged).filter(([, cfg]) => (cfg.command || cfg.url) && !cfg.disabled))
39
+ }
40
+
41
+ const expand = (v) => String(v).replace(/\$\{(\w+)\}/g, (m, k) => process.env[k] ?? '')
42
+
43
+ // A server's server-sent events: { event, data }.
44
+ async function* sseEvents(body) {
45
+ const dec = new TextDecoder()
46
+ let buf = ''
47
+ let ev = { event: 'message', data: [] }
48
+ for await (const chunk of body) {
49
+ buf += dec.decode(chunk, { stream: true })
50
+ let nl
51
+ while ((nl = buf.indexOf('\n')) !== -1) {
52
+ const line = buf.slice(0, nl).replace(/\r$/, '')
53
+ buf = buf.slice(nl + 1)
54
+ if (!line) {
55
+ if (ev.data.length) yield { event: ev.event, data: ev.data.join('\n') }
56
+ ev = { event: 'message', data: [] }
57
+ } else if (!line.startsWith(':')) {
58
+ const i = line.indexOf(':')
59
+ const [field, value] = i < 0 ? [line, ''] : [line.slice(0, i), line.slice(i + 1).replace(/^ /, '')]
60
+ if (field === 'event') ev.event = value
61
+ else if (field === 'data') ev.data.push(value)
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ class Server {
68
+ constructor(name, cfg, root, hub) {
69
+ Object.assign(this, { name, cfg, root, hub, next: 1, pending: new Map(), child: null, ready: null, tools: null })
70
+ }
71
+
72
+ get url() {
73
+ return this.cfg.url && expand(this.cfg.url)
74
+ }
75
+
76
+ start(signal) {
77
+ this.ready ??= (async () => {
78
+ const init = () => this.request('initialize', { protocolVersion: '2025-06-18', capabilities: { roots: {} }, clientInfo: { name: 'heartcode', version: '0.1.0' } }, this.url ? 600_000 : 120_000, signal) // remote: time to sign in
79
+ let r
80
+ if (this.url) {
81
+ const type = this.cfg.type ?? this.cfg.transport
82
+ log('mcp', `connecting to ${this.name}: ${this.url}`)
83
+ this.sse = type === 'sse' || (!type && /\/sse\/?$/.test(new URL(this.url).pathname))
84
+ if (this.sse) {
85
+ try {
86
+ await this.#openSse(signal)
87
+ } catch (e) {
88
+ if (type || !(e.status >= 400 && e.status < 500)) throw e
89
+ log('mcp', `${this.name}: no HTTP+SSE (${e.status}); trying Streamable HTTP`)
90
+ this.sse = false // only guessed from the URL
91
+ this.noSse = true
92
+ }
93
+ }
94
+ try {
95
+ r = await init()
96
+ } catch (e) {
97
+ if (type || this.sse || this.noSse || ![400, 404, 405].includes(e.status)) throw e
98
+ log('mcp', `${this.name}: no Streamable HTTP (${e.status}); trying HTTP+SSE`)
99
+ this.sse = true
100
+ await this.#openSse(signal)
101
+ r = await init()
102
+ }
103
+ } else r = await this.#spawn(init)
104
+ this.protocol = r?.protocolVersion
105
+ this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' })
106
+ })().catch((e) => {
107
+ this.stop() // failed to start: the next use tries again
108
+ throw e
109
+ })
110
+ return this.ready
111
+ }
112
+
113
+ async #spawn(init) {
114
+ const { command, args = [], env = {} } = this.cfg
115
+ log('mcp', `starting ${this.name}: ${command} ${args.join(' ')}`)
116
+ 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
117
+ let buf = ''
118
+ child.stdout.on('data', (d) => {
119
+ buf += d
120
+ let nl
121
+ while ((nl = buf.indexOf('\n')) !== -1) {
122
+ const line = buf.slice(0, nl).trim()
123
+ buf = buf.slice(nl + 1)
124
+ if (line) this.#receive(line)
125
+ }
126
+ })
127
+ child.stderr.on('data', (d) => log('mcp', `${this.name} stderr: ${String(d).trim()}`))
128
+ const gone = new Promise((_, reject) => {
129
+ child.on('error', (e) => reject(new Error(`${this.name} could not start: ${e.message}`)))
130
+ child.on('exit', (code) => {
131
+ this.ready = null
132
+ this.#failAll(new Error(`${this.name} stopped (exit ${code})`))
133
+ reject(new Error(`${this.name} stopped (exit ${code})`))
134
+ })
135
+ })
136
+ gone.catch(() => {})
137
+ return Promise.race([init(), gone])
138
+ }
139
+
140
+ #failAll(err) {
141
+ for (const { reject } of this.pending.values()) reject(err)
142
+ this.pending.clear()
143
+ }
144
+
145
+ // An HTTP request to the server, signed in: a saved token goes with it; a 401 refreshes it, or
146
+ // signs in (the browser opens) once.
147
+ async #http(url, init, signal) {
148
+ this.token ??= await savedToken(this.url)
149
+ for (let attempt = 0; ; attempt++) {
150
+ const headers = { ...Object.fromEntries(Object.entries(this.cfg.headers ?? {}).map(([k, v]) => [k, expand(v)])), ...init.headers, ...(this.token && { authorization: `Bearer ${this.token}` }) }
151
+ const res = await fetch(url, { ...init, headers, signal: init.signal ?? signal })
152
+ if (res.status === 401 && attempt === 0) {
153
+ const how = res.headers.get('www-authenticate') ?? ''
154
+ await res.body?.cancel()
155
+ this.token = (this.token && (await savedToken(this.url, { force: true }))) || (await this.#signIn(how, signal))
156
+ continue
157
+ }
158
+ if (!res.ok) {
159
+ const text = await res.text().catch(() => '')
160
+ throw Object.assign(new Error(`${this.name}: ${res.status} from ${url}${text ? `: ${text.slice(0, 200)}` : ''}`), { status: res.status })
161
+ }
162
+ return res
163
+ }
164
+ }
165
+
166
+ #signIn(how, signal) {
167
+ const open = this.hub.onSignIn
168
+ if (!open) throw new Error(`${this.name} needs you to sign in: start heartcode and run /login_mcp ${this.name}`)
169
+ return signIn(this.url, how, (link) => open(this.name, link), { signal, oauth: this.cfg.oauth })
170
+ }
171
+
172
+ // HTTP+SSE (the older transport): a stream of events from the server; the first names where
173
+ // to POST messages, the rest are its messages.
174
+ async #openSse(signal) {
175
+ this.abort?.abort()
176
+ this.abort = new AbortController()
177
+ const res = await this.#http(this.url, { method: 'GET', headers: { accept: 'text/event-stream' }, signal: this.abort.signal }, signal)
178
+ const events = sseEvents(res.body)
179
+ for (;;) {
180
+ const { value, done } = await events.next()
181
+ if (done) throw new Error(`${this.name} closed its event stream before saying where to send messages`)
182
+ if (value.event === 'endpoint') {
183
+ this.endpoint = new URL(value.data, this.url).href
184
+ break
185
+ }
186
+ }
187
+ ;(async () => {
188
+ for await (const ev of events) if (ev.event === 'message') this.#receive(ev.data)
189
+ })()
190
+ .catch((e) => log('mcp', `${this.name} event stream: ${e.message}`))
191
+ .finally(() => {
192
+ if (this.abort?.signal.aborted) return
193
+ this.ready = null
194
+ this.#failAll(new Error(`${this.name} closed the connection`))
195
+ })
196
+ }
197
+
198
+ // Streamable HTTP: each message is a POST; the answer is JSON or a stream of events.
199
+ // (HTTP+SSE: answers come on the event stream instead.)
200
+ async #post(msg) {
201
+ 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 }) }
202
+ const res = await this.#http(this.sse ? this.endpoint : this.url, { method: 'POST', headers, body: JSON.stringify(msg) })
203
+ this.session = res.headers.get('mcp-session-id') ?? this.session
204
+ const type = res.headers.get('content-type') ?? ''
205
+ if (this.sse || res.status === 202 || !res.body) return void (await res.body?.cancel())
206
+ if (type.includes('text/event-stream')) {
207
+ for await (const ev of sseEvents(res.body)) if (ev.event === 'message') this.#receive(ev.data)
208
+ } else {
209
+ const text = await res.text()
210
+ if (text.trim()) for (const m of [JSON.parse(text)].flat()) this.#receive(m)
211
+ }
212
+ }
213
+
214
+ #send(msg) {
215
+ if (!this.url) return this.child.stdin.write(JSON.stringify(msg) + '\n')
216
+ this.#post(msg).catch((e) => {
217
+ const p = this.pending.get(msg.id)
218
+ if (!p) return log('mcp', `${this.name}: ${e.message}`)
219
+ this.pending.delete(msg.id)
220
+ p.reject(e)
221
+ })
222
+ }
223
+
224
+ #receive(line) {
225
+ let msg = line
226
+ if (typeof line === 'string') {
227
+ try {
228
+ msg = JSON.parse(line)
229
+ } catch {
230
+ return log('mcp', `${this.name} sent a non-JSON line: ${line.slice(0, 200)}`)
231
+ }
232
+ }
233
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error) && this.pending.has(msg.id)) {
234
+ const { resolve, reject } = this.pending.get(msg.id)
235
+ this.pending.delete(msg.id)
236
+ return msg.error ? reject(new Error(`${this.name}: ${msg.error.message ?? JSON.stringify(msg.error)}`)) : resolve(msg.result)
237
+ }
238
+ // requests from the server: answer the common ones, decline the rest
239
+ if (msg.id !== undefined && msg.method) {
240
+ if (msg.method === 'ping') return this.#send({ jsonrpc: '2.0', id: msg.id, result: {} })
241
+ // the project folder: servers that read or write files (screenshots, traces) may use it
242
+ 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) }] : [] } })
243
+ return this.#send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `HeartCode does not support ${msg.method}` } })
244
+ }
245
+ }
246
+
247
+ request(method, params, timeout = 120_000, signal) {
248
+ return new Promise((resolve, reject) => {
249
+ const id = this.next++
250
+ const timer = setTimeout(() => {
251
+ this.pending.delete(id)
252
+ reject(new Error(`${this.name} did not answer ${method} within ${timeout / 1000}s`))
253
+ }, timeout)
254
+ const done = (fn) => (v) => {
255
+ clearTimeout(timer)
256
+ signal?.removeEventListener('abort', onAbort)
257
+ fn(v)
258
+ }
259
+ const onAbort = () => {
260
+ this.pending.delete(id)
261
+ done(reject)(Object.assign(new Error('stopped'), { name: 'AbortError' }))
262
+ }
263
+ signal?.addEventListener('abort', onAbort, { once: true })
264
+ this.pending.set(id, { resolve: done(resolve), reject: done(reject) })
265
+ this.#send({ jsonrpc: '2.0', id, method, params })
266
+ })
267
+ }
268
+
269
+ async listTools(signal) {
270
+ await this.start(signal)
271
+ if (!this.tools) {
272
+ this.tools = []
273
+ let cursor
274
+ do {
275
+ const r = await this.request('tools/list', cursor ? { cursor } : {})
276
+ this.tools.push(...(r.tools ?? []))
277
+ cursor = r.nextCursor
278
+ } while (cursor)
279
+ }
280
+ return this.tools
281
+ }
282
+
283
+ async call(tool, args, signal) {
284
+ await this.start(signal)
285
+ return this.request('tools/call', { name: tool, arguments: args ?? {} }, 300_000, signal)
286
+ }
287
+
288
+ stop() {
289
+ if (this.url) {
290
+ this.abort?.abort()
291
+ Object.assign(this, { ready: null, session: null, endpoint: null })
292
+ return
293
+ }
294
+ if (!this.child) return
295
+ try {
296
+ if (this.child.pid && process.platform !== 'win32') process.kill(-this.child.pid, 'SIGTERM')
297
+ else this.child.kill()
298
+ } catch {}
299
+ this.child = null
300
+ this.ready = null
301
+ }
302
+ }
303
+
304
+ // All configured servers for a project; nothing runs until a server is first used.
305
+ // onSignIn(name, url): set by the screen, to show a remote server's sign-in page (none headless).
306
+ export class Mcp {
307
+ constructor(root, opts) {
308
+ this.servers = new Map(Object.entries(loadServers(root, opts)).map(([name, cfg]) => [name, new Server(name, cfg, root, this)]))
309
+ this.onExit = () => this.stopAll()
310
+ process.on('exit', this.onExit)
311
+ }
312
+
313
+ // Done with it (replaced after the config changed): servers stop, the exit hook goes.
314
+ dispose() {
315
+ this.stopAll()
316
+ process.off('exit', this.onExit)
317
+ }
318
+
319
+ get names() {
320
+ return [...this.servers.keys()]
321
+ }
322
+
323
+ get(name) {
324
+ return this.servers.get(name)
325
+ }
326
+
327
+ stopAll() {
328
+ for (const s of this.servers.values()) s.stop()
329
+ }
330
+ }