askell-mcp 0.4.12 → 0.4.14

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.
@@ -27,7 +27,27 @@ export class AskellClient {
27
27
  this.baseUrl = normalizeBaseUrl(config.apiBaseUrl);
28
28
  }
29
29
 
30
- async request(input: AskellRequest): Promise<FormattedResponse & { ok: boolean; status: number }> {
30
+ private apiKeyFor(kind: ApiKeyKind | undefined): string {
31
+ const apiKeyKind = kind ?? 'secret';
32
+ const apiKey =
33
+ apiKeyKind === 'public'
34
+ ? this.config.publicApiKey
35
+ : this.config.secretApiKey;
36
+
37
+ if (!apiKey) {
38
+ throw new Error(
39
+ apiKeyKind === 'public'
40
+ ? 'publicApiKey is not configured'
41
+ : 'secretApiKey is not configured',
42
+ );
43
+ }
44
+
45
+ return apiKey;
46
+ }
47
+
48
+ async request(
49
+ input: AskellRequest,
50
+ ): Promise<FormattedResponse & { ok: boolean; status: number }> {
31
51
  const method = input.method.toUpperCase();
32
52
  const path = normalizeApiPath(input.path);
33
53
  const url = new URL(`${this.baseUrl}${path}`);
@@ -49,19 +69,7 @@ export class AskellClient {
49
69
  }
50
70
  }
51
71
 
52
- const apiKeyKind = input.apiKeyKind ?? 'secret';
53
- const apiKey =
54
- apiKeyKind === 'public'
55
- ? this.config.publicApiKey
56
- : this.config.secretApiKey;
57
-
58
- if (!apiKey) {
59
- throw new Error(
60
- apiKeyKind === 'public'
61
- ? 'publicApiKey is not configured'
62
- : 'secretApiKey is not configured',
63
- );
64
- }
72
+ const apiKey = this.apiKeyFor(input.apiKeyKind);
65
73
 
66
74
  const started = performance.now();
67
75
  const response = await fetch(url, {
@@ -74,8 +82,7 @@ export class AskellClient {
74
82
  : {}),
75
83
  ...input.headers,
76
84
  },
77
- body:
78
- input.body !== undefined ? JSON.stringify(input.body) : undefined,
85
+ body: input.body !== undefined ? JSON.stringify(input.body) : undefined,
79
86
  signal: input.signal,
80
87
  });
81
88
 
@@ -108,19 +115,7 @@ export class AskellClient {
108
115
  signal?: AbortSignal;
109
116
  }): Promise<FormattedResponse & { ok: boolean; status: number }> {
110
117
  const maxPages = input.maxPages ?? 20;
111
- const apiKeyKind = input.apiKeyKind ?? 'secret';
112
- const apiKey =
113
- apiKeyKind === 'public'
114
- ? this.config.publicApiKey
115
- : this.config.secretApiKey;
116
-
117
- if (!apiKey) {
118
- throw new Error(
119
- apiKeyKind === 'public'
120
- ? 'publicApiKey is not configured'
121
- : 'secretApiKey is not configured',
122
- );
123
- }
118
+ const apiKey = this.apiKeyFor(input.apiKeyKind);
124
119
 
125
120
  const collected: unknown[] = [];
126
121
  let nextUrl: URL | null = null;
@@ -1,7 +1,5 @@
1
- import {
2
- redactSensitiveFields,
3
- redactSecretsInText,
4
- } from './redact.ts';
1
+ import { isRecord } from '../is-record.ts';
2
+ import { redactSensitiveFields, redactSecretsInText } from './redact.ts';
5
3
 
6
4
  export interface FormattedResponse {
7
5
  text: string;
@@ -41,11 +39,8 @@ export function limitText(
41
39
  }
42
40
 
43
41
  function summarizeListItem(item: unknown): unknown {
44
- if (item == null || typeof item !== 'object' || Array.isArray(item)) {
45
- return item;
46
- }
42
+ if (!isRecord(item)) return item;
47
43
 
48
- const obj = item as Record<string, unknown>;
49
44
  const out: Record<string, unknown> = {};
50
45
  const scalarKeys = [
51
46
  'id',
@@ -67,16 +62,20 @@ function summarizeListItem(item: unknown): unknown {
67
62
  'currency',
68
63
  'amount',
69
64
  'total_amount',
65
+ 'code',
66
+ 'valid',
67
+ 'duration',
68
+ 'coupon',
70
69
  ] as const;
71
70
 
72
71
  for (const key of scalarKeys) {
73
- if (key in obj) {
74
- out[key] = obj[key];
72
+ if (key in item) {
73
+ out[key] = item[key];
75
74
  }
76
75
  }
77
76
 
78
- if (obj.customer && typeof obj.customer === 'object') {
79
- const customer = obj.customer as Record<string, unknown>;
77
+ if (item.customer && typeof item.customer === 'object') {
78
+ const customer = item.customer as Record<string, unknown>;
80
79
  out.customer = {
81
80
  id: customer.id,
82
81
  customer_reference:
@@ -84,62 +83,63 @@ function summarizeListItem(item: unknown): unknown {
84
83
  };
85
84
  }
86
85
 
87
- if (obj.plan && typeof obj.plan === 'object') {
88
- const plan = obj.plan as Record<string, unknown>;
86
+ if (item.plan && typeof item.plan === 'object') {
87
+ const plan = item.plan as Record<string, unknown>;
89
88
  out.plan = {
90
89
  id: plan.id,
91
90
  name: plan.name,
92
91
  };
93
92
  }
94
93
 
95
- return Object.keys(out).length > 0 ? out : obj;
94
+ return Object.keys(out).length > 0 ? out : item;
96
95
  }
97
96
 
98
97
  /** Tight projection for analytical list queries (dates, plan name, customer). */
99
98
  function indexListItem(item: unknown): unknown {
100
- if (item == null || typeof item !== 'object' || Array.isArray(item)) {
101
- return item;
102
- }
99
+ if (!isRecord(item)) return item;
103
100
 
104
- const obj = item as Record<string, unknown>;
105
101
  const out: Record<string, unknown> = {};
106
102
 
107
- if ('id' in obj) {
108
- out.id = obj.id;
103
+ if ('id' in item) {
104
+ out.id = item.id;
109
105
  }
110
106
 
111
- if ('start_date' in obj) {
112
- out.start_date = obj.start_date;
113
- } else if ('created_at' in obj) {
114
- out.created_at = obj.created_at;
107
+ if ('start_date' in item) {
108
+ out.start_date = item.start_date;
109
+ } else if ('created_at' in item) {
110
+ out.created_at = item.created_at;
115
111
  }
116
112
 
117
- if ('ended_at' in obj && obj.ended_at != null) {
118
- out.ended_at = obj.ended_at;
113
+ if ('ended_at' in item && item.ended_at != null) {
114
+ out.ended_at = item.ended_at;
119
115
  }
120
116
 
121
- const plan = obj.plan;
117
+ const plan = item.plan;
122
118
  if (typeof plan === 'string' || typeof plan === 'number') {
123
119
  out.plan = plan;
124
120
  } else if (plan && typeof plan === 'object' && 'name' in plan) {
125
121
  out.plan = (plan as { name: unknown }).name;
126
- } else if ('name' in obj && typeof obj.name === 'string') {
127
- out.name = obj.name;
122
+ } else if ('name' in item && typeof item.name === 'string') {
123
+ out.name = item.name;
124
+ }
125
+
126
+ if (typeof item.code === 'string') {
127
+ out.code = item.code;
128
128
  }
129
129
 
130
130
  const customerRef =
131
- obj.customer_reference ??
132
- (obj.customer && typeof obj.customer === 'object'
133
- ? ((obj.customer as Record<string, unknown>).customer_reference ??
134
- (obj.customer as Record<string, unknown>).reference ??
135
- (obj.customer as Record<string, unknown>).id)
131
+ item.customer_reference ??
132
+ (item.customer && typeof item.customer === 'object'
133
+ ? ((item.customer as Record<string, unknown>).customer_reference ??
134
+ (item.customer as Record<string, unknown>).reference ??
135
+ (item.customer as Record<string, unknown>).id)
136
136
  : undefined);
137
137
  if (customerRef !== undefined) {
138
138
  out.customer_reference = customerRef;
139
139
  }
140
140
 
141
- if (typeof obj.email === 'string') {
142
- out.email = obj.email;
141
+ if (typeof item.email === 'string') {
142
+ out.email = item.email;
143
143
  }
144
144
 
145
145
  return Object.keys(out).length > 0 ? out : summarizeListItem(item);
@@ -164,9 +164,7 @@ function serializeListPayload(
164
164
  body: items.slice(0, returnedCount),
165
165
  };
166
166
 
167
- return pretty
168
- ? JSON.stringify(payload, null, 2)
169
- : JSON.stringify(payload);
167
+ return pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
170
168
  }
171
169
 
172
170
  function maxFittingCount(
@@ -296,11 +294,7 @@ export function buildBoundedListPayload(input: {
296
294
 
297
295
  const text = serializeListPayload(
298
296
  status,
299
- compactMeta(
300
- meta,
301
- 'index',
302
- 'Response too large; returning metadata only',
303
- ),
297
+ compactMeta(meta, 'index', 'Response too large; returning metadata only'),
304
298
  [],
305
299
  0,
306
300
  true,
@@ -367,12 +361,7 @@ export function formatApiResponse(
367
361
  }
368
362
 
369
363
  function pickHeaders(headers: Headers): Record<string, string> {
370
- const interesting = [
371
- 'content-type',
372
- 'date',
373
- 'x-request-id',
374
- 'retry-after',
375
- ];
364
+ const interesting = ['content-type', 'date', 'x-request-id', 'retry-after'];
376
365
  const out: Record<string, string> = {};
377
366
 
378
367
  for (const name of interesting) {
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return value != null && typeof value === 'object' && !Array.isArray(value);
3
+ }
@@ -5,6 +5,8 @@
5
5
  * Idempotent: safe to run on an already-patched document.
6
6
  */
7
7
 
8
+ import { isRecord } from '../is-record.ts';
9
+
8
10
  type JsonSchema = Record<string, unknown>;
9
11
  type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
10
12
 
@@ -49,14 +51,7 @@ const RESPONSE_BODIES = [
49
51
  readonly [string, HttpMethod, string, string]
50
52
  >;
51
53
 
52
- const HTTP_METHODS = [
53
- 'get',
54
- 'post',
55
- 'put',
56
- 'patch',
57
- 'delete',
58
- 'head',
59
- ] as const;
54
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'] as const;
60
55
 
61
56
  const nullableString = (maxLength?: number): JsonSchema => ({
62
57
  type: 'string',
@@ -136,10 +131,6 @@ const CUSTOMER_READ_SCHEMA: JsonSchema = {
136
131
  },
137
132
  };
138
133
 
139
- function isRecord(value: unknown): value is Record<string, unknown> {
140
- return value != null && typeof value === 'object' && !Array.isArray(value);
141
- }
142
-
143
134
  function dropWebhookCallOperations(doc: OpenApiDocument): void {
144
135
  const paths = doc.paths;
145
136
  if (!paths) {
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/server';
2
2
 
3
3
  import { getBundledSpec } from '../openapi/registry.ts';
4
4
 
5
- const WEBHOOK_EVENTS_DOC = `# Askell webhook events (reference)
5
+ export const WEBHOOK_EVENTS_DOC = `# Askell webhook events (reference)
6
6
 
7
7
  Askell POSTs signed JSON to each URL you register. Verify \`Hook-HMAC\` before parsing.
8
8
 
@@ -63,11 +63,11 @@ V2 migration: \`subscription.*\` is **not** aliased onto the new contract (paylo
63
63
  \`created\`, \`changed\` — \`token\`, \`checkout_url\`, \`status\`.
64
64
 
65
65
  ### fulfillment_order.* (v2)
66
- Family wildcard \`fulfillment_order.*\`. Concrete event names beyond the family are not listed in swagger or live webhook docs — do not invent \`created\`/\`changed\`.
66
+ Family wildcard \`fulfillment_order.*\`. Bundled swagger names \`fulfillment_order.fulfilled\` (\`POST .../fulfill/\` or dashboard ship) and \`fulfillment_order.cancelled\` (\`POST .../cancel/\`). Do not invent \`created\`/\`changed\`. Live https://docs.askell.is/en/api/webhooks.html still omits this family.
67
67
 
68
- Body = \`V2FulfillmentOrder\` = \`GET /v2/fulfillment-orders/{fulfillmentOrderId}/\` (list items are the same object). Physical order from a paid billing run (\`billing_run_id\`; at most one order per run). \`delivery_address\` is a snapshot (later contract address edits do not change it). \`shipping_selection\` is the checkout snapshot. \`fulfillments[]\` are booked shipments (empty until booked / if no shipping providers). Status: \`open\` | \`partially_fulfilled\` | \`fulfilled\` | \`cancelled\`.
68
+ Body = \`V2FulfillmentOrder\` = \`GET /v2/fulfillment-orders/{fulfillmentOrderId}/\` (list items are the same object). Physical order from a paid billing run (\`billing_run_id\`; at most one order per run). \`delivery_address\` is a snapshot (later contract address edits do not change it). \`shipping_selection\` is the checkout snapshot. \`fulfillments[]\` are booked shipments (empty until booked / if no shipping providers). Status: \`open\` | \`partially_fulfilled\` | \`fulfilled\` | \`cancelled\`. External carrier with no Askell integration: shipment \`handler\` is \`""\` — read \`carrier\`.
69
69
 
70
- Register \`fulfillment_order.*\` on \`POST /webhooks/\`. REST backfill: \`GET /v2/fulfillment-orders/?updated_since=\` (secret; newest first; \`403\` if the account has no subscription contracts or shipping is disabled). Read-only — no mark-shipped mutate.
70
+ Register \`fulfillment_order.*\` on \`POST /webhooks/\`. REST backfill: \`GET /v2/fulfillment-orders/?updated_since=\` (secret; newest first; \`403\` if the account has no subscription contracts or shipping is disabled). Mutate via \`askell_mutate\`: \`POST /v2/fulfillment-orders/{id}/fulfill/\` (optional body) and \`POST .../cancel/\` (no body). Both idempotent \`200\` if already in that state (webhook/email not replayed). \`409\` codes: \`order_cancelled\`, \`order_fulfilled\`, \`booking_in_progress\`.
71
71
  `;
72
72
 
73
73
  export function registerResources(server: McpServer): void {
package/src/server.ts CHANGED
@@ -37,7 +37,7 @@ Workflow:
37
37
 
38
38
  API models:
39
39
  - v1 (legacy): PlanVariant + Subscription at paths like /subscriptions/, /customers/. Still supported for existing integrations.
40
- - v2 (current): Catalog, bundles, quotes, checkouts, subscription contracts, billing runs, fulfillment orders under /v2/. Prefer v2 for new integrations.
40
+ - v2 (current): Catalog, bundles, quotes, checkouts, subscription contracts, billing runs, coupons/promotion codes, fulfillment orders under /v2/. Prefer v2 for new integrations.
41
41
  - Prose docs at https://docs.askell.is/api/ may describe flows (embedded checkout, 3D Secure, wallet passes) not fully listed in OpenAPI.
42
42
 
43
43
  API layout:
@@ -47,8 +47,9 @@ API layout:
47
47
  - V2 list endpoints paginate only when page_size is provided (default 10, max 1000).
48
48
  - GET /v2/customer-entitlements/ requires customer_reference query param.
49
49
 
50
- V2 discounts — two systems, not v1 Subscription.discount (0-100 on a PlanVariant; never send that to v2):
51
- - Coupons: one active per contract. GET /v2/subscription-contracts/{id}/discount/ (also nested as contract.discount). Apply with POST .../apply-code/ {promotion_code}. Remove with POST .../remove-discount/.
50
+ V2 discounts — not v1 Subscription.discount (0-100 on a PlanVariant; never send that to v2). Coupon = discount definition; promotion code = customer-facing code:
51
+ - Catalog (secret): CRUD /v2/coupons/ and /v2/promotion-codes/. Create coupon: exactly one of amount_off+currency or percent_off; duration_in_months required iff duration=repeating (omit otherwise); redeem_by must be future. PATCH type switch: send the old field as null. Redeemed coupon/code cannot DELETE — retire coupon with redeem_by/max_redemptions, promo with active=false (frees code for reuse). List/get hide soft-deletes. Promo code is uppercased and generated if omitted; unique among active; restrict with customer xor customer_reference.
52
+ - Contract: one active discount. GET /v2/subscription-contracts/{id}/discount/ (also nested as contract.discount). Apply with POST .../apply-code/ {promotion_code}. Remove with POST .../remove-discount/.
52
53
  - Quotes (POST /v2/subscription-offer-quotes/): pass promotion_code for coupons. When quoting an existing customer, pass customer (numeric id) or combo discounts from their other active contracts and promo-code customer restrictions are skipped. First-period subtotal/tax/total already include coupon + combo. quote.recurring_* include combo, not the coupon — renewal-with-coupon is discount.recurring_final_amount, and only while duration still applies (once → after first payment use recurring_*). combo_discounts[] and discount.recurring_* are on the quote response (askell_describe_operation omits response schemas). Combo is automatic, not apply-code.
53
54
 
54
55
  V2 checkout notes:
@@ -59,8 +60,9 @@ V2 checkout notes:
59
60
  - Hosted iframe (not askell.js): POST /v2/checkouts/ and POST .../payment-method-registrations/ take allowed_origin (one origin, no path; http only localhost/loopback). Replaces account-level frame-ancestors; GET empty string = account-level. Rejected on /v2/checkout-sessions/ (sales-channel allowed_origins[]).
60
61
  - Embedded checkout uses POST /v2/checkout-sessions/ plus browser session-token sub-paths (widget collects address/shipping; see docs, not all in OpenAPI).
61
62
 
62
- V2 fulfillment (warehouse, read-only):
63
- - GET /v2/fulfillment-orders/ and GET /v2/fulfillment-orders/{id}/. Same body as fulfillment_order.* webhooks (V2FulfillmentOrder). Secret key. 403 if the account has no subscription contracts or shipping is disabled. Poll updated_since after a missed webhook (newest first). No POST/PATCH — cannot mark shipped via the API.
63
+ V2 fulfillment (warehouse):
64
+ - GET /v2/fulfillment-orders/ and GET /v2/fulfillment-orders/{id}/. Same body as fulfillment_order.* webhooks (V2FulfillmentOrder). Secret key. 403 if contracts/shipping off. Poll updated_since after a missed webhook (newest first).
65
+ - POST .../{id}/fulfill/ marks shipped (body optional: tracking_number, tracking_url, provider_order_id, carrier, weight_grams). POST .../{id}/cancel/ (no body). Both idempotent 200 if already in that state (no second webhook/email). 409: order_cancelled | order_fulfilled | booking_in_progress (retry shortly). 403 if fulfillment is switched off. External carrier with no Askell integration: shipment.handler is "" — read carrier.
64
66
 
65
67
  Auth:
66
68
  - Most endpoints need the secret API key.
@@ -2,21 +2,86 @@ import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
2
2
  import * as z from 'zod';
3
3
 
4
4
  import { AskellClient } from '../client/askell-client.ts';
5
+ import { isRecord } from '../is-record.ts';
5
6
 
6
7
  /** Minimal shape of the handler `ctx` param needed here — avoids depending on the SDK's internal context type name. */
7
8
  type ToolContext = { mcpReq: { signal: AbortSignal } };
8
9
 
10
+ type SafeResult = { ok: boolean; data: unknown; error?: string };
11
+
12
+ const ERROR_DETAIL_MAX = 200;
13
+
14
+ function clip(text: string): string {
15
+ const oneLine = text.replace(/\s+/g, ' ').trim();
16
+ if (oneLine.length <= ERROR_DETAIL_MAX) return oneLine;
17
+
18
+ return `${oneLine.slice(0, ERROR_DETAIL_MAX - 1)}…`;
19
+ }
20
+
21
+ function messageList(value: unknown): string | undefined {
22
+ if (typeof value === 'string') {
23
+ const text = clip(value);
24
+ return text.length > 0 ? text : undefined;
25
+ }
26
+ if (
27
+ !Array.isArray(value) ||
28
+ !value.every((item) => typeof item === 'string')
29
+ ) {
30
+ return undefined;
31
+ }
32
+ const text = clip(value.filter((item) => item.trim().length > 0).join('; '));
33
+ return text.length > 0 ? text : undefined;
34
+ }
35
+
36
+ /**
37
+ * One line for the model. The Askell body stays in `data`.
38
+ * DRF `detail` / `non_field_errors` / flat field errors only — a resource
39
+ * object must not be flattened into the summary.
40
+ */
41
+ export function summarizeApiFailure(status: number, body: unknown): string {
42
+ const detail = apiErrorDetail(body);
43
+ return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
44
+ }
45
+
46
+ function apiErrorDetail(body: unknown): string | undefined {
47
+ if (typeof body === 'string') return messageList(body);
48
+ if (!isRecord(body)) return undefined;
49
+
50
+ if ('detail' in body) {
51
+ const detail = messageList(body.detail);
52
+ if (detail) return detail;
53
+ }
54
+
55
+ if ('non_field_errors' in body) {
56
+ const detail = messageList(body.non_field_errors);
57
+ if (detail) return detail;
58
+ }
59
+
60
+ const parts: string[] = [];
61
+ for (const [key, value] of Object.entries(body)) {
62
+ if (key === 'detail' || key === 'non_field_errors') continue;
63
+ const text = messageList(value);
64
+ if (!text) return undefined;
65
+ parts.push(`${key}: ${text}`);
66
+ }
67
+
68
+ return parts.length > 0 ? clip(parts.join('; ')) : undefined;
69
+ }
70
+
9
71
  async function safeRequest(
10
72
  client: AskellClient,
11
73
  request: Parameters<AskellClient['request']>[0],
12
- ): Promise<{ ok: boolean; data: unknown; error?: string }> {
74
+ ): Promise<SafeResult> {
13
75
  try {
14
76
  const response = await client.request(request);
15
77
  const parsed = JSON.parse(response.text) as { body?: unknown };
78
+ const data = parsed.body ?? parsed;
79
+ if (response.ok) return { ok: true, data };
80
+
16
81
  return {
17
- ok: response.ok,
18
- data: parsed.body ?? parsed,
19
- error: response.ok ? undefined : response.text,
82
+ ok: false,
83
+ data,
84
+ error: summarizeApiFailure(response.status, data),
20
85
  };
21
86
  } catch (error) {
22
87
  return {
@@ -27,6 +92,13 @@ async function safeRequest(
27
92
  }
28
93
  }
29
94
 
95
+ function failedCalls(
96
+ calls: Array<[name: string, result: SafeResult]>,
97
+ ): string[] | undefined {
98
+ const names = calls.filter(([, result]) => !result.ok).map(([name]) => name);
99
+ return names.length > 0 ? names : undefined;
100
+ }
101
+
30
102
  export function registerAnalysisTools(
31
103
  server: McpServer,
32
104
  client: AskellClient,
@@ -74,7 +146,8 @@ export function registerAnalysisTools(
74
146
  content: [
75
147
  {
76
148
  type: 'text',
77
- text: error instanceof Error ? error.message : 'Pagination failed',
149
+ text:
150
+ error instanceof Error ? error.message : 'Pagination failed',
78
151
  },
79
152
  ],
80
153
  isError: true,
@@ -88,7 +161,7 @@ export function registerAnalysisTools(
88
161
  {
89
162
  title: 'Customer overview (v1)',
90
163
  description:
91
- 'Fetch a v1 customer and their v1 subscriptions in one call. Useful for support and billing investigations.',
164
+ 'Fetch a v1 customer and their v1 subscriptions in one call. Useful for support and billing investigations. Result is an error when the customer fetch fails; subscriptions are still included. `failures` lists every call that failed.',
92
165
  inputSchema: z.object({
93
166
  customerReference: z
94
167
  .string()
@@ -99,7 +172,10 @@ export function registerAnalysisTools(
99
172
  openWorldHint: true,
100
173
  },
101
174
  },
102
- async ({ customerReference }, ctx: ToolContext): Promise<CallToolResult> => {
175
+ async (
176
+ { customerReference },
177
+ ctx: ToolContext,
178
+ ): Promise<CallToolResult> => {
103
179
  const [customer, subscriptions] = await Promise.all([
104
180
  safeRequest(client, {
105
181
  method: 'GET',
@@ -113,25 +189,26 @@ export function registerAnalysisTools(
113
189
  }),
114
190
  ]);
115
191
 
192
+ const failures = failedCalls([
193
+ ['customer', customer],
194
+ ['subscriptions', subscriptions],
195
+ ]);
196
+
116
197
  const payload = {
117
198
  customerReference,
118
199
  customer,
119
200
  subscriptions,
201
+ ...(failures ? { failures } : {}),
202
+ ...(!customer.ok
203
+ ? {
204
+ hint: 'Verify the customerReference with askell_call GET /customers/ or askell_paginate_all.',
205
+ }
206
+ : {}),
120
207
  };
121
208
 
122
- const notFoundHint =
123
- !customer.ok && !subscriptions.ok
124
- ? ' Verify the customerReference with askell_call GET /customers/ or askell_paginate_all.'
125
- : '';
126
-
127
209
  return {
128
- content: [
129
- {
130
- type: 'text',
131
- text: JSON.stringify(payload, null, 2) + notFoundHint,
132
- },
133
- ],
134
- isError: !customer.ok && !subscriptions.ok,
210
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
211
+ isError: !customer.ok,
135
212
  };
136
213
  },
137
214
  );
@@ -141,7 +218,7 @@ export function registerAnalysisTools(
141
218
  {
142
219
  title: 'Subscription contract overview (v2)',
143
220
  description:
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, `shipping_selection` when shipping was chosen at checkout, and `subscriber_page` (customer-facing management URL, read-only).',
221
+ 'Fetch a v2 subscription contract and recent billing runs filtered by contract id. The contract payload includes `discount` (active coupon) when one is applied, `shipping_selection` when shipping was chosen at checkout, and `subscriber_page` (customer-facing management URL, read-only). Result is an error when the contract fetch fails. `failures` lists every call that failed.',
145
222
  inputSchema: z.object({
146
223
  contractId: z
147
224
  .union([z.string().min(1), z.int()])
@@ -181,23 +258,25 @@ export function registerAnalysisTools(
181
258
  }),
182
259
  ]);
183
260
 
261
+ const failures = failedCalls([
262
+ ['contract', contract],
263
+ ['billingRuns', billingRuns],
264
+ ]);
265
+
184
266
  const payload = {
185
267
  contractId,
186
268
  contract,
187
269
  billingRuns,
270
+ ...(failures ? { failures } : {}),
271
+ ...(!contract.ok
272
+ ? {
273
+ hint: 'Verify the contractId with askell_call GET /v2/subscription-contracts/.',
274
+ }
275
+ : {}),
188
276
  };
189
277
 
190
278
  return {
191
- content: [
192
- {
193
- type: 'text',
194
- text:
195
- JSON.stringify(payload, null, 2) +
196
- (!contract.ok
197
- ? ' Verify the contractId with askell_call GET /v2/subscription-contracts/.'
198
- : ''),
199
- },
200
- ],
279
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
201
280
  isError: !contract.ok,
202
281
  };
203
282
  },
@@ -208,7 +287,7 @@ export function registerAnalysisTools(
208
287
  {
209
288
  title: 'Billing run triage (v2)',
210
289
  description:
211
- 'Fetch a billing run by id with optional related contract context for failure analysis.',
290
+ 'Fetch a billing run by id with optional related contract context for failure analysis. Result is an error when the billing run fetch fails. A failed related contract stays in the payload and is listed in `failures`.',
212
291
  inputSchema: z.object({
213
292
  billingRunId: z
214
293
  .union([z.string().min(1), z.int()])
@@ -216,7 +295,9 @@ export function registerAnalysisTools(
216
295
  includeContract: z
217
296
  .boolean()
218
297
  .default(true)
219
- .describe('Also fetch the related subscription contract when the run has a contract id'),
298
+ .describe(
299
+ 'Also fetch the related subscription contract when the run has a contract id',
300
+ ),
220
301
  }),
221
302
  annotations: {
222
303
  readOnlyHint: true,
@@ -251,23 +332,26 @@ export function registerAnalysisTools(
251
332
  }
252
333
  }
253
334
 
335
+ const calls: [string, SafeResult][] = [['billingRun', billingRun]];
336
+ if (contract) {
337
+ calls.push(['contract', contract]);
338
+ }
339
+ const failures = failedCalls(calls);
340
+
254
341
  const payload = {
255
342
  billingRunId,
256
343
  billingRun,
257
344
  contract,
345
+ ...(failures ? { failures } : {}),
346
+ ...(!billingRun.ok
347
+ ? {
348
+ hint: 'Verify the billingRunId with askell_call GET /v2/billing-runs/.',
349
+ }
350
+ : {}),
258
351
  };
259
352
 
260
353
  return {
261
- content: [
262
- {
263
- type: 'text',
264
- text:
265
- JSON.stringify(payload, null, 2) +
266
- (!billingRun.ok
267
- ? ' Verify the billingRunId with askell_call GET /v2/billing-runs/.'
268
- : ''),
269
- },
270
- ],
354
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
271
355
  isError: !billingRun.ok,
272
356
  };
273
357
  },
@@ -285,7 +369,9 @@ export function registerAnalysisTools(
285
369
  .positive()
286
370
  .max(1000)
287
371
  .optional()
288
- .describe('Page size for GET /webhooks/ (Askell default 10, max 1000)'),
372
+ .describe(
373
+ 'Page size for GET /webhooks/ (Askell default 10, max 1000)',
374
+ ),
289
375
  }),
290
376
  annotations: {
291
377
  readOnlyHint: true,