hivecode-mcp 0.12.2 → 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.
- package/HIVE_RULES.md +11 -40
- package/LICENSE +21 -21
- package/README.md +421 -167
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-activity.js +336 -310
- package/hive-await.js +207 -0
- package/hive-branch.js +686 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +1904 -0
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +520 -386
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +579 -137
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-notify.js +233 -0
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +350 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +230 -225
- package/hive-room-key.js +209 -0
- package/hive-secrets.js +89 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +558 -445
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +293 -241
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -0
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/hive-webhook.js +54 -0
- package/icr-merge.js +58 -58
- package/icr.js +851 -745
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +189 -164
- package/reconcile.cjs +614 -477
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/substrate.js +252 -252
- package/sync.js +1956 -177
- package/grm-link.cjs +0 -141
package/hive-mcp-http.js
ADDED
|
@@ -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
|
+
}
|