hivecode-mcp 0.12.4 → 0.13.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.
@@ -0,0 +1,580 @@
1
+ // hive-mcp-http — Hivecode as a REMOTE MCP server, so Slackbot can drive a room.
2
+ //
3
+ // node hive-mcp-http.js "<relay|room>" --port 8790 [--name Hivecode] [--auth slack|none] [--trust-proxy]
4
+ // env: SLACK_SIGNING_SECRET (required for --auth slack), HIVE_TOKEN, HIVE_TRUST_PROXY=1 (behind a proxy)
5
+ //
6
+ // WHY A SECOND MCP SERVER. hive-mcp.js speaks stdio, which is what a local agent needs and what
7
+ // Slack explicitly does not accept: the Slackbot MCP Client connects OUT over Streamable HTTP,
8
+ // and the deprecated HTTP+SSE transport and stdio are both refused. So this is not a rewrite of
9
+ // hive-mcp.js, it is the other transport, deliberately kept to a small surface.
10
+ //
11
+ // WHAT IS EXPOSED, AND WHY IT IS NOT ALL 26 TOOLS.
12
+ // Slack aborts any tool that has not returned in 60 SECONDS. hive_wait is a long poll whose whole
13
+ // purpose is to block until work arrives -- it would time out every time and surface an error to
14
+ // the user, so it is not here. Nor are the editing tools: a person typing into Slackbot is asking
15
+ // "what is happening" and "stop that", not writing code from their phone. Every tool below
16
+ // returns in milliseconds.
17
+ //
18
+ // TRUST. Slack attaches the caller's identity as `_meta.slack.user_id` on every tools/call. That
19
+ // is ordinary request JSON and anybody who can reach this endpoint can forge it, so nothing reads
20
+ // it until the request signature has been verified. In `--auth slack` mode an unsigned request is
21
+ // refused before it is parsed as a call at all.
22
+
23
+ import fs from 'fs'
24
+ import http from 'http'
25
+ import crypto from 'crypto'
26
+ import { startSync, parseLink } from './sync.js'
27
+ import { attachRateLimit } from './limits.js'
28
+ import { makeSlackActorCheck, formatSlackSender, slackPost } from './hive-slack.js'
29
+
30
+ import { VERSION } from "./hive-version.js"
31
+ export { VERSION }
32
+
33
+ const PROTOCOL = '2025-06-18'
34
+ const MAX_BODY = 1 << 20 // a control-plane request is never a megabyte
35
+ const SKEW_MS = 5 * 60 * 1000 // Slack's own replay window
36
+ const MAX_SESSIONS = 1000 // LRU bound on tracked MCP session IDs
37
+
38
+ /** Slack's v0 request signature.
39
+ *
40
+ * Two things here are load-bearing and easy to get wrong: the comparison must be timing-safe,
41
+ * and the timestamp window must be checked SEPARATELY -- a valid signature on an hour-old body
42
+ * is a replay, and it verifies perfectly. */
43
+ export function verifySlack({ raw, timestamp, signature, secret, now = Date.now() }) {
44
+ if (!secret) return { ok: false, why: 'Q510 no signing secret configured' }
45
+ if (!timestamp || !signature) return { ok: false, why: 'Q511 request is not signed' }
46
+ const ts = Number(timestamp)
47
+ if (!Number.isFinite(ts)) return { ok: false, why: 'Q511 request is not signed' }
48
+ if (Math.abs(now - ts * 1000) > SKEW_MS) return { ok: false, why: 'Q512 signature is outside the replay window' }
49
+ const mine = 'v0=' + crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${raw}`).digest('hex')
50
+ const a = Buffer.from(mine), b = Buffer.from(String(signature))
51
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, why: 'Q513 signature does not match' }
52
+ return { ok: true }
53
+ }
54
+
55
+ const text = (s) => ({ content: [{ type: 'text', text: s }] })
56
+
57
+ /** Resolve a typed name to the room's real spelling. An unmatched name must REFUSE rather than
58
+ * write a control entry nothing reads -- see the same bug and comment in hive-agent.js. */
59
+ export function resolveName(hive, who) {
60
+ const want = String(who || '').toLowerCase()
61
+ for (const p of (hive.members ? hive.members() : [])) if (String(p.name).toLowerCase() === want) return p.name
62
+ return null
63
+ }
64
+
65
+ const pausedOf = (hive, who) => { const c = hive.doc.getMap('controls').get(who); return !!(c && c.state === 'paused') }
66
+
67
+ // `room` is passed in: startSync returns no room field, so `hive.room` is undefined and the
68
+ // status tool reported "room: undefined" to whoever asked Slackbot what was going on.
69
+ export function makeTools(hive, room = '', { actorCheck = null, allow = [] } = {}) {
70
+ const ai = () => (hive.members ? hive.members() : []).filter((p) => p.kind === 'ai')
71
+ const userIdOf = (actor) => {
72
+ const s = String(actor || '')
73
+ return s.startsWith('slack:') ? s.slice(6) : ''
74
+ }
75
+ return [
76
+ {
77
+ name: 'hive_status',
78
+ description: 'What is happening in the Hivecode room right now: how many agents are running or paused, how many tasks are open, and what is awaiting a human. Ask this first.',
79
+ inputSchema: { type: 'object', properties: {} },
80
+ run: () => {
81
+ const people = hive.members ? hive.members() : []
82
+ const tasks = [...hive.doc.getMap('tasks').values()]
83
+ const paused = people.filter((p) => pausedOf(hive, p.name))
84
+ return text([
85
+ `room: ${room || '(unnamed)'}`,
86
+ `${people.length} in the room (${people.filter((p) => p.kind === 'ai').length} agents, ${paused.length} paused)`,
87
+ `${tasks.filter((t) => t.status === 'accepted').length} in progress, ${tasks.filter((t) => t.status === 'pending').length} awaiting approval, ${tasks.filter((t) => t.status === 'done').length} done`,
88
+ paused.length ? `paused: ${paused.map((p) => p.name).join(', ')}` : 'nothing is paused',
89
+ ].join('\n'))
90
+ },
91
+ },
92
+ {
93
+ name: 'hive_members',
94
+ description: 'Who is in the room, whether each one is a human or an AI agent, and whether it is running or paused.',
95
+ inputSchema: { type: 'object', properties: {} },
96
+ run: () => {
97
+ const people = hive.members ? hive.members() : []
98
+ if (!people.length) return text('nobody is in the room')
99
+ return text(people.map((p) => `${p.name} (${p.kind})${pausedOf(hive, p.name) ? ' — PAUSED' : ''}`).join('\n'))
100
+ },
101
+ },
102
+ {
103
+ name: 'hive_tasks',
104
+ description: 'The task board: who was asked to do what, and where each task stands (pending approval, accepted, done, denied).',
105
+ inputSchema: { type: 'object', properties: {} },
106
+ run: () => {
107
+ const all = [...hive.doc.getMap('tasks').values()]
108
+ if (!all.length) return text('no tasks')
109
+ return text(all.map((t) => `[${t.status}] ${t.by} -> ${t.to}: ${t.text}`).join('\n'))
110
+ },
111
+ },
112
+ {
113
+ name: 'hive_claims',
114
+ description: 'Which files each agent is holding right now. This is what to read before asking whether it is safe to stop someone.',
115
+ inputSchema: { type: 'object', properties: {} },
116
+ run: () => {
117
+ const c = hive.doc.getMap('claims')
118
+ const rows = [...c.entries()].map(([region, v]) => `${region} — ${(v && v.by) || 'unknown'}`)
119
+ return text(rows.length ? rows.join('\n') : 'nothing is claimed')
120
+ },
121
+ },
122
+ {
123
+ name: 'hive_recent',
124
+ description: 'The last things said in the room, newest last. Use this to answer "what happened overnight".',
125
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many messages, default 20' } } },
126
+ run: ({ limit }) => {
127
+ const n = Math.max(1, Math.min(100, Number(limit) || 20))
128
+ const all = hive.doc.getArray('chat').toArray().slice(-n)
129
+ return text(all.length ? all.map((m) => `${m.by}: ${m.text}`).join('\n') : 'the room has said nothing')
130
+ },
131
+ },
132
+ {
133
+ name: 'hive_pause',
134
+ description: 'STOP an agent: it finishes its current step and is then refused any new work until resumed. Pass who="all" to stop every agent in the room. This is the control to reach for when something is going wrong and nobody is at a desk.',
135
+ inputSchema: { type: 'object', properties: { who: { type: 'string', description: 'an agent name, or "all"' } }, required: ['who'] },
136
+ run: async ({ who }, actor) => {
137
+ let by = actor
138
+ let unverifiedNote = ''
139
+ const uid = userIdOf(actor)
140
+ if (actorCheck && uid) {
141
+ const resolved = await actorCheck.resolve(uid)
142
+ const check = actorCheck.mayAct(resolved, 'stop', allow)
143
+ if (!check.ok) return text(check.reply || 'Q521 refused by Slack actor check')
144
+ by = (resolved.ok && resolved.handle && resolved.handle !== uid) ? check.by : (actor || check.by)
145
+ if (check.unverified && check.reply) unverifiedNote = ` (${check.reply})`
146
+ }
147
+ if (String(who).toLowerCase() === 'all') {
148
+ const names = ai().map((p) => p.name)
149
+ for (const n of names) hive.control(n, 'paused', by)
150
+ return text(names.length ? `paused ${names.join(', ')} — nothing new will be picked up${unverifiedNote}` : 'there are no agents to pause')
151
+ }
152
+ const real = resolveName(hive, who)
153
+ if (!real) return text(`Q514 there is nobody called "${who}" in this room. Here now: ${(hive.members ? hive.members() : []).map((p) => p.name).join(', ') || 'nobody'}`)
154
+ hive.control(real, 'paused', by)
155
+ return text(`paused ${real} — it will finish its current step and then stop picking up work${unverifiedNote}`)
156
+ },
157
+ },
158
+ {
159
+ name: 'hive_resume',
160
+ description: 'Let a paused agent pick up work again. Pass who="all" to resume every agent.',
161
+ inputSchema: { type: 'object', properties: { who: { type: 'string', description: 'an agent name, or "all"' } }, required: ['who'] },
162
+ run: async ({ who }, actor) => {
163
+ let by = actor
164
+ if (actorCheck) {
165
+ const uid = userIdOf(actor)
166
+ const resolved = await actorCheck.resolve(uid)
167
+ const check = actorCheck.mayAct(resolved, 'resume', allow)
168
+ if (!check.ok) return text(check.reply || 'Q521 refused: not in --allow')
169
+ by = check.by || actor
170
+ }
171
+ if (String(who).toLowerCase() === 'all') {
172
+ const names = ai().map((p) => p.name)
173
+ for (const n of names) hive.control(n, 'running', by)
174
+ return text(names.length ? `resumed ${names.join(', ')}` : 'there are no agents to resume')
175
+ }
176
+ const real = resolveName(hive, who)
177
+ if (!real) return text(`Q514 there is nobody called "${who}" in this room`)
178
+ hive.control(real, 'running', by)
179
+ return text(`resumed ${real}`)
180
+ },
181
+ },
182
+ {
183
+ name: 'hive_say',
184
+ description: 'Send a message into the room. Address an agent with @Name so it lands as mail rather than ambient chatter — an agent only acts on what names it.',
185
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
186
+ run: async ({ text: t }, actor) => {
187
+ let by = formatSlackSender(userIdOf(actor) || actor || 'user')
188
+ if (actorCheck) {
189
+ const uid = userIdOf(actor)
190
+ const resolved = await actorCheck.resolve(uid)
191
+ const check = actorCheck.mayAct(resolved, 'reply', allow)
192
+ if (!check.ok) return text(check.reply || 'Q521 refused: not in --allow')
193
+ by = check.by || by
194
+ }
195
+ if (hive && hive.doc && typeof hive.doc.getArray === 'function') {
196
+ hive.doc.getArray('chat').push([{
197
+ by,
198
+ kind: 'say',
199
+ at: Date.now() * 1000,
200
+ text: String(t || '').trim(),
201
+ viaSlack: true,
202
+ }])
203
+ } else if (hive && typeof hive.say === 'function') {
204
+ hive.say(`${t}`, { by })
205
+ }
206
+ return text(`sent to the room as ${by}`)
207
+ },
208
+ },
209
+ ]
210
+ }
211
+
212
+ /** JSON-RPC dispatch. Returns the response object, or null for a notification (which takes 202). */
213
+ export async function dispatch({ msg, tools, actor }) {
214
+ const reply = (result) => ({ jsonrpc: '2.0', id: msg.id, result })
215
+ const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id, error: { code, message } })
216
+
217
+ if (msg.method === 'initialize') {
218
+ return reply({
219
+ protocolVersion: PROTOCOL,
220
+ capabilities: { tools: { listChanged: false } },
221
+ serverInfo: { name: 'hivecode', version: VERSION },
222
+ })
223
+ }
224
+ // Notifications carry no id and MUST NOT be answered with a result.
225
+ if (typeof msg.id === 'undefined') return null
226
+ if (msg.method === 'tools/list') {
227
+ return reply({ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) })
228
+ }
229
+ if (msg.method === 'tools/call') {
230
+ const t = tools.find((x) => x.name === (msg.params || {}).name)
231
+ if (!t) return fail(-32602, `Q515 unknown tool: ${(msg.params || {}).name}`)
232
+ try {
233
+ return reply(await t.run((msg.params || {}).arguments || {}, actor))
234
+ } catch (e) {
235
+ // A thrown tool is a tool ERROR, not a protocol error: the model should see the text and
236
+ // be able to try something else, rather than the whole session failing.
237
+ return reply({ ...text(`Q516 ${t.name} failed: ${e.message}`), isError: true })
238
+ }
239
+ }
240
+ return fail(-32601, `Q517 method not supported: ${msg.method}`)
241
+ }
242
+
243
+ /** Who is making this call. Slack attaches it under params._meta.slack, and it is only read
244
+ * after the signature has been verified -- the field is forgeable by anyone who can reach us. */
245
+ export function actorOf(msg, verified) {
246
+ const s = ((msg.params || {})._meta || {}).slack || {}
247
+ if (verified && s.user_id) return `slack:${s.user_id}`
248
+ return 'slack'
249
+ }
250
+
251
+ // WHOSE ADDRESS IS IT (27 Sep 2026). The per-IP limit keyed on the FIRST X-Forwarded-For entry,
252
+ // which is whatever the caller wrote: a flood that sent a different made-up address on every
253
+ // request got a fresh budget every time, and each made-up address was a new entry in a map that
254
+ // never shrank. X-Forwarded-For is now read only when this sits behind a proxy the operator
255
+ // names (HIVE_TRUST_PROXY=1 or --trust-proxy), and then its LAST entry -- the hop that proxy
256
+ // appended, not one the client typed. Otherwise the socket's own address.
257
+ const TRUST_PROXY = /^(1|true|yes)$/i.test(process.env.HIVE_TRUST_PROXY || '')
258
+ const MAX_LIMITERS = 10000
259
+ export function clientAddress(req, trustProxy = TRUST_PROXY) {
260
+ const sock = String((req.socket && req.socket.remoteAddress) || '127.0.0.1')
261
+ if (!trustProxy) return sock
262
+ const hops = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean)
263
+ return hops.length ? hops[hops.length - 1] : sock
264
+ }
265
+
266
+ export function makeHandler({
267
+ hive,
268
+ room = '',
269
+ auth = 'slack',
270
+ signingSecret = '',
271
+ now = Date.now,
272
+ tier = null,
273
+ trustProxy = TRUST_PROXY,
274
+ allow = [],
275
+ token = '',
276
+ teamId = '',
277
+ lookupUser = null,
278
+ api = undefined,
279
+ fetchImpl = undefined,
280
+ actorCheck = null,
281
+ }) {
282
+ const effectiveActorCheck = actorCheck || (auth === 'slack'
283
+ ? makeSlackActorCheck({ token, teamId, api, fetchImpl, lookupUser, now })
284
+ : null)
285
+ const tools = makeTools(hive, room, { actorCheck: effectiveActorCheck, allow })
286
+ const sessions = new Set() // insertion order = LRU order (oldest first, most-recently-used last)
287
+ const limiters = new Map() // insertion order = age, so the oldest address is the first key
288
+
289
+ function touchSession(sid) {
290
+ if (!sid) return
291
+ if (sessions.has(sid)) sessions.delete(sid)
292
+ sessions.add(sid)
293
+ if (sessions.size > MAX_SESSIONS) {
294
+ sessions.delete(sessions.values().next().value)
295
+ }
296
+ }
297
+
298
+ function limiterFor(ip) {
299
+ let l = limiters.get(ip)
300
+ if (!l) {
301
+ const conn = {}
302
+ l = attachRateLimit(conn, tier)
303
+ limiters.set(ip, l)
304
+ if (limiters.size > MAX_LIMITERS) limiters.delete(limiters.keys().next().value)
305
+ }
306
+ return l
307
+ }
308
+
309
+ handle.limiterCount = () => limiters.size
310
+ handle.sessionCount = () => sessions.size
311
+ handle.hasSession = (sid) => sessions.has(sid)
312
+ return handle
313
+ async function handle(req, res) {
314
+ const send = (code, obj, headers = {}) => {
315
+ const body = obj === null ? '' : JSON.stringify(obj)
316
+ res.writeHead(code, { 'Content-Type': 'application/json', ...headers })
317
+ res.end(body)
318
+ }
319
+ if (req.method === 'GET') return send(405, { error: 'Q518 this endpoint takes POST' })
320
+ if (req.method !== 'POST') return send(405, { error: 'Q518 this endpoint takes POST' })
321
+
322
+ // Rate limiting (HIV-15): per-IP rate limit applied BEFORE signature verification
323
+ const ip = clientAddress(req, trustProxy)
324
+ const limiter = limiterFor(ip)
325
+ const rateRefusal = limiter.check(1)
326
+ if (rateRefusal) {
327
+ return send(429, { error: rateRefusal.reason })
328
+ }
329
+
330
+ let raw = ''
331
+ let tooBig = false
332
+ for await (const chunk of req) {
333
+ raw += chunk
334
+ if (raw.length > MAX_BODY) { tooBig = true; break }
335
+ }
336
+ if (tooBig) return send(413, { error: 'Q519 body too large' })
337
+
338
+ let verified = auth !== 'slack'
339
+ if (auth === 'slack') {
340
+ const v = verifySlack({
341
+ raw,
342
+ timestamp: req.headers['x-slack-request-timestamp'],
343
+ signature: req.headers['x-slack-signature'],
344
+ secret: signingSecret,
345
+ now: now(),
346
+ })
347
+ // Refuse BEFORE parsing: an unsigned body should never reach the dispatcher at all.
348
+ if (!v.ok) return send(401, { jsonrpc: '2.0', id: null, error: { code: -32001, message: v.why } })
349
+ verified = true
350
+ }
351
+
352
+ let msg
353
+ try { msg = JSON.parse(raw || '{}') } catch { return send(400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Q520 body is not JSON' } }) }
354
+
355
+ const batch = Array.isArray(msg) ? msg : [msg]
356
+ const out = []
357
+ for (const m of batch) {
358
+ const r = await dispatch({ msg: m, tools, actor: actorOf(m, verified) })
359
+ if (r) out.push(r)
360
+ }
361
+ if (!out.length) return send(202, null) // notifications only
362
+
363
+ const payload = Array.isArray(msg) ? out : out[0]
364
+ const sid = req.headers['mcp-session-id'] || crypto.randomUUID()
365
+ touchSession(sid)
366
+
367
+ // Streamable HTTP: the client says which framing it can read. Slack accepts either, but a
368
+ // client that asked ONLY for an event stream will not parse a plain JSON body.
369
+ const accept = String(req.headers.accept || '')
370
+ if (/text\/event-stream/.test(accept) && !/application\/json/.test(accept)) {
371
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Mcp-Session-Id': sid })
372
+ res.end(`event: message\ndata: ${JSON.stringify(payload)}\n\n`)
373
+ return
374
+ }
375
+ return send(200, payload, { 'Mcp-Session-Id': sid })
376
+ }
377
+ }
378
+
379
+ /** Verify SLACK_BOT_TOKEN and resolve workspace team_id via auth.test when --team is not given (Q630, Q634). */
380
+ export async function preflightMcpSlack({ token = '', teamId = '', api = undefined, fetchImpl = undefined } = {}) {
381
+ if (!token || !String(token).trim()) {
382
+ return {
383
+ ok: false,
384
+ code: 'Q630',
385
+ error: 'Q630 --auth slack needs SLACK_BOT_TOKEN. Set SLACK_BOT_TOKEN=xoxb-… in your environment (docs/SLACK_SETUP.md).',
386
+ }
387
+ }
388
+ if (teamId && String(teamId).trim()) {
389
+ return { ok: true, teamId: String(teamId).trim() }
390
+ }
391
+ let auth
392
+ try {
393
+ auth = await slackPost('auth.test', {}, token, fetchImpl, api)
394
+ } catch (e) {
395
+ if (!e || !e.slackError) {
396
+ const why = (e && e.message) || 'network error'
397
+ return { ok: false, transient: true, code: 'Q636', error: `Q636 could not reach Slack (${why}). The token was not checked; check this machine's network and try again.` }
398
+ }
399
+ const why = (e && (e.slackError || e.code || e.message)) || 'auth_failed'
400
+ return {
401
+ ok: false,
402
+ code: 'Q630',
403
+ error: `Q630 SLACK_BOT_TOKEN was rejected by Slack (${why}). Check SLACK_BOT_TOKEN or reinstall the Slack app (docs/SLACK_SETUP.md).`,
404
+ }
405
+ }
406
+ if (!auth || !auth.team_id) {
407
+ return {
408
+ ok: false,
409
+ code: 'Q634',
410
+ error: 'Q634 Slack did not say which workspace the bot is in (auth.test returned no team_id), so external workspaces could not be refused. Pass --team <T…> (or SLACK_TEAM_ID) or reinstall the Slack app.',
411
+ }
412
+ }
413
+ const scopes = Array.isArray(auth._scopes) ? auth._scopes : null
414
+ if (Array.isArray(scopes) && !scopes.includes('users:read')) {
415
+ return {
416
+ ok: false,
417
+ code: 'Q634',
418
+ error: 'Q634 SLACK_BOT_TOKEN is missing the users:read scope required to verify Slack callers. Reinstall your Slack app with slack-app-manifest-inbound.json (docs/SLACK_SETUP.md Section 5).',
419
+ }
420
+ }
421
+ return {
422
+ ok: true,
423
+ teamId: String(auth.team_id),
424
+ scopes: scopes || [],
425
+ botUser: auth.user || 'Hivecode',
426
+ botUserId: auth.user_id || '',
427
+ }
428
+ }
429
+
430
+ export async function serve({
431
+ hive,
432
+ room = '',
433
+ port,
434
+ auth = 'slack',
435
+ signingSecret,
436
+ now,
437
+ tier = null,
438
+ trustProxy = TRUST_PROXY,
439
+ allow = [],
440
+ token = '',
441
+ teamId = '',
442
+ lookupUser = null,
443
+ api = undefined,
444
+ fetchImpl = undefined,
445
+ actorCheck = null,
446
+ }) {
447
+ let resolvedTeamId = teamId
448
+ if (auth === 'slack' && !actorCheck) {
449
+ const pf = await preflightMcpSlack({ token, teamId, api, fetchImpl })
450
+ if (!pf.ok) throw new Error(pf.error)
451
+ resolvedTeamId = pf.teamId
452
+ }
453
+ const handle = makeHandler({
454
+ hive,
455
+ room,
456
+ auth,
457
+ signingSecret,
458
+ now,
459
+ tier,
460
+ trustProxy,
461
+ allow,
462
+ token,
463
+ teamId: resolvedTeamId,
464
+ lookupUser,
465
+ api,
466
+ fetchImpl,
467
+ actorCheck,
468
+ })
469
+ const server = http.createServer((req, res) => {
470
+ handle(req, res).catch(() => { try { res.writeHead(500); res.end('{}') } catch { /* already sent */ } })
471
+ })
472
+ return new Promise((resolve) => server.listen(port, () => resolve(server)))
473
+ }
474
+
475
+ export function resolveConfig(argv = process.argv.slice(2), env = process.env) {
476
+ const flag = (k, d = '') => { const i = argv.indexOf('--' + k); return i === -1 ? d : (argv[i + 1] || '') }
477
+ const link = argv.find((a) => !a.startsWith('--')) || ''
478
+ const port = Number(flag('port', '') || env.PORT || '8790')
479
+ const auth = flag('auth', 'slack')
480
+ const secret = env.SLACK_SIGNING_SECRET || ''
481
+ const allow = flag('allow', env.SLACK_ALLOW || '')
482
+ const token = env.SLACK_BOT_TOKEN || ''
483
+ const teamId = flag('team', env.SLACK_TEAM_ID || '')
484
+ const name = flag('name', 'Hivecode')
485
+ const trustProxy = /^(1|true|yes)$/i.test(env.HIVE_TRUST_PROXY || '') || argv.includes('--trust-proxy')
486
+ const parsed = link ? parseLink(link) : { relay: '', room: '', token: null }
487
+ const hiveToken = parsed.token || env.HIVE_TOKEN || ''
488
+ return { link, relay: parsed.relay, room: parsed.room, port, auth, secret, allow, token, teamId, name, trustProxy, hiveToken }
489
+ }
490
+
491
+ // ---- main -------------------------------------------------------------------------------
492
+ export const USAGE = `usage:
493
+ SLACK_SIGNING_SECRET=… SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack mcp-server "<relay|room>" [--port 8790] [--auth slack|none] [--allow <U123,U456>] [--trust-proxy]`
494
+
495
+ export async function main(argv = process.argv.slice(2), {
496
+ log = console.log,
497
+ logErr = console.error,
498
+ env = process.env,
499
+ fetchImpl = fetch,
500
+ } = {}) {
501
+ if (argv.includes('--help') || argv.includes('-h') || argv.includes('help')) {
502
+ log(USAGE)
503
+ return 0
504
+ }
505
+ const cfg = resolveConfig(argv, env)
506
+ if (!cfg.link) {
507
+ logErr(USAGE)
508
+ return 2
509
+ }
510
+ // Fail closed. Starting in `slack` mode without a secret would silently serve an OPEN control
511
+ // plane for a room -- anybody who found the URL could stop every agent in it.
512
+ if (cfg.auth === 'slack' && !cfg.secret) {
513
+ logErr('Q510 --auth slack needs SLACK_SIGNING_SECRET. Use --auth none only on localhost.')
514
+ return 2
515
+ }
516
+ if (cfg.auth === 'slack' && !cfg.token) {
517
+ logErr('Q630 --auth slack needs SLACK_BOT_TOKEN. Set SLACK_BOT_TOKEN=xoxb-… in your environment (docs/SLACK_SETUP.md).')
518
+ return 2
519
+ }
520
+ let resolvedTeamId = cfg.teamId
521
+ if (cfg.auth === 'slack') {
522
+ const pf = await preflightMcpSlack({ token: cfg.token, teamId: cfg.teamId, fetchImpl })
523
+ if (!pf.ok) {
524
+ logErr(pf.error)
525
+ return 1
526
+ }
527
+ resolvedTeamId = pf.teamId
528
+ }
529
+ const hive = startSync({
530
+ relay: cfg.relay,
531
+ room: cfg.room,
532
+ dir: (env.TEMP || '.') + '/hive-mcp-http-' + cfg.room,
533
+ name: cfg.name,
534
+ kind: 'human',
535
+ token: cfg.hiveToken,
536
+ syncFiles: false,
537
+ log: () => {},
538
+ })
539
+ let server
540
+ try {
541
+ server = await serve({
542
+ hive,
543
+ room: cfg.room,
544
+ port: cfg.port,
545
+ auth: cfg.auth,
546
+ signingSecret: cfg.secret,
547
+ allow: cfg.allow,
548
+ token: cfg.token,
549
+ teamId: resolvedTeamId,
550
+ trustProxy: cfg.trustProxy,
551
+ })
552
+ log(`[hive-mcp-http] room ${cfg.room} on :${cfg.port}/ auth=${cfg.auth}`)
553
+ if (cfg.auth === 'none') log(' auth=none: do NOT expose this beyond localhost.')
554
+ } catch (e) {
555
+ logErr((e && e.message) || String(e))
556
+ try { hive.stop() } catch {}
557
+ return 1
558
+ }
559
+
560
+ return new Promise((resolve) => {
561
+ let done = false
562
+ const finish = (code = 0) => {
563
+ if (done) return
564
+ done = true
565
+ process.removeListener('SIGINT', onSignal)
566
+ process.removeListener('SIGTERM', onSignal)
567
+ try { server.close() } catch {}
568
+ try { hive.stop() } catch {}
569
+ resolve(code)
570
+ }
571
+ const onSignal = () => finish(0)
572
+ process.once('SIGINT', onSignal)
573
+ process.once('SIGTERM', onSignal)
574
+ server.on('close', () => finish(0))
575
+ })
576
+ }
577
+
578
+ if (process.argv[1] && import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) {
579
+ main().then((code) => { if (code !== 0) process.exit(code) })
580
+ }