askell-mcp 0.4.2 → 0.4.4

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
@@ -113,7 +113,7 @@ Typical agent workflow:
113
113
  | `askell_customer_overview` | v1 customer + subscriptions |
114
114
  | `askell_contract_overview` | v2 subscription contract + billing runs |
115
115
  | `askell_billing_run_triage` | v2 billing run (+ optional contract) |
116
- | `askell_list_webhooks` | List configured webhooks |
116
+ | `askell_list_webhooks` | List configured webhooks (`hmac_secret` redacted) |
117
117
 
118
118
  ### Resources
119
119
 
@@ -127,7 +127,7 @@ Typical agent workflow:
127
127
 
128
128
  - **v1** — legacy paths like `/customers/`, `/subscriptions/` (no `/v2` prefix)
129
129
  - **v2** — current model: catalogs, quotes, checkouts, contracts, billing runs under `/v2/`
130
- - **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.
130
+ - **v2 discounts** — coupons: `GET/POST /v2/subscription-contracts/{id}/discount|apply-code|remove-discount` (one active). Quotes take `promotion_code` and, for an existing buyer, `customer` (id) so combo discounts + promo restrictions apply. Totals already include both. Recurring `finalize` needs a verified payment method even when due-now is 0. Not the v1 `discount` 0–100 field.
131
131
  - Paths use **trailing slashes**
132
132
  - Prefer **v2** for new integrations; v1 remains for existing ones
133
133
  - 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.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "MCP server for the Askell payment and subscription API (Bun + stdio)",
5
5
  "author": "Neschadin Oleksandr",
6
6
  "license": "MIT",
@@ -62,11 +62,11 @@
62
62
  "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"
63
63
  },
64
64
  "devDependencies": {
65
- "@types/bun": "1.4.0",
65
+ "@types/bun": "1.4.2",
66
66
  "typescript": "7.0.2"
67
67
  },
68
68
  "dependencies": {
69
69
  "@modelcontextprotocol/server": "2.0.0",
70
- "zod": "4.5.4"
70
+ "zod": "4.6.1"
71
71
  }
72
72
  }
@@ -1455,7 +1455,7 @@
1455
1455
  "V2 Checkouts"
1456
1456
  ],
1457
1457
  "summary": "Finalize a V2 checkout",
1458
- "description": "Creates the subscription contract and initial billing run, then executes payment. The contract is activated only after successful payment. Requires a secret key.",
1458
+ "description": "Creates the subscription contract and initial billing run, then executes payment. The contract is activated only after successful payment. An offer with recurring items requires a verified payment method for the customer even when the initial total is zero (trial period or fully discounted first period); the card is charged when the first paid period arrives. Only a free one-time purchase finalizes without one. Requires a secret key.",
1459
1459
  "parameters": [
1460
1460
  {
1461
1461
  "$ref": "#/components/parameters/V2CheckoutToken"
@@ -2369,6 +2369,11 @@
2369
2369
  "type": "string",
2370
2370
  "nullable": true,
2371
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."
2372
+ },
2373
+ "customer": {
2374
+ "type": "integer",
2375
+ "nullable": true,
2376
+ "description": "Optional customer id. When given, the quote also reflects combo discounts earned through the customer's other active contracts, and promotion-code restrictions are checked against this customer."
2372
2377
  }
2373
2378
  }
2374
2379
  },
@@ -3079,11 +3084,93 @@
3079
3084
  "delivery_address": {
3080
3085
  "$ref": "#/components/schemas/V2Address",
3081
3086
  "description": "Optional delivery-address snapshot for physical products or alternate delivery."
3087
+ },
3088
+ "shipping": {
3089
+ "$ref": "#/components/schemas/V2ShippingSelectionInput",
3090
+ "description": "The shipping option the customer chose. Required when the offer contains physical products and the account has active shipping options."
3082
3091
  }
3083
3092
  }
3084
3093
  }
3085
3094
  ]
3086
3095
  },
3096
+ "V2ShippingSelectionInput": {
3097
+ "type": "object",
3098
+ "required": [
3099
+ "option"
3100
+ ],
3101
+ "properties": {
3102
+ "option": {
3103
+ "type": "integer",
3104
+ "description": "Id of one of the account's active shipping options."
3105
+ },
3106
+ "location_id": {
3107
+ "type": "string",
3108
+ "nullable": true,
3109
+ "description": "Provider id of the chosen pickup location. Required for options with `requires_location`."
3110
+ },
3111
+ "location_name": {
3112
+ "type": "string",
3113
+ "nullable": true
3114
+ },
3115
+ "location_address": {
3116
+ "type": "string",
3117
+ "nullable": true
3118
+ }
3119
+ }
3120
+ },
3121
+ "V2ShippingSelection": {
3122
+ "type": "object",
3123
+ "nullable": true,
3124
+ "description": "Snapshot of the shipping option chosen at checkout.",
3125
+ "properties": {
3126
+ "id": {
3127
+ "type": "integer"
3128
+ },
3129
+ "option_id": {
3130
+ "type": "integer",
3131
+ "nullable": true
3132
+ },
3133
+ "handler": {
3134
+ "type": "string",
3135
+ "enum": [
3136
+ "dropp",
3137
+ "posturinn",
3138
+ "store_pickup"
3139
+ ]
3140
+ },
3141
+ "delivery_mode": {
3142
+ "type": "string",
3143
+ "enum": [
3144
+ "home_delivery",
3145
+ "pickup_point",
3146
+ "post_office",
3147
+ "store_pickup",
3148
+ "freight"
3149
+ ]
3150
+ },
3151
+ "option_name": {
3152
+ "type": "string"
3153
+ },
3154
+ "service_code": {
3155
+ "type": "string"
3156
+ },
3157
+ "price_amount": {
3158
+ "type": "string"
3159
+ },
3160
+ "currency": {
3161
+ "type": "string"
3162
+ },
3163
+ "location_id": {
3164
+ "type": "string"
3165
+ },
3166
+ "location_name": {
3167
+ "type": "string"
3168
+ },
3169
+ "location_address": {
3170
+ "type": "string"
3171
+ }
3172
+ }
3173
+ },
3087
3174
  "V2Address": {
3088
3175
  "type": "object",
3089
3176
  "properties": {
@@ -3914,6 +4001,77 @@
3914
4001
  }
3915
4002
  }
3916
4003
  },
4004
+ "combo_discounts": {
4005
+ "type": "array",
4006
+ "description": "Automatic combo discounts applied to this quote: one entry per discounted recurring line. The quoted totals already include the first-period amounts, and the recurring_* totals already include the renewal amounts. Rules are evaluated against the products in the offer plus, when a customer is given, the products of the customer's other active contracts.",
4007
+ "items": {
4008
+ "type": "object",
4009
+ "properties": {
4010
+ "id": {
4011
+ "type": "integer",
4012
+ "description": "The combo discount rule id."
4013
+ },
4014
+ "name": {
4015
+ "type": "string"
4016
+ },
4017
+ "line_key": {
4018
+ "type": "string",
4019
+ "description": "Key of the discounted line in recurring_items."
4020
+ },
4021
+ "product_id": {
4022
+ "type": "integer"
4023
+ },
4024
+ "product_name": {
4025
+ "type": "string"
4026
+ },
4027
+ "required_product_ids": {
4028
+ "type": "array",
4029
+ "items": {
4030
+ "type": "integer"
4031
+ }
4032
+ },
4033
+ "percent_off": {
4034
+ "type": "string",
4035
+ "format": "decimal",
4036
+ "nullable": true
4037
+ },
4038
+ "amount_off": {
4039
+ "type": "string",
4040
+ "format": "decimal",
4041
+ "nullable": true
4042
+ },
4043
+ "currency": {
4044
+ "type": "string"
4045
+ },
4046
+ "discount_subtotal_amount": {
4047
+ "type": "string",
4048
+ "format": "decimal",
4049
+ "description": "First-period (prorated) discount, already reflected in subtotal_amount."
4050
+ },
4051
+ "discount_tax_amount": {
4052
+ "type": "string",
4053
+ "format": "decimal"
4054
+ },
4055
+ "discount_total_amount": {
4056
+ "type": "string",
4057
+ "format": "decimal"
4058
+ },
4059
+ "renewal_discount_subtotal_amount": {
4060
+ "type": "string",
4061
+ "format": "decimal",
4062
+ "description": "Full-period discount applied on each renewal while the customer holds the required products."
4063
+ },
4064
+ "renewal_discount_tax_amount": {
4065
+ "type": "string",
4066
+ "format": "decimal"
4067
+ },
4068
+ "renewal_discount_total_amount": {
4069
+ "type": "string",
4070
+ "format": "decimal"
4071
+ }
4072
+ }
4073
+ }
4074
+ },
3917
4075
  "recurring_items": {
3918
4076
  "type": "array",
3919
4077
  "items": {
@@ -4630,6 +4788,9 @@
4630
4788
  "customer_reference": {
4631
4789
  "type": "string"
4632
4790
  },
4791
+ "shipping_selection": {
4792
+ "$ref": "#/components/schemas/V2ShippingSelection"
4793
+ },
4633
4794
  "discount": {
4634
4795
  "allOf": [
4635
4796
  {
@@ -5334,4 +5495,4 @@
5334
5495
  }
5335
5496
  }
5336
5497
  }
5337
- }
5498
+ }
@@ -1,5 +1,6 @@
1
1
  import { normalizeBaseUrl, type AppConfig } from '../config.ts';
2
2
  import { normalizeApiPath } from './paths.ts';
3
+ import { redactSecretsInText } from './redact.ts';
3
4
  import {
4
5
  buildBoundedListPayload,
5
6
  formatApiResponse,
@@ -182,10 +183,11 @@ export class AskellClient {
182
183
  try {
183
184
  parsed = JSON.parse(bodyText);
184
185
  } catch {
186
+ const text = redactSecretsInText(bodyText);
185
187
  return {
186
- text: bodyText,
188
+ text,
187
189
  truncated: false,
188
- byteLength: Buffer.byteLength(bodyText, 'utf8'),
190
+ byteLength: Buffer.byteLength(text, 'utf8'),
189
191
  ok: false,
190
192
  status: lastStatus,
191
193
  };
@@ -0,0 +1,54 @@
1
+ const SECRET_KEYS = new Set(['hmac_secret']);
2
+
3
+ const JSON_SECRET_STRING =
4
+ /"(hmac_secret)"\s*:\s*("(?:\\.|[^"\\])*")/g;
5
+
6
+ export function formatRedactedSecret(value: unknown): string {
7
+ if (typeof value === 'string' && value.startsWith('<redacted')) {
8
+ return value;
9
+ }
10
+ if (typeof value === 'string') {
11
+ return `<redacted len=${value.length}>`;
12
+ }
13
+ return '<redacted>';
14
+ }
15
+
16
+ export function redactSensitiveFields(value: unknown): unknown {
17
+ if (Array.isArray(value)) {
18
+ return value.map(redactSensitiveFields);
19
+ }
20
+
21
+ if (value !== null && typeof value === 'object') {
22
+ const out: Record<string, unknown> = {};
23
+ for (const [key, nested] of Object.entries(
24
+ value as Record<string, unknown>,
25
+ )) {
26
+ out[key] = SECRET_KEYS.has(key)
27
+ ? formatRedactedSecret(nested)
28
+ : redactSensitiveFields(nested);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ return value;
34
+ }
35
+
36
+ /** Fallback for non-JSON / truncated bodies. Leaves already-redacted values alone. */
37
+ export function redactSecretsInText(text: string): string {
38
+ if (!text.includes('hmac_secret')) {
39
+ return text;
40
+ }
41
+
42
+ try {
43
+ return JSON.stringify(redactSensitiveFields(JSON.parse(text)));
44
+ } catch {
45
+ JSON_SECRET_STRING.lastIndex = 0;
46
+ return text.replace(JSON_SECRET_STRING, (_match, key: string, raw: string) => {
47
+ try {
48
+ return `"${key}": ${JSON.stringify(formatRedactedSecret(JSON.parse(raw)))}`;
49
+ } catch {
50
+ return `"${key}": ${JSON.stringify(formatRedactedSecret(undefined))}`;
51
+ }
52
+ });
53
+ }
54
+ }
@@ -1,3 +1,8 @@
1
+ import {
2
+ redactSensitiveFields,
3
+ redactSecretsInText,
4
+ } from './redact.ts';
5
+
1
6
  export interface FormattedResponse {
2
7
  text: string;
3
8
  truncated: boolean;
@@ -203,7 +208,8 @@ export function buildBoundedListPayload(input: {
203
208
  items: unknown[];
204
209
  maxBytes: number;
205
210
  }): FormattedResponse {
206
- const { status, meta, items, maxBytes } = input;
211
+ const { status, meta, maxBytes } = input;
212
+ const items = input.items.map((item) => redactSensitiveFields(item));
207
213
 
208
214
  const attempts: Array<{
209
215
  items: unknown[];
@@ -334,13 +340,14 @@ export function formatApiResponse(
334
340
  ): FormattedResponse {
335
341
  const byteLength = Buffer.byteLength(bodyText, 'utf8');
336
342
  const truncated = byteLength > maxBytes;
337
- const visibleBody = truncated ? truncateUtf8(bodyText, maxBytes) : bodyText;
343
+ const safeText = redactSecretsInText(bodyText);
344
+ const visibleBody = truncated ? truncateUtf8(safeText, maxBytes) : safeText;
338
345
 
339
346
  let parsedBody: unknown = visibleBody;
340
347
  try {
341
348
  parsedBody = JSON.parse(visibleBody);
342
349
  } catch {
343
- // keep raw text
350
+ // keep redacted raw text
344
351
  }
345
352
 
346
353
  const payload = {
@@ -21,9 +21,10 @@ Rare historical payloads used \`{ event, data, ref?, sender? }\`. If both \`even
21
21
 
22
22
  ## Registering endpoints (v1 management API)
23
23
 
24
- - GET/POST \`/webhooks/\` · GET/PUT/PATCH/DELETE \`/webhooks/{id}/\` (secret key)
24
+ - GET/POST \`/webhooks/\` · GET/PUT/PATCH/DELETE \`/webhooks/{id}/\` (secret key). Live GET \`/webhooks/{id}/\` exists even if OpenAPI omits it.
25
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\`)
26
+ - Askell returns plaintext \`hmac_secret\` on list, get, and create (it is re-readable, not create-only), plus \`hmac_digest\` (typically \`SHA512\`)
27
+ - MCP tool output redacts \`hmac_secret\` to \`<redacted len=N>\`. Do not treat that placeholder as the real secret. Copy the secret from the Askell dashboard or a direct API call outside MCP.
27
28
 
28
29
  Tools: \`askell_list_webhooks\`, \`askell_call\` (GET), \`askell_mutate\` (POST/PUT/PATCH/DELETE).
29
30
 
package/src/server.ts CHANGED
@@ -47,14 +47,15 @@ API layout:
47
47
  - V2 list endpoints paginate only when page_size is provided (default 10, max 1000).
48
48
  - GET /v2/customer-entitlements/ requires customer_reference query param.
49
49
 
50
- V2 discounts (coupons / promotion codes) not the same as v1:
51
- - 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/.
52
- - Quotes: pass promotion_code on POST /v2/subscription-offer-quotes/; totals already include the discount when set.
53
- - v1 Subscription.discount is a 0-100 percent override on a PlanVariant subscription. Do not send it to v2 contract endpoints.
50
+ V2 discounts — two systems, not v1 Subscription.discount (0-100 on a PlanVariant; never send that to v2):
51
+ - Coupons: one active per contract. GET /v2/subscription-contracts/{id}/discount/ (also nested as contract.discount). Apply with POST .../apply-code/ {promotion_code}. Remove with POST .../remove-discount/.
52
+ - 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. Quoted totals already include coupon + combo; do not subtract again. combo_discounts[] is on the quote response (askell_describe_operation omits response schemas). Combo is automatic, not apply-code.
54
53
 
55
54
  V2 checkout notes:
56
- - checkout_url on V2 checkouts points to the API object URL, not a browser payment page.
57
- - Embedded checkout uses POST /v2/checkout-sessions/ plus browser session-token sub-paths (see docs, not all in OpenAPI).
55
+ - checkout_url on V2 checkouts points to the API object URL, not a hosted payment page.
56
+ - finalize: a recurring offer needs a verified payment method even when due-now/total is 0 (trial or fully discounted first period). Only a free one-time purchase finalizes without one. Live docs still say "unless 0 ISK" — ignore that; bundled OpenAPI is right.
57
+ - Hosted POST /v2/checkouts/: shipping {option, location_id?} is required when the offer has physical products and the account has active shipping options. No shipping-options list in OpenAPI (ids are account config). Pickup options need location_id. Snapshot is contract.shipping_selection, not on V2Checkout.
58
+ - Embedded checkout uses POST /v2/checkout-sessions/ plus browser session-token sub-paths (widget collects address/shipping; see docs, not all in OpenAPI).
58
59
 
59
60
  Auth:
60
61
  - Most endpoints need the secret API key.
@@ -64,6 +65,7 @@ Safety:
64
65
  - Writes go through askell_mutate (destructiveHint). Reads go through askell_call (readOnlyHint).
65
66
  - 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.
66
67
  - 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.
68
+ - Tool output redacts webhook hmac_secret to \`<redacted len=N>\` (Askell list/get/create return the plaintext secret).
67
69
 
68
70
  Resources:
69
71
  - askell://spec/v1 and askell://spec/v2 — bundled OpenAPI
@@ -141,7 +141,7 @@ export function registerAnalysisTools(
141
141
  {
142
142
  title: 'Subscription contract overview (v2)',
143
143
  description:
144
- 'Fetch a v2 subscription contract and recent billing runs filtered by contract id. The contract payload includes `discount` (active coupon) when one is applied.',
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, and `shipping_selection` when shipping was chosen at checkout.',
145
145
  inputSchema: z.object({
146
146
  contractId: z
147
147
  .union([z.string().min(1), z.int()])
@@ -278,7 +278,7 @@ export function registerAnalysisTools(
278
278
  {
279
279
  title: 'List configured webhooks (v1)',
280
280
  description:
281
- 'List Askell webhook endpoints configured for the account (management API only).',
281
+ 'List Askell webhook endpoints configured for the account (management API only). hmac_secret is redacted in the tool output (`<redacted len=N>`); copy the real secret from the dashboard or a non-MCP API call.',
282
282
  inputSchema: z.object({
283
283
  page_size: z
284
284
  .int()
package/src/tools/call.ts CHANGED
@@ -171,7 +171,7 @@ export function registerCallTools(
171
171
  {
172
172
  title: 'Call Askell API (read)',
173
173
  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.',
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.',
175
175
  inputSchema: callInputSchema,
176
176
  annotations: {
177
177
  readOnlyHint: true,
@@ -190,7 +190,7 @@ export function registerCallTools(
190
190
  {
191
191
  title: 'Mutate Askell API',
192
192
  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.',
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).',
194
194
  inputSchema: mutateInputSchema,
195
195
  annotations: {
196
196
  readOnlyHint: false,