@theronap/cortex-mcp 0.9.29 → 0.9.31
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 +186 -3
- package/package.json +1 -1
package/lib/server.mjs
CHANGED
|
@@ -517,6 +517,187 @@ export async function runServer(version) {
|
|
|
517
517
|
},
|
|
518
518
|
)
|
|
519
519
|
|
|
520
|
+
server.registerTool(
|
|
521
|
+
'set_page_privacy',
|
|
522
|
+
{
|
|
523
|
+
title: 'Change who can see a wiki page',
|
|
524
|
+
description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. If the target tier already has a page, you\'ll be asked to merge via author first, then re-run with absorb=true. Org admins may demote any page, never promote.',
|
|
525
|
+
inputSchema: {
|
|
526
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
527
|
+
name: z.string().describe('the exact page name'),
|
|
528
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
|
|
529
|
+
source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
|
|
530
|
+
absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant'),
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
async ({ kind, name, tier, source_tier, absorb }) => {
|
|
534
|
+
let res
|
|
535
|
+
try {
|
|
536
|
+
res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
|
|
537
|
+
method: 'POST',
|
|
538
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
539
|
+
body: JSON.stringify({ kind, name, tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}) }),
|
|
540
|
+
})
|
|
541
|
+
} catch (e) {
|
|
542
|
+
return { content: [{ type: 'text', text: `Could not set page privacy: ${e.message}` }] }
|
|
543
|
+
}
|
|
544
|
+
const out = await res.json().catch(() => null)
|
|
545
|
+
if (!res.ok) {
|
|
546
|
+
// 409s carry the collision protocol (merge instruction, both variants when readable) —
|
|
547
|
+
// surface the server's structured message verbatim so the agent can follow it.
|
|
548
|
+
if (out?.error) {
|
|
549
|
+
const extra = out.collision === 'readable' && out.blocking
|
|
550
|
+
? `\nYour ${out.source?.tier} page: ${out.source?.summary ?? out.source?.title}\nExisting ${out.blocking.tier} page: ${out.blocking.summary ?? out.blocking.title}`
|
|
551
|
+
: ''
|
|
552
|
+
return { content: [{ type: 'text', text: `Could not set page privacy: ${out.error}${extra}` }] }
|
|
553
|
+
}
|
|
554
|
+
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
555
|
+
return { content: [{ type: 'text', text: `Could not set page privacy: ${d.message}` }] }
|
|
556
|
+
}
|
|
557
|
+
const g = out.live_grants?.length
|
|
558
|
+
? ` Grants still active for: ${out.live_grants.map((x) => x.grantee_name ?? x.grantee_user_id).join(', ')}.`
|
|
559
|
+
: ''
|
|
560
|
+
return { content: [{ type: 'text', text: `Done — "${out.moved.title}" moved ${out.moved.from} → ${out.moved.to}.${out.ownership_taken ? ' (You took ownership of this previously owner-less page.)' : ''} ${out.note}${g}` }] }
|
|
561
|
+
},
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
server.registerTool(
|
|
565
|
+
'grant_page_access',
|
|
566
|
+
{
|
|
567
|
+
title: 'Grant or revoke a specific person\'s access to your page',
|
|
568
|
+
description: 'Share one of YOUR non-accessible wiki pages with a specific org member (or take that access back). A grant lets exactly that person read the page even though its tier would hide it — the escape hatch for "confidential, but Dana needs it". Owner-only. Grants survive re-tiering: revoke them when they should end.',
|
|
569
|
+
inputSchema: {
|
|
570
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
571
|
+
name: z.string().describe('the exact page name'),
|
|
572
|
+
grantee: z.string().describe('the org member\'s display name or email (must resolve uniquely — use email if ambiguous)'),
|
|
573
|
+
action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
|
|
574
|
+
tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
async ({ kind, name, grantee, action, tier }) => {
|
|
578
|
+
let res
|
|
579
|
+
try {
|
|
580
|
+
res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
|
|
581
|
+
method: 'POST',
|
|
582
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
583
|
+
body: JSON.stringify({ kind, name, grantee, action, ...(tier ? { tier } : {}) }),
|
|
584
|
+
})
|
|
585
|
+
} catch (e) {
|
|
586
|
+
return { content: [{ type: 'text', text: `Could not ${action}: ${e.message}` }] }
|
|
587
|
+
}
|
|
588
|
+
const out = await res.json().catch(() => null)
|
|
589
|
+
if (!res.ok) return { content: [{ type: 'text', text: `Could not ${action}: ${out?.error ?? res.status}` }] }
|
|
590
|
+
const verb = { granted: 'now has access to', already_granted: 'already had access to', revoked: 'no longer has access to', not_granted: 'had no grant on' }[out.action]
|
|
591
|
+
return { content: [{ type: 'text', text: `Done — ${out.grantee} ${verb} the ${out.tier} page.` }] }
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
server.registerTool(
|
|
596
|
+
'list_page_grants',
|
|
597
|
+
{
|
|
598
|
+
title: 'List who has granted access to your page',
|
|
599
|
+
description: 'Show every explicit access grant on YOUR page\'s tier variants (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
|
|
600
|
+
inputSchema: {
|
|
601
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
602
|
+
name: z.string().describe('the exact page name'),
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
async ({ kind, name }) => {
|
|
606
|
+
let res
|
|
607
|
+
try {
|
|
608
|
+
res = await fetchCortex(`${BASE}/api/brain/page-grants?kind=${encodeURIComponent(kind)}&name=${encodeURIComponent(name)}`, {
|
|
609
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
610
|
+
})
|
|
611
|
+
} catch (e) {
|
|
612
|
+
return { content: [{ type: 'text', text: `Could not list grants: ${e.message}` }] }
|
|
613
|
+
}
|
|
614
|
+
const out = await res.json().catch(() => null)
|
|
615
|
+
if (!res.ok) return { content: [{ type: 'text', text: `Could not list grants: ${out?.error ?? res.status}` }] }
|
|
616
|
+
if (!out.grants?.length) return { content: [{ type: 'text', text: 'No grants on this page.' }] }
|
|
617
|
+
const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier} variant (since ${String(g.created_at).slice(0, 10)})`)
|
|
618
|
+
return { content: [{ type: 'text', text: `Grants (${out.grants.length}):\n${lines.join('\n')}` }] }
|
|
619
|
+
},
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
server.registerTool(
|
|
623
|
+
'my_retier_notices',
|
|
624
|
+
{
|
|
625
|
+
title: 'Records of yours that a page demotion re-tiered',
|
|
626
|
+
description: 'When someone demotes a project page, its evidence records follow (demote-only) — including yours. This lists those notices (newest first) and marks them seen. To undo one, call set_record_privacy with the record_id and its previous tier (shown as from_privacy).',
|
|
627
|
+
inputSchema: {},
|
|
628
|
+
},
|
|
629
|
+
async () => {
|
|
630
|
+
let res
|
|
631
|
+
try {
|
|
632
|
+
res = await fetchCortex(`${BASE}/api/brain/retier-notices`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
633
|
+
} catch (e) {
|
|
634
|
+
return { content: [{ type: 'text', text: `Could not list notices: ${e.message}` }] }
|
|
635
|
+
}
|
|
636
|
+
if (!res.ok) {
|
|
637
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
638
|
+
return { content: [{ type: 'text', text: `Could not list notices: ${d.message}` }] }
|
|
639
|
+
}
|
|
640
|
+
const { notices } = await res.json()
|
|
641
|
+
if (!notices?.length) return { content: [{ type: 'text', text: 'No re-tier notices.' }] }
|
|
642
|
+
const lines = notices.map((n) =>
|
|
643
|
+
`- record ${n.record_id} ("${n.record_title ?? 'untitled'}") ${n.from_privacy} → ${n.to_privacy} — ${n.demoted_by_name ?? 'someone'} demoted the ${n.node_name ?? n.node_kind} page. Revert: set_record_privacy(record_id, "${n.from_privacy}").`)
|
|
644
|
+
return { content: [{ type: 'text', text: `Your re-tiered records (${notices.length}):\n${lines.join('\n')}` }] }
|
|
645
|
+
},
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
server.registerTool(
|
|
649
|
+
'page_merge_requests',
|
|
650
|
+
{
|
|
651
|
+
title: 'Merge requests on pages you own',
|
|
652
|
+
description: 'Someone tried to move their page into a tier slot your page occupies (they only saw "slot occupied"). Review pending requests here; read their variant, merge anything worth keeping into your page via author, then decide with decide_page_merge.',
|
|
653
|
+
inputSchema: {},
|
|
654
|
+
},
|
|
655
|
+
async () => {
|
|
656
|
+
let res
|
|
657
|
+
try {
|
|
658
|
+
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
659
|
+
} catch (e) {
|
|
660
|
+
return { content: [{ type: 'text', text: `Could not list merge requests: ${e.message}` }] }
|
|
661
|
+
}
|
|
662
|
+
if (!res.ok) {
|
|
663
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
664
|
+
return { content: [{ type: 'text', text: `Could not list merge requests: ${d.message}` }] }
|
|
665
|
+
}
|
|
666
|
+
const { requests } = await res.json()
|
|
667
|
+
if (!requests?.length) return { content: [{ type: 'text', text: 'No pending page merge requests.' }] }
|
|
668
|
+
const lines = requests.map((q) =>
|
|
669
|
+
`- [${q.id}] ${q.requester_name ?? 'someone'} wants their ${q.kind} page merged into your ${q.requested_tier} "${q.page_title}". Merge via author first, then decide_page_merge.`)
|
|
670
|
+
return { content: [{ type: 'text', text: `Pending page merge requests (${requests.length}):\n${lines.join('\n')}` }] }
|
|
671
|
+
},
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
server.registerTool(
|
|
675
|
+
'decide_page_merge',
|
|
676
|
+
{
|
|
677
|
+
title: 'Approve or deny a page merge request',
|
|
678
|
+
description: 'Decide a pending page merge request you own. IMPORTANT: approve only AFTER you have merged whatever of the requester\'s content you want into your page (via author) — approving DELETES their variant of the node. Deny closes the request and nothing moves.',
|
|
679
|
+
inputSchema: {
|
|
680
|
+
id: z.string().describe('the request id from page_merge_requests'),
|
|
681
|
+
decision: z.enum(['approve', 'deny']).describe('approve = their variant is removed (merge first!); deny = nothing moves'),
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
async ({ id, decision }) => {
|
|
685
|
+
let res
|
|
686
|
+
try {
|
|
687
|
+
res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, {
|
|
688
|
+
method: 'POST',
|
|
689
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
690
|
+
body: JSON.stringify({ id, decision }),
|
|
691
|
+
})
|
|
692
|
+
} catch (e) {
|
|
693
|
+
return { content: [{ type: 'text', text: `Could not decide: ${e.message}` }] }
|
|
694
|
+
}
|
|
695
|
+
const out = await res.json().catch(() => null)
|
|
696
|
+
if (!res.ok) return { content: [{ type: 'text', text: `Could not decide: ${out?.error ?? res.status}` }] }
|
|
697
|
+
return { content: [{ type: 'text', text: out.decision === 'denied' ? 'Denied — nothing moved.' : `Approved — removed variant(s): ${out.removed_variants.join(', ') || 'none remained'}. ${out.note}` }] }
|
|
698
|
+
},
|
|
699
|
+
)
|
|
700
|
+
|
|
520
701
|
// ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
|
|
521
702
|
const fail = (verb, res) => async () =>
|
|
522
703
|
({ content: [{ type: 'text', text: `Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}` }] })
|
|
@@ -645,7 +826,7 @@ export async function runServer(version) {
|
|
|
645
826
|
{
|
|
646
827
|
title: 'Authoring context (call before author)',
|
|
647
828
|
description:
|
|
648
|
-
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (existing node names — link to these with the EXACT name inside [[ ]]) and the node-type CONNECTION RULES (what kinds of links to look for). ALWAYS call this BEFORE `author` so the page links to real nodes by their established names instead of minting synonyms. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating. If the page IS about a specific code repo, also stamp [[repo:owner/name]] (lowercase)
|
|
829
|
+
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (existing node names — link to these with the EXACT name inside [[ ]]) and the node-type CONNECTION RULES (what kinds of links to look for). ALWAYS call this BEFORE `author` so the page links to real nodes by their established names instead of minting synonyms. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating. If the page IS about a specific code repo or chat channel, also stamp it once — [[repo:owner/name]] or [[channel:name]] (lowercase, no #) — identifier join keys, not page links.',
|
|
649
830
|
inputSchema: {
|
|
650
831
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
|
|
651
832
|
},
|
|
@@ -688,12 +869,14 @@ export async function runServer(version) {
|
|
|
688
869
|
heading: z.string().describe('e.g. Overview, Current state, Decisions, Open threads, People'),
|
|
689
870
|
body: z.string().describe('dense markdown WITH inline [[links]] where the prose references another node'),
|
|
690
871
|
})).describe('3-5 sections; the page body'),
|
|
691
|
-
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier
|
|
872
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('visibility tier. Omit for the safe default: scoped (you + your management chain) on nodes that support it — your user page, projects you own-scope — and accessible elsewhere (person/org pages are the shared wiki). Pass accessible explicitly when the page is meant for the whole org.'),
|
|
692
873
|
base_version: z.string().optional().describe('the `version` hash shown when you read this page (read_page) — REQUIRED when updating an existing page, so a concurrent edit is caught instead of clobbered. Omit only for a brand-new node. If the save returns "stale" or "read first", read_page again and retry with the fresh version.'),
|
|
693
874
|
},
|
|
694
875
|
},
|
|
695
876
|
async ({ kind, name, summary, sections, tier, base_version }) => {
|
|
696
|
-
|
|
877
|
+
// No client-side tier default — the server computes the per-kind safe default (page-privacy
|
|
878
|
+
// T4/D10) so version-pinned installs can't bake a stale policy.
|
|
879
|
+
const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
697
880
|
let res
|
|
698
881
|
try {
|
|
699
882
|
res = await fetchCortex(`${BASE}/api/brain/author`, {
|