@honkio/mcp 1.2.0 → 1.5.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.
Files changed (46) hide show
  1. package/README.md +145 -48
  2. package/dist/auth.d.ts +40 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +98 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/client.d.ts +21 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +57 -19
  9. package/dist/client.js.map +1 -1
  10. package/dist/http.d.ts +51 -0
  11. package/dist/http.d.ts.map +1 -0
  12. package/dist/http.js +248 -0
  13. package/dist/http.js.map +1 -0
  14. package/dist/index.d.ts +0 -9
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +19 -46
  17. package/dist/index.js.map +1 -1
  18. package/dist/log.d.ts +10 -0
  19. package/dist/log.d.ts.map +1 -0
  20. package/dist/log.js +10 -0
  21. package/dist/log.js.map +1 -0
  22. package/dist/prompts.d.ts +1 -1
  23. package/dist/prompts.d.ts.map +1 -1
  24. package/dist/prompts.js +19 -18
  25. package/dist/prompts.js.map +1 -1
  26. package/dist/rateLimit.d.ts +50 -0
  27. package/dist/rateLimit.d.ts.map +1 -0
  28. package/dist/rateLimit.js +85 -0
  29. package/dist/rateLimit.js.map +1 -0
  30. package/dist/resources.d.ts +1 -1
  31. package/dist/resources.d.ts.map +1 -1
  32. package/dist/resources.js +4 -4
  33. package/dist/resources.js.map +1 -1
  34. package/dist/server.d.ts +7 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +46 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/toolMeta.d.ts +20 -0
  39. package/dist/toolMeta.d.ts.map +1 -0
  40. package/dist/toolMeta.js +75 -0
  41. package/dist/toolMeta.js.map +1 -0
  42. package/dist/tools.d.ts +1 -1
  43. package/dist/tools.d.ts.map +1 -1
  44. package/dist/tools.js +222 -181
  45. package/dist/tools.js.map +1 -1
  46. package/package.json +16 -7
package/dist/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { HonkioError } from './client.js';
3
+ import { meta } from './toolMeta.js';
3
4
  const DNCL_EXEMPTIONS = [
4
5
  'existing_business_relationship',
5
6
  'registered_charity',
@@ -18,6 +19,9 @@ const WEBHOOK_EVENTS = [
18
19
  'message.received',
19
20
  'opt_out.recorded',
20
21
  'opt_out.reinstated',
22
+ 'account.delivery_warning',
23
+ 'account.sending_paused',
24
+ 'account.spend_warning',
21
25
  ];
22
26
  function ok(data) {
23
27
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
@@ -36,34 +40,29 @@ function formatError(err) {
36
40
  }
37
41
  export function registerTools(server, client) {
38
42
  /**
39
- * Account-scoped tools take an account_id, but an API key does not carry one
40
- * and nothing used to return it, so an agent could not call them without the
41
- * user pasting an id from the dashboard. Omitting it now resolves the caller
42
- * from the key. Cached because it cannot change for the life of the process —
43
- * the key is fixed at startup.
43
+ * Account-scoped tools take an optional account_id. Omitting it resolves the
44
+ * caller from the key; the client memoizes that per instance.
44
45
  */
45
- let cachedAccountId;
46
- const resolveAccountId = async (provided) => {
47
- if (provided)
48
- return provided;
49
- if (cachedAccountId)
50
- return cachedAccountId;
51
- const me = await client.getCurrentAccount();
52
- cachedAccountId = me.account.id;
53
- return cachedAccountId;
54
- };
46
+ const resolveAccountId = (provided) => provided ? Promise.resolve(provided) : client.currentAccountId();
55
47
  const ACCOUNT_ID_ARG = z
56
48
  .string()
57
49
  .optional()
58
50
  .describe('Your account ID. Omit it and it is resolved from your API key.');
59
51
  // ── Messages ──────────────────────────────────────────────
60
- server.tool('send_sms', 'Send an SMS message from one of your provisioned Canadian numbers. Automatically checks CASL consent (CRTC DNCL checking is coming soon and not yet enforced) unless overridden. Billed per SMS part as the carrier splits the body (get_pricing → message_cost_cents; typographic quotes and dashes count as GSM-7, emoji force Unicode parts); the charge is settled to the carrier\'s part count after the send, and a send the carrier rejects costs nothing. Use test-mode keys (mk_test_...) to send without real delivery or charges. A test-mode send still returns status "DELIVERED" — check the "mode" field, not the status, to tell a simulation from a real send.', {
61
- from: z.string().describe('Sending phone number in E.164 format (e.g. +14165551234). Must be a number provisioned on your account.'),
62
- to: z.string().describe('Recipient phone number in E.164 format. Must be a Canadian number.'),
63
- body: z.string().max(1600).describe('Message body text. Maximum 1600 characters and 10 SMS parts (about 1,530 GSM-7 or 670 Unicode characters); a longer body is rejected with 422 MESSAGE_TOO_LONG before anything is charged. Billing is per part, counted the way the carrier splits it.'),
64
- skip_consent_check: z.boolean().optional().describe('Skip the CASL consent gate. Use only when you have consent recorded outside HonkIO.'),
65
- dncl_exemptions: z.array(z.enum(DNCL_EXEMPTIONS)).optional().describe('DNCL exemption reasons. Required if the recipient is on the CRTC Do Not Call List.'),
66
- }, async (input) => {
52
+ server.registerTool('send_sms', { ...meta('send_sms'), description: 'Send an SMS message from one of your provisioned Canadian numbers. Automatically checks CASL consent (CRTC DNCL checking is coming soon and not yet enforced) unless overridden. Billed per SMS part as the carrier splits the body (get_pricing → message_cost_cents; typographic quotes and dashes count as GSM-7, emoji force Unicode parts); the charge is settled to the carrier\'s part count after the send. A send the carrier rejects outright costs nothing; a message the carrier accepts but cannot deliver keeps its charge. A send to a reserved exchange (555-XXXX, N11 exchanges, carrier test codes) is refused for free as 422 RESERVED_DESTINATION, in test mode too — use a real recipient. A number the carrier has failed three times running, for any customer, is refused for free as 422 UNDELIVERABLE_NUMBER for 90 days. Use test-mode keys (mk_test_...) to send without real delivery or charges. A test-mode send still returns status "DELIVERED" — check the "mode" field, not the status, to tell a simulation from a real send. ' +
53
+ 'Live sends are subject to sending limits (get_send_limit): a daily cap, a cap on identical messages to distinct recipients, a per-number rate, a per-recipient cap (30 an hour and 100 a day to one number), ' +
54
+ 'and an automatic pause on high opt-out or failure rates — refusals come back as DAILY_LIMIT_REACHED, FANOUT_LIMIT_REACHED, NUMBER_RATE_LIMITED, RECIPIENT_RATE_LIMITED or SENDING_PAUSED with details. ' +
55
+ 'Link shorteners (bit.ly and similar) are refused in both modes (LINK_SHORTENER_BLOCKED); use the full URL. ' +
56
+ 'When you retry a send, pass the same idempotency_key: the API returns the original message instead of sending and charging again. ' +
57
+ 'A 503 CARRIER_UNAVAILABLE means the carrier could not be reached — nothing was sent or charged; retry shortly. ' +
58
+ 'A 422 NOT_A_MOBILE_NUMBER means the destination is a landline or VoIP number: the carrier refuses it before sending, nothing is charged, and three such refusals list the number as undeliverable.', inputSchema: z.object({
59
+ from: z.string().describe('Sending phone number in E.164 format (e.g. +14165551234). Must be a number provisioned on your account.'),
60
+ to: z.string().describe('Recipient phone number in E.164 format. Must be a Canadian number.'),
61
+ body: z.string().max(1600).describe('Message body text. Maximum 1600 characters and 10 SMS parts (about 1,530 GSM-7 or 670 Unicode characters); a longer body is rejected with 422 MESSAGE_TOO_LONG before anything is charged. Billing is per part, counted the way the carrier splits it.'),
62
+ skip_consent_check: z.boolean().optional().describe('Skip the CASL consent gate. Use only when you have consent recorded outside HonkIO.'),
63
+ dncl_exemptions: z.array(z.enum(DNCL_EXEMPTIONS)).optional().describe('DNCL exemption reasons. Required if the recipient is on the CRTC Do Not Call List.'),
64
+ idempotency_key: z.string().min(1).max(255).optional().describe('Sent as the Idempotency-Key header. Reuse the same key on a retry so it can never become a second send or a second charge.'),
65
+ }) }, async (input) => {
67
66
  try {
68
67
  const result = await client.sendMessage(input);
69
68
  return ok(result);
@@ -72,16 +71,16 @@ export function registerTools(server, client) {
72
71
  return formatError(err);
73
72
  }
74
73
  });
75
- server.tool('list_messages', 'List SMS messages for your account with optional filters. Returns paginated results. On each message, cost_cents is what it actually cost in CAD cents (settled to the carrier\'s part count; 0 on a FAILED send or a TEST simulation; inbound messages carry the inbound charge) and segment_count is the carrier\'s part count.', {
76
- from: z.string().optional().describe('Filter by sending phone number (E.164).'),
77
- to: z.string().optional().describe('Filter by recipient phone number (E.164).'),
78
- status: z.enum(['QUEUED', 'SENDING', 'SENT', 'DELIVERED', 'FAILED', 'UNDELIVERED', 'RECEIVED']).optional().describe('Filter by message status.'),
79
- direction: z.enum(['OUTBOUND', 'INBOUND']).optional().describe('Filter by message direction.'),
80
- page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
81
- limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20, max: 100).'),
82
- date_from: z.string().optional().describe('Filter messages from this date (YYYY-MM-DD).'),
83
- date_to: z.string().optional().describe('Filter messages to this date (YYYY-MM-DD).'),
84
- }, async (input) => {
74
+ server.registerTool('list_messages', { ...meta('list_messages'), description: 'List SMS messages for your account with optional filters. Returns paginated results. On each message, cost_cents is what it actually cost in CAD cents (settled to the carrier\'s part count; 0 on a TEST simulation or when the carrier refused the send outright — a message the carrier accepted but could not deliver keeps its charge; inbound messages carry the inbound charge) and segment_count is the carrier\'s part count. On a FAILED or UNDELIVERED message, error_code and error_message carry the carrier\'s reason.', inputSchema: z.object({
75
+ from: z.string().optional().describe('Filter by sending phone number (E.164).'),
76
+ to: z.string().optional().describe('Filter by recipient phone number (E.164).'),
77
+ status: z.enum(['QUEUED', 'SENDING', 'SENT', 'DELIVERED', 'FAILED', 'UNDELIVERED', 'RECEIVED']).optional().describe('Filter by message status.'),
78
+ direction: z.enum(['OUTBOUND', 'INBOUND']).optional().describe('Filter by message direction.'),
79
+ page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
80
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20, max: 100).'),
81
+ date_from: z.string().optional().describe('Filter messages from this date (YYYY-MM-DD).'),
82
+ date_to: z.string().optional().describe('Filter messages to this date (YYYY-MM-DD).'),
83
+ }) }, async (input) => {
85
84
  try {
86
85
  const result = await client.listMessages(input);
87
86
  return ok(result);
@@ -90,9 +89,9 @@ export function registerTools(server, client) {
90
89
  return formatError(err);
91
90
  }
92
91
  });
93
- server.tool('get_message', 'Get full details of a single SMS message by its ID, including delivery events. cost_cents is what it actually cost in CAD cents (settled to the carrier\'s part count; 0 on a FAILED send or a TEST simulation) and segment_count is the carrier\'s part count.', {
94
- id: z.string().describe('Message ID.'),
95
- }, async ({ id }) => {
92
+ server.registerTool('get_message', { ...meta('get_message'), description: 'Get full details of a single SMS message by its ID, including delivery events. cost_cents is what it actually cost in CAD cents (settled to the carrier\'s part count; 0 on a TEST simulation or when the carrier refused the send outright; an undelivered message keeps its charge) and segment_count is the carrier\'s part count. On a FAILED or UNDELIVERED message, error_code and error_message carry the carrier\'s reason.', inputSchema: z.object({
93
+ id: z.string().describe('Message ID.'),
94
+ }) }, async ({ id }) => {
96
95
  try {
97
96
  const result = await client.getMessage(id);
98
97
  return ok(result);
@@ -102,10 +101,10 @@ export function registerTools(server, client) {
102
101
  }
103
102
  });
104
103
  // ── Phone Numbers ─────────────────────────────────────────
105
- server.tool('search_phone_numbers', 'Search available Canadian phone numbers you can provision. Filter by area code to find numbers in a specific province.', {
106
- area_code: z.string().regex(/^\d{3}$/).optional().describe('Canadian area code to filter by (e.g. "416" for Toronto, "514" for Montreal, "604" for Vancouver).'),
107
- limit: z.number().int().min(1).max(50).optional().describe('Number of results to return (default: 10, max: 50).'),
108
- }, async (input) => {
104
+ server.registerTool('search_phone_numbers', { ...meta('search_phone_numbers'), description: 'Search available Canadian phone numbers you can provision. Filter by area code to find numbers in a specific province.', inputSchema: z.object({
105
+ area_code: z.string().regex(/^\d{3}$/).optional().describe('Canadian area code to filter by (e.g. "416" for Toronto, "514" for Montreal, "604" for Vancouver).'),
106
+ limit: z.number().int().min(1).max(50).optional().describe('Number of results to return (default: 10, max: 50).'),
107
+ }) }, async (input) => {
109
108
  try {
110
109
  const result = await client.searchPhoneNumbers(input);
111
110
  return ok(result);
@@ -114,18 +113,18 @@ export function registerTools(server, client) {
114
113
  return formatError(err);
115
114
  }
116
115
  });
117
- server.tool('provision_phone_number', 'Provision (purchase) a Canadian phone number to your account. Get the phone_number value from search_phone_numbers first. ' +
118
- 'On a live key this spends real money: the first month\'s rent plus a one-time activation fee are debited together the ' +
119
- 'moment the number is bought (search results and get_pricing show both amounts), and rent recurs monthly until ' +
120
- 'release_phone_number. A balance that cannot cover the total returns 402 INSUFFICIENT_BALANCE with nothing charged. ' +
121
- 'Accounts are capped on how many numbers they may hold at once — a per-account limit that falls back to a platform-wide ' +
122
- 'default staff can change at runtime — exceeding it returns NUMBER_LIMIT_REACHED, and a higher allowance is requested from ' +
123
- 'the HonkIO dashboard, not through this API. May also return a 409 PURCHASE_IN_PROGRESS if another purchase for this ' +
124
- 'account is already running; this is transient and safe to retry after a short delay — unlike the terminal 409 returned ' +
125
- 'when the number is already owned, it does not mean the purchase failed. Firing several provision_phone_number calls in ' +
126
- 'parallel will produce these; retry rather than dropping them.', {
127
- phone_number: z.string().describe('E.164 phone number to provision (e.g. +14165551234). Must be from the search_phone_numbers results.'),
128
- }, async ({ phone_number }) => {
116
+ server.registerTool('provision_phone_number', { ...meta('provision_phone_number'), description: 'Provision (purchase) a Canadian phone number to your account. Get the phone_number value from search_phone_numbers first. ' +
117
+ 'On a live key this spends real money: the first month\'s rent plus a one-time activation fee are debited together the ' +
118
+ 'moment the number is bought (search results and get_pricing show both amounts), and rent recurs monthly until ' +
119
+ 'release_phone_number. A balance that cannot cover the total returns 402 INSUFFICIENT_BALANCE with nothing charged. ' +
120
+ 'Accounts are capped on how many numbers they may hold at once — a per-account limit that falls back to a platform-wide ' +
121
+ 'default staff can change at runtime — exceeding it returns NUMBER_LIMIT_REACHED, and a higher allowance is requested from ' +
122
+ 'the HonkIO dashboard, not through this API. May also return a 409 PURCHASE_IN_PROGRESS if another purchase for this ' +
123
+ 'account is already running; this is transient and safe to retry after a short delay — unlike the terminal 409 returned ' +
124
+ 'when the number is already owned, it does not mean the purchase failed. Firing several provision_phone_number calls in ' +
125
+ 'parallel will produce these; retry rather than dropping them.', inputSchema: z.object({
126
+ phone_number: z.string().describe('E.164 phone number to provision (e.g. +14165551234). Must be from the search_phone_numbers results.'),
127
+ }) }, async ({ phone_number }) => {
129
128
  try {
130
129
  const result = await client.provisionPhoneNumber(phone_number);
131
130
  return ok(result);
@@ -134,7 +133,7 @@ export function registerTools(server, client) {
134
133
  return formatError(err);
135
134
  }
136
135
  });
137
- server.tool('list_phone_numbers', "List all phone numbers provisioned on your account.", {}, async () => {
136
+ server.registerTool('list_phone_numbers', { ...meta('list_phone_numbers'), description: "List all phone numbers provisioned on your account.", inputSchema: z.object({}) }, async () => {
138
137
  try {
139
138
  const result = await client.listPhoneNumbers();
140
139
  return ok(result);
@@ -143,9 +142,9 @@ export function registerTools(server, client) {
143
142
  return formatError(err);
144
143
  }
145
144
  });
146
- server.tool('get_phone_number', 'Get details for a specific provisioned phone number.', {
147
- id: z.string().describe('Phone number record ID.'),
148
- }, async ({ id }) => {
145
+ server.registerTool('get_phone_number', { ...meta('get_phone_number'), description: 'Get details for a specific provisioned phone number.', inputSchema: z.object({
146
+ id: z.string().describe('Phone number record ID.'),
147
+ }) }, async ({ id }) => {
149
148
  try {
150
149
  const result = await client.getPhoneNumber(id);
151
150
  return ok(result);
@@ -154,9 +153,9 @@ export function registerTools(server, client) {
154
153
  return formatError(err);
155
154
  }
156
155
  });
157
- server.tool('release_phone_number', 'Release (cancel) a provisioned phone number. This will stop monthly billing for the number; the one-time activation fee and the current month are not refunded. This action cannot be undone.', {
158
- id: z.string().describe('Phone number record ID to release.'),
159
- }, async ({ id }) => {
156
+ server.registerTool('release_phone_number', { ...meta('release_phone_number'), description: 'Release (cancel) a provisioned phone number. This will stop monthly billing for the number; the one-time activation fee and the current month are not refunded. This action cannot be undone.', inputSchema: z.object({
157
+ id: z.string().describe('Phone number record ID to release.'),
158
+ }) }, async ({ id }) => {
160
159
  try {
161
160
  await client.releasePhoneNumber(id);
162
161
  return ok({ status: 'released', id });
@@ -166,16 +165,16 @@ export function registerTools(server, client) {
166
165
  }
167
166
  });
168
167
  // ── Compliance: CASL Consents ─────────────────────────────
169
- server.tool('record_consent', 'Record CASL consent for a phone number before sending commercial messages. Express consent requires source_description. Implied consent requires relationship_type and is valid for 2 years from last_transaction_date.', {
170
- phone_number: z.string().describe('E.164 phone number of the subscriber granting consent.'),
171
- consent_type: z.enum(['express', 'implied']).describe('Type of CASL consent. "express" = subscriber explicitly opted in. "implied" = business relationship exists.'),
172
- source_description: z.string().optional().describe('[Required for express] How/where the subscriber gave consent (e.g. "Website signup form at honkio.ca/signup on 2024-01-15").'),
173
- source_ip: z.string().optional().describe('[Optional, express] IP address of the subscriber at time of consent.'),
174
- source_url: z.string().optional().describe('[Optional, express] URL where consent was captured.'),
175
- relationship_type: z.string().optional().describe('[Required for implied] Business relationship type (e.g. "purchase", "inquiry", "membership").'),
176
- last_transaction_date: z.string().optional().describe('[Optional, implied] Date of last transaction (YYYY-MM-DD). Implied consent expires 2 years after this date.'),
177
- expires_at: z.string().optional().describe('[Optional, implied] Explicit expiry (ISO 8601 date or date-time). Overrides the expiry derived from last_transaction_date. Rejected for express consent, which never expires.'),
178
- }, async (input) => {
168
+ server.registerTool('record_consent', { ...meta('record_consent'), description: 'Record CASL consent for a phone number before sending commercial messages. Express consent requires source_description. Implied consent requires relationship_type and is valid for 2 years from last_transaction_date.', inputSchema: z.object({
169
+ phone_number: z.string().describe('E.164 phone number of the subscriber granting consent.'),
170
+ consent_type: z.enum(['express', 'implied']).describe('Type of CASL consent. "express" = subscriber explicitly opted in. "implied" = business relationship exists.'),
171
+ source_description: z.string().optional().describe('[Required for express] How/where the subscriber gave consent (e.g. "Website signup form at honkio.ca/signup on 2024-01-15").'),
172
+ source_ip: z.string().optional().describe('[Optional, express] IP address of the subscriber at time of consent.'),
173
+ source_url: z.string().optional().describe('[Optional, express] URL where consent was captured.'),
174
+ relationship_type: z.string().optional().describe('[Required for implied] Business relationship type (e.g. "purchase", "inquiry", "membership").'),
175
+ last_transaction_date: z.string().optional().describe('[Optional, implied] Date of last transaction (YYYY-MM-DD). Implied consent expires 2 years after this date.'),
176
+ expires_at: z.string().optional().describe('[Optional, implied] Explicit expiry (ISO 8601 date or date-time). Overrides the expiry derived from last_transaction_date. Rejected for express consent, which never expires.'),
177
+ }) }, async (input) => {
179
178
  try {
180
179
  const result = await client.recordConsent(input);
181
180
  return ok(result);
@@ -184,12 +183,12 @@ export function registerTools(server, client) {
184
183
  return formatError(err);
185
184
  }
186
185
  });
187
- server.tool('list_consents', 'List CASL consent records for your account, optionally filtered by phone number or status.', {
188
- phone_number: z.string().optional().describe('Filter consents for a specific phone number (E.164).'),
189
- status: z.enum(['ACTIVE', 'EXPIRED', 'REVOKED']).optional().describe('Filter by consent status.'),
190
- page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
191
- limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
192
- }, async (input) => {
186
+ server.registerTool('list_consents', { ...meta('list_consents'), description: 'List CASL consent records for your account, optionally filtered by phone number or status.', inputSchema: z.object({
187
+ phone_number: z.string().optional().describe('Filter consents for a specific phone number (E.164).'),
188
+ status: z.enum(['ACTIVE', 'EXPIRED', 'REVOKED']).optional().describe('Filter by consent status.'),
189
+ page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
190
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
191
+ }) }, async (input) => {
193
192
  try {
194
193
  const result = await client.listConsents(input);
195
194
  return ok(result);
@@ -198,9 +197,9 @@ export function registerTools(server, client) {
198
197
  return formatError(err);
199
198
  }
200
199
  });
201
- server.tool('check_consent', 'Check whether a phone number has valid CASL consent before sending a message.', {
202
- phone_number: z.string().describe('E.164 phone number to check consent for.'),
203
- }, async ({ phone_number }) => {
200
+ server.registerTool('check_consent', { ...meta('check_consent'), description: 'Check whether a phone number has valid CASL consent before sending a message.', inputSchema: z.object({
201
+ phone_number: z.string().describe('E.164 phone number to check consent for.'),
202
+ }) }, async ({ phone_number }) => {
204
203
  try {
205
204
  const result = await client.checkConsent(phone_number);
206
205
  return ok(result);
@@ -209,9 +208,9 @@ export function registerTools(server, client) {
209
208
  return formatError(err);
210
209
  }
211
210
  });
212
- server.tool('revoke_consent', 'Revoke CASL consent for a phone number. Future messages to this number will be blocked unless new consent is recorded.', {
213
- phone_number: z.string().describe('E.164 phone number to revoke consent for.'),
214
- }, async ({ phone_number }) => {
211
+ server.registerTool('revoke_consent', { ...meta('revoke_consent'), description: 'Revoke CASL consent for a phone number. Future messages to this number will be blocked unless new consent is recorded.', inputSchema: z.object({
212
+ phone_number: z.string().describe('E.164 phone number to revoke consent for.'),
213
+ }) }, async ({ phone_number }) => {
215
214
  try {
216
215
  await client.revokeConsent(phone_number);
217
216
  return ok({ status: 'revoked', phone_number });
@@ -221,10 +220,10 @@ export function registerTools(server, client) {
221
220
  }
222
221
  });
223
222
  // ── Compliance: Opt-Outs ──────────────────────────────────
224
- server.tool('record_opt_out', 'Manually record an opt-out for a subscriber. Use this when a subscriber contacts you directly to opt out rather than replying STOP to a message.', {
225
- phone_number: z.string().describe('E.164 phone number of the subscriber opting out.'),
226
- from_number: z.string().describe('E.164 number the subscriber is opting out from (your sending number).'),
227
- }, async (input) => {
223
+ server.registerTool('record_opt_out', { ...meta('record_opt_out'), description: 'Manually record an opt-out for a subscriber. Use this when a subscriber contacts you directly to opt out rather than replying STOP to a message.', inputSchema: z.object({
224
+ phone_number: z.string().describe('E.164 phone number of the subscriber opting out.'),
225
+ from_number: z.string().describe('E.164 number the subscriber is opting out from (your sending number).'),
226
+ }) }, async (input) => {
228
227
  try {
229
228
  const result = await client.recordOptOut(input);
230
229
  return ok(result);
@@ -233,12 +232,12 @@ export function registerTools(server, client) {
233
232
  return formatError(err);
234
233
  }
235
234
  });
236
- server.tool('list_opt_outs', 'List opt-out records for your account.', {
237
- phone_number: z.string().optional().describe('Filter opt-outs for a specific subscriber number (E.164).'),
238
- from_number: z.string().optional().describe('Filter opt-outs from a specific sending number (E.164).'),
239
- page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
240
- limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
241
- }, async (input) => {
235
+ server.registerTool('list_opt_outs', { ...meta('list_opt_outs'), description: 'List opt-out records for your account.', inputSchema: z.object({
236
+ phone_number: z.string().optional().describe('Filter opt-outs for a specific subscriber number (E.164).'),
237
+ from_number: z.string().optional().describe('Filter opt-outs from a specific sending number (E.164).'),
238
+ page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
239
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
240
+ }) }, async (input) => {
242
241
  try {
243
242
  const result = await client.listOptOuts(input);
244
243
  return ok(result);
@@ -248,9 +247,9 @@ export function registerTools(server, client) {
248
247
  }
249
248
  });
250
249
  // ── Compliance: DNCL ─────────────────────────────────────
251
- server.tool('check_dncl', "Coming soon: check if a Canadian phone number is on the CRTC Do Not Call List. CRTC DNCL checking is not available yet — this endpoint currently returns 501 (DNCL_COMING_SOON).", {
252
- phone_number: z.string().describe('Canadian phone number to check in E.164 format.'),
253
- }, async ({ phone_number }) => {
250
+ server.registerTool('check_dncl', { ...meta('check_dncl'), description: "Coming soon: check if a Canadian phone number is on the CRTC Do Not Call List. CRTC DNCL checking is not available yet — this endpoint currently returns 501 (DNCL_COMING_SOON).", inputSchema: z.object({
251
+ phone_number: z.string().describe('Canadian phone number to check in E.164 format.'),
252
+ }) }, async ({ phone_number }) => {
254
253
  try {
255
254
  const result = await client.checkDncl(phone_number);
256
255
  return ok(result);
@@ -259,9 +258,9 @@ export function registerTools(server, client) {
259
258
  return formatError(err);
260
259
  }
261
260
  });
262
- server.tool('batch_check_dncl', 'Coming soon: check up to 100 Canadian phone numbers against the CRTC Do Not Call List in a single request. CRTC DNCL checking is not available yet — this endpoint currently returns 501 (DNCL_COMING_SOON).', {
263
- phone_numbers: z.array(z.string()).min(1).max(100).describe('Array of Canadian phone numbers in E.164 format.'),
264
- }, async ({ phone_numbers }) => {
261
+ server.registerTool('batch_check_dncl', { ...meta('batch_check_dncl'), description: 'Coming soon: check up to 100 Canadian phone numbers against the CRTC Do Not Call List in a single request. CRTC DNCL checking is not available yet — this endpoint currently returns 501 (DNCL_COMING_SOON).', inputSchema: z.object({
262
+ phone_numbers: z.array(z.string()).min(1).max(100).describe('Array of Canadian phone numbers in E.164 format.'),
263
+ }) }, async ({ phone_numbers }) => {
265
264
  try {
266
265
  const result = await client.batchCheckDncl(phone_numbers);
267
266
  return ok(result);
@@ -271,10 +270,10 @@ export function registerTools(server, client) {
271
270
  }
272
271
  });
273
272
  // ── Compliance: Erasure ───────────────────────────────────
274
- server.tool('request_erasure', 'Execute a PIPEDA/Quebec Law 25 right-to-erasure request for a phone number. Purges message bodies, consent records, and opt-out records for the specified number. Creates an immutable audit log entry.', {
275
- phone_number: z.string().describe('E.164 phone number to erase data for.'),
276
- reason: z.string().optional().describe('Reason for the erasure request (recorded in the audit log).'),
277
- }, async (input) => {
273
+ server.registerTool('request_erasure', { ...meta('request_erasure'), description: 'Execute a PIPEDA/Quebec Law 25 right-to-erasure request for a phone number. Purges message bodies, consent records, and opt-out records for the specified number. Creates an immutable audit log entry.', inputSchema: z.object({
274
+ phone_number: z.string().describe('E.164 phone number to erase data for.'),
275
+ reason: z.string().optional().describe('Reason for the erasure request (recorded in the audit log).'),
276
+ }) }, async (input) => {
278
277
  try {
279
278
  const result = await client.requestErasure(input);
280
279
  return ok(result);
@@ -284,10 +283,10 @@ export function registerTools(server, client) {
284
283
  }
285
284
  });
286
285
  // ── Webhooks ──────────────────────────────────────────────
287
- server.tool('create_webhook', 'Register a webhook endpoint to receive HonkIO event notifications. The signing_secret in the response is shown once — store it to verify X-HonkIO-Signature on incoming requests.', {
288
- url: z.string().url().describe('HTTPS URL to deliver events to.'),
289
- events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).describe('Event types to subscribe to.'),
290
- }, async (input) => {
286
+ server.registerTool('create_webhook', { ...meta('create_webhook'), description: 'Register a webhook endpoint to receive HonkIO event notifications. The signing_secret in the response is shown once — store it to verify X-HonkIO-Signature on incoming requests.', inputSchema: z.object({
287
+ url: z.string().url().describe('HTTPS URL to deliver events to.'),
288
+ events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).describe('Event types to subscribe to.'),
289
+ }) }, async (input) => {
291
290
  try {
292
291
  const result = await client.createWebhook(input);
293
292
  return ok(result);
@@ -296,7 +295,7 @@ export function registerTools(server, client) {
296
295
  return formatError(err);
297
296
  }
298
297
  });
299
- server.tool('list_webhooks', 'List all registered webhook endpoints for your account.', {}, async () => {
298
+ server.registerTool('list_webhooks', { ...meta('list_webhooks'), description: 'List all registered webhook endpoints for your account.', inputSchema: z.object({}) }, async () => {
300
299
  try {
301
300
  const result = await client.listWebhooks();
302
301
  return ok(result);
@@ -305,12 +304,12 @@ export function registerTools(server, client) {
305
304
  return formatError(err);
306
305
  }
307
306
  });
308
- server.tool('update_webhook', 'Update a webhook endpoint URL, event subscriptions, or active status.', {
309
- id: z.string().describe('Webhook ID to update.'),
310
- url: z.string().url().optional().describe('New HTTPS URL.'),
311
- events: z.array(z.enum(WEBHOOK_EVENTS)).optional().describe('New event subscriptions (replaces existing).'),
312
- active: z.boolean().optional().describe('Enable or disable the webhook.'),
313
- }, async ({ id, ...updates }) => {
307
+ server.registerTool('update_webhook', { ...meta('update_webhook'), description: 'Update a webhook endpoint URL, event subscriptions, or active status.', inputSchema: z.object({
308
+ id: z.string().describe('Webhook ID to update.'),
309
+ url: z.string().url().optional().describe('New HTTPS URL.'),
310
+ events: z.array(z.enum(WEBHOOK_EVENTS)).optional().describe('New event subscriptions (replaces existing).'),
311
+ active: z.boolean().optional().describe('Enable or disable the webhook.'),
312
+ }) }, async ({ id, ...updates }) => {
314
313
  try {
315
314
  const result = await client.updateWebhook(id, updates);
316
315
  return ok(result);
@@ -319,9 +318,9 @@ export function registerTools(server, client) {
319
318
  return formatError(err);
320
319
  }
321
320
  });
322
- server.tool('delete_webhook', 'Delete a registered webhook endpoint.', {
323
- id: z.string().describe('Webhook ID to delete.'),
324
- }, async ({ id }) => {
321
+ server.registerTool('delete_webhook', { ...meta('delete_webhook'), description: 'Delete a registered webhook endpoint.', inputSchema: z.object({
322
+ id: z.string().describe('Webhook ID to delete.'),
323
+ }) }, async ({ id }) => {
325
324
  try {
326
325
  await client.deleteWebhook(id);
327
326
  return ok({ status: 'deleted', id });
@@ -330,10 +329,10 @@ export function registerTools(server, client) {
330
329
  return formatError(err);
331
330
  }
332
331
  });
333
- server.tool('list_webhook_deliveries', 'List recent delivery attempts for a webhook (success/failure, HTTP status, duration, timestamp). Use this to debug why customer events are not arriving or why a webhook was auto-disabled.', {
334
- webhook_id: z.string().describe('Webhook ID.'),
335
- limit: z.number().int().min(1).max(200).optional().describe('Max attempts to return (default 50).'),
336
- }, async ({ webhook_id, limit }) => {
332
+ server.registerTool('list_webhook_deliveries', { ...meta('list_webhook_deliveries'), description: 'List recent delivery attempts for a webhook (success/failure, HTTP status, duration, timestamp). Use this to debug why customer events are not arriving or why a webhook was auto-disabled.', inputSchema: z.object({
333
+ webhook_id: z.string().describe('Webhook ID.'),
334
+ limit: z.number().int().min(1).max(200).optional().describe('Max attempts to return (default 50).'),
335
+ }) }, async ({ webhook_id, limit }) => {
337
336
  try {
338
337
  const result = await client.listWebhookDeliveries(webhook_id, limit ? { limit } : undefined);
339
338
  return ok(result);
@@ -342,9 +341,9 @@ export function registerTools(server, client) {
342
341
  return formatError(err);
343
342
  }
344
343
  });
345
- server.tool('reactivate_webhook', 'Re-enable a webhook that was auto-disabled by repeated delivery failures. The destination URL is re-validated (SSRF check) before reactivation.', {
346
- webhook_id: z.string().describe('Webhook ID to reactivate.'),
347
- }, async ({ webhook_id }) => {
344
+ server.registerTool('reactivate_webhook', { ...meta('reactivate_webhook'), description: 'Re-enable a webhook that was auto-disabled by repeated delivery failures. The destination URL is re-validated (SSRF check) before reactivation.', inputSchema: z.object({
345
+ webhook_id: z.string().describe('Webhook ID to reactivate.'),
346
+ }) }, async ({ webhook_id }) => {
348
347
  try {
349
348
  const result = await client.reactivateWebhook(webhook_id);
350
349
  return ok(result);
@@ -353,11 +352,11 @@ export function registerTools(server, client) {
353
352
  return formatError(err);
354
353
  }
355
354
  });
356
- server.tool('list_webhook_dead_letters', 'List events that exhausted all retry attempts (dead-letter queue). These are events that failed delivery and were never received by your endpoint. Use replay_webhook_dead_letter to retry after fixing the issue.', {
357
- webhook_id: z.string().describe('Webhook ID.'),
358
- limit: z.number().int().min(1).max(200).optional().describe('Max rows (default 50).'),
359
- include_replayed: z.boolean().optional().describe('Include events that have been successfully replayed (default false).'),
360
- }, async ({ webhook_id, ...query }) => {
355
+ server.registerTool('list_webhook_dead_letters', { ...meta('list_webhook_dead_letters'), description: 'List events that exhausted all retry attempts (dead-letter queue). These are events that failed delivery and were never received by your endpoint. Use replay_webhook_dead_letter to retry after fixing the issue.', inputSchema: z.object({
356
+ webhook_id: z.string().describe('Webhook ID.'),
357
+ limit: z.number().int().min(1).max(200).optional().describe('Max rows (default 50).'),
358
+ include_replayed: z.boolean().optional().describe('Include events that have been successfully replayed (default false).'),
359
+ }) }, async ({ webhook_id, ...query }) => {
361
360
  try {
362
361
  const result = await client.listWebhookDeadLetters(webhook_id, query);
363
362
  return ok(result);
@@ -366,9 +365,9 @@ export function registerTools(server, client) {
366
365
  return formatError(err);
367
366
  }
368
367
  });
369
- server.tool('replay_webhook_dead_letter', 'Resend a dead-lettered event against the original webhook URL. The event is re-signed with the current timestamp; on success the dead-letter row is marked as replayed.', {
370
- dead_letter_id: z.string().describe('Dead-letter row ID (from list_webhook_dead_letters).'),
371
- }, async ({ dead_letter_id }) => {
368
+ server.registerTool('replay_webhook_dead_letter', { ...meta('replay_webhook_dead_letter'), description: 'Resend a dead-lettered event against the original webhook URL. The event is re-signed with the current timestamp; on success the dead-letter row is marked as replayed.', inputSchema: z.object({
369
+ dead_letter_id: z.string().describe('Dead-letter row ID (from list_webhook_dead_letters).'),
370
+ }) }, async ({ dead_letter_id }) => {
372
371
  try {
373
372
  const result = await client.replayWebhookDeadLetter(dead_letter_id);
374
373
  return ok(result);
@@ -377,9 +376,9 @@ export function registerTools(server, client) {
377
376
  return formatError(err);
378
377
  }
379
378
  });
380
- server.tool('discard_webhook_dead_letter', 'Permanently discard a dead-lettered event without replaying it. Use when the event is no longer relevant (e.g. the underlying message has expired).', {
381
- dead_letter_id: z.string().describe('Dead-letter row ID.'),
382
- }, async ({ dead_letter_id }) => {
379
+ server.registerTool('discard_webhook_dead_letter', { ...meta('discard_webhook_dead_letter'), description: 'Permanently discard a dead-lettered event without replaying it. Use when the event is no longer relevant (e.g. the underlying message has expired).', inputSchema: z.object({
380
+ dead_letter_id: z.string().describe('Dead-letter row ID.'),
381
+ }) }, async ({ dead_letter_id }) => {
383
382
  try {
384
383
  await client.discardWebhookDeadLetter(dead_letter_id);
385
384
  return ok({ status: 'discarded', dead_letter_id });
@@ -389,7 +388,7 @@ export function registerTools(server, client) {
389
388
  }
390
389
  });
391
390
  // ── Pricing ───────────────────────────────────────────────
392
- server.tool('get_pricing', 'Current HonkIO prices in CAD cents: per-SMS-segment cost for outbound messages, the per-segment cost charged for inbound SMS received on a provisioned number (STOP/START/HELP keywords are free), the verification upcharge, what a typical single-segment verification costs, phone-number upfront (first month) and monthly rent, and the one-time activation fee charged with the first month on every number. Call this before quoting a cost to a user or deciding whether an operation is worth its price — these are set at runtime and change without a release, so never assume a figure.', {}, async () => {
391
+ server.registerTool('get_pricing', { ...meta('get_pricing'), description: 'Current HonkIO prices in CAD cents: per-SMS-segment cost for outbound messages, the per-segment cost charged for inbound SMS received on a provisioned number (STOP/START/HELP keywords are free), the verification upcharge, what a typical single-segment verification costs, phone-number upfront (first month) and monthly rent, and the one-time activation fee charged with the first month on every number. Call this before quoting a cost to a user or deciding whether an operation is worth its price — these are set at runtime and change without a release, so never assume a figure.', inputSchema: z.object({}) }, async () => {
393
392
  try {
394
393
  const result = await client.getPricing();
395
394
  return ok(result);
@@ -399,13 +398,13 @@ export function registerTools(server, client) {
399
398
  }
400
399
  });
401
400
  // ── Verify (OTP) ──────────────────────────────────────────
402
- server.tool('start_verification', 'Send a phone-number verification code (OTP) via SMS. The recipient receives a 4/6/8-digit numeric code. With a live key (mk_live_...) this sends a real SMS and charges the per-part message cost (one part unless app_name is long or non-GSM; settled to the carrier\'s part count) plus the verification upcharge — call get_pricing for the current amount; a rejected OTP is refunded in full. With a test-mode key (mk_test_...) nothing is sent and nothing is charged: the code is always zeros for the chosen length (000000 for 6 digits), and the returned verification has mode "TEST". Check the "mode" field to confirm which happened.', {
403
- from: z.string().describe('Your HonkIO number (E.164, must be active on your account).'),
404
- to: z.string().describe('The phone number to verify (E.164, Canadian numbers only).'),
405
- code_length: z.union([z.literal(4), z.literal(6), z.literal(8)]).optional().describe('OTP digit length (default 6).'),
406
- ttl_minutes: z.number().int().min(1).max(60).optional().describe('Minutes until the code expires (default 10).'),
407
- app_name: z.string().max(64).optional().describe('Brand name shown in the SMS body (default HonkIO).'),
408
- }, async (input) => {
401
+ server.registerTool('start_verification', { ...meta('start_verification'), description: 'Send a phone-number verification code (OTP) via SMS. The recipient receives a 4/6/8-digit numeric code. With a live key (mk_live_...) this sends a real SMS and charges the per-part message cost (one part unless app_name is long or non-GSM; settled to the carrier\'s part count) plus the verification upcharge — call get_pricing for the current amount; a rejected OTP is refunded in full. With a test-mode key (mk_test_...) nothing is sent and nothing is charged: the code is always zeros for the chosen length (000000 for 6 digits), and the returned verification has mode "TEST". Check the "mode" field to confirm which happened.', inputSchema: z.object({
402
+ from: z.string().describe('Your HonkIO number (E.164, must be active on your account).'),
403
+ to: z.string().describe('The phone number to verify (E.164, Canadian numbers only).'),
404
+ code_length: z.union([z.literal(4), z.literal(6), z.literal(8)]).optional().describe('OTP digit length (default 6).'),
405
+ ttl_minutes: z.number().int().min(1).max(60).optional().describe('Minutes until the code expires (default 10).'),
406
+ app_name: z.string().max(64).optional().describe('Brand name shown in the SMS body (default HonkIO).'),
407
+ }) }, async (input) => {
409
408
  try {
410
409
  const result = await client.startVerification(input);
411
410
  return ok(result);
@@ -414,10 +413,10 @@ export function registerTools(server, client) {
414
413
  return formatError(err);
415
414
  }
416
415
  });
417
- server.tool('check_verification', 'Submit the OTP a user entered to complete verification. Returns the verification status (verified, invalid_code, expired, max_attempts).', {
418
- verification_id: z.string().describe('Verification ID returned from start_verification.'),
419
- code: z.string().regex(/^\d{4,8}$/).describe('The 4/6/8-digit code the user submitted.'),
420
- }, async ({ verification_id, code }) => {
416
+ server.registerTool('check_verification', { ...meta('check_verification'), description: 'Submit the OTP a user entered to complete verification. Returns the verification status (verified, invalid_code, expired, max_attempts).', inputSchema: z.object({
417
+ verification_id: z.string().describe('Verification ID returned from start_verification.'),
418
+ code: z.string().regex(/^\d{4,8}$/).describe('The 4/6/8-digit code the user submitted.'),
419
+ }) }, async ({ verification_id, code }) => {
421
420
  try {
422
421
  const result = await client.checkVerification(verification_id, code);
423
422
  return ok(result);
@@ -426,9 +425,9 @@ export function registerTools(server, client) {
426
425
  return formatError(err);
427
426
  }
428
427
  });
429
- server.tool('get_verification', 'Look up the current state of a verification (status, attempts, expiry, and whether it was real). The "mode" field is "LIVE" for a real billed SMS or "TEST" for a simulation; cost_cents is 0 on a TEST verification.', {
430
- verification_id: z.string().describe('Verification ID.'),
431
- }, async ({ verification_id }) => {
428
+ server.registerTool('get_verification', { ...meta('get_verification'), description: 'Look up the current state of a verification (status, attempts, expiry, and whether it was real). The "mode" field is "LIVE" for a real billed SMS or "TEST" for a simulation; cost_cents is 0 on a TEST verification.', inputSchema: z.object({
429
+ verification_id: z.string().describe('Verification ID.'),
430
+ }) }, async ({ verification_id }) => {
432
431
  try {
433
432
  const result = await client.getVerification(verification_id);
434
433
  return ok(result);
@@ -437,12 +436,12 @@ export function registerTools(server, client) {
437
436
  return formatError(err);
438
437
  }
439
438
  });
440
- server.tool('list_verifications', 'List recent verifications for your account, optionally filtered by phone number or status.', {
441
- phone_number: z.string().optional().describe('Filter to verifications for this E.164 number.'),
442
- status: z.enum(['pending', 'verified', 'expired', 'max_attempts']).optional().describe('Filter by status.'),
443
- page: z.number().int().min(1).optional(),
444
- limit: z.number().int().min(1).max(200).optional(),
445
- }, async (input) => {
439
+ server.registerTool('list_verifications', { ...meta('list_verifications'), description: 'List recent verifications for your account, optionally filtered by phone number or status.', inputSchema: z.object({
440
+ phone_number: z.string().optional().describe('Filter to verifications for this E.164 number.'),
441
+ status: z.enum(['pending', 'verified', 'expired', 'max_attempts']).optional().describe('Filter by status.'),
442
+ page: z.number().int().min(1).optional(),
443
+ limit: z.number().int().min(1).max(200).optional(),
444
+ }) }, async (input) => {
446
445
  try {
447
446
  const result = await client.listVerifications(input);
448
447
  return ok(result);
@@ -452,7 +451,49 @@ export function registerTools(server, client) {
452
451
  }
453
452
  });
454
453
  // ── Account ───────────────────────────────────────────────
455
- server.tool('whoami', 'Identify the HonkIO account the configured API key belongs to. Returns the account ID, name, credit balance and status. Call this first when you need an account ID, or to confirm which account and mode (live or test) the key is for.', {}, async () => {
454
+ // ─── Sending limits ──────────────────────────────────────────
455
+ server.registerTool('get_send_limit', { ...meta('get_send_limit'), description: 'Your account\'s sending limits and current usage: the daily cap (a rolling 24 hours, 250/day for new accounts until a volume request is approved), ' +
456
+ 'how many live messages were sent in the last 24 hours and how many remain, the probation status that gates "request a higher volume" ' +
457
+ '(it opens 30 days after the first live send), the per-recipient cap (recipient_rate_per_hour / recipient_rate_per_day — a breach comes back as RECIPIENT_RATE_LIMITED with Retry-After), ' +
458
+ 'whether live sending is currently paused and until when, and past volume requests. ' +
459
+ 'Check this when a send is refused with DAILY_LIMIT_REACHED, RECIPIENT_RATE_LIMITED or SENDING_PAUSED, or before a batch of sends.', inputSchema: z.object({}) }, async () => {
460
+ try {
461
+ const result = await client.getSendLimit();
462
+ return ok(result);
463
+ }
464
+ catch (err) {
465
+ return formatError(err);
466
+ }
467
+ });
468
+ server.registerTool('request_send_limit', { ...meta('request_send_limit'), description: 'File a "request a higher volume" for HonkIO staff to review. Only available once the account has completed its probation period ' +
469
+ '(get_send_limit → probation.eligible_to_request); one request may be pending at a time. Omit requested_limit to ask for the standard ' +
470
+ 'approved volume (get_send_limit → approved_daily_limit), or name a target above the current cap. The owner is emailed when it is decided. ' +
471
+ 'HonkIO is for transactional and relationship messaging: describe what is sent, to whom, and roughly how many a day — bulk marketing is not approved.', inputSchema: z.object({
472
+ requested_limit: z.number().int().min(1).optional().describe('Messages per day wanted (a total, not an increment). Must exceed the current daily limit. Omit for the standard approved volume.'),
473
+ reason: z.string().min(10).max(1000).describe('What is sent and to whom, and the expected daily volume. Staff decide on this.'),
474
+ }) }, async (input) => {
475
+ try {
476
+ const result = await client.requestSendLimit(input);
477
+ return ok(result);
478
+ }
479
+ catch (err) {
480
+ return formatError(err);
481
+ }
482
+ });
483
+ server.registerTool('get_topup_allowance', { ...meta('get_topup_allowance'), description: 'How much credit can be added to the account right now. Top-ups are capped by a maximum balance and a rolling 30-day total ' +
484
+ '(platform defaults $500 and $1,000 CAD, raisable per account); a checkout above max_topup_now_cents is refused with TOPUP_LIMIT_REACHED. ' +
485
+ 'Returns the caps, the current balance, the 30-day total so far, and the amount that fits now, all in CAD cents.', inputSchema: z.object({
486
+ account_id: ACCOUNT_ID_ARG,
487
+ }) }, async ({ account_id }) => {
488
+ try {
489
+ const result = await client.getTopupAllowance(await resolveAccountId(account_id));
490
+ return ok(result);
491
+ }
492
+ catch (err) {
493
+ return formatError(err);
494
+ }
495
+ });
496
+ server.registerTool('whoami', { ...meta('whoami'), description: 'Identify the HonkIO account the configured API key belongs to. Returns the account ID, name, credit balance and status. Call this first when you need an account ID, or to confirm which account and mode (live or test) the key is for.', inputSchema: z.object({}) }, async () => {
456
497
  try {
457
498
  const result = await client.getCurrentAccount();
458
499
  return ok(result);
@@ -461,11 +502,11 @@ export function registerTools(server, client) {
461
502
  return formatError(err);
462
503
  }
463
504
  });
464
- server.tool('get_account', 'Get details for your HonkIO account including name, credit balance, status, and active API keys. ' +
465
- 'Also returns phone_number_limit and phone_numbers_used — check these before calling ' +
466
- 'provision_phone_number, since a purchase past the limit is rejected with NUMBER_LIMIT_REACHED.', {
467
- account_id: ACCOUNT_ID_ARG,
468
- }, async ({ account_id }) => {
505
+ server.registerTool('get_account', { ...meta('get_account'), description: 'Get details for your HonkIO account including name, credit balance, status, and active API keys. ' +
506
+ 'Also returns phone_number_limit and phone_numbers_used — check these before calling ' +
507
+ 'provision_phone_number, since a purchase past the limit is rejected with NUMBER_LIMIT_REACHED.', inputSchema: z.object({
508
+ account_id: ACCOUNT_ID_ARG,
509
+ }) }, async ({ account_id }) => {
469
510
  try {
470
511
  const result = await client.getAccount(await resolveAccountId(account_id));
471
512
  return ok(result);
@@ -474,10 +515,10 @@ export function registerTools(server, client) {
474
515
  return formatError(err);
475
516
  }
476
517
  });
477
- server.tool('update_account', 'Update your HonkIO account name.', {
478
- account_id: ACCOUNT_ID_ARG,
479
- name: z.string().min(1).max(200).describe('New account name.'),
480
- }, async ({ account_id, name }) => {
518
+ server.registerTool('update_account', { ...meta('update_account'), description: 'Update your HonkIO account name.', inputSchema: z.object({
519
+ account_id: ACCOUNT_ID_ARG,
520
+ name: z.string().min(1).max(200).describe('New account name.'),
521
+ }) }, async ({ account_id, name }) => {
481
522
  try {
482
523
  const result = await client.updateAccount(await resolveAccountId(account_id), { name });
483
524
  return ok(result);
@@ -486,11 +527,11 @@ export function registerTools(server, client) {
486
527
  return formatError(err);
487
528
  }
488
529
  });
489
- server.tool('get_usage', 'Get usage statistics for your account including message counts and spending. Defaults to the current calendar month.', {
490
- account_id: ACCOUNT_ID_ARG,
491
- from: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to start of current month.'),
492
- to: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'),
493
- }, async ({ account_id, from, to }) => {
530
+ server.registerTool('get_usage', { ...meta('get_usage'), description: 'Get usage statistics for your account including message counts, spending, and live delivery health: "delivery" (liveOutbound, delivered, failed, undelivered, pending, failureRatePct) and "byNumber" (the same per sending number). A rising failure rate usually means wrong numbers, landlines, or a script retrying one recipient. Defaults to the current calendar month.', inputSchema: z.object({
531
+ account_id: ACCOUNT_ID_ARG,
532
+ from: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to start of current month.'),
533
+ to: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'),
534
+ }) }, async ({ account_id, from, to }) => {
494
535
  try {
495
536
  const result = await client.getUsage(await resolveAccountId(account_id), { from, to });
496
537
  return ok(result);
@@ -499,11 +540,11 @@ export function registerTools(server, client) {
499
540
  return formatError(err);
500
541
  }
501
542
  });
502
- server.tool('create_api_key', 'Issue a new API key for your account. The raw key is shown once — store it securely.', {
503
- account_id: ACCOUNT_ID_ARG,
504
- mode: z.enum(['live', 'test']).optional().describe('Key mode: "live" (real SMS, charges apply) or "test" (sandbox, no charges). Default: "live".'),
505
- label: z.string().max(100).optional().describe('Human-readable label to identify this key (e.g. "Production server", "CI pipeline").'),
506
- }, async ({ account_id, mode, label }) => {
543
+ server.registerTool('create_api_key', { ...meta('create_api_key'), description: 'Issue a new API key for your account. The raw key is shown once — store it securely.', inputSchema: z.object({
544
+ account_id: ACCOUNT_ID_ARG,
545
+ mode: z.enum(['live', 'test']).optional().describe('Key mode: "live" (real SMS, charges apply) or "test" (sandbox, no charges). Default: "live".'),
546
+ label: z.string().max(100).optional().describe('Human-readable label to identify this key (e.g. "Production server", "CI pipeline").'),
547
+ }) }, async ({ account_id, mode, label }) => {
507
548
  try {
508
549
  const result = await client.createApiKey(await resolveAccountId(account_id), { mode, label });
509
550
  return ok(result);
@@ -512,10 +553,10 @@ export function registerTools(server, client) {
512
553
  return formatError(err);
513
554
  }
514
555
  });
515
- server.tool('revoke_api_key', 'Revoke an API key, immediately blocking all requests using that key. This cannot be undone.', {
516
- account_id: ACCOUNT_ID_ARG,
517
- key_id: z.string().describe('The API key record ID to revoke (from get_account api_keys list).'),
518
- }, async ({ account_id, key_id }) => {
556
+ server.registerTool('revoke_api_key', { ...meta('revoke_api_key'), description: 'Revoke an API key, immediately blocking all requests using that key. This cannot be undone.', inputSchema: z.object({
557
+ account_id: ACCOUNT_ID_ARG,
558
+ key_id: z.string().describe('The API key record ID to revoke (from get_account api_keys list).'),
559
+ }) }, async ({ account_id, key_id }) => {
519
560
  try {
520
561
  await client.revokeApiKey(await resolveAccountId(account_id), key_id);
521
562
  return ok({ status: 'revoked', key_id });
@@ -524,10 +565,10 @@ export function registerTools(server, client) {
524
565
  return formatError(err);
525
566
  }
526
567
  });
527
- server.tool('rotate_api_key', 'Atomically issue a replacement API key inheriting the same permissions/label/mode as the source key, and revoke the source key in the same transaction. Returns the new raw key ONCE.', {
528
- account_id: ACCOUNT_ID_ARG,
529
- key_id: z.string().describe('The API key record ID to rotate.'),
530
- }, async ({ account_id, key_id }) => {
568
+ server.registerTool('rotate_api_key', { ...meta('rotate_api_key'), description: 'Atomically issue a replacement API key inheriting the same permissions/label/mode as the source key, and revoke the source key in the same transaction. Returns the new raw key ONCE.', inputSchema: z.object({
569
+ account_id: ACCOUNT_ID_ARG,
570
+ key_id: z.string().describe('The API key record ID to rotate.'),
571
+ }) }, async ({ account_id, key_id }) => {
531
572
  try {
532
573
  const result = await client.rotateApiKey(await resolveAccountId(account_id), key_id);
533
574
  return ok(result);