@theronap/agnoclast-mcp 0.9.210 → 0.9.211
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/imessage_send.mjs +70 -80
- package/lib/server.mjs +20 -11
- package/package.json +1 -1
package/lib/imessage_send.mjs
CHANGED
|
@@ -1,88 +1,78 @@
|
|
|
1
|
-
// send_imessage —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
1
|
+
// send_imessage — ask to send a text from the user's own Mac, the same way request_send_email asks to
|
|
2
|
+
// send an email (ADR-0076).
|
|
3
|
+
//
|
|
4
|
+
// WHAT CHANGED, AND WHY. This tool used to text directly: osascript → Messages.app on whatever machine
|
|
5
|
+
// the MCP ran on, gated by CORTEX_IMESSAGE_SEND_ALLOWLIST and an out-of-band confirm secret. No
|
|
6
|
+
// approval screen, no history, no "did it go out?", and nothing written to Agnoclast — while email had
|
|
7
|
+
// all of it. Theron, 2026-10-05: "my texts are not being handled in the same way that emails are
|
|
8
|
+
// handled, and that seems like a failure."
|
|
9
|
+
//
|
|
10
|
+
// Now it creates a request on agnoclast.com (POST /api/texts/send-requests). The request waits on
|
|
11
|
+
// /email → Waiting for the user's OK, unless their send rule — the SAME rule email uses, set only on
|
|
12
|
+
// the web — lets it through. Once approved, the user's Mac (the Agnoclast desktop app) sends it through
|
|
13
|
+
// Messages and reports back. So:
|
|
14
|
+
// * this process never runs osascript and never sends anything itself;
|
|
15
|
+
// * the env allowlist and the confirm secret are gone — the web send rule replaced both, and an agent
|
|
16
|
+
// cannot loosen it;
|
|
17
|
+
// * without send:true it is still a draft preview, exactly as before, so existing callers that rely on
|
|
18
|
+
// draft-by-default see no change in that path.
|
|
19
|
+
//
|
|
20
|
+
// ⚠ THE POST IS NOT IDEMPOTENT. Never retried: a 5xx may mean the request was created.
|
|
11
21
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
set targetMessage to item 2 of argv
|
|
16
|
-
tell application "Messages"
|
|
17
|
-
set targetService to 1st account whose service type = iMessage
|
|
18
|
-
set targetBuddy to participant targetId of targetService
|
|
19
|
-
send targetMessage to targetBuddy
|
|
20
|
-
end tell
|
|
21
|
-
end run`
|
|
22
|
-
|
|
23
|
-
export function phoneKey(raw) {
|
|
24
|
-
const d = String(raw).replace(/[^0-9]/g, '')
|
|
25
|
-
return d.length < 7 ? null : d.slice(-10)
|
|
26
|
-
}
|
|
27
|
-
function handleKey(h) {
|
|
28
|
-
return h.includes('@') ? h.trim().toLowerCase() : phoneKey(h)
|
|
29
|
-
}
|
|
30
|
-
export function loadSendAllowlist(raw = process.env.CORTEX_IMESSAGE_SEND_ALLOWLIST ?? '') {
|
|
31
|
-
const set = new Set()
|
|
32
|
-
for (const item of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
33
|
-
set.add(item.includes('@') ? item.toLowerCase() : (phoneKey(item) ?? item))
|
|
34
|
-
}
|
|
35
|
-
return set
|
|
22
|
+
/** PURE. The draft preview — unchanged in spirit from the old tool. */
|
|
23
|
+
export function draftText({ recipient, message }) {
|
|
24
|
+
return `📝 DRAFT (not sent) — to ${recipient}:\n${message}\n\n(Call send_imessage again with send:true to ask the user to approve it.)`
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function sendViaOsascript(recipient, message) {
|
|
57
|
-
return new Promise((resolve) => {
|
|
58
|
-
let err = ''
|
|
59
|
-
let proc
|
|
60
|
-
try {
|
|
61
|
-
proc = spawn('osascript', ['-', String(recipient), String(message)], { stdio: ['pipe', 'ignore', 'pipe'] })
|
|
62
|
-
} catch (e) {
|
|
63
|
-
resolve({ ok: false, error: String(e) }); return
|
|
27
|
+
/**
|
|
28
|
+
* PURE. The server's answer → what the agent is told. Mirrors request_send_email's contract exactly:
|
|
29
|
+
* `reviewUrl` PRESENT means pending, and the hand-off sentence is relayed verbatim; ABSENT means the
|
|
30
|
+
* user's send rule already approved it, and there is nothing to approve.
|
|
31
|
+
*
|
|
32
|
+
* Returns { text, isError }.
|
|
33
|
+
*/
|
|
34
|
+
export function textRequestReply(status, body, recipient) {
|
|
35
|
+
const msg = typeof body?.error === 'string' ? body.error : null
|
|
36
|
+
if (status === 201 || (status >= 200 && status < 300)) {
|
|
37
|
+
const reviewUrl = body?.reviewUrl
|
|
38
|
+
if (reviewUrl) {
|
|
39
|
+
return { isError: false, text:
|
|
40
|
+
`Text request created to ${recipient}. It is NOT sent yet. ` +
|
|
41
|
+
`Tell the user, in these words: It's waiting for your OK: ${reviewUrl}` }
|
|
64
42
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
43
|
+
const state = body?.request?.state ?? 'approved'
|
|
44
|
+
return { isError: false, text:
|
|
45
|
+
`Approved by the user's send rule — nothing to approve (state: ${state}). Their Mac sends it through ` +
|
|
46
|
+
`Messages the next time it checks in, usually within a minute. Do not offer an approval link, and do ` +
|
|
47
|
+
`not send it again; the user can see how it went on Email → History in Agnoclast.` }
|
|
48
|
+
}
|
|
49
|
+
if (body?.code === 'no_text_sender') {
|
|
50
|
+
return { isError: true, text: `${msg} Nothing was stored, so nothing will be sent.` }
|
|
51
|
+
}
|
|
52
|
+
if (status >= 500) {
|
|
53
|
+
return { isError: true, text:
|
|
54
|
+
`${msg ?? `Agnoclast could not finish that (${status}).`} ⚠ The request MAY have been created anyway. ` +
|
|
55
|
+
`Do NOT call send_imessage again — ask the user to check Email in Agnoclast.` }
|
|
56
|
+
}
|
|
57
|
+
return { isError: true, text: msg ?? `send_imessage failed (${status}).` }
|
|
71
58
|
}
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Tool entry. `request` is the injected POST (fetchCortex with retries off, in server.mjs), so this
|
|
62
|
+
* stays testable without a network.
|
|
63
|
+
*/
|
|
64
|
+
export async function runSendImessage({ recipient, message, send }, request) {
|
|
65
|
+
if (!recipient || !String(recipient).trim()) return { isError: true, text: 'Name who to text: a phone number or an iMessage email address.' }
|
|
66
|
+
if (!message || !String(message).trim()) return { isError: true, text: 'The text is empty.' }
|
|
67
|
+
if (send !== true) return { isError: false, text: draftText({ recipient, message }) }
|
|
68
|
+
let res
|
|
69
|
+
try {
|
|
70
|
+
res = await request({ to: String(recipient).trim(), text: String(message) })
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { isError: true, text:
|
|
73
|
+
`Could not confirm the text request: ${e?.message ?? e} ⚠ It may have been created anyway. ` +
|
|
74
|
+
`Do NOT call send_imessage again — ask the user to check Email in Agnoclast.` }
|
|
85
75
|
}
|
|
86
|
-
const
|
|
87
|
-
return
|
|
76
|
+
const body = await res.json().catch(() => null)
|
|
77
|
+
return textRequestReply(res.status, body, recipient)
|
|
88
78
|
}
|
package/lib/server.mjs
CHANGED
|
@@ -5869,23 +5869,32 @@ function renderNudge(payload) {
|
|
|
5869
5869
|
},
|
|
5870
5870
|
)
|
|
5871
5871
|
|
|
5872
|
-
// send_imessage —
|
|
5873
|
-
//
|
|
5872
|
+
// send_imessage — ask to send a text from the user's own Mac (ADR-0076). It no longer texts from this
|
|
5873
|
+
// machine: it creates a request on Agnoclast that waits on /email → Waiting for the user's OK (or their
|
|
5874
|
+
// web send rule approves it), and the user's Mac — the desktop app, through Messages — sends it and
|
|
5875
|
+
// reports back. The same contract as request_send_email: reviewUrl PRESENT = pending, relayed verbatim.
|
|
5876
|
+
//
|
|
5877
|
+
// ⚠ NOT IDEMPOTENT: `idempotent: false, retries: 0`, exactly as request_send_email, so a widened
|
|
5878
|
+
// fetchCortex default can never make sending a text retriable.
|
|
5874
5879
|
server.registerTool(
|
|
5875
5880
|
'send_imessage',
|
|
5876
5881
|
{
|
|
5877
|
-
title: '
|
|
5878
|
-
description: '
|
|
5882
|
+
title: 'Ask to send a text (iMessage) from the user\'s Mac',
|
|
5883
|
+
description: 'Text someone from the USER\'S OWN Mac, through Messages. Without send:true this only returns a draft preview. With send:true it creates a request that WAITS for the user to approve it in Agnoclast (Email → Waiting), unless their own send rule allows it; once approved, their Mac sends it within about a minute. The text is frozen when the request is made, so write the final wording, and show the user what you are about to send first. Returns an approval link to relay, or confirmation that their rule approved it. ⚠ NEVER call it twice with send:true for the same text — it is not idempotent.',
|
|
5879
5884
|
inputSchema: {
|
|
5880
|
-
recipient: z.string().describe('phone number (e.g. +18015551234) or iMessage email'),
|
|
5881
|
-
message: z.string().describe('the
|
|
5882
|
-
send: z.boolean().optional().describe('
|
|
5883
|
-
confirm: z.string().optional().describe('
|
|
5885
|
+
recipient: z.string().describe('one phone number (e.g. +18015551234, or (801) 555-1234 for a US number) or iMessage email address'),
|
|
5886
|
+
message: z.string().describe('the text to send'),
|
|
5887
|
+
send: z.boolean().optional().describe('true to ask the user to approve sending it; omit or false for a draft preview only'),
|
|
5888
|
+
confirm: z.string().optional().describe('NO LONGER USED — approval happens in Agnoclast now. Ignored.'),
|
|
5884
5889
|
},
|
|
5885
5890
|
},
|
|
5886
|
-
async ({ recipient, message, send
|
|
5887
|
-
const
|
|
5888
|
-
|
|
5891
|
+
async ({ recipient, message, send }) => {
|
|
5892
|
+
const r = await runSendImessage({ recipient, message, send }, (body) => fetchCortex(`${BASE}/api/texts/send-requests`, {
|
|
5893
|
+
method: 'POST',
|
|
5894
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
5895
|
+
body: JSON.stringify(body),
|
|
5896
|
+
}, { idempotent: false, retries: 0 }))
|
|
5897
|
+
return r.isError ? toolError(r.text) : { content: [{ type: 'text', text: r.text }] }
|
|
5889
5898
|
},
|
|
5890
5899
|
)
|
|
5891
5900
|
|
package/package.json
CHANGED