askell-mcp 0.4.20 → 0.4.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askell-mcp",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "mcpName": "io.github.Neschadin/askell-mcp",
5
5
  "description": "MCP server for the Askell payment and subscription API (Bun + stdio)",
6
6
  "author": "Neschadin Oleksandr",
@@ -67,7 +67,7 @@
67
67
  "typescript": "7.0.2"
68
68
  },
69
69
  "dependencies": {
70
- "@modelcontextprotocol/server": "2.2.0",
70
+ "@modelcontextprotocol/server": "2.3.1",
71
71
  "zod": "4.6.5"
72
72
  }
73
73
  }
@@ -41,6 +41,10 @@
41
41
  "name": "Subscription",
42
42
  "description": "Subscription operations, requires secret api key."
43
43
  },
44
+ {
45
+ "name": "Subscription Discounts",
46
+ "description": "Promotion codes on subscriptions. Authenticated by the subscription's token instead of an API key, so they can be called from customer-facing applications."
47
+ },
44
48
  {
45
49
  "name": "Plan",
46
50
  "description": "Plan operations, requires a secret api key."
@@ -410,6 +414,7 @@
410
414
  "Customer"
411
415
  ],
412
416
  "summary": "Add subscription to a customer",
417
+ "description": "Adds a subscription for an existing customer, charging the first period with the customer's payment method unless the plan has a trial.\n\nPass `promotion_code` to redeem a promotion code on the new subscription before its first charge. An invalid code returns 400 before the delivery address or the subscription is created and before anything is charged.",
413
418
  "parameters": [
414
419
  {
415
420
  "name": "customerReference",
@@ -719,6 +724,7 @@
719
724
  "Subscription"
720
725
  ],
721
726
  "summary": "Create multiple subscriptions",
727
+ "description": "Creates or reuses a customer and creates one subscription per item in `subscriptions`, charging the first period of each item that has no trial.\n\nEach item can carry a `promotion_code`, and a code used on several items must have a redemption left for each. Every item's code is validated up front: an invalid code (or a checkout price mismatch, below) returns 400 before the payment method is stored and before any subscription is created or charged. The customer may already have been created or updated by then. When `payment_method.token` comes from a checkout created with a `promotion_code` (`POST /checkouts/`), that code is redeemed on the first item for the checkout's plan that has no `promotion_code` of its own. Unless the checkout was `capture_only`, that item's discounted first charge must still equal the amount the checkout quoted (and the card was authorised for); if the coupon's terms or the item's `amount`/`discount` make it differ, the request returns 400 before the payment method is stored or any subscription is created or charged. Pass `promotion_code` on the item explicitly to redeem the code at the current price without this check.",
722
728
  "requestBody": {
723
729
  "$ref": "#/components/requestBodies/Subscription"
724
730
  },
@@ -1023,13 +1029,367 @@
1023
1029
  ]
1024
1030
  }
1025
1031
  },
1032
+ "/subscriptions/{subscriptionId}/apply-code/": {
1033
+ "post": {
1034
+ "tags": [
1035
+ "Subscription Discounts"
1036
+ ],
1037
+ "summary": "Apply a promotion code to a subscription",
1038
+ "description": "Redeems a promotion code on an active subscription, creating a discount that is applied to its subsequent charges. A subscription can only carry one discount at a time. That includes a pending one: a code redeemed when the subscription was created with a future `start_date` has a discount whose window opens on that date. Until then it still blocks a new code with 400, although `GET /subscriptions/{subscriptionId}/discount/` reports it as `has_discount: false`.\n\nThis endpoint does not use an API key. It is authenticated by the subscription's `token` (the `token` field of the Subscription object), sent as `subscription_token` in the request body, so it can be called from a customer-facing application. Treat the subscription token as a secret.\n\nThe code is matched case-insensitively. It is validated against the subscription's customer, currency and billing amount (minimum amount and customer restrictions apply).\n\nTo discount the first charge of a new subscription, pass `promotion_code` when creating it instead (`POST /customers/{customerReference}/subscriptions/add/` or `POST /subscriptions/multi/`).",
1039
+ "parameters": [
1040
+ {
1041
+ "name": "subscriptionId",
1042
+ "in": "path",
1043
+ "description": "Subscription id",
1044
+ "required": true,
1045
+ "style": "simple",
1046
+ "explode": false,
1047
+ "schema": {
1048
+ "type": "string"
1049
+ }
1050
+ }
1051
+ ],
1052
+ "requestBody": {
1053
+ "content": {
1054
+ "application/json": {
1055
+ "schema": {
1056
+ "type": "object",
1057
+ "required": [
1058
+ "code",
1059
+ "subscription_token"
1060
+ ],
1061
+ "properties": {
1062
+ "code": {
1063
+ "type": "string",
1064
+ "description": "The promotion code to redeem.",
1065
+ "example": "SUMMER20"
1066
+ },
1067
+ "subscription_token": {
1068
+ "type": "string",
1069
+ "description": "The subscription's `token`.",
1070
+ "example": "3f5a8c1e-2b7d-4a9e-8c6f-1d2e3f4a5b6c"
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ },
1076
+ "required": true
1077
+ },
1078
+ "responses": {
1079
+ "200": {
1080
+ "description": "Promotion code applied",
1081
+ "content": {
1082
+ "application/json": {
1083
+ "schema": {
1084
+ "type": "object",
1085
+ "properties": {
1086
+ "success": {
1087
+ "type": "boolean",
1088
+ "example": true
1089
+ },
1090
+ "message": {
1091
+ "type": "string",
1092
+ "example": "Promotion code applied successfully"
1093
+ },
1094
+ "discount": {
1095
+ "type": "object",
1096
+ "properties": {
1097
+ "id": {
1098
+ "type": "integer",
1099
+ "example": 123
1100
+ },
1101
+ "coupon_name": {
1102
+ "type": "string",
1103
+ "nullable": true,
1104
+ "example": "Summer Sale"
1105
+ },
1106
+ "discount_display": {
1107
+ "type": "string",
1108
+ "nullable": true,
1109
+ "description": "The coupon's discount, for example `20%` or `500 ISK`.",
1110
+ "example": "20%"
1111
+ },
1112
+ "start": {
1113
+ "type": "string",
1114
+ "format": "date-time",
1115
+ "nullable": true
1116
+ },
1117
+ "end": {
1118
+ "type": "string",
1119
+ "format": "date-time",
1120
+ "nullable": true
1121
+ }
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ }
1128
+ },
1129
+ "400": {
1130
+ "description": "The code or token is missing, the subscription is not active, it already has an active or pending discount, or the code is invalid, expired, used up or not valid for this subscription",
1131
+ "content": {
1132
+ "application/json": {
1133
+ "schema": {
1134
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1135
+ }
1136
+ }
1137
+ }
1138
+ },
1139
+ "404": {
1140
+ "description": "No subscription with this id and token",
1141
+ "content": {
1142
+ "application/json": {
1143
+ "schema": {
1144
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1145
+ }
1146
+ }
1147
+ }
1148
+ },
1149
+ "500": {
1150
+ "description": "Unexpected error while applying the code",
1151
+ "content": {
1152
+ "application/json": {
1153
+ "schema": {
1154
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ },
1160
+ "security": []
1161
+ }
1162
+ },
1163
+ "/subscriptions/{subscriptionId}/discount/": {
1164
+ "get": {
1165
+ "tags": [
1166
+ "Subscription Discounts"
1167
+ ],
1168
+ "summary": "Get the active discount for a subscription",
1169
+ "description": "Returns the subscription's active coupon discount, or `has_discount: false` when none is active.\n\nThis endpoint does not use an API key. It is authenticated by the subscription's `token` (the `token` field of the Subscription object), sent as the `subscription_token` query parameter.",
1170
+ "parameters": [
1171
+ {
1172
+ "name": "subscriptionId",
1173
+ "in": "path",
1174
+ "description": "Subscription id",
1175
+ "required": true,
1176
+ "style": "simple",
1177
+ "explode": false,
1178
+ "schema": {
1179
+ "type": "string"
1180
+ }
1181
+ },
1182
+ {
1183
+ "name": "subscription_token",
1184
+ "in": "query",
1185
+ "description": "The subscription's `token`.",
1186
+ "required": true,
1187
+ "schema": {
1188
+ "type": "string"
1189
+ }
1190
+ }
1191
+ ],
1192
+ "responses": {
1193
+ "200": {
1194
+ "description": "Active discount",
1195
+ "content": {
1196
+ "application/json": {
1197
+ "schema": {
1198
+ "type": "object",
1199
+ "properties": {
1200
+ "has_discount": {
1201
+ "type": "boolean",
1202
+ "example": true
1203
+ },
1204
+ "discount": {
1205
+ "type": "object",
1206
+ "nullable": true,
1207
+ "properties": {
1208
+ "id": {
1209
+ "type": "integer",
1210
+ "example": 123
1211
+ },
1212
+ "coupon_name": {
1213
+ "type": "string",
1214
+ "example": "Summer Sale"
1215
+ },
1216
+ "discount_display": {
1217
+ "type": "string",
1218
+ "example": "20%"
1219
+ },
1220
+ "promotion_code": {
1221
+ "type": "string",
1222
+ "nullable": true,
1223
+ "description": "The code the discount was redeemed with, or null for a coupon applied without a code.",
1224
+ "example": "SUMMER20"
1225
+ },
1226
+ "start": {
1227
+ "type": "string",
1228
+ "format": "date-time",
1229
+ "nullable": true
1230
+ },
1231
+ "end": {
1232
+ "type": "string",
1233
+ "format": "date-time",
1234
+ "nullable": true
1235
+ },
1236
+ "duration": {
1237
+ "type": "string",
1238
+ "enum": [
1239
+ "once",
1240
+ "repeating",
1241
+ "forever"
1242
+ ],
1243
+ "example": "forever"
1244
+ },
1245
+ "duration_in_months": {
1246
+ "type": "integer",
1247
+ "nullable": true,
1248
+ "description": "Number of months a `repeating` discount lasts."
1249
+ }
1250
+ }
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+ }
1256
+ },
1257
+ "400": {
1258
+ "description": "The subscription token is missing",
1259
+ "content": {
1260
+ "application/json": {
1261
+ "schema": {
1262
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1263
+ }
1264
+ }
1265
+ }
1266
+ },
1267
+ "404": {
1268
+ "description": "No subscription with this id and token",
1269
+ "content": {
1270
+ "application/json": {
1271
+ "schema": {
1272
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1273
+ }
1274
+ }
1275
+ }
1276
+ },
1277
+ "500": {
1278
+ "description": "Unexpected error while reading the discount",
1279
+ "content": {
1280
+ "application/json": {
1281
+ "schema": {
1282
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+ },
1288
+ "security": []
1289
+ }
1290
+ },
1291
+ "/subscriptions/{subscriptionId}/remove-discount/": {
1292
+ "post": {
1293
+ "tags": [
1294
+ "Subscription Discounts"
1295
+ ],
1296
+ "summary": "Remove the active or pending discount from a subscription",
1297
+ "description": "Removes the subscription's active coupon discount, or a pending one that has not started yet (a code redeemed at creation with a future `start_date`; `GET /subscriptions/{subscriptionId}/discount/` reports a pending discount as `has_discount: false`). Subsequent charges are made at the full subscription amount.\n\nThis endpoint does not use an API key. It is authenticated by the subscription's `token` (the `token` field of the Subscription object), sent as `subscription_token` in the request body.",
1298
+ "parameters": [
1299
+ {
1300
+ "name": "subscriptionId",
1301
+ "in": "path",
1302
+ "description": "Subscription id",
1303
+ "required": true,
1304
+ "style": "simple",
1305
+ "explode": false,
1306
+ "schema": {
1307
+ "type": "string"
1308
+ }
1309
+ }
1310
+ ],
1311
+ "requestBody": {
1312
+ "content": {
1313
+ "application/json": {
1314
+ "schema": {
1315
+ "type": "object",
1316
+ "required": [
1317
+ "subscription_token"
1318
+ ],
1319
+ "properties": {
1320
+ "subscription_token": {
1321
+ "type": "string",
1322
+ "description": "The subscription's `token`.",
1323
+ "example": "3f5a8c1e-2b7d-4a9e-8c6f-1d2e3f4a5b6c"
1324
+ }
1325
+ }
1326
+ }
1327
+ }
1328
+ },
1329
+ "required": true
1330
+ },
1331
+ "responses": {
1332
+ "200": {
1333
+ "description": "Discount removed",
1334
+ "content": {
1335
+ "application/json": {
1336
+ "schema": {
1337
+ "type": "object",
1338
+ "properties": {
1339
+ "success": {
1340
+ "type": "boolean",
1341
+ "example": true
1342
+ },
1343
+ "message": {
1344
+ "type": "string",
1345
+ "example": "Discount removed successfully"
1346
+ }
1347
+ }
1348
+ }
1349
+ }
1350
+ }
1351
+ },
1352
+ "400": {
1353
+ "description": "The subscription token is missing, or the subscription has no active or pending discount",
1354
+ "content": {
1355
+ "application/json": {
1356
+ "schema": {
1357
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1358
+ }
1359
+ }
1360
+ }
1361
+ },
1362
+ "404": {
1363
+ "description": "No subscription with this id and token",
1364
+ "content": {
1365
+ "application/json": {
1366
+ "schema": {
1367
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1368
+ }
1369
+ }
1370
+ }
1371
+ },
1372
+ "500": {
1373
+ "description": "Unexpected error while removing the discount",
1374
+ "content": {
1375
+ "application/json": {
1376
+ "schema": {
1377
+ "$ref": "#/components/schemas/SubscriptionDiscountError"
1378
+ }
1379
+ }
1380
+ }
1381
+ }
1382
+ },
1383
+ "security": []
1384
+ }
1385
+ },
1026
1386
  "/checkouts/": {
1027
1387
  "post": {
1028
1388
  "tags": [
1029
1389
  "Checkout"
1030
1390
  ],
1031
1391
  "summary": "Create a hosted checkout card form",
1032
- "description": "Creates a hosted checkout and returns a `checkout_url` that should be rendered in an iframe. The hosted page collects card details, handles 3D Secure when required, and posts completion messages to the embedding page. The resulting `token` is a reference to a payment method that can be added to a customer after checkout status becomes `tokencreated`.\n\nCreate a checkout either for a `plan` or directly for an account `payment_processor`. When using `payment_processor`, `currency` is required. A `plan` checkout completes into a legacy subscription, so on an account that uses subscription contracts only it is refused with 400 and `code: legacy_subscriptions_disabled`; a checkout for a `payment_processor` still works there. To capture only the card details, set `capture_only` to `true`; this value is optional and defaults to `false`.\n\nIf supplied, `allowed_origin` must be an origin only, for example `https://merchant.example`, with no path, query string, or credentials. It controls the single parent page origin allowed to frame this checkout and receive checkout postMessage notifications. If omitted, all checkout origins configured for the account are allowed to frame the checkout.",
1392
+ "description": "Creates a hosted checkout and returns a `checkout_url` that should be rendered in an iframe. The hosted page collects card details, handles 3D Secure when required, and posts completion messages to the embedding page. The resulting `token` is a reference to a payment method that can be added to a customer after checkout status becomes `tokencreated`.\n\nCreate a checkout either for a `plan` or directly for an account `payment_processor`. When using `payment_processor`, `currency` is required. A `plan` checkout completes into a legacy subscription, so on an account that uses subscription contracts only it is refused with 400 and `code: legacy_subscriptions_disabled`; a checkout for a `payment_processor` still works there. To capture only the card details, set `capture_only` to `true`; this value is optional and defaults to `false`.\n\nIf supplied, `allowed_origin` must be an origin only, for example `https://merchant.example`, with no path, query string, or credentials. It controls the single parent page origin allowed to frame this checkout and receive checkout postMessage notifications. If omitted, all checkout origins configured for the account are allowed to frame the checkout.\n\nWith a `plan`, a `promotion_code` can be supplied. The card form then shows the discounted first-period amount, and the code is redeemed when the checkout's `token` is used in `POST /subscriptions/multi/`. See `promotion_code` in the request body.",
1033
1393
  "requestBody": {
1034
1394
  "$ref": "#/components/requestBodies/CheckoutCreate"
1035
1395
  },
@@ -1876,6 +2236,11 @@
1876
2236
  "format": "uri",
1877
2237
  "description": "Optional iframe parent origin for this checkout, for example `https://merchant.example`. Must be an origin only, with no path, query string, or credentials. Overrides the account configured checkout origins for this checkout.",
1878
2238
  "example": "https://merchant.example"
2239
+ },
2240
+ "promotion_code": {
2241
+ "type": "string",
2242
+ "example": "SUMMER20",
2243
+ "description": "Optional promotion code for the subscription this checkout's card will pay for (requires a `plan`). It is validated against the plan amount and currency; a code restricted to a specific customer cannot be used here, as the checkout has no customer yet. The card form and 3D Secure use the discounted first-period amount (unless `capture_only` is set). The checkout itself creates no subscription and redeems nothing: when the checkout's `token` is used as `payment_method.token` in `POST /subscriptions/multi/`, the code is redeemed on the first item for this checkout's plan that has no `promotion_code` of its own (an item's own code always wins). Other endpoints do not carry it over: pass `promotion_code` explicitly there."
1879
2244
  }
1880
2245
  },
1881
2246
  "oneOf": [
@@ -2588,6 +2953,11 @@
2588
2953
  "example": "My custom note",
2589
2954
  "required": "false",
2590
2955
  "description": "An optional description for internal purposes."
2956
+ },
2957
+ "promotion_code": {
2958
+ "type": "string",
2959
+ "example": "SUMMER20",
2960
+ "description": "Optional promotion code to redeem on the new subscription. It is validated against the customer and the amount it applies to: the plan amount, or `amount` when given, less the `discount` percentage. It is redeemed before the first charge, so that charge is already discounted, and later renewals follow the coupon's duration. When a future `start_date` defers the first charge of a plan without a trial, the discount starts on that date, so a `repeating` coupon's months count from the first charge. A coupon that skips the trial period charges the discounted first period immediately instead of starting the plan's trial, unless `start_date` is in the future (the first billing then lands on it, as with a trial). A `once` coupon that brings the immediate first charge to zero is used up by it. An invalid, expired, used-up or customer-restricted code returns 400 before any payment method is stored and before any subscription is created or charged (with `POST /subscriptions/multi/`, the customer may already have been created or updated). When the same code is used on several items of `POST /subscriptions/multi/`, it must have a redemption left for each of them. Blank or absent means no code."
2591
2961
  }
2592
2962
  }
2593
2963
  },
@@ -2748,6 +3118,46 @@
2748
3118
  "checkout_url": {
2749
3119
  "type": "string",
2750
3120
  "example": "https://askell.is/checkout/637302f0412e2e11adc0ba21adafcf11/"
3121
+ },
3122
+ "promotion_code": {
3123
+ "type": "object",
3124
+ "description": "Present only when the checkout was created with a `promotion_code`.",
3125
+ "properties": {
3126
+ "code": {
3127
+ "type": "string",
3128
+ "description": "The normalised (upper-case) code stored on the checkout.",
3129
+ "example": "SUMMER20"
3130
+ },
3131
+ "discount_display": {
3132
+ "type": "string",
3133
+ "description": "The coupon's discount, for example `20%` or `500 ISK`.",
3134
+ "example": "20%"
3135
+ },
3136
+ "original_amount": {
3137
+ "type": "string",
3138
+ "description": "The plan's first-period amount before the discount.",
3139
+ "example": "1000.00"
3140
+ },
3141
+ "discounted_amount": {
3142
+ "type": "string",
3143
+ "description": "The first-period amount after the discount, shown in the card form.",
3144
+ "example": "800.00"
3145
+ }
3146
+ }
3147
+ }
3148
+ }
3149
+ },
3150
+ "SubscriptionDiscountError": {
3151
+ "type": "object",
3152
+ "properties": {
3153
+ "success": {
3154
+ "type": "boolean",
3155
+ "description": "Present on the `apply-code` and `remove-discount` responses.",
3156
+ "example": false
3157
+ },
3158
+ "error": {
3159
+ "type": "string",
3160
+ "example": "Invalid subscription or token"
2751
3161
  }
2752
3162
  }
2753
3163
  },
@@ -4568,6 +4568,45 @@
4568
4568
  }
4569
4569
  }
4570
4570
  },
4571
+ "V2CustomerPaymentMethodCard": {
4572
+ "type": "object",
4573
+ "description": "The card behind a card payment method. Each field is `null` when the payment processor does not tell Áskell.",
4574
+ "properties": {
4575
+ "brand": {
4576
+ "type": "string",
4577
+ "nullable": true,
4578
+ "enum": [
4579
+ "visa",
4580
+ "mastercard",
4581
+ "amex",
4582
+ "maestro",
4583
+ "diners",
4584
+ "discover",
4585
+ "jcb",
4586
+ "dankort",
4587
+ null
4588
+ ],
4589
+ "description": "Card brand. Visa Electron is reported as `visa`."
4590
+ },
4591
+ "last4": {
4592
+ "type": "string",
4593
+ "nullable": true,
4594
+ "pattern": "^[0-9]{4}$",
4595
+ "description": "Last four digits of the card number."
4596
+ },
4597
+ "funding": {
4598
+ "type": "string",
4599
+ "nullable": true,
4600
+ "enum": [
4601
+ "credit",
4602
+ "debit",
4603
+ "prepaid",
4604
+ null
4605
+ ],
4606
+ "description": "Whether the card is a credit, debit or prepaid card. Only known for processors that report it (Straumur, and Valitor Pay after the card's first successful charge); always `null` for Teya."
4607
+ }
4608
+ }
4609
+ },
4571
4610
  "V2CustomerPaymentMethod": {
4572
4611
  "type": "object",
4573
4612
  "properties": {
@@ -4596,6 +4635,15 @@
4596
4635
  "display_info": {
4597
4636
  "type": "string"
4598
4637
  },
4638
+ "card": {
4639
+ "allOf": [
4640
+ {
4641
+ "$ref": "#/components/schemas/V2CustomerPaymentMethodCard"
4642
+ }
4643
+ ],
4644
+ "nullable": true,
4645
+ "description": "Brand, last four digits and funding of the card. `null` for claim and invoice payment methods."
4646
+ },
4599
4647
  "verified": {
4600
4648
  "type": "boolean"
4601
4649
  },
@@ -27,8 +27,14 @@ export class AskellClient {
27
27
  this.baseUrl = normalizeBaseUrl(config.apiBaseUrl);
28
28
  }
29
29
 
30
- private apiKeyFor(kind: ApiKeyKind | undefined): string {
30
+ private authorizationHeader(
31
+ kind: ApiKeyKind | undefined,
32
+ ): Record<string, string> {
31
33
  const apiKeyKind = kind ?? 'secret';
34
+ if (apiKeyKind === 'none') {
35
+ return {};
36
+ }
37
+
32
38
  const apiKey =
33
39
  apiKeyKind === 'public'
34
40
  ? this.config.publicApiKey
@@ -42,7 +48,7 @@ export class AskellClient {
42
48
  );
43
49
  }
44
50
 
45
- return apiKey;
51
+ return { Authorization: `Api-Key ${apiKey}` };
46
52
  }
47
53
 
48
54
  async request(
@@ -69,14 +75,14 @@ export class AskellClient {
69
75
  }
70
76
  }
71
77
 
72
- const apiKey = this.apiKeyFor(input.apiKeyKind);
78
+ const authorization = this.authorizationHeader(input.apiKeyKind);
73
79
 
74
80
  const started = performance.now();
75
81
  const response = await fetch(url, {
76
82
  method,
77
83
  headers: {
78
- Authorization: `Api-Key ${apiKey}`,
79
84
  Accept: 'application/json',
85
+ ...authorization,
80
86
  ...(input.body !== undefined
81
87
  ? { 'Content-Type': 'application/json' }
82
88
  : {}),
@@ -115,7 +121,7 @@ export class AskellClient {
115
121
  signal?: AbortSignal;
116
122
  }): Promise<FormattedResponse & { ok: boolean; status: number }> {
117
123
  const maxPages = input.maxPages ?? 20;
118
- const apiKey = this.apiKeyFor(input.apiKeyKind);
124
+ const authorization = this.authorizationHeader(input.apiKeyKind);
119
125
 
120
126
  const collected: unknown[] = [];
121
127
  let nextUrl: URL | null = null;
@@ -146,8 +152,8 @@ export class AskellClient {
146
152
  const response = await fetch(url, {
147
153
  method: 'GET',
148
154
  headers: {
149
- Authorization: `Api-Key ${apiKey}`,
150
155
  Accept: 'application/json',
156
+ ...authorization,
151
157
  },
152
158
  signal: input.signal,
153
159
  });
@@ -1,3 +1,23 @@
1
+ /** OpenAPI `{param}` template vs a concrete request path. Both should be normalized. */
2
+ export function openApiPathMatches(template: string, concrete: string): boolean {
3
+ if (template === concrete) {
4
+ return true;
5
+ }
6
+
7
+ const templateParts = template.split('/');
8
+ const concreteParts = concrete.split('/');
9
+ if (templateParts.length !== concreteParts.length) {
10
+ return false;
11
+ }
12
+
13
+ return templateParts.every((part, index) => {
14
+ if (part.startsWith('{') && part.endsWith('}') && part.length > 2) {
15
+ return concreteParts[index] !== '';
16
+ }
17
+ return part === concreteParts[index];
18
+ });
19
+ }
20
+
1
21
  /** Normalize Askell API paths: leading slash + trailing slash (OpenAPI convention). */
2
22
  export function normalizeApiPath(path: string): string {
3
23
  let normalized = path.startsWith('/') ? path : `/${path}`;
@@ -60,6 +60,83 @@ function resolveSchema(doc: OpenApiDocument, schema: unknown): unknown {
60
60
  return schema;
61
61
  }
62
62
 
63
+ /**
64
+ * Follow nested `$ref`s in a schema. `seen` is the ancestor chain only, so the
65
+ * same component used twice as a sibling is inlined twice; a cycle stops.
66
+ */
67
+ function resolveSchemaDeep(
68
+ doc: OpenApiDocument,
69
+ schema: unknown,
70
+ seen = new Set<string>(),
71
+ ): unknown {
72
+ if (schema == null || typeof schema !== 'object') {
73
+ return schema;
74
+ }
75
+
76
+ if (Array.isArray(schema)) {
77
+ return schema.map((item) => resolveSchemaDeep(doc, item, seen));
78
+ }
79
+
80
+ const record = schema as Record<string, unknown>;
81
+ if (typeof record.$ref === 'string') {
82
+ if (record.circular === true || seen.has(record.$ref)) {
83
+ return { $ref: record.$ref, circular: true };
84
+ }
85
+
86
+ const next = new Set(seen);
87
+ next.add(record.$ref);
88
+ return resolveSchemaDeep(doc, resolveRef(doc, record.$ref), next);
89
+ }
90
+
91
+ const out: Record<string, unknown> = {};
92
+ for (const [key, value] of Object.entries(record)) {
93
+ out[key] =
94
+ value !== null && typeof value === 'object'
95
+ ? resolveSchemaDeep(doc, value, seen)
96
+ : value;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function resolveRequestBody(
102
+ doc: OpenApiDocument,
103
+ requestBody: {
104
+ $ref?: string;
105
+ required?: boolean;
106
+ description?: string;
107
+ content?: Record<string, { schema?: unknown }>;
108
+ },
109
+ ): ApiOperation['requestBody'] {
110
+ let source = requestBody;
111
+
112
+ if (typeof requestBody.$ref === 'string') {
113
+ const resolved = resolveRef(doc, requestBody.$ref);
114
+ if (
115
+ resolved == null ||
116
+ typeof resolved !== 'object' ||
117
+ Array.isArray(resolved)
118
+ ) {
119
+ return { contentTypes: [] };
120
+ }
121
+ source = resolved as typeof requestBody;
122
+ }
123
+
124
+ const content = source.content ?? {};
125
+ const contentTypes = Object.keys(content);
126
+ const firstContent = contentTypes[0] ? content[contentTypes[0]] : undefined;
127
+
128
+ return {
129
+ ...(source.required !== undefined ? { required: source.required } : {}),
130
+ ...(source.description !== undefined
131
+ ? { description: source.description }
132
+ : {}),
133
+ contentTypes,
134
+ ...(firstContent?.schema !== undefined
135
+ ? { schema: resolveSchemaDeep(doc, firstContent.schema) }
136
+ : {}),
137
+ };
138
+ }
139
+
63
140
  function resolveParameters(
64
141
  doc: OpenApiDocument,
65
142
  parameters: OpenApiParameter[] | undefined,
@@ -100,10 +177,16 @@ function resolveParameters(
100
177
  function inferApiKeyKind(
101
178
  security: Array<Record<string, unknown[]>> | undefined,
102
179
  ): ApiKeyKind {
103
- if (!security?.length) {
180
+ // Omitted `security` inherits the document default (secret key).
181
+ // `security: []` opts out of every scheme — no API key.
182
+ if (security === undefined) {
104
183
  return 'secret';
105
184
  }
106
185
 
186
+ if (security.length === 0) {
187
+ return 'none';
188
+ }
189
+
107
190
  for (const requirement of security) {
108
191
  if ('Public-Api-Key' in requirement) {
109
192
  return 'public';
@@ -139,11 +222,6 @@ function parseDocument(
139
222
  }
140
223
 
141
224
  const method = methodKey.toUpperCase() as HttpMethod;
142
- const content = operation.requestBody?.content ?? {};
143
- const contentTypes = Object.keys(content);
144
- const firstContent = contentTypes[0]
145
- ? content[contentTypes[0]]
146
- : undefined;
147
225
 
148
226
  operations.push({
149
227
  id: buildOperationId(apiVersion, method, path),
@@ -155,14 +233,7 @@ function parseDocument(
155
233
  description: operation.description,
156
234
  parameters: resolveParameters(doc, operation.parameters),
157
235
  requestBody: operation.requestBody
158
- ? {
159
- required: operation.requestBody.required,
160
- description: operation.requestBody.description,
161
- contentTypes,
162
- schema: firstContent
163
- ? resolveSchema(doc, firstContent.schema)
164
- : undefined,
165
- }
236
+ ? resolveRequestBody(doc, operation.requestBody)
166
237
  : undefined,
167
238
  apiKeyKind: inferApiKeyKind(operation.security),
168
239
  deprecated: operation.deprecated,
@@ -2,7 +2,7 @@ export type ApiVersion = 'v1' | 'v2';
2
2
 
3
3
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
4
4
 
5
- export type ApiKeyKind = 'secret' | 'public';
5
+ export type ApiKeyKind = 'secret' | 'public' | 'none';
6
6
 
7
7
  export interface OpenApiParameter {
8
8
  name?: string;
@@ -51,6 +51,7 @@ export interface OpenApiDocument {
51
51
  deprecated?: boolean;
52
52
  parameters?: OpenApiParameter[];
53
53
  requestBody?: {
54
+ $ref?: string;
54
55
  required?: boolean;
55
56
  description?: string;
56
57
  content?: Record<string, { schema?: unknown }>;
package/src/server.ts CHANGED
@@ -13,6 +13,14 @@ import { registerCallTools } from './tools/call.ts';
13
13
  import { registerDiscoveryTools } from './tools/discovery.ts';
14
14
  import { PACKAGE_VERSION } from './version.ts';
15
15
 
16
+ /**
17
+ * Combined array elements and object members allowed in one `tools/call`
18
+ * `arguments` payload. Off in the SDK by default; set here so a wide or deep
19
+ * JSON body cannot be walked before schema validation. Legitimate Askell
20
+ * writes (checkout, contract, webhook) sit far under this.
21
+ */
22
+ export const MAX_TOOL_INPUT_ELEMENTS = 10_000;
23
+
16
24
  export function buildServerInstructions(config: AppConfig): string {
17
25
  const apiBase = normalizeBaseUrl(config.apiBaseUrl);
18
26
  const envLine =
@@ -47,11 +55,18 @@ API layout:
47
55
  - V2 list endpoints paginate only when page_size is provided (default 10, max 1000).
48
56
  - GET /v2/customer-entitlements/ requires customer_reference query param.
49
57
 
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:
58
+ V2 discounts — not the v1 percent field Subscription.discount (0-100 on a PlanVariant; never send that to v2). v1 promotion codes are a separate system, below. Coupon = discount definition; promotion code = customer-facing code:
51
59
  - 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
60
  - 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/.
53
61
  - 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.
54
62
 
63
+ V1 promotion codes — not Subscription.discount (0-100). Live subscription docs do not describe these yet; bundled OpenAPI is right:
64
+ - First charge: promotion_code on POST /customers/{customerReference}/subscriptions/add/, or on each item of POST /subscriptions/multi/. apply-code does not discount the first charge.
65
+ - POST /checkouts/ promotion_code requires a plan. The checkout redeems nothing. The code is applied only when that checkout's token is payment_method.token on POST /subscriptions/multi/, on the first item for that plan that has no promotion_code of its own (an item's own code wins). POST .../subscriptions/add/ does not carry the checkout code over. Unless capture_only, that item's discounted first charge must equal the amount the checkout quoted, or the request is 400 before the payment method is stored. A customer-restricted code cannot be used at checkout (no customer yet).
66
+ - One discount per subscription, including a pending one from a future start_date. GET /subscriptions/{subscriptionId}/discount/ reports a pending discount as has_discount: false, but apply-code still returns 400 until remove-discount.
67
+ - POST /subscriptions/{subscriptionId}/apply-code/ body {code, subscription_token}. POST .../remove-discount/ body {subscription_token}. GET .../discount/?subscription_token=. subscription_token is Subscription.token. Treat it as a secret. These three paths are apiKeyKind none: do not send Authorization.
68
+ - An invalid code on add, multi, or checkout is 400 before the subscription is created or charged. On multi the customer may already have been created or updated. The same code on several multi items needs a redemption left for each. A coupon that skips the trial charges the discounted first period immediately, unless start_date is in the future. legacy_subscriptions_disabled still refuses add, multi, and a plan checkout.
69
+
55
70
  V2 checkout notes:
56
71
  - checkout_url on V2 checkouts points to the API object URL, not a hosted payment page.
57
72
  - GET contract.subscriber_page is the customer-facing subscription management URL (readOnly, nullable). Not checkout_url, not v1 /public/payments/{id}/ (hosted signup). Do not send it on create/patch.
@@ -71,6 +86,9 @@ V2 contract changes:
71
86
  - apply_on_payment (default false; items/update and proration-preview; apply_at=now only): false switches the item now and collects afterwards. true keeps the current values until the proration run is collected, without moving the billing schedule. Same-interval (an upgrade): pending_interval_change is null; the wait is only pending_change (interval_change false, status awaiting_payment). Renewal is held. Other item edits and change-anchor return 409 pending_change — change-anchor's operation text only names pending_interval_change. Needs invoice_now (400 apply_on_payment_requires_invoice_now). Nothing to collect (no proration, a downgrade, or a charge fully covered) applies at once. A terminal failure leaves the item unchanged; a later manual retry that succeeds still applies the change unless the item was changed or its renewal billed in the meantime, in which case the payment is credited to the contract balance. Send the same apply_on_payment on proration-preview: a preview token only validates an update with the same value.
72
87
  - POST .../change-anchor/ moves the next renewal of the contract and every active item. new_billing_anchor_at must be after effective_at and, with proration, at most one billing period later. Does not extend entitlements. Preview with proration-preview operation=change_anchor (new_billing_anchor_at required). Do not PATCH billing_anchor_at.
73
88
 
89
+ V2 payment methods:
90
+ - V2CustomerPaymentMethod.card is brand, last4, funding. null for claim and invoice. funding is null for Teya, and for Valitor Pay until the card's first successful charge. Visa Electron is visa.
91
+
74
92
  V2 refunds:
75
93
  - A billing-run charge is not a Payment. payment.* for that charge is a flat object (Hook-API-Version v2, subscription_contract_id, billing_run_id, billing_run_attempt_id), not transactions[]; a payment.retry may have a null uuid and state retry_scheduled. Refund with POST /v2/billing-runs/{id}/refund/ (no body, full amount only, secret). 200 → state refunded, metadata.transaction_refund, webhook billing_run.changed (no separate refund event). 202 → run still succeeded (metadata.refund_requests); wait or re-GET; do not resend immediately. 400 if not succeeded, zero amount, already refunded, or the processor cannot refund. No response (timeout): GET the run and check state plus metadata.refund_requests before retrying. POST /payments/{uuid}/refund/ is one-off Payments only.
76
94
 
@@ -81,11 +99,13 @@ V2 fulfillment (warehouse):
81
99
  Auth:
82
100
  - Most endpoints need the secret API key.
83
101
  - Only temporary payment method and checkout status endpoints use the public key.
102
+ - v1 subscription discount paths (apply-code, discount, remove-discount) are apiKeyKind none: no Authorization header. Pass subscription_token (Subscription.token) and treat that token as a secret. Omit apiKeyKind on askell_call/askell_mutate to follow the operation.
84
103
 
85
104
  Safety:
86
105
  - Writes go through askell_mutate (destructiveHint). Reads go through askell_call (readOnlyHint).
87
106
  - 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.
88
107
  - 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.
108
+ - Tool arguments are rejected when they contain more than ${MAX_TOOL_INPUT_ELEMENTS} combined array elements and object members. The call returns isError and names the limit; shrink the JSON body or query.
89
109
  - Tool output redacts webhook hmac_secret to \`<redacted len=N>\` (Askell list/get/create return the plaintext secret).
90
110
 
91
111
  Resources:
@@ -101,6 +121,7 @@ export function createServer(config: AppConfig): McpServer {
101
121
  },
102
122
  {
103
123
  instructions: buildServerInstructions(config),
124
+ maxToolInputElements: MAX_TOOL_INPUT_ELEMENTS,
104
125
  },
105
126
  );
106
127
 
@@ -116,9 +116,11 @@ export function registerAnalysisTools(
116
116
  .optional()
117
117
  .describe('Query string parameters forwarded to the list endpoint'),
118
118
  apiKeyKind: z
119
- .enum(['secret', 'public'])
119
+ .enum(['secret', 'public', 'none'])
120
120
  .default('secret')
121
- .describe('Which configured API key to use'),
121
+ .describe(
122
+ 'Which configured API key to use. none sends no Authorization header',
123
+ ),
122
124
  maxPages: z
123
125
  .int()
124
126
  .positive()
package/src/tools/call.ts CHANGED
@@ -9,9 +9,10 @@ import {
9
9
  import * as z from 'zod';
10
10
 
11
11
  import { AskellClient, type AskellRequest } from '../client/askell-client.ts';
12
- import { normalizeApiPath } from '../client/paths.ts';
12
+ import { normalizeApiPath, openApiPathMatches } from '../client/paths.ts';
13
13
  import type { AppConfig } from '../config.ts';
14
14
  import { operationRegistry } from '../openapi/registry.ts';
15
+ import type { ApiKeyKind, HttpMethod } from '../openapi/types.ts';
15
16
  import {
16
17
  clientSupportsFormElicitation,
17
18
  decideMutationGate,
@@ -32,11 +33,33 @@ const sharedCallFields = {
32
33
  .describe('Query string parameters'),
33
34
  body: z.json().optional().describe('JSON request body'),
34
35
  apiKeyKind: z
35
- .enum(['secret', 'public'])
36
- .default('secret')
37
- .describe('Which configured API key to use'),
36
+ .enum(['secret', 'public', 'none'])
37
+ .optional()
38
+ .describe(
39
+ 'Which configured API key to send. Omit to use the operation from askell_describe_operation: secret, public, or none (no Authorization header). v1 subscription discount paths are none and authenticate with subscription_token',
40
+ ),
38
41
  };
39
42
 
43
+ export function resolveCallApiKeyKind(
44
+ method: HttpMethod,
45
+ path: string,
46
+ explicit: ApiKeyKind | undefined,
47
+ ): ApiKeyKind {
48
+ if (explicit) {
49
+ return explicit;
50
+ }
51
+
52
+ const normalized = normalizeApiPath(path);
53
+ const matches = operationRegistry.operations.filter(
54
+ (operation) =>
55
+ operation.method === method &&
56
+ openApiPathMatches(operation.path, normalized),
57
+ );
58
+ const exact = matches.find((operation) => operation.path === normalized);
59
+
60
+ return (exact ?? matches[0])?.apiKeyKind ?? 'secret';
61
+ }
62
+
40
63
  const callInputSchema = z.object({
41
64
  method: z.enum(['GET', 'HEAD']).describe('HTTP method (read-only)'),
42
65
  ...sharedCallFields,
@@ -61,11 +84,16 @@ type ToolCtx = {
61
84
  };
62
85
 
63
86
  function buildApprovalMessage(input: MutateInput): string {
87
+ const apiKeyKind = resolveCallApiKeyKind(
88
+ input.method,
89
+ input.path,
90
+ input.apiKeyKind,
91
+ );
64
92
  const lines = [
65
93
  'Approve this Askell API request?',
66
94
  '',
67
95
  `${input.method} ${input.path}`,
68
- `apiKeyKind: ${input.apiKeyKind}`,
96
+ `apiKeyKind: ${apiKeyKind}`,
69
97
  ];
70
98
 
71
99
  if (input.query && Object.keys(input.query).length > 0) {
@@ -85,19 +113,12 @@ async function executeAskellRequest(
85
113
  signal: AbortSignal,
86
114
  ): Promise<CallToolResult> {
87
115
  const path = normalizeApiPath(input.path);
88
- const known = operationRegistry
89
- .find({
90
- method: input.method,
91
- pathPrefix: path,
92
- })
93
- .find((operation) => operation.path === path);
94
-
95
116
  const request: AskellRequest = {
96
117
  method: input.method,
97
118
  path,
98
119
  query: input.query,
99
120
  body: input.body,
100
- apiKeyKind: input.apiKeyKind ?? known?.apiKeyKind ?? 'secret',
121
+ apiKeyKind: resolveCallApiKeyKind(input.method, path, input.apiKeyKind),
101
122
  signal,
102
123
  };
103
124
 
@@ -171,7 +192,7 @@ export function registerCallTools(
171
192
  {
172
193
  title: 'Call Askell API (read)',
173
194
  description:
174
- 'Read-only Askell API call (GET, HEAD) for any v1/v2 path. For POST/PUT/PATCH/DELETE use askell_mutate. Discover paths with askell_list_operations and askell_describe_operation first. Webhook hmac_secret is redacted in the response.',
195
+ 'Read-only Askell API call (GET, HEAD) for any v1/v2 path. For POST/PUT/PATCH/DELETE use askell_mutate. Discover paths with askell_list_operations and askell_describe_operation first. Omit apiKeyKind to follow the operation (none sends no Authorization header). Webhook hmac_secret is redacted in the response.',
175
196
  inputSchema: callInputSchema,
176
197
  annotations: {
177
198
  readOnlyHint: true,
@@ -190,7 +211,7 @@ export function registerCallTools(
190
211
  {
191
212
  title: 'Mutate Askell API',
192
213
  description:
193
- 'Mutating Askell API call (POST, PUT, PATCH, DELETE). Clients that declared form elicitation get a confirmation form; others rely on the client tool-approval UI. Use askell_call for GET. Discover paths with askell_list_operations and askell_describe_operation first. Webhook hmac_secret is redacted in the response (including POST /webhooks/ create).',
214
+ 'Mutating Askell API call (POST, PUT, PATCH, DELETE). Clients that declared form elicitation get a confirmation form; others rely on the client tool-approval UI. Use askell_call for GET. Discover paths with askell_list_operations and askell_describe_operation first. Omit apiKeyKind to follow the operation (none sends no Authorization header). Webhook hmac_secret is redacted in the response (including POST /webhooks/ create).',
194
215
  inputSchema: mutateInputSchema,
195
216
  annotations: {
196
217
  readOnlyHint: false,
@@ -24,9 +24,11 @@ const listInputSchema = z.object({
24
24
  'Case-insensitive search in id, path, summary, description, tags',
25
25
  ),
26
26
  apiKeyKind: z
27
- .enum(['secret', 'public'])
27
+ .enum(['secret', 'public', 'none'])
28
28
  .optional()
29
- .describe('Filter by required API key type'),
29
+ .describe(
30
+ 'Filter by required API key type. none means the operation does not use an API key',
31
+ ),
30
32
  limit: z
31
33
  .int()
32
34
  .positive()
@@ -44,7 +46,7 @@ const describeInputSchema = z.object({
44
46
  });
45
47
 
46
48
  const apiVersionSchema = z.enum(['v1', 'v2']);
47
- const apiKeyKindSchema = z.enum(['secret', 'public']);
49
+ const apiKeyKindSchema = z.enum(['secret', 'public', 'none']);
48
50
  const httpMethodSchema = z.enum([
49
51
  'GET',
50
52
  'POST',