@theronap/agnoclast-mcp 0.9.242 → 0.9.244

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,173 @@
1
+ // The Agnoclast calendar on the stdio server: upcoming, create_event, update_event.
2
+ //
3
+ // ⚠ WHY THIS FILE EXISTS. Those three tools shipped on the claude.ai connector only (web/lib/mcp/tools.ts,
4
+ // 2026-10-06), so a person set up through the desktop app or Claude Code — the stdio server — could track
5
+ // obligations but had no way to read or add calendar events. Found at David Morgan's setup, 2026-10-19.
6
+ //
7
+ // ⚠ THIS IS A PORT, PINNED BY A PARITY TEST. resolveEventTimes / describeEventTime are
8
+ // web/lib/engine/event_time.ts and the three renderers are web/lib/engine/mcp_remote_render.ts. This
9
+ // package ships to npm on its own and cannot import the web app's TypeScript, so the logic is copied —
10
+ // and calendar_events.parity.test.mjs runs both copies on the same inputs, so an edit to one without the
11
+ // other fails CI instead of silently putting a Denver 3pm class at 9am on one surface.
12
+
13
+ export const CONSOLE = 'https://agnoclast.com'
14
+ export const NATIVE_EVENT_SOURCE = 'agnoclast-calendar'
15
+
16
+ const oneLine = (s, max = 140) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
17
+
18
+ // ── event_time.ts ───────────────────────────────────────────────────────────────────────────────
19
+
20
+ const OFFSET_RE = /(Z|[+-]\d{2}:?\d{2})$/i
21
+ const NAIVE_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/
22
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
23
+
24
+ function offsetMinutes(utcMs, tz) {
25
+ const parts = new Intl.DateTimeFormat('en-US', {
26
+ timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
27
+ }).formatToParts(new Date(utcMs))
28
+ const n = (t) => Number(parts.find((p) => p.type === t)?.value)
29
+ const asUtc = Date.UTC(n('year'), n('month') - 1, n('day'), n('hour'), n('minute'), n('second'))
30
+ return Math.round((asUtc - utcMs) / 60000)
31
+ }
32
+
33
+ /** An ISO-ish time to a UTC instant. Without an offset it is the wall-clock time in `tz`. */
34
+ export function toInstant(raw, tz) {
35
+ const s = raw.trim()
36
+ if (OFFSET_RE.test(s)) {
37
+ const d = new Date(s)
38
+ return Number.isNaN(d.getTime()) ? null : d
39
+ }
40
+ const m = NAIVE_RE.exec(s)
41
+ if (!m) return null
42
+ const wallAsUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] ?? 0))
43
+ let utc = wallAsUtc - offsetMinutes(wallAsUtc, tz) * 60000
44
+ utc = wallAsUtc - offsetMinutes(utc, tz) * 60000
45
+ return new Date(utc)
46
+ }
47
+
48
+ /** Validate and resolve an event's start/end, in the person's timezone. */
49
+ export function resolveEventTimes(input, tz) {
50
+ const start = typeof input.start === 'string' ? input.start.trim() : ''
51
+ const end = typeof input.end === 'string' && input.end.trim() ? input.end.trim() : null
52
+ if (!start) return { ok: false, error: 'start is required (ISO 8601, e.g. 2026-10-08T15:00, or a date for an all-day event).' }
53
+
54
+ if (input.allDay === true || DATE_RE.test(start)) {
55
+ const date = start.slice(0, 10)
56
+ if (!DATE_RE.test(date) || Number.isNaN(Date.parse(`${date}T00:00:00Z`))) return { ok: false, error: `Not a date: ${start}` }
57
+ const endDate = end ? end.slice(0, 10) : null
58
+ if (endDate && (!DATE_RE.test(endDate) || endDate < date)) return { ok: false, error: 'An all-day event cannot end before it starts.' }
59
+ return {
60
+ ok: true, allDay: true, date,
61
+ startsAt: `${date}T00:00:00.000Z`,
62
+ endsAt: endDate && endDate !== date ? `${endDate}T23:59:59.000Z` : null,
63
+ }
64
+ }
65
+
66
+ const s = toInstant(start, tz)
67
+ if (!s) return { ok: false, error: `Could not read the start time: ${start}` }
68
+ let e = null
69
+ if (end) {
70
+ e = toInstant(end, tz)
71
+ if (!e) return { ok: false, error: `Could not read the end time: ${end}` }
72
+ if (e.getTime() <= s.getTime()) return { ok: false, error: 'The event has to end after it starts.' }
73
+ }
74
+ return { ok: true, allDay: false, startsAt: s.toISOString(), endsAt: e ? e.toISOString() : null }
75
+ }
76
+
77
+ /** "Thu, Oct 8, 3:00–4:00 PM" in the person's timezone, or "Tue, Oct 20 (all day)". */
78
+ export function describeEventTime(t, tz) {
79
+ if (t.allDay) {
80
+ const d = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'short', month: 'short', day: 'numeric' }).format(new Date(`${t.date}T12:00:00Z`))
81
+ return `${d} (all day)`
82
+ }
83
+ const day = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' }).format(new Date(t.startsAt))
84
+ const time = (iso) => new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(new Date(iso))
85
+ return `${day}, ${time(t.startsAt)}${t.endsAt ? `–${time(t.endsAt)}` : ''}`
86
+ }
87
+
88
+ // ── mcp_remote_render.ts ────────────────────────────────────────────────────────────────────────
89
+
90
+ function dayLabel(d, tz) {
91
+ return new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' }).format(d)
92
+ }
93
+ function timeLabel(d, tz) {
94
+ return new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(d)
95
+ }
96
+ function allDayLabel(ymd) {
97
+ return dayLabel(new Date(`${ymd}T12:00:00Z`), 'UTC')
98
+ }
99
+
100
+ /** The calendar, in the person's own timezone. An ALL-DAY event renders from `allDayDate`, never `startsAt`. */
101
+ export function renderUpcoming(items, tz, incomplete = false) {
102
+ if (!items.length) {
103
+ return 'Nothing on the calendar in that window.' + (incomplete ? ' (One brain could not be read, so this may be missing events.)' : '') +
104
+ ' If the person expected events, their calendar may not be connected — that is set up at ' + CONSOLE + '/connectors.'
105
+ }
106
+ const lines = [`Coming up (times in ${tz}):`]
107
+ let lastDay = ''
108
+ for (const it of items) {
109
+ const start = new Date(it.startsAt)
110
+ const day = it.allDayDate ? allDayLabel(it.allDayDate) : dayLabel(start, tz)
111
+ if (day !== lastDay) { lines.push('', day); lastDay = day }
112
+ const when = it.allDayDate
113
+ ? 'all day'
114
+ : `${timeLabel(start, tz)}${it.endsAt ? `–${timeLabel(new Date(it.endsAt), tz)}` : ''}`
115
+ const more = it.furtherOccurrences > 0 ? ` (+${it.furtherOccurrences} more this series)` : ''
116
+ const where = it.location ? ` · ${oneLine(it.location, 80)}` : ''
117
+ const id = it.source === NATIVE_EVENT_SOURCE && it.recordId ? ` [event id ${it.recordId}]` : ''
118
+ lines.push(` ${when} ${oneLine(it.title ?? '(untitled)', 140)}${where}${more}${id}`)
119
+ }
120
+ if (incomplete) lines.push('', '(One brain could not be read, so this may be missing events.)')
121
+ return lines.join('\n')
122
+ }
123
+
124
+ /** create_event's answer. A SEALED event (private-intake accounts) is not on the calendar yet — say so. */
125
+ export function renderEventCreated(title, when, tz, body) {
126
+ if (body?.sealed) {
127
+ return `Saved "${oneLine(title, 200)}" (${when}), but it is NOT on the calendar yet: this account holds new items until they are filed from its intake pile, which an Agnoclast session in Claude Code or the desktop app does. It shows on the calendar once filed.` +
128
+ (body.intakeItemId ? `\nEvent id: ${body.intakeItemId} — if anything about it is wrong (time, end, place, name), fix it with update_event; do not save a second copy.` : '')
129
+ }
130
+ return `Added to the Agnoclast calendar: "${oneLine(title, 200)}", ${when} (${tz}). It shows on ${CONSOLE}/calendar and in upcoming. Nothing was added to Google or Outlook, and no one was invited.` +
131
+ (body?.id ? `\nEvent id: ${body.id} — if anything about it changes, fix it with update_event; do not save a second copy.` : '')
132
+ }
133
+
134
+ export function renderEventUpdated(b) {
135
+ const changed = (b?.changed ?? []).filter((c) => ['title', 'start', 'end', 'location'].includes(c))
136
+ const where = b?.location ? `, at ${oneLine(b.location, 120)}` : ''
137
+ const head = changed.length
138
+ ? `Updated "${oneLine(b?.title, 200)}": now ${b?.when ?? ''}${where} (${b?.tz ?? 'UTC'}). Changed: ${changed.join(', ')}.`
139
+ : `Nothing to change: "${oneLine(b?.title, 200)}" is already ${b?.when ?? ''}${where}.`
140
+ return b?.kind === 'sealed'
141
+ ? `${head} It is still held in this account's intake pile and shows on the calendar once filed.`
142
+ : `${head} Nothing in Google or Outlook was touched.`
143
+ }
144
+
145
+ // ── request bodies ──────────────────────────────────────────────────────────────────────────────
146
+
147
+ /** POST /api/records/capture body for a new Agnoclast-calendar event — the same shape create_event sends on claude.ai. */
148
+ export function captureEventBody({ title, location, notes, brain }, t, when) {
149
+ return {
150
+ title,
151
+ content: [title, when, location, notes].filter(Boolean).join('\n'),
152
+ summary: notes ? `${title} — ${notes}` : title,
153
+ source: NATIVE_EVENT_SOURCE,
154
+ recordType: 'event',
155
+ occurredAt: t.startsAt,
156
+ ...(t.endsAt ? { endsAt: t.endsAt } : {}),
157
+ location,
158
+ eventStart: t.allDay ? t.date : t.startsAt,
159
+ ...(t.endsAt ? { eventEnd: t.allDay ? t.endsAt.slice(0, 10) : t.endsAt } : {}),
160
+ brain,
161
+ }
162
+ }
163
+
164
+ /** POST /api/events/update body, or null when nothing would change. "" for end/location removes it. */
165
+ export function updateEventBody(args) {
166
+ const body = { eventId: String(args.event_id ?? '').trim() }
167
+ if (typeof args.title === 'string' && args.title.trim()) body.title = args.title.trim()
168
+ if (typeof args.start === 'string' && args.start.trim()) body.start = args.start.trim()
169
+ if (args.end !== undefined) body.end = typeof args.end === 'string' && args.end.trim() ? args.end.trim() : null
170
+ if (typeof args.all_day === 'boolean') body.allDay = args.all_day
171
+ if (args.location !== undefined) body.location = typeof args.location === 'string' && args.location.trim() ? args.location.trim() : null
172
+ return Object.keys(body).length === 1 ? null : body
173
+ }
@@ -0,0 +1,48 @@
1
+ // INTERNAL MESSAGING (ADR-0088) — how `my_messages` and `read_conversation` read to an agent.
2
+ // Shapes are web/lib/engine/messages.ts (InboxItem, MessageView), trimmed to what is read.
3
+
4
+ const REASON = { dm: 'direct message', mention: 'mentioned you', thread: 'reply in your thread', channel: 'channel' }
5
+
6
+ /** "Builder (agent of Theron Peterson)" / "Ann Lee" / "Deleted account". */
7
+ export function authorLabel(author) {
8
+ if (!author) return 'Deleted account'
9
+ if (author.kind === 'agent') return `${author.name} (agent${author.ownerName ? ` of ${author.ownerName}` : ''})`
10
+ return author.name
11
+ }
12
+
13
+ function when(iso) {
14
+ return iso ? iso.replace('T', ' ').slice(0, 16) + 'Z' : ''
15
+ }
16
+
17
+ /** The inbox, grouped by conversation so an agent answers each one once. */
18
+ export function renderMessageInbox(items) {
19
+ if (!items.length) return 'No new messages.'
20
+ const byConv = new Map()
21
+ for (const it of items) {
22
+ const key = it.conversation.id
23
+ if (!byConv.has(key)) byConv.set(key, { conv: it.conversation, items: [] })
24
+ byConv.get(key).items.push(it)
25
+ }
26
+ const lines = [`${items.length} new message${items.length === 1 ? '' : 's'}:`]
27
+ for (const { conv, items: its } of byConv.values()) {
28
+ lines.push('', `## ${conv.name} — conversation ${conv.id}`)
29
+ for (const it of its) {
30
+ const where = it.threadRootId ? ` · in thread ${it.threadRootId}` : ''
31
+ lines.push(`- [${when(it.createdAt)}] ${authorLabel(it.author)} (${REASON[it.reason] ?? it.reason}${where}) · message ${it.id}`)
32
+ for (const l of it.body.split('\n')) lines.push(` ${l}`)
33
+ }
34
+ }
35
+ lines.push('', 'Reply with send_message. To answer in a thread, pass thread = the thread id shown (or the message id, to start one).')
36
+ return lines.join('\n')
37
+ }
38
+
39
+ /** A conversation page or one thread, oldest first. */
40
+ export function renderConversation(messages) {
41
+ if (!messages.length) return 'No messages yet.'
42
+ return messages.map((m) => {
43
+ const head = `[${when(m.createdAt)}] ${authorLabel(m.author)} · message ${m.id}${m.editedAt ? ' · edited' : ''}`
44
+ const replies = m.replyCount ? `\n (${m.replyCount} repl${m.replyCount === 1 ? 'y' : 'ies'} in thread ${m.id})` : ''
45
+ const body = m.deleted ? ' (message deleted)' : m.body.split('\n').map((l) => ` ${l}`).join('\n')
46
+ return `${head}\n${body}${replies}`
47
+ }).join('\n')
48
+ }
@@ -0,0 +1,44 @@
1
+ // RELEASE COHORTS on the stdio server (Theron, 2026-10-19 — page: Agnoclast — release cohorts and feature flags).
2
+ // The flag registry and the cohort rule live on the server (web/lib/engine/release.ts). This module only asks,
3
+ // once at start-up, which tools this caller must NOT see (GET /api/me/release) and removes them before the
4
+ // first tools/list. Fails CLOSED: no answer within the budget → hide GATED_FALLBACK, a copy of every gated tool
5
+ // that web/lib/engine/release.test.ts pins to the server's registry.
6
+
7
+ /** Tools any release flag gates — hidden when the server cannot be asked. Mirror of allGatedTools(). */
8
+ export const GATED_FALLBACK = ['my_messages', 'read_conversation', 'send_message']
9
+
10
+ export const RELEASE_FETCH_TIMEOUT_MS = 2500
11
+
12
+ /** → { known: true, hidden: string[] } | { known: false }. 404 (server predates cohorts) = nothing hidden. Never throws. */
13
+ export async function fetchReleaseState(base, token, fetchImpl) {
14
+ try {
15
+ const res = await fetchImpl(`${base}/api/me/release`, { headers: { Authorization: `Bearer ${token}` } }, { skipAutofile: true })
16
+ if (res.status === 404) return { known: true, hidden: [] }
17
+ if (!res.ok) return { known: false }
18
+ const j = await res.json()
19
+ if (!Array.isArray(j?.hiddenTools)) return { known: false }
20
+ return { known: true, hidden: j.hiddenTools.filter((t) => typeof t === 'string') }
21
+ } catch {
22
+ return { known: false }
23
+ }
24
+ }
25
+
26
+ export async function releaseStateWithin(promise, ms = RELEASE_FETCH_TIMEOUT_MS) {
27
+ let timer
28
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ known: false }), ms) })
29
+ try { return await Promise.race([promise, timeout]) } finally { clearTimeout(timer) }
30
+ }
31
+
32
+ /** Remove hidden tools from the registry. `handles` maps name → { handle }. Returns the names removed. */
33
+ export function applyRelease(handles, state) {
34
+ const names = state?.known ? state.hidden : GATED_FALLBACK
35
+ const removed = []
36
+ for (const name of names) {
37
+ const h = handles.get(name)
38
+ if (!h) continue
39
+ h.handle.remove()
40
+ handles.delete(name)
41
+ removed.push(name)
42
+ }
43
+ return removed
44
+ }
package/lib/server.mjs CHANGED
@@ -31,8 +31,11 @@ import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, r
31
31
  import { formatSeriesRefusal, formatSeriesUpdated } from './series_render.mjs'
