@theronap/agnoclast-mcp 0.9.232 โ†’ 0.9.234

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.
@@ -0,0 +1,102 @@
1
+ // AGNOCLAST LITE โ€” LOCKED TOOLS on the stdio server (Theron, 2026-10-13 3:19 PM MDT: "What if we stopped the
2
+ // agent from using the tools but we did allow them to see that the tools are there as something that would be
3
+ // open to them if they either used the trial or paid for the plan?").
4
+ //
5
+ // The list, the ๐Ÿ”’ line and every answer live on the server (web/lib/engine/locked_tools.ts) so the claude.ai
6
+ // connector and this server can never disagree. This module only:
7
+ // ยท asks once at start-up whether this account is locked (GET /api/lite/locked), and marks those tools'
8
+ // descriptions with the server's ๐Ÿ”’ line before the first tools/list goes out โ€” the rest of each
9
+ // description is left exactly as written;
10
+ // ยท for a call to a locked tool, asks the server for the answer (POST /api/lite/locked) and returns it
11
+ // INSTEAD of running the tool. If the server now says "not locked" (the trial started mid-session), the
12
+ // tool runs as usual.
13
+ // ยท FAILS CLOSED when it cannot know (Codex review, 2026-10-13): if start-up could not learn the account's
14
+ // state, every tool in REGULAR_ONLY_TOOLS asks per call, and a call whose check cannot be made runs
15
+ // nothing and says so. A definite "not locked" (Regular) at start-up costs nothing per call.
16
+
17
+ /**
18
+ * The fallback list, used only when start-up could not ask the server. The same names as the server's
19
+ * LOCKED_TOOLS (web/lib/engine/locked_tools.ts) that exist here โ€” web/lib/engine/trial.test.ts pins the two.
20
+ */
21
+ export const REGULAR_ONLY_TOOLS = [
22
+ 'list_records', 'read_record', 'timeline_pull', 'record_status', 'set_record_notes', 'set_record_privacy',
23
+ 'relabel_record_people', 'supersede_record', 'move_record', 'contain_record', 'pending_records', 'claim_record',
24
+ 'route_record', 'unroute_record', 'request_file', 'get_file', 'create_custom_source', 'search_spam_email',
25
+ 'gmail_modify', 'draft_email_reply', 'request_send_email', 'send_imessage', 'create_brain', 'add_to_brain',
26
+ ]
27
+
28
+ /** What a locked tool answers when the check itself could not be made. Nothing was run. */
29
+ export const LOCK_CHECK_FAILED =
30
+ 'Not run: this tool is part of the Agnoclast Regular plan, and Agnoclast did not answer whether this account can use it right now, ' +
31
+ 'so nothing was done. Try again in a moment.'
32
+
33
+ /** Start-up budget: the MCP handshake must not wait on this for long. */
34
+ export const LOCK_FETCH_TIMEOUT_MS = 2500
35
+
36
+ /**
37
+ * GET /api/lite/locked โ†’ { known: true, locked: false } (Regular, or a server that predates locked tools: a
38
+ * 404) | { known: true, locked: true, tools, lockLine } | { known: false, locked: false } (no answer). Never throws.
39
+ */
40
+ export async function fetchLockState(base, token, fetchImpl) {
41
+ try {
42
+ const res = await fetchImpl(`${base}/api/lite/locked`, { headers: { Authorization: `Bearer ${token}` } }, { skipAutofile: true })
43
+ if (res.status === 404) return { known: true, locked: false }
44
+ if (!res.ok) return { known: false, locked: false }
45
+ const j = await res.json()
46
+ if (j?.locked === false) return { known: true, locked: false }
47
+ if (j?.locked !== true || !Array.isArray(j.tools) || typeof j.lockLine !== 'string') return { known: false, locked: false }
48
+ return { known: true, locked: true, tools: j.tools.filter((t) => typeof t === 'string'), lockLine: j.lockLine }
49
+ } catch {
50
+ return { known: false, locked: false }
51
+ }
52
+ }
53
+
54
+ /** The lock state, or "unknown" if it did not arrive within the budget. */
55
+ export async function lockStateWithin(promise, ms = LOCK_FETCH_TIMEOUT_MS) {
56
+ let timer
57
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ known: false, locked: false }), ms) })
58
+ try { return await Promise.race([promise, timeout]) } finally { clearTimeout(timer) }
59
+ }
60
+
61
+ /**
62
+ * Which registered tools must ask before they run, marking the locked ones' descriptions (lockLine + the
63
+ * original). Locked โ†’ the server's list. Unknown โ†’ REGULAR_ONLY_TOOLS, unmarked (we do not know they are
64
+ * locked; each call asks). Regular โ†’ none. `handles` maps a name to { handle, description }.
65
+ */
66
+ export function applyLock(handles, lock) {
67
+ const checked = new Set()
68
+ if (lock?.known === false) {
69
+ for (const name of REGULAR_ONLY_TOOLS) if (handles.has(name)) checked.add(name)
70
+ return checked
71
+ }
72
+ if (!lock?.locked) return checked
73
+ for (const name of lock.tools) {
74
+ const h = handles.get(name)
75
+ if (!h) continue
76
+ h.handle.update({ description: `${lock.lockLine}${h.description ?? ''}` })
77
+ checked.add(name)
78
+ }
79
+ return checked
80
+ }
81
+
82
+ /**
83
+ * POST /api/lite/locked { tool, args } โ†’ { run: true } to run the tool as usual, or { run: false, text } to
84
+ * answer instead. A check that cannot be made answers LOCK_CHECK_FAILED: nothing runs. Never throws.
85
+ */
86
+ export async function lockedAnswerFor(base, token, tool, args, fetchImpl) {
87
+ try {
88
+ const res = await fetchImpl(`${base}/api/lite/locked`, {
89
+ method: 'POST',
90
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ tool, args: args && typeof args === 'object' && !Array.isArray(args) ? args : {} }),
92
+ }, { skipAutofile: true })
93
+ if (res.status === 404) return { run: true } // a server that predates locked tools locks nothing
94
+ if (!res.ok) return { run: false, text: LOCK_CHECK_FAILED }
95
+ const j = await res.json()
96
+ if (j?.locked === false) return { run: true }
97
+ if (j?.locked === true && typeof j.text === 'string') return { run: false, text: j.text }
98
+ return { run: false, text: LOCK_CHECK_FAILED }
99
+ } catch {
100
+ return { run: false, text: LOCK_CHECK_FAILED }
101
+ }
102
+ }
package/lib/server.mjs CHANGED
@@ -30,6 +30,8 @@ import { renderRecord, READ_RECORD_DEFAULT_CHARS, READ_RECORD_PARTS } from './re
30
30
  import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
