@theronap/cortex-mcp 0.9.74 → 0.9.76
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/lib/diagnose.mjs +35 -0
- package/lib/doctor.mjs +26 -2
- package/lib/server.mjs +8 -1
- package/lib/skills.mjs +54 -10
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -215,6 +215,41 @@ export async function checkToken(token, base) {
|
|
|
215
215
|
return { ok: true, status: 200, projectCount, requestId }
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// Probe the org-skills surface. SEPARATE FROM checkToken ON PURPOSE.
|
|
219
|
+
//
|
|
220
|
+
// WHY THIS EXISTS. `doctor` checked exactly one endpoint — /api/mcp-context — and reported PASS on
|
|
221
|
+
// the strength of it. Meanwhile /api/skills answered 409 to every multi-brain caller for three days
|
|
222
|
+
// (measured 2026-08-08: 14 requests, 14 × 409, zero successes; the local cache sat frozen from Aug 5
|
|
223
|
+
// to Aug 8). Org-published skills silently never installed, the client swallowed the status, and
|
|
224
|
+
// doctor — the tool setup.txt tells people to run FIRST when something is wrong — said everything
|
|
225
|
+
// was fine, every single time. A check that probes one surface is a health check for that surface,
|
|
226
|
+
// not for the system, and must not be reported as the latter.
|
|
227
|
+
//
|
|
228
|
+
// Returns checkToken's shape so the caller renders both the same way. `skillCount` is
|
|
229
|
+
// informational: an org with zero published skills is perfectly healthy, so it is NOT a failure.
|
|
230
|
+
export async function checkSkills(token, base) {
|
|
231
|
+
if (!token || !isUuid(token)) {
|
|
232
|
+
return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
|
|
233
|
+
message: 'no usable token — the context check above already covers this' } }
|
|
234
|
+
}
|
|
235
|
+
const url = `${(base ?? CANONICAL_BASE).replace(/\/$/, '')}/api/skills`
|
|
236
|
+
let res
|
|
237
|
+
try {
|
|
238
|
+
res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return { ok: false, status: 0, diagnosis: { kind: 'network', retriable: true, message: e.message } }
|
|
241
|
+
}
|
|
242
|
+
const requestId = res.headers.get('x-vercel-id') ?? null
|
|
243
|
+
const contentType = res.headers.get('content-type')
|
|
244
|
+
const body = await res.text()
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
|
|
247
|
+
}
|
|
248
|
+
let skillCount
|
|
249
|
+
try { skillCount = (JSON.parse(body)?.skills ?? []).length } catch { /* shape drift — non-fatal */ }
|
|
250
|
+
return { ok: true, status: 200, skillCount, requestId }
|
|
251
|
+
}
|
|
252
|
+
|
|
218
253
|
// Release the keep-alive sockets the global fetch (undici) holds, so a short-lived CLI command
|
|
219
254
|
// can tear down cleanly. Without this, calling process.exit() right after a fetch can race a
|
|
220
255
|
// lingering socket handle and trip a libuv assertion on Windows
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { checkToken, resolveBase } from './diagnose.mjs'
|
|
4
|
+
import { checkToken, checkSkills, resolveBase } from './diagnose.mjs'
|
|
5
5
|
|
|
6
6
|
// `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
|
|
7
7
|
//
|
|
@@ -64,9 +64,33 @@ export async function runDoctor() {
|
|
|
64
64
|
const r = await checkToken(token, base)
|
|
65
65
|
|
|
66
66
|
if (r.ok) {
|
|
67
|
-
out(' ✓
|
|
67
|
+
out(' ✓ context — your token authenticates and Agnoclast returned your context.')
|
|
68
68
|
if (typeof r.projectCount === 'number') out(` You can currently see ${r.projectCount} project(s).`)
|
|
69
69
|
if (r.requestId) out(` (request id: ${r.requestId})`)
|
|
70
|
+
|
|
71
|
+
// A SECOND surface, because one endpoint answering is not "the system is healthy". /api/skills
|
|
72
|
+
// 409'd every multi-brain caller for three days while this command printed PASS on the strength
|
|
73
|
+
// of /api/mcp-context alone. See checkSkills' header for the full record.
|
|
74
|
+
const s = await checkSkills(token, base)
|
|
75
|
+
if (s.ok) {
|
|
76
|
+
const n = typeof s.skillCount === 'number'
|
|
77
|
+
? ` (${s.skillCount} org skill${s.skillCount === 1 ? '' : 's'} published)` : ''
|
|
78
|
+
out(` ✓ skills — the org-skill surface answers${n}.`)
|
|
79
|
+
} else {
|
|
80
|
+
// NOT a total failure — context works, so capture and retrieval are fine. But it must never
|
|
81
|
+
// again render as PASS, because org skills silently stop updating when this breaks.
|
|
82
|
+
out(` ✗ skills — ${s.diagnosis?.kind ?? 'error'}${s.status ? ` (HTTP ${s.status})` : ''}`)
|
|
83
|
+
out(` ${s.diagnosis?.message ?? 'unknown error'}`)
|
|
84
|
+
out('')
|
|
85
|
+
out(' ⚠ PARTIAL — your connection is fine and capture/retrieval work, but org-published')
|
|
86
|
+
out(' skills cannot be fetched, so they will silently stop updating (the client falls')
|
|
87
|
+
out(' back to its last-good cache). Report this rather than ignoring it.')
|
|
88
|
+
out('')
|
|
89
|
+
return 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
out('')
|
|
93
|
+
out(' ✓ PASS — every surface checked is answering.')
|
|
70
94
|
out('')
|
|
71
95
|
out(' If your AI still does not see Agnoclast, the server just is not loaded yet —')
|
|
72
96
|
out(' fully quit and reopen Claude Code (the MCP server starts on launch).')
|
package/lib/server.mjs
CHANGED
|
@@ -719,7 +719,14 @@ export async function runServer(version) {
|
|
|
719
719
|
}
|
|
720
720
|
const out = await res.json().catch(() => null)
|
|
721
721
|
if (!res.ok) return toolError(`Could not roll back "${name}": ${out?.error ?? res.status}`)
|
|
722
|
-
|
|
722
|
+
// #477: when the target revision had no summary, the page's CURRENT summary was kept rather than
|
|
723
|
+
// erased. Say it on its own line instead of at the tail of `note` — this is the 2026-08-04 W3
|
|
724
|
+
// shape, where empty summaries copied over populated ones destroyed 31 of them under a report
|
|
725
|
+
// that read as success. A rescue the operator does not see is still a silent write.
|
|
726
|
+
const keptLine = out.summaryKept === 'current'
|
|
727
|
+
? `\n\n⚠ That revision had NO summary, so the page's current summary was KEPT rather than erased — check it still describes the restored body, and use set_summary if not.`
|
|
728
|
+
: ''
|
|
729
|
+
return { content: [{ type: 'text', text: `Done — "${name}" ${out.note}. \`read_page "${name}"\` to confirm the current content.${keptLine}` }] }
|
|
723
730
|
},
|
|
724
731
|
)
|
|
725
732
|
|
package/lib/skills.mjs
CHANGED
|
@@ -265,22 +265,65 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
265
265
|
return summary
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
268
|
+
// Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
|
|
269
|
+
// body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
|
|
270
|
+
// the caller can choose "by what each one HOLDS, not by which name sounds related". This function
|
|
271
|
+
// existed as `body.error ?? HTTP ${status}`, which printed the bare code `brain_required` and threw
|
|
272
|
+
// all of that away — the user saw a two-word error with no notion of what a brain is, which ones they
|
|
273
|
+
// have, or what to type next. Pure + exported so the shape is unit-testable without a network.
|
|
274
|
+
export function renderPushError(body, status) {
|
|
275
|
+
const lines = [body?.message ?? body?.error ?? `HTTP ${status}`]
|
|
276
|
+
const brains = Array.isArray(body?.brains) ? body.brains : []
|
|
277
|
+
if (brains.length) {
|
|
278
|
+
lines.push('', ' Your brains:')
|
|
279
|
+
for (const b of brains) {
|
|
280
|
+
const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
281
|
+
const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
|
|
282
|
+
? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
|
|
283
|
+
lines.push(` ${b?.name ?? b?.orgId ?? '(unnamed)'}${pages}${titles}`)
|
|
284
|
+
}
|
|
285
|
+
lines.push('', ' Re-run with --brain "<name>".')
|
|
286
|
+
}
|
|
287
|
+
return lines.join('\n')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// `skills push <file> [--brain <name>]` / `skills push --disable <name> [--brain <name>]` /
|
|
291
|
+
// `skills push --enable <name> [--brain <name>]` — publish or toggle an org skill
|
|
292
|
+
// (owner/manager/admin; the server enforces the role).
|
|
293
|
+
//
|
|
294
|
+
// ⚠ --brain IS NOT OPTIONAL FOR A MULTI-BRAIN CALLER, and until 2026-08-08 there was no way to pass
|
|
295
|
+
// it. Publishing MODIFIES one brain, so the server correctly uses ADR-0022's `requireBrain` half and
|
|
296
|
+
// refuses to guess — but this command sent no `brain`, so every push and every --disable from a
|
|
297
|
+
// multi-brain account died on `brain_required` with no way forward. Confirmed against production:
|
|
298
|
+
// `skills push --disable cortex-author-docs` → `✗ brain_required`, full stop.
|
|
299
|
+
//
|
|
300
|
+
// This is the WRITE twin of the GET bug fixed in #467. The read was miscategorised and now fans out;
|
|
301
|
+
// the write was categorised correctly and simply had no input for the answer it demanded.
|
|
302
|
+
export async function runSkillsPush(argv) {
|
|
271
303
|
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
272
304
|
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
273
305
|
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
274
306
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
275
307
|
|
|
308
|
+
const brainIdx = argv.findIndex((a) => a === '--brain')
|
|
309
|
+
const brain = brainIdx === -1 ? null : argv[brainIdx + 1]
|
|
310
|
+
if (brainIdx !== -1 && (!brain || brain.startsWith('-'))) {
|
|
311
|
+
process.stderr.write('Usage: skills push … --brain <name> (missing brain name)\n')
|
|
312
|
+
return 1
|
|
313
|
+
}
|
|
314
|
+
// Strip --brain AND its value before any positional parsing below. The value does not start with
|
|
315
|
+
// '-', so the `argv.find(a => !a.startsWith('-'))` file lookup would otherwise take it as the
|
|
316
|
+
// SKILL.md path and push the wrong thing.
|
|
317
|
+
const rest = brainIdx === -1 ? argv : argv.filter((_, i) => i !== brainIdx && i !== brainIdx + 1)
|
|
318
|
+
|
|
276
319
|
let payload
|
|
277
|
-
const toggleIdx =
|
|
320
|
+
const toggleIdx = rest.findIndex((a) => a === '--disable' || a === '--enable')
|
|
278
321
|
if (toggleIdx !== -1) {
|
|
279
|
-
const name =
|
|
280
|
-
if (!name) { process.stderr.write(`Usage: skills push ${
|
|
281
|
-
payload = { name, enabled:
|
|
322
|
+
const name = rest[toggleIdx + 1]
|
|
323
|
+
if (!name) { process.stderr.write(`Usage: skills push ${rest[toggleIdx]} <name>\n`); return 1 }
|
|
324
|
+
payload = { name, enabled: rest[toggleIdx] === '--enable' }
|
|
282
325
|
} else {
|
|
283
|
-
const file =
|
|
326
|
+
const file = rest.find((a) => !a.startsWith('-'))
|
|
284
327
|
if (!file || !existsSync(file)) { process.stderr.write('Usage: skills push <SKILL.md> (file not found)\n'); return 1 }
|
|
285
328
|
const body_md = readFileSync(file, 'utf8')
|
|
286
329
|
const name = frontmatterName(body_md, '').toLowerCase()
|
|
@@ -292,13 +335,14 @@ async function runSkillsPush(argv) {
|
|
|
292
335
|
}
|
|
293
336
|
|
|
294
337
|
try {
|
|
295
|
-
const
|
|
338
|
+
const qs = brain ? `?brain=${encodeURIComponent(brain)}` : ''
|
|
339
|
+
const res = await fetchCortex(`${base}/api/skills${qs}`, {
|
|
296
340
|
method: 'POST',
|
|
297
341
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
298
342
|
body: JSON.stringify(payload),
|
|
299
343
|
})
|
|
300
344
|
const body = await res.json().catch(() => ({}))
|
|
301
|
-
if (!res.ok) { process.stderr.write(`✗ ${body
|
|
345
|
+
if (!res.ok) { process.stderr.write(`✗ ${renderPushError(body, res.status)}\n`); return 1 }
|
|
302
346
|
process.stdout.write(
|
|
303
347
|
payload.body_md
|
|
304
348
|
? `✓ published "${payload.name}" to your org — every seat installs it on next session start.\n`
|