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.
- package/README.md +16 -5
- package/package.json +9 -8
- package/spec/openapi-v2.json +435 -49
- package/src/client/response-formatter.ts +113 -32
- package/src/config.ts +36 -27
- package/src/openapi/registry.ts +17 -7
- package/src/resources/register.ts +53 -27
- package/src/server.ts +13 -7
- package/src/tools/analysis.ts +38 -10
- package/src/tools/call.ts +142 -61
- package/src/tools/discovery.ts +9 -5
- package/src/tools/mutation-gate.ts +74 -0
|
@@ -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
|
-
|
|
211
|
+
compactedMode: CompactedMode;
|
|
162
212
|
note?: string;
|
|
163
213
|
}> = [
|
|
164
|
-
{ items, pretty: true,
|
|
165
|
-
{ items, pretty: 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
attempt.
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
78
|
-
...(
|
|
79
|
-
? {
|
|
80
|
-
requireMutationApproval: parseEnvFlag(requireMutationApproval, true),
|
|
81
|
-
}
|
|
82
|
-
: {}),
|
|
90
|
+
...(responseMaxBytes ? { responseMaxBytes } : {}),
|
|
91
|
+
...(mutationGate !== undefined ? { mutationGate } : {}),
|
|
83
92
|
};
|
|
84
93
|
}
|
|
85
94
|
|
package/src/openapi/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
85
|
-
|
|
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
|
|
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
|
|
12
|
-
- Hook-API-Version: v1 for
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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: '
|
|
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 {
|
|
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
|
|
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
|
-
-
|
|
39
|
-
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
65
|
+
registerCallTools(server, client, config);
|
|
60
66
|
registerAnalysisTools(server, client);
|
|
61
67
|
registerResources(server);
|
|
62
68
|
|
package/src/tools/analysis.ts
CHANGED
|
@@ -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.
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
136
|
-
|
|
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
|
|
196
|
-
|
|
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
|
|
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,
|