hivecode-mcp 0.11.6 → 0.12.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/hive-setup.js ADDED
@@ -0,0 +1,445 @@
1
+ // hive-setup.js -- `npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI]`
2
+ //
3
+ // ONE COMMAND FROM NOTHING TO AN AGENT THAT CAN JOIN. It exists because setup cost a real
4
+ // tester an hour on 24 Sep 2026, and when it was measured the next day on a cold machine the
5
+ // hour had concrete causes, none of them in the relay (hive_join itself took 0.5s):
6
+ //
7
+ // 1. The README's config was {"command":"npx", ...}. Agent apps start MCP servers WITHOUT a
8
+ // shell, and on Windows npx is npx.cmd, so the launch fails with `spawn npx ENOENT` -- the
9
+ // agent just never gets the hive_* tools, and nothing on screen says why.
10
+ // 2. The first `npx hivecode-mcp@latest` start took 36.7s, downloading. Claude Code gives an
11
+ // MCP server 30s, so a new user's FIRST launch fails, and the second one may not.
12
+ // 3. hive_join's default folder was './workspace', relative to whatever directory the agent
13
+ // app happened to start the server in -- sometimes the user's project, sometimes a
14
+ // directory nobody can write to.
15
+ //
16
+ // So this does the four things a person was doing by hand, and then CHECKS them:
17
+ // - installs a runtime copy under ~/.hivecode/runtime/<version>, so launching needs no
18
+ // download, no network and no npx;
19
+ // - writes the hivecode entry into every agent config it finds, with the absolute path of
20
+ // THIS node, which also dodges GUI apps on macOS not seeing an nvm PATH;
21
+ // - launches each entry exactly the way the agent app will -- read back from the file, no
22
+ // shell -- and does a real MCP handshake with it;
23
+ // - prints the one sentence to paste into the agent.
24
+ //
25
+ // THE FAILURE THIS MUST NEVER HAVE is the one every defect in this repo has had: reporting
26
+ // success while the result is wrong. An agent whose config could not be written, or whose entry
27
+ // does not start, is printed as FAILED and makes the exit code non-zero. "Configured" is only
28
+ // ever printed about an entry that was read back from disk and answered a handshake.
29
+ //
30
+ // WHAT IT COSTS WHEN WRONG: these are the user's own config files, holding their other MCP
31
+ // servers. So a file is parsed or refused (JSON with comments is refused, not rewritten), the
32
+ // original is backed up once before the first change, only the `hivecode` key is touched, and
33
+ // every other key survives byte-for-byte as a value.
34
+ import fs from 'fs'
35
+ import os from 'os'
36
+ import path from 'path'
37
+ import { spawn } from 'child_process'
38
+ import { fileURLToPath } from 'url'
39
+ import { VERSION } from './hive-version.js'
40
+ import { currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
41
+ import { validateMemberName } from './hive-mailbox.js'
42
+
43
+ export const SERVER_KEY = 'hivecode'
44
+ const BACKUP_SUFFIX = '.before-hivecode'
45
+
46
+ /** Every agent app we know how to configure, and where each keeps its MCP servers. */
47
+ export function agentTargets(home = os.homedir(), env = process.env, platform = process.platform) {
48
+ const claudeDesktopDir = platform === 'win32' ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude')
49
+ : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Claude')
50
+ : path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Claude')
51
+ // `present` is how we decide the app is installed. It is deliberately the app's OWN directory
52
+ // (or, where the app creates it on first run, its own file) -- never "the file we would write",
53
+ // which would make every run create configs for apps the user does not have.
54
+ return [
55
+ { id: 'claude-code', label: 'Claude Code', file: path.join(home, '.claude.json'), present: path.join(home, '.claude.json'), claudeCode: true,
56
+ restart: 'start a new Claude Code session (or restart `claude`)' },
57
+ { id: 'antigravity', label: 'Antigravity', file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), present: path.join(home, '.gemini', 'antigravity'),
58
+ restart: 'Settings > Skills & Customizations > MCP Servers, then click refresh on hivecode (or restart Antigravity)' },
59
+ // Antigravity also reads this one. It is only touched when it ALREADY has a hivecode entry --
60
+ // a stale one there could shadow the fresh one above -- and never created.
61
+ { id: 'antigravity-2', label: 'Antigravity (2nd)', file: path.join(home, '.gemini', 'config', 'mcp_config.json'), present: path.join(home, '.gemini', 'config', 'mcp_config.json'),
62
+ onlyIfHasEntry: true, restart: 'same refresh as above' },
63
+ { id: 'cursor', label: 'Cursor', file: path.join(home, '.cursor', 'mcp.json'), present: path.join(home, '.cursor'),
64
+ restart: 'Cursor Settings > MCP, then turn hivecode off and on (or restart Cursor)' },
65
+ { id: 'windsurf', label: 'Windsurf', file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), present: path.join(home, '.codeium', 'windsurf'),
66
+ restart: 'Windsurf > MCP servers > Refresh (or restart Windsurf)' },
67
+ { id: 'claude-desktop', label: 'Claude Desktop', file: path.join(claudeDesktopDir, 'claude_desktop_config.json'), present: claudeDesktopDir,
68
+ restart: 'quit Claude Desktop fully (tray icon > Quit) and open it again' },
69
+ // The Gemini CLI shares ~/.gemini with Antigravity, so its directory proves nothing. Its own
70
+ // settings file does: the CLI writes it on first run.
71
+ { id: 'gemini-cli', label: 'Gemini CLI', file: path.join(home, '.gemini', 'settings.json'), present: path.join(home, '.gemini', 'settings.json'),
72
+ restart: 'restart `gemini`' },
73
+ ]
74
+ }
75
+
76
+ /**
77
+ * The node to write into configs: an absolute path (a bare "node" fails with ENOENT in GUI apps
78
+ * started from the macOS Dock, which do not see an nvm or Homebrew PATH), but the most STABLE
79
+ * absolute path that is still this same program. process.execPath on Homebrew is the versioned
80
+ * Cellar path, which `brew upgrade node` deletes; /opt/homebrew/bin/node is a link that follows
81
+ * the upgrade. A candidate is only taken when its realpath is exactly this node, so the choice
82
+ * can never change WHICH node runs -- and verifyEntry launches whatever was chosen anyway.
83
+ */
84
+ export function stableNodePath(execPath = process.execPath, env = process.env) {
85
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
86
+ const target = real(execPath)
87
+ if (!target) return execPath
88
+ const exe = process.platform === 'win32' ? 'node.exe' : 'node'
89
+ const versioned = /[\\/]v?\d+\.\d+\.\d+[\\/]|[\\/]Cellar[\\/]/
90
+ const fromPath = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, exe))
91
+ const cands = ['/opt/homebrew/bin/node', '/usr/local/bin/node', ...fromPath.filter((c) => !versioned.test(c))]
92
+ for (const c of cands) if (c !== execPath && fs.existsSync(c) && real(c) === target) return c
93
+ return execPath
94
+ }
95
+
96
+ /**
97
+ * Parse the JSON-with-comments that Cursor, VS Code and friends accept: // and /* *\/ comments and
98
+ * trailing commas, all outside strings. Returns the plain-JSON text.
99
+ */
100
+ export function stripJsonc(src) {
101
+ let out = ''
102
+ for (let i = 0, inStr = false; i < src.length; i++) {
103
+ const c = src[i]
104
+ if (inStr) { out += c; if (c === '\\') out += src[++i] ?? ''; else if (c === '"') inStr = false; continue }
105
+ if (c === '"') { inStr = true; out += c; continue }
106
+ if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue }
107
+ if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue }
108
+ out += c
109
+ }
110
+ let res = ''
111
+ for (let i = 0, inStr = false; i < out.length; i++) {
112
+ const c = out[i]
113
+ if (inStr) { res += c; if (c === '\\') res += out[++i] ?? ''; else if (c === '"') inStr = false; continue }
114
+ if (c === '"') { inStr = true; res += c; continue }
115
+ if (c === ',') { let j = i + 1; while (j < out.length && /\s/.test(out[j])) j++; if (out[j] === '}' || out[j] === ']') continue }
116
+ res += c
117
+ }
118
+ return res
119
+ }
120
+
121
+ /** The entry every agent gets: this node, by absolute path, running hive-mcp.js directly. */
122
+ export function serverEntry(runtimeJs, { claudeCode = false, previous = null, node = stableNodePath() } = {}) {
123
+ const entry = { command: node, args: [runtimeJs] }
124
+ // What the user added to an existing entry is theirs: a token, a relay, a working directory.
125
+ // Only the launch itself -- command and args -- is ours to replace.
126
+ if (previous && typeof previous === 'object') {
127
+ if (previous.env && typeof previous.env === 'object') entry.env = previous.env
128
+ if (typeof previous.cwd === 'string') entry.cwd = previous.cwd
129
+ }
130
+ if (claudeCode) return { type: 'stdio', ...entry, env: entry.env || {} }
131
+ return entry
132
+ }
133
+
134
+ /**
135
+ * Write the hivecode entry into one config file. Returns { status, detail }, where status is one
136
+ * of: 'written', 'unchanged', 'refused' (the file exists but is not plain JSON), 'error'.
137
+ */
138
+ export function writeEntry(target, runtimeJs, { dryRun = false } = {}) {
139
+ let raw = ''
140
+ try { raw = fs.readFileSync(target.file, 'utf8') } catch (e) { if (e.code !== 'ENOENT') return { status: 'error', detail: e.message } }
141
+ let cfg = {}
142
+ let droppedComments = false
143
+ if (raw.trim()) {
144
+ const text = raw.replace(/^/, '')
145
+ try { cfg = JSON.parse(text) } catch {
146
+ // JSON with comments is legal in the files Cursor and VS Code write. Refusing it would hand
147
+ // the user back the hand-editing this command exists to remove; the original is kept.
148
+ try { cfg = JSON.parse(stripJsonc(text)); droppedComments = true } catch {
149
+ return { status: 'refused', detail: 'not valid JSON even allowing comments, so it was left exactly as it is' }
150
+ }
151
+ }
152
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { status: 'refused', detail: 'the file is not a JSON object' }
153
+ }
154
+ const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? cfg.mcpServers : {}
155
+ const previous = servers[SERVER_KEY] || null
156
+ if (target.onlyIfHasEntry && !previous) return { status: 'skipped' }
157
+ const entry = serverEntry(runtimeJs, { claudeCode: !!target.claudeCode, previous })
158
+ const next = { ...cfg, mcpServers: { ...servers, [SERVER_KEY]: entry } }
159
+ // CLAUDE CODE'S OTHER SCOPE. `claude mcp add` stores a server under projects[<folder>] unless
160
+ // told otherwise, and that entry OVERRIDES the user-wide one inside that folder. Everyone who
161
+ // followed the old README therefore has a broken npx entry that would silently win over the one
162
+ // written above -- and this command would have printed READY about it. Those are ours by name.
163
+ let projectFixes = 0
164
+ if (target.claudeCode && cfg.projects && typeof cfg.projects === 'object') {
165
+ for (const [p, v] of Object.entries(cfg.projects)) {
166
+ const old = v && v.mcpServers && v.mcpServers[SERVER_KEY]
167
+ if (!old || typeof old !== 'object') continue
168
+ const fixed = serverEntry(runtimeJs, { claudeCode: true, previous: old })
169
+ if (JSON.stringify(fixed) === JSON.stringify(old)) continue
170
+ if (next.projects === cfg.projects) next.projects = { ...cfg.projects }
171
+ next.projects[p] = { ...v, mcpServers: { ...v.mcpServers, [SERVER_KEY]: fixed } }
172
+ projectFixes++
173
+ }
174
+ }
175
+ if (previous && !projectFixes && JSON.stringify(previous) === JSON.stringify(entry)) return { status: 'unchanged', detail: 'already set up' }
176
+ if (dryRun) return { status: 'written', detail: 'dry run: would write', entry }
177
+ try {
178
+ fs.mkdirSync(path.dirname(target.file), { recursive: true })
179
+ // The backup is taken ONCE, of the file as it was before Hivecode ever touched it, so a
180
+ // second or tenth run can never overwrite the one copy worth going back to.
181
+ if (raw && !fs.existsSync(target.file + BACKUP_SUFFIX)) fs.writeFileSync(target.file + BACKUP_SUFFIX, raw)
182
+ const tmp = target.file + '.hivecode-tmp'
183
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
184
+ try { fs.renameSync(tmp, target.file) } catch {
185
+ // A locked destination (an app holding it, an antivirus scan) refuses the rename on
186
+ // Windows. Writing in place is less atomic but still leaves a whole file behind.
187
+ fs.writeFileSync(target.file, JSON.stringify(next, null, 2) + '\n'); try { fs.rmSync(tmp, { force: true }) } catch {}
188
+ }
189
+ } catch (e) { return { status: 'error', detail: e.message } }
190
+ let detail = previous ? 'replaced the old hivecode entry' : 'added hivecode'
191
+ if (projectFixes) detail += `, and fixed ${projectFixes} per-folder entr${projectFixes === 1 ? 'y' : 'ies'} that would have overridden it`
192
+ if (droppedComments) detail += ` (its comments were dropped; the original is ${path.basename(target.file)}${BACKUP_SUFFIX})`
193
+ return { status: 'written', detail }
194
+ }
195
+
196
+ /**
197
+ * Every hivecode entry in a written file must launch the same thing -- including Claude Code's
198
+ * per-folder ones, which are never verified by launching separately. Returns the first entry
199
+ * that disagrees with `entry`, or null.
200
+ */
201
+ export function strayEntry(file, entry) {
202
+ let cfg
203
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')) } catch { return null }
204
+ for (const [p, v] of Object.entries((cfg && cfg.projects) || {})) {
205
+ const e = v && v.mcpServers && v.mcpServers[SERVER_KEY]
206
+ if (e && (e.command !== entry.command || JSON.stringify(e.args) !== JSON.stringify(entry.args))) return p
207
+ }
208
+ return null
209
+ }
210
+
211
+ /** Read the entry back FROM DISK -- the thing the agent app will actually launch. */
212
+ export function readEntry(file) {
213
+ try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')).mcpServers[SERVER_KEY] || null } catch { return null }
214
+ }
215
+
216
+ /**
217
+ * Launch an entry exactly as an MCP client does -- no shell, the entry's own env and cwd -- and
218
+ * complete a real `initialize` handshake. Anything short of a hivecode server answering is a
219
+ * failure with the reason attached.
220
+ */
221
+ export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
222
+ return new Promise((resolve) => {
223
+ if (!entry || !entry.command) return resolve({ ok: false, error: 'no hivecode entry in the file' })
224
+ const t0 = Date.now()
225
+ const cwd = entry.cwd && fs.existsSync(entry.cwd) ? entry.cwd : os.tmpdir()
226
+ let child
227
+ try {
228
+ child = spawn(entry.command, entry.args || [], { cwd, env: { ...process.env, ...(entry.env || {}) }, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
229
+ } catch (e) { return resolve({ ok: false, error: e.message }) }
230
+ let out = ''
231
+ let errTail = ''
232
+ let settled = false
233
+ const done = (r) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill() } catch {}; resolve({ ms: Date.now() - t0, ...r }) }
234
+ const timer = setTimeout(() => done({ ok: false, error: `no answer within ${Math.round(timeoutMs / 1000)}s${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }), timeoutMs)
235
+ child.on('error', (e) => done({ ok: false, error: `${e.code || ''} ${e.message}`.trim() }))
236
+ child.on('exit', (code) => done({ ok: false, error: `the server exited (code ${code}) before answering${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }))
237
+ child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-400) })
238
+ child.stdout.on('data', (d) => {
239
+ out += d
240
+ let i
241
+ while ((i = out.indexOf('\n')) >= 0) {
242
+ const line = out.slice(0, i); out = out.slice(i + 1)
243
+ let m; try { m = JSON.parse(line) } catch { continue }
244
+ if (m.id !== 1) continue
245
+ const info = m.result && m.result.serverInfo
246
+ if (info && info.name === 'hivecode') done({ ok: true, version: info.version })
247
+ else done({ ok: false, error: 'something answered, but it is not the hivecode server' })
248
+ }
249
+ })
250
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'hivecode-setup', version: VERSION } } }) + '\n')
251
+ })
252
+ }
253
+
254
+ /**
255
+ * Put a runtime where agent apps can launch it with no download. When this file is running out
256
+ * of a checkout (development), the checkout IS the runtime. When it is running out of an
257
+ * installed package (npx), the package and its dependency closure are copied under
258
+ * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
259
+ * npm prunes it and the config would break months later with nothing to connect it to setup.
260
+ */
261
+ export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url)) {
262
+ const pkgDir = path.dirname(here)
263
+ const nm = path.dirname(pkgDir)
264
+ if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
265
+ const dest = path.join(home, '.hivecode', 'runtime', VERSION)
266
+ const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
267
+ if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
268
+ // ONLY the dependency closure is copied, never the whole node_modules around us: that would
269
+ // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
270
+ const closure = new Set()
271
+ const seen = new Set()
272
+ const visit = (name, fromDir) => {
273
+ // Node's own resolution: <dir>/node_modules/<name> for each directory walking up from the
274
+ // requiring package, skipping directories that ARE node_modules (Node never looks in
275
+ // node_modules/node_modules), and stopping at the folder that holds our node_modules.
276
+ for (let d = fromDir; ;) {
277
+ if (path.basename(d) !== 'node_modules') {
278
+ const cand = path.join(d, 'node_modules', name)
279
+ if (fs.existsSync(path.join(cand, 'package.json'))) {
280
+ if (seen.has(cand)) return // dependency cycles are real (and legal) in npm trees
281
+ seen.add(cand)
282
+ // Nested copies travel inside their parent's directory; only top-level ones are listed.
283
+ if (path.relative(nm, cand) === path.join(...name.split('/'))) closure.add(name)
284
+ let pj = {}
285
+ try { pj = JSON.parse(fs.readFileSync(path.join(cand, 'package.json'), 'utf8')) } catch {}
286
+ for (const dep of Object.keys({ ...pj.dependencies, ...pj.optionalDependencies, ...pj.peerDependencies })) visit(dep, cand)
287
+ return
288
+ }
289
+ }
290
+ const up = path.dirname(d)
291
+ if (up === d || d === path.dirname(nm)) return // a missing optional or peer dependency
292
+ d = up
293
+ }
294
+ }
295
+ closure.add('hivecode-mcp')
296
+ let rootPj = {}
297
+ try { rootPj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) } catch {}
298
+ for (const dep of Object.keys({ ...rootPj.dependencies, ...rootPj.optionalDependencies })) visit(dep, pkgDir)
299
+ const tmp = `${dest}.tmp-${process.pid}`
300
+ fs.rmSync(tmp, { recursive: true, force: true })
301
+ for (const name of closure) {
302
+ fs.cpSync(path.join(nm, name), path.join(tmp, 'node_modules', name), { recursive: true, dereference: true })
303
+ }
304
+ fs.rmSync(dest, { recursive: true, force: true })
305
+ fs.renameSync(tmp, dest)
306
+ return { mode: 'installed', js, dir: dest, packages: closure.size }
307
+ }
308
+
309
+ function defaultName() {
310
+ let base = 'agent'
311
+ try { base = os.userInfo().username || base } catch {}
312
+ const cand = (base.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent') + '-AI'
313
+ const v = validateMemberName(cand)
314
+ return v.ok ? cand : v.suggested
315
+ }
316
+
317
+ async function wakeRelay(relay) {
318
+ const url = relay.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '') + '/health'
319
+ const t0 = Date.now()
320
+ try {
321
+ const r = await fetch(url, { signal: AbortSignal.timeout(75000) })
322
+ return { ok: r.ok, ms: Date.now() - t0, status: r.status }
323
+ } catch (e) { return { ok: false, ms: Date.now() - t0, error: e.name === 'TimeoutError' ? 'no answer in 75s' : e.message } }
324
+ }
325
+
326
+ function parseArgs(argv) {
327
+ const o = { link: '', name: '', dryRun: false, help: false }
328
+ for (let i = 0; i < argv.length; i++) {
329
+ const a = argv[i]
330
+ if (a === '--name') o.name = argv[++i] || ''
331
+ else if (a.startsWith('--name=')) o.name = a.slice(7)
332
+ else if (a === '--dry-run') o.dryRun = true
333
+ else if (a === '-h' || a === '--help') o.help = true
334
+ else if (!a.startsWith('-') && !o.link) o.link = a
335
+ }
336
+ return o
337
+ }
338
+
339
+ const pad = (s, n) => (s + ' '.repeat(n)).slice(0, Math.max(n, s.length))
340
+
341
+ /** Returns the process exit code: 0 only when nothing that exists failed. */
342
+ export async function main(argv = [], { home = os.homedir(), log = console.log } = {}) {
343
+ const o = parseArgs(argv)
344
+ if (o.help) {
345
+ log('Usage: npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI] [--dry-run]')
346
+ return 0
347
+ }
348
+ log(`\nHivecode setup (hivecode-mcp ${VERSION})\n`)
349
+ const major = Number(process.versions.node.split('.')[0])
350
+ if (major < 18) { log(` FAILED Node ${process.versions.node} is too old. Install Node 18 or newer from https://nodejs.org and run this again.`); return 1 }
351
+
352
+ let name = o.name || defaultName()
353
+ const nv = validateMemberName(name)
354
+ if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
355
+
356
+ let runtime
357
+ try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home) }
358
+ catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
359
+ log(` runtime ${runtime.js}`)
360
+ log(` node ${process.execPath}\n`)
361
+
362
+ const results = []
363
+ for (const t of agentTargets(home)) {
364
+ // A second config path of an app that IS installed is not an app of its own: printing
365
+ // "Antigravity (2nd) not installed" made a stranger ask what part of Antigravity was missing.
366
+ if (!fs.existsSync(t.present)) { if (!t.onlyIfHasEntry) results.push({ t, status: 'absent' }); continue }
367
+ const w = writeEntry(t, runtime.js, { dryRun: o.dryRun })
368
+ if (w.status !== 'skipped') results.push({ t, ...w })
369
+ }
370
+ // Verify what is ON DISK, once per distinct launch -- the entries are normally identical.
371
+ const cache = new Map()
372
+ for (const r of results) {
373
+ if (r.status !== 'written' && r.status !== 'unchanged') continue
374
+ if (o.dryRun) { r.verify = { ok: true, ms: 0, skipped: true }; continue }
375
+ const entry = readEntry(r.t.file)
376
+ const key = JSON.stringify(entry)
377
+ if (!cache.has(key)) cache.set(key, await verifyEntry(entry))
378
+ r.verify = cache.get(key)
379
+ const stray = r.verify.ok && entry && strayEntry(r.t.file, entry)
380
+ if (stray) r.verify = { ok: false, error: `the entry for the folder ${stray} still launches something else, and it wins inside that folder` }
381
+ }
382
+ // A project's own .mcp.json outranks everything written above, inside that project. It is a
383
+ // shared, committed file, so it is reported rather than rewritten with this machine's paths.
384
+ const projectMcp = path.join(process.cwd(), '.mcp.json')
385
+ if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry.\n`)
386
+
387
+ let failed = 0
388
+ const ready = []
389
+ for (const r of results) {
390
+ const where = r.t.file
391
+ if (r.status === 'absent') { log(` ${pad(r.t.label, 15)} not installed`); continue }
392
+ if (r.status === 'refused' || r.status === 'error') {
393
+ failed++
394
+ log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} ${r.detail}. Add the hivecode entry below by hand.`)
395
+ continue
396
+ }
397
+ if (!r.verify.ok) {
398
+ failed++
399
+ log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} written, but it does not start: ${r.verify.error}`)
400
+ continue
401
+ }
402
+ ready.push(r)
403
+ const how = r.verify.skipped ? 'dry run, nothing written' : `starts in ${(r.verify.ms / 1000).toFixed(1)}s`
404
+ log(` ${pad(r.t.label, 15)} READY ${r.status === 'unchanged' ? 'already set up' : r.detail} (${how})`)
405
+ }
406
+
407
+ const manual = { mcpServers: { [SERVER_KEY]: serverEntry(runtime.js) } }
408
+ if (failed || !ready.length) {
409
+ log(`\n ${ready.length ? 'For the agents above that FAILED' : 'No supported agent app was found. For any MCP agent'}, add this to its MCP settings:\n`)
410
+ log(JSON.stringify(manual, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
411
+ }
412
+
413
+ let link = o.link.trim()
414
+ if (link) {
415
+ const [relayPart, ...rest] = link.split('|')
416
+ const { relay, migratedFrom } = currentRelayFor(relayPart.trim() || PUBLIC_DEFAULT_RELAY)
417
+ if (migratedFrom) { link = [relay, ...rest].join('|'); log(`\n note that link used a retired relay; it now points at ${relay}`) }
418
+ if (!rest.length || !rest[0].trim()) {
419
+ // THE PIPE TRAP. Unquoted, `setup wss://relay|room-x` is two commands to every shell: this
420
+ // one gets `wss://relay`, and our stdout is piped into a program called room-x. So the
421
+ // explanation also goes to stderr, the one stream still reaching the terminal.
422
+ const hint = `\n WARNING "${o.link}" has no room in it -- the part after | was cut off. Put the whole link in quotes, or leave it out: you give the link to your agent, not to this command.`
423
+ log(hint)
424
+ if (!process.stdout.isTTY) console.error(hint)
425
+ }
426
+ const w = await wakeRelay(relay)
427
+ log(w.ok ? `\n relay ${relay} is up (answered in ${(w.ms / 1000).toFixed(1)}s)` : `\n WARNING ${relay} did not answer (${w.error || 'HTTP ' + w.status}). Joining will fail until it does.`)
428
+ }
429
+
430
+ if (ready.length) {
431
+ log(`\nNEXT\n`)
432
+ ready.forEach((r, i) => log(` ${i + 1}. ${r.t.label}: ${r.t.restart}.`))
433
+ if (link) {
434
+ log(`\n Then paste this to your agent:\n`)
435
+ log(` Use your hive_join tool to join the Hivecode room ${link} as ${name}. Then follow the rules it gives you.`)
436
+ } else {
437
+ log(`\n Then paste ONE of these to your agent:\n`)
438
+ log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
439
+ log(` Use your hive_join tool to host a new Hivecode room as ${name}, then give me the join link.`)
440
+ }
441
+ log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
442
+ log(` People join from a browser, no install: ${PUBLIC_DEFAULT_RELAY.replace(/^wss:/, 'https:')}/control\n`)
443
+ }
444
+ return failed ? 1 : 0
445
+ }
package/hive-stdio.js ADDED
@@ -0,0 +1,168 @@
1
+ // hive-stdio.js -- the MCP stdio server Hivecode actually needs, without the SDK's web stack.
2
+ //
3
+ // WHY THIS EXISTS. @modelcontextprotocol/sdk is a client AND a server AND three HTTP transports,
4
+ // so installing it installs express, hono, cors, jose, zod, ajv and ~85 other packages -- 25 MB,
5
+ // most of a stranger's first `npx hivecode-mcp` download (36.7s measured cold, 25 Sep 2026). A
6
+ // load trace of a full session (join, claim, write, release, say, wait) showed the client uses
7
+ // exactly one slice of it: newline-delimited JSON-RPC over stdin/stdout, `initialize`, `ping`,
8
+ // `tools/list`, `tools/call`, and two outgoing notifications. That slice is this file.
9
+ //
10
+ // IT MUST BEHAVE EXACTLY LIKE THE SDK WHERE A CLIENT CAN TELL. The SDK is still a devDependency,
11
+ // and tests/mcp-conformance-test.mjs drives this server with the SDK's own CLIENT -- the one
12
+ // Claude Code and the Gemini CLI are built on. Specifically, as in the SDK (1.30):
13
+ // - protocol version: echo the client's if supported, else answer with the newest we know;
14
+ // - `ping` -> {}; an unknown method -> error -32601; a handler that throws -> its own numeric
15
+ // `code` if it has one, else -32603, with the error's message;
16
+ // - a request the client CANCELLED gets no response (the spec: "SHOULD NOT send a response");
17
+ // - a line that is not JSON is logged to stderr and otherwise ignored, never answered.
18
+ // And one thing the SDK does not do: when stdin closes, the server says so (onclose), so the
19
+ // process can EXIT. The spec asks exactly that of a stdio server; the SDK left it running, and a
20
+ // Hivecode server that outlives its agent app keeps a seat in the room for a member who is gone.
21
+
22
+ export const LATEST_PROTOCOL_VERSION = '2025-11-25'
23
+ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
24
+
25
+ // Same shape hive-mcp.js used to import from '@modelcontextprotocol/sdk/types.js': something
26
+ // setRequestHandler can take the method name from.
27
+ export const ListToolsRequestSchema = Object.freeze({ method: 'tools/list' })
28
+ export const CallToolRequestSchema = Object.freeze({ method: 'tools/call' })
29
+
30
+ const METHOD_NOT_FOUND = -32601
31
+ const INVALID_PARAMS = -32602
32
+ const INTERNAL_ERROR = -32603
33
+
34
+ export class Server {
35
+ constructor(serverInfo, { capabilities = {}, instructions } = {}) {
36
+ this._serverInfo = serverInfo
37
+ this._capabilities = capabilities
38
+ this._instructions = instructions
39
+ // Keyed by method, called as handler(request, extra) -- the SDK's own internal shape, which
40
+ // the test seam in hive-mcp.js reaches into on purpose.
41
+ this._requestHandlers = new Map()
42
+ this._transport = null
43
+ this._inflight = new Map() // request id -> AbortController, so a cancel can find it
44
+ this.onclose = null
45
+ this.oninitialized = null
46
+ this._requestHandlers.set('initialize', (req) => {
47
+ const requested = req.params && req.params.protocolVersion
48
+ return {
49
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION,
50
+ capabilities: this._capabilities,
51
+ serverInfo: this._serverInfo,
52
+ ...(this._instructions ? { instructions: this._instructions } : {}),
53
+ }
54
+ })
55
+ this._requestHandlers.set('ping', () => ({}))
56
+ }
57
+
58
+ setRequestHandler(schema, handler) {
59
+ const method = typeof schema === 'string' ? schema : schema && schema.method
60
+ if (!method) throw new Error('setRequestHandler needs a method')
61
+ this._requestHandlers.set(method, handler)
62
+ }
63
+
64
+ async connect(transport) {
65
+ this._transport = transport
66
+ transport.onmessage = (msg) => this._onmessage(msg)
67
+ transport.onclose = () => { this._transport = null; if (this.onclose) this.onclose() }
68
+ await transport.start()
69
+ }
70
+
71
+ /** Send a notification to the client. Rejects when there is nobody to send it to. */
72
+ notification({ method, params }) {
73
+ if (!this._transport) return Promise.reject(new Error('Not connected'))
74
+ return this._transport.send({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) })
75
+ }
76
+
77
+ _onmessage(msg) {
78
+ if (Array.isArray(msg)) {
79
+ // JSON-RPC batches: the 2025-03-26 revision allowed them. Answer each request in the batch;
80
+ // the replies go back one per line, which every client matches by id.
81
+ for (const m of msg) this._onmessage(m)
82
+ return
83
+ }
84
+ if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return
85
+ if (msg.method === undefined) return // a response to a request we never send
86
+ if (msg.id === undefined || msg.id === null) return this._onnotification(msg)
87
+ this._onrequest(msg)
88
+ }
89
+
90
+ _onnotification(msg) {
91
+ if (msg.method === 'notifications/initialized') { if (this.oninitialized) this.oninitialized(); return }
92
+ if (msg.method === 'notifications/cancelled') {
93
+ const id = msg.params && msg.params.requestId
94
+ const ac = this._inflight.get(id)
95
+ if (ac) ac.abort()
96
+ }
97
+ // every other notification is ignorable by spec
98
+ }
99
+
100
+ async _onrequest(msg) {
101
+ const { id, method } = msg
102
+ const handler = this._requestHandlers.get(method)
103
+ if (!handler) return this._reply({ jsonrpc: '2.0', id, error: { code: METHOD_NOT_FOUND, message: 'Method not found' } })
104
+ if (method === 'tools/call' && (!msg.params || typeof msg.params !== 'object' || typeof msg.params.name !== 'string')) {
105
+ return this._reply({ jsonrpc: '2.0', id, error: { code: INVALID_PARAMS, message: 'tools/call needs params.name' } })
106
+ }
107
+ const ac = new AbortController()
108
+ this._inflight.set(id, ac)
109
+ let reply
110
+ try {
111
+ const result = await handler({ method, params: msg.params }, { signal: ac.signal, requestId: id })
112
+ reply = { jsonrpc: '2.0', id, result: result === undefined ? {} : result }
113
+ } catch (e) {
114
+ const code = e && Number.isSafeInteger(e.code) ? e.code : INTERNAL_ERROR
115
+ reply = { jsonrpc: '2.0', id, error: { code, message: (e && e.message) || 'Internal error' } }
116
+ } finally {
117
+ this._inflight.delete(id)
118
+ }
119
+ if (ac.signal.aborted) return // cancelled by the client: it is no longer waiting for this
120
+ this._reply(reply)
121
+ }
122
+
123
+ _reply(message) {
124
+ if (!this._transport) return
125
+ Promise.resolve(this._transport.send(message)).catch((e) => process.stderr.write(`[hive-stdio] could not reply: ${e && e.message}\n`))
126
+ }
127
+ }
128
+
129
+ export class StdioServerTransport {
130
+ constructor(stdin = process.stdin, stdout = process.stdout) {
131
+ this._stdin = stdin
132
+ this._stdout = stdout
133
+ this._buf = ''
134
+ this._closed = false
135
+ this.onmessage = null
136
+ this.onclose = null
137
+ }
138
+
139
+ async start() {
140
+ this._stdin.setEncoding('utf8') // a multi-byte character split across two chunks stays whole
141
+ this._stdin.on('data', (chunk) => {
142
+ this._buf += chunk
143
+ let i
144
+ while ((i = this._buf.indexOf('\n')) >= 0) {
145
+ const line = this._buf.slice(0, i).replace(/\r$/, '')
146
+ this._buf = this._buf.slice(i + 1)
147
+ if (!line.trim()) continue
148
+ let msg
149
+ try { msg = JSON.parse(line) } catch {
150
+ process.stderr.write(`[hive-stdio] ignored a line that is not JSON (${line.length} chars)\n`)
151
+ continue
152
+ }
153
+ try { if (this.onmessage) this.onmessage(msg) } catch (e) { process.stderr.write(`[hive-stdio] ${e && e.stack}\n`) }
154
+ }
155
+ })
156
+ const close = () => { if (this._closed) return; this._closed = true; if (this.onclose) this.onclose() }
157
+ this._stdin.on('end', close)
158
+ this._stdin.on('close', close)
159
+ this._stdin.on('error', close)
160
+ }
161
+
162
+ send(message) {
163
+ return new Promise((resolve, reject) => {
164
+ if (this._closed && !this._stdout.writable) return reject(new Error('stdout is closed'))
165
+ this._stdout.write(JSON.stringify(message) + '\n', (err) => (err ? reject(err) : resolve()))
166
+ })
167
+ }
168
+ }