32
32
  import { formatSends } from './sends_render.mjs'
33
33
  import { applyLock, fetchLockState, lockedAnswerFor, lockStateWithin } from './locked_tools.mjs'
34
+ import { applyRelease, fetchReleaseState, releaseStateWithin } from './release_gate.mjs'
34
35
  import { filterUnshownSettling } from './settling_shown.mjs'
35
36
  import { renderContactBriefing, renderContactCandidates, renderContactList } from './contacts_render.mjs'
37
+ import { renderConversation, renderMessageInbox } from './messages_render.mjs'
38
+ import { captureEventBody, describeEventTime, renderEventCreated, renderEventUpdated, renderUpcoming, resolveEventTimes, updateEventBody } from './calendar_events.mjs'
36
39
 
37
40
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
38
41
  // set_record_privacy below) — an owner-relative tier is applied to a page's records when they are read
@@ -394,6 +397,8 @@ export async function runServer(version) {
394
397
  // with registering ~120 tools, and awaited (bounded) just before the handshake, so the first tools/list a
395
398
  // Lite account's AI sees already marks the Regular-only tools 🔒. Regular accounts: nothing changes.
396
399
  const lockP = fetchLockState(BASE, TOKEN, fetchCortex)
400
+ // RELEASE COHORTS (lib/release_gate.mjs): tools behind a flag this caller's brains lack are removed before the handshake.
401
+ const releaseP = fetchReleaseState(BASE, TOKEN, fetchCortex)
397
402
  const toolHandles = new Map()
398
403
  let lockedTools = new Set()
399
404
  // ADR-0070: every tool runs with its NAME in async context, so an outage fetchCortex auto-files is
@@ -4208,6 +4213,95 @@ function renderNudge(payload) {
4208
4213
  },
4209
4214
  )
