@theronap/agnoclast-mcp 0.9.194 → 0.9.195

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,36 @@
1
+ // A brain that pays for its members' seats (ADR-0072) refuses a member-add until the NEW monthly
2
+ // total has been confirmed: POST /api/invite answers 409 with
3
+ // { code: 'seat_charge_unconfirmed', seatMonthlyTotal, seats, error }.
4
+ // The confirmation is a recurring charge on the brain owner's card, so the tool must be able to tell
5
+ // this apart from every other refusal and put the question to the person instead of reporting an
6
+ // error. Pure, so it can be tested without a server.
7
+
8
+ /**
9
+ * @param {number} status HTTP status of the refused add
10
+ * @param {string} raw the response body, unparsed
11
+ * @returns {{ total: number, seats: number | null } | null} null for any other refusal
12
+ */
13
+ export function seatChargeRefusal(status, raw) {
14
+ if (status !== 409) return null
15
+ let body
16
+ try { body = JSON.parse(raw) } catch { return null }
17
+ if (!body || body.code !== 'seat_charge_unconfirmed') return null
18
+ const total = body.seatMonthlyTotal
19
+ // No usable number means there is nothing a person could confirm; fall through to the plain error
20
+ // rather than ask them to agree to "$undefined/month".
21
+ if (typeof total !== 'number' || !Number.isFinite(total) || total < 0) return null
22
+ const seats = typeof body.seats === 'number' && Number.isFinite(body.seats) ? body.seats : null
23
+ return { total, seats }
24
+ }
25
+
26
+ /** What the caller is told. `confirmed` is the total it passed, if any — then that total was stale. */
27
+ export function seatChargeQuestion({ brainName, email, total, seats, confirmed }) {
28
+ return [
29
+ `${brainName} pays for its members' seats, so adding ${email} changes what its owner is charged.`,
30
+ `New total: $${total}/month${seats ? ` for ${seats} ${seats === 1 ? 'seat' : 'seats'}` : ''} ($10/month for each member), charged to the brain's owner.`,
31
+ ...(typeof confirmed === 'number' ? [`The total you confirmed ($${confirmed}) is not the current one — the membership changed in between.`] : []),
32
+ '',
33
+ 'NOTHING HAS HAPPENED: nobody was added and nothing was charged.',
34
+ `Ask the user whether to accept $${total}/month. Only if they say yes, re-run with the same arguments plus confirm_seat_total: ${total}. Do not confirm it on their behalf.`,
35
+ ].join('\n')
36
+ }
package/lib/server.mjs CHANGED
@@ -5,6 +5,7 @@ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
5
5
  import { homedir } from 'os'
6
6
  import { join } from 'path'
7
7
  import { createHash, randomUUID } from 'crypto'
8
+ import { seatChargeRefusal, seatChargeQuestion } from './seat_charge.mjs'
8
9
  import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
9
10
  import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
10
11
  import { folderClaimLine } from './folder_claims.mjs'
