From 56eb4ec8d6bfe4fce003f641b558deee08e62d01 Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 5 Nov 2026 14:47:20 +0300 Subject: [PATCH 1/2] fix(send_sms): accept local numbers and short codes Recipients were limited to strict E.164, which blocked short codes and local numbers that the phone can send to. The tool now accepts 3 to 15 digits with an optional leading plus, strips formatting, and still prefers international format in its description. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017B6pTUL543EK4Ai2Aw2T12 --- README.md | 2 +- src/tools.ts | 35 ++++++++++++++++++++++++++--------- test/tools.test.ts | 22 +++++++++++++++++++--- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ef8d5c6..bfa05d1 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ npm install -g @textbee/mcp ### send_sms -Send an SMS to one or more recipients (E.164 format, for example `+15550100123`). The sending phone is chosen automatically: your default device, otherwise the enabled device with the most recent heartbeat. Optional `device_id`, `sim_subscription_id`, and `scheduled_at`. Returns an `sms_batch_id` for delivery checks when the account uses the SMS queue. +Send an SMS to one or more phone numbers or short codes. International format with the country code (for example `+15550100123`) works everywhere. Local numbers and short codes only reach recipients in the sending phone's country. The sending phone is chosen automatically: your default device, otherwise the enabled device with the most recent heartbeat. Optional `device_id`, `sim_subscription_id`, and `scheduled_at`. Returns an `sms_batch_id` for delivery checks when the account uses the SMS queue. ### get_messages diff --git a/src/tools.ts b/src/tools.ts index 5e5f245..477f7c7 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,5 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { Textbee, countSmsSegments, isValidE164 } from '@textbee/sdk' +import { Textbee, countSmsSegments } from '@textbee/sdk' import { z } from 'zod' import { TEXTBEE_DEFAULT_BASE_URL, VERSION, stderrLogger } from './config.js' import { sendDedupeKey } from './dedupe.js' @@ -13,6 +13,19 @@ import type { ToolResult, } from './types.js' +// Formatting people type inside a number. Phones ignore it, so it is removed. +const RECIPIENT_FORMATTING = /[\s.\-()]/g +// International numbers, local numbers and short codes. 15 is the E.164 maximum. +const RECIPIENT_PATTERN = /^\+?\d{3,15}$/ + +function normalizeRecipient(value: string): string { + return value.trim().replace(RECIPIENT_FORMATTING, '') +} + +function isValidRecipient(value: string): boolean { + return RECIPIENT_PATTERN.test(normalizeRecipient(value)) +} + const INSTRUCTIONS = 'textbee sends and reads SMS through the user\'s own Android phone. Sends cost the user ' + 'real messages against their plan quota, so confirm the recipient before sending to a ' + @@ -20,8 +33,11 @@ const INSTRUCTIONS = 'Reads are account-wide and need no device id.' const SEND_SMS_DESCRIPTION = - 'Send an SMS through the user\'s own textbee account and Android phone. Recipients must ' + - 'be in international E.164 format such as +15550100123. The sending phone is chosen ' + + 'Send an SMS through the user\'s own textbee account and Android phone. Recipients are ' + + 'phone numbers or short codes. Prefer international format with the country code, such ' + + 'as +15550100123. Local numbers and short codes are sent as given and only reach ' + + 'recipients in the sending phone\'s country. Sender names such as MYBANK cannot ' + + 'receive SMS. The sending phone is chosen ' + 'automatically: the account default device, otherwise the enabled device with the most ' + 'recent heartbeat. Only pass device_id when the user explicitly names a phone; ids come ' + 'from list_devices. Sending costs the user a real message against their textbee plan ' + @@ -61,8 +77,9 @@ const sendSmsShape = { .min(1) .max(1000) .describe( - 'Phone numbers in international E.164 format including the country code, for ' + - 'example ["+15550100123"]. Each recipient counts as one message. The plan caps ' + + 'Phone numbers or short codes. Prefer international format with the country code, ' + + 'for example ["+15550100123"]. Use a local number or short code only when the user ' + + 'gives one. Each recipient counts as one message. The plan caps ' + 'how many recipients one send may have; the server rejects the send beyond it.', ), device_id: z @@ -197,7 +214,7 @@ export function buildToolDefinitions(opts: BuildOptions): TextbeeToolDefinition[ scheduled_at?: string } - const invalid = input.recipients.filter((r) => !isValidE164(r.trim())) + const invalid = input.recipients.filter((r) => !isValidRecipient(r)) if (invalid.length > 0) { return { isError: true, @@ -205,14 +222,14 @@ export function buildToolDefinitions(opts: BuildOptions): TextbeeToolDefinition[ { type: 'text', text: - `Not sent. These recipients are not valid E.164 numbers: ${invalid.join(', ')}. ` + - 'Use the international format with a country code and no spaces, for example +15550100123.', + `Not sent. These recipients are not phone numbers or short codes: ${invalid.join(', ')}. ` + + 'Use 3 to 15 digits with an optional leading +, for example +15550100123.', }, ], } } - const recipients = input.recipients.map((r) => r.trim()) + const recipients = input.recipients.map(normalizeRecipient) const deviceId = input.device_id?.trim() || undefined const dedupeKey = opts.sendDedupe diff --git a/test/tools.test.ts b/test/tools.test.ts index 279528f..4d45eba 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -119,16 +119,32 @@ describe('send_sms', () => { expect(text).not.toContain('sms_batch_id: ') }) - it('rejects invalid E.164 before any network call', async () => { + it('rejects sender names and impossible lengths before any network call', async () => { const result = await tool('send_sms').handler( - { message: 'hi', recipients: ['+15550100123', '0912345678'] }, + { message: 'hi', recipients: ['+15550100123', 'MYBANK', '12'] }, {}, ) expect(result.isError).toBe(true) - expect(resultText(result)).toContain('0912345678') + expect(resultText(result)).toContain('MYBANK, 12') expect(fetchMock).not.toHaveBeenCalled() }) + it('sends local numbers and short codes without formatting', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { data: { successCount: 3, failureCount: 0 } }), + ) + await tool('send_sms').handler( + { message: 'hi', recipients: ['(555) 555-0123', '605', ' +15550100123 '] }, + {}, + ) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + expect(JSON.parse(init.body as string).recipients).toEqual([ + '5555550123', + '605', + '+15550100123', + ]) + }) + it('strips a whitespace-only device_id instead of sending an empty string', async () => { fetchMock.mockResolvedValue( jsonResponse(200, { data: { successCount: 1, failureCount: 0 } }), From 6f02a690a24d3ad6319e9a98c49445c55e3046ce Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 5 Nov 2026 14:57:53 +0300 Subject: [PATCH 2/2] fix(send_sms): keep line breaks and drop a bracketed trunk zero Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017B6pTUL543EK4Ai2Aw2T12 --- src/tools.ts | 8 ++++++-- test/tools.test.ts | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index 477f7c7..1920910 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -14,12 +14,16 @@ import type { } from './types.js' // Formatting people type inside a number. Phones ignore it, so it is removed. -const RECIPIENT_FORMATTING = /[\s.\-()]/g +// Line breaks are kept so two numbers on two lines fail instead of joining. +const RECIPIENT_FORMATTING = /[ \t\u00a0.\-()]/g +// "+44 (0)20 7946 0018": the bracketed national trunk zero is not dialled in +// international format. +const OPTIONAL_TRUNK_ZERO = /^(\+\d{1,3})\s*\(0\)/ // International numbers, local numbers and short codes. 15 is the E.164 maximum. const RECIPIENT_PATTERN = /^\+?\d{3,15}$/ function normalizeRecipient(value: string): string { - return value.trim().replace(RECIPIENT_FORMATTING, '') + return value.trim().replace(OPTIONAL_TRUNK_ZERO, '$1').replace(RECIPIENT_FORMATTING, '') } function isValidRecipient(value: string): boolean { diff --git a/test/tools.test.ts b/test/tools.test.ts index 4d45eba..ba3ba8e 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -131,10 +131,13 @@ describe('send_sms', () => { it('sends local numbers and short codes without formatting', async () => { fetchMock.mockResolvedValue( - jsonResponse(200, { data: { successCount: 3, failureCount: 0 } }), + jsonResponse(200, { data: { successCount: 4, failureCount: 0 } }), ) await tool('send_sms').handler( - { message: 'hi', recipients: ['(555) 555-0123', '605', ' +15550100123 '] }, + { + message: 'hi', + recipients: ['(555) 555-0123', '605', ' +15550100123 ', '+44 (0)20 7946 0018'], + }, {}, ) const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] @@ -142,9 +145,19 @@ describe('send_sms', () => { '5555550123', '605', '+15550100123', + '+442079460018', ]) }) + it('does not join two numbers split by a line break', async () => { + const result = await tool('send_sms').handler( + { message: 'hi', recipients: ['5550100\n5550101'] }, + {}, + ) + expect(result.isError).toBe(true) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('strips a whitespace-only device_id instead of sending an empty string', async () => { fetchMock.mockResolvedValue( jsonResponse(200, { data: { successCount: 1, failureCount: 0 } }),