@balpal4495/quorum 3.3.3 → 3.4.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/README.md +73 -0
- package/bin/__tests__/ingest.test.js +220 -0
- package/bin/__tests__/mcp-server.test.js +739 -0
- package/bin/__tests__/mcp-tools.test.js +525 -0
- package/bin/commands/bootstrap.js +65 -0
- package/bin/commands/ingest-git.js +192 -0
- package/bin/commands/ingest-url.js +224 -0
- package/bin/commands/ingest.js +212 -0
- package/bin/commands/serve.js +52 -0
- package/bin/mcp/server.js +301 -0
- package/bin/mcp/tools.js +454 -0
- package/bin/quorum.js +51 -0
- package/bin/shared/chronicle.js +40 -0
- package/bin/ui/app.html +676 -0
- package/package.json +1 -1
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import os from "os"
|
|
5
|
+
import http from "http"
|
|
6
|
+
import { randomUUID } from "crypto"
|
|
7
|
+
|
|
8
|
+
import { createServer } from "../mcp/server.js"
|
|
9
|
+
|
|
10
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
async function makeTmp() {
|
|
13
|
+
return fs.mkdtemp(path.join(os.tmpdir(), "quorum-server-"))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function rmrf(dir) {
|
|
17
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function makeChronicle(root, { entries = [], proposals = [] } = {}) {
|
|
21
|
+
const dir = path.join(root, ".chronicle")
|
|
22
|
+
await fs.mkdir(path.join(dir, "committed"), { recursive: true })
|
|
23
|
+
await fs.mkdir(path.join(dir, "proposals"), { recursive: true })
|
|
24
|
+
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const id = entry.id ?? randomUUID()
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
path.join(dir, "committed", `${id}.json`),
|
|
29
|
+
JSON.stringify({ schema_version: 2, id, timestamp: new Date().toISOString(), ...entry }),
|
|
30
|
+
"utf8"
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const proposalIds = []
|
|
35
|
+
for (const proposal of proposals) {
|
|
36
|
+
const id = proposal.proposalId ?? randomUUID()
|
|
37
|
+
proposalIds.push(id)
|
|
38
|
+
await fs.writeFile(
|
|
39
|
+
path.join(dir, "proposals", `${id}.json`),
|
|
40
|
+
JSON.stringify({ schema_version: 2, ...proposal }),
|
|
41
|
+
"utf8"
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { dir, proposalIds }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Start a server on a random port and return { server, baseUrl, port, close }. */
|
|
49
|
+
async function startServer(projectRoot, chronicleDir) {
|
|
50
|
+
const server = await createServer({ projectRoot, chronicleDir })
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
server.listen(0, "127.0.0.1", () => resolve())
|
|
53
|
+
server.once("error", reject)
|
|
54
|
+
})
|
|
55
|
+
const { port } = server.address()
|
|
56
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
57
|
+
return {
|
|
58
|
+
server,
|
|
59
|
+
baseUrl,
|
|
60
|
+
port,
|
|
61
|
+
close: () => new Promise(resolve => server.close(resolve)),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Thin fetch wrapper that handles response JSON + status. */
|
|
66
|
+
async function req(method, url, body) {
|
|
67
|
+
const opts = {
|
|
68
|
+
method,
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
}
|
|
71
|
+
if (body !== undefined) opts.body = JSON.stringify(body)
|
|
72
|
+
const res = await fetch(url, opts)
|
|
73
|
+
const text = await res.text()
|
|
74
|
+
let json
|
|
75
|
+
try { json = JSON.parse(text) } catch { json = text }
|
|
76
|
+
return { status: res.status, headers: res.headers, body: json }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── test fixtures ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
let tmpDir
|
|
82
|
+
let srv // { server, baseUrl, close }
|
|
83
|
+
|
|
84
|
+
const ENTRY = { topic: "caching layer", decision: "use Redis", key_insight: "Redis chosen", status: "validated", confidence: 0.9, affected_areas: ["src/cache.ts"] }
|
|
85
|
+
const PROPOSAL = { topic: "new idea", decision: "try it", status: "open", confidence: 0.6, affected_areas: [] }
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
tmpDir = await makeTmp()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
if (srv) { await srv.close(); srv = null }
|
|
93
|
+
await rmrf(tmpDir)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ── CORS and preflight ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("CORS", () => {
|
|
99
|
+
it("sets CORS headers on every response", async () => {
|
|
100
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
101
|
+
srv = await startServer(tmpDir, dir)
|
|
102
|
+
|
|
103
|
+
const { headers } = await req("GET", `${srv.baseUrl}/api/proposals`)
|
|
104
|
+
expect(headers.get("access-control-allow-origin")).toBe("*")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("handles OPTIONS preflight with 204", async () => {
|
|
108
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
109
|
+
srv = await startServer(tmpDir, dir)
|
|
110
|
+
|
|
111
|
+
const { status } = await req("OPTIONS", `${srv.baseUrl}/mcp`)
|
|
112
|
+
expect(status).toBe(204)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ── Web UI ────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("GET /", () => {
|
|
119
|
+
it("returns HTML", async () => {
|
|
120
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
121
|
+
srv = await startServer(tmpDir, dir)
|
|
122
|
+
|
|
123
|
+
const { status, headers } = await req("GET", `${srv.baseUrl}/`)
|
|
124
|
+
expect(status).toBe(200)
|
|
125
|
+
expect(headers.get("content-type")).toMatch(/text\/html/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("returns the same HTML for /index.html", async () => {
|
|
129
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
130
|
+
srv = await startServer(tmpDir, dir)
|
|
131
|
+
|
|
132
|
+
const [a, b] = await Promise.all([
|
|
133
|
+
req("GET", `${srv.baseUrl}/`),
|
|
134
|
+
req("GET", `${srv.baseUrl}/index.html`),
|
|
135
|
+
])
|
|
136
|
+
expect(a.body).toBe(b.body)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ── REST: entries ─────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("GET /api/entries", () => {
|
|
143
|
+
it("returns all committed entries", async () => {
|
|
144
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY, { topic: "auth", decision: "JWT", key_insight: "JWT" }] })
|
|
145
|
+
srv = await startServer(tmpDir, dir)
|
|
146
|
+
|
|
147
|
+
const { status, body } = await req("GET", `${srv.baseUrl}/api/entries`)
|
|
148
|
+
expect(status).toBe(200)
|
|
149
|
+
expect(Array.isArray(body)).toBe(true)
|
|
150
|
+
expect(body).toHaveLength(2)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("filters entries with ?q= search", async () => {
|
|
154
|
+
const { dir } = await makeChronicle(tmpDir, {
|
|
155
|
+
entries: [
|
|
156
|
+
ENTRY,
|
|
157
|
+
{ topic: "auth", decision: "JWT tokens with RS256", key_insight: "JWT RS256" },
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
srv = await startServer(tmpDir, dir)
|
|
161
|
+
|
|
162
|
+
const { status, body } = await req("GET", `${srv.baseUrl}/api/entries?q=Redis+caching`)
|
|
163
|
+
expect(status).toBe(200)
|
|
164
|
+
expect(body.some(e => e.topic === "caching layer")).toBe(true)
|
|
165
|
+
expect(body.every(e => e.topic !== "auth")).toBe(true) // JWT entry should not match
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("returns empty array when chronicle has no entries", async () => {
|
|
169
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
170
|
+
srv = await startServer(tmpDir, dir)
|
|
171
|
+
|
|
172
|
+
const { body } = await req("GET", `${srv.baseUrl}/api/entries`)
|
|
173
|
+
expect(body).toEqual([])
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ── REST: proposals ───────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("GET /api/proposals", () => {
|
|
180
|
+
it("returns pending proposals", async () => {
|
|
181
|
+
const { dir } = await makeChronicle(tmpDir, {
|
|
182
|
+
proposals: [PROPOSAL, { topic: "another", decision: "do it" }],
|
|
183
|
+
})
|
|
184
|
+
srv = await startServer(tmpDir, dir)
|
|
185
|
+
|
|
186
|
+
const { status, body } = await req("GET", `${srv.baseUrl}/api/proposals`)
|
|
187
|
+
expect(status).toBe(200)
|
|
188
|
+
expect(body).toHaveLength(2)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("returns empty array when no proposals exist", async () => {
|
|
192
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
193
|
+
srv = await startServer(tmpDir, dir)
|
|
194
|
+
|
|
195
|
+
const { body } = await req("GET", `${srv.baseUrl}/api/proposals`)
|
|
196
|
+
expect(body).toEqual([])
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ── REST: coverage ────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe("GET /api/coverage", () => {
|
|
203
|
+
it("returns coverage summary with expected shape", async () => {
|
|
204
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
205
|
+
srv = await startServer(tmpDir, dir)
|
|
206
|
+
|
|
207
|
+
const { status, body } = await req("GET", `${srv.baseUrl}/api/coverage`)
|
|
208
|
+
expect(status).toBe(200)
|
|
209
|
+
expect(body).toHaveProperty("percentage")
|
|
210
|
+
expect(body).toHaveProperty("totalFiles")
|
|
211
|
+
expect(body).toHaveProperty("coverageByFile")
|
|
212
|
+
expect(Array.isArray(body.coverageByFile)).toBe(true)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// ── REST: growth ──────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
describe("GET /api/growth", () => {
|
|
219
|
+
it("returns memory health with expected shape", async () => {
|
|
220
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
221
|
+
srv = await startServer(tmpDir, dir)
|
|
222
|
+
|
|
223
|
+
const { status, body } = await req("GET", `${srv.baseUrl}/api/growth`)
|
|
224
|
+
expect(status).toBe(200)
|
|
225
|
+
expect(body).toHaveProperty("health")
|
|
226
|
+
expect(body).toHaveProperty("entries")
|
|
227
|
+
expect(body).toHaveProperty("hint")
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ── REST: commit proposal ─────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe("POST /api/proposals/:id/commit", () => {
|
|
234
|
+
it("commits a proposal and returns id + topic", async () => {
|
|
235
|
+
const { dir, proposalIds } = await makeChronicle(tmpDir, {
|
|
236
|
+
proposals: [PROPOSAL],
|
|
237
|
+
})
|
|
238
|
+
srv = await startServer(tmpDir, dir)
|
|
239
|
+
|
|
240
|
+
const { status, body } = await req("POST", `${srv.baseUrl}/api/proposals/${proposalIds[0]}/commit`)
|
|
241
|
+
expect(status).toBe(200)
|
|
242
|
+
expect(body).toHaveProperty("id")
|
|
243
|
+
expect(body).toHaveProperty("topic", "new idea")
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("returns 404 for unknown proposal id", async () => {
|
|
247
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
248
|
+
srv = await startServer(tmpDir, dir)
|
|
249
|
+
|
|
250
|
+
const { status, body } = await req("POST", `${srv.baseUrl}/api/proposals/does-not-exist/commit`)
|
|
251
|
+
expect(status).toBe(404)
|
|
252
|
+
expect(body).toHaveProperty("error")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("removes proposal after committing (idempotency check — second call returns 404)", async () => {
|
|
256
|
+
const { dir, proposalIds } = await makeChronicle(tmpDir, {
|
|
257
|
+
proposals: [PROPOSAL],
|
|
258
|
+
})
|
|
259
|
+
srv = await startServer(tmpDir, dir)
|
|
260
|
+
|
|
261
|
+
await req("POST", `${srv.baseUrl}/api/proposals/${proposalIds[0]}/commit`)
|
|
262
|
+
const { status } = await req("POST", `${srv.baseUrl}/api/proposals/${proposalIds[0]}/commit`)
|
|
263
|
+
expect(status).toBe(404)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// ── REST: delete proposal ─────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe("DELETE /api/proposals/:id", () => {
|
|
270
|
+
it("deletes a proposal and returns deleted id", async () => {
|
|
271
|
+
const { dir, proposalIds } = await makeChronicle(tmpDir, {
|
|
272
|
+
proposals: [PROPOSAL],
|
|
273
|
+
})
|
|
274
|
+
srv = await startServer(tmpDir, dir)
|
|
275
|
+
|
|
276
|
+
const { status, body } = await req("DELETE", `${srv.baseUrl}/api/proposals/${proposalIds[0]}`)
|
|
277
|
+
expect(status).toBe(200)
|
|
278
|
+
expect(body).toHaveProperty("deleted")
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("returns 404 for unknown proposal", async () => {
|
|
282
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
283
|
+
srv = await startServer(tmpDir, dir)
|
|
284
|
+
|
|
285
|
+
const { status, body } = await req("DELETE", `${srv.baseUrl}/api/proposals/ghost-id`)
|
|
286
|
+
expect(status).toBe(404)
|
|
287
|
+
expect(body).toHaveProperty("error")
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// ── MCP JSON-RPC: generic ─────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("POST /mcp — protocol", () => {
|
|
294
|
+
it("returns parse error on malformed JSON", async () => {
|
|
295
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
296
|
+
srv = await startServer(tmpDir, dir)
|
|
297
|
+
|
|
298
|
+
const res = await fetch(`${srv.baseUrl}/mcp`, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "content-type": "application/json" },
|
|
301
|
+
body: "{ not valid json ~~",
|
|
302
|
+
})
|
|
303
|
+
expect(res.status).toBe(400)
|
|
304
|
+
const body = await res.json()
|
|
305
|
+
expect(body.error.message).toMatch(/parse/i)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("returns error for unknown method", async () => {
|
|
309
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
310
|
+
srv = await startServer(tmpDir, dir)
|
|
311
|
+
|
|
312
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, { jsonrpc: "2.0", id: 1, method: "made/up" })
|
|
313
|
+
expect(body.error.code).toBe(-32601)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it("handles batch requests", async () => {
|
|
317
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
318
|
+
srv = await startServer(tmpDir, dir)
|
|
319
|
+
|
|
320
|
+
const { status, body } = await req("POST", `${srv.baseUrl}/mcp`, [
|
|
321
|
+
{ jsonrpc: "2.0", id: 1, method: "ping" },
|
|
322
|
+
{ jsonrpc: "2.0", id: 2, method: "ping" },
|
|
323
|
+
])
|
|
324
|
+
expect(status).toBe(200)
|
|
325
|
+
expect(Array.isArray(body)).toBe(true)
|
|
326
|
+
expect(body).toHaveLength(2)
|
|
327
|
+
expect(body.every(r => r.jsonrpc === "2.0")).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe("POST /mcp — initialize", () => {
|
|
332
|
+
it("returns protocol version and capabilities", async () => {
|
|
333
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
334
|
+
srv = await startServer(tmpDir, dir)
|
|
335
|
+
|
|
336
|
+
const { status, body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
337
|
+
jsonrpc: "2.0", id: 1, method: "initialize",
|
|
338
|
+
params: { protocolVersion: "2024-11-05", capabilities: {} },
|
|
339
|
+
})
|
|
340
|
+
expect(status).toBe(200)
|
|
341
|
+
expect(body.result.protocolVersion).toBe("2024-11-05")
|
|
342
|
+
expect(body.result.serverInfo.name).toBe("quorum")
|
|
343
|
+
expect(body.result.capabilities.tools).toBeDefined()
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe("POST /mcp — tools/list", () => {
|
|
348
|
+
it("returns all ten quorum tools", async () => {
|
|
349
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
350
|
+
srv = await startServer(tmpDir, dir)
|
|
351
|
+
|
|
352
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, { jsonrpc: "2.0", id: 2, method: "tools/list" })
|
|
353
|
+
const names = body.result.tools.map(t => t.name)
|
|
354
|
+
|
|
355
|
+
expect(names).toContain("quorum_query")
|
|
356
|
+
expect(names).toContain("quorum_brief")
|
|
357
|
+
expect(names).toContain("quorum_stage")
|
|
358
|
+
expect(names).toContain("quorum_pending")
|
|
359
|
+
expect(names).toContain("quorum_coverage")
|
|
360
|
+
expect(names).toContain("quorum_growth")
|
|
361
|
+
expect(names).toContain("quorum_help")
|
|
362
|
+
expect(names).toContain("quorum_advisor")
|
|
363
|
+
expect(names).toContain("quorum_check")
|
|
364
|
+
expect(names).toContain("quorum_compass")
|
|
365
|
+
expect(names).toHaveLength(10)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("each tool has name, description, and inputSchema", async () => {
|
|
369
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
370
|
+
srv = await startServer(tmpDir, dir)
|
|
371
|
+
|
|
372
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, { jsonrpc: "2.0", id: 3, method: "tools/list" })
|
|
373
|
+
for (const tool of body.result.tools) {
|
|
374
|
+
expect(tool).toHaveProperty("name")
|
|
375
|
+
expect(tool).toHaveProperty("description")
|
|
376
|
+
expect(tool).toHaveProperty("inputSchema")
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe("POST /mcp — tools/call", () => {
|
|
382
|
+
it("chronicle_query returns matching entries", async () => {
|
|
383
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
384
|
+
srv = await startServer(tmpDir, dir)
|
|
385
|
+
|
|
386
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
387
|
+
jsonrpc: "2.0", id: 4, method: "tools/call",
|
|
388
|
+
params: { name: "quorum_query", arguments: { topic: "Redis caching", projectRoot: tmpDir } },
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
expect(body.error).toBeUndefined()
|
|
392
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
393
|
+
expect(result.entries.some(e => e.topic === "caching layer")).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it("chronicle_brief returns summary", async () => {
|
|
397
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
398
|
+
srv = await startServer(tmpDir, dir)
|
|
399
|
+
|
|
400
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
401
|
+
jsonrpc: "2.0", id: 5, method: "tools/call",
|
|
402
|
+
params: { name: "quorum_brief", arguments: { projectRoot: tmpDir } },
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
406
|
+
expect(result.total).toBe(1)
|
|
407
|
+
expect(result.byStatus.validated).toBe(1)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it("chronicle_propose creates a proposal file", async () => {
|
|
411
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
412
|
+
srv = await startServer(tmpDir, dir)
|
|
413
|
+
|
|
414
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
415
|
+
jsonrpc: "2.0", id: 6, method: "tools/call",
|
|
416
|
+
params: {
|
|
417
|
+
name: "quorum_stage",
|
|
418
|
+
arguments: { entry: { topic: "mcp idea", decision: "do it via MCP" }, projectRoot: tmpDir },
|
|
419
|
+
},
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
423
|
+
expect(result).toHaveProperty("proposalId")
|
|
424
|
+
expect(result.topic).toBe("mcp idea")
|
|
425
|
+
|
|
426
|
+
const proposalFile = path.join(dir, "proposals", `${result.proposalId}.json`)
|
|
427
|
+
const written = JSON.parse(await fs.readFile(proposalFile, "utf8"))
|
|
428
|
+
expect(written.decision).toBe("do it via MCP")
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it("chronicle_pending lists proposals", async () => {
|
|
432
|
+
const { dir } = await makeChronicle(tmpDir, {
|
|
433
|
+
proposals: [PROPOSAL, { topic: "second", decision: "also" }],
|
|
434
|
+
})
|
|
435
|
+
srv = await startServer(tmpDir, dir)
|
|
436
|
+
|
|
437
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
438
|
+
jsonrpc: "2.0", id: 7, method: "tools/call",
|
|
439
|
+
params: { name: "quorum_pending", arguments: { projectRoot: tmpDir } },
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
443
|
+
expect(result.count).toBe(2)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it("quorum_growth returns health report", async () => {
|
|
447
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
448
|
+
srv = await startServer(tmpDir, dir)
|
|
449
|
+
|
|
450
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
451
|
+
jsonrpc: "2.0", id: 8, method: "tools/call",
|
|
452
|
+
params: { name: "quorum_growth", arguments: { projectRoot: tmpDir } },
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
expect(body.error).toBeUndefined()
|
|
456
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
457
|
+
expect(result).toHaveProperty("health")
|
|
458
|
+
expect(result).toHaveProperty("entries")
|
|
459
|
+
expect(result).toHaveProperty("hint")
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it("quorum_help returns documentation content", async () => {
|
|
463
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
464
|
+
srv = await startServer(tmpDir, dir)
|
|
465
|
+
|
|
466
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
467
|
+
jsonrpc: "2.0", id: 9, method: "tools/call",
|
|
468
|
+
params: { name: "quorum_help", arguments: { topic: "index" } },
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
expect(body.error).toBeUndefined()
|
|
472
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
473
|
+
expect(result.topic).toBe("index")
|
|
474
|
+
expect(typeof result.content).toBe("string")
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it("quorum_advisor returns todo status", async () => {
|
|
478
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
479
|
+
srv = await startServer(tmpDir, dir)
|
|
480
|
+
|
|
481
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
482
|
+
jsonrpc: "2.0", id: 10, method: "tools/call",
|
|
483
|
+
params: { name: "quorum_advisor", arguments: { question: "what is the retry strategy?", projectRoot: tmpDir } },
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
expect(body.error).toBeUndefined()
|
|
487
|
+
const result = JSON.parse(body.result.content[0].text)
|
|
488
|
+
expect(result.status).toBe("todo")
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("returns -32601 for unknown tool name", async () => {
|
|
492
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
493
|
+
srv = await startServer(tmpDir, dir)
|
|
494
|
+
|
|
495
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
496
|
+
jsonrpc: "2.0", id: 11, method: "tools/call",
|
|
497
|
+
params: { name: "does_not_exist", arguments: {} },
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
expect(body.error.code).toBe(-32601)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it("returns -32603 when tool throws (missing required arg)", async () => {
|
|
504
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
505
|
+
srv = await startServer(tmpDir, dir)
|
|
506
|
+
|
|
507
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
508
|
+
jsonrpc: "2.0", id: 12, method: "tools/call",
|
|
509
|
+
params: { name: "quorum_query", arguments: { projectRoot: tmpDir } }, // no topic
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
expect(body.error.code).toBe(-32603)
|
|
513
|
+
expect(body.error.message).toMatch(/topic is required/i)
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
describe("POST /mcp — ping", () => {
|
|
518
|
+
it("returns empty result", async () => {
|
|
519
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
520
|
+
srv = await startServer(tmpDir, dir)
|
|
521
|
+
|
|
522
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, { jsonrpc: "2.0", id: 99, method: "ping" })
|
|
523
|
+
expect(body.result).toEqual({})
|
|
524
|
+
expect(body.id).toBe(99)
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
describe("POST /mcp — notifications/initialized", () => {
|
|
529
|
+
it("returns no response body for notifications", async () => {
|
|
530
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
531
|
+
srv = await startServer(tmpDir, dir)
|
|
532
|
+
|
|
533
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
534
|
+
jsonrpc: "2.0", id: null, method: "notifications/initialized",
|
|
535
|
+
})
|
|
536
|
+
// Notification handler returns null, which is filtered out.
|
|
537
|
+
// Response body will be an empty object {} (single-request filter(Boolean) with no items)
|
|
538
|
+
expect(body).toBeDefined()
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
// ── 404 ───────────────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
describe("unknown route", () => {
|
|
545
|
+
it("returns 404 with error body", async () => {
|
|
546
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
547
|
+
srv = await startServer(tmpDir, dir)
|
|
548
|
+
|
|
549
|
+
const { status, body } = await req("GET", `${srv.baseUrl}/this/does/not/exist`)
|
|
550
|
+
expect(status).toBe(404)
|
|
551
|
+
expect(body).toHaveProperty("error")
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// ── MCP Resources ─────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
describe("POST /mcp — resources/list", () => {
|
|
558
|
+
it("returns all five chronicle:// resources", async () => {
|
|
559
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
560
|
+
srv = await startServer(tmpDir, dir)
|
|
561
|
+
|
|
562
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
563
|
+
jsonrpc: "2.0", id: 20, method: "resources/list",
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
expect(body.error).toBeUndefined()
|
|
567
|
+
const uris = body.result.resources.map(r => r.uri ?? r.uriTemplate)
|
|
568
|
+
expect(uris).toContain("chronicle://summary")
|
|
569
|
+
expect(uris).toContain("chronicle://proposals")
|
|
570
|
+
expect(uris).toContain("chronicle://coverage")
|
|
571
|
+
expect(uris).toContain("chronicle://growth")
|
|
572
|
+
expect(uris).toContain("chronicle://entry/{id}")
|
|
573
|
+
expect(uris).toHaveLength(5)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it("each resource has a name, description, and mimeType", async () => {
|
|
577
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
578
|
+
srv = await startServer(tmpDir, dir)
|
|
579
|
+
|
|
580
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
581
|
+
jsonrpc: "2.0", id: 21, method: "resources/list",
|
|
582
|
+
})
|
|
583
|
+
for (const resource of body.result.resources) {
|
|
584
|
+
expect(resource).toHaveProperty("name")
|
|
585
|
+
expect(resource).toHaveProperty("description")
|
|
586
|
+
expect(resource).toHaveProperty("mimeType", "application/json")
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
describe("POST /mcp — resources/read", () => {
|
|
592
|
+
it("chronicle://summary returns entry summary", async () => {
|
|
593
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
594
|
+
srv = await startServer(tmpDir, dir)
|
|
595
|
+
|
|
596
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
597
|
+
jsonrpc: "2.0", id: 22, method: "resources/read",
|
|
598
|
+
params: { uri: "chronicle://summary" },
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
expect(body.error).toBeUndefined()
|
|
602
|
+
const content = JSON.parse(body.result.contents[0].text)
|
|
603
|
+
expect(content.total).toBe(1)
|
|
604
|
+
expect(content.byStatus.validated).toBe(1)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it("chronicle://proposals returns pending proposals", async () => {
|
|
608
|
+
const { dir } = await makeChronicle(tmpDir, { proposals: [PROPOSAL] })
|
|
609
|
+
srv = await startServer(tmpDir, dir)
|
|
610
|
+
|
|
611
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
612
|
+
jsonrpc: "2.0", id: 23, method: "resources/read",
|
|
613
|
+
params: { uri: "chronicle://proposals" },
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
expect(body.error).toBeUndefined()
|
|
617
|
+
const content = JSON.parse(body.result.contents[0].text)
|
|
618
|
+
expect(Array.isArray(content)).toBe(true)
|
|
619
|
+
expect(content.length).toBe(1)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it("chronicle://coverage returns coverage map", async () => {
|
|
623
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
624
|
+
srv = await startServer(tmpDir, dir)
|
|
625
|
+
|
|
626
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
627
|
+
jsonrpc: "2.0", id: 24, method: "resources/read",
|
|
628
|
+
params: { uri: "chronicle://coverage" },
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
expect(body.error).toBeUndefined()
|
|
632
|
+
const content = JSON.parse(body.result.contents[0].text)
|
|
633
|
+
expect(content).toHaveProperty("percentage")
|
|
634
|
+
expect(content).toHaveProperty("coverageByFile")
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it("chronicle://growth returns health report", async () => {
|
|
638
|
+
const { dir } = await makeChronicle(tmpDir, { entries: [ENTRY] })
|
|
639
|
+
srv = await startServer(tmpDir, dir)
|
|
640
|
+
|
|
641
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
642
|
+
jsonrpc: "2.0", id: 25, method: "resources/read",
|
|
643
|
+
params: { uri: "chronicle://growth" },
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
expect(body.error).toBeUndefined()
|
|
647
|
+
const content = JSON.parse(body.result.contents[0].text)
|
|
648
|
+
expect(content).toHaveProperty("health")
|
|
649
|
+
expect(content).toHaveProperty("hint")
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it("chronicle://entry/{id} returns a specific entry", async () => {
|
|
653
|
+
const entryId = randomUUID()
|
|
654
|
+
const { dir } = await makeChronicle(tmpDir, {
|
|
655
|
+
entries: [{ id: entryId, topic: "specific entry", decision: "it worked", status: "validated", confidence: 0.9 }],
|
|
656
|
+
})
|
|
657
|
+
srv = await startServer(tmpDir, dir)
|
|
658
|
+
|
|
659
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
660
|
+
jsonrpc: "2.0", id: 26, method: "resources/read",
|
|
661
|
+
params: { uri: `chronicle://entry/${entryId}` },
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
expect(body.error).toBeUndefined()
|
|
665
|
+
const content = JSON.parse(body.result.contents[0].text)
|
|
666
|
+
expect(content.topic).toBe("specific entry")
|
|
667
|
+
expect(content.id).toBe(entryId)
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it("chronicle://entry/{prefix} matches by id prefix", async () => {
|
|
671
|
+
const entryId = randomUUID()
|
|
672
|
+
const { dir } = await makeChronicle(tmpDir, {
|
|
673
|
+
entries: [{ id: entryId, topic: "prefix match", decision: "works", status: "validated", confidence: 0.8 }],
|
|
674
|
+
})
|
|
675
|
+
srv = await startServer(tmpDir, dir)
|
|
676
|
+
|
|
677
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
678
|
+
jsonrpc: "2.0", id: 27, method: "resources/read",
|
|
679
|
+
params: { uri: `chronicle://entry/${entryId.slice(0, 8)}` },
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
expect(body.error).toBeUndefined()
|
|
683
|
+
const content = JSON.parse(body.result.contents[0].text)
|
|
684
|
+
expect(content.topic).toBe("prefix match")
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it("returns -32602 for unknown entry id", async () => {
|
|
688
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
689
|
+
srv = await startServer(tmpDir, dir)
|
|
690
|
+
|
|
691
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
692
|
+
jsonrpc: "2.0", id: 28, method: "resources/read",
|
|
693
|
+
params: { uri: "chronicle://entry/does-not-exist" },
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
expect(body.error.code).toBe(-32602)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it("returns -32602 for unknown resource URI", async () => {
|
|
700
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
701
|
+
srv = await startServer(tmpDir, dir)
|
|
702
|
+
|
|
703
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
704
|
+
jsonrpc: "2.0", id: 29, method: "resources/read",
|
|
705
|
+
params: { uri: "chronicle://unknown" },
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
expect(body.error.code).toBe(-32602)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it("returns -32602 when uri is missing", async () => {
|
|
712
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
713
|
+
srv = await startServer(tmpDir, dir)
|
|
714
|
+
|
|
715
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
716
|
+
jsonrpc: "2.0", id: 30, method: "resources/read",
|
|
717
|
+
params: {},
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
expect(body.error.code).toBe(-32602)
|
|
721
|
+
})
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
// ── initialize advertises resources capability ─────────────────────────────────
|
|
725
|
+
|
|
726
|
+
describe("POST /mcp — initialize advertises resources", () => {
|
|
727
|
+
it("capabilities includes resources", async () => {
|
|
728
|
+
const { dir } = await makeChronicle(tmpDir)
|
|
729
|
+
srv = await startServer(tmpDir, dir)
|
|
730
|
+
|
|
731
|
+
const { body } = await req("POST", `${srv.baseUrl}/mcp`, {
|
|
732
|
+
jsonrpc: "2.0", id: 31, method: "initialize",
|
|
733
|
+
params: { protocolVersion: "2024-11-05", capabilities: {} },
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
expect(body.result.capabilities.resources).toBeDefined()
|
|
737
|
+
})
|
|
738
|
+
})
|
|
739
|
+
|