4210
4215
 
4216
+ // 💬 INTERNAL MESSAGING (ADR-0088; Theron, 2026-10-19). Agnoclast's own chat between people AND agents who
4217
+ // share a brain. For an agent, a message is its wake-up: `my_messages` is its inbox.
4218
+ server.registerTool(
4219
+ 'send_message',
4220
+ {
4221
+ title: 'Send a message — to a person, an agent, or a #channel',
4222
+ description: 'Send a chat message inside Agnoclast, like Slack. `to` is a person\'s or agent\'s name or email (a DM) or a #channel. You can only message people and agents who share a brain with you. Mention someone with @Name to notify them in a channel. Reply in a thread with `thread` (a message id from my_messages or read_conversation). If a name matches more than one person you get the list back: ask which, then send again with their email. Messages are not email: nothing leaves Agnoclast.',
4223
+ inputSchema: {
4224
+ to: z.string().describe('a name, an email, or a #channel'),
4225
+ body: z.string().describe('the message (at most 8000 characters)'),
4226
+ thread: z.string().optional().describe('reply in this thread: the id of its first message'),
4227
+ brain: z.string().optional().describe('which brain, when a #channel name exists in more than one'),
4228
+ },
4229
+ },
4230
+ async ({ to, body, thread, brain }) => {
4231
+ let res
4232
+ try {
4233
+ res = await fetchCortex(`${BASE}/api/messages/send`, {
4234
+ method: 'POST',
4235
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4236
+ body: JSON.stringify({ to, body, threadRootId: thread, brain }),
4237
+ })
4238
+ } catch (e) {
4239
+ return toolError(`Could not send: ${e.message}`)
4240
+ }
4241
+ const out = await res.json().catch(() => null)
4242
+ if (res.status === 404 && !out?.error) return toolError('This Agnoclast server has no messaging yet (it predates ADR-0088).')
4243
+ if (res.status === 409 && out?.error === 'ambiguous') {
4244
+ const lines = (out.candidates ?? []).map((p) => `- ${p.name}${p.kind === 'agent' ? ` (agent${p.ownerName ? ` of ${p.ownerName}` : ''})` : ''}`)
4245
+ return { content: [{ type: 'text', text: `More than one match for "${to}":\n${lines.join('\n')}\nAsk which one, then send again using their email.` }] }
4246
+ }
4247
+ if (!res.ok) return toolError(`Not sent: ${out?.detail ?? out?.error ?? res.status}`)
4248
+ const told = out.notified ? ` ${out.notified} notified.` : ''
4249
+ return { content: [{ type: 'text', text: `Sent to ${to}.${told} (message ${out.message.id}, conversation ${out.conversationId})` }] }
4250
+ },
4251
+ )
4252
+
4253
+ server.registerTool(
4254
+ 'my_messages',
4255
+ {
4256
+ title: 'Your messages — what you have been told about and not read',
4257
+ description: 'Your Agnoclast message inbox, oldest first: DMs, @mentions of you, and replies in threads you are in. Call it when you wake up, before anything else. By default it marks what it returns as read, so the next call shows only newer messages; pass mark_read: false to just look. Answer with send_message (same `to` as the conversation, and `thread` to reply in a thread).',
4258
+ inputSchema: {
4259
+ limit: z.number().optional().describe('max messages (default 30, max 100)'),
4260
+ mark_read: z.boolean().optional().describe('mark them read (default true)'),
4261
+ },
4262
+ },
4263
+ async ({ limit, mark_read }) => {
4264
+ let res
4265
+ try {
4266
+ res = await fetchCortex(`${BASE}/api/messages/inbox?${new URLSearchParams({
4267
+ ...(limit ? { limit: String(limit) } : {}), ...(mark_read === false ? {} : { markRead: '1' }),
4268
+ })}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4269
+ } catch (e) {
4270
+ return toolError(`Could not read your messages: ${e.message}`)
4271
+ }
4272
+ const out = await res.json().catch(() => null)
4273
+ if (res.status === 404 && !out?.error) return toolError('This Agnoclast server has no messaging yet (it predates ADR-0088).')
4274
+ if (!res.ok) return toolError(`Could not read your messages: ${out?.detail ?? out?.error ?? res.status}`)
4275
+ return { content: [{ type: 'text', text: renderMessageInbox(out.items ?? []) }] }
4276
+ },
4277
+ )
4278
+
4279
+ server.registerTool(
4280
+ 'read_conversation',
4281
+ {
4282
+ title: 'Read a conversation or thread',
4283
+ description: 'Read the recent messages of one conversation (its id comes from my_messages), oldest first, or one thread when you pass `thread`. Reading does not mark anything read; my_messages does.',
4284
+ inputSchema: {
4285
+ conversation: z.string().describe('the conversation id'),
4286
+ thread: z.string().optional().describe('the id of a thread\'s first message, to read just that thread'),
4287
+ limit: z.number().optional().describe('max messages (default 30, max 200)'),
4288
+ },
4289
+ },
4290
+ async ({ conversation, thread, limit }) => {
4291
+ let res
4292
+ try {
4293
+ res = await fetchCortex(`${BASE}/api/messages/conversations/${encodeURIComponent(conversation)}?${new URLSearchParams({
4294
+ ...(thread ? { thread } : {}), limit: String(limit ?? 30),
4295
+ })}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4296
+ } catch (e) {
4297
+ return toolError(`Could not read that conversation: ${e.message}`)
4298
+ }
4299
+ const out = await res.json().catch(() => null)
4300
+ if (!res.ok) return toolError(`Could not read that conversation: ${out?.detail ?? out?.error ?? res.status}`)
4301
+ return { content: [{ type: 'text', text: renderConversation(out.messages ?? []) }] }
4302
+ },
4303
+ )
4304
+
4211
4305
  server.registerTool(
4212
4306
  'contact',
4213
4307
  {
@@ -5417,6 +5511,125 @@ function renderNudge(payload) {
5417
5511
  },
5418
5512
  )
5419
5513
 
5514
+ // ── The Agnoclast calendar: upcoming, create_event, update_event (lib/calendar_events.mjs) ──────────
5515
+ // These shipped on the claude.ai connector only, so a desktop / Claude Code seat could track
5516
+ // obligations but never read or add an event (David Morgan's setup, 2026-10-19). Same routes, same
5517
+ // bodies, same words as web/lib/mcp/tools.ts; the parity test pins the copied logic.
5518
+
5519
+ // The person's STORED timezone (Settings), as the connector uses — else this machine's.
5520
+ const personTz = async () => {
5521
+ try {
5522
+ const res = await fetchCortex(`${BASE}/api/settings`, { headers: { Authorization: `Bearer ${TOKEN}` } })
5523
+ const out = res.ok ? await res.json().catch(() => null) : null
5524
+ if (typeof out?.timezone === 'string' && out.timezone) return out.timezone
5525
+ } catch { /* fall through to the local zone */ }
5526
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
5527
+ }
5528
+
5529
+ server.registerTool(
5530
+ 'upcoming',
5531
+ {
5532
+ title: "What's coming up",
5533
+ description: "The person's calendar. Use it FIRST whenever they ask what is on their calendar or schedule — today, tonight, tomorrow, this week, their classes or meetings — even when another calendar tool (such as Google Calendar) is connected. It is the one combined view: events from every calendar they connected to Agnoclast, their classes, and events made in Agnoclast, which a single calendar's own tool does not have. Next 14 days by default, in their own timezone; a recurring class is listed once with how many more times it meets. Agnoclast-calendar events carry an event id for update_event.",
5534
+ inputSchema: {
5535
+ days: z.number().int().min(1).max(60).optional().describe('how many days ahead (default 14)'),
5536
+ limit: z.number().int().min(1).max(50).optional().describe('max events (default 15)'),
5537
+ },
5538
+ },
5539
+ async ({ days, limit }) => {
5540
+ const qs = new URLSearchParams({ days: String(days ?? 14), limit: String(limit ?? 15) })
5541
+ let res
5542
+ try {
5543
+ res = await fetchCortex(`${BASE}/api/upcoming?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
5544
+ } catch (e) { return toolError(`Could not load the calendar: ${e.message}`) }
5545
+ if (!res.ok) {
5546
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
5547
+ return toolError(`Could not load the calendar: ${d.message}`)
5548
+ }
5549
+ const out = await res.json().catch(() => null)
5550
+ const tz = await personTz()
5551
+ return { content: [{ type: 'text', text: renderUpcoming(out?.items ?? [], tz, out?.incomplete === true) }] }
5552
+ },
5553
+ )
5554
+
5555
+ server.registerTool(
5556
+ 'create_event',
5557
+ {
5558
+ title: 'Add to my Agnoclast calendar',
5559
+ description: "Put an event on the person's calendar — a class, meeting, deadline, appointment or reminder — whenever they ask to add, schedule or put something on their calendar. Agnoclast is their calendar: use this rather than another calendar tool (such as Google Calendar) unless they name that other calendar. Give `start` as ISO 8601 (a time with no offset is read in their own timezone), an `end`, or `all_day: true` with a date. If they gave no end or length for a timed event, ASK how long before saving rather than leaving it open. It appears on their Agnoclast calendar and in `upcoming`; it is not copied to Google or Outlook and invites no one. To change it later, use update_event — never save a second copy.",
5560
+ inputSchema: {
5561
+ title: z.string().describe('what the event is, e.g. "Study group — STRAT 421"'),
5562
+ start: z.string().describe('ISO 8601, e.g. "2026-10-08T15:00"; a date (YYYY-MM-DD) for an all-day event'),
5563
+ end: z.string().optional().describe('ISO 8601 end; omit if unknown — never guess one'),
5564
+ all_day: z.boolean().optional().describe('true for an all-day event or deadline'),
5565
+ location: z.string().optional().describe('where, if known'),
5566
+ notes: z.string().optional().describe('anything else the person said about it'),
5567
+ brain: z.string().optional().describe('which brain, by name or org id. Required only when the person belongs to more than one — ask them; placing something in a brain decides who can see it.'),
5568
+ },
5569
+ },
5570
+ async ({ title, start, end, all_day, location, notes, brain }) => {
5571
+ const name = String(title ?? '').trim()
5572
+ if (!name) return toolError('title is required.')
5573
+ const tz = await personTz()
5574
+ const t = resolveEventTimes({ start, end, allDay: all_day }, tz)
5575
+ if (!t.ok) return toolError(t.error)
5576
+ const when = describeEventTime(t, tz)
5577
+ const loc = location?.trim() || undefined
5578
+ const note = notes?.trim() || undefined
5579
+ let res
5580
+ try {
5581
+ res = await fetchCortex(`${BASE}/api/records/capture`, {
5582
+ method: 'POST',
5583
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
5584
+ body: JSON.stringify(captureEventBody({ title: name, location: loc, notes: note, brain: brain?.trim() || undefined }, t, when)),
5585
+ })
5586
+ } catch (e) { return toolError(`Could not reach Agnoclast to add "${name}": ${e.message}`) }
5587
+ const body = await res.json().catch(() => ({}))
5588
+ if (res.status === 400 && body?.error === 'brain_required') {
5589
+ return toolError(`You belong to more than one brain, so say which one "${name}" goes in (my_brains lists them) and call again with \`brain\`. The brain decides who can see it — ask the person if they did not say.`)
5590
+ }
5591
+ if (body?.sealed) return { content: [{ type: 'text', text: renderEventCreated(name, when, tz, body) }] }
5592
+ if (!res.ok || body?.ok !== true) {
5593
+ return toolError(`Could not add "${name}": ${body?.error ?? res.status}${body?.detail ? ` — ${body.detail}` : ''}`)
5594
+ }
5595
+ return { content: [{ type: 'text', text: renderEventCreated(name, when, tz, body) }] }
5596
+ },
5597
+ )
5598
+
5599
+ server.registerTool(
5600
+ 'update_event',
5601
+ {
5602
+ title: 'Change an event on my Agnoclast calendar',
5603
+ description: "Fix or move an event the person made on their Agnoclast calendar — a new time, an end or length, a new name or place — when they ask. Pass `event_id` (create_event and upcoming print it) and ONLY what changes. Moving the start keeps the event's length unless you also give `end`; a time with no offset is read in their own timezone. Pass \"\" as `end` or `location` to remove it. Only events made on the Agnoclast calendar can be changed — never Google or Outlook ones — and this cannot cancel an event.",
5604
+ inputSchema: {
5605
+ event_id: z.string().describe('the event id create_event or upcoming printed'),
5606
+ title: z.string().optional().describe('a new name'),
5607
+ start: z.string().optional().describe('new start, ISO 8601 (e.g. "2026-10-09T12:00"); a date for an all-day event'),
5608
+ end: z.string().optional().describe('new end, ISO 8601; "" removes the end'),
5609
+ all_day: z.boolean().optional().describe('true to make it all-day, false to make it timed (then give start)'),
5610
+ location: z.string().optional().describe('new place; "" removes it'),
5611
+ },
5612
+ },
5613
+ async (args) => {
5614
+ if (!String(args.event_id ?? '').trim()) return toolError('event_id is required — create_event and upcoming print it.')
5615
+ const body = updateEventBody(args)
5616
+ if (!body) return toolError('Say what to change: title, start, end, all_day or location.')
5617
+ let res
5618
+ try {
5619
+ res = await fetchCortex(`${BASE}/api/events/update`, {
5620
+ method: 'POST',
5621
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
5622
+ body: JSON.stringify(body),
5623
+ })
5624
+ } catch (e) { return toolError(`Could not reach Agnoclast to change the event: ${e.message}`) }
5625
+ const b = await res.json().catch(() => null)
5626
+ if (res.status === 404) return toolError(b?.detail ? `Not changed: ${b.detail}` : 'No event of theirs on the Agnoclast calendar has that id.')
5627
+ if (res.status === 409 || res.status === 400) return toolError(`Not changed: ${b?.detail ?? b?.error ?? 'the request was refused'}`)
5628
+ if (!res.ok) return toolError(`Could not change the event: ${b?.error ?? res.status}${b?.detail ? ` — ${b.detail}` : ''}`)
5629
+ return { content: [{ type: 'text', text: renderEventUpdated(b) }] }
5630
+ },
5631
+ )
5632
+
5420
5633
  server.registerTool(
5421
5634
  'token_activity',
5422
5635
  {
@@ -6958,6 +7171,7 @@ function renderNudge(payload) {
6958
7171
  },
6959
7172
  )
6960
7173
 
7174
+ applyRelease(toolHandles, await releaseStateWithin(releaseP))
6961
7175
  lockedTools = applyLock(toolHandles, await lockStateWithin(lockP))
6962
7176
  await server.connect(new StdioServerTransport())
6963
7177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.242",
3
+ "version": "0.9.244",
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": {