@theronap/cortex-mcp 0.9.156 → 0.9.158
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/server.mjs +152 -1
- package/package.json +1 -1
package/lib/server.mjs
CHANGED
|
@@ -193,7 +193,16 @@ export async function runServer(version) {
|
|
|
193
193
|
// seat runs — session-ping carried liveness only. That is why every version gate in this
|
|
194
194
|
// ADR was unobservable. Additive and forward-compatible: the route destructures sessionKey
|
|
195
195
|
// and cwd and ignores the rest, so this is inert until a column exists to store it.
|
|
196
|
-
|
|
196
|
+
// repo (ADR-0066 §7): the repo this session is WORKING IN, so it holds more than what it
|
|
197
|
+
// has read. Same detector the context cache is keyed on, so no new failure mode — a
|
|
198
|
+
// non-GitHub or non-repo cwd yields null and the field is simply omitted. The server drops
|
|
199
|
+
// anything malformed; this is attention data, never routing.
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
sessionKey: SESSION_KEY,
|
|
202
|
+
cwd: process.cwd(),
|
|
203
|
+
mcpVersion: version,
|
|
204
|
+
repo: repoFullNameFrom(process.cwd()) ?? undefined,
|
|
205
|
+
}),
|
|
197
206
|
})
|
|
198
207
|
} catch { /* best-effort heartbeat — never disrupt the session */ }
|
|
199
208
|
}
|
|
@@ -2741,6 +2750,100 @@ function renderNudge(payload) {
|
|
|
2741
2750
|
},
|
|
2742
2751
|
)
|
|
2743
2752
|
|
|
2753
|
+
server.registerTool(
|
|
2754
|
+
'my_source_routes',
|
|
2755
|
+
{
|
|
2756
|
+
title: 'Where a source lands — the routes you declared, and what is waiting for one',
|
|
2757
|
+
description: 'Show the DECLARED landings: (account, source type, key) -> brain, with who decided each one. ⚠ THIS IS A DIFFERENT TABLE FROM my_routing_claims AND ANSWERS A DIFFERENT QUESTION. A claim says which PAGE a record attaches to (and the brain falls out of that); a route says which BRAIN a source\'s raw lands in when nothing else decides. Both can be right and disagree, which is exactly when mail piles up. ⚠ A route settles a claim TIE only when `decided_by` is `explicit` — a person decided it; a `triage` route was an agent unblocking a queue and does not overrule live ambiguity (ADR-0066 §5d). ⚠ APPEND-ONLY BY DESIGN: a route is never updated, because re-pointing a live source splits its stream irreparably — records are unique on the org-scoped (org_id, dedupe_key), so the two halves never reconcile. Creating one is a decision you do not get to take back cheaply. Also lists what is STAGED per source — those are exactly the routes worth creating.',
|
|
2758
|
+
inputSchema: {},
|
|
2759
|
+
},
|
|
2760
|
+
async () => {
|
|
2761
|
+
let res
|
|
2762
|
+
try {
|
|
2763
|
+
res = await fetchCortex(`${BASE}/api/source-routes`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
2764
|
+
} catch (e) {
|
|
2765
|
+
return toolError(`Could not read source routes: ${e.message}`)
|
|
2766
|
+
}
|
|
2767
|
+
const out = await res.json().catch(() => null)
|
|
2768
|
+
if (!res.ok) return toolError(`Could not read source routes: ${out?.error ?? res.status}`)
|
|
2769
|
+
|
|
2770
|
+
const routes = Array.isArray(out?.routes) ? out.routes : []
|
|
2771
|
+
const pending = Array.isArray(out?.pendingStaged) ? out.pendingStaged : []
|
|
2772
|
+
const lines = []
|
|
2773
|
+
|
|
2774
|
+
if (routes.length === 0) {
|
|
2775
|
+
lines.push('No declared routes. Every source resolves by claims alone, and a claim tie has nothing to fall back on.')
|
|
2776
|
+
} else {
|
|
2777
|
+
lines.push(`${routes.length} declared route(s):`)
|
|
2778
|
+
for (const r of routes) {
|
|
2779
|
+
// `account`/`sourceKey` are empty when the route means "any" at that position — say so
|
|
2780
|
+
// rather than printing a blank, because a blank reads as missing data.
|
|
2781
|
+
const acct = r.account || '(any account)'
|
|
2782
|
+
const key = r.sourceKey || '(any key)'
|
|
2783
|
+
const mark = r.decidedBy === 'explicit' ? '✓' : '·'
|
|
2784
|
+
lines.push(` ${mark} ${r.sourceType} · ${acct} · ${key} → ${r.brain} [${r.decidedBy}${r.decidedNote ? `: ${r.decidedNote}` : ''}]`)
|
|
2785
|
+
}
|
|
2786
|
+
lines.push(' ✓ = decided by a person, and therefore able to settle a claim tie. · = decided by triage, which cannot.')
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
if (pending.length) {
|
|
2790
|
+
lines.push('')
|
|
2791
|
+
lines.push('Waiting on a routing decision:')
|
|
2792
|
+
for (const p of pending) {
|
|
2793
|
+
lines.push(` ${p.sourceType} · ${p.account || '(any account)'} — ${p.count} staged, oldest ${String(p.oldest).slice(0, 10)}`)
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2797
|
+
},
|
|
2798
|
+
)
|
|
2799
|
+
|
|
2800
|
+
server.registerTool(
|
|
2801
|
+
'my_routing_claims',
|
|
2802
|
+
{
|
|
2803
|
+
title: 'What you have claimed — and therefore where your mail goes',
|
|
2804
|
+
description: 'Show the routing claims you can see: which pages claim which identifiers, in which brains, and WHAT THE ROUTER WILL DO with a message carrying each one. ⚠ THE COUNT IS THE WHOLE POINT: a message routes only when EXACTLY ONE brain claims something on it — zero or several both STAGE, which is why mail piles up in the staged queue while every individual claim looks correct. Pass `identifier` to ask about one exact key ("who claims notifications@github.com?"); a zero answer is a verdict, not an empty result. ⚠ NOTHING ELSE SHOWS THIS. read_page on an identifier greps page BODIES for the literal stamp, and a body mention does not drive routing (ADR-0026) — so the surface that looks like the answer reads a different table from the one the router reads. ⚠ CHECK BEFORE YOU CLAIM: adding a claim to a second brain turns "routes" into "stages", and replacing a stale one turns a silent misroute into a different silent misroute (ADR-0066 §5b). `email:`/`thread:` claims are private to whoever made them and attach only that person\'s own units — so for YOUR records, your own claims are exactly the set the router consults, not a redacted view of a larger truth.',
|
|
2805
|
+
inputSchema: {
|
|
2806
|
+
identifier: z.string().optional().describe('one canonical identifier to ask about, e.g. email:support@npmjs.com or repo:owner/name. Omit to list everything you can see, ambiguous identifiers first.'),
|
|
2807
|
+
limit: z.number().optional().describe('max claim rows (default 200, capped at 500)'),
|
|
2808
|
+
},
|
|
2809
|
+
},
|
|
2810
|
+
async ({ identifier, limit }) => {
|
|
2811
|
+
const qs = new URLSearchParams()
|
|
2812
|
+
if (identifier) qs.set('identifier', identifier)
|
|
2813
|
+
if (limit) qs.set('limit', String(limit))
|
|
2814
|
+
let res
|
|
2815
|
+
try {
|
|
2816
|
+
res = await fetchCortex(`${BASE}/api/routing-claims${qs.toString() ? `?${qs}` : ''}`, {
|
|
2817
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
2818
|
+
})
|
|
2819
|
+
} catch (e) {
|
|
2820
|
+
return toolError(`Could not read routing claims: ${e.message}`)
|
|
2821
|
+
}
|
|
2822
|
+
const out = await res.json().catch(() => null)
|
|
2823
|
+
if (!res.ok) return toolError(`Could not read routing claims: ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}`)
|
|
2824
|
+
|
|
2825
|
+
const groups = Array.isArray(out?.identifiers) ? out.identifiers : []
|
|
2826
|
+
if (groups.length === 0) {
|
|
2827
|
+
return { content: [{ type: 'text', text: 'No routing claims you can see. Nothing you own decides a brain for any identifier, so inbound mail resolves by nothing and stages.' }] }
|
|
2828
|
+
}
|
|
2829
|
+
const lines = []
|
|
2830
|
+
for (const g of groups) {
|
|
2831
|
+
const mark = g.brains > 1 ? '⚠ ' : g.brains === 0 ? '· ' : '✓ '
|
|
2832
|
+
lines.push(`${mark}${g.identifier} — ${g.brains} brain(s) → ${g.verdict}`)
|
|
2833
|
+
for (const c of g.claims ?? []) {
|
|
2834
|
+
// A page the caller cannot open still claims the identifier and still decides routing, so it
|
|
2835
|
+
// is listed; only its title is withheld. Dropping the row would under-report the count.
|
|
2836
|
+
const page = c.page ?? '(a page you cannot open)'
|
|
2837
|
+
lines.push(` ${c.brain} · ${page}${c.mine ? '' : ' · claimed by someone else'} · ${c.source} · ${String(c.createdAt).slice(0, 10)}`)
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
lines.push('')
|
|
2841
|
+
lines.push('— Exactly one brain routes. Zero or several STAGE, and a real message carries several identifiers, so one clean sender can still stage on another key it carries.')
|
|
2842
|
+
lines.push('— Before adding a claim: a second brain on the same identifier converts routing into staging. Claim the NARROWEST key the message carries (a repo or package), not the account it came from.')
|
|
2843
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2844
|
+
},
|
|
2845
|
+
)
|
|
2846
|
+
|
|
2744
2847
|
server.registerTool(
|
|
2745
2848
|
'set_summary',
|
|
2746
2849
|
{
|
|
@@ -4004,6 +4107,54 @@ function renderNudge(payload) {
|
|
|
4004
4107
|
},
|
|
4005
4108
|
)
|
|
4006
4109
|
|
|
4110
|
+
// THE CUSTODIAL HALF OF OWNERSHIP. `transferPageOwnership` and its route have existed since
|
|
4111
|
+
// 2026-08-24 and had NEVER run in production (0 of 4,280 page revisions as of 2026-09-18) for the
|
|
4112
|
+
// simple reason that no agent could reach them — the endpoint was HTTP-only, and ownership is a
|
|
4113
|
+
// thing people ask their agent about ("put Dana in charge of this"), not a thing they open a
|
|
4114
|
+
// console for. Added 2026-09-18 on Theron's ask, as the first piece of the member-removal design
|
|
4115
|
+
// on [[Member removal and org shape]]: rehoming a departing person's pages is impossible without it.
|
|
4116
|
+
server.registerTool(
|
|
4117
|
+
'transfer_page_ownership',
|
|
4118
|
+
{
|
|
4119
|
+
title: 'Hand a page over to someone else',
|
|
4120
|
+
description: 'Make another org member the owner of a wiki page — "put Dana in charge of the Northwind page". Ownership decides who may re-tier it, who may set its edit policy, and (for scoped/confidential pages) who can read it, so this is a real handover, not a label. Allowed for the page\'s owner, or for a manager-ancestor who can already read it; pass yourself as `to` to TAKE ownership, which is the documented path for a manager who then needs to change policy. Recorded in page history with you as the actor. The page keeps its tier — transferring does not re-tier, so a scoped page handed to someone outside the new owner\'s chain can become unreadable to its previous audience; re-tier separately if that matters.',
|
|
4121
|
+
inputSchema: {
|
|
4122
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
4123
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
4124
|
+
ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
|
|
4125
|
+
to: z.string().describe('who becomes the owner — an org member\'s email, or their user id. Must be an ACTIVE member of the same brain as the page. Pass your own address to take ownership yourself.'),
|
|
4126
|
+
},
|
|
4127
|
+
},
|
|
4128
|
+
async ({ kind, name, ref, to }) => {
|
|
4129
|
+
let res
|
|
4130
|
+
try {
|
|
4131
|
+
res = await fetchCortex(`${BASE}/api/brain/node-policy`, {
|
|
4132
|
+
method: 'POST',
|
|
4133
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4134
|
+
// No `tier`: a node has exactly one page (ADR-0064 §4), and the route resolves it. Sending a
|
|
4135
|
+
// guessed tier is how this used to 404 on precisely the scoped/confidential pages it matters for.
|
|
4136
|
+
body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), transfer_to: to }),
|
|
4137
|
+
})
|
|
4138
|
+
} catch (e) {
|
|
4139
|
+
return toolError(`Could not transfer ownership: ${e.message}`)
|
|
4140
|
+
}
|
|
4141
|
+
const out = await res.json().catch(() => null)
|
|
4142
|
+
if (!res.ok) {
|
|
4143
|
+
// The route's refusals name their own remedy (not permitted / not an active member / no page
|
|
4144
|
+
// yet) — surface them verbatim rather than flattening them to a status code.
|
|
4145
|
+
if (out?.error) return toolError(`Could not transfer ownership: ${out.error}`)
|
|
4146
|
+
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
4147
|
+
return toolError(`Could not transfer ownership: ${d.message}`)
|
|
4148
|
+
}
|
|
4149
|
+
return {
|
|
4150
|
+
content: [{
|
|
4151
|
+
type: 'text',
|
|
4152
|
+
text: `Done — ${name ?? out.ref} now belongs to ${to}. The page is still ${out.tier}; transferring never re-tiers, so check that its audience is still right.`,
|
|
4153
|
+
}],
|
|
4154
|
+
}
|
|
4155
|
+
},
|
|
4156
|
+
)
|
|
4157
|
+
|
|
4007
4158
|
server.registerTool(
|
|
4008
4159
|
'my_retier_notices',
|
|
4009
4160
|
{
|
package/package.json
CHANGED