31
31
  import { formatSeriesRefusal, formatSeriesUpdated } from './series_render.mjs'
32
32
  import { formatSends } from './sends_render.mjs'
33
+ import { applyLock, fetchLockState, lockedAnswerFor, lockStateWithin } from './locked_tools.mjs'
34
+ import { filterUnshownSettling } from './settling_shown.mjs'
33
35
 
34
36
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
35
37
  // set_record_privacy below) โ€” an owner-relative tier is applied to a page's records when they are read
@@ -387,11 +389,30 @@ export async function runServer(version) {
387
389
  }
388
390
 
389
391
  const server = new McpServer({ name: 'cortex', version })
392
+ // AGNOCLAST LITE โ€” locked tools (lib/locked_tools.mjs; Theron, 2026-10-13 3:19 PM). Asked now, in parallel
393
+ // with registering ~120 tools, and awaited (bounded) just before the handshake, so the first tools/list a
394
+ // Lite account's AI sees already marks the Regular-only tools ๐Ÿ”’. Regular accounts: nothing changes.
395
+ const lockP = fetchLockState(BASE, TOKEN, fetchCortex)
396
+ const toolHandles = new Map()
397
+ let lockedTools = new Set()
390
398
  // ADR-0070: every tool runs with its NAME in async context, so an outage fetchCortex auto-files is
391
399
  // attributed to the tool that hit it rather than guessed from a URL shared by several tools.
392
400
  const registerTool = server.registerTool.bind(server)
393
- server.registerTool = (name, config, handler) =>
394
- registerTool(name, config, (...args) => runWithTool(name, () => handler(...args)))
401
+ server.registerTool = (name, config, handler) => {
402
+ const takesArgs = !!config?.inputSchema
403
+ const handle = registerTool(name, config, (...args) => runWithTool(name, async () => {
404
+ // A LOCKED tool runs nothing: the server's answer says what it would have done and how Regular is
405
+ // reached. "Not locked any more" (the trial started mid-session) โ†’ the tool runs as usual; a check that
406
+ // cannot be made โ†’ nothing runs (fails closed).
407
+ if (lockedTools.has(name)) {
408
+ const check = await lockedAnswerFor(BASE, TOKEN, name, takesArgs ? args[0] : {}, fetchCortex)
409
+ if (!check.run) return { content: [{ type: 'text', text: check.text }] }
410
+ }
411
+ return handler(...args)
412
+ }))
413
+ toolHandles.set(name, { handle, description: config?.description })
414
+ return handle
415
+ }
395
416
 
