askell-mcp 0.1.2 → 0.3.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.
@@ -59,7 +59,6 @@ function summarizeListItem(item: unknown): unknown {
59
59
  'email',
60
60
  'first_name',
61
61
  'last_name',
62
- 'description',
63
62
  'currency',
64
63
  'amount',
65
64
  'total_amount',
@@ -91,6 +90,56 @@ function summarizeListItem(item: unknown): unknown {
91
90
  return Object.keys(out).length > 0 ? out : obj;
92
91
  }
93
92
 
93
+ /** Tight projection for analytical list queries (dates, plan name, customer). */
94
+ function indexListItem(item: unknown): unknown {
95
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) {
96
+ return item;
97
+ }
98
+
99
+ const obj = item as Record<string, unknown>;
100
+ const out: Record<string, unknown> = {};
101
+
102
+ if ('id' in obj) {
103
+ out.id = obj.id;
104
+ }
105
+
106
+ if ('start_date' in obj) {
107
+ out.start_date = obj.start_date;
108
+ } else if ('created_at' in obj) {
109
+ out.created_at = obj.created_at;
110
+ }
111
+
112
+ if ('ended_at' in obj && obj.ended_at != null) {
113
+ out.ended_at = obj.ended_at;
114
+ }
115
+
116
+ const plan = obj.plan;
117
+ if (typeof plan === 'string' || typeof plan === 'number') {
118
+ out.plan = plan;
119
+ } else if (plan && typeof plan === 'object' && 'name' in plan) {
120
+ out.plan = (plan as { name: unknown }).name;
121
+ } else if ('name' in obj && typeof obj.name === 'string') {
122
+ out.name = obj.name;
123
+ }
124
+
125
+ const customerRef =
126
+ obj.customer_reference ??
127
+ (obj.customer && typeof obj.customer === 'object'
128
+ ? ((obj.customer as Record<string, unknown>).customer_reference ??
129
+ (obj.customer as Record<string, unknown>).reference ??
130
+ (obj.customer as Record<string, unknown>).id)
131
+ : undefined);
132
+ if (customerRef !== undefined) {
133
+ out.customer_reference = customerRef;
134
+ }
135
+
136
+ if (typeof obj.email === 'string') {
137
+ out.email = obj.email;
138
+ }
139
+
140
+ return Object.keys(out).length > 0 ? out : summarizeListItem(item);
141
+ }
142
+
94
143
  function serializeListPayload(
95
144
  status: number,
96
145
  meta: Record<string, unknown>,
@@ -146,6 +195,8 @@ function maxFittingCount(
146
195
  return lo;
147
196
  }
148
197
 
198
+ type CompactedMode = 'none' | 'summary' | 'index';
199
+
149
200
  export function buildBoundedListPayload(input: {
150
201
  status: number;
151
202
  meta: Record<string, unknown>;
@@ -153,30 +204,42 @@ export function buildBoundedListPayload(input: {
153
204
  maxBytes: number;
154
205
  }): FormattedResponse {
155
206
  const { status, meta, items, maxBytes } = input;
156
- const itemCount = items.length;
157
207
 
158
208
  const attempts: Array<{
159
209
  items: unknown[];
160
210
  pretty: boolean;
161
- compacted: boolean;
211
+ compactedMode: CompactedMode;
162
212
  note?: string;
163
213
  }> = [
164
- { items, pretty: true, compacted: false },
165
- { items, pretty: false, compacted: false },
214
+ { items, pretty: true, compactedMode: 'none' },
215
+ { items, pretty: false, compactedMode: 'none' },
166
216
  {
167
217
  items: items.map(summarizeListItem),
168
218
  pretty: false,
169
- compacted: true,
219
+ compactedMode: 'summary',
170
220
  note: 'Items summarized to fit responseMaxBytes',
171
221
  },
222
+ {
223
+ items: items.map(indexListItem),
224
+ pretty: false,
225
+ compactedMode: 'index',
226
+ note: 'Items reduced to an index (id, dates, plan, customer) to fit responseMaxBytes',
227
+ },
172
228
  ];
173
229
 
174
230
  let fullByteLength = 0;
231
+ let bestPartial: {
232
+ text: string;
233
+ returnedCount: number;
234
+ compactedMode: CompactedMode;
235
+ note?: string;
236
+ } | null = null;
175
237
 
176
238
  for (const attempt of attempts) {
239
+ const attemptMeta = compactMeta(meta, attempt.compactedMode, attempt.note);
177
240
  const fullText = serializeListPayload(
178
241
  status,
179
- meta,
242
+ attemptMeta,
180
243
  attempt.items,
181
244
  attempt.items.length,
182
245
  false,
@@ -195,42 +258,43 @@ export function buildBoundedListPayload(input: {
195
258
  const returnedCount = maxFittingCount(
196
259
  attempt.items,
197
260
  status,
198
- {
199
- ...meta,
200
- ...(attempt.compacted ? { compacted: true, note: attempt.note } : {}),
201
- },
261
+ attemptMeta,
202
262
  maxBytes,
203
263
  attempt.pretty,
204
264
  );
205
265
 
206
- if (returnedCount > 0) {
207
- const text = serializeListPayload(
208
- status,
209
- {
210
- ...meta,
211
- ...(attempt.compacted ? { compacted: true, note: attempt.note } : {}),
212
- },
213
- attempt.items,
266
+ if (returnedCount > (bestPartial?.returnedCount ?? 0)) {
267
+ bestPartial = {
268
+ text: serializeListPayload(
269
+ status,
270
+ attemptMeta,
271
+ attempt.items,
272
+ returnedCount,
273
+ true,
274
+ attempt.pretty,
275
+ ),
214
276
  returnedCount,
215
- true,
216
- attempt.pretty,
217
- );
218
-
219
- return {
220
- text,
221
- truncated: true,
222
- byteLength: fullByteLength,
277
+ compactedMode: attempt.compactedMode,
278
+ note: attempt.note,
223
279
  };
224
280
  }
225
281
  }
226
282
 
283
+ if (bestPartial && bestPartial.returnedCount > 0) {
284
+ return {
285
+ text: bestPartial.text,
286
+ truncated: true,
287
+ byteLength: fullByteLength,
288
+ };
289
+ }
290
+
227
291
  const text = serializeListPayload(
228
292
  status,
229
- {
230
- ...meta,
231
- compacted: true,
232
- note: 'Response too large; returning metadata only',
233
- },
293
+ compactMeta(
294
+ meta,
295
+ 'index',
296
+ 'Response too large; returning metadata only',
297
+ ),
234
298
  [],
235
299
  0,
236
300
  true,
@@ -244,6 +308,23 @@ export function buildBoundedListPayload(input: {
244
308
  };
245
309
  }
246
310
 
311
+ function compactMeta(
312
+ meta: Record<string, unknown>,
313
+ compactedMode: CompactedMode,
314
+ note?: string,
315
+ ): Record<string, unknown> {
316
+ if (compactedMode === 'none') {
317
+ return meta;
318
+ }
319
+
320
+ return {
321
+ ...meta,
322
+ compacted: true,
323
+ compactedMode,
324
+ ...(note ? { note } : {}),
325
+ };
326
+ }
327
+
247
328
  export function formatApiResponse(
248
329
  status: number,
249
330
  headers: Headers,
package/src/config.ts CHANGED
@@ -1,24 +1,46 @@
1
1
  import * as z from 'zod';
2
2
 
3
+ export const MUTATION_GATES = ['auto', 'elicit', 'off'] as const;
4
+ export type MutationGate = (typeof MUTATION_GATES)[number];
5
+
6
+ const httpUrl = z
7
+ .url({ protocol: /^https?$/ })
8
+ .describe('Askell API base URL (default production host)');
9
+
10
+ const mutationGateAliases = z
11
+ .enum(['true', 'false', 'on', 'yes', 'no', '1', '0'])
12
+ .transform((value): MutationGate => {
13
+ return value === 'true' || value === 'on' || value === 'yes' || value === '1'
14
+ ? 'elicit'
15
+ : 'off';
16
+ });
17
+
18
+ export const MutationGateSchema = z
19
+ .union([
20
+ z.enum(MUTATION_GATES),
21
+ z.boolean().transform((value): MutationGate => (value ? 'elicit' : 'off')),
22
+ mutationGateAliases,
23
+ ])
24
+ .default('auto')
25
+ .describe(
26
+ 'Mutation confirmation: auto (elicit if client declared it), elicit (require form), off (never)',
27
+ );
28
+
3
29
  export const ConfigSchema = z.object({
4
- apiBaseUrl: z
5
- .httpUrl()
6
- .default('https://askell.is/api')
7
- .describe('Askell API base URL (default production host)'),
30
+ apiBaseUrl: httpUrl.default('https://askell.is/api'),
8
31
  secretApiKey: z.string().min(1).describe('Secret (private) API key'),
9
32
  publicApiKey: z
10
33
  .string()
34
+ .min(1)
11
35
  .optional()
12
36
  .describe('Public API key for temporary payment method endpoints'),
13
- responseMaxBytes: z
37
+ responseMaxBytes: z.coerce
38
+ .number()
14
39
  .int()
15
40
  .positive()
16
41
  .default(64_000)
17
42
  .describe('Max response body size returned to the model'),
18
- requireMutationApproval: z
19
- .boolean()
20
- .default(true)
21
- .describe('Require operator confirmation before mutating requests'),
43
+ mutationGate: MutationGateSchema,
22
44
  });
23
45
 
24
46
  export type AppConfig = z.infer<typeof ConfigSchema>;
@@ -45,17 +67,6 @@ Set ASKELL_PRIVATE_API_KEY (or ASKELL_SECRET_API_KEY), optionally ASKELL_PUBLIC_
45
67
  }
46
68
  }`;
47
69
 
48
- function parseEnvFlag(
49
- value: string | undefined,
50
- defaultValue: boolean,
51
- ): boolean {
52
- if (value === undefined) {
53
- return defaultValue;
54
- }
55
-
56
- return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
57
- }
58
-
59
70
  function loadConfigFromEnv(): unknown {
60
71
  const env = Bun.env;
61
72
  const secretApiKey = env.ASKELL_PRIVATE_API_KEY ?? env.ASKELL_SECRET_API_KEY;
@@ -66,7 +77,9 @@ function loadConfigFromEnv(): unknown {
66
77
 
67
78
  const apiBaseUrl = env.ASKELL_API_URL ?? env.ASKELL_API_BASE_URL;
68
79
  const responseMaxBytes = env.ASKELL_RESPONSE_MAX_BYTES;
69
- const requireMutationApproval = env.ASKELL_REQUIRE_MUTATION_APPROVAL;
80
+ const mutationGateRaw =
81
+ env.ASKELL_MUTATION_GATE ?? env.ASKELL_REQUIRE_MUTATION_APPROVAL;
82
+ const mutationGate = mutationGateRaw?.trim().toLowerCase() || undefined;
70
83
 
71
84
  return {
72
85
  ...(apiBaseUrl ? { apiBaseUrl } : {}),
@@ -74,12 +87,8 @@ function loadConfigFromEnv(): unknown {
74
87
  ...(env.ASKELL_PUBLIC_API_KEY
75
88
  ? { publicApiKey: env.ASKELL_PUBLIC_API_KEY }
76
89
  : {}),
77
- ...(responseMaxBytes ? { responseMaxBytes: Number(responseMaxBytes) } : {}),
78
- ...(requireMutationApproval !== undefined
79
- ? {
80
- requireMutationApproval: parseEnvFlag(requireMutationApproval, true),
81
- }
82
- : {}),
90
+ ...(responseMaxBytes ? { responseMaxBytes } : {}),
91
+ ...(mutationGate !== undefined ? { mutationGate } : {}),
83
92
  };
84
93
  }
85
94
 
@@ -69,20 +69,30 @@ function resolveParameters(
69
69
  }
70
70
 
71
71
  return parameters.map((parameter) => {
72
+ let source: OpenApiParameter = parameter;
73
+
72
74
  if ('$ref' in parameter && typeof parameter.$ref === 'string') {
73
75
  const resolved = resolveRef(doc, parameter.$ref);
74
76
  if (resolved && typeof resolved === 'object') {
75
- const param = resolved as OpenApiParameter;
76
- return {
77
- ...param,
78
- schema: resolveSchema(doc, param.schema),
79
- };
77
+ source = resolved as OpenApiParameter;
80
78
  }
81
79
  }
82
80
 
81
+ // Pick known fields only — OpenAPI params often include style/explode/example,
82
+ // which MCP outputSchema rejects (additionalProperties: false).
83
83
  return {
84
- ...parameter,
85
- schema: resolveSchema(doc, parameter.schema),
84
+ ...(source.name !== undefined ? { name: source.name } : {}),
85
+ ...(source.in !== undefined ? { in: source.in } : {}),
86
+ ...(source.required !== undefined ? { required: source.required } : {}),
87
+ ...(source.description !== undefined
88
+ ? { description: source.description }
89
+ : {}),
90
+ ...(source.schema !== undefined
91
+ ? { schema: resolveSchema(doc, source.schema) }
92
+ : {}),
93
+ ...(source.$ref !== undefined && source.name === undefined
94
+ ? { $ref: source.$ref }
95
+ : {}),
86
96
  };
87
97
  });
88
98
  }
@@ -4,34 +4,60 @@ import { getBundledSpec } from '../openapi/registry.ts';
4
4
 
5
5
  const WEBHOOK_EVENTS_DOC = `# Askell webhook events (reference)
6
6
 
7
- Askell sends signed webhook calls to your configured endpoints.
7
+ Askell POSTs signed JSON to each URL you register. Verify \`Hook-HMAC\` before parsing.
8
8
 
9
9
  Headers:
10
- - Hook-HMAC: base64 HMAC-SHA512 of the raw body
11
- - Hook-Event: event type identifier
12
- - Hook-API-Version: v1 for legacy plan/subscription webhooks, v2 for subscription contract webhooks
13
-
14
- Event families:
15
- - subscription.* (legacy v1): subscription.created, subscription.changed, subscription.renewed
16
- - subscription_contract.* (v2): subscription_contract.created, subscription_contract.changed,
17
- subscription_contract.renewed, subscription_contract.migrated (legacy subscription migrated to a
18
- V2 contract; payload includes legacy_subscription_ids and migration_effective_at)
19
- - billing_run.* (v2): billing_run.created, billing_run.changed, billing_run.succeeded,
20
- billing_run.failed, billing_run.retry
21
- - customer.*: customer.created, customer.changed
22
- - payment.*: payment.created, payment.changed, payment.retry
23
- - checkout.*: checkout.created, checkout.changed
24
-
25
- Notes:
26
- - During migration to V2, legacy subscription.* aliases are NOT sent for the new contract (event data
27
- is based on SubscriptionContract, not Subscription), and subscription.canceled is not sent solely
28
- because billing moved to a V2 contract.
29
-
30
- Webhook management is available via v1 API:
31
- - GET/POST /webhooks/
32
- - GET/PATCH/DELETE /webhooks/{id}/
33
-
34
- This MCP server exposes webhook management through askell_call and askell_list_webhooks.
10
+ - Hook-HMAC: base64 HMAC-SHA512 of the **raw body** (secret = \`hmac_secret\` from webhook create)
11
+ - Hook-Event: event type (\`subscription.renewed\`, \`payment.changed\`, or a family wildcard \`subscription.*\`)
12
+ - Hook-API-Version: \`v1\` for plan/subscription/customer/payment/checkout, \`v2\` for subscription_contract / billing_run
13
+
14
+ ## Body shape (OpenAPI is wrong here)
15
+
16
+ JSON body **is the event object**. It is **not** \`{ event, data }\`.
17
+
18
+ Ignore \`POST /your-webhook-url/\` in the bundled v1 spec — its requestBody (\`SubscriptionMultiLite\`: \`{ customer, subscriptions[] }\`) does not match live webhooks.
19
+
20
+ Rare historical payloads used \`{ event, data, ref?, sender? }\`. If both \`event\` and \`data\` are objects, use \`data\`.
21
+
22
+ ## Registering endpoints (v1 management API)
23
+
24
+ - GET/POST \`/webhooks/\` · GET/PUT/PATCH/DELETE \`/webhooks/{id}/\` (secret key)
25
+ - Create body: \`{ url, event }\` (\`event\` may be a specific type or a family wildcard like \`payment.*\`)
26
+ - Create/get response includes \`hmac_secret\` (store it; Askell will not show it again in a useful way if you lose it) and \`hmac_digest\` (typically \`SHA512\`)
27
+
28
+ Tools: \`askell_list_webhooks\`, \`askell_call\` (GET), \`askell_mutate\` (POST/PUT/PATCH/DELETE).
29
+
30
+ ## Event families and payload fields
31
+
32
+ Live REST \`Subscription\` objects have extra fields the OpenAPI schema omits. Webhook bodies differ slightly from GET \`/subscriptions/\` (notably \`last_billing_log\` vs \`billing_logs[]\`).
33
+
34
+ ### subscription.* (v1)
35
+ \`subscription.created\`, \`subscription.changed\`, \`subscription.renewed\`
36
+
37
+ \`id\`, \`plan\` (no \`payment_processor\` / membership-card / wallet-pass fields), \`customer\` (numeric id), \`customer_reference\`, \`trial_end\`, \`start_date\`, \`ended_at\`, \`reference\`, \`active\`, \`meta\` (JSON **string**, often \`"{}"\`), \`description\`, \`active_until\`, \`is_on_trial\`, \`token\`, \`is_failing\`, \`last_billing_log\` (single object or null — not \`billing_logs[]\`), \`delivery_address\`, \`amount\`. Live cancel/change events also send \`cancelled\`, \`cancel_date\`, \`has_payment_plan\`, \`payment_plan_info\`.
38
+
39
+ V2 migration: \`subscription.*\` is **not** aliased onto the new contract (payload is \`SubscriptionContract\`). \`subscription.canceled\` is not sent merely because billing moved to a V2 contract.
40
+
41
+ ### subscription_contract.* (v2)
42
+ \`created\`, \`changed\`, \`renewed\`, \`migrated\`
43
+
44
+ \`id\`, \`customer\`, \`state\`, \`billing_anchor_at\`, \`next_billing_at\`, \`cancel_at\`, \`cancel_at_period_end\`, \`canceled_at\`, \`ended_at\`, \`currency\`, \`recurring\`, \`legacy_subscription\`, \`legacy_subscription_ids\`, \`migration_effective_at\`, \`billing_managed_by\`, \`created_at\`, \`updated_at\`.
45
+
46
+ ### billing_run.* (v2)
47
+ \`created\`, \`changed\`, \`succeeded\`, \`failed\`, \`retry\`
48
+
49
+ \`id\`, \`contract\`, \`period_start_at\`, \`period_end_at\`, \`state\`, \`currency\`, \`subtotal_amount\`, \`tax_amount\`, \`total_amount\`, \`attempt_count\`, \`max_attempts\`, \`next_retry_at\`, \`last_attempt_at\`, \`transaction\`, \`created_at\`, \`updated_at\`.
50
+
51
+ ### customer.* (v1)
52
+ \`created\`, \`changed\` — same shape as GET \`/customers/{ref}/\` (\`id\`, names, \`email\`, \`phone\`, \`customer_reference\`, address fields, \`payment_method[]\`).
53
+
54
+ ### payment.* (v1)
55
+ \`created\`, \`changed\`, \`retry\`
56
+
57
+ \`uuid\`, \`amount\`, \`currency\`, \`description\`, \`reference\`, \`state\` (\`pending\` | \`settled\` | \`failed\` | \`retrying\`), \`created_at\`, \`updated_at\`, \`transactions[]\`.
58
+
59
+ ### checkout.* (v1)
60
+ \`created\`, \`changed\` — \`token\`, \`checkout_url\`, \`status\`.
35
61
  `;
36
62
 
37
63
  export function registerResources(server: McpServer): void {
@@ -79,7 +105,7 @@ export function registerResources(server: McpServer): void {
79
105
  'askell://docs/webhook-events',
80
106
  {
81
107
  title: 'Askell webhook events',
82
- description: 'Webhook event types and management overview',
108
+ description: 'Inbound webhook events, payload shapes, HMAC, and /webhooks/ management',
83
109
  mimeType: 'text/markdown',
84
110
  },
85
111
  async () => ({
package/src/server.ts CHANGED
@@ -4,7 +4,7 @@ import { AskellClient } from './client/askell-client.ts';
4
4
  import type { AppConfig } from './config.ts';
5
5
  import { registerResources } from './resources/register.ts';
6
6
  import { registerAnalysisTools } from './tools/analysis.ts';
7
- import { registerCallTool } from './tools/call.ts';
7
+ import { registerCallTools } from './tools/call.ts';
8
8
  import { registerDiscoveryTools } from './tools/discovery.ts';
9
9
 
10
10
  const SERVER_INSTRUCTIONS = `Askell MCP server for payment and subscription operations.
@@ -12,7 +12,7 @@ const SERVER_INSTRUCTIONS = `Askell MCP server for payment and subscription oper
12
12
  Workflow:
13
13
  1. Use askell_list_operations and askell_describe_operation to discover endpoints, parameters, and auth requirements.
14
14
  2. Prefer analysis tools (askell_customer_overview, askell_contract_overview, askell_billing_run_triage, askell_paginate_all, askell_list_webhooks) for common support tasks.
15
- 3. Use askell_call only when no dedicated tool covers the request.
15
+ 3. Use askell_call (GET/HEAD) or askell_mutate (POST/PUT/PATCH/DELETE) when no dedicated tool covers the request.
16
16
 
17
17
  API models:
18
18
  - v1 (legacy): PlanVariant + Subscription at paths like /subscriptions/, /customers/. Still supported for existing integrations.
@@ -26,6 +26,11 @@ API layout:
26
26
  - V2 list endpoints paginate only when page_size is provided (default 10, max 1000).
27
27
  - GET /v2/customer-entitlements/ requires customer_reference query param.
28
28
 
29
+ V2 discounts (coupons / promotion codes) — not the same as v1:
30
+ - v2 contracts: one active coupon at a time. GET /v2/subscription-contracts/{id}/discount/ (also nested as contract.discount). Apply with POST .../apply-code/ {promotion_code}. Remove with POST .../remove-discount/.
31
+ - Quotes: pass promotion_code on POST /v2/subscription-offer-quotes/; totals already include the discount when set.
32
+ - v1 Subscription.discount is a 0-100 percent override on a PlanVariant subscription. Do not send it to v2 contract endpoints.
33
+
29
34
  V2 checkout notes:
30
35
  - checkout_url on V2 checkouts points to the API object URL, not a browser payment page.
31
36
  - Embedded checkout uses POST /v2/checkout-sessions/ plus browser session-token sub-paths (see docs, not all in OpenAPI).
@@ -35,18 +40,19 @@ Auth:
35
40
  - Only temporary payment method and checkout status endpoints use the public key.
36
41
 
37
42
  Safety:
38
- - Mutating askell_call requests require operator approval when requireMutationApproval is enabled.
39
- - Large list responses may be truncated or summarized to fit responseMaxBytes; check meta.truncatedByMaxBytes and meta.compacted.
43
+ - Writes go through askell_mutate (destructiveHint). Reads go through askell_call (readOnlyHint).
44
+ - mutationGate=auto (default): confirmation form only if this request's envelope declared form elicitation; otherwise the client's own tool-allow UI is the gate. elicit always returns a form (SDK refuses if the client cannot fulfil it). off never asks.
45
+ - Large list responses are compacted (index of id/dates/plan/customer) to fit responseMaxBytes before dropping rows; check meta.truncatedByMaxBytes, meta.compacted, and meta.compactedMode.
40
46
 
41
47
  Resources:
42
48
  - askell://spec/v1 and askell://spec/v2 — bundled OpenAPI
43
- - askell://docs/webhook-events — webhook event types including V2 subscription_contract.* and billing_run.*`;
49
+ - askell://docs/webhook-events — inbound webhook payloads (ignore OpenAPI /your-webhook-url/), HMAC-SHA512, /webhooks/ hmac_secret`;
44
50
 
45
51
  export function createServer(config: AppConfig): McpServer {
46
52
  const server = new McpServer(
47
53
  {
48
54
  name: 'askell-mcp',
49
- version: '0.1.0',
55
+ version: '0.3.0',
50
56
  },
51
57
  {
52
58
  instructions: SERVER_INSTRUCTIONS,
@@ -56,7 +62,7 @@ export function createServer(config: AppConfig): McpServer {
56
62
  const client = new AskellClient(config);
57
63
 
58
64
  registerDiscoveryTools(server);
59
- registerCallTool(server, client, config);
65
+ registerCallTools(server, client, config);
60
66
  registerAnalysisTools(server, client);
61
67
  registerResources(server);
62
68
 
@@ -36,12 +36,23 @@ export function registerAnalysisTools(
36
36
  {
37
37
  title: 'Paginate Askell list endpoint',
38
38
  description:
39
- 'Fetch all pages from a paginated Askell list endpoint (v1/v2). Follows `next` links until exhausted or maxPages is reached. Large results are summarized to fit responseMaxBytescheck meta.truncatedByMaxBytes and meta.compacted in the response.',
39
+ 'Fetch all pages from a paginated Askell list endpoint (v1/v2). Follows `next` links until exhausted or maxPages is reached. When the full payload exceeds responseMaxBytes, items are compacted (summary, then an index of id/dates/plan/customer) so rows are kept dropping rows is last resort. Check meta.truncatedByMaxBytes, meta.compacted, and meta.compactedMode.',
40
40
  inputSchema: z.object({
41
41
  path: z.string().describe('List endpoint path, e.g. /subscriptions/'),
42
- query: z.record(z.string(), z.unknown()).optional(),
43
- apiKeyKind: z.enum(['secret', 'public']).default('secret'),
44
- maxPages: z.int().positive().max(100).default(20),
42
+ query: z
43
+ .record(z.string(), z.json())
44
+ .optional()
45
+ .describe('Query string parameters forwarded to the list endpoint'),
46
+ apiKeyKind: z
47
+ .enum(['secret', 'public'])
48
+ .default('secret')
49
+ .describe('Which configured API key to use'),
50
+ maxPages: z
51
+ .int()
52
+ .positive()
53
+ .max(100)
54
+ .default(20)
55
+ .describe('Stop after this many pages (default 20, max 100)'),
45
56
  }),
46
57
  annotations: {
47
58
  readOnlyHint: true,
@@ -130,10 +141,17 @@ export function registerAnalysisTools(
130
141
  {
131
142
  title: 'Subscription contract overview (v2)',
132
143
  description:
133
- 'Fetch a v2 subscription contract and recent billing runs filtered by contract id.',
144
+ 'Fetch a v2 subscription contract and recent billing runs filtered by contract id. The contract payload includes `discount` (active coupon) when one is applied.',
134
145
  inputSchema: z.object({
135
- contractId: z.union([z.string(), z.number()]),
136
- billingRunLimit: z.int().positive().max(50).default(10),
146
+ contractId: z
147
+ .union([z.string().min(1), z.int()])
148
+ .describe('V2 subscription contract id'),
149
+ billingRunLimit: z
150
+ .int()
151
+ .positive()
152
+ .max(50)
153
+ .default(10)
154
+ .describe('Max billing runs to include (page_size)'),
137
155
  }),
138
156
  annotations: {
139
157
  readOnlyHint: true,
@@ -192,8 +210,13 @@ export function registerAnalysisTools(
192
210
  description:
193
211
  'Fetch a billing run by id with optional related contract context for failure analysis.',
194
212
  inputSchema: z.object({
195
- billingRunId: z.union([z.string(), z.number()]),
196
- includeContract: z.boolean().default(true),
213
+ billingRunId: z
214
+ .union([z.string().min(1), z.int()])
215
+ .describe('V2 billing run id'),
216
+ includeContract: z
217
+ .boolean()
218
+ .default(true)
219
+ .describe('Also fetch the related subscription contract when the run has a contract id'),
197
220
  }),
198
221
  annotations: {
199
222
  readOnlyHint: true,
@@ -257,7 +280,12 @@ export function registerAnalysisTools(
257
280
  description:
258
281
  'List Askell webhook endpoints configured for the account (management API only).',
259
282
  inputSchema: z.object({
260
- page_size: z.int().positive().max(1000).optional(),
283
+ page_size: z
284
+ .int()
285
+ .positive()
286
+ .max(1000)
287
+ .optional()
288
+ .describe('Page size for GET /webhooks/ (Askell default 10, max 1000)'),
261
289
  }),
262
290
  annotations: {
263
291
  readOnlyHint: true,