askell-mcp 0.1.2 → 0.2.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 CHANGED
@@ -8,7 +8,7 @@ Connect it to Cursor, Claude Desktop, or any MCP client to discover Askell endpo
8
8
 
9
9
  - An [Askell](https://askell.is) account and **secret API key** (from the Askell dashboard)
10
10
  - One of:
11
- - [Bun](https://bun.sh) ≥ 1.3.14 (for `bunx`), or
11
+ - [Bun](https://bun.sh) ≥ 1.4.0 (for `bunx`), or
12
12
  - a prebuilt binary from [Releases](https://github.com/Neschadin/askell-mcp/releases) (no Bun needed)
13
13
 
14
14
  ## Quick start
@@ -99,6 +99,7 @@ Typical agent workflow:
99
99
 
100
100
  - **v1** — legacy paths like `/customers/`, `/subscriptions/` (no `/v2` prefix)
101
101
  - **v2** — current model: catalogs, quotes, checkouts, contracts, billing runs under `/v2/`
102
+ - **v2 coupons** — `GET/POST /v2/subscription-contracts/{id}/discount|apply-code|remove-discount` (one active coupon). Quotes take `promotion_code`. Not the v1 `discount` 0–100 field.
102
103
  - Paths use **trailing slashes**
103
104
  - Prefer **v2** for new integrations; v1 remains for existing ones
104
105
  - Docs: [docs.askell.is](https://docs.askell.is/) · OpenAPI: [v1](https://askell.is/api/swagger/swagger.json) · [v2](https://askell.is/api/swagger/v2/swagger.json)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askell-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for the Askell payment and subscription API (Bun + stdio)",
5
5
  "author": "Neschadin Oleksandr",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "mcp.json.example"
36
36
  ],
37
37
  "engines": {
38
- "bun": ">=1.3.14"
38
+ "bun": ">=1.4.0"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public",
@@ -47,19 +47,20 @@
47
47
  "test": "bun test",
48
48
  "smoke": "bun run scripts/smoke-test.ts",
49
49
  "test:integration": "bun run scripts/integration-test.ts",
50
+ "eval:tools": "bun run scripts/eval-tools.ts",
50
51
  "typecheck": "tsc --noEmit",
51
52
  "inspect": "bunx @modelcontextprotocol/inspector bun bin/askell-mcp",
52
53
  "sync-specs": "bun run scripts/sync-specs.ts",
53
54
  "prepack": "bun test && bun run typecheck",
54
- "build": "bun build --compile --minify src/index.ts --outfile dist/askell-mcp",
55
- "build:linux-x64": "bun build --compile --minify --target=bun-linux-x64 src/index.ts --outfile dist/askell-mcp-linux-x64",
56
- "build:linux-arm64": "bun build --compile --minify --target=bun-linux-arm64 src/index.ts --outfile dist/askell-mcp-linux-arm64",
57
- "build:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile dist/askell-mcp-darwin-arm64",
58
- "build:darwin-x64": "bun build --compile --minify --target=bun-darwin-x64 src/index.ts --outfile dist/askell-mcp-darwin-x64",
55
+ "build": "bun build --compile --minify --bytecode --format=esm src/index.ts --outfile dist/askell-mcp",
56
+ "build:linux-x64": "bun build --compile --minify --bytecode --format=esm --target=bun-linux-x64 src/index.ts --outfile dist/askell-mcp-linux-x64",
57
+ "build:linux-arm64": "bun build --compile --minify --bytecode --format=esm --target=bun-linux-arm64 src/index.ts --outfile dist/askell-mcp-linux-arm64",
58
+ "build:darwin-arm64": "bun build --compile --minify --bytecode --format=esm --target=bun-darwin-arm64 src/index.ts --outfile dist/askell-mcp-darwin-arm64",
59
+ "build:darwin-x64": "bun build --compile --minify --bytecode --format=esm --target=bun-darwin-x64 src/index.ts --outfile dist/askell-mcp-darwin-x64",
59
60
  "build:all": "bun run build && bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:darwin-arm64 && bun run build:darwin-x64"
60
61
  },
61
62
  "devDependencies": {
62
- "@types/bun": "1.3.14",
63
+ "@types/bun": "1.4.0",
63
64
  "typescript": "7.0.2"
64
65
  },
65
66
  "dependencies": {
@@ -1137,6 +1137,161 @@
1137
1137
  ]
1138
1138
  }
1139
1139
  },
1140
+ "/v2/subscription-contracts/{contractId}/apply-code/": {
1141
+ "post": {
1142
+ "tags": [
1143
+ "V2 Subscription Contracts"
1144
+ ],
1145
+ "summary": "Apply a promotion code to a V2 subscription contract",
1146
+ "description": "Validates and redeems a promotion code for the contract, creating an active discount that is applied to subsequent billing runs. A contract can only carry one active discount at a time. Requires a secret key.",
1147
+ "parameters": [
1148
+ {
1149
+ "$ref": "#/components/parameters/V2ContractId"
1150
+ }
1151
+ ],
1152
+ "requestBody": {
1153
+ "content": {
1154
+ "application/json": {
1155
+ "schema": {
1156
+ "type": "object",
1157
+ "required": [
1158
+ "promotion_code"
1159
+ ],
1160
+ "properties": {
1161
+ "promotion_code": {
1162
+ "type": "string"
1163
+ }
1164
+ }
1165
+ }
1166
+ }
1167
+ },
1168
+ "required": true
1169
+ },
1170
+ "responses": {
1171
+ "200": {
1172
+ "description": "Promotion code applied",
1173
+ "content": {
1174
+ "application/json": {
1175
+ "schema": {
1176
+ "type": "object",
1177
+ "properties": {
1178
+ "success": {
1179
+ "type": "boolean"
1180
+ },
1181
+ "message": {
1182
+ "type": "string"
1183
+ },
1184
+ "discount": {
1185
+ "$ref": "#/components/schemas/V2ContractDiscount"
1186
+ }
1187
+ }
1188
+ }
1189
+ }
1190
+ }
1191
+ },
1192
+ "400": {
1193
+ "$ref": "#/components/responses/V2ValidationError"
1194
+ },
1195
+ "404": {
1196
+ "$ref": "#/components/responses/V2NotFound"
1197
+ }
1198
+ },
1199
+ "security": [
1200
+ {
1201
+ "Secret-Api-Key": []
1202
+ }
1203
+ ]
1204
+ }
1205
+ },
1206
+ "/v2/subscription-contracts/{contractId}/discount/": {
1207
+ "get": {
1208
+ "tags": [
1209
+ "V2 Subscription Contracts"
1210
+ ],
1211
+ "summary": "Get the active discount for a V2 subscription contract",
1212
+ "description": "Returns the contract's active coupon discount, or null when none is active. Requires a secret key.",
1213
+ "parameters": [
1214
+ {
1215
+ "$ref": "#/components/parameters/V2ContractId"
1216
+ }
1217
+ ],
1218
+ "responses": {
1219
+ "200": {
1220
+ "description": "Active discount",
1221
+ "content": {
1222
+ "application/json": {
1223
+ "schema": {
1224
+ "type": "object",
1225
+ "properties": {
1226
+ "discount": {
1227
+ "allOf": [
1228
+ {
1229
+ "$ref": "#/components/schemas/V2ContractDiscount"
1230
+ }
1231
+ ],
1232
+ "nullable": true
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ },
1239
+ "404": {
1240
+ "$ref": "#/components/responses/V2NotFound"
1241
+ }
1242
+ },
1243
+ "security": [
1244
+ {
1245
+ "Secret-Api-Key": []
1246
+ }
1247
+ ]
1248
+ }
1249
+ },
1250
+ "/v2/subscription-contracts/{contractId}/remove-discount/": {
1251
+ "post": {
1252
+ "tags": [
1253
+ "V2 Subscription Contracts"
1254
+ ],
1255
+ "summary": "Remove the active discount from a V2 subscription contract",
1256
+ "description": "Ends the contract's active coupon discount. Subsequent billing runs are charged at full price. Requires a secret key.",
1257
+ "parameters": [
1258
+ {
1259
+ "$ref": "#/components/parameters/V2ContractId"
1260
+ }
1261
+ ],
1262
+ "responses": {
1263
+ "200": {
1264
+ "description": "Discount removed",
1265
+ "content": {
1266
+ "application/json": {
1267
+ "schema": {
1268
+ "type": "object",
1269
+ "properties": {
1270
+ "success": {
1271
+ "type": "boolean"
1272
+ },
1273
+ "message": {
1274
+ "type": "string"
1275
+ }
1276
+ }
1277
+ }
1278
+ }
1279
+ }
1280
+ },
1281
+ "400": {
1282
+ "$ref": "#/components/responses/V2ValidationError"
1283
+ },
1284
+ "404": {
1285
+ "$ref": "#/components/responses/V2NotFound"
1286
+ }
1287
+ },
1288
+ "security": [
1289
+ {
1290
+ "Secret-Api-Key": []
1291
+ }
1292
+ ]
1293
+ }
1294
+ },
1140
1295
  "/v2/checkouts/": {
1141
1296
  "post": {
1142
1297
  "tags": [
@@ -2091,6 +2246,80 @@
2091
2246
  }
2092
2247
  }
2093
2248
  },
2249
+ "V2ContractDiscount": {
2250
+ "type": "object",
2251
+ "description": "An applied coupon discount on a V2 subscription contract.",
2252
+ "properties": {
2253
+ "id": {
2254
+ "type": "string"
2255
+ },
2256
+ "coupon": {
2257
+ "type": "object",
2258
+ "properties": {
2259
+ "id": {
2260
+ "type": "string"
2261
+ },
2262
+ "name": {
2263
+ "type": "string",
2264
+ "nullable": true
2265
+ },
2266
+ "amount_off": {
2267
+ "type": "string",
2268
+ "format": "decimal",
2269
+ "nullable": true
2270
+ },
2271
+ "currency": {
2272
+ "type": "string",
2273
+ "nullable": true
2274
+ },
2275
+ "percent_off": {
2276
+ "type": "string",
2277
+ "format": "decimal",
2278
+ "nullable": true
2279
+ },
2280
+ "duration": {
2281
+ "type": "string",
2282
+ "enum": [
2283
+ "forever",
2284
+ "once",
2285
+ "repeating"
2286
+ ]
2287
+ },
2288
+ "duration_in_months": {
2289
+ "type": "integer",
2290
+ "nullable": true
2291
+ },
2292
+ "discount_display": {
2293
+ "type": "string"
2294
+ }
2295
+ }
2296
+ },
2297
+ "promotion_code": {
2298
+ "type": "object",
2299
+ "nullable": true,
2300
+ "properties": {
2301
+ "id": {
2302
+ "type": "string"
2303
+ },
2304
+ "code": {
2305
+ "type": "string"
2306
+ }
2307
+ }
2308
+ },
2309
+ "start": {
2310
+ "type": "string",
2311
+ "format": "date-time"
2312
+ },
2313
+ "end": {
2314
+ "type": "string",
2315
+ "format": "date-time",
2316
+ "nullable": true
2317
+ },
2318
+ "is_active": {
2319
+ "type": "boolean"
2320
+ }
2321
+ }
2322
+ },
2094
2323
  "V2SubscriptionOfferQuoteCreate": {
2095
2324
  "type": "object",
2096
2325
  "properties": {
@@ -2135,6 +2364,11 @@
2135
2364
  "items": {
2136
2365
  "$ref": "#/components/schemas/V2AdditionalBundle"
2137
2366
  }
2367
+ },
2368
+ "promotion_code": {
2369
+ "type": "string",
2370
+ "nullable": true,
2371
+ "description": "Optional promotion code. When valid, the resulting discount is folded into the offer totals; on contract creation the code is redeemed and a discount is attached to the contract."
2138
2372
  }
2139
2373
  }
2140
2374
  },
@@ -2405,58 +2639,14 @@
2405
2639
  }
2406
2640
  }
2407
2641
  },
2408
- "V2SubscriptionContractProrationPreview": {
2642
+ "V2SubscriptionContractChangeCommon": {
2409
2643
  "type": "object",
2410
2644
  "properties": {
2411
- "operation": {
2412
- "type": "string",
2413
- "enum": [
2414
- "add_item",
2415
- "remove_item",
2416
- "update_item",
2417
- "pause",
2418
- "resume",
2419
- "cancel",
2420
- "restart"
2421
- ],
2422
- "default": "update_item"
2423
- },
2424
- "contract_item": {
2425
- "type": "integer"
2426
- },
2427
- "price": {
2428
- "type": "integer",
2429
- "nullable": true
2430
- },
2431
- "quantity": {
2432
- "type": "integer",
2433
- "nullable": true
2434
- },
2435
- "discount_percent": {
2436
- "type": "string",
2437
- "format": "decimal",
2438
- "nullable": true
2439
- },
2440
2645
  "effective_at": {
2441
2646
  "type": "string",
2442
2647
  "format": "date-time",
2443
2648
  "nullable": true
2444
2649
  },
2445
- "start_date": {
2446
- "type": "string",
2447
- "format": "date",
2448
- "nullable": true
2449
- },
2450
- "end_date": {
2451
- "type": "string",
2452
- "format": "date",
2453
- "nullable": true
2454
- },
2455
- "pause_end_at": {
2456
- "type": "string",
2457
- "format": "date-time",
2458
- "nullable": true
2459
- },
2460
2650
  "proration_behavior": {
2461
2651
  "type": "string",
2462
2652
  "enum": [
@@ -2486,13 +2676,86 @@
2486
2676
  }
2487
2677
  }
2488
2678
  },
2679
+ "V2SubscriptionContractProrationPreview": {
2680
+ "description": "Polymorphic preview request. Fields beyond the common ones are operation-specific; fields not supported by the selected operation are rejected with a 400 validation error.",
2681
+ "allOf": [
2682
+ {
2683
+ "$ref": "#/components/schemas/V2SubscriptionContractChangeCommon"
2684
+ },
2685
+ {
2686
+ "type": "object",
2687
+ "properties": {
2688
+ "operation": {
2689
+ "type": "string",
2690
+ "enum": [
2691
+ "add_item",
2692
+ "remove_item",
2693
+ "update_item",
2694
+ "pause",
2695
+ "resume",
2696
+ "cancel",
2697
+ "restart"
2698
+ ],
2699
+ "default": "update_item"
2700
+ },
2701
+ "contract_item": {
2702
+ "type": "integer",
2703
+ "description": "Required for update_item and remove_item."
2704
+ },
2705
+ "price": {
2706
+ "type": "integer",
2707
+ "nullable": true,
2708
+ "description": "Only applies to add_item and update_item. Required for add_item."
2709
+ },
2710
+ "quantity": {
2711
+ "type": "integer",
2712
+ "nullable": true,
2713
+ "description": "Only applies to add_item and update_item."
2714
+ },
2715
+ "discount_percent": {
2716
+ "type": "string",
2717
+ "format": "decimal",
2718
+ "nullable": true,
2719
+ "description": "Only applies to add_item and update_item."
2720
+ },
2721
+ "unit_amount_override": {
2722
+ "type": "string",
2723
+ "format": "decimal",
2724
+ "nullable": true,
2725
+ "description": "Fixed per-unit amount for the item, used instead of the catalog price. Only applies to add_item and update_item. On update_item, omitting the field keeps the item's current override while an explicit null clears it so the item reverts to the catalog price."
2726
+ },
2727
+ "start_date": {
2728
+ "type": "string",
2729
+ "format": "date",
2730
+ "nullable": true,
2731
+ "description": "Only applies to pause."
2732
+ },
2733
+ "end_date": {
2734
+ "type": "string",
2735
+ "format": "date",
2736
+ "nullable": true,
2737
+ "description": "Only applies to pause."
2738
+ },
2739
+ "pause_end_at": {
2740
+ "type": "string",
2741
+ "format": "date-time",
2742
+ "nullable": true,
2743
+ "description": "Only applies to pause."
2744
+ }
2745
+ }
2746
+ }
2747
+ ]
2748
+ },
2489
2749
  "V2SubscriptionContractItemAdd": {
2490
2750
  "allOf": [
2491
2751
  {
2492
- "$ref": "#/components/schemas/V2SubscriptionContractProrationPreview"
2752
+ "$ref": "#/components/schemas/V2SubscriptionContractChangeCommon"
2493
2753
  },
2494
2754
  {
2495
2755
  "type": "object",
2756
+ "required": [
2757
+ "price"
2758
+ ],
2496
2759
  "properties": {
2497
2760
  "operation": {
2498
2761
  "type": "string",
@@ -2501,6 +2764,24 @@
2501
2764
  ],
2502
2765
  "default": "add_item"
2503
2766
  },
2767
+ "price": {
2768
+ "type": "integer"
2769
+ },
2770
+ "quantity": {
2771
+ "type": "integer",
2772
+ "nullable": true
2773
+ },
2774
+ "discount_percent": {
2775
+ "type": "string",
2776
+ "format": "decimal",
2777
+ "nullable": true
2778
+ },
2779
+ "unit_amount_override": {
2780
+ "type": "string",
2781
+ "format": "decimal",
2782
+ "nullable": true,
2783
+ "description": "Fixed per-unit amount for the new item, used instead of the catalog price."
2784
+ },
2504
2785
  "idempotency_key": {
2505
2786
  "type": "string",
2506
2787
  "nullable": true
@@ -2515,7 +2796,7 @@
2515
2796
  "V2SubscriptionContractItemRemove": {
2516
2797
  "allOf": [
2517
2798
  {
2518
- "$ref": "#/components/schemas/V2SubscriptionContractProrationPreview"
2799
+ "$ref": "#/components/schemas/V2SubscriptionContractChangeCommon"
2519
2800
  },
2520
2801
  {
2521
2802
  "type": "object",
@@ -2530,6 +2811,9 @@
2530
2811
  ],
2531
2812
  "default": "remove_item"
2532
2813
  },
2814
+ "contract_item": {
2815
+ "type": "integer"
2816
+ },
2533
2817
  "idempotency_key": {
2534
2818
  "type": "string",
2535
2819
  "nullable": true
@@ -2544,7 +2828,7 @@
2544
2828
  "V2SubscriptionContractItemUpdate": {
2545
2829
  "allOf": [
2546
2830
  {
2547
- "$ref": "#/components/schemas/V2SubscriptionContractProrationPreview"
2831
+ "$ref": "#/components/schemas/V2SubscriptionContractChangeCommon"
2548
2832
  },
2549
2833
  {
2550
2834
  "type": "object",
@@ -2552,6 +2836,35 @@
2552
2836
  "contract_item"
2553
2837
  ],
2554
2838
  "properties": {
2839
+ "operation": {
2840
+ "type": "string",
2841
+ "enum": [
2842
+ "update_item"
2843
+ ],
2844
+ "default": "update_item"
2845
+ },
2846
+ "contract_item": {
2847
+ "type": "integer"
2848
+ },
2849
+ "price": {
2850
+ "type": "integer",
2851
+ "nullable": true
2852
+ },
2853
+ "quantity": {
2854
+ "type": "integer",
2855
+ "nullable": true
2856
+ },
2857
+ "discount_percent": {
2858
+ "type": "string",
2859
+ "format": "decimal",
2860
+ "nullable": true
2861
+ },
2862
+ "unit_amount_override": {
2863
+ "type": "string",
2864
+ "format": "decimal",
2865
+ "nullable": true,
2866
+ "description": "Fixed per-unit amount for the item, used instead of the catalog price. Omitting the field keeps the item's current override; an explicit null clears it so the item reverts to the catalog price."
2867
+ },
2555
2868
  "idempotency_key": {
2556
2869
  "type": "string",
2557
2870
  "nullable": true
@@ -2688,9 +3001,15 @@
2688
3001
  "type": "string",
2689
3002
  "format": "decimal"
2690
3003
  },
3004
+ "discount_amount_due_now": {
3005
+ "type": "string",
3006
+ "format": "decimal",
3007
+ "description": "Contract coupon discount applied to the immediate (invoice_now) charge. Percent-off, non-once coupons only; 0.0000 otherwise."
3008
+ },
2691
3009
  "next_billing_estimate": {
2692
3010
  "type": "string",
2693
- "format": "decimal"
3011
+ "format": "decimal",
3012
+ "description": "Estimated next recurring charge, net of the contract's active coupon discount."
2694
3013
  },
2695
3014
  "resolved_price_version_ids": {
2696
3015
  "type": "object",
@@ -3537,6 +3856,64 @@
3537
3856
  "billing_schedule_preview": {
3538
3857
  "$ref": "#/components/schemas/V2Metadata"
3539
3858
  },
3859
+ "promotion_code": {
3860
+ "type": "string",
3861
+ "nullable": true,
3862
+ "description": "The promotion code applied to this quote, if any. When set, subtotal_amount, tax_amount, and total_amount already include the discount."
3863
+ },
3864
+ "discount": {
3865
+ "type": "object",
3866
+ "nullable": true,
3867
+ "description": "Discount preview when a promotion code was applied.",
3868
+ "properties": {
3869
+ "code": {
3870
+ "type": "string"
3871
+ },
3872
+ "coupon_display": {
3873
+ "type": "string"
3874
+ },
3875
+ "duration": {
3876
+ "type": "string",
3877
+ "enum": [
3878
+ "forever",
3879
+ "once",
3880
+ "repeating"
3881
+ ]
3882
+ },
3883
+ "duration_in_months": {
3884
+ "type": "integer",
3885
+ "nullable": true
3886
+ },
3887
+ "skip_trial_period": {
3888
+ "type": "boolean",
3889
+ "description": "When true, the coupon skipped the offer's trial period: the quoted totals bill the first period immediately."
3890
+ },
3891
+ "discount_subtotal_amount": {
3892
+ "type": "string",
3893
+ "format": "decimal"
3894
+ },
3895
+ "discount_tax_amount": {
3896
+ "type": "string",
3897
+ "format": "decimal"
3898
+ },
3899
+ "discount_total_amount": {
3900
+ "type": "string",
3901
+ "format": "decimal"
3902
+ },
3903
+ "original_subtotal_amount": {
3904
+ "type": "string",
3905
+ "format": "decimal"
3906
+ },
3907
+ "original_tax_amount": {
3908
+ "type": "string",
3909
+ "format": "decimal"
3910
+ },
3911
+ "original_total_amount": {
3912
+ "type": "string",
3913
+ "format": "decimal"
3914
+ }
3915
+ }
3916
+ },
3540
3917
  "recurring_items": {
3541
3918
  "type": "array",
3542
3919
  "items": {
@@ -4253,6 +4630,15 @@
4253
4630
  "customer_reference": {
4254
4631
  "type": "string"
4255
4632
  },
4633
+ "discount": {
4634
+ "allOf": [
4635
+ {
4636
+ "$ref": "#/components/schemas/V2ContractDiscount"
4637
+ }
4638
+ ],
4639
+ "nullable": true,
4640
+ "description": "The active coupon discount on this contract, if any."
4641
+ },
4256
4642
  "state": {
4257
4643
  "type": "string"
4258
4644
  },
@@ -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,22 +1,25 @@
1
1
  import * as z from 'zod';
2
2
 
3
+ const httpUrl = z
4
+ .url({ protocol: /^https?$/ })
5
+ .describe('Askell API base URL (default production host)');
6
+
3
7
  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)'),
8
+ apiBaseUrl: httpUrl.default('https://askell.is/api'),
8
9
  secretApiKey: z.string().min(1).describe('Secret (private) API key'),
9
10
  publicApiKey: z
10
11
  .string()
12
+ .min(1)
11
13
  .optional()
12
14
  .describe('Public API key for temporary payment method endpoints'),
13
- responseMaxBytes: z
15
+ responseMaxBytes: z.coerce
16
+ .number()
14
17
  .int()
15
18
  .positive()
16
19
  .default(64_000)
17
20
  .describe('Max response body size returned to the model'),
18
21
  requireMutationApproval: z
19
- .boolean()
22
+ .union([z.boolean(), z.stringbool()])
20
23
  .default(true)
21
24
  .describe('Require operator confirmation before mutating requests'),
22
25
  });
@@ -45,17 +48,6 @@ Set ASKELL_PRIVATE_API_KEY (or ASKELL_SECRET_API_KEY), optionally ASKELL_PUBLIC_
45
48
  }
46
49
  }`;
47
50
 
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
51
  function loadConfigFromEnv(): unknown {
60
52
  const env = Bun.env;
61
53
  const secretApiKey = env.ASKELL_PRIVATE_API_KEY ?? env.ASKELL_SECRET_API_KEY;
@@ -74,11 +66,9 @@ function loadConfigFromEnv(): unknown {
74
66
  ...(env.ASKELL_PUBLIC_API_KEY
75
67
  ? { publicApiKey: env.ASKELL_PUBLIC_API_KEY }
76
68
  : {}),
77
- ...(responseMaxBytes ? { responseMaxBytes: Number(responseMaxBytes) } : {}),
69
+ ...(responseMaxBytes ? { responseMaxBytes } : {}),
78
70
  ...(requireMutationApproval !== undefined
79
- ? {
80
- requireMutationApproval: parseEnvFlag(requireMutationApproval, true),
81
- }
71
+ ? { requireMutationApproval }
82
72
  : {}),
83
73
  };
84
74
  }
@@ -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
  }
package/src/server.ts CHANGED
@@ -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).
@@ -36,7 +41,7 @@ Auth:
36
41
 
37
42
  Safety:
38
43
  - 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.
44
+ - 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
45
 
41
46
  Resources:
42
47
  - askell://spec/v1 and askell://spec/v2 — bundled OpenAPI
@@ -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,
package/src/tools/call.ts CHANGED
@@ -25,10 +25,10 @@ const callInputSchema = z.object({
25
25
  .string()
26
26
  .describe('API path relative to apiBaseUrl, e.g. /v2/subscription-contracts/'),
27
27
  query: z
28
- .record(z.string(), z.unknown())
28
+ .record(z.string(), z.json())
29
29
  .optional()
30
30
  .describe('Query string parameters'),
31
- body: z.unknown().optional().describe('JSON request body'),
31
+ body: z.json().optional().describe('JSON request body'),
32
32
  apiKeyKind: z
33
33
  .enum(['secret', 'public'])
34
34
  .default('secret')
@@ -77,11 +77,11 @@ const openApiParameterSchema = z.object({
77
77
  in: z.enum(['query', 'path', 'header', 'cookie']).optional(),
78
78
  required: z.boolean().optional(),
79
79
  description: z.string().optional(),
80
- schema: z.unknown().optional(),
80
+ schema: z.json().optional(),
81
81
  $ref: z.string().optional(),
82
82
  });
83
83
 
84
- const operationDetailSchema = z.object({
84
+ export const operationDetailSchema = z.object({
85
85
  id: z.string(),
86
86
  apiVersion: apiVersionSchema,
87
87
  method: httpMethodSchema,
@@ -95,7 +95,7 @@ const operationDetailSchema = z.object({
95
95
  required: z.boolean().optional(),
96
96
  description: z.string().optional(),
97
97
  contentTypes: z.array(z.string()),
98
- schema: z.unknown().optional(),
98
+ schema: z.json().optional(),
99
99
  })
100
100
  .optional(),
101
101
  apiKeyKind: apiKeyKindSchema,
@@ -171,9 +171,13 @@ export function registerDiscoveryTools(server: McpServer): void {
171
171
  };
172
172
  }
173
173
 
174
+ // Strip OpenAPI extras (style/explode/…) so structuredContent matches
175
+ // the output JSON Schema (additionalProperties: false).
176
+ const payload = operationDetailSchema.parse(operation);
177
+
174
178
  return {
175
- content: [{ type: 'text', text: JSON.stringify(operation, null, 2) }],
176
- structuredContent: operation,
179
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
180
+ structuredContent: payload,
177
181
  };
178
182
  },
179
183
  );