askell-mcp 0.2.0 → 0.3.1

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [MCP](https://modelcontextprotocol.io) server for the [Askell](https://askell.is) payment and subscription API.
4
4
 
5
- Connect it to Cursor, Claude Desktop, or any MCP client to discover Askell endpoints, inspect customers/contracts/billing, and call the API with confirmation before mutating requests.
5
+ Connect it to Cursor, Claude Desktop, or any MCP client to discover Askell endpoints, inspect customers/contracts/billing, and call the API. Reads and writes are separate tools so clients can show their own approval UI on mutations.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -62,17 +62,26 @@ Restart the client after saving.
62
62
  | `ASKELL_PUBLIC_API_KEY` | no | — | Public key for a few checkout/payment endpoints |
63
63
  | `ASKELL_API_URL` | no | `https://askell.is/api` | API base URL (_or_ `ASKELL_API_BASE_URL`) |
64
64
  | `ASKELL_RESPONSE_MAX_BYTES` | no | `64000` | Max response size returned to the model |
65
- | `ASKELL_REQUIRE_MUTATION_APPROVAL` | no | `true` | Confirm before POST/PUT/PATCH/DELETE |
65
+ | `ASKELL_MUTATION_GATE` | no | `auto` | `auto` / `elicit` / `off` — see below |
66
+ | `ASKELL_REQUIRE_MUTATION_APPROVAL` | no | — | Deprecated alias: `true`→`elicit`, `false`→`off` |
66
67
 
67
68
  Askell has **no separate sandbox host** — production and test traffic use the same URL. Use the **Áskell Test Gateway** acquirer in your dashboard for safe payment testing. See [Askell getting started](https://docs.askell.is/en/getting_started/index.html).
68
69
 
70
+ `ASKELL_MUTATION_GATE`:
71
+
72
+ - **`auto` (default)** — confirmation form only if *this request's* `_meta` envelope declared form elicitation (MCP 2026-07-28). 2025-era clients (Cursor, most hosts) do not send that envelope, so the mutation runs and their own “allow this tool” UI is the gate.
73
+ - **`elicit`** — always return an elicitation form. The SDK refuses the call if the client cannot fulfil it (2026 envelope / 2025 initialize via the legacy shim).
74
+ - **`off`** — never ask (eval / trusted automation).
75
+
76
+ If both `ASKELL_MUTATION_GATE` and `ASKELL_REQUIRE_MUTATION_APPROVAL` are set, `ASKELL_MUTATION_GATE` wins.
77
+
69
78
  ## What you can do
70
79
 
71
80
  Typical agent workflow:
72
81
 
73
82
  1. **Discover** — `askell_list_operations` / `askell_describe_operation` (from bundled OpenAPI v1 + v2)
74
83
  2. **Support tasks** — customer/contract/billing helpers below
75
- 3. **Anything else** — `askell_call` for a raw endpoint (mutations ask for approval when enabled)
84
+ 3. **Anything else** — `askell_call` for GET/HEAD, `askell_mutate` for POST/PUT/PATCH/DELETE
76
85
 
77
86
  ### Tools
78
87
 
@@ -80,7 +89,8 @@ Typical agent workflow:
80
89
  | --------------------------- | ---------------------------------------- |
81
90
  | `askell_list_operations` | Search bundled OpenAPI operations |
82
91
  | `askell_describe_operation` | Params and body schema for one operation |
83
- | `askell_call` | Call any v1/v2 endpoint |
92
+ | `askell_call` | GET/HEAD any v1/v2 endpoint |
93
+ | `askell_mutate` | POST/PUT/PATCH/DELETE any v1/v2 endpoint |
84
94
  | `askell_paginate_all` | Follow paginated list endpoints |
85
95
  | `askell_customer_overview` | v1 customer + subscriptions |
86
96
  | `askell_contract_overview` | v2 subscription contract + billing runs |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askell-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "MCP server for the Askell payment and subscription API (Bun + stdio)",
5
5
  "author": "Neschadin Oleksandr",
6
6
  "license": "MIT",
@@ -65,6 +65,6 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@modelcontextprotocol/server": "2.0.0",
68
- "zod": "4.4.3"
68
+ "zod": "4.5.4"
69
69
  }
70
70
  }
@@ -8,7 +8,8 @@
8
8
  "name": "Documentation",
9
9
  "url": "https://docs.askell.is"
10
10
  },
11
- "version": "1.0.0"
11
+ "version": "1.0.0",
12
+ "x-askell-mcp-patched": "v1-overlay-1"
12
13
  },
