askell-mcp 0.4.13 → 0.4.15

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
@@ -19,34 +19,79 @@ In the Askell dashboard, copy your **private (secret)** API key. Optionally also
19
19
 
20
20
  ### 2. Add to your MCP client
21
21
 
22
- Prefer **two server entries** if you have both production and sandbox keys. Tool names are the same on both; the client distinguishes them by the `mcp.json` key (`askell-prod` vs `askell-sandbox`). Set `ASKELL_ENV` — the server picks the host. Each instance's instructions include the environment it is talking to.
22
+ Prefer **two server entries** if you have both production and sandbox keys. Tool names are the same on both; the client distinguishes them by the server key (`askell-prod` vs `askell-sandbox`). Each instance's instructions include the environment it is talking to.
23
23
 
24
- **With Bun** (`bunx`):
24
+ Put keys in gitignored dotenv files, not in JSON. Copy [`.env.example`](./.env.example):
25
+
26
+ - `.env` — production (`ASKELL_ENV=production` and that dashboard's keys)
27
+ - `.env.sandbox` — sandbox (`ASKELL_ENV=sandbox` and that dashboard's keys)
28
+
29
+ Bun does not auto-load `.env.sandbox`. `--no-env-file` stops the sandbox process from also reading a production `.env` that happens to sit in the cwd.
30
+
31
+ #### Cursor
32
+
33
+ Project file: `.cursor/mcp.json`. [`mcp.json.example`](./mcp.json.example) is this shape. `${workspaceFolder}` is the directory that contains that `mcp.json` (the repo root when the file is `.cursor/mcp.json`). In `~/.cursor/mcp.json`, use an absolute `envFile` path.
34
+
35
+ **With Bun:**
25
36
 
26
37
  ```json
27
38
  {
28
39
  "mcpServers": {
29
40
  "askell-prod": {
30
41
  "command": "bunx",
31
- "args": ["-y", "askell-mcp@latest"],
32
- "env": {
33
- "ASKELL_ENV": "production",
34
- "ASKELL_PRIVATE_API_KEY": "your_production_secret_api_key"
35
- }
42
+ "args": ["--no-env-file", "x", "askell-mcp"],
43
+ "envFile": "${workspaceFolder}/.env"
36
44
  },
37
45
  "askell-sandbox": {
38
46
  "command": "bunx",
39
- "args": ["-y", "askell-mcp@latest"],
40
- "env": {
41
- "ASKELL_ENV": "sandbox",
42
- "ASKELL_PRIVATE_API_KEY": "your_sandbox_secret_api_key"
43
- }
47
+ "args": ["--no-env-file", "x", "askell-mcp"],
48
+ "envFile": "${workspaceFolder}/.env.sandbox"
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ **With a binary** (download `askell-mcp-<os>-<arch>` from [Releases](https://github.com/Neschadin/askell-mcp/releases), then `chmod +x`). Same `envFile`; the binary reads the environment Cursor injects:
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "askell-prod": {
60
+ "command": "/absolute/path/to/askell-mcp-linux-x64",
61
+ "envFile": "${workspaceFolder}/.env"
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ Reload the window after saving.
68
+
69
+ #### Claude Desktop
70
+
71
+ Config file:
72
+
73
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
74
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
75
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
76
+
77
+ No `envFile` field. The desktop process cwd is not your repo, so a relative `.env` path does not resolve. With Bun, pass an absolute `--env-file`:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "askell-prod": {
83
+ "command": "bunx",
84
+ "args": ["--no-env-file", "--env-file=/absolute/path/.env", "x", "askell-mcp"]
85
+ },
86
+ "askell-sandbox": {
87
+ "command": "bunx",
88
+ "args": ["--no-env-file", "--env-file=/absolute/path/.env.sandbox", "x", "askell-mcp"]
44
89
  }
45
90
  }
46
91
  }
47
92
  ```
48
93
 
49
- **With a binary** (download `askell-mcp-<os>-<arch>` from [Releases](https://github.com/Neschadin/askell-mcp/releases), then `chmod +x`):
94
+ A binary has no `--env-file`. Put the keys in `env` (plaintext in that JSON file):
50
95
 
51
96
  ```json
52
97
  {
@@ -55,16 +100,19 @@ Prefer **two server entries** if you have both production and sandbox keys. Tool
55
100
  "command": "/absolute/path/to/askell-mcp-linux-x64",
56
101
  "env": {
57
102
  "ASKELL_ENV": "production",
58
- "ASKELL_PRIVATE_API_KEY": "your_production_secret_api_key"
103
+ "ASKELL_PRIVATE_API_KEY": "your_production_secret_api_key",
104
+ "ASKELL_PUBLIC_API_KEY": "your_production_public_api_key_optional"
59
105
  }
60
106
  }
61
107
  }
62
108
  }
63
109
  ```
64
110
 
65
- Example file: [`mcp.json.example`](./mcp.json.example).
111
+ Quit Claude Desktop completely and reopen it. Saving the file is not enough.
112
+
113
+ #### Claude Code
66
114
 
67
- Restart the client after saving.
115
+ Project `.mcp.json` expands `${VAR}` from the environment of the process that launched `claude`. It does not load a dotenv file. The Bun `--env-file` args from the Desktop section work here as well; a relative path is fine when you start `claude` from the repo. `${ASKELL_PRIVATE_API_KEY}` inside `env` only works when that variable is already exported in that environment. A `.env` file alone is not read.
68
116
 
69
117
  ## Configuration
70
118
 
package/mcp.json.example CHANGED
@@ -2,21 +2,13 @@
2
2
  "mcpServers": {
3
3
  "askell-prod": {
4
4
  "command": "bunx",
5
- "args": ["-y", "askell-mcp@latest"],
6
- "env": {
7
- "ASKELL_ENV": "production",
8
- "ASKELL_PRIVATE_API_KEY": "your_production_secret_api_key",
9
- "ASKELL_PUBLIC_API_KEY": "your_production_public_api_key_optional"
10
- }
5
+ "args": ["--no-env-file", "x", "askell-mcp"],
6
+ "envFile": "${workspaceFolder}/.env"
11
7
  },
12
8
  "askell-sandbox": {
13
9
  "command": "bunx",
14
- "args": ["-y", "askell-mcp@latest"],
15
- "env": {
16
- "ASKELL_ENV": "sandbox",
17
- "ASKELL_PRIVATE_API_KEY": "your_sandbox_secret_api_key",
18
- "ASKELL_PUBLIC_API_KEY": "your_sandbox_public_api_key_optional"
19
- }
10
+ "args": ["--no-env-file", "x", "askell-mcp"],
11
+ "envFile": "${workspaceFolder}/.env.sandbox"
20
12
  }
21
13
  }
22
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askell-mcp",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "MCP server for the Askell payment and subscription API (Bun + stdio)",
5
5
  "author": "Neschadin Oleksandr",
6
6
  "license": "MIT",
@@ -66,7 +66,7 @@
66
66
  "typescript": "7.0.2"
67
67
  },
68
68
  "dependencies": {
69
- "@modelcontextprotocol/server": "2.0.0",
69
+ "@modelcontextprotocol/server": "2.1.0",
70
70
  "zod": "4.6.5"
71
71
  }
72
72
  }
@@ -27,7 +27,27 @@ export class AskellClient {
27
27
  this.baseUrl = normalizeBaseUrl(config.apiBaseUrl);
28
28
  }
29
29
 
30
- async request(input: AskellRequest): Promise<FormattedResponse & { ok: boolean; status: number }> {
30
+ private apiKeyFor(kind: ApiKeyKind | undefined): string {
31
+ const apiKeyKind = kind ?? 'secret';
32
+ const apiKey =
33
+ apiKeyKind === 'public'
34
+ ? this.config.publicApiKey
35
+ : this.config.secretApiKey;
36
+
37
+ if (!apiKey) {
38
+ throw new Error(
39
+ apiKeyKind === 'public'
40
+ ? 'publicApiKey is not configured'
41
+ : 'secretApiKey is not configured',
42
+ );
43
+ }
44
+
45
+ return apiKey;
46
+ }
47
+
48
+ async request(
49
+ input: AskellRequest,
50
+ ): Promise<FormattedResponse & { ok: boolean; status: number }> {
31
51
  const method = input.method.toUpperCase();
32
52
  const path = normalizeApiPath(input.path);
33
53
  const url = new URL(`${this.baseUrl}${path}`);
@@ -49,19 +69,7 @@ export class AskellClient {
49
69
  }
50
70
  }
51
71
 
52
- const apiKeyKind = input.apiKeyKind ?? 'secret';
53
- const apiKey =
54
- apiKeyKind === 'public'
55
- ? this.config.publicApiKey
56
- : this.config.secretApiKey;
57
-
58
- if (!apiKey) {
59
- throw new Error(
60
- apiKeyKind === 'public'
61
- ? 'publicApiKey is not configured'
62
- : 'secretApiKey is not configured',
63
- );
64
- }
72
+ const apiKey = this.apiKeyFor(input.apiKeyKind);
65
73
 
66
74
  const started = performance.now();
67
75
  const response = await fetch(url, {
@@ -74,8 +82,7 @@ export class AskellClient {
74
82
  : {}),
75
83
  ...input.headers,
76
84
  },
77
- body:
78
- input.body !== undefined ? JSON.stringify(input.body) : undefined,
85
+ body: input.body !== undefined ? JSON.stringify(input.body) : undefined,
79
86
  signal: input.signal,
80
87
  });
81
88
 
@@ -108,19 +115,7 @@ export class AskellClient {
108
115
  signal?: AbortSignal;
109
116
  }): Promise<FormattedResponse & { ok: boolean; status: number }> {
110
117
  const maxPages = input.maxPages ?? 20;
111
- const apiKeyKind = input.apiKeyKind ?? 'secret';
112
- const apiKey =
113
- apiKeyKind === 'public'
114
- ? this.config.publicApiKey
115
- : this.config.secretApiKey;
116
-
117
- if (!apiKey) {
118
- throw new Error(
119
- apiKeyKind === 'public'
120
- ? 'publicApiKey is not configured'
121
- : 'secretApiKey is not configured',
122
- );
123
- }
118
+ const apiKey = this.apiKeyFor(input.apiKeyKind);
124
119
 
125
120
  const collected: unknown[] = [];
126
121
  let nextUrl: URL | null = null;
@@ -1,7 +1,5 @@
1
- import {
2
- redactSensitiveFields,
3
- redactSecretsInText,
4
- } from './redact.ts';
1
+ import { isRecord } from '../is-record.ts';
2
+ import { redactSensitiveFields, redactSecretsInText } from './redact.ts';
5
3
 
6
4
  export interface FormattedResponse {
7
5
  text: string;
@@ -41,11 +39,8 @@ export function limitText(
41
39
  }
42
40
 
43
41
  function summarizeListItem(item: unknown): unknown {
44
- if (item == null || typeof item !== 'object' || Array.isArray(item)) {
45
- return item;
46
- }
42
+ if (!isRecord(item)) return item;
47
43
 
48
- const obj = item as Record<string, unknown>;
49
44
  const out: Record<string, unknown> = {};
50
45
  const scalarKeys = [
51
46
  'id',
@@ -74,13 +69,13 @@ function summarizeListItem(item: unknown): unknown {
74
69
  ] as const;
75
70
 
76
71
  for (const key of scalarKeys) {
77
- if (key in obj) {
78
- out[key] = obj[key];
72
+ if (key in item) {
73
+ out[key] = item[key];
79
74
  }
80
75
  }
81
76
 
82
- if (obj.customer && typeof obj.customer === 'object') {
83
- const customer = obj.customer as Record<string, unknown>;
77
+ if (item.customer && typeof item.customer === 'object') {
78
+ const customer = item.customer as Record<string, unknown>;
84
79
  out.customer = {
85
80
  id: customer.id,
86
81
  customer_reference:
@@ -88,66 +83,63 @@ function summarizeListItem(item: unknown): unknown {
88
83
  };
89
84
  }
90
85
 
91
- if (obj.plan && typeof obj.plan === 'object') {
92
- const plan = obj.plan as Record<string, unknown>;
86
+ if (item.plan && typeof item.plan === 'object') {
87
+ const plan = item.plan as Record<string, unknown>;
93
88
  out.plan = {
94
89
  id: plan.id,
95
90
  name: plan.name,
96
91
  };
97
92
  }
98
93
 
99
- return Object.keys(out).length > 0 ? out : obj;
94
+ return Object.keys(out).length > 0 ? out : item;
100
95
  }
101
96
 
102
97
  /** Tight projection for analytical list queries (dates, plan name, customer). */
103
98
  function indexListItem(item: unknown): unknown {
104
- if (item == null || typeof item !== 'object' || Array.isArray(item)) {
105
- return item;
106
- }
99
+ if (!isRecord(item)) return item;
107
100
 
108
- const obj = item as Record<string, unknown>;
109
101
  const out: Record<string, unknown> = {};
110
102
 
111
- if ('id' in obj) {
112
- out.id = obj.id;
103
+ if ('id' in item) {
104
+ out.id = item.id;
113
105
  }
114
106
 
115
- if ('start_date' in obj) {
116
- out.start_date = obj.start_date;
117
- } else if ('created_at' in obj) {
118
- out.created_at = obj.created_at;
107
+ if ('start_date' in item) {
108
+ out.start_date = item.start_date;
109
+ } else if ('created_at' in item) {
110
+ out.created_at = item.created_at;
119
111
  }
120
112
 
121
- if ('ended_at' in obj && obj.ended_at != null) {
122
- out.ended_at = obj.ended_at;
113
+ if ('ended_at' in item && item.ended_at != null) {
114
+ out.ended_at = item.ended_at;
123
115
  }
124
116
 
125
- const plan = obj.plan;
117
+ const plan = item.plan;
126
118
  if (typeof plan === 'string' || typeof plan === 'number') {
127
119
  out.plan = plan;
128
120
  } else if (plan && typeof plan === 'object' && 'name' in plan) {
129
121
  out.plan = (plan as { name: unknown }).name;
130
- } else if ('name' in obj && typeof obj.name === 'string') {
131
- out.name = obj.name;
122
+ } else if ('name' in item && typeof item.name === 'string') {
123
+ out.name = item.name;
132
124
  }
133
125
 
134
- if (typeof obj.code === 'string') {
135
- out.code = obj.code;
126
+ if (typeof item.code === 'string') {
127
+ out.code = item.code;
136
128
  }
137
129
 
138
130
  const customerRef =
139
- obj.customer_reference ??
140
- (obj.customer && typeof obj.customer === 'object'
141
- ? ((obj.customer as Record<string, unknown>).customer_reference ??
142
- (obj.customer as Record<string, unknown>).reference ??
143
- (obj.customer as Record<string, unknown>).id)
131
+ item.customer_reference ??
132
+ (item.customer && typeof item.customer === 'object'
133
+ ? ((item.customer as Record<string, unknown>).customer_reference ??
134
+ (item.customer as Record<string, unknown>).reference ??
135
+ (item.customer as Record<string, unknown>).id)
144
136
  : undefined);
145
137
  if (customerRef !== undefined) {
146
138
  out.customer_reference = customerRef;
147
139
  }
148
140
 
149
- if (typeof obj.email === 'string') {
150
- out.email = obj.email;
141
+ if (typeof item.email === 'string') {
142
+ out.email = item.email;
151
143
  }
152
144
 
153
145
  return Object.keys(out).length > 0 ? out : summarizeListItem(item);
@@ -172,9 +164,7 @@ function serializeListPayload(
172
164
  body: items.slice(0, returnedCount),
173
165
  };
174
166
 
175
- return pretty
176
- ? JSON.stringify(payload, null, 2)
177
- : JSON.stringify(payload);
167
+ return pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
178
168
  }
179
169
 
180
170
  function maxFittingCount(
@@ -304,11 +294,7 @@ export function buildBoundedListPayload(input: {
304
294
 
305
295
  const text = serializeListPayload(
306
296
  status,
307
- compactMeta(
308
- meta,
309
- 'index',
310
- 'Response too large; returning metadata only',
311
- ),
297
+ compactMeta(meta, 'index', 'Response too large; returning metadata only'),
312
298
  [],
313
299
  0,
314
300
  true,
@@ -375,12 +361,7 @@ export function formatApiResponse(
375
361
  }
376
362
 
377
363
  function pickHeaders(headers: Headers): Record<string, string> {
378
- const interesting = [
379
- 'content-type',
380
- 'date',
381
- 'x-request-id',
382
- 'retry-after',
383
- ];
364
+ const interesting = ['content-type', 'date', 'x-request-id', 'retry-after'];
384
365
  const out: Record<string, string> = {};
385
366
 
386
367
  for (const name of interesting) {
package/src/config.ts CHANGED
@@ -139,29 +139,15 @@ Keys are per host. ASKELL_API_BASE_URL is only for a custom/local API.
139
139
  ASKELL_PRIVATE_API_KEY=...
140
140
  ASKELL_PUBLIC_API_KEY=...
141
141
 
142
- Published package (requires Bun) — Cursor / Claude mcp.json (two entries if you use sandbox):
143
- {
144
- "mcpServers": {
145
- "askell-prod": {
146
- "command": "bunx",
147
- "args": ["-y", "askell-mcp@latest"],
148
- "env": {
149
- "ASKELL_ENV": "production",
150
- "ASKELL_PRIVATE_API_KEY": "...",
151
- "ASKELL_PUBLIC_API_KEY": "..."
152
- }
153
- },
154
- "askell-sandbox": {
155
- "command": "bunx",
156
- "args": ["-y", "askell-mcp@latest"],
157
- "env": {
158
- "ASKELL_ENV": "sandbox",
159
- "ASKELL_PRIVATE_API_KEY": "...",
160
- "ASKELL_PUBLIC_API_KEY": "..."
161
- }
162
- }
163
- }
164
- }`;
142
+ Cursor (.cursor/mcp.json) — keys in .env / .env.sandbox, not in JSON:
143
+ "command": "bunx",
144
+ "args": ["--no-env-file", "x", "askell-mcp"],
145
+ "envFile": "\${workspaceFolder}/.env" (sandbox: .env.sandbox)
146
+
147
+ Claude Desktop (no envFile; cwd is not the repo — absolute path):
148
+ "command": "bunx",
149
+ "args": ["--no-env-file", "--env-file=/absolute/path/.env", "x", "askell-mcp"]
150
+ Binary on Claude Desktop: put ASKELL_ENV and ASKELL_PRIVATE_API_KEY in "env".`;
165
151
 
166
152
  function loadConfigFromEnv(): unknown {
167
153
  const env = Bun.env;
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return value != null && typeof value === 'object' && !Array.isArray(value);
3
+ }
@@ -5,6 +5,8 @@
5
5
  * Idempotent: safe to run on an already-patched document.
6
6
  */
7
7
 
8
+ import { isRecord } from '../is-record.ts';
9
+
8
10
  type JsonSchema = Record<string, unknown>;
9
11
  type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
10
12
 
@@ -49,14 +51,7 @@ const RESPONSE_BODIES = [
49
51
  readonly [string, HttpMethod, string, string]
50
52
  >;
51
53
 
52
- const HTTP_METHODS = [
53
- 'get',
54
- 'post',
55
- 'put',
56
- 'patch',
57
- 'delete',
58
- 'head',
59
- ] as const;
54
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'] as const;
60
55
 
61
56
  const nullableString = (maxLength?: number): JsonSchema => ({
62
57
  type: 'string',
@@ -136,10 +131,6 @@ const CUSTOMER_READ_SCHEMA: JsonSchema = {
136
131
  },
137
132
  };
138
133
 
139
- function isRecord(value: unknown): value is Record<string, unknown> {
140
- return value != null && typeof value === 'object' && !Array.isArray(value);
141
- }
142
-
143
134
  function dropWebhookCallOperations(doc: OpenApiDocument): void {
144
135
  const paths = doc.paths;
145
136
  if (!paths) {
@@ -2,21 +2,86 @@ import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
2
2
  import * as z from 'zod';
3
3
 
4
4
  import { AskellClient } from '../client/askell-client.ts';
5
+ import { isRecord } from '../is-record.ts';
5
6
 
6
7
  /** Minimal shape of the handler `ctx` param needed here — avoids depending on the SDK's internal context type name. */
7
8
  type ToolContext = { mcpReq: { signal: AbortSignal } };
8
9
 
10
+ type SafeResult = { ok: boolean; data: unknown; error?: string };
11
+
12
+ const ERROR_DETAIL_MAX = 200;
13
+
14
+ function clip(text: string): string {
15
+ const oneLine = text.replace(/\s+/g, ' ').trim();
16
+ if (oneLine.length <= ERROR_DETAIL_MAX) return oneLine;
17
+
18
+ return `${oneLine.slice(0, ERROR_DETAIL_MAX - 1)}…`;
19
+ }
20
+
21
+ function messageList(value: unknown): string | undefined {
22
+ if (typeof value === 'string') {
23
+ const text = clip(value);
24
+ return text.length > 0 ? text : undefined;
25
+ }
26
+ if (
27
+ !Array.isArray(value) ||
28
+ !value.every((item) => typeof item === 'string')
29
+ ) {
30
+ return undefined;
31
+ }
32
+ const text = clip(value.filter((item) => item.trim().length > 0).join('; '));
33
+ return text.length > 0 ? text : undefined;
34
+ }
35
+
36
+ /**
37
+ * One line for the model. The Askell body stays in `data`.
38
+ * DRF `detail` / `non_field_errors` / flat field errors only — a resource
39
+ * object must not be flattened into the summary.
40
+ */
41
+ export function summarizeApiFailure(status: number, body: unknown): string {
42
+ const detail = apiErrorDetail(body);
43
+ return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
44
+ }
45
+
46
+ function apiErrorDetail(body: unknown): string | undefined {
47
+ if (typeof body === 'string') return messageList(body);
48
+ if (!isRecord(body)) return undefined;
49
+
50
+ if ('detail' in body) {
51
+ const detail = messageList(body.detail);
52
+ if (detail) return detail;
53
+ }
54
+
55
+ if ('non_field_errors' in body) {
56
+ const detail = messageList(body.non_field_errors);
57
+ if (detail) return detail;
58
+ }
59
+
60
+ const parts: string[] = [];
61
+ for (const [key, value] of Object.entries(body)) {
62
+ if (key === 'detail' || key === 'non_field_errors') continue;
63
+ const text = messageList(value);
64
+ if (!text) return undefined;
65
+ parts.push(`${key}: ${text}`);
66
+ }
67
+
68
+ return parts.length > 0 ? clip(parts.join('; ')) : undefined;
69
+ }
70
+
9
71
  async function safeRequest(
10
72
  client: AskellClient,
11
73
  request: Parameters<AskellClient['request']>[0],
12
- ): Promise<{ ok: boolean; data: unknown; error?: string }> {
74
+ ): Promise<SafeResult> {
13
75
  try {
14
76
  const response = await client.request(request);
15
77
  const parsed = JSON.parse(response.text) as { body?: unknown };
78
+ const data = parsed.body ?? parsed;
79
+ if (response.ok) return { ok: true, data };
80
+
16
81
  return {
17
- ok: response.ok,
18
- data: parsed.body ?? parsed,
19
- error: response.ok ? undefined : response.text,
82
+ ok: false,
83
+ data,
84
+ error: summarizeApiFailure(response.status, data),
20
85
  };
21
86
  } catch (error) {
22
87
  return {
@@ -27,6 +92,13 @@ async function safeRequest(
27
92
  }
28
93
  }
29
94
 
95
+ function failedCalls(
96
+ calls: Array<[name: string, result: SafeResult]>,
97
+ ): string[] | undefined {
98
+ const names = calls.filter(([, result]) => !result.ok).map(([name]) => name);
99
+ return names.length > 0 ? names : undefined;
100
+ }
101
+
30
102
  export function registerAnalysisTools(
31
103
  server: McpServer,
32
104
  client: AskellClient,
@@ -74,7 +146,8 @@ export function registerAnalysisTools(
74
146
  content: [
75
147
  {
76
148
  type: 'text',
77
- text: error instanceof Error ? error.message : 'Pagination failed',
149
+ text:
150
+ error instanceof Error ? error.message : 'Pagination failed',
78
151
  },
79
152
  ],
80
153
  isError: true,
@@ -88,7 +161,7 @@ export function registerAnalysisTools(
88
161
  {
89
162
  title: 'Customer overview (v1)',
90
163
  description:
91
- 'Fetch a v1 customer and their v1 subscriptions in one call. Useful for support and billing investigations.',
164
+ 'Fetch a v1 customer and their v1 subscriptions in one call. Useful for support and billing investigations. Result is an error when the customer fetch fails; subscriptions are still included. `failures` lists every call that failed.',
92
165
  inputSchema: z.object({
93
166
  customerReference: z
94
167
  .string()
@@ -99,7 +172,10 @@ export function registerAnalysisTools(
99
172
  openWorldHint: true,
100
173
  },
101
174
  },
102
- async ({ customerReference }, ctx: ToolContext): Promise<CallToolResult> => {
175
+ async (
176
+ { customerReference },
177
+ ctx: ToolContext,
178
+ ): Promise<CallToolResult> => {
103
179
  const [customer, subscriptions] = await Promise.all([
104
180
  safeRequest(client, {
105
181
  method: 'GET',
@@ -113,25 +189,26 @@ export function registerAnalysisTools(
113
189
  }),
114
190
  ]);
115
191
 
192
+ const failures = failedCalls([
193
+ ['customer', customer],
194
+ ['subscriptions', subscriptions],
195
+ ]);
196
+
116
197
  const payload = {
117
198
  customerReference,
118
199
  customer,
119
200
  subscriptions,
201
+ ...(failures ? { failures } : {}),
202
+ ...(!customer.ok
203
+ ? {
204
+ hint: 'Verify the customerReference with askell_call GET /customers/ or askell_paginate_all.',
205
+ }
206
+ : {}),
120
207
  };
121
208
 
122
- const notFoundHint =
123
- !customer.ok && !subscriptions.ok
124
- ? ' Verify the customerReference with askell_call GET /customers/ or askell_paginate_all.'
125
- : '';
126
-
127
209
  return {
128
- content: [
129
- {
130
- type: 'text',
131
- text: JSON.stringify(payload, null, 2) + notFoundHint,
132
- },
133
- ],
134
- isError: !customer.ok && !subscriptions.ok,
210
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
211
+ isError: !customer.ok,
135
212
  };
136
213
  },
137
214
  );
@@ -141,7 +218,7 @@ export function registerAnalysisTools(
141
218
  {
142
219
  title: 'Subscription contract overview (v2)',
143
220
  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, `shipping_selection` when shipping was chosen at checkout, and `subscriber_page` (customer-facing management URL, read-only).',
221
+ 'Fetch a v2 subscription contract and recent billing runs filtered by contract id. The contract payload includes `discount` (active coupon) when one is applied, `shipping_selection` when shipping was chosen at checkout, and `subscriber_page` (customer-facing management URL, read-only). Result is an error when the contract fetch fails. `failures` lists every call that failed.',
145
222
  inputSchema: z.object({
146
223
  contractId: z
147
224
  .union([z.string().min(1), z.int()])
@@ -181,23 +258,25 @@ export function registerAnalysisTools(
181
258
  }),
182
259
  ]);
183
260
 
261
+ const failures = failedCalls([
262
+ ['contract', contract],
263
+ ['billingRuns', billingRuns],
264
+ ]);
265
+
184
266
  const payload = {
185
267
  contractId,
186
268
  contract,
187
269
  billingRuns,
270
+ ...(failures ? { failures } : {}),
271
+ ...(!contract.ok
272
+ ? {
273
+ hint: 'Verify the contractId with askell_call GET /v2/subscription-contracts/.',
274
+ }
275
+ : {}),
188
276
  };
189
277
 
190
278
  return {
191
- content: [
192
- {
193
- type: 'text',
194
- text:
195
- JSON.stringify(payload, null, 2) +
196
- (!contract.ok
197
- ? ' Verify the contractId with askell_call GET /v2/subscription-contracts/.'
198
- : ''),
199
- },
200
- ],
279
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
201
280
  isError: !contract.ok,
202
281
  };
203
282
  },
@@ -208,7 +287,7 @@ export function registerAnalysisTools(
208
287
  {
209
288
  title: 'Billing run triage (v2)',
210
289
  description:
211
- 'Fetch a billing run by id with optional related contract context for failure analysis.',
290
+ 'Fetch a billing run by id with optional related contract context for failure analysis. Result is an error when the billing run fetch fails. A failed related contract stays in the payload and is listed in `failures`.',
212
291
  inputSchema: z.object({
213
292
  billingRunId: z
214
293
  .union([z.string().min(1), z.int()])
@@ -216,7 +295,9 @@ export function registerAnalysisTools(
216
295
  includeContract: z
217
296
  .boolean()
218
297
  .default(true)
219
- .describe('Also fetch the related subscription contract when the run has a contract id'),
298
+ .describe(
299
+ 'Also fetch the related subscription contract when the run has a contract id',
300
+ ),
220
301
  }),
221
302
  annotations: {
222
303
  readOnlyHint: true,
@@ -251,23 +332,26 @@ export function registerAnalysisTools(
251
332
  }
252
333
  }
253
334
 
335
+ const calls: [string, SafeResult][] = [['billingRun', billingRun]];
336
+ if (contract) {
337
+ calls.push(['contract', contract]);
338
+ }
339
+ const failures = failedCalls(calls);
340
+
254
341
  const payload = {
255
342
  billingRunId,
256
343
  billingRun,
257
344
  contract,
345
+ ...(failures ? { failures } : {}),
346
+ ...(!billingRun.ok
347
+ ? {
348
+ hint: 'Verify the billingRunId with askell_call GET /v2/billing-runs/.',
349
+ }
350
+ : {}),
258
351
  };
259
352
 
260
353
  return {
261
- content: [
262
- {
263
- type: 'text',
264
- text:
265
- JSON.stringify(payload, null, 2) +
266
- (!billingRun.ok
267
- ? ' Verify the billingRunId with askell_call GET /v2/billing-runs/.'
268
- : ''),
269
- },
270
- ],
354
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
271
355
  isError: !billingRun.ok,
272
356
  };
273
357
  },
@@ -285,7 +369,9 @@ export function registerAnalysisTools(
285
369
  .positive()
286
370
  .max(1000)
287
371
  .optional()
288
- .describe('Page size for GET /webhooks/ (Askell default 10, max 1000)'),
372
+ .describe(
373
+ 'Page size for GET /webhooks/ (Askell default 10, max 1000)',
374
+ ),
289
375
  }),
290
376
  annotations: {
291
377
  readOnlyHint: true,
@@ -4,13 +4,10 @@ import {
4
4
  } from '@modelcontextprotocol/server';
5
5
 
6
6
  import type { MutationGate } from '../config.ts';
7
+ import { isRecord } from '../is-record.ts';
7
8
 
8
9
  export type MutationGateDecision = { action: 'execute' } | { action: 'elicit' };
9
10
 
10
- function isRecord(value: unknown): value is Record<string, unknown> {
11
- return value != null && typeof value === 'object' && !Array.isArray(value);
12
- }
13
-
14
11
  /**
15
12
  * Per-request client capabilities (protocol 2026-07-28).
16
13
  *
@@ -28,9 +25,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
28
25
  export function readClientCapabilities(
29
26
  envelope: unknown,
30
27
  ): ClientCapabilities | undefined {
31
- if (!isRecord(envelope)) {
32
- return undefined;
33
- }
28
+ if (!isRecord(envelope)) return undefined;
29
+
34
30
  const value = envelope[CLIENT_CAPABILITIES_META_KEY];
35
31
  return isRecord(value) ? (value as ClientCapabilities) : undefined;
36
32
  }
@@ -40,16 +36,13 @@ export function clientSupportsFormElicitation(
40
36
  capabilities: ClientCapabilities | undefined,
41
37
  ): boolean {
42
38
  const elicitation = capabilities?.elicitation;
43
- if (elicitation == null) {
44
- return false;
45
- }
46
- if (elicitation.form != null) {
47
- return true;
48
- }
39
+ if (elicitation == null) return false;
40
+ if (elicitation.form != null) return true;
41
+
49
42
  const keys = Object.keys(elicitation);
50
- if (elicitation.url != null && keys.every((key) => key === 'url')) {
43
+ if (elicitation.url != null && keys.every((key) => key === 'url'))
51
44
  return false;
52
- }
45
+
53
46
  return true;
54
47
  }
55
48