@@ -3900,7 +3901,7 @@ function renderNudge(payload) {
3900
3901
  'add_to_brain',
3901
3902
  {
3902
3903
  title: 'Add someone to one of your brains',
3903
- description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. Use when asked to invite/add someone to a brain.",
3904
+ description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. IF THE BRAIN PAYS FOR ITS MEMBERS' SEATS, adding someone raises its owner's monthly charge: the first execute is refused with the new total, you ASK THE USER whether to accept that charge, and only on a yes re-run with confirm_seat_total set to that number. Use when asked to invite/add someone to a brain.",
3904
3905
  inputSchema: {
3905
3906
  email: z.string().describe("the person's email address — the login their Agnoclast account is (or will be) on"),
3906
3907
  name: z.string().optional().describe('their full name; falls back to the email local-part'),
@@ -3909,9 +3910,10 @@ function renderNudge(payload) {
3909
3910
  brain: z.string().optional().describe('which brain, by name or org id. Optional when planning; REQUIRED with execute:true. Pass the org id when a name matches more than one of your brains'),
3910
3911
  execute: z.boolean().optional().describe('default false. false = report the plan only. true = actually add them — not undoable through the API, and requires an explicit brain'),
3911
3912
  inherit_page_id: z.string().optional().describe("an existing PERSON page in that brain that becomes their user page (ADR-0023). The plan lists candidates; ask the user which, or omit for a new page"),
3913
+ confirm_seat_total: z.number().optional().describe("ONLY after an execute was refused because this brain pays for its members' seats: the new monthly total in dollars that the refusal named, and ONLY once the user has said yes to that charge in this conversation. Never guess it, never pass it on a first attempt — it puts a recurring charge on the brain owner's card"),
3912
3914
  },
3913
3915
  },
3914
- async ({ email, name, title, role, brain, execute, inherit_page_id }) => {
3916
+ async ({ email, name, title, role, brain, execute, inherit_page_id, confirm_seat_total }) => {
3915
3917
  const addr = (email ?? '').trim().toLowerCase()
3916
3918
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
3917
3919
 
@@ -4003,13 +4005,29 @@ function renderNudge(payload) {
4003
4005
  done = await fetchCortex(`${BASE}/api/invite`, {
4004
4006
  method: 'POST',
4005
4007
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4006
- body: JSON.stringify({ ...payload, brain: target.orgId, ...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}) }),
4008
+ body: JSON.stringify({
4009
+ ...payload,
4010
+ brain: target.orgId,
4011
+ ...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}),
4012
+ // ADR-0072 §3.5. Sent only when the caller passed it: the server compares it with the
4013
+ // total THIS add produces and refuses anything else, so a stale or invented number
4014
+ // cannot charge anyone.
4015
+ ...(typeof confirm_seat_total === 'number' ? { confirmSeatTotal: confirm_seat_total } : {}),
4016
+ }),
4007
4017
  })
4008
4018
  } catch (e) {
4009
4019
  return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
4010
4020
  }
4011
4021
  if (!done.ok) {
4012
- const d = classify(done.status, done.headers.get('content-type'), await done.text(), done.headers.get('x-vercel-id'))
4022
+ const raw = await done.text()
4023
+ // A brain that pays for its members' seats refuses the add until the new monthly total is
4024
+ // confirmed (ADR-0072 §3.5). That is not a failure to report and move on from — it is a
4025
+ // question only the person can answer, because the answer is a charge on the owner's card.
4026
+ const seat = seatChargeRefusal(done.status, raw)
4027
+ if (seat) {
4028
+ return toolError(seatChargeQuestion({ brainName: target.name, email: payload.email, total: seat.total, seats: seat.seats, confirmed: confirm_seat_total }))
4029
+ }
4030
+ const d = classify(done.status, done.headers.get('content-type'), raw, done.headers.get('x-vercel-id'))
4013
4031
  return toolError(`Could not add ${payload.email} to ${target.name}: ${d.message}`)
4014
4032
  }
4015
4033
  const r = await done.json().catch(() => ({}))
@@ -4024,7 +4042,10 @@ function renderNudge(payload) {
4024
4042
  const page = r.userPage?.status
4025
4043
  ? `\nTheir page in ${target.name}: ${r.userPage.status}${r.userPage.error ? ` (${r.userPage.error})` : ''}.`
4026
4044
  : ''
4027
- return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}${page}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
4045
+ // What the seat sync did, in the server's words: the new monthly total, or that the seat
4046
+ // could NOT be charged and the brain is not paying for them yet.
4047
+ const seatLine = typeof r.seatNote === 'string' && r.seatNote ? `\n${r.seatNote}` : ''
4048
+ return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}${page}${seatLine}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
4028
4049
  }
4029
4050
 
4030
4051
  // ADR-0023: does this brain already have a page about them? The inviter decides — list, never pick.
@@ -4052,6 +4073,8 @@ function renderNudge(payload) {
4052
4073
  ` manager you [${target.userId}] (they are placed under you)`,
4053
4074
  ...pageLines,
4054
4075
  '',
4076
+ "If this brain pays for its members' seats, the first execute is refused with the new monthly total; ask the user before confirming it.",
4077
+ '',
4055
4078
  'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
4056
4079
  `or do it yourself at ${BASE}/?invite=1 with these values:`,
4057
4080
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.194",
3
+ "version": "0.9.195",
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": {