13
14
  "servers": [
14
15
  {
@@ -52,10 +53,6 @@
52
53
  "name": "Webhooks",
53
54
  "description": "Management of webhooks. Requires secret api key."
54
55
  },
55
- {
56
- "name": "Webhook calls",
57
- "description": "Webhook calls from Áskell to your system."
58
- },
59
56
  {
60
57
  "name": "Transaction",
61
58
  "description": "Transaction operations, requires secret api key."
@@ -339,7 +336,14 @@
339
336
  },
340
337
  "responses": {
341
338
  "201": {
342
- "description": "Successful operation"
339
+ "description": "Successful operation",
340
+ "content": {
341
+ "application/json": {
342
+ "schema": {
343
+ "$ref": "#/components/schemas/Customer"
344
+ }
345
+ }
346
+ }
343
347
  },
344
348
  "400": {
345
349
  "description": "Invalid status value",
@@ -506,7 +510,14 @@
506
510
  ],
507
511
  "responses": {
508
512
  "200": {
509
- "description": "Successful operation"
513
+ "description": "Successful operation",
514
+ "content": {
515
+ "application/json": {
516
+ "schema": {
517
+ "$ref": "#/components/schemas/Customer"
518
+ }
519
+ }
520
+ }
510
521
  },
511
522
  "404": {
512
523
  "description": "Not found"
@@ -572,7 +583,14 @@
572
583
  ],
573
584
  "responses": {
574
585
  "200": {
575
- "description": "Successful operation"
586
+ "description": "Successful operation",
587
+ "content": {
588
+ "application/json": {
589
+ "schema": {
590
+ "$ref": "#/components/schemas/Customer"
591
+ }
592
+ }
593
+ }
576
594
  },
577
595
  "404": {
578
596
  "description": "Not found"
@@ -1367,23 +1385,6 @@
1367
1385
  ]
1368
1386
  }
1369
1387
  },
1370
- "/your-webhook-url/": {
1371
- "post": {
1372
- "tags": [
1373
- "Webhook calls"
1374
- ],
1375
- "summary": "Called when subscription changed or renewed",
1376
- "description": "We recommend that you verify all calls from us to your webhook endpoints. We sign each request with the `Hook-HMAC` header. The value is a HMAC digest derived from the body of the request and a secret key only known by the API and you. We also supply the type of event in the `Hook-Event` header.\n\nHere is a sample implementation in Python. This process is very similar to the one Shopify uses so more information can possibly be gleaned from their [documentation](https://shopify.dev/tutorials/manage-webhooks#verify-webhook).\n```\nimport base64\nimport hmac\nimport hashlib\n\nWEBHOOK_SECRET = \"Your webhook secret\".encode()\nWEBHOOK_DIGEST_TYPE = 'sha512'\n\ndef verify(hmac_header, digest_method, secret, message):\n digestmod = getattr(hashlib, digest_method)\n signed = base64.b64encode(\n hmac.new(secret, message, digestmod).digest(),\n ).strip()\n return hmac.compare_digest(signed, hmac_header)\n\n# your view function\ndef handle_webhook(request):\n # The signature\n digest = request.META.get('HTTP_HOOK_HMAC').encode()\n # The name of the webhook event\n event = request.META.get('HTTP_HOOK_EVENT').encode()\n\n body = request.body\n if verify(digest, WEBHOOK_DIGEST_TYPE, WEBHOOK_SECRET, body):\n payload = json.loads(body)\n # ... the rest of your code here```\n",
1377
- "requestBody": {
1378
- "$ref": "#/components/requestBodies/SubscriptionMultiLite"
1379
- },
1380
- "responses": {
1381
- "200": {
1382
- "description": "Successful operation"
1383
- }
1384
- }
1385
- }
1386
- },
1387
1388
  "/webhooks/": {
1388
1389
  "get": {
1389
1390
  "tags": [
@@ -1423,7 +1424,14 @@
1423
1424
  },
1424
1425
  "responses": {
1425
1426
  "201": {
1426
- "description": "Successful operation"
1427
+ "description": "Successful operation",
1428
+ "content": {
1429
+ "application/json": {
1430
+ "schema": {
1431
+ "$ref": "#/components/schemas/Webhook"
1432
+ }
1433
+ }
1434
+ }
1427
1435
  },
1428
1436
  "400": {
1429
1437
  "description": "Invalid status value",
@@ -1521,7 +1529,14 @@
1521
1529
  },
1522
1530
  "responses": {
1523
1531
  "200": {
1524
- "description": "Successful operation"
1532
+ "description": "Successful operation",
1533
+ "content": {
1534
+ "application/json": {
1535
+ "schema": {
1536
+ "$ref": "#/components/schemas/Webhook"
1537
+ }
1538
+ }
1539
+ }
1525
1540
  },
1526
1541
  "400": {
1527
1542
  "description": "Invalid status value",
@@ -1964,27 +1979,94 @@
1964
1979
  }
1965
1980
  },
1966
1981
  "Customer": {
1967
- "allOf": [
1968
- {
1969
- "$ref": "#/components/schemas/CustomerCreate"
1982
+ "type": "object",
1983
+ "required": [
1984
+ "id",
1985
+ "first_name",
1986
+ "last_name",
1987
+ "email",
1988
+ "customer_reference",
1989
+ "phone",
1990
+ "delivery_name",
1991
+ "address_1",
1992
+ "address_2",
1993
+ "address_3",
1994
+ "zip_code",
1995
+ "city",
1996
+ "country",
1997
+ "payment_method",
1998
+ "address"
1999
+ ],
2000
+ "properties": {
2001
+ "id": {
2002
+ "type": "integer",
2003
+ "format": "int64"
1970
2004
  },
1971
- {
1972
- "type": "object",
1973
- "properties": {
1974
- "id": {
1975
- "type": "integer",
1976
- "format": "int64",
1977
- "example": 1
1978
- },
1979
- "payment_method": {
1980
- "type": "array",
1981
- "items": {
1982
- "$ref": "#/components/schemas/PaymentMethod"
1983
- }
1984
- }
2005
+ "first_name": {
2006
+ "type": "string",
2007
+ "maxLength": 128
2008
+ },
2009
+ "last_name": {
2010
+ "type": "string",
2011
+ "maxLength": 128
2012
+ },
2013
+ "email": {
2014
+ "type": "string",
2015
+ "nullable": true,
2016
+ "maxLength": 254
2017
+ },
2018
+ "phone": {
2019
+ "type": "string",
2020
+ "nullable": true,
2021
+ "maxLength": 32
2022
+ },
2023
+ "customer_reference": {
2024
+ "type": "string",
2025
+ "maxLength": 256
2026
+ },
2027
+ "delivery_name": {
2028
+ "type": "string",
2029
+ "nullable": true
2030
+ },
2031
+ "address_1": {
2032
+ "type": "string",
2033
+ "nullable": true
2034
+ },
2035
+ "address_2": {
2036
+ "type": "string",
2037
+ "nullable": true
2038
+ },
2039
+ "address_3": {
2040
+ "type": "string",
2041
+ "nullable": true
2042
+ },
2043
+ "zip_code": {
2044
+ "type": "string",
2045
+ "nullable": true
2046
+ },
2047
+ "city": {
2048
+ "type": "string",
2049
+ "nullable": true
2050
+ },
2051
+ "country": {
2052
+ "type": "string",
2053
+ "nullable": true
2054
+ },
2055
+ "payment_method": {
2056
+ "type": "array",
2057
+ "items": {
2058
+ "$ref": "#/components/schemas/PaymentMethod"
1985
2059
  }
2060
+ },
2061
+ "address": {
2062
+ "allOf": [
2063
+ {
2064
+ "$ref": "#/components/schemas/CustomerAddress"
2065
+ }
2066
+ ],
2067
+ "nullable": true
1986
2068
  }
1987
- ]
2069
+ }
1988
2070
  },
1989
2071
  "CustomerPaginated": {
1990
2072
  "type": "object",
@@ -2593,6 +2675,48 @@
2593
2675
  "example": "tokencreated"
2594
2676
  }
2595
2677
  }
2678
+ },
2679
+ "CustomerAddress": {
2680
+ "type": "object",
2681
+ "required": [
2682
+ "delivery_name",
2683
+ "address_1",
2684
+ "address_2",
2685
+ "address_3",
2686
+ "zip_code",
2687
+ "city",
2688
+ "country"
2689
+ ],
2690
+ "properties": {
2691
+ "delivery_name": {
2692
+ "type": "string",
2693
+ "nullable": true
2694
+ },
2695
+ "address_1": {
2696
+ "type": "string",
2697
+ "nullable": true
2698
+ },
2699
+ "address_2": {
2700
+ "type": "string",
2701
+ "nullable": true
2702
+ },
2703
+ "address_3": {
2704
+ "type": "string",
2705
+ "nullable": true
2706
+ },
2707
+ "zip_code": {
2708
+ "type": "string",
2709
+ "nullable": true
2710
+ },
2711
+ "city": {
2712
+ "type": "string",
2713
+ "nullable": true
2714
+ },
2715
+ "country": {
2716
+ "type": "string",
2717
+ "nullable": true
2718
+ }
2719
+ }
2596
2720
  }
2597
2721
  },
2598
2722
  "requestBodies": {
@@ -2780,4 +2904,4 @@
2780
2904
  }
2781
2905
  }
2782
2906
  }
2783
- }
2907
+ }
package/src/config.ts CHANGED
@@ -1,9 +1,31 @@
1
1
  import * as z from 'zod';
2
2
 
3
+ export const MUTATION_GATES = ['auto', 'elicit', 'off'] as const;
4
+ export type MutationGate = (typeof MUTATION_GATES)[number];
5
+
3
6
  const httpUrl = z
4
7
  .url({ protocol: /^https?$/ })
5
8
  .describe('Askell API base URL (default production host)');
6
9
 
10
+ const mutationGateAliases = z
11
+ .enum(['true', 'false', 'on', 'yes', 'no', '1', '0'])
12
+ .transform((value): MutationGate => {
13
+ return value === 'true' || value === 'on' || value === 'yes' || value === '1'
14
+ ? 'elicit'
15
+ : 'off';
16
+ });
17
+
18
+ export const MutationGateSchema = z
19
+ .union([
20
+ z.enum(MUTATION_GATES),
21
+ z.boolean().transform((value): MutationGate => (value ? 'elicit' : 'off')),
22
+ mutationGateAliases,
23
+ ])
24
+ .default('auto')
25
+ .describe(
26
+ 'Mutation confirmation: auto (elicit if client declared it), elicit (require form), off (never)',
27
+ );
28
+
7
29
  export const ConfigSchema = z.object({
8
30
  apiBaseUrl: httpUrl.default('https://askell.is/api'),
9
31
  secretApiKey: z.string().min(1).describe('Secret (private) API key'),
@@ -18,10 +40,7 @@ export const ConfigSchema = z.object({
18
40
  .positive()
19
41
  .default(64_000)
20
42
  .describe('Max response body size returned to the model'),
21
- requireMutationApproval: z
22
- .union([z.boolean(), z.stringbool()])
23
- .default(true)
24
- .describe('Require operator confirmation before mutating requests'),
43
+ mutationGate: MutationGateSchema,
25
44
  });
26
45
 
27
46
  export type AppConfig = z.infer<typeof ConfigSchema>;
@@ -58,7 +77,9 @@ function loadConfigFromEnv(): unknown {
58
77
 
59
78
  const apiBaseUrl = env.ASKELL_API_URL ?? env.ASKELL_API_BASE_URL;
60
79
  const responseMaxBytes = env.ASKELL_RESPONSE_MAX_BYTES;
61
- const requireMutationApproval = env.ASKELL_REQUIRE_MUTATION_APPROVAL;
80
+ const mutationGateRaw =
81
+ env.ASKELL_MUTATION_GATE ?? env.ASKELL_REQUIRE_MUTATION_APPROVAL;
82
+ const mutationGate = mutationGateRaw?.trim().toLowerCase() || undefined;
62
83
 
63
84
  return {
64
85
  ...(apiBaseUrl ? { apiBaseUrl } : {}),
@@ -67,9 +88,7 @@ function loadConfigFromEnv(): unknown {
67
88
  ? { publicApiKey: env.ASKELL_PUBLIC_API_KEY }
68
89
  : {}),
69
90
  ...(responseMaxBytes ? { responseMaxBytes } : {}),
70
- ...(requireMutationApproval !== undefined
71
- ? { requireMutationApproval }
72
- : {}),
91
+ ...(mutationGate !== undefined ? { mutationGate } : {}),
73
92
  };
74
93
  }
75
94
 
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Overlay for Askell OpenAPI v1. Upstream swagger is wrong in a few places;
3
+ * this is the same transform as askell_client_v1 `patchAskellSpec` (without Orval).
4
+ *
5
+ * Idempotent: safe to run on an already-patched document.
6
+ */
7
+
8
+ type JsonSchema = Record<string, unknown>;
9
+ type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
10
+
11
+ type OpenApiResponse = {
12
+ description?: string;
13
+ content?: Record<string, { schema?: JsonSchema }>;
14
+ [key: string]: unknown;
15
+ };
16
+
17
+ type OpenApiOperation = {
18
+ tags?: string[];
19
+ responses?: Record<string, OpenApiResponse>;
20
+ [key: string]: unknown;
21
+ };
22
+
23
+ type OpenApiPathItem = Partial<Record<HttpMethod, OpenApiOperation>> & {
24
+ [key: string]: unknown;
25
+ };
26
+
27
+ type OpenApiDocument = {
28
+ info?: Record<string, unknown>;
29
+ tags?: Array<{ name: string; description?: string }>;
30
+ paths?: Record<string, OpenApiPathItem>;
31
+ components?: {
32
+ schemas?: Record<string, JsonSchema>;
33
+ [key: string]: unknown;
34
+ };
35
+ [key: string]: unknown;
36
+ };
37
+
38
+ export const ASKELL_V1_PATCH = 'v1-overlay-1';
39
+ export const WEBHOOK_CALLS_TAG = 'Webhook calls';
40
+
41
+ /** Success bodies missing from swagger `content` (DELETE 204 stays empty). */
42
+ const RESPONSE_BODIES = [
43
+ ['/customers/', 'post', '201', 'Customer'],
44
+ ['/customers/{customerReference}/', 'put', '200', 'Customer'],
45
+ ['/customers/{customerReference}/', 'patch', '200', 'Customer'],
46
+ ['/webhooks/', 'post', '201', 'Webhook'],
47
+ ['/webhooks/{id}/', 'patch', '200', 'Webhook'],
48
+ ] as const satisfies ReadonlyArray<
49
+ readonly [string, HttpMethod, string, string]
50
+ >;
51
+
52
+ const HTTP_METHODS = [
53
+ 'get',
54
+ 'post',
55
+ 'put',
56
+ 'patch',
57
+ 'delete',
58
+ 'head',
59
+ ] as const;
60
+
61
+ const nullableString = (maxLength?: number): JsonSchema => ({
62
+ type: 'string',
63
+ nullable: true,
64
+ ...(maxLength === undefined ? {} : { maxLength }),
65
+ });
66
+
67
+ /** Flattened + nested address as returned by GET /customers/ and customer.* webhooks. */
68
+ const CUSTOMER_ADDRESS_SCHEMA: JsonSchema = {
69
+ type: 'object',
70
+ required: [
71
+ 'delivery_name',
72
+ 'address_1',
73
+ 'address_2',
74
+ 'address_3',
75
+ 'zip_code',
76
+ 'city',
77
+ 'country',
78
+ ],
79
+ properties: {
80
+ delivery_name: nullableString(),
81
+ address_1: nullableString(),
82
+ address_2: nullableString(),
83
+ address_3: nullableString(),
84
+ zip_code: nullableString(),
85
+ city: nullableString(),
86
+ country: nullableString(),
87
+ },
88
+ };
89
+
90
+ /**
91
+ * Askell swagger reuses CustomerCreate (write) as the GET model via allOf.
92
+ * Live GET / webhooks return extra address fields, null emails/phones, and a
93
+ * required numeric id. Keep CustomerCreate as the POST body.
94
+ */
95
+ const CUSTOMER_READ_SCHEMA: JsonSchema = {
96
+ type: 'object',
97
+ required: [
98
+ 'id',
99
+ 'first_name',
100
+ 'last_name',
101
+ 'email',
102
+ 'customer_reference',
103
+ 'phone',
104
+ 'delivery_name',
105
+ 'address_1',
106
+ 'address_2',
107
+ 'address_3',
108
+ 'zip_code',
109
+ 'city',
110
+ 'country',
111
+ 'payment_method',
112
+ 'address',
113
+ ],
114
+ properties: {
115
+ id: { type: 'integer', format: 'int64' },
116
+ first_name: { type: 'string', maxLength: 128 },
117
+ last_name: { type: 'string', maxLength: 128 },
118
+ email: nullableString(254),
119
+ phone: nullableString(32),
120
+ customer_reference: { type: 'string', maxLength: 256 },
121
+ delivery_name: nullableString(),
122
+ address_1: nullableString(),
123
+ address_2: nullableString(),
124
+ address_3: nullableString(),
125
+ zip_code: nullableString(),
126
+ city: nullableString(),
127
+ country: nullableString(),
128
+ payment_method: {
129
+ type: 'array',
130
+ items: { $ref: '#/components/schemas/PaymentMethod' },
131
+ },
132
+ address: {
133
+ allOf: [{ $ref: '#/components/schemas/CustomerAddress' }],
134
+ nullable: true,
135
+ },
136
+ },
137
+ };
138
+
139
+ function isRecord(value: unknown): value is Record<string, unknown> {
140
+ return value != null && typeof value === 'object' && !Array.isArray(value);
141
+ }
142
+
143
+ function dropWebhookCallOperations(doc: OpenApiDocument): void {
144
+ const paths = doc.paths;
145
+ if (!paths) {
146
+ return;
147
+ }
148
+
149
+ for (const path of Object.keys(paths)) {
150
+ const pathItem = paths[path];
151
+ if (!pathItem) {
152
+ continue;
153
+ }
154
+
155
+ for (const method of HTTP_METHODS) {
156
+ const operation = pathItem[method as HttpMethod];
157
+ if (operation?.tags?.includes(WEBHOOK_CALLS_TAG)) {
158
+ delete pathItem[method as HttpMethod];
159
+ }
160
+ }
161
+
162
+ const remaining = HTTP_METHODS.some(
163
+ (method) => pathItem[method as HttpMethod] != null,
164
+ );
165
+ if (!remaining) {
166
+ delete paths[path];
167
+ }
168
+ }
169
+
170
+ if (doc.tags) {
171
+ doc.tags = doc.tags.filter((tag) => tag.name !== WEBHOOK_CALLS_TAG);
172
+ }
173
+ }
174
+
175
+ function ensureResponseBody(
176
+ doc: OpenApiDocument,
177
+ path: string,
178
+ method: HttpMethod,
179
+ status: string,
180
+ schemaName: string,
181
+ ): void {
182
+ const pathItem = doc.paths?.[path];
183
+ const operation = pathItem?.[method];
184
+ const response = operation?.responses?.[status];
185
+ const label = `${method.toUpperCase()} ${path} ${status}`;
186
+
187
+ if (!pathItem || !operation || !response) {
188
+ throw new Error(`Askell spec missing ${label}`);
189
+ }
190
+
191
+ if (response.content?.['application/json']?.schema) {
192
+ return;
193
+ }
194
+
195
+ response.content = {
196
+ ...response.content,
197
+ 'application/json': {
198
+ schema: { $ref: `#/components/schemas/${schemaName}` },
199
+ },
200
+ };
201
+ }
202
+
203
+ export function patchAskellV1Spec(spec: unknown): OpenApiDocument {
204
+ if (!isRecord(spec)) {
205
+ throw new Error('Askell v1 spec is not an object');
206
+ }
207
+
208
+ const doc = structuredClone(spec) as OpenApiDocument;
209
+ const schemas = doc.components?.schemas;
210
+ if (!schemas?.Customer) {
211
+ throw new Error('Askell spec missing components.schemas.Customer');
212
+ }
213
+ if (!schemas.Webhook) {
214
+ throw new Error('Askell spec missing components.schemas.Webhook');
215
+ }
216
+
217
+ dropWebhookCallOperations(doc);
218
+
219
+ doc.components = {
220
+ ...doc.components,
221
+ schemas: {
222
+ ...schemas,
223
+ CustomerAddress: CUSTOMER_ADDRESS_SCHEMA,
224
+ Customer: CUSTOMER_READ_SCHEMA,
225
+ },
226
+ };
227
+
228
+ for (const [path, method, status, schemaName] of RESPONSE_BODIES) {
229
+ ensureResponseBody(doc, path, method, status, schemaName);
230
+ }
231
+
232
+ doc.info = {
233
+ ...doc.info,
234
+ 'x-askell-mcp-patched': ASKELL_V1_PATCH,
235
+ };
236
+
237
+ return doc;
238
+ }
@@ -4,34 +4,60 @@ import { getBundledSpec } from '../openapi/registry.ts';
4
4
 
5
5
  const WEBHOOK_EVENTS_DOC = `# Askell webhook events (reference)
6
6
 
7
- Askell sends signed webhook calls to your configured endpoints.
7
+ Askell POSTs signed JSON to each URL you register. Verify \`Hook-HMAC\` before parsing.
8
8
 
9
9
  Headers:
10
- - Hook-HMAC: base64 HMAC-SHA512 of the raw body
11
- - Hook-Event: event type identifier
12
- - Hook-API-Version: v1 for legacy plan/subscription webhooks, v2 for subscription contract webhooks
13
-
14
- Event families:
15
- - subscription.* (legacy v1): subscription.created, subscription.changed, subscription.renewed
16
- - subscription_contract.* (v2): subscription_contract.created, subscription_contract.changed,
17
- subscription_contract.renewed, subscription_contract.migrated (legacy subscription migrated to a
18
- V2 contract; payload includes legacy_subscription_ids and migration_effective_at)
19
- - billing_run.* (v2): billing_run.created, billing_run.changed, billing_run.succeeded,
20
- billing_run.failed, billing_run.retry
21
- - customer.*: customer.created, customer.changed
22
- - payment.*: payment.created, payment.changed, payment.retry
23
- - checkout.*: checkout.created, checkout.changed
24
-
25
- Notes:
26
- - During migration to V2, legacy subscription.* aliases are NOT sent for the new contract (event data
27
- is based on SubscriptionContract, not Subscription), and subscription.canceled is not sent solely
28
- because billing moved to a V2 contract.
29
-
30
- Webhook management is available via v1 API:
31
- - GET/POST /webhooks/
32
- - GET/PATCH/DELETE /webhooks/{id}/
33
-
34
- This MCP server exposes webhook management through askell_call and askell_list_webhooks.
10
+ - Hook-HMAC: base64 HMAC-SHA512 of the **raw body** (secret = \`hmac_secret\` from webhook create)
11
+ - Hook-Event: event type (\`subscription.renewed\`, \`payment.changed\`, or a family wildcard \`subscription.*\`)
12
+ - Hook-API-Version: \`v1\` for plan/subscription/customer/payment/checkout, \`v2\` for subscription_contract / billing_run
13
+
14
+ ## Body shape (not in OpenAPI)
15
+
16
+ JSON body **is the event object**. It is **not** \`{ event, data }\`.
17
+
18
+ Upstream swagger used to document a dummy \`POST /your-webhook-url/\` with \`SubscriptionMultiLite\` (\`{ customer, subscriptions[] }\`). \`sync-specs\` strips that path. Inbound payloads are still undocumented in OpenAPI — this resource is the overlay.
19
+
20
+ Rare historical payloads used \`{ event, data, ref?, sender? }\`. If both \`event\` and \`data\` are objects, use \`data\`.
21
+
22
+ ## Registering endpoints (v1 management API)
23
+
24
+ - GET/POST \`/webhooks/\` · GET/PUT/PATCH/DELETE \`/webhooks/{id}/\` (secret key)
25
+ - Create body: \`{ url, event }\` (\`event\` may be a specific type or a family wildcard like \`payment.*\`)
26
+ - Create/get response includes \`hmac_secret\` (store it; Askell will not show it again in a useful way if you lose it) and \`hmac_digest\` (typically \`SHA512\`)
27
+
28
+ Tools: \`askell_list_webhooks\`, \`askell_call\` (GET), \`askell_mutate\` (POST/PUT/PATCH/DELETE).
29
+
30
+ ## Event families and payload fields
31
+
32
+ Live REST \`Subscription\` objects have extra fields the OpenAPI schema omits. Webhook bodies differ slightly from GET \`/subscriptions/\` (notably \`last_billing_log\` vs \`billing_logs[]\`).
33
+
34
+ ### subscription.* (v1)
35
+ \`subscription.created\`, \`subscription.changed\`, \`subscription.renewed\`
36
+
37
+ \`id\`, \`plan\` (no \`payment_processor\` / membership-card / wallet-pass fields), \`customer\` (numeric id), \`customer_reference\`, \`trial_end\`, \`start_date\`, \`ended_at\`, \`reference\`, \`active\`, \`meta\` (JSON **string**, often \`"{}"\`), \`description\`, \`active_until\`, \`is_on_trial\`, \`token\`, \`is_failing\`, \`last_billing_log\` (single object or null — not \`billing_logs[]\`), \`delivery_address\`, \`amount\`. Live cancel/change events also send \`cancelled\`, \`cancel_date\`, \`has_payment_plan\`, \`payment_plan_info\`.
38
+
39
+ V2 migration: \`subscription.*\` is **not** aliased onto the new contract (payload is \`SubscriptionContract\`). \`subscription.canceled\` is not sent merely because billing moved to a V2 contract.
40
+
41
+ ### subscription_contract.* (v2)
42
+ \`created\`, \`changed\`, \`renewed\`, \`migrated\`
43
+
44
+ \`id\`, \`customer\`, \`state\`, \`billing_anchor_at\`, \`next_billing_at\`, \`cancel_at\`, \`cancel_at_period_end\`, \`canceled_at\`, \`ended_at\`, \`currency\`, \`recurring\`, \`legacy_subscription\`, \`legacy_subscription_ids\`, \`migration_effective_at\`, \`billing_managed_by\`, \`created_at\`, \`updated_at\`.
45
+
46
+ ### billing_run.* (v2)
47
+ \`created\`, \`changed\`, \`succeeded\`, \`failed\`, \`retry\`
48
+
49
+ \`id\`, \`contract\`, \`period_start_at\`, \`period_end_at\`, \`state\`, \`currency\`, \`subtotal_amount\`, \`tax_amount\`, \`total_amount\`, \`attempt_count\`, \`max_attempts\`, \`next_retry_at\`, \`last_attempt_at\`, \`transaction\`, \`created_at\`, \`updated_at\`.
50
+
51
+ ### customer.* (v1)
52
+ \`created\`, \`changed\` — same shape as GET \`/customers/{ref}/\` (\`id\`, names, \`email\`, \`phone\`, \`customer_reference\`, address fields, \`payment_method[]\`).
53
+
54
+ ### payment.* (v1)
55
+ \`created\`, \`changed\`, \`retry\`
56
+
57
+ \`uuid\`, \`amount\`, \`currency\`, \`description\`, \`reference\`, \`state\` (\`pending\` | \`settled\` | \`failed\` | \`retrying\`), \`created_at\`, \`updated_at\`, \`transactions[]\`.
58
+
59
+ ### checkout.* (v1)
60
+ \`created\`, \`changed\` — \`token\`, \`checkout_url\`, \`status\`.
35
61
  `;
36
62
 
37
63
  export function registerResources(server: McpServer): void {
@@ -79,7 +105,7 @@ export function registerResources(server: McpServer): void {
79
105
  'askell://docs/webhook-events',
80
106
  {
81
107
  title: 'Askell webhook events',
82
- description: 'Webhook event types and management overview',
108
+ description: 'Inbound webhook events, payload shapes, HMAC, and /webhooks/ management',
83
109
  mimeType: 'text/markdown',
84
110
  },
85
111
  async () => ({
package/src/server.ts CHANGED
@@ -4,7 +4,7 @@ import { AskellClient } from './client/askell-client.ts';
4
4
  import type { AppConfig } from './config.ts';
5
5
  import { registerResources } from './resources/register.ts';
6
6
  import { registerAnalysisTools } from './tools/analysis.ts';
7
- import { registerCallTool } from './tools/call.ts';
7
+ import { registerCallTools } from './tools/call.ts';
8
8
  import { registerDiscoveryTools } from './tools/discovery.ts';
9
9
 
10
10
  const SERVER_INSTRUCTIONS = `Askell MCP server for payment and subscription operations.
@@ -12,7 +12,7 @@ const SERVER_INSTRUCTIONS = `Askell MCP server for payment and subscription oper
12
12
  Workflow:
13
13
  1. Use askell_list_operations and askell_describe_operation to discover endpoints, parameters, and auth requirements.
14
14
  2. Prefer analysis tools (askell_customer_overview, askell_contract_overview, askell_billing_run_triage, askell_paginate_all, askell_list_webhooks) for common support tasks.
15
- 3. Use askell_call only when no dedicated tool covers the request.
15
+ 3. Use askell_call (GET/HEAD) or askell_mutate (POST/PUT/PATCH/DELETE) when no dedicated tool covers the request.
16
16
 
17
17
  API models:
18
18
  - v1 (legacy): PlanVariant + Subscription at paths like /subscriptions/, /customers/. Still supported for existing integrations.
@@ -40,18 +40,19 @@ Auth:
40
40
  - Only temporary payment method and checkout status endpoints use the public key.
41
41
 
42
42
  Safety:
43
- - Mutating askell_call requests require operator approval when requireMutationApproval is enabled.
43
+ - Writes go through askell_mutate (destructiveHint). Reads go through askell_call (readOnlyHint).
44
+ - mutationGate=auto (default): confirmation form only if this request's envelope declared form elicitation; otherwise the client's own tool-allow UI is the gate. elicit always returns a form (SDK refuses if the client cannot fulfil it). off never asks.
44
45
  - Large list responses are compacted (index of id/dates/plan/customer) to fit responseMaxBytes before dropping rows; check meta.truncatedByMaxBytes, meta.compacted, and meta.compactedMode.
45
46
 
46
47
  Resources:
47
48
  - askell://spec/v1 and askell://spec/v2 — bundled OpenAPI
48
- - askell://docs/webhook-events — webhook event types including V2 subscription_contract.* and billing_run.*`;
49
+ - askell://docs/webhook-events — inbound webhook payloads (not in OpenAPI; dummy /your-webhook-url/ is stripped on sync), HMAC-SHA512, /webhooks/ hmac_secret`;
49
50
 
50
51
  export function createServer(config: AppConfig): McpServer {
51
52
  const server = new McpServer(
52
53
  {
53
54
  name: 'askell-mcp',
54
- version: '0.1.0',
55
+ version: '0.3.1',
55
56
  },
56
57
  {
57
58
  instructions: SERVER_INSTRUCTIONS,
@@ -61,7 +62,7 @@ export function createServer(config: AppConfig): McpServer {
61
62
  const client = new AskellClient(config);
62
63
 
63
64
  registerDiscoveryTools(server);
64
- registerCallTool(server, client, config);
65
+ registerCallTools(server, client, config);
65
66
  registerAnalysisTools(server, client);
66
67
  registerResources(server);
67
68
 
package/src/tools/call.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  acceptedContent,
3
3
  inputRequired,
4
+ inputResponse,
4
5
  type CallToolResult,
5
6
  type InputRequiredResult,
6
7
  type McpServer,
@@ -9,18 +10,19 @@ import * as z from 'zod';
9
10
 
10
11
  import { AskellClient, type AskellRequest } from '../client/askell-client.ts';
11
12
  import { normalizeApiPath } from '../client/paths.ts';
12
- import { isMutatingMethod } from '../client/response-formatter.ts';
13
13
  import type { AppConfig } from '../config.ts';
14
14
  import { operationRegistry } from '../openapi/registry.ts';
15
+ import {
16
+ clientSupportsFormElicitation,
17
+ decideMutationGate,
18
+ readClientCapabilities,
19
+ } from './mutation-gate.ts';
15
20
 
16
21
  const confirmationSchema = z.object({
17
22
  confirm: z.boolean().meta({ title: 'Confirm mutating Askell API request' }),
18
23
  });
19
24
 
20
- const callInputSchema = z.object({
21
- method: z
22
- .enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])
23
- .describe('HTTP method'),
25
+ const sharedCallFields = {
24
26
  path: z
25
27
  .string()
26
28
  .describe('API path relative to apiBaseUrl, e.g. /v2/subscription-contracts/'),
@@ -33,11 +35,34 @@ const callInputSchema = z.object({
33
35
  .enum(['secret', 'public'])
34
36
  .default('secret')
35
37
  .describe('Which configured API key to use'),
38
+ };
39
+
40
+ const callInputSchema = z.object({
41
+ method: z.enum(['GET', 'HEAD']).describe('HTTP method (read-only)'),
42
+ ...sharedCallFields,
36
43
  });
37
44
 
38
- function buildApprovalMessage(input: z.infer<typeof callInputSchema>): string {
45
+ const mutateInputSchema = z.object({
46
+ method: z
47
+ .enum(['POST', 'PUT', 'PATCH', 'DELETE'])
48
+ .describe('HTTP method (mutating)'),
49
+ ...sharedCallFields,
50
+ });
51
+
52
+ type CallInput = z.infer<typeof callInputSchema>;
53
+ type MutateInput = z.infer<typeof mutateInputSchema>;
54
+
55
+ type ToolCtx = {
56
+ mcpReq: {
57
+ signal: AbortSignal;
58
+ envelope?: unknown;
59
+ inputResponses?: Record<string, unknown>;
60
+ };
61
+ };
62
+
63
+ function buildApprovalMessage(input: MutateInput): string {
39
64
  const lines = [
40
- `Approve this Askell API request?`,
65
+ 'Approve this Askell API request?',
41
66
  '',
42
67
  `${input.method} ${input.path}`,
43
68
  `apiKeyKind: ${input.apiKeyKind}`,
@@ -54,7 +79,89 @@ function buildApprovalMessage(input: z.infer<typeof callInputSchema>): string {
54
79
  return lines.join('\n');
55
80
  }
56
81
 
57
- export function registerCallTool(
82
+ async function executeAskellRequest(
83
+ client: AskellClient,
84
+ input: CallInput | MutateInput,
85
+ signal: AbortSignal,
86
+ ): Promise<CallToolResult> {
87
+ 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
+ const request: AskellRequest = {
96
+ method: input.method,
97
+ path,
98
+ query: input.query,
99
+ body: input.body,
100
+ apiKeyKind: input.apiKeyKind ?? known?.apiKeyKind ?? 'secret',
101
+ signal,
102
+ };
103
+
104
+ try {
105
+ const response = await client.request(request);
106
+ return {
107
+ content: [{ type: 'text', text: response.text }],
108
+ isError: !response.ok,
109
+ };
110
+ } catch (error) {
111
+ const message =
112
+ error instanceof Error ? error.message : 'Unknown Askell API error';
113
+ return {
114
+ content: [{ type: 'text', text: message }],
115
+ isError: true,
116
+ };
117
+ }
118
+ }
119
+
120
+ function mutationConfirmation(
121
+ config: AppConfig,
122
+ input: MutateInput,
123
+ ctx: ToolCtx,
124
+ ): CallToolResult | InputRequiredResult | undefined {
125
+ const declined = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
126
+ if (
127
+ declined.kind === 'elicit' &&
128
+ (declined.action === 'decline' || declined.action === 'cancel')
129
+ ) {
130
+ return {
131
+ content: [{ type: 'text', text: 'Mutation cancelled by operator.' }],
132
+ isError: true,
133
+ };
134
+ }
135
+
136
+ const confirmed = acceptedContent(
137
+ ctx.mcpReq.inputResponses,
138
+ 'confirm',
139
+ confirmationSchema,
140
+ );
141
+
142
+ const decision = decideMutationGate({
143
+ gate: config.mutationGate,
144
+ alreadyConfirmed: confirmed?.confirm === true,
145
+ supportsFormElicitation: clientSupportsFormElicitation(
146
+ readClientCapabilities(ctx.mcpReq.envelope),
147
+ ),
148
+ });
149
+
150
+ if (decision.action === 'execute') {
151
+ return undefined;
152
+ }
153
+
154
+ return inputRequired({
155
+ inputRequests: {
156
+ confirm: inputRequired.elicit({
157
+ message: buildApprovalMessage(input),
158
+ requestedSchema: confirmationSchema,
159
+ }),
160
+ },
161
+ });
162
+ }
163
+
164
+ export function registerCallTools(
58
165
  server: McpServer,
59
166
  client: AskellClient,
60
167
  config: AppConfig,
@@ -62,10 +169,29 @@ export function registerCallTool(
62
169
  server.registerTool(
63
170
  'askell_call',
64
171
  {
65
- title: 'Call Askell API',
172
+ title: 'Call Askell API (read)',
66
173
  description:
67
- 'Execute any Askell API endpoint (v1 or v2). Mutating requests require operator approval when requireMutationApproval is enabled. Use askell_list_operations and askell_describe_operation first to discover paths and parameters.',
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.',
68
175
  inputSchema: callInputSchema,
176
+ annotations: {
177
+ readOnlyHint: true,
178
+ destructiveHint: false,
179
+ idempotentHint: true,
180
+ openWorldHint: true,
181
+ },
182
+ },
183
+ async (input, ctx): Promise<CallToolResult> => {
184
+ return executeAskellRequest(client, input, ctx.mcpReq.signal);
185
+ },
186
+ );
187
+
188
+ server.registerTool(
189
+ 'askell_mutate',
190
+ {
191
+ title: 'Mutate Askell API',
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.',
194
+ inputSchema: mutateInputSchema,
69
195
  annotations: {
70
196
  readOnlyHint: false,
71
197
  destructiveHint: true,
@@ -74,56 +200,11 @@ export function registerCallTool(
74
200
  },
75
201
  },
76
202
  async (input, ctx): Promise<CallToolResult | InputRequiredResult> => {
77
- const mutating = isMutatingMethod(input.method);
78
-
79
- if (config.requireMutationApproval && mutating) {
80
- const confirmed = acceptedContent(
81
- ctx.mcpReq.inputResponses,
82
- 'confirm',
83
- confirmationSchema,
84
- );
85
-
86
- if (confirmed?.confirm !== true) {
87
- return inputRequired({
88
- inputRequests: {
89
- confirm: inputRequired.elicit({
90
- message: buildApprovalMessage(input),
91
- requestedSchema: confirmationSchema,
92
- }),
93
- },
94
- });
95
- }
96
- }
97
-
98
- const path = normalizeApiPath(input.path);
99
- const known = operationRegistry.find({
100
- method: input.method,
101
- pathPrefix: path,
102
- }).find((operation) => operation.path === path);
103
-
104
- const request: AskellRequest = {
105
- method: input.method,
106
- path,
107
- query: input.query,
108
- body: input.body,
109
- apiKeyKind: input.apiKeyKind ?? known?.apiKeyKind ?? 'secret',
110
- signal: ctx.mcpReq.signal,
111
- };
112
-
113
- try {
114
- const response = await client.request(request);
115
- return {
116
- content: [{ type: 'text', text: response.text }],
117
- isError: !response.ok,
118
- };
119
- } catch (error) {
120
- const message =
121
- error instanceof Error ? error.message : 'Unknown Askell API error';
122
- return {
123
- content: [{ type: 'text', text: message }],
124
- isError: true,
125
- };
203
+ const gated = mutationConfirmation(config, input, ctx);
204
+ if (gated) {
205
+ return gated;
126
206
  }
207
+ return executeAskellRequest(client, input, ctx.mcpReq.signal);
127
208
  },
128
209
  );
129
210
  }
@@ -0,0 +1,74 @@
1
+ import {
2
+ CLIENT_CAPABILITIES_META_KEY,
3
+ type ClientCapabilities,
4
+ } from '@modelcontextprotocol/server';
5
+
6
+ import type { MutationGate } from '../config.ts';
7
+
8
+ export type MutationGateDecision = { action: 'execute' } | { action: 'elicit' };
9
+
10
+ function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return value != null && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+
14
+ /**
15
+ * Per-request client capabilities (protocol 2026-07-28).
16
+ *
17
+ * Source of truth is `ctx.mcpReq.envelope[CLIENT_CAPABILITIES_META_KEY]` —
18
+ * reserved `io.modelcontextprotocol/*` keys are lifted out of `_meta` before
19
+ * the handler runs. Do not use deprecated `Server.getClientCapabilities()`:
20
+ * on 2026 stdio (`serveStdio` factory) it is always undefined (no `initialize`).
21
+ *
22
+ * 2025-era clients do not send this envelope; `undefined` here means "this
23
+ * request did not declare elicitation", so `auto` falls through to the
24
+ * client's own tool-allow UI.
25
+ *
26
+ * @see https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md
27
+ */
28
+ export function readClientCapabilities(
29
+ envelope: unknown,
30
+ ): ClientCapabilities | undefined {
31
+ if (!isRecord(envelope)) {
32
+ return undefined;
33
+ }
34
+ const value = envelope[CLIENT_CAPABILITIES_META_KEY];
35
+ return isRecord(value) ? (value as ClientCapabilities) : undefined;
36
+ }
37
+
38
+ /** Form-mode elicitation: `elicitation: {}` (pre-mode) or `elicitation.form`. URL-only is not form. */
39
+ export function clientSupportsFormElicitation(
40
+ capabilities: ClientCapabilities | undefined,
41
+ ): boolean {
42
+ const elicitation = capabilities?.elicitation;
43
+ if (elicitation == null) {
44
+ return false;
45
+ }
46
+ if (elicitation.form != null) {
47
+ return true;
48
+ }
49
+ const keys = Object.keys(elicitation);
50
+ if (elicitation.url != null && keys.every((key) => key === 'url')) {
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * auto — elicit only if this request's envelope declared form elicitation.
58
+ * elicit — always return inputRequired; the SDK era-gates the wire
59
+ * (2026 envelope / 2025 initialize via the legacy shim).
60
+ * off — never elicit.
61
+ */
62
+ export function decideMutationGate(options: {
63
+ gate: MutationGate;
64
+ alreadyConfirmed: boolean;
65
+ supportsFormElicitation: boolean;
66
+ }): MutationGateDecision {
67
+ if (options.alreadyConfirmed || options.gate === 'off') {
68
+ return { action: 'execute' };
69
+ }
70
+ if (options.gate === 'elicit' || options.supportsFormElicitation) {
71
+ return { action: 'elicit' };
72
+ }
73
+ return { action: 'execute' };
74
+ }