@honkio/mcp 1.4.0 → 1.6.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 +193 -65
  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 +43 -5
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +97 -22
  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 +23 -23
  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 +81 -0
  41. package/dist/toolMeta.js.map +1 -0
  42. package/dist/tools.d.ts +10 -1
  43. package/dist/tools.d.ts.map +1 -1
  44. package/dist/tools.js +344 -209
  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',
@@ -8,7 +9,11 @@ const DNCL_EXEMPTIONS = [
8
9
  'newspaper_subscription',
9
10
  'personal',
10
11
  ];
11
- const WEBHOOK_EVENTS = [
12
+ // Copied from api/src/services/webhookDelivery.ts (WebhookEventType) minus
13
+ // the email.*/email_domain.* events — the email product has no MCP surface
14
+ // yet. Keep the two lists in step (see webhookEvents.test.ts). Exported so
15
+ // that test can compare it against the API source without duplicating it.
16
+ export const WEBHOOK_EVENTS = [
12
17
  'message.queued',
13
18
  'message.sending',
14
19
  'message.sent',
@@ -21,14 +26,84 @@ const WEBHOOK_EVENTS = [
21
26
  'account.delivery_warning',
22
27
  'account.sending_paused',
23
28
  'account.spend_warning',
29
+ 'phone_number.suspended',
30
+ 'phone_number.released',
24
31
  ];
32
+ // Copied from api/src/permissions.ts (RESOURCES) — the resource names an API
33
+ // key's permissions object may name. Keep the two lists in step (see
34
+ // permissionResources.test.ts). Exported so that test can compare it against
35
+ // the API source without duplicating it.
36
+ export const PERMISSION_RESOURCES = [
37
+ 'messages',
38
+ 'phone_numbers',
39
+ 'contacts',
40
+ 'contact_groups',
41
+ 'lists',
42
+ 'compliance',
43
+ 'webhooks',
44
+ 'verify',
45
+ 'account',
46
+ 'api_keys',
47
+ 'emails',
48
+ 'email_domains',
49
+ 'email_suppressions',
50
+ ];
51
+ const PERMISSIONS_ARG = z
52
+ .record(z.string(), z.string())
53
+ .refine((perms) => Object.entries(perms).every(([resource, ops]) => PERMISSION_RESOURCES.includes(resource) && /^[rwmd]*$/.test(ops)), { message: `Resource names must be one of: ${PERMISSION_RESOURCES.join(', ')}. Each value is a string made only of r, w, m, d.` })
54
+ .optional()
55
+ .describe('Per-resource permissions: each key is a resource name (' + PERMISSION_RESOURCES.join(', ') +
56
+ '), each value a string of allowed operations (r=read, w=write, m=modify, d=delete). Omit a resource to deny it. ' +
57
+ 'Omit this whole field to default to the calling key\'s own permissions. A key can only be created with permissions the calling key itself holds.');
25
58
  function ok(data) {
26
59
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
27
60
  }
28
- function formatError(err) {
61
+ // Since Task 8.1, a VALIDATION_ERROR's top-level `message` is the generic
62
+ // bilingual sentence ("Request validation failed."); the field-specific text
63
+ // an agent needs to self-correct a bad call moved into `details` — an AJV
64
+ // array of `{instancePath, message, ...}` entries. Other error codes (e.g.
65
+ // the suspended/closed 403) carry an ad hoc object there instead
66
+ // (`{reason: 'account_suspended'}`). Render whichever shape it is, so that
67
+ // text isn't lost.
68
+ const DETAILS_CHAR_CAP = 1000;
69
+ function isAjvDetails(details) {
70
+ return Array.isArray(details) && details.length > 0 &&
71
+ details.every((d) => d && typeof d === 'object' && 'instancePath' in d && 'message' in d);
72
+ }
73
+ function formatDetails(details) {
74
+ const rendered = isAjvDetails(details)
75
+ ? details.map((d) => `${d.instancePath} ${d.message}`).join('\n')
76
+ : JSON.stringify(details);
77
+ return rendered.length > DETAILS_CHAR_CAP ? `${rendered.slice(0, DETAILS_CHAR_CAP)}…` : rendered;
78
+ }
79
+ // The response body's own standard fields (see api/src/i18n/errors.ts'
80
+ // buildError): everything else is a code-specific extra the API attached at
81
+ // the top level rather than under `details` — e.g. `attempts_remaining` on a
82
+ // verify-check 422 (api/src/routes/verify/index.ts). HonkioError.details
83
+ // only ever holds `details`; the raw body (HonkioError.body) is what carries
84
+ // these, so render whatever's left over from it too.
85
+ const KNOWN_ERROR_BODY_FIELDS = new Set(['code', 'message', 'messageEn', 'messageFr', 'statusCode', 'details', 'error']);
86
+ function formatExtraFields(body) {
87
+ if (!body)
88
+ return undefined;
89
+ const lines = Object.entries(body)
90
+ .filter(([key]) => !KNOWN_ERROR_BODY_FIELDS.has(key))
91
+ .map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`);
92
+ if (lines.length === 0)
93
+ return undefined;
94
+ const rendered = lines.join('\n');
95
+ return rendered.length > DETAILS_CHAR_CAP ? `${rendered.slice(0, DETAILS_CHAR_CAP)}…` : rendered;
96
+ }
97
+ export function formatError(err) {
29
98
  if (err instanceof HonkioError) {
99
+ const parts = [`Error [${err.code}]: ${err.message}`];
100
+ if (err.details !== undefined)
101
+ parts.push(formatDetails(err.details));
102
+ const extra = formatExtraFields(err.body);
103
+ if (extra !== undefined)
104
+ parts.push(extra);
30
105
  return {
31
- content: [{ type: 'text', text: `Error [${err.code}]: ${err.message}` }],
106
+ content: [{ type: 'text', text: parts.join('\n') }],
32
107
  isError: true,
33
108
  };
34
109
  }
@@ -39,40 +114,31 @@ function formatError(err) {
39
114
  }
40
115
  export function registerTools(server, client) {
41
116
  /**
42
- * Account-scoped tools take an account_id, but an API key does not carry one
43
- * and nothing used to return it, so an agent could not call them without the
44
- * user pasting an id from the dashboard. Omitting it now resolves the caller
45
- * from the key. Cached because it cannot change for the life of the process —
46
- * the key is fixed at startup.
117
+ * Account-scoped tools take an optional account_id. Omitting it resolves the
118
+ * caller from the key; the client memoizes that per instance.
47
119
  */
48
- let cachedAccountId;
49
- const resolveAccountId = async (provided) => {
50
- if (provided)
51
- return provided;
52
- if (cachedAccountId)
53
- return cachedAccountId;
54
- const me = await client.getCurrentAccount();
55
- cachedAccountId = me.account.id;
56
- return cachedAccountId;
57
- };
120
+ const resolveAccountId = (provided) => provided ? Promise.resolve(provided) : client.currentAccountId();
58
121
  const ACCOUNT_ID_ARG = z
59
122
  .string()
60
123
  .optional()
61
124
  .describe('Your account ID. Omit it and it is resolved from your API key.');
62
125
  // ── Messages ──────────────────────────────────────────────
63
- 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. A send the carrier rejects outright costs nothing; a message the carrier accepts but cannot deliver keeps its charge, and so does a send to a reserved exchange (555-XXXX, N11 exchanges, carrier test codes) — those are blocked here, never reach the carrier, and come back 201 with status "FAILED" and error_code "RESERVED_DESTINATION", in test mode too. 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. ' +
64
- '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), ' +
65
- '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. ' +
66
- 'Link shorteners (bit.ly and similar) are refused in both modes (LINK_SHORTENER_BLOCKED); use the full URL. ' +
67
- 'When you retry a send, pass the same idempotency_key: the API returns the original message instead of sending and charging again. ' +
68
- 'A 503 CARRIER_UNAVAILABLE means the carrier could not be reached — nothing was sent or charged; retry shortly.', {
69
- from: z.string().describe('Sending phone number in E.164 format (e.g. +14165551234). Must be a number provisioned on your account.'),
70
- to: z.string().describe('Recipient phone number in E.164 format. Must be a Canadian number.'),
71
- 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.'),
72
- skip_consent_check: z.boolean().optional().describe('Skip the CASL consent gate. Use only when you have consent recorded outside HonkIO.'),
73
- 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.'),
74
- 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.'),
75
- }, async (input) => {
126
+ 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 simulate a send: it runs the same compliance checks as a live send (consent, opt-out, allow/deny lists, reserved and undeliverable destinations) but does not require owning the from number, and nothing is sent or charged. A test-mode send still returns status "DELIVERED": check the "mode" field, not the status, to tell a simulation from a real send. ' +
127
+ '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), ' +
128
+ '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. ' +
129
+ 'Link shorteners (bit.ly and similar) are refused in both modes (LINK_SHORTENER_BLOCKED); use the full URL. ' +
130
+ 'When you retry a send, pass the same idempotency_key: the API returns the original message instead of sending and charging again. ' +
131
+ 'A 503 CARRIER_UNAVAILABLE means the carrier could not be reached: nothing was sent or charged; retry shortly. ' +
132
+ 'A 503 CARRIER_TIMEOUT means the carrier did not answer in time: the message may or may not have been sent and nothing is charged; check list_messages before sending again. A retry with the same idempotency_key returns the failed message instead of sending it again. ' +
133
+ '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. ' +
134
+ 'A 422 NON_CANADIAN_NUMBER means the recipient is not a Canadian number, and a 422 INVALID_PHONE_NUMBER means the carrier found the number invalid; neither costs anything.', inputSchema: z.object({
135
+ from: z.string().describe('Sending phone number in E.164 format (e.g. +14165551234). Must be a number provisioned on your account.'),
136
+ to: z.string().describe('Recipient phone number in E.164 format. Must be a Canadian number.'),
137
+ 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.'),
138
+ skip_consent_check: z.boolean().optional().describe('Skip the CASL consent gate. Test-mode keys only: a live key gets 403 FORBIDDEN. Use only when you have consent recorded outside HonkIO.'),
139
+ 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.'),
140
+ 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.'),
141
+ }) }, async (input) => {
76
142
  try {
77
143
  const result = await client.sendMessage(input);
78
144
  return ok(result);
@@ -81,16 +147,16 @@ export function registerTools(server, client) {
81
147
  return formatError(err);
82
148
  }
83
149
  });
84
- 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 TEST simulation or when the carrier refused the send outright — a message the carrier accepted but could not deliver, or one blocked as a reserved destination, 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 (or RESERVED_DESTINATION for a reserved exchange blocked by the platform).', {
85
- from: z.string().optional().describe('Filter by sending phone number (E.164).'),
86
- to: z.string().optional().describe('Filter by recipient phone number (E.164).'),
87
- status: z.enum(['QUEUED', 'SENDING', 'SENT', 'DELIVERED', 'FAILED', 'UNDELIVERED', 'RECEIVED']).optional().describe('Filter by message status.'),
88
- direction: z.enum(['OUTBOUND', 'INBOUND']).optional().describe('Filter by message direction.'),
89
- page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
90
- limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20, max: 100).'),
91
- date_from: z.string().optional().describe('Filter messages from this date (YYYY-MM-DD).'),
92
- date_to: z.string().optional().describe('Filter messages to this date (YYYY-MM-DD).'),
93
- }, async (input) => {
150
+ 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({
151
+ from: z.string().optional().describe('Filter by sending phone number (E.164).'),
152
+ to: z.string().optional().describe('Filter by recipient phone number (E.164).'),
153
+ status: z.enum(['QUEUED', 'SENDING', 'SENT', 'DELIVERED', 'FAILED', 'UNDELIVERED', 'RECEIVED']).optional().describe('Filter by message status.'),
154
+ direction: z.enum(['OUTBOUND', 'INBOUND']).optional().describe('Filter by message direction.'),
155
+ page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
156
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20, max: 100).'),
157
+ date_from: z.string().optional().describe('Filter messages from this date (YYYY-MM-DD).'),
158
+ date_to: z.string().optional().describe('Filter messages to this date (YYYY-MM-DD).'),
159
+ }) }, async (input) => {
94
160
  try {
95
161
  const result = await client.listMessages(input);
96
162
  return ok(result);
@@ -99,9 +165,9 @@ export function registerTools(server, client) {
99
165
  return formatError(err);
100
166
  }
101
167
  });
102
- 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 TEST simulation or when the carrier refused the send outright; an undelivered or reserved-destination 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.', {
103
- id: z.string().describe('Message ID.'),
104
- }, async ({ id }) => {
168
+ server.registerTool('get_message', { ...meta('get_message'), description: 'Get full details of a single SMS message by its ID. 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({
169
+ id: z.string().describe('Message ID.'),
170
+ }) }, async ({ id }) => {
105
171
  try {
106
172
  const result = await client.getMessage(id);
107
173
  return ok(result);
@@ -111,30 +177,45 @@ export function registerTools(server, client) {
111
177
  }
112
178
  });
113
179
  // ── Phone Numbers ─────────────────────────────────────────
114
- server.tool('search_phone_numbers', 'Search available Canadian phone numbers you can provision. Filter by area code to find numbers in a specific province.', {
115
- 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).'),
116
- limit: z.number().int().min(1).max(50).optional().describe('Number of results to return (default: 10, max: 50).'),
117
- }, async (input) => {
180
+ const AREA_CODE = z.string().regex(/^\d{3}$/);
181
+ server.registerTool('list_area_codes', { ...meta('list_area_codes'), description: 'List the provinces HonkIO has numbers in and the active Canadian area codes within each. Use this to see what is available before calling search_phone_numbers with an area_code.', inputSchema: z.object({}) }, async () => {
182
+ try {
183
+ const result = await client.listAreaCodes();
184
+ return ok(result);
185
+ }
186
+ catch (err) {
187
+ return formatError(err);
188
+ }
189
+ });
190
+ server.registerTool('search_phone_numbers', { ...meta('search_phone_numbers'), description: 'Search available Canadian phone numbers you can provision. Filter by one area code (area_code) or up to five (area_codes) to find numbers in specific provinces; omit both to search everywhere. See list_area_codes for what is active.', inputSchema: z.object({
191
+ area_code: AREA_CODE.optional().describe('A single Canadian area code to filter by (e.g. "416" for Toronto, "514" for Montreal, "604" for Vancouver). Combined with area_codes if both are given.'),
192
+ area_codes: z.array(AREA_CODE).max(5).optional().describe('Up to 5 Canadian area codes to filter by.'),
193
+ limit: z.number().int().min(1).max(50).optional().describe('Number of results to return (default: 10, max: 50).'),
194
+ }) }, async ({ area_code, area_codes, limit }) => {
118
195
  try {
119
- const result = await client.searchPhoneNumbers(input);
196
+ const codes = [...(area_code ? [area_code] : []), ...(area_codes ?? [])];
197
+ const result = await client.searchPhoneNumbers({
198
+ area_codes: codes.length ? codes.join(',') : undefined,
199
+ limit,
200
+ });
120
201
  return ok(result);
121
202
  }
122
203
  catch (err) {
123
204
  return formatError(err);
124
205
  }
125
206
  });
126
- server.tool('provision_phone_number', 'Provision (purchase) a Canadian phone number to your account. Get the phone_number value from search_phone_numbers first. ' +
127
- 'On a live key this spends real money: the first month\'s rent plus a one-time activation fee are debited together the ' +
128
- 'moment the number is bought (search results and get_pricing show both amounts), and rent recurs monthly until ' +
129
- 'release_phone_number. A balance that cannot cover the total returns 402 INSUFFICIENT_BALANCE with nothing charged. ' +
130
- 'Accounts are capped on how many numbers they may hold at once — a per-account limit that falls back to a platform-wide ' +
131
- 'default staff can change at runtime — exceeding it returns NUMBER_LIMIT_REACHED, and a higher allowance is requested from ' +
132
- 'the HonkIO dashboard, not through this API. May also return a 409 PURCHASE_IN_PROGRESS if another purchase for this ' +
133
- 'account is already running; this is transient and safe to retry after a short delay — unlike the terminal 409 returned ' +
134
- 'when the number is already owned, it does not mean the purchase failed. Firing several provision_phone_number calls in ' +
135
- 'parallel will produce these; retry rather than dropping them.', {
136
- phone_number: z.string().describe('E.164 phone number to provision (e.g. +14165551234). Must be from the search_phone_numbers results.'),
137
- }, async ({ phone_number }) => {
207
+ 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. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. ' +
208
+ 'This spends real money: the first month\'s rent plus a one-time activation fee are debited together the ' +
209
+ 'moment the number is bought (search results and get_pricing show both amounts), and rent recurs monthly until ' +
210
+ 'release_phone_number. A balance that cannot cover the total returns 402 INSUFFICIENT_BALANCE with nothing charged. ' +
211
+ 'Accounts are capped on how many numbers they may hold at once: a per-account limit that falls back to a platform-wide ' +
212
+ 'default staff can change at runtime. Exceeding it returns NUMBER_LIMIT_REACHED, and a higher allowance is requested with ' +
213
+ 'request_number_allowance. May also return a 409 PURCHASE_IN_PROGRESS if another purchase for this ' +
214
+ 'account is already running; this is transient and safe to retry after a short delay, unlike the terminal 409 returned ' +
215
+ 'when the number is already owned, it does not mean the purchase failed. Firing several provision_phone_number calls in ' +
216
+ 'parallel will produce these; retry rather than dropping them.', inputSchema: z.object({
217
+ phone_number: z.string().describe('E.164 phone number to provision (e.g. +14165551234). Must be from the search_phone_numbers results.'),
218
+ }) }, async ({ phone_number }) => {
138
219
  try {
139
220
  const result = await client.provisionPhoneNumber(phone_number);
140
221
  return ok(result);
@@ -143,7 +224,7 @@ export function registerTools(server, client) {
143
224
  return formatError(err);
144
225
  }
145
226
  });
146
- server.tool('list_phone_numbers', "List all phone numbers provisioned on your account.", {}, async () => {
227
+ server.registerTool('list_phone_numbers', { ...meta('list_phone_numbers'), description: "List all phone numbers provisioned on your account.", inputSchema: z.object({}) }, async () => {
147
228
  try {
148
229
  const result = await client.listPhoneNumbers();
149
230
  return ok(result);
@@ -152,9 +233,9 @@ export function registerTools(server, client) {
152
233
  return formatError(err);
153
234
  }
154
235
  });
155
- server.tool('get_phone_number', 'Get details for a specific provisioned phone number.', {
156
- id: z.string().describe('Phone number record ID.'),
157
- }, async ({ id }) => {
236
+ server.registerTool('get_phone_number', { ...meta('get_phone_number'), description: 'Get details for a specific provisioned phone number.', inputSchema: z.object({
237
+ id: z.string().describe('Phone number record ID.'),
238
+ }) }, async ({ id }) => {
158
239
  try {
159
240
  const result = await client.getPhoneNumber(id);
160
241
  return ok(result);
@@ -163,9 +244,9 @@ export function registerTools(server, client) {
163
244
  return formatError(err);
164
245
  }
165
246
  });
166
- 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.', {
167
- id: z.string().describe('Phone number record ID to release.'),
168
- }, async ({ id }) => {
247
+ server.registerTool('release_phone_number', { ...meta('release_phone_number'), description: 'Release (cancel) a provisioned phone number. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. 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({
248
+ id: z.string().describe('Phone number record ID to release.'),
249
+ }) }, async ({ id }) => {
169
250
  try {
170
251
  await client.releasePhoneNumber(id);
171
252
  return ok({ status: 'released', id });
@@ -174,17 +255,38 @@ export function registerTools(server, client) {
174
255
  return formatError(err);
175
256
  }
176
257
  });
258
+ server.registerTool('request_number_allowance', { ...meta('request_number_allowance'), description: 'File a request to raise how many phone numbers this account may hold at once, for HonkIO staff to review. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. Only one request may be pending at a time (a second returns 409 ALLOWANCE_REQUEST_PENDING); the owner is emailed when it is decided.', inputSchema: z.object({
259
+ requested_limit: z.number().int().min(2).max(100).describe('Total numbers you want to be able to hold, not an increment. Must exceed the current limit (see get_account → phone_number_limit).'),
260
+ reason: z.string().min(10).max(1000).describe('What the numbers are for. Staff decide on this.'),
261
+ }) }, async (input) => {
262
+ try {
263
+ const result = await client.requestNumberAllowance(input);
264
+ return ok(result);
265
+ }
266
+ catch (err) {
267
+ return formatError(err);
268
+ }
269
+ });
270
+ server.registerTool('list_number_allowance_requests', { ...meta('list_number_allowance_requests'), description: 'List past and pending phone-number allowance requests for this account.', inputSchema: z.object({}) }, async () => {
271
+ try {
272
+ const result = await client.listNumberAllowanceRequests();
273
+ return ok(result);
274
+ }
275
+ catch (err) {
276
+ return formatError(err);
277
+ }
278
+ });
177
279
  // ── Compliance: CASL Consents ─────────────────────────────
178
- 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.', {
179
- phone_number: z.string().describe('E.164 phone number of the subscriber granting consent.'),
180
- consent_type: z.enum(['express', 'implied']).describe('Type of CASL consent. "express" = subscriber explicitly opted in. "implied" = business relationship exists.'),
181
- 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").'),
182
- source_ip: z.string().optional().describe('[Optional, express] IP address of the subscriber at time of consent.'),
183
- source_url: z.string().optional().describe('[Optional, express] URL where consent was captured.'),
184
- relationship_type: z.string().optional().describe('[Required for implied] Business relationship type (e.g. "purchase", "inquiry", "membership").'),
185
- last_transaction_date: z.string().optional().describe('[Optional, implied] Date of last transaction (YYYY-MM-DD). Implied consent expires 2 years after this date.'),
186
- 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.'),
187
- }, async (input) => {
280
+ 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({
281
+ phone_number: z.string().describe('E.164 phone number of the subscriber granting consent.'),
282
+ consent_type: z.enum(['express', 'implied']).describe('Type of CASL consent. "express" = subscriber explicitly opted in. "implied" = business relationship exists.'),
283
+ 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").'),
284
+ source_ip: z.string().optional().describe('[Optional, express] IP address of the subscriber at time of consent.'),
285
+ source_url: z.string().optional().describe('[Optional, express] URL where consent was captured.'),
286
+ relationship_type: z.string().optional().describe('[Required for implied] Business relationship type (e.g. "purchase", "inquiry", "membership").'),
287
+ last_transaction_date: z.string().optional().describe('[Optional, implied] Date of last transaction (YYYY-MM-DD). Implied consent expires 2 years after this date.'),
288
+ 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.'),
289
+ }) }, async (input) => {
188
290
  try {
189
291
  const result = await client.recordConsent(input);
190
292
  return ok(result);
@@ -193,12 +295,12 @@ export function registerTools(server, client) {
193
295
  return formatError(err);
194
296
  }
195
297
  });
196
- server.tool('list_consents', 'List CASL consent records for your account, optionally filtered by phone number or status.', {
197
- phone_number: z.string().optional().describe('Filter consents for a specific phone number (E.164).'),
198
- status: z.enum(['ACTIVE', 'EXPIRED', 'REVOKED']).optional().describe('Filter by consent status.'),
199
- page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
200
- limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
201
- }, async (input) => {
298
+ 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({
299
+ phone_number: z.string().optional().describe('Filter consents for a specific phone number (E.164).'),
300
+ status: z.enum(['ACTIVE', 'EXPIRED', 'REVOKED']).optional().describe('Filter by consent status.'),
301
+ page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
302
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
303
+ }) }, async (input) => {
202
304
  try {
203
305
  const result = await client.listConsents(input);
204
306
  return ok(result);
@@ -207,9 +309,9 @@ export function registerTools(server, client) {
207
309
  return formatError(err);
208
310
  }
209
311
  });
210
- server.tool('check_consent', 'Check whether a phone number has valid CASL consent before sending a message.', {
211
- phone_number: z.string().describe('E.164 phone number to check consent for.'),
212
- }, async ({ phone_number }) => {
312
+ server.registerTool('check_consent', { ...meta('check_consent'), description: 'Check whether a phone number has valid CASL consent before sending a message.', inputSchema: z.object({
313
+ phone_number: z.string().describe('E.164 phone number to check consent for.'),
314
+ }) }, async ({ phone_number }) => {
213
315
  try {
214
316
  const result = await client.checkConsent(phone_number);
215
317
  return ok(result);
@@ -218,9 +320,9 @@ export function registerTools(server, client) {
218
320
  return formatError(err);
219
321
  }
220
322
  });
221
- server.tool('revoke_consent', 'Revoke CASL consent for a phone number. Future messages to this number will be blocked unless new consent is recorded.', {
222
- phone_number: z.string().describe('E.164 phone number to revoke consent for.'),
223
- }, async ({ phone_number }) => {
323
+ 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({
324
+ phone_number: z.string().describe('E.164 phone number to revoke consent for.'),
325
+ }) }, async ({ phone_number }) => {
224
326
  try {
225
327
  await client.revokeConsent(phone_number);
226
328
  return ok({ status: 'revoked', phone_number });
@@ -230,10 +332,10 @@ export function registerTools(server, client) {
230
332
  }
231
333
  });
232
334
  // ── Compliance: Opt-Outs ──────────────────────────────────
233
- 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.', {
234
- phone_number: z.string().describe('E.164 phone number of the subscriber opting out.'),
235
- from_number: z.string().describe('E.164 number the subscriber is opting out from (your sending number).'),
236
- }, async (input) => {
335
+ 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. A live key must own from_number: opting a subscriber out of a number you don\'t hold is refused with 403 PHONE_NUMBER_NOT_OWNED. A test key skips that check.', inputSchema: z.object({
336
+ phone_number: z.string().describe('E.164 phone number of the subscriber opting out.'),
337
+ from_number: z.string().describe('E.164 number the subscriber is opting out from (your sending number).'),
338
+ }) }, async (input) => {
237
339
  try {
238
340
  const result = await client.recordOptOut(input);
239
341
  return ok(result);
@@ -242,12 +344,12 @@ export function registerTools(server, client) {
242
344
  return formatError(err);
243
345
  }
244
346
  });
245
- server.tool('list_opt_outs', 'List opt-out records for your account.', {
246
- phone_number: z.string().optional().describe('Filter opt-outs for a specific subscriber number (E.164).'),
247
- from_number: z.string().optional().describe('Filter opt-outs from a specific sending number (E.164).'),
248
- page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
249
- limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
250
- }, async (input) => {
347
+ server.registerTool('list_opt_outs', { ...meta('list_opt_outs'), description: 'List opt-out records for your account.', inputSchema: z.object({
348
+ phone_number: z.string().optional().describe('Filter opt-outs for a specific subscriber number (E.164).'),
349
+ from_number: z.string().optional().describe('Filter opt-outs from a specific sending number (E.164).'),
350
+ page: z.number().int().min(1).optional().describe('Page number (default: 1).'),
351
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20).'),
352
+ }) }, async (input) => {
251
353
  try {
252
354
  const result = await client.listOptOuts(input);
253
355
  return ok(result);
@@ -257,9 +359,9 @@ export function registerTools(server, client) {
257
359
  }
258
360
  });
259
361
  // ── Compliance: DNCL ─────────────────────────────────────
260
- 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).", {
261
- phone_number: z.string().describe('Canadian phone number to check in E.164 format.'),
262
- }, async ({ phone_number }) => {
362
+ 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({
363
+ phone_number: z.string().describe('Canadian phone number to check in E.164 format.'),
364
+ }) }, async ({ phone_number }) => {
263
365
  try {
264
366
  const result = await client.checkDncl(phone_number);
265
367
  return ok(result);
@@ -268,9 +370,9 @@ export function registerTools(server, client) {
268
370
  return formatError(err);
269
371
  }
270
372
  });
271
- 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).', {
272
- phone_numbers: z.array(z.string()).min(1).max(100).describe('Array of Canadian phone numbers in E.164 format.'),
273
- }, async ({ phone_numbers }) => {
373
+ 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({
374
+ phone_numbers: z.array(z.string()).min(1).max(100).describe('Array of Canadian phone numbers in E.164 format.'),
375
+ }) }, async ({ phone_numbers }) => {
274
376
  try {
275
377
  const result = await client.batchCheckDncl(phone_numbers);
276
378
  return ok(result);
@@ -280,10 +382,10 @@ export function registerTools(server, client) {
280
382
  }
281
383
  });
282
384
  // ── Compliance: Erasure ───────────────────────────────────
283
- 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.', {
284
- phone_number: z.string().describe('E.164 phone number to erase data for.'),
285
- reason: z.string().optional().describe('Reason for the erasure request (recorded in the audit log).'),
286
- }, async (input) => {
385
+ server.registerTool('request_erasure', { ...meta('request_erasure'), description: 'Execute a PIPEDA/Quebec Law 25 right-to-erasure request for a phone number. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. Erases message bodies (keeping message metadata), consent records, verifications, contacts, non-DENY contact-list entries, and webhook dead letters referencing the number. Opt-out records are NOT erased (CASL requires keeping them as compliance evidence), and any DENY-list block on the number is kept so the number stays blocked; the response reports opt_outs_preserved: true. Creates an immutable audit log entry.', inputSchema: z.object({
386
+ phone_number: z.string().describe('E.164 phone number to erase data for.'),
387
+ reason: z.string().optional().describe('Reason for the erasure request (recorded in the audit log).'),
388
+ }) }, async (input) => {
287
389
  try {
288
390
  const result = await client.requestErasure(input);
289
391
  return ok(result);
@@ -293,10 +395,10 @@ export function registerTools(server, client) {
293
395
  }
294
396
  });
295
397
  // ── Webhooks ──────────────────────────────────────────────
296
- 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.', {
297
- url: z.string().url().describe('HTTPS URL to deliver events to.'),
298
- events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).describe('Event types to subscribe to.'),
299
- }, async (input) => {
398
+ server.registerTool('create_webhook', { ...meta('create_webhook'), description: 'Register a webhook endpoint to receive HonkIO event notifications. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. The signing_secret in the response is shown once; store it to verify X-HonkIO-Signature on incoming requests.', inputSchema: z.object({
399
+ url: z.string().url().describe('HTTPS URL to deliver events to.'),
400
+ events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).describe('Event types to subscribe to.'),
401
+ }) }, async (input) => {
300
402
  try {
301
403
  const result = await client.createWebhook(input);
302
404
  return ok(result);
@@ -305,7 +407,7 @@ export function registerTools(server, client) {
305
407
  return formatError(err);
306
408
  }
307
409
  });
308
- server.tool('list_webhooks', 'List all registered webhook endpoints for your account.', {}, async () => {
410
+ server.registerTool('list_webhooks', { ...meta('list_webhooks'), description: 'List all registered webhook endpoints for your account.', inputSchema: z.object({}) }, async () => {
309
411
  try {
310
412
  const result = await client.listWebhooks();
311
413
  return ok(result);
@@ -314,12 +416,23 @@ export function registerTools(server, client) {
314
416
  return formatError(err);
315
417
  }
316
418
  });
317
- server.tool('update_webhook', 'Update a webhook endpoint URL, event subscriptions, or active status.', {
318
- id: z.string().describe('Webhook ID to update.'),
319
- url: z.string().url().optional().describe('New HTTPS URL.'),
320
- events: z.array(z.enum(WEBHOOK_EVENTS)).optional().describe('New event subscriptions (replaces existing).'),
321
- active: z.boolean().optional().describe('Enable or disable the webhook.'),
322
- }, async ({ id, ...updates }) => {
419
+ server.registerTool('get_webhook', { ...meta('get_webhook'), description: 'Get details for a single registered webhook endpoint by ID.', inputSchema: z.object({
420
+ id: z.string().describe('Webhook ID.'),
421
+ }) }, async ({ id }) => {
422
+ try {
423
+ const result = await client.getWebhook(id);
424
+ return ok(result);
425
+ }
426
+ catch (err) {
427
+ return formatError(err);
428
+ }
429
+ });
430
+ server.registerTool('update_webhook', { ...meta('update_webhook'), description: 'Update a webhook endpoint URL, event subscriptions, or active status. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED.', inputSchema: z.object({
431
+ id: z.string().describe('Webhook ID to update.'),
432
+ url: z.string().url().optional().describe('New HTTPS URL.'),
433
+ events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional().describe('New event subscriptions (replaces existing). Cannot be an empty array: that would silently mute the webhook, so it is refused with 422.'),
434
+ active: z.boolean().optional().describe('Enable or disable the webhook.'),
435
+ }) }, async ({ id, ...updates }) => {
323
436
  try {
324
437
  const result = await client.updateWebhook(id, updates);
325
438
  return ok(result);
@@ -328,9 +441,9 @@ export function registerTools(server, client) {
328
441
  return formatError(err);
329
442
  }
330
443
  });
331
- server.tool('delete_webhook', 'Delete a registered webhook endpoint.', {
332
- id: z.string().describe('Webhook ID to delete.'),
333
- }, async ({ id }) => {
444
+ server.registerTool('delete_webhook', { ...meta('delete_webhook'), description: 'Delete a registered webhook endpoint. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED.', inputSchema: z.object({
445
+ id: z.string().describe('Webhook ID to delete.'),
446
+ }) }, async ({ id }) => {
334
447
  try {
335
448
  await client.deleteWebhook(id);
336
449
  return ok({ status: 'deleted', id });
@@ -339,10 +452,10 @@ export function registerTools(server, client) {
339
452
  return formatError(err);
340
453
  }
341
454
  });
342
- 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.', {
343
- webhook_id: z.string().describe('Webhook ID.'),
344
- limit: z.number().int().min(1).max(200).optional().describe('Max attempts to return (default 50).'),
345
- }, async ({ webhook_id, limit }) => {
455
+ 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({
456
+ webhook_id: z.string().describe('Webhook ID.'),
457
+ limit: z.number().int().min(1).max(200).optional().describe('Max attempts to return (default 50).'),
458
+ }) }, async ({ webhook_id, limit }) => {
346
459
  try {
347
460
  const result = await client.listWebhookDeliveries(webhook_id, limit ? { limit } : undefined);
348
461
  return ok(result);
@@ -351,9 +464,9 @@ export function registerTools(server, client) {
351
464
  return formatError(err);
352
465
  }
353
466
  });
354
- 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.', {
355
- webhook_id: z.string().describe('Webhook ID to reactivate.'),
356
- }, async ({ webhook_id }) => {
467
+ server.registerTool('reactivate_webhook', { ...meta('reactivate_webhook'), description: 'Re-enable a webhook that was auto-disabled by repeated delivery failures. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. The destination URL is re-validated (SSRF check) before reactivation.', inputSchema: z.object({
468
+ webhook_id: z.string().describe('Webhook ID to reactivate.'),
469
+ }) }, async ({ webhook_id }) => {
357
470
  try {
358
471
  const result = await client.reactivateWebhook(webhook_id);
359
472
  return ok(result);
@@ -362,11 +475,11 @@ export function registerTools(server, client) {
362
475
  return formatError(err);
363
476
  }
364
477
  });
365
- 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.', {
366
- webhook_id: z.string().describe('Webhook ID.'),
367
- limit: z.number().int().min(1).max(200).optional().describe('Max rows (default 50).'),
368
- include_replayed: z.boolean().optional().describe('Include events that have been successfully replayed (default false).'),
369
- }, async ({ webhook_id, ...query }) => {
478
+ server.registerTool('list_webhook_dead_letters', { ...meta('list_webhook_dead_letters'), description: 'List events that exhausted all retry attempts (dead-letter queue). Live keys only: a test key gets 403 LIVE_KEY_REQUIRED, since a dead letter carries the failed payload. 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({
479
+ webhook_id: z.string().describe('Webhook ID.'),
480
+ limit: z.number().int().min(1).max(200).optional().describe('Max rows (default 50).'),
481
+ include_replayed: z.boolean().optional().describe('Include events that have been successfully replayed (default false).'),
482
+ }) }, async ({ webhook_id, ...query }) => {
370
483
  try {
371
484
  const result = await client.listWebhookDeadLetters(webhook_id, query);
372
485
  return ok(result);
@@ -375,9 +488,9 @@ export function registerTools(server, client) {
375
488
  return formatError(err);
376
489
  }
377
490
  });
378
- 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.', {
379
- dead_letter_id: z.string().describe('Dead-letter row ID (from list_webhook_dead_letters).'),
380
- }, async ({ dead_letter_id }) => {
491
+ server.registerTool('replay_webhook_dead_letter', { ...meta('replay_webhook_dead_letter'), description: 'Resend a dead-lettered event against the original webhook URL. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. The event is re-signed with the current timestamp; on success the dead-letter row is marked as replayed.', inputSchema: z.object({
492
+ dead_letter_id: z.string().describe('Dead-letter row ID (from list_webhook_dead_letters).'),
493
+ }) }, async ({ dead_letter_id }) => {
381
494
  try {
382
495
  const result = await client.replayWebhookDeadLetter(dead_letter_id);
383
496
  return ok(result);
@@ -386,9 +499,9 @@ export function registerTools(server, client) {
386
499
  return formatError(err);
387
500
  }
388
501
  });
389
- 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).', {
390
- dead_letter_id: z.string().describe('Dead-letter row ID.'),
391
- }, async ({ dead_letter_id }) => {
502
+ server.registerTool('discard_webhook_dead_letter', { ...meta('discard_webhook_dead_letter'), description: 'Permanently discard a dead-lettered event without replaying it. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. Use when the event is no longer relevant (e.g. the underlying message has expired).', inputSchema: z.object({
503
+ dead_letter_id: z.string().describe('Dead-letter row ID.'),
504
+ }) }, async ({ dead_letter_id }) => {
392
505
  try {
393
506
  await client.discardWebhookDeadLetter(dead_letter_id);
394
507
  return ok({ status: 'discarded', dead_letter_id });
@@ -398,7 +511,7 @@ export function registerTools(server, client) {
398
511
  }
399
512
  });
400
513
  // ── Pricing ───────────────────────────────────────────────
401
- 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 () => {
514
+ 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 () => {
402
515
  try {
403
516
  const result = await client.getPricing();
404
517
  return ok(result);
@@ -408,13 +521,13 @@ export function registerTools(server, client) {
408
521
  }
409
522
  });
410
523
  // ── Verify (OTP) ──────────────────────────────────────────
411
- 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.', {
412
- from: z.string().describe('Your HonkIO number (E.164, must be active on your account).'),
413
- to: z.string().describe('The phone number to verify (E.164, Canadian numbers only).'),
414
- code_length: z.union([z.literal(4), z.literal(6), z.literal(8)]).optional().describe('OTP digit length (default 6).'),
415
- ttl_minutes: z.number().int().min(1).max(60).optional().describe('Minutes until the code expires (default 10).'),
416
- app_name: z.string().max(64).optional().describe('Brand name shown in the SMS body (default HonkIO).'),
417
- }, async (input) => {
524
+ 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({
525
+ from: z.string().describe('Your HonkIO number (E.164, must be active on your account).'),
526
+ to: z.string().describe('The phone number to verify (E.164, Canadian numbers only).'),
527
+ code_length: z.union([z.literal(4), z.literal(6), z.literal(8)]).optional().describe('OTP digit length (default 6).'),
528
+ ttl_minutes: z.number().int().min(1).max(60).optional().describe('Minutes until the code expires (default 10).'),
529
+ app_name: z.string().max(64).optional().describe('Brand name shown in the SMS body (default HonkIO).'),
530
+ }) }, async (input) => {
418
531
  try {
419
532
  const result = await client.startVerification(input);
420
533
  return ok(result);
@@ -423,10 +536,10 @@ export function registerTools(server, client) {
423
536
  return formatError(err);
424
537
  }
425
538
  });
426
- server.tool('check_verification', 'Submit the OTP a user entered to complete verification. Returns the verification status (verified, invalid_code, expired, max_attempts).', {
427
- verification_id: z.string().describe('Verification ID returned from start_verification.'),
428
- code: z.string().regex(/^\d{4,8}$/).describe('The 4/6/8-digit code the user submitted.'),
429
- }, async ({ verification_id, code }) => {
539
+ server.registerTool('check_verification', { ...meta('check_verification'), description: 'Submit the OTP a user entered to complete verification. On success, returns the verification with status "verified" and attempts_remaining: 0. A wrong code comes back as a 422 VERIFICATION_INVALID_CODE error carrying attempts_remaining (5 attempts total); an expired verification is a 410 VERIFICATION_EXPIRED error; five failed attempts is a 429 VERIFICATION_MAX_ATTEMPTS error; checking one already completed is a 409 VERIFICATION_ALREADY_VERIFIED error. These come back as tool errors to catch, not a status value to branch on.', inputSchema: z.object({
540
+ verification_id: z.string().describe('Verification ID returned from start_verification.'),
541
+ code: z.string().regex(/^\d{4,8}$/).describe('The 4/6/8-digit code the user submitted.'),
542
+ }) }, async ({ verification_id, code }) => {
430
543
  try {
431
544
  const result = await client.checkVerification(verification_id, code);
432
545
  return ok(result);
@@ -435,9 +548,9 @@ export function registerTools(server, client) {
435
548
  return formatError(err);
436
549
  }
437
550
  });
438
- 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.', {
439
- verification_id: z.string().describe('Verification ID.'),
440
- }, async ({ verification_id }) => {
551
+ 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({
552
+ verification_id: z.string().describe('Verification ID.'),
553
+ }) }, async ({ verification_id }) => {
441
554
  try {
442
555
  const result = await client.getVerification(verification_id);
443
556
  return ok(result);
@@ -446,12 +559,11 @@ export function registerTools(server, client) {
446
559
  return formatError(err);
447
560
  }
448
561
  });
449
- server.tool('list_verifications', 'List recent verifications for your account, optionally filtered by phone number or status.', {
450
- phone_number: z.string().optional().describe('Filter to verifications for this E.164 number.'),
451
- status: z.enum(['pending', 'verified', 'expired', 'max_attempts']).optional().describe('Filter by status.'),
452
- page: z.number().int().min(1).optional(),
453
- limit: z.number().int().min(1).max(200).optional(),
454
- }, async (input) => {
562
+ server.registerTool('list_verifications', { ...meta('list_verifications'), description: 'List verifications for your account, newest first, optionally filtered by status.', inputSchema: z.object({
563
+ status: z.enum(['pending', 'verified', 'expired', 'max_attempts']).optional().describe('Filter by status.'),
564
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 50, max: 100).'),
565
+ offset: z.number().int().min(0).optional().describe('Number of records to skip (default: 0).'),
566
+ }) }, async (input) => {
455
567
  try {
456
568
  const result = await client.listVerifications(input);
457
569
  return ok(result);
@@ -462,11 +574,11 @@ export function registerTools(server, client) {
462
574
  });
463
575
  // ── Account ───────────────────────────────────────────────
464
576
  // ─── Sending limits ──────────────────────────────────────────
465
- server.tool('get_send_limit', '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), ' +
466
- 'how many live messages were sent in the last 24 hours and how many remain, the probation status that gates "request a higher volume" ' +
467
- '(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), ' +
468
- 'whether live sending is currently paused and until when, and past volume requests. ' +
469
- 'Check this when a send is refused with DAILY_LIMIT_REACHED, RECIPIENT_RATE_LIMITED or SENDING_PAUSED, or before a batch of sends.', {}, async () => {
577
+ 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), ' +
578
+ 'how many live messages were sent in the last 24 hours and how many remain, the probation status that gates "request a higher volume" ' +
579
+ '(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), ' +
580
+ 'whether live sending is currently paused and until when, and past volume requests. ' +
581
+ '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 () => {
470
582
  try {
471
583
  const result = await client.getSendLimit();
472
584
  return ok(result);
@@ -475,13 +587,13 @@ export function registerTools(server, client) {
475
587
  return formatError(err);
476
588
  }
477
589
  });
478
- server.tool('request_send_limit', 'File a "request a higher volume" for HonkIO staff to review. Only available once the account has completed its probation period ' +
479
- '(get_send_limit → probation.eligible_to_request); one request may be pending at a time. Omit requested_limit to ask for the standard ' +
480
- 'approved volume (get_send_limit → approved_daily_limit), or name a target above the current cap. The owner is emailed when it is decided. ' +
481
- 'HonkIO is for transactional and relationship messaging: describe what is sent, to whom, and roughly how many a day — bulk marketing is not approved.', {
482
- 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.'),
483
- reason: z.string().min(10).max(1000).describe('What is sent and to whom, and the expected daily volume. Staff decide on this.'),
484
- }, async (input) => {
590
+ server.registerTool('request_send_limit', { ...meta('request_send_limit'), description: 'File a "request a higher volume" for HonkIO staff to review. Live keys only: a test key gets 403 LIVE_KEY_REQUIRED. Only available once the account has completed its probation period ' +
591
+ '(get_send_limit → probation.eligible_to_request); one request may be pending at a time (a second returns 409 SEND_LIMIT_REQUEST_PENDING). Omit requested_limit to ask for the standard ' +
592
+ 'approved volume (get_send_limit → approved_daily_limit), or name a target above the current cap. The owner is emailed when it is decided. ' +
593
+ '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({
594
+ 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.'),
595
+ reason: z.string().min(10).max(1000).describe('What is sent and to whom, and the expected daily volume. Staff decide on this.'),
596
+ }) }, async (input) => {
485
597
  try {
486
598
  const result = await client.requestSendLimit(input);
487
599
  return ok(result);
@@ -490,11 +602,20 @@ export function registerTools(server, client) {
490
602
  return formatError(err);
491
603
  }
492
604
  });
493
- server.tool('get_topup_allowance', '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 ' +
494
- '(platform defaults $500 and $1,000 CAD, raisable per account); a checkout above max_topup_now_cents is refused with TOPUP_LIMIT_REACHED. ' +
495
- 'Returns the caps, the current balance, the 30-day total so far, and the amount that fits now, all in CAD cents.', {
496
- account_id: ACCOUNT_ID_ARG,
497
- }, async ({ account_id }) => {
605
+ server.registerTool('list_send_limit_requests', { ...meta('list_send_limit_requests'), description: 'List past and pending "higher volume" sending requests for this account.', inputSchema: z.object({}) }, async () => {
606
+ try {
607
+ const result = await client.listSendLimitRequests();
608
+ return ok(result);
609
+ }
610
+ catch (err) {
611
+ return formatError(err);
612
+ }
613
+ });
614
+ 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 ' +
615
+ '(platform defaults $500 and $1,000 CAD, raisable per account); a checkout above max_topup_now_cents is refused with TOPUP_LIMIT_REACHED. ' +
616
+ '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({
617
+ account_id: ACCOUNT_ID_ARG,
618
+ }) }, async ({ account_id }) => {
498
619
  try {
499
620
  const result = await client.getTopupAllowance(await resolveAccountId(account_id));
500
621
  return ok(result);
@@ -503,7 +624,7 @@ export function registerTools(server, client) {
503
624
  return formatError(err);
504
625
  }
505
626
  });
506
- 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 () => {
627
+ server.registerTool('whoami', { ...meta('whoami'), description: 'Identify the HonkIO account the configured API key belongs to: the account (ID, name, credit balance, status) and its list of API keys (each with its own prefix, mode and label). It does not say which of those keys is the one you are calling with (the API never identifies the calling key back to itself), so track your own key\'s mode (mk_live_... or mk_test_...) separately. Call this first when you need an account ID.', inputSchema: z.object({}) }, async () => {
507
628
  try {
508
629
  const result = await client.getCurrentAccount();
509
630
  return ok(result);
@@ -512,11 +633,11 @@ export function registerTools(server, client) {
512
633
  return formatError(err);
513
634
  }
514
635
  });
515
- server.tool('get_account', 'Get details for your HonkIO account including name, credit balance, status, and active API keys. ' +
516
- 'Also returns phone_number_limit and phone_numbers_used — check these before calling ' +
517
- 'provision_phone_number, since a purchase past the limit is rejected with NUMBER_LIMIT_REACHED.', {
518
- account_id: ACCOUNT_ID_ARG,
519
- }, async ({ account_id }) => {
636
+ server.registerTool('get_account', { ...meta('get_account'), description: 'Get details for your HonkIO account including name, credit balance, status, and active API keys. ' +
637
+ 'Also returns phone_number_limit and phone_numbers_used: check these before calling ' +
638
+ 'provision_phone_number, since a purchase past the limit is rejected with NUMBER_LIMIT_REACHED.', inputSchema: z.object({
639
+ account_id: ACCOUNT_ID_ARG,
640
+ }) }, async ({ account_id }) => {
520
641
  try {
521
642
  const result = await client.getAccount(await resolveAccountId(account_id));
522
643
  return ok(result);
@@ -525,10 +646,10 @@ export function registerTools(server, client) {
525
646
  return formatError(err);
526
647
  }
527
648
  });
528
- server.tool('update_account', 'Update your HonkIO account name.', {
529
- account_id: ACCOUNT_ID_ARG,
530
- name: z.string().min(1).max(200).describe('New account name.'),
531
- }, async ({ account_id, name }) => {
649
+ server.registerTool('update_account', { ...meta('update_account'), description: 'Update your HonkIO account name.', inputSchema: z.object({
650
+ account_id: ACCOUNT_ID_ARG,
651
+ name: z.string().min(1).max(200).describe('New account name.'),
652
+ }) }, async ({ account_id, name }) => {
532
653
  try {
533
654
  const result = await client.updateAccount(await resolveAccountId(account_id), { name });
534
655
  return ok(result);
@@ -537,11 +658,11 @@ export function registerTools(server, client) {
537
658
  return formatError(err);
538
659
  }
539
660
  });
540
- server.tool('get_usage', '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.', {
541
- account_id: ACCOUNT_ID_ARG,
542
- from: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to start of current month.'),
543
- to: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'),
544
- }, async ({ account_id, from, to }) => {
661
+ 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({
662
+ account_id: ACCOUNT_ID_ARG,
663
+ from: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to start of current month.'),
664
+ to: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'),
665
+ }) }, async ({ account_id, from, to }) => {
545
666
  try {
546
667
  const result = await client.getUsage(await resolveAccountId(account_id), { from, to });
547
668
  return ok(result);
@@ -550,23 +671,37 @@ export function registerTools(server, client) {
550
671
  return formatError(err);
551
672
  }
552
673
  });
553
- server.tool('create_api_key', 'Issue a new API key for your account. The raw key is shown once — store it securely.', {
554
- account_id: ACCOUNT_ID_ARG,
555
- mode: z.enum(['live', 'test']).optional().describe('Key mode: "live" (real SMS, charges apply) or "test" (sandbox, no charges). Default: "live".'),
556
- label: z.string().max(100).optional().describe('Human-readable label to identify this key (e.g. "Production server", "CI pipeline").'),
557
- }, async ({ account_id, mode, label }) => {
674
+ server.registerTool('list_transactions', { ...meta('list_transactions'), description: 'List the account\'s balance transaction ledger, newest first: top-ups, refunds, disputes, phone rent, provisioning fees, activation fees, and verification upcharges. Per-message SMS costs are NOT included here: see list_messages/get_message for those.', inputSchema: z.object({
675
+ account_id: ACCOUNT_ID_ARG,
676
+ limit: z.number().int().min(1).max(100).optional().describe('Results per page (default: 50, max: 100).'),
677
+ before: z.string().optional().describe('Id of the oldest transaction already seen: returns the next (older) page.'),
678
+ }) }, async ({ account_id, limit, before }) => {
679
+ try {
680
+ const result = await client.listTransactions(await resolveAccountId(account_id), { limit, before });
681
+ return ok(result);
682
+ }
683
+ catch (err) {
684
+ return formatError(err);
685
+ }
686
+ });
687
+ 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. A key can only be created with permissions the calling key itself holds: asking for more is refused with 403 PERMISSION_ESCALATION. A test key can only create test keys (mode "live" from a test key is refused with 403 LIVE_KEY_REQUIRED).', inputSchema: z.object({
688
+ account_id: ACCOUNT_ID_ARG,
689
+ mode: z.enum(['live', 'test']).optional().describe('Key mode: "live" or "test". Defaults to the calling key\'s own mode.'),
690
+ label: z.string().max(100).optional().describe('Human-readable label to identify this key (e.g. "Production server", "CI pipeline").'),
691
+ permissions: PERMISSIONS_ARG,
692
+ }) }, async ({ account_id, mode, label, permissions }) => {
558
693
  try {
559
- const result = await client.createApiKey(await resolveAccountId(account_id), { mode, label });
694
+ const result = await client.createApiKey(await resolveAccountId(account_id), { mode, label, permissions });
560
695
  return ok(result);
561
696
  }
562
697
  catch (err) {
563
698
  return formatError(err);
564
699
  }
565
700
  });
566
- server.tool('revoke_api_key', 'Revoke an API key, immediately blocking all requests using that key. This cannot be undone.', {
567
- account_id: ACCOUNT_ID_ARG,
568
- key_id: z.string().describe('The API key record ID to revoke (from get_account api_keys list).'),
569
- }, async ({ account_id, key_id }) => {
701
+ 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. A test key can revoke any of the account\'s keys except a live one: revoking a live key needs a live key (403 LIVE_KEY_REQUIRED).', inputSchema: z.object({
702
+ account_id: ACCOUNT_ID_ARG,
703
+ key_id: z.string().describe('The API key record ID to revoke (from get_account api_keys list).'),
704
+ }) }, async ({ account_id, key_id }) => {
570
705
  try {
571
706
  await client.revokeApiKey(await resolveAccountId(account_id), key_id);
572
707
  return ok({ status: 'revoked', key_id });
@@ -575,10 +710,10 @@ export function registerTools(server, client) {
575
710
  return formatError(err);
576
711
  }
577
712
  });
578
- 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.', {
579
- account_id: ACCOUNT_ID_ARG,
580
- key_id: z.string().describe('The API key record ID to rotate.'),
581
- }, async ({ account_id, key_id }) => {
713
+ server.registerTool('rotate_api_key', { ...meta('rotate_api_key'), description: 'Atomically issue a replacement API key inheriting the same permissions, label, mode, allow/deny lists and default-deny flag as the source key, and revoke the source key in the same transaction. Returns the new raw key ONCE. A test key can only rotate a test key (403 LIVE_KEY_REQUIRED otherwise). A dashboard session key can never be the source: it always expires on its own (409 CONFLICT). Rotating a key into more permissions than the calling key holds, or calling this tool with a session key, is refused with 403 PERMISSION_ESCALATION.', inputSchema: z.object({
714
+ account_id: ACCOUNT_ID_ARG,
715
+ key_id: z.string().describe('The API key record ID to rotate.'),
716
+ }) }, async ({ account_id, key_id }) => {
582
717
  try {
583
718
  const result = await client.rotateApiKey(await resolveAccountId(account_id), key_id);
584
719
  return ok(result);