396
417
 
397
418
  // โ”€โ”€ THE PRE-CLAIM NUDGE, rendered โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -430,9 +451,18 @@ function renderArrival(payload) {
430
451
  ` โ€” \`staged_records\` lists them. Not offered to you, just counted; \`ignore_arrivals\` if this is noise right now.`
431
452
  }
432
453
 
454
+ // ASK ONCE, THEN ONLY WHAT IS NEW (Theron, 2026-10-14). This block rode on EVERY tool reply, unchanged,
455
+ // and an offer that can be ignored on every call is ignored on every call โ€” measured that day: 869
456
+ // pending, 0 claimed, while the block was printed to every session. Now a unit is shown to this process
457
+ // (one MCP server = one session) the first time only, framed as a decision owed before other work.
458
+ // Units every session passes on are escalated to the PERSON at the next session start
459
+ // (web/lib/engine/orphan_escalation.ts), so a unit dropped here is never lost โ€” it changes audience.
460
+ const settlingShown = new Set()
461
+
433
462
  function renderSettling(payload) {
434
463
  const feed = payload?.settling
435
- const offers = Array.isArray(feed?.offers) ? feed.offers : []
464
+ const firstSight = settlingShown.size === 0
465
+ const offers = filterUnshownSettling(Array.isArray(feed?.offers) ? feed.offers : [], settlingShown)
436
466
  if (!offers.length) return ''
437
467
  const total = Number(feed?.total ?? offers.length)
438
468
 
@@ -463,7 +493,10 @@ function renderSettling(payload) {
463
493
  ? `\nโ€” โš  ${maxHolders} live sessions of yours hold a matching identifier, so these are offered to all of them. \`intake_claim\` takes a lease โ€” claim before you work, not after, or two sessions judge the same arrival.`
464
494
  : ''
465
495
 
466
- return `\n\nโšก ARRIVALS SETTLING โ€” yours alone, and no one else can see these yet:\n${lines.join('\n')}\n` +
496
+ const head = firstSight
497
+ ? `\n\nโšก ARRIVALS SETTLING โ€” DECIDE EACH LINE BEFORE CONTINUING: \`intake_claim\` it if it belongs to this session's work, otherwise \`not_mine\`. You are asked once; when every session has passed, the person is told at the next session start.\n`
498
+ : `\n\nโšก NEW ARRIVALS SETTLING โ€” decide each: \`intake_claim\` or \`not_mine\`:\n`
499
+ return `${head}${lines.join('\n')}\n` +
467
500
  `โ€” These are unjudged ARRIVALS, not records. Since automatic materialization was deleted, nothing files them for you.\n` +
468
501
  `โ€” \`intake_look <id>\` reads the full body without claiming it โ€” looking is free, non-exclusive and logged, so look rather than guess from the headline.\n` +
469
502
  `โ€” Then \`intake_claim {intakeItemIds: ["<id>"]}\` to take a lease on THAT unit (the array is required; without an id you claim the head of the queue instead), then \`intake_materialize\` into the brain and pages it belongs to โ€” or \`intake_discard\`, or \`intake_defer\` with a question when only the owner can decide.\n` +
@@ -6710,5 +6743,6 @@ function renderNudge(payload) {
6710
6743
  },
6711
6744
  )
6712
6745
 
6746
+ lockedTools = applyLock(toolHandles, await lockStateWithin(lockP))
6713
6747
  await server.connect(new StdioServerTransport())
6714
6748
  }
@@ -0,0 +1,9 @@
1
+ // Ask-once filter for the โšก ARRIVALS SETTLING block (2026-10-14). Pure, so it is testable without
2
+ // importing server.mjs. An offer is fresh if ANY id it stands for has not been shown to this session;
3
+ // a text conversation counts as one offer carrying many ids, so one new text re-raises its chat.
4
+ export function filterUnshownSettling(offers, shown) {
5
+ const idsOf = (x) => (x.conversation?.ids?.length ? x.conversation.ids : [x.intakeItemId])
6
+ const fresh = offers.filter((x) => idsOf(x).some((id) => !shown.has(id)))
7
+ for (const x of fresh) for (const id of idsOf(x)) shown.add(id)
8
+ return fresh
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.232",
3
+ "version": "0.9.234",
4
4
  "description": "Connect your AI assistant to Cortex โ€” your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {