@askeleven/sms-guard 0.1.0

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/src/index.js ADDED
@@ -0,0 +1,420 @@
1
+ import { countSegments, ucs2Characters, replaceTypographic, LIMITS } from './segments.js'
2
+ import { splitMessage, DEFAULT_MAX_SEGMENTS } from './split.js'
3
+ import {
4
+ parseNumber,
5
+ zonesFor,
6
+ parseWindow,
7
+ inWindow,
8
+ localTime,
9
+ nextWindowStart,
10
+ } from './phone.js'
11
+ import { SHORTENER_DOMAINS } from './data/shorteners.js'
12
+
13
+ export { encodingOf, countSegments, ucs2Characters, LIMITS } from './segments.js'
14
+ export { splitMessage, DEFAULT_MAX_SEGMENTS } from './split.js'
15
+ export { parseNumber } from './phone.js'
16
+
17
+ /**
18
+ * @typedef {import('./segments.js').Encoding} Encoding
19
+ * @typedef {import('./segments.js').SegmentCount} SegmentCount
20
+ * @typedef {import('./segments.js').Ucs2Character} Ucs2Character
21
+ * @typedef {import('./split.js').Part} Part
22
+ * @typedef {import('./split.js').SplitOptions} SplitOptions
23
+ * @typedef {import('./phone.js').LocalTime} LocalTime
24
+ *
25
+ * @typedef {'keep'|'review'|'fail'} Verdict
26
+ *
27
+ * @typedef {'empty'|'too_many_segments'|'invalid_number'|'quiet_hours'
28
+ * |'timezone_ambiguous'|'timezone_unknown'|'url_shortener'|'no_opt_out'
29
+ * |'ucs2'} ReasonCode
30
+ *
31
+ * @typedef {object} Reason
32
+ * @property {ReasonCode} code Stable, safe to match on.
33
+ * @property {Verdict} verdict What this reason alone would make of the message.
34
+ * @property {string} detail Plain English.
35
+ *
36
+ * @typedef {object} Recipient
37
+ * @property {string} input As given.
38
+ * @property {string | null} number E.164, or null when it could not be read.
39
+ * @property {boolean} assumedNanp Read as +1 because it had no country code and
40
+ * 10 digits (or 11 starting with 1).
41
+ * @property {string | null} region State, province or country, when known.
42
+ * @property {string[]} timezones Every IANA zone the number could be in.
43
+ * @property {string | null} timezone The zone, when there is exactly one.
44
+ * @property {Array<LocalTime & { inWindow: boolean }>} local Local send time in each
45
+ * of the zones.
46
+ * @property {boolean | null} inWindow Whether the send time is inside the window in
47
+ * every zone; null when no zone is known.
48
+ * @property {string | null} nextWindowStart ISO 8601, the first minute after the send
49
+ * time that is inside the window in every zone, when the send time is not.
50
+ *
51
+ * @typedef {object} Facts
52
+ * @property {Encoding} encoding
53
+ * @property {number} characters Unicode code points.
54
+ * @property {number} units Septets for GSM-7, UTF-16 code units for UCS-2.
55
+ * @property {number} segments
56
+ * @property {number} perSegment Units per segment at this length.
57
+ * @property {number} maxSegments
58
+ * @property {Ucs2Character[]} ucs2Characters What forces UCS-2, if anything.
59
+ * @property {string} sendAt ISO 8601.
60
+ * @property {string} window HH:MM-HH:MM, recipient local time.
61
+ * @property {'checked'|'skipped'} timeCheck Whether the send time was checked
62
+ * against the recipient's local time. Skipped with no recipient, or when their
63
+ * time zone is not known.
64
+ * @property {Recipient | null} recipient
65
+ *
66
+ * @typedef {object} Result
67
+ * @property {Verdict} verdict The worst verdict among the reasons; 'keep' with none.
68
+ * @property {Reason[]} reasons
69
+ * @property {Facts} facts
70
+ * @property {string[]} notes What the reader needs to know to trust the result,
71
+ * such as checks that did not run.
72
+ *
73
+ * @typedef {object} CheckOptions
74
+ * @property {string} [to] Recipient phone number, E.164 preferred.
75
+ * @property {Date | string | number} [sendAt] When it will be sent. A string must be
76
+ * ISO 8601 with an offset or Z. Default now.
77
+ * @property {number} [maxSegments] Fail above this many segments. Default 5.
78
+ * @property {string} [window] Allowed local send time, HH:MM-HH:MM, start inclusive,
79
+ * end exclusive. Default 08:00-21:00.
80
+ * @property {boolean} [firstMessage] The first text to this person: look for opt-out
81
+ * wording. Default false.
82
+ * @property {Date | (() => Date)} [now] The clock, for tests.
83
+ */
84
+
85
+ export const DEFAULT_WINDOW = '08:00-21:00'
86
+
87
+ const VERDICT_ORDER = { keep: 0, review: 1, fail: 2 }
88
+
89
+ export class GuardError extends Error {
90
+ /**
91
+ * @param {'invalid_option'} code
92
+ * @param {string} message
93
+ */
94
+ constructor(code, message) {
95
+ super(message)
96
+ this.name = 'GuardError'
97
+ this.code = code
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Checks one text message before it is sent: how many segments it will be, what
103
+ * forces it into UCS-2, whether it lands inside the recipient's allowed hours, and
104
+ * a few things carriers and recipients object to. Runs locally, touches no network.
105
+ *
106
+ * @param {string} text
107
+ * @param {CheckOptions} [options]
108
+ * @returns {Result}
109
+ */
110
+ export function checkMessage(text, options = {}) {
111
+ const {
112
+ to,
113
+ maxSegments = DEFAULT_MAX_SEGMENTS,
114
+ window = DEFAULT_WINDOW,
115
+ firstMessage = false,
116
+ } = options
117
+ if (!Number.isInteger(maxSegments) || maxSegments < 1) {
118
+ throw new GuardError('invalid_option', `maxSegments must be a whole number of 1 or more, got ${maxSegments}.`)
119
+ }
120
+ const win = parseWindow(window)
121
+ if (!win) {
122
+ throw new GuardError('invalid_option', `window must be HH:MM-HH:MM, such as ${DEFAULT_WINDOW}, got ${window}.`)
123
+ }
124
+ const sendAt = resolveSendAt(options.sendAt, options.now)
125
+
126
+ const body = String(text ?? '')
127
+ const count = countSegments(body)
128
+ const ucs2 = ucs2Characters(body)
129
+
130
+ /** @type {Reason[]} */
131
+ const reasons = []
132
+ /** @type {string[]} */
133
+ const notes = []
134
+ const add = (/** @type {ReasonCode} */ code, /** @type {Verdict} */ verdict, /** @type {string} */ detail) =>
135
+ reasons.push({ code, verdict, detail })
136
+
137
+ if (!body.trim()) {
138
+ add('empty', 'fail', 'The message is empty. Nothing would arrive, or a blank text would.')
139
+ } else {
140
+ if (count.segments > maxSegments) {
141
+ add('too_many_segments', 'fail', segmentsDetail(body, count, ucs2, maxSegments))
142
+ }
143
+ if (ucs2.length > 0) add('ucs2', 'keep', ucs2Detail(body, count, ucs2))
144
+
145
+ const shorteners = findShorteners(body)
146
+ if (shorteners.length > 0) {
147
+ add(
148
+ 'url_shortener',
149
+ 'review',
150
+ `Links on ${shorteners.join(', ')}: carriers filter public shorteners, since anyone ` +
151
+ 'can hide anything behind one, and the text can be dropped without an error. ' +
152
+ 'Use the full link, or a short link on your own domain.',
153
+ )
154
+ }
155
+ if (firstMessage && !hasOptOut(body)) {
156
+ add(
157
+ 'no_opt_out',
158
+ 'review',
159
+ 'This is the first text to this person and it does not say how to stop them, such ' +
160
+ 'as "Reply STOP to opt out" (or "Responde STOP para no recibir más mensajes"). ' +
161
+ 'Carriers and the recipient both expect it on a first message.',
162
+ )
163
+ }
164
+ }
165
+
166
+ /** @type {Facts['timeCheck']} */
167
+ let timeCheck = 'skipped'
168
+ /** @type {Recipient | null} */
169
+ let recipient = null
170
+ if (to === undefined || to === null || String(to).trim() === '') {
171
+ notes.push('No recipient was given, so the send time was not checked against their local time.')
172
+ } else {
173
+ ;({ recipient, timeCheck } = checkRecipient(String(to), sendAt, win, add, notes))
174
+ }
175
+
176
+ const verdict = reasons.reduce(
177
+ (worst, r) => (VERDICT_ORDER[r.verdict] > VERDICT_ORDER[worst] ? r.verdict : worst),
178
+ /** @type {Verdict} */ ('keep'),
179
+ )
180
+
181
+ return {
182
+ verdict,
183
+ reasons,
184
+ facts: {
185
+ encoding: count.encoding,
186
+ characters: count.characters,
187
+ units: count.units,
188
+ segments: count.segments,
189
+ perSegment: count.perSegment,
190
+ maxSegments,
191
+ ucs2Characters: ucs2,
192
+ sendAt: sendAt.toISOString(),
193
+ window: win.label,
194
+ timeCheck,
195
+ recipient,
196
+ },
197
+ notes,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * @param {string} to
203
+ * @param {Date} sendAt
204
+ * @param {import('./phone.js').Window} win
205
+ * @param {(code: ReasonCode, verdict: Verdict, detail: string) => void} add
206
+ * @param {string[]} notes
207
+ * @returns {{ recipient: Recipient, timeCheck: Facts['timeCheck'] }}
208
+ */
209
+ function checkRecipient(to, sendAt, win, add, notes) {
210
+ /** @type {Recipient} */
211
+ const recipient = {
212
+ input: to,
213
+ number: null,
214
+ assumedNanp: false,
215
+ region: null,
216
+ timezones: [],
217
+ timezone: null,
218
+ local: [],
219
+ inWindow: null,
220
+ nextWindowStart: null,
221
+ }
222
+
223
+ const parsed = parseNumber(to)
224
+ if (!parsed.ok) {
225
+ add('invalid_number', 'fail', parsed.error)
226
+ return { recipient, timeCheck: 'skipped' }
227
+ }
228
+ recipient.number = parsed.number.e164
229
+ recipient.assumedNanp = parsed.number.assumedNanp
230
+ if (parsed.number.assumedNanp) {
231
+ notes.push(`${to} had no country code, so it was read as a US or Canadian number, ${parsed.number.e164}.`)
232
+ }
233
+
234
+ const { region, zones, basis } = zonesFor(parsed.number)
235
+ recipient.region = region
236
+ recipient.timezones = zones
237
+ recipient.timezone = zones.length === 1 ? zones[0] : null
238
+
239
+ if (zones.length === 0) {
240
+ add(
241
+ 'timezone_unknown',
242
+ 'review',
243
+ `${basis} Its local time is unknown, so whether ${sendAt.toISOString()} is inside ` +
244
+ `${win.label} there could not be checked.`,
245
+ )
246
+ return { recipient, timeCheck: 'skipped' }
247
+ }
248
+
249
+ recipient.local = zones.map((z) => {
250
+ const t = localTime(sendAt, z)
251
+ return { ...t, inWindow: inWindow(t.minutes, win) }
252
+ })
253
+ const inside = recipient.local.filter((t) => t.inWindow)
254
+ recipient.inWindow = inside.length === recipient.local.length
255
+ if (!recipient.inWindow) {
256
+ recipient.nextWindowStart = nextWindowStart(sendAt, zones, win)?.toISOString() ?? null
257
+ }
258
+ const where = region ? ` (${region})` : ''
259
+ const times = recipient.local.map((t) => `${t.label} in ${t.timezone}`).join(', ')
260
+ let next = ''
261
+ if (recipient.nextWindowStart) {
262
+ const opens = new Date(recipient.nextWindowStart)
263
+ const at = zones.map((z) => localTime(opens, z).label).join(', ')
264
+ next =
265
+ zones.length === 1
266
+ ? ` The window next opens at ${recipient.nextWindowStart} (${at}).`
267
+ : ` The earliest time inside it in every one of them is ${recipient.nextWindowStart} (${at}).`
268
+ }
269
+
270
+ if (inside.length === 0) {
271
+ add(
272
+ 'quiet_hours',
273
+ 'fail',
274
+ `It would arrive at ${times}, outside ${win.label}. The federal TCPA window for ` +
275
+ 'telephone solicitations, which covers texts, is 8am to 9pm at the recipient\'s ' +
276
+ 'location, and some states are stricter. Schedule it for the recipient\'s ' +
277
+ `morning instead.${next}`,
278
+ )
279
+ } else if (zones.length > 1 && inside.length < zones.length) {
280
+ add(
281
+ 'timezone_ambiguous',
282
+ 'review',
283
+ `${parsed.number.e164}${where} could be in ${zones.length} time zones and the number ` +
284
+ `alone does not say which. It would arrive at ${times}: inside ${win.label} in ` +
285
+ `some, outside it in others.${next}`,
286
+ )
287
+ } else if (zones.length > 1) {
288
+ notes.push(
289
+ `${parsed.number.e164}${where} could be in ${zones.join(', ')}. The send time is ` +
290
+ `inside ${win.label} in every one of them.`,
291
+ )
292
+ }
293
+ return { recipient, timeCheck: 'checked' }
294
+ }
295
+
296
+ /**
297
+ * @param {Date | string | number | undefined} sendAt
298
+ * @param {Date | (() => Date) | undefined} now
299
+ * @returns {Date}
300
+ */
301
+ function resolveSendAt(sendAt, now) {
302
+ if (sendAt === undefined || sendAt === null) {
303
+ const d = typeof now === 'function' ? now() : (now ?? new Date())
304
+ return new Date(d.getTime())
305
+ }
306
+ if (typeof sendAt === 'string') {
307
+ const iso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i
308
+ if (!iso.test(sendAt.trim())) {
309
+ throw new GuardError(
310
+ 'invalid_option',
311
+ `sendAt must be ISO 8601 with an offset or Z, such as 2026-09-27T14:30:00Z, got ${sendAt}.`,
312
+ )
313
+ }
314
+ }
315
+ const date = sendAt instanceof Date ? new Date(sendAt.getTime()) : new Date(sendAt)
316
+ if (Number.isNaN(date.getTime())) {
317
+ throw new GuardError('invalid_option', `sendAt is not a valid time: ${String(sendAt)}.`)
318
+ }
319
+ return date
320
+ }
321
+
322
+ /**
323
+ * @param {string} body
324
+ * @param {SegmentCount} count
325
+ * @param {Ucs2Character[]} ucs2
326
+ * @param {number} maxSegments
327
+ * @returns {string}
328
+ */
329
+ function segmentsDetail(body, count, ucs2, maxSegments) {
330
+ const unitName = count.encoding === 'GSM-7' ? 'septets' : 'UTF-16 units'
331
+ let detail =
332
+ `${count.segments} segments (${count.units} ${unitName}, ${count.perSegment} per ` +
333
+ `segment in ${count.encoding}), over the limit of ${maxSegments}. Providers usually ` +
334
+ 'accept a long message and bill every segment, but some carriers refuse long ' +
335
+ 'multi-part messages later and the recipient never gets it, so a success response ' +
336
+ 'from the API tells you nothing.'
337
+ if (ucs2.length > 0 && ucs2.every((c) => c.replacement !== null)) {
338
+ const gsm = countSegments(replaceTypographic(body))
339
+ detail +=
340
+ ` Replacing the typographic characters listed would make it GSM-7, ${gsm.segments} ` +
341
+ `segment${gsm.segments === 1 ? '' : 's'}.`
342
+ }
343
+ const parts = splitMessage(body, { maxSegments }).length
344
+ detail += ` Send it as ${parts} shorter messages instead (splitMessage, or --split), or cut it.`
345
+ return detail
346
+ }
347
+
348
+ /**
349
+ * @param {string} body
350
+ * @param {SegmentCount} count
351
+ * @param {Ucs2Character[]} ucs2
352
+ * @returns {string}
353
+ */
354
+ function ucs2Detail(body, count, ucs2) {
355
+ const list = ucs2.map((c) => `${displayChar(c)} (${c.count})`).join(', ')
356
+ const replaceable = ucs2.filter((c) => c.replacement !== null)
357
+ const letters = ucs2.filter((c) => c.replacement === null)
358
+ let detail =
359
+ `UCS-2, because of ${list}. UCS-2 fits ${LIMITS['UCS-2'].single} characters in one ` +
360
+ `segment instead of ${LIMITS['GSM-7'].single}, and ${LIMITS['UCS-2'].part} per part ` +
361
+ `instead of ${LIMITS['GSM-7'].part}.`
362
+ if (replaceable.length > 0) {
363
+ const swaps = replaceable
364
+ .map((c) => `${displayChar(c)} with ${c.replacement === '' ? 'nothing' : `"${c.replacement}"`}`)
365
+ .join(', ')
366
+ detail += ` Typographic characters can be swapped for GSM-7 ones that read the same: ${swaps}.`
367
+ if (letters.length === 0) {
368
+ const gsm = countSegments(replaceTypographic(body))
369
+ detail += ` That makes it GSM-7, ${gsm.segments} segment${gsm.segments === 1 ? '' : 's'} instead of ${count.segments}.`
370
+ }
371
+ }
372
+ if (letters.length > 0) {
373
+ detail +=
374
+ ' Letters are never replaced: they are the words. A message in Spanish will ' +
375
+ 'usually be UCS-2, and that is fine; just budget segments for it.'
376
+ }
377
+ return detail
378
+ }
379
+
380
+ /**
381
+ * Shows invisible and whitespace characters by code point, the rest as themselves.
382
+ *
383
+ * @param {Ucs2Character} c
384
+ * @returns {string}
385
+ */
386
+ export function displayChar(c) {
387
+ return /^[\s\p{Cf}\p{Cc}]$/u.test(c.char) ? c.codePoint : `${c.char} ${c.codePoint}`
388
+ }
389
+
390
+ const SHORTENER_PATTERN = new RegExp(
391
+ `(?<![\\p{L}\\p{N}.@-])(?:[a-z0-9-]+\\.)*(${[...SHORTENER_DOMAINS]
392
+ .map((d) => d.replace(/\./g, '\\.'))
393
+ .join('|')})(?![\\p{L}\\p{N}-]|\\.[\\p{L}\\p{N}])`,
394
+ 'giu',
395
+ )
396
+
397
+ /**
398
+ * @param {string} text
399
+ * @returns {string[]} Shortener domains linked to, each once.
400
+ */
401
+ function findShorteners(text) {
402
+ return [...new Set([...text.matchAll(SHORTENER_PATTERN)].map((m) => m[1].toLowerCase()))]
403
+ }
404
+
405
+ /** Opt-out wording, in English and Spanish. */
406
+ const OPT_OUT_PATTERNS = [
407
+ // A keyword in capitals is an instruction, not a word in a sentence.
408
+ /\b(?:STOP|STOPALL|UNSUBSCRIBE|BAJA|ALTO|CANCELAR)\b/,
409
+ /\b(?:reply|text|txt|send|respond|responde|responda|contesta|conteste|env[ií]a|escribe|escriba)\s+(?:with\s+|con\s+)?["'\u2018\u2019\u201C\u201D\u00AB]?(?:stop|end|quit|cancel|unsubscribe|baja|alto|parar|salir)\b/i,
410
+ /\bopt[\s-]?out\b/i,
411
+ /\bdarte de baja\b|\bdarse de baja\b/i,
412
+ ]
413
+
414
+ /**
415
+ * @param {string} text
416
+ * @returns {boolean}
417
+ */
418
+ function hasOptOut(text) {
419
+ return OPT_OUT_PATTERNS.some((p) => p.test(text))
420
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,289 @@
1
+ import { createInterface } from 'node:readline'
2
+ import {
3
+ checkMessage,
4
+ splitMessage,
5
+ GuardError,
6
+ DEFAULT_MAX_SEGMENTS,
7
+ DEFAULT_WINDOW,
8
+ } from './index.js'
9
+ import { VERSION } from './version.js'
10
+
11
+ /**
12
+ * A Model Context Protocol server over stdio, written against the spec directly so the
13
+ * package keeps zero runtime dependencies. Messages are newline-delimited JSON-RPC 2.0.
14
+ */
15
+
16
+ /** Newest first. The first is offered when a client asks for one we do not know. */
17
+ const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
18
+
19
+ /** Characters in one message. A text this long is a document, not an SMS. */
20
+ const MAX_TEXT = 20_000
21
+
22
+ const INSTRUCTIONS =
23
+ 'Call check_sms before sending any SMS, with the recipient number as to. Never send ' +
24
+ 'a message whose verdict is fail. If it failed on too_many_segments, call split_sms ' +
25
+ 'and send the parts it returns instead, in order, as separate messages. If it failed ' +
26
+ 'on quiet_hours, schedule it for the recipient\'s morning (nextWindowStart) rather ' +
27
+ 'than sending now. If the verdict is review, show the review reasons to a person and ' +
28
+ 'let them decide; do not decide for them.'
29
+
30
+ const MAX_SEGMENTS_PROPERTY = {
31
+ type: 'integer',
32
+ minimum: 1,
33
+ description: `Most segments one message may use. Default ${DEFAULT_MAX_SEGMENTS}.`,
34
+ }
35
+
36
+ const TEXT_PROPERTY = {
37
+ type: 'string',
38
+ maxLength: MAX_TEXT,
39
+ description: 'The message, exactly as it will be sent.',
40
+ }
41
+
42
+ const TOOLS = [
43
+ {
44
+ name: 'check_sms',
45
+ title: 'Check a text message before sending',
46
+ description:
47
+ 'Checks one SMS before you send it. Returns verdict (keep, review or fail), reasons ' +
48
+ 'with a stable code and a plain-English detail, and facts: GSM-7 or UCS-2, the ' +
49
+ 'characters forcing UCS-2, segment count, and the recipient\'s time zone and local ' +
50
+ 'send time. Fails on too many segments (carriers refuse long multi-part messages ' +
51
+ 'even when the provider API accepts them), a send time outside the recipient\'s ' +
52
+ 'allowed hours, an invalid number, or an empty message. Flags public link ' +
53
+ 'shorteners, an unknown or ambiguous time zone, and a first message with no ' +
54
+ 'opt-out wording for review. Runs locally.',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ text: TEXT_PROPERTY,
59
+ to: {
60
+ type: 'string',
61
+ description:
62
+ 'Recipient, E.164 such as +13055550100. Without it the send time is not checked.',
63
+ },
64
+ send_at: {
65
+ type: 'string',
66
+ description: 'When it will be sent, ISO 8601 with an offset or Z. Default now.',
67
+ },
68
+ max_segments: MAX_SEGMENTS_PROPERTY,
69
+ window: {
70
+ type: 'string',
71
+ description: `Allowed recipient local time, HH:MM-HH:MM. Default ${DEFAULT_WINDOW}.`,
72
+ },
73
+ first_message: {
74
+ type: 'boolean',
75
+ description: 'The first text to this person, so it needs opt-out wording. Default false.',
76
+ },
77
+ },
78
+ required: ['text'],
79
+ additionalProperties: false,
80
+ },
81
+ annotations: { readOnlyHint: true, openWorldHint: false },
82
+ },
83
+ {
84
+ name: 'split_sms',
85
+ title: 'Split a long text message into parts',
86
+ description:
87
+ 'Splits a message that is too long into parts that each fit within max_segments, ' +
88
+ 'breaking at paragraphs, then lines, then sentences, then words. Send the parts in ' +
89
+ 'order as separate messages. Each part comes with its own encoding and segment count.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ text: TEXT_PROPERTY,
94
+ max_segments: MAX_SEGMENTS_PROPERTY,
95
+ },
96
+ required: ['text'],
97
+ additionalProperties: false,
98
+ },
99
+ annotations: { readOnlyHint: true, openWorldHint: false },
100
+ },
101
+ ]
102
+
103
+ /**
104
+ * @typedef {object} ServeOptions
105
+ * @property {NodeJS.ReadableStream} [input]
106
+ * @property {NodeJS.WritableStream} [output]
107
+ * @property {Partial<import('./index.js').CheckOptions>} [checkOptions] Overrides for
108
+ * tests, such as a fixed clock.
109
+ */
110
+
111
+ /**
112
+ * Serves until the input stream closes.
113
+ *
114
+ * @param {ServeOptions} [options]
115
+ * @returns {Promise<void>}
116
+ */
117
+ export function serve(options = {}) {
118
+ const { input = process.stdin, output = process.stdout } = options
119
+ const send = (/** @type {object} */ message) => {
120
+ output.write(`${JSON.stringify(message)}\n`)
121
+ }
122
+
123
+ /** @type {Promise<void>[]} */
124
+ const pending = []
125
+ const lines = createInterface({ input, crlfDelay: Infinity })
126
+ lines.on('line', (line) => {
127
+ if (!line.trim()) return
128
+ pending.push(handleLine(line, send, options))
129
+ })
130
+ return new Promise((done) => {
131
+ lines.on('close', () => {
132
+ Promise.all(pending).then(() => done())
133
+ })
134
+ })
135
+ }
136
+
137
+ /**
138
+ * @param {string} line
139
+ * @param {(message: object) => void} send
140
+ * @param {ServeOptions} options
141
+ * @returns {Promise<void>}
142
+ */
143
+ async function handleLine(line, send, options) {
144
+ /** @type {any} */
145
+ let message
146
+ try {
147
+ message = JSON.parse(line)
148
+ } catch {
149
+ send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
150
+ return
151
+ }
152
+
153
+ const isRequest = message && typeof message === 'object' && 'id' in message
154
+ if (!isRequest) return // Notifications, including notifications/initialized.
155
+
156
+ const { id, method, params } = message
157
+ try {
158
+ const result = await dispatch(method, params ?? {}, options)
159
+ send({ jsonrpc: '2.0', id, result })
160
+ } catch (error) {
161
+ const code = error instanceof RpcError ? error.code : -32603
162
+ send({ jsonrpc: '2.0', id, error: { code, message: /** @type {Error} */ (error).message } })
163
+ }
164
+ }
165
+
166
+ class RpcError extends Error {
167
+ /**
168
+ * @param {number} code
169
+ * @param {string} message
170
+ */
171
+ constructor(code, message) {
172
+ super(message)
173
+ this.code = code
174
+ }
175
+ }
176
+
177
+ /**
178
+ * @param {string} method
179
+ * @param {any} params
180
+ * @param {ServeOptions} options
181
+ * @returns {Promise<object>}
182
+ */
183
+ async function dispatch(method, params, options) {
184
+ switch (method) {
185
+ case 'initialize':
186
+ return {
187
+ protocolVersion: PROTOCOL_VERSIONS.includes(params.protocolVersion)
188
+ ? params.protocolVersion
189
+ : PROTOCOL_VERSIONS[0],
190
+ capabilities: { tools: {} },
191
+ serverInfo: { name: 'sms-guard', title: 'sms-guard', version: VERSION },
192
+ instructions: INSTRUCTIONS,
193
+ }
194
+ case 'ping':
195
+ return {}
196
+ case 'tools/list':
197
+ return { tools: TOOLS }
198
+ case 'tools/call':
199
+ return callTool(params.name, params.arguments ?? {}, options)
200
+ default:
201
+ throw new RpcError(-32601, `Method not found: ${method}`)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Tool failures are results with isError set, not protocol errors, so the model sees
207
+ * the message and can act on it.
208
+ *
209
+ * @param {string} name
210
+ * @param {any} args
211
+ * @param {ServeOptions} options
212
+ * @returns {Promise<object>}
213
+ */
214
+ async function callTool(name, args, options) {
215
+ if (name !== 'check_sms' && name !== 'split_sms') {
216
+ throw new RpcError(-32602, `Unknown tool: ${name}`)
217
+ }
218
+ try {
219
+ const payload = name === 'check_sms' ? checkSms(args, options) : splitSms(args)
220
+ return {
221
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
222
+ structuredContent: payload,
223
+ }
224
+ } catch (error) {
225
+ if (error instanceof GuardError || error instanceof ToolInputError) {
226
+ return { content: [{ type: 'text', text: error.message }], isError: true }
227
+ }
228
+ throw error
229
+ }
230
+ }
231
+
232
+ class ToolInputError extends Error {}
233
+
234
+ /**
235
+ * @param {any} args
236
+ * @returns {string}
237
+ */
238
+ function textArg(args) {
239
+ if (typeof args.text !== 'string') throw new ToolInputError('text must be a string.')
240
+ if (args.text.length > MAX_TEXT) {
241
+ throw new ToolInputError(`text is over ${MAX_TEXT} characters, which is not a text message.`)
242
+ }
243
+ return args.text
244
+ }
245
+
246
+ /**
247
+ * @param {any} args
248
+ * @returns {number | undefined}
249
+ */
250
+ function maxSegmentsArg(args) {
251
+ if (args.max_segments === undefined) return undefined
252
+ if (!Number.isInteger(args.max_segments) || args.max_segments < 1) {
253
+ throw new ToolInputError('max_segments must be a whole number of 1 or more.')
254
+ }
255
+ return args.max_segments
256
+ }
257
+
258
+ /**
259
+ * @param {any} args
260
+ * @param {ServeOptions} options
261
+ */
262
+ function checkSms(args, options) {
263
+ const text = textArg(args)
264
+ for (const key of ['to', 'send_at', 'window']) {
265
+ if (args[key] !== undefined && typeof args[key] !== 'string') {
266
+ throw new ToolInputError(`${key} must be a string.`)
267
+ }
268
+ }
269
+ const result = checkMessage(text, {
270
+ ...options.checkOptions,
271
+ to: args.to,
272
+ sendAt: args.send_at,
273
+ maxSegments: maxSegmentsArg(args),
274
+ window: args.window,
275
+ firstMessage: args.first_message === true,
276
+ })
277
+ return { passed: result.verdict === 'keep', ...result }
278
+ }
279
+
280
+ /**
281
+ * @param {any} args
282
+ */
283
+ function splitSms(args) {
284
+ const text = textArg(args)
285
+ const maxSegments = maxSegmentsArg(args) ?? DEFAULT_MAX_SEGMENTS
286
+ const parts = splitMessage(text, { maxSegments })
287
+ if (parts.length === 0) throw new ToolInputError('The message is empty. Nothing to split.')
288
+ return { count: parts.length, maxSegments, parts }
289
+ }