diff --git a/package.json b/package.json index 2f06a5d..da971f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@textbee/mcp", - "version": "0.0.2", + "version": "0.1.0", "mcpName": "io.github.textbee/mcp", "description": "MCP server for textbee.dev. Give your AI agent a phone number: send and read SMS through your own Android phone.", "license": "MIT", diff --git a/server.json b/server.json index 6134c28..8437216 100644 --- a/server.json +++ b/server.json @@ -8,13 +8,13 @@ "source": "github" }, "websiteUrl": "https://textbee.dev/mcp", - "version": "0.0.2", + "version": "0.1.0", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@textbee/mcp", - "version": "0.0.2", + "version": "0.1.0", "transport": { "type": "stdio" }, diff --git a/src/format.ts b/src/format.ts index 240c2fc..161ddd2 100644 --- a/src/format.ts +++ b/src/format.ts @@ -2,8 +2,11 @@ import type { Device, Message, MessageList } from '@textbee/sdk' const BODY_PREVIEW_CHARS = 300 -/** Statuses that will not change again; anything else may still progress. */ -export const FINAL_STATUSES = ['delivered', 'failed', 'unknown'] as const +/** Statuses that rarely change. failed and unknown can still change. */ +export const FINAL_STATUSES = ['delivered'] as const + +// Local shape until the published SDK types carry issueHistory. +type WithIssueHistory = { issueHistory?: Array<{ deliveryReport?: boolean }> } /** * Direction of a message. Prefer the lowercase `direction` field; fall back to @@ -41,7 +44,25 @@ function formatMessageLine(m: Message, index: number): string { : `to ${m.recipient ?? 'unknown number'}` const status = m.status ?? 'unknown' const header = `[${index}] ${when} ${direction} ${party} status=${status} id=${m._id}` - return `${header}\n ${messageBody(m)}` + const lines = [header, ` ${messageBody(m)}`] + const state = String(m.status ?? '').toLowerCase() + if (state === 'failed' && (m.errorCode || m.errorMessage)) { + const code = m.errorCode ?? '' + const text = m.errorMessage ?? '' + const detail = code && text && !text.startsWith(`${code}:`) ? `${code} - ${text}` : text || code + lines.push(` error: ${detail}`) + } else if (state === 'unknown') { + lines.push(' no report from the phone within 20 minutes (not a failure)') + } + const history = (m as WithIssueHistory).issueHistory ?? [] + const failures = history.filter((e) => !e?.deliveryReport).length + if (failures > 0) { + lines.push(` ${failures >= 15 ? '15 or more' : failures} failure${failures === 1 ? '' : 's'} recorded`) + } + if (history.length > failures) { + lines.push(' delivery report not confirmed (the message was sent)') + } + return lines.join('\n') } export interface FormatMessagesContext { @@ -71,7 +92,7 @@ export function formatMessages(list: MessageList, ctx: FormatMessagesContext): s lines.push('') lines.push( - `Statuses ${FINAL_STATUSES.join(', ')} are final; pending, dispatched, and sent may still change, so wait before re-checking.`, + 'Status delivered rarely changes. failed and unknown can still change (a late phone report or a recovery). pending, dispatched, and sent are in progress. The user can ask to check again later.', ) if (meta.nextCursor) { diff --git a/src/tools.ts b/src/tools.ts index 5e5f245..bd70844 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -262,7 +262,7 @@ export function buildToolDefinitions(opts: BuildOptions): TextbeeToolDefinition[ lines.push(`sms_batch_id: ${result.smsBatchId}`) lines.push( `Check per-recipient delivery with get_messages sms_batch_id="${result.smsBatchId}". ` + - 'Statuses delivered, failed, and unknown are final.', + 'Status delivered rarely changes. failed and unknown can still change.', ) } else { const immediate = result as { successCount?: number; failureCount?: number } diff --git a/test/format.test.ts b/test/format.test.ts index 08f8e9d..33b51b3 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -48,6 +48,78 @@ describe('formatMessages', () => { expect(text).toContain('66b0000000000000000001aa') }) + it('shows the current error on a failed message', () => { + const list: MessageList = { + data: [message({ direction: 'sent', status: 'failed', errorCode: 'FCM_SEND_FAILED', errorMessage: 'push failed' })], + meta: baseMeta, + } + const text = formatMessages(list, { direction: 'all', usedCursor: false, order: 'desc' }) + expect(text).toContain('error: FCM_SEND_FAILED - push failed') + }) + + it('counts recorded failures neutrally', () => { + const entry = { code: 'FCM_SEND_FAILED', at: '2026-10-01T10:00:00.000Z', source: 'push' } + const fmt = (n: number) => + formatMessages( + { data: [message({ direction: 'sent', status: 'sent', issueHistory: Array(n).fill(entry) } as Partial)], meta: baseMeta }, + { direction: 'all', usedCursor: false, order: 'desc' }, + ) + expect(fmt(1)).toContain('1 failure recorded') + expect(fmt(3)).toContain('3 failures recorded') + expect(fmt(10)).toContain('10 failures recorded') + expect(fmt(15)).toContain('15 or more failures recorded') + expect(fmt(1)).not.toContain('before it was') + }) + + it('does not count delivery report entries as failures', () => { + const fail = { code: 'FCM_SEND_FAILED', at: '2026-10-01T10:00:00.000Z', source: 'push' } + const report = { code: 'DELIVERY_FAILED', at: '2026-10-01T10:05:00.000Z', source: 'device', deliveryReport: true } + const fmt = (history: unknown[]) => + formatMessages( + { data: [message({ direction: 'sent', status: 'sent', issueHistory: history } as Partial)], meta: baseMeta }, + { direction: 'all', usedCursor: false, order: 'desc' }, + ) + const only = fmt([report]) + expect(only).toContain('delivery report not confirmed (the message was sent)') + expect(only).not.toContain('failure') + const mixed = fmt([fail, report]) + expect(mixed).toContain('1 failure recorded') + expect(mixed).toContain('delivery report not confirmed (the message was sent)') + expect(fmt([fail])).not.toContain('delivery report') + }) + + it('compares status case-insensitively', () => { + const list: MessageList = { + data: [message({ direction: 'sent', status: 'FAILED' as never, errorCode: 'X_CODE', errorMessage: 'broke' })], + meta: baseMeta, + } + const text = formatMessages(list, { direction: 'all', usedCursor: false, order: 'desc' }) + expect(text).toContain('error: X_CODE - broke') + }) + + it('prints only the message when it already starts with the code', () => { + const list: MessageList = { + data: [message({ direction: 'sent', status: 'failed', errorCode: 'FCM_SEND_TIMEOUT', errorMessage: 'FCM_SEND_TIMEOUT: no ack' })], + meta: baseMeta, + } + const text = formatMessages(list, { direction: 'all', usedCursor: false, order: 'desc' }) + expect(text).toContain('error: FCM_SEND_TIMEOUT: no ack') + expect(text).not.toContain('FCM_SEND_TIMEOUT - ') + }) + + it('explains an unknown status is not a failure', () => { + const list: MessageList = { data: [message({ direction: 'sent', status: 'unknown' })], meta: baseMeta } + const text = formatMessages(list, { direction: 'all', usedCursor: false, order: 'desc' }) + expect(text).toContain('no report from the phone within 20 minutes (not a failure)') + }) + + it('says failed and unknown can still change and delivered rarely changes', () => { + const text = formatMessages({ data: [], meta: baseMeta }, { direction: 'all', usedCursor: false, order: 'desc' }) + expect(text).toContain('failed and unknown can still change') + expect(text).toContain('delivered rarely changes') + expect(text).not.toContain('is final') + }) + it('renders missing status and missing body as unknown, never undefined', () => { const list: MessageList = { data: [message({ message: undefined, status: undefined } as unknown as Partial)],