askell-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ import { normalizeBaseUrl, type AppConfig } from '../config.ts';
2
+ import { normalizeApiPath } from './paths.ts';
3
+ import {
4
+ buildBoundedListPayload,
5
+ formatApiResponse,
6
+ isMutatingMethod,
7
+ type FormattedResponse,
8
+ } from './response-formatter.ts';
9
+
10
+ import type { ApiKeyKind } from '../openapi/types.ts';
11
+
12
+ export interface AskellRequest {
13
+ method: string;
14
+ path: string;
15
+ query?: Record<string, unknown>;
16
+ body?: unknown;
17
+ apiKeyKind?: ApiKeyKind;
18
+ headers?: Record<string, string>;
19
+ signal?: AbortSignal;
20
+ }
21
+
22
+ export class AskellClient {
23
+ private readonly baseUrl: string;
24
+
25
+ constructor(private readonly config: AppConfig) {
26
+ this.baseUrl = normalizeBaseUrl(config.apiBaseUrl);
27
+ }
28
+
29
+ async request(input: AskellRequest): Promise<FormattedResponse & { ok: boolean; status: number }> {
30
+ const method = input.method.toUpperCase();
31
+ const path = normalizeApiPath(input.path);
32
+ const url = new URL(`${this.baseUrl}${path}`);
33
+
34
+ if (input.query) {
35
+ for (const [key, value] of Object.entries(input.query)) {
36
+ if (value === undefined || value === null) {
37
+ continue;
38
+ }
39
+
40
+ if (Array.isArray(value)) {
41
+ for (const item of value) {
42
+ url.searchParams.append(key, String(item));
43
+ }
44
+ continue;
45
+ }
46
+
47
+ url.searchParams.set(key, String(value));
48
+ }
49
+ }
50
+
51
+ const apiKeyKind = input.apiKeyKind ?? 'secret';
52
+ const apiKey =
53
+ apiKeyKind === 'public'
54
+ ? this.config.publicApiKey
55
+ : this.config.secretApiKey;
56
+
57
+ if (!apiKey) {
58
+ throw new Error(
59
+ apiKeyKind === 'public'
60
+ ? 'publicApiKey is not configured'
61
+ : 'secretApiKey is not configured',
62
+ );
63
+ }
64
+
65
+ const started = performance.now();
66
+ const response = await fetch(url, {
67
+ method,
68
+ headers: {
69
+ Authorization: `Api-Key ${apiKey}`,
70
+ Accept: 'application/json',
71
+ ...(input.body !== undefined
72
+ ? { 'Content-Type': 'application/json' }
73
+ : {}),
74
+ ...input.headers,
75
+ },
76
+ body:
77
+ input.body !== undefined ? JSON.stringify(input.body) : undefined,
78
+ signal: input.signal,
79
+ });
80
+
81
+ const bodyText = await response.text();
82
+ const formatted = formatApiResponse(
83
+ response.status,
84
+ response.headers,
85
+ bodyText,
86
+ this.config.responseMaxBytes,
87
+ {
88
+ method,
89
+ url: url.toString(),
90
+ durationMs: Math.round(performance.now() - started),
91
+ mutating: isMutatingMethod(method),
92
+ },
93
+ );
94
+
95
+ return {
96
+ ...formatted,
97
+ ok: response.ok,
98
+ status: response.status,
99
+ };
100
+ }
101
+
102
+ async paginateAll(input: {
103
+ path: string;
104
+ query?: Record<string, unknown>;
105
+ apiKeyKind?: ApiKeyKind;
106
+ maxPages?: number;
107
+ signal?: AbortSignal;
108
+ }): Promise<FormattedResponse & { ok: boolean; status: number }> {
109
+ const maxPages = input.maxPages ?? 20;
110
+ const apiKeyKind = input.apiKeyKind ?? 'secret';
111
+ const apiKey =
112
+ apiKeyKind === 'public'
113
+ ? this.config.publicApiKey
114
+ : this.config.secretApiKey;
115
+
116
+ if (!apiKey) {
117
+ throw new Error(
118
+ apiKeyKind === 'public'
119
+ ? 'publicApiKey is not configured'
120
+ : 'secretApiKey is not configured',
121
+ );
122
+ }
123
+
124
+ const collected: unknown[] = [];
125
+ let nextUrl: URL | null = null;
126
+ let page = 0;
127
+ let lastStatus = 200;
128
+
129
+ const buildInitialUrl = (): URL => {
130
+ const path = normalizeApiPath(input.path);
131
+ const url = new URL(`${this.baseUrl}${path}`);
132
+
133
+ if (input.query) {
134
+ for (const [key, value] of Object.entries(input.query)) {
135
+ if (value === undefined || value === null) {
136
+ continue;
137
+ }
138
+ url.searchParams.set(key, String(value));
139
+ }
140
+ }
141
+
142
+ return url;
143
+ };
144
+
145
+ do {
146
+ page += 1;
147
+ const url = nextUrl ?? buildInitialUrl();
148
+ const started = performance.now();
149
+
150
+ const response = await fetch(url, {
151
+ method: 'GET',
152
+ headers: {
153
+ Authorization: `Api-Key ${apiKey}`,
154
+ Accept: 'application/json',
155
+ },
156
+ signal: input.signal,
157
+ });
158
+
159
+ lastStatus = response.status;
160
+ const bodyText = await response.text();
161
+
162
+ if (!response.ok) {
163
+ return {
164
+ ...formatApiResponse(
165
+ response.status,
166
+ response.headers,
167
+ bodyText,
168
+ this.config.responseMaxBytes,
169
+ {
170
+ method: 'GET',
171
+ url: url.toString(),
172
+ durationMs: Math.round(performance.now() - started),
173
+ mutating: false,
174
+ },
175
+ ),
176
+ ok: false,
177
+ status: response.status,
178
+ };
179
+ }
180
+
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(bodyText);
184
+ } catch {
185
+ return {
186
+ text: bodyText,
187
+ truncated: false,
188
+ byteLength: Buffer.byteLength(bodyText, 'utf8'),
189
+ ok: false,
190
+ status: lastStatus,
191
+ };
192
+ }
193
+
194
+ if (Array.isArray(parsed)) {
195
+ collected.push(...parsed);
196
+ nextUrl = null;
197
+ } else if (
198
+ parsed &&
199
+ typeof parsed === 'object' &&
200
+ 'results' in parsed &&
201
+ Array.isArray((parsed as { results: unknown[] }).results)
202
+ ) {
203
+ const pageBody = parsed as {
204
+ results: unknown[];
205
+ next?: string | null;
206
+ };
207
+ collected.push(...pageBody.results);
208
+ nextUrl = pageBody.next ? new URL(pageBody.next) : null;
209
+ } else {
210
+ const bodyText = JSON.stringify(parsed, null, 2);
211
+
212
+ return {
213
+ ...formatApiResponse(
214
+ lastStatus,
215
+ response.headers,
216
+ bodyText,
217
+ this.config.responseMaxBytes,
218
+ {
219
+ pagesFetched: page,
220
+ note: 'Response is not a paginated list; returning raw body',
221
+ },
222
+ ),
223
+ ok: true,
224
+ status: lastStatus,
225
+ };
226
+ }
227
+ } while (nextUrl && page < maxPages);
228
+
229
+ return {
230
+ ...buildBoundedListPayload({
231
+ status: lastStatus,
232
+ meta: {
233
+ pagesFetched: page,
234
+ truncatedByMaxPages: Boolean(nextUrl),
235
+ },
236
+ items: collected,
237
+ maxBytes: this.config.responseMaxBytes,
238
+ }),
239
+ ok: true,
240
+ status: lastStatus,
241
+ };
242
+ }
243
+ }
@@ -0,0 +1,10 @@
1
+ /** Normalize Askell API paths: leading slash + trailing slash (OpenAPI convention). */
2
+ export function normalizeApiPath(path: string): string {
3
+ let normalized = path.startsWith('/') ? path : `/${path}`;
4
+
5
+ if (normalized !== '/' && !normalized.endsWith('/')) {
6
+ normalized += '/';
7
+ }
8
+
9
+ return normalized;
10
+ }
@@ -0,0 +1,302 @@
1
+ export interface FormattedResponse {
2
+ text: string;
3
+ truncated: boolean;
4
+ byteLength: number;
5
+ }
6
+
7
+ export function truncateUtf8(text: string, maxBytes: number): string {
8
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) {
9
+ return text;
10
+ }
11
+
12
+ let end = Math.min(text.length, maxBytes);
13
+ while (end > 0) {
14
+ const slice = text.slice(0, end);
15
+ if (Buffer.byteLength(slice, 'utf8') <= maxBytes) {
16
+ return slice;
17
+ }
18
+ end -= 1;
19
+ }
20
+
21
+ return '';
22
+ }
23
+
24
+ export function limitText(
25
+ text: string,
26
+ maxBytes: number,
27
+ ): Pick<FormattedResponse, 'text' | 'truncated' | 'byteLength'> {
28
+ const byteLength = Buffer.byteLength(text, 'utf8');
29
+ const truncated = byteLength > maxBytes;
30
+
31
+ return {
32
+ text: truncated ? truncateUtf8(text, maxBytes) : text,
33
+ truncated,
34
+ byteLength,
35
+ };
36
+ }
37
+
38
+ function summarizeListItem(item: unknown): unknown {
39
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) {
40
+ return item;
41
+ }
42
+
43
+ const obj = item as Record<string, unknown>;
44
+ const out: Record<string, unknown> = {};
45
+ const scalarKeys = [
46
+ 'id',
47
+ 'reference',
48
+ 'customer_reference',
49
+ 'uuid',
50
+ 'name',
51
+ 'state',
52
+ 'status',
53
+ 'active',
54
+ 'cancelled',
55
+ 'start_date',
56
+ 'created_at',
57
+ 'ended_at',
58
+ 'active_until',
59
+ 'email',
60
+ 'first_name',
61
+ 'last_name',
62
+ 'description',
63
+ 'currency',
64
+ 'amount',
65
+ 'total_amount',
66
+ ] as const;
67
+
68
+ for (const key of scalarKeys) {
69
+ if (key in obj) {
70
+ out[key] = obj[key];
71
+ }
72
+ }
73
+
74
+ if (obj.customer && typeof obj.customer === 'object') {
75
+ const customer = obj.customer as Record<string, unknown>;
76
+ out.customer = {
77
+ id: customer.id,
78
+ customer_reference:
79
+ customer.customer_reference ?? customer.reference ?? customer.id,
80
+ };
81
+ }
82
+
83
+ if (obj.plan && typeof obj.plan === 'object') {
84
+ const plan = obj.plan as Record<string, unknown>;
85
+ out.plan = {
86
+ id: plan.id,
87
+ name: plan.name,
88
+ };
89
+ }
90
+
91
+ return Object.keys(out).length > 0 ? out : obj;
92
+ }
93
+
94
+ function serializeListPayload(
95
+ status: number,
96
+ meta: Record<string, unknown>,
97
+ items: unknown[],
98
+ returnedCount: number,
99
+ truncatedByMaxBytes: boolean,
100
+ pretty: boolean,
101
+ ): string {
102
+ const payload = {
103
+ status,
104
+ meta: {
105
+ ...meta,
106
+ itemCount: items.length,
107
+ returnedCount,
108
+ truncatedByMaxBytes,
109
+ },
110
+ body: items.slice(0, returnedCount),
111
+ };
112
+
113
+ return pretty
114
+ ? JSON.stringify(payload, null, 2)
115
+ : JSON.stringify(payload);
116
+ }
117
+
118
+ function maxFittingCount(
119
+ items: unknown[],
120
+ status: number,
121
+ meta: Record<string, unknown>,
122
+ maxBytes: number,
123
+ pretty: boolean,
124
+ ): number {
125
+ const itemCount = items.length;
126
+ let lo = 0;
127
+ let hi = itemCount;
128
+
129
+ while (lo < hi) {
130
+ const mid = Math.ceil((lo + hi) / 2);
131
+ const candidate = serializeListPayload(
132
+ status,
133
+ meta,
134
+ items,
135
+ mid,
136
+ mid < itemCount,
137
+ pretty,
138
+ );
139
+ if (Buffer.byteLength(candidate, 'utf8') <= maxBytes) {
140
+ lo = mid;
141
+ } else {
142
+ hi = mid - 1;
143
+ }
144
+ }
145
+
146
+ return lo;
147
+ }
148
+
149
+ export function buildBoundedListPayload(input: {
150
+ status: number;
151
+ meta: Record<string, unknown>;
152
+ items: unknown[];
153
+ maxBytes: number;
154
+ }): FormattedResponse {
155
+ const { status, meta, items, maxBytes } = input;
156
+ const itemCount = items.length;
157
+
158
+ const attempts: Array<{
159
+ items: unknown[];
160
+ pretty: boolean;
161
+ compacted: boolean;
162
+ note?: string;
163
+ }> = [
164
+ { items, pretty: true, compacted: false },
165
+ { items, pretty: false, compacted: false },
166
+ {
167
+ items: items.map(summarizeListItem),
168
+ pretty: false,
169
+ compacted: true,
170
+ note: 'Items summarized to fit responseMaxBytes',
171
+ },
172
+ ];
173
+
174
+ let fullByteLength = 0;
175
+
176
+ for (const attempt of attempts) {
177
+ const fullText = serializeListPayload(
178
+ status,
179
+ meta,
180
+ attempt.items,
181
+ attempt.items.length,
182
+ false,
183
+ attempt.pretty,
184
+ );
185
+ fullByteLength = Buffer.byteLength(fullText, 'utf8');
186
+
187
+ if (fullByteLength <= maxBytes) {
188
+ return {
189
+ text: fullText,
190
+ truncated: false,
191
+ byteLength: fullByteLength,
192
+ };
193
+ }
194
+
195
+ const returnedCount = maxFittingCount(
196
+ attempt.items,
197
+ status,
198
+ {
199
+ ...meta,
200
+ ...(attempt.compacted ? { compacted: true, note: attempt.note } : {}),
201
+ },
202
+ maxBytes,
203
+ attempt.pretty,
204
+ );
205
+
206
+ if (returnedCount > 0) {
207
+ const text = serializeListPayload(
208
+ status,
209
+ {
210
+ ...meta,
211
+ ...(attempt.compacted ? { compacted: true, note: attempt.note } : {}),
212
+ },
213
+ attempt.items,
214
+ returnedCount,
215
+ true,
216
+ attempt.pretty,
217
+ );
218
+
219
+ return {
220
+ text,
221
+ truncated: true,
222
+ byteLength: fullByteLength,
223
+ };
224
+ }
225
+ }
226
+
227
+ const text = serializeListPayload(
228
+ status,
229
+ {
230
+ ...meta,
231
+ compacted: true,
232
+ note: 'Response too large; returning metadata only',
233
+ },
234
+ [],
235
+ 0,
236
+ true,
237
+ false,
238
+ );
239
+
240
+ return {
241
+ text,
242
+ truncated: true,
243
+ byteLength: fullByteLength,
244
+ };
245
+ }
246
+
247
+ export function formatApiResponse(
248
+ status: number,
249
+ headers: Headers,
250
+ bodyText: string,
251
+ maxBytes: number,
252
+ meta?: Record<string, unknown>,
253
+ ): FormattedResponse {
254
+ const byteLength = Buffer.byteLength(bodyText, 'utf8');
255
+ const truncated = byteLength > maxBytes;
256
+ const visibleBody = truncated ? truncateUtf8(bodyText, maxBytes) : bodyText;
257
+
258
+ let parsedBody: unknown = visibleBody;
259
+ try {
260
+ parsedBody = JSON.parse(visibleBody);
261
+ } catch {
262
+ // keep raw text
263
+ }
264
+
265
+ const payload = {
266
+ status,
267
+ headers: pickHeaders(headers),
268
+ meta,
269
+ truncated,
270
+ byteLength,
271
+ body: parsedBody,
272
+ };
273
+
274
+ return {
275
+ text: JSON.stringify(payload, null, 2),
276
+ truncated,
277
+ byteLength,
278
+ };
279
+ }
280
+
281
+ function pickHeaders(headers: Headers): Record<string, string> {
282
+ const interesting = [
283
+ 'content-type',
284
+ 'date',
285
+ 'x-request-id',
286
+ 'retry-after',
287
+ ];
288
+ const out: Record<string, string> = {};
289
+
290
+ for (const name of interesting) {
291
+ const value = headers.get(name);
292
+ if (value) {
293
+ out[name] = value;
294
+ }
295
+ }
296
+
297
+ return out;
298
+ }
299
+
300
+ export function isMutatingMethod(method: string): boolean {
301
+ return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase());
302
+ }
package/src/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ import * as z from 'zod';
2
+
3
+ export const ConfigSchema = z.object({
4
+ apiBaseUrl: z
5
+ .httpUrl()
6
+ .default('https://askell.is/api')
7
+ .describe('Askell API base URL (default production host)'),
8
+ secretApiKey: z.string().min(1).describe('Secret (private) API key'),
9
+ publicApiKey: z
10
+ .string()
11
+ .optional()
12
+ .describe('Public API key for temporary payment method endpoints'),
13
+ responseMaxBytes: z
14
+ .int()
15
+ .positive()
16
+ .default(64_000)
17
+ .describe('Max response body size returned to the model'),
18
+ requireMutationApproval: z
19
+ .boolean()
20
+ .default(true)
21
+ .describe('Require operator confirmation before mutating requests'),
22
+ });
23
+
24
+ export type AppConfig = z.infer<typeof ConfigSchema>;
25
+
26
+ const CONFIG_HELP = `Askell MCP credentials missing.
27
+
28
+ Set ASKELL_PRIVATE_API_KEY (or ASKELL_SECRET_API_KEY), optionally ASKELL_PUBLIC_API_KEY and ASKELL_API_URL:
29
+
30
+ Local dev — create .env in the project root (Bun loads it automatically):
31
+ ASKELL_PRIVATE_API_KEY=...
32
+ ASKELL_PUBLIC_API_KEY=...
33
+
34
+ Published package (requires Bun) — Cursor / Claude mcp.json:
35
+ {
36
+ "mcpServers": {
37
+ "askell": {
38
+ "command": "bunx",
39
+ "args": ["-y", "askell-mcp"],
40
+ "env": {
41
+ "ASKELL_PRIVATE_API_KEY": "...",
42
+ "ASKELL_PUBLIC_API_KEY": "..."
43
+ }
44
+ }
45
+ }
46
+ }`;
47
+
48
+ function parseEnvFlag(
49
+ value: string | undefined,
50
+ defaultValue: boolean,
51
+ ): boolean {
52
+ if (value === undefined) {
53
+ return defaultValue;
54
+ }
55
+
56
+ return !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
57
+ }
58
+
59
+ function loadConfigFromEnv(): unknown {
60
+ const env = Bun.env;
61
+ const secretApiKey = env.ASKELL_PRIVATE_API_KEY ?? env.ASKELL_SECRET_API_KEY;
62
+
63
+ if (!secretApiKey) {
64
+ return undefined;
65
+ }
66
+
67
+ const apiBaseUrl = env.ASKELL_API_URL ?? env.ASKELL_API_BASE_URL;
68
+ const responseMaxBytes = env.ASKELL_RESPONSE_MAX_BYTES;
69
+ const requireMutationApproval = env.ASKELL_REQUIRE_MUTATION_APPROVAL;
70
+
71
+ return {
72
+ ...(apiBaseUrl ? { apiBaseUrl } : {}),
73
+ secretApiKey,
74
+ ...(env.ASKELL_PUBLIC_API_KEY
75
+ ? { publicApiKey: env.ASKELL_PUBLIC_API_KEY }
76
+ : {}),
77
+ ...(responseMaxBytes ? { responseMaxBytes: Number(responseMaxBytes) } : {}),
78
+ ...(requireMutationApproval !== undefined
79
+ ? {
80
+ requireMutationApproval: parseEnvFlag(requireMutationApproval, true),
81
+ }
82
+ : {}),
83
+ };
84
+ }
85
+
86
+ export async function loadConfig(): Promise<AppConfig> {
87
+ const fromEnv = loadConfigFromEnv();
88
+ if (!fromEnv) {
89
+ throw new Error(CONFIG_HELP);
90
+ }
91
+
92
+ const parsed = ConfigSchema.safeParse(fromEnv);
93
+ if (!parsed.success) {
94
+ throw new Error(
95
+ `Invalid config from environment: ${z.prettifyError(parsed.error)}`,
96
+ );
97
+ }
98
+
99
+ return parsed.data;
100
+ }
101
+
102
+ export function normalizeBaseUrl(baseUrl: string): string {
103
+ return baseUrl.replace(/\/+$/, '');
104
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
2
+
3
+ import { loadConfig } from './config.ts';
4
+ import { createServer } from './server.ts';
5
+
6
+ try {
7
+ const config = await loadConfig();
8
+
9
+ void serveStdio(() => createServer(config));
10
+
11
+ console.error('askell-mcp running on stdio');
12
+ } catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ console.error(`askell-mcp failed to start: ${message}`);
15
+ process.exit(1);
16
+ }