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.
- package/README.md +3 -2
- package/package.json +1 -1
- package/spec/openapi-v2.json +1491 -373
- package/src/client/askell-client.ts +24 -29
- package/src/client/response-formatter.ts +40 -51
- package/src/is-record.ts +3 -0
- package/src/openapi/patch-v1.ts +3 -12
- package/src/resources/register.ts +4 -4
- package/src/server.ts +7 -5
- package/src/tools/analysis.ts +129 -43
- package/src/tools/mutation-gate.ts +8 -15
|
@@ -27,7 +27,27 @@ export class AskellClient {
|
|
|
27
27
|
this.baseUrl = normalizeBaseUrl(config.apiBaseUrl);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
74
|
-
out[key] =
|
|
72
|
+
if (key in item) {
|
|
73
|
+
out[key] = item[key];
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
if (
|
|
79
|
-
const customer =
|
|
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 (
|
|
88
|
-
const plan =
|
|
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 :
|
|
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 (
|
|
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
|
|
108
|
-
out.id =
|
|
103
|
+
if ('id' in item) {
|
|
104
|
+
out.id = item.id;
|
|
109
105
|
}
|
|
110
106
|
|
|
111
|
-
if ('start_date' in
|
|
112
|
-
out.start_date =
|
|
113
|
-
} else if ('created_at' in
|
|
114
|
-
out.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
|
|
118
|
-
out.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 =
|
|
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
|
|
127
|
-
out.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
|
-
|
|
132
|
-
(
|
|
133
|
-
? ((
|
|
134
|
-
(
|
|
135
|
-
(
|
|
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
|
|
142
|
-
out.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) {
|
package/src/is-record.ts
ADDED
package/src/openapi/patch-v1.ts
CHANGED
|
@@ -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.*\`.
|
|
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).
|
|
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 —
|
|
51
|
-
-
|
|
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
|
|
63
|
-
- GET /v2/fulfillment-orders/ and GET /v2/fulfillment-orders/{id}/. Same body as fulfillment_order.* webhooks (V2FulfillmentOrder). Secret key. 403 if
|
|
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.
|
package/src/tools/analysis.ts
CHANGED
|
@@ -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<
|
|
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:
|
|
18
|
-
data
|
|
19
|
-
error: response.
|
|
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:
|
|
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 (
|
|
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(
|
|
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(
|
|
372
|
+
.describe(
|
|
373
|
+
'Page size for GET /webhooks/ (Askell default 10, max 1000)',
|
|
374
|
+
),
|
|
289
375
|
}),
|
|
290
376
|
annotations: {
|
|
291
377
|
readOnlyHint: true,
|