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.
- package/LICENSE +21 -0
- package/README.md +204 -0
- package/bin/askell-mcp +2 -0
- package/mcp.json.example +12 -0
- package/package.json +69 -0
- package/spec/openapi-v1.json +2783 -0
- package/spec/openapi-v2.json +4951 -0
- package/src/client/askell-client.ts +243 -0
- package/src/client/paths.ts +10 -0
- package/src/client/response-formatter.ts +302 -0
- package/src/config.ts +104 -0
- package/src/index.ts +16 -0
- package/src/openapi/registry.ts +257 -0
- package/src/openapi/types.ts +68 -0
- package/src/resources/register.ts +95 -0
- package/src/server.ts +64 -0
- package/src/tools/analysis.ts +286 -0
- package/src/tools/call.ts +129 -0
- package/src/tools/discovery.ts +180 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
|
|
4
|
+
import { AskellClient } from '../client/askell-client.ts';
|
|
5
|
+
|
|
6
|
+
/** Minimal shape of the handler `ctx` param needed here — avoids depending on the SDK's internal context type name. */
|
|
7
|
+
type ToolContext = { mcpReq: { signal: AbortSignal } };
|
|
8
|
+
|
|
9
|
+
async function safeRequest(
|
|
10
|
+
client: AskellClient,
|
|
11
|
+
request: Parameters<AskellClient['request']>[0],
|
|
12
|
+
): Promise<{ ok: boolean; data: unknown; error?: string }> {
|
|
13
|
+
try {
|
|
14
|
+
const response = await client.request(request);
|
|
15
|
+
const parsed = JSON.parse(response.text) as { body?: unknown };
|
|
16
|
+
return {
|
|
17
|
+
ok: response.ok,
|
|
18
|
+
data: parsed.body ?? parsed,
|
|
19
|
+
error: response.ok ? undefined : response.text,
|
|
20
|
+
};
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
data: null,
|
|
25
|
+
error: error instanceof Error ? error.message : 'Request failed',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerAnalysisTools(
|
|
31
|
+
server: McpServer,
|
|
32
|
+
client: AskellClient,
|
|
33
|
+
): void {
|
|
34
|
+
server.registerTool(
|
|
35
|
+
'askell_paginate_all',
|
|
36
|
+
{
|
|
37
|
+
title: 'Paginate Askell list endpoint',
|
|
38
|
+
description:
|
|
39
|
+
'Fetch all pages from a paginated Askell list endpoint (v1/v2). Follows `next` links until exhausted or maxPages is reached. Large results are summarized to fit responseMaxBytes — check meta.truncatedByMaxBytes and meta.compacted in the response.',
|
|
40
|
+
inputSchema: z.object({
|
|
41
|
+
path: z.string().describe('List endpoint path, e.g. /subscriptions/'),
|
|
42
|
+
query: z.record(z.string(), z.unknown()).optional(),
|
|
43
|
+
apiKeyKind: z.enum(['secret', 'public']).default('secret'),
|
|
44
|
+
maxPages: z.int().positive().max(100).default(20),
|
|
45
|
+
}),
|
|
46
|
+
annotations: {
|
|
47
|
+
readOnlyHint: true,
|
|
48
|
+
openWorldHint: true,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
async (input, ctx: ToolContext): Promise<CallToolResult> => {
|
|
52
|
+
try {
|
|
53
|
+
const response = await client.paginateAll({
|
|
54
|
+
...input,
|
|
55
|
+
signal: ctx.mcpReq.signal,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: response.text }],
|
|
59
|
+
isError: !response.ok,
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: 'text',
|
|
66
|
+
text: error instanceof Error ? error.message : 'Pagination failed',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
isError: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
server.registerTool(
|
|
76
|
+
'askell_customer_overview',
|
|
77
|
+
{
|
|
78
|
+
title: 'Customer overview (v1)',
|
|
79
|
+
description:
|
|
80
|
+
'Fetch a v1 customer and their v1 subscriptions in one call. Useful for support and billing investigations.',
|
|
81
|
+
inputSchema: z.object({
|
|
82
|
+
customerReference: z
|
|
83
|
+
.string()
|
|
84
|
+
.describe('Customer reference from Askell'),
|
|
85
|
+
}),
|
|
86
|
+
annotations: {
|
|
87
|
+
readOnlyHint: true,
|
|
88
|
+
openWorldHint: true,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
async ({ customerReference }, ctx: ToolContext): Promise<CallToolResult> => {
|
|
92
|
+
const [customer, subscriptions] = await Promise.all([
|
|
93
|
+
safeRequest(client, {
|
|
94
|
+
method: 'GET',
|
|
95
|
+
path: `/customers/${encodeURIComponent(customerReference)}/`,
|
|
96
|
+
signal: ctx.mcpReq.signal,
|
|
97
|
+
}),
|
|
98
|
+
safeRequest(client, {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
path: `/customers/${encodeURIComponent(customerReference)}/subscriptions/`,
|
|
101
|
+
signal: ctx.mcpReq.signal,
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
const payload = {
|
|
106
|
+
customerReference,
|
|
107
|
+
customer,
|
|
108
|
+
subscriptions,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const notFoundHint =
|
|
112
|
+
!customer.ok && !subscriptions.ok
|
|
113
|
+
? ' Verify the customerReference with askell_call GET /customers/ or askell_paginate_all.'
|
|
114
|
+
: '';
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: JSON.stringify(payload, null, 2) + notFoundHint,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
isError: !customer.ok && !subscriptions.ok,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
server.registerTool(
|
|
129
|
+
'askell_contract_overview',
|
|
130
|
+
{
|
|
131
|
+
title: 'Subscription contract overview (v2)',
|
|
132
|
+
description:
|
|
133
|
+
'Fetch a v2 subscription contract and recent billing runs filtered by contract id.',
|
|
134
|
+
inputSchema: z.object({
|
|
135
|
+
contractId: z.union([z.string(), z.number()]),
|
|
136
|
+
billingRunLimit: z.int().positive().max(50).default(10),
|
|
137
|
+
}),
|
|
138
|
+
annotations: {
|
|
139
|
+
readOnlyHint: true,
|
|
140
|
+
openWorldHint: true,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
async (
|
|
144
|
+
{ contractId, billingRunLimit },
|
|
145
|
+
ctx: ToolContext,
|
|
146
|
+
): Promise<CallToolResult> => {
|
|
147
|
+
const contractPath = `/v2/subscription-contracts/${encodeURIComponent(String(contractId))}/`;
|
|
148
|
+
|
|
149
|
+
const [contract, billingRuns] = await Promise.all([
|
|
150
|
+
safeRequest(client, {
|
|
151
|
+
method: 'GET',
|
|
152
|
+
path: contractPath,
|
|
153
|
+
signal: ctx.mcpReq.signal,
|
|
154
|
+
}),
|
|
155
|
+
safeRequest(client, {
|
|
156
|
+
method: 'GET',
|
|
157
|
+
path: '/v2/billing-runs/',
|
|
158
|
+
query: {
|
|
159
|
+
contract: contractId,
|
|
160
|
+
page_size: billingRunLimit,
|
|
161
|
+
},
|
|
162
|
+
signal: ctx.mcpReq.signal,
|
|
163
|
+
}),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const payload = {
|
|
167
|
+
contractId,
|
|
168
|
+
contract,
|
|
169
|
+
billingRuns,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: 'text',
|
|
176
|
+
text:
|
|
177
|
+
JSON.stringify(payload, null, 2) +
|
|
178
|
+
(!contract.ok
|
|
179
|
+
? ' Verify the contractId with askell_call GET /v2/subscription-contracts/.'
|
|
180
|
+
: ''),
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
isError: !contract.ok,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
server.registerTool(
|
|
189
|
+
'askell_billing_run_triage',
|
|
190
|
+
{
|
|
191
|
+
title: 'Billing run triage (v2)',
|
|
192
|
+
description:
|
|
193
|
+
'Fetch a billing run by id with optional related contract context for failure analysis.',
|
|
194
|
+
inputSchema: z.object({
|
|
195
|
+
billingRunId: z.union([z.string(), z.number()]),
|
|
196
|
+
includeContract: z.boolean().default(true),
|
|
197
|
+
}),
|
|
198
|
+
annotations: {
|
|
199
|
+
readOnlyHint: true,
|
|
200
|
+
openWorldHint: true,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
async (
|
|
204
|
+
{ billingRunId, includeContract },
|
|
205
|
+
ctx: ToolContext,
|
|
206
|
+
): Promise<CallToolResult> => {
|
|
207
|
+
const billingRun = await safeRequest(client, {
|
|
208
|
+
method: 'GET',
|
|
209
|
+
path: `/v2/billing-runs/${encodeURIComponent(String(billingRunId))}/`,
|
|
210
|
+
signal: ctx.mcpReq.signal,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
let contract: Awaited<ReturnType<typeof safeRequest>> | undefined;
|
|
214
|
+
|
|
215
|
+
if (includeContract && billingRun.ok && billingRun.data) {
|
|
216
|
+
const run = billingRun.data as {
|
|
217
|
+
contract?: string | number;
|
|
218
|
+
contract_id?: string | number;
|
|
219
|
+
};
|
|
220
|
+
const contractId = run.contract ?? run.contract_id;
|
|
221
|
+
|
|
222
|
+
if (contractId != null) {
|
|
223
|
+
contract = await safeRequest(client, {
|
|
224
|
+
method: 'GET',
|
|
225
|
+
path: `/v2/subscription-contracts/${encodeURIComponent(String(contractId))}/`,
|
|
226
|
+
signal: ctx.mcpReq.signal,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const payload = {
|
|
232
|
+
billingRunId,
|
|
233
|
+
billingRun,
|
|
234
|
+
contract,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
content: [
|
|
239
|
+
{
|
|
240
|
+
type: 'text',
|
|
241
|
+
text:
|
|
242
|
+
JSON.stringify(payload, null, 2) +
|
|
243
|
+
(!billingRun.ok
|
|
244
|
+
? ' Verify the billingRunId with askell_call GET /v2/billing-runs/.'
|
|
245
|
+
: ''),
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
isError: !billingRun.ok,
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
server.registerTool(
|
|
254
|
+
'askell_list_webhooks',
|
|
255
|
+
{
|
|
256
|
+
title: 'List configured webhooks (v1)',
|
|
257
|
+
description:
|
|
258
|
+
'List Askell webhook endpoints configured for the account (management API only).',
|
|
259
|
+
inputSchema: z.object({
|
|
260
|
+
page_size: z.int().positive().max(1000).optional(),
|
|
261
|
+
}),
|
|
262
|
+
annotations: {
|
|
263
|
+
readOnlyHint: true,
|
|
264
|
+
openWorldHint: true,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
async (input, ctx: ToolContext): Promise<CallToolResult> => {
|
|
268
|
+
const response = await safeRequest(client, {
|
|
269
|
+
method: 'GET',
|
|
270
|
+
path: '/webhooks/',
|
|
271
|
+
query: input,
|
|
272
|
+
signal: ctx.mcpReq.signal,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: 'text',
|
|
279
|
+
text: JSON.stringify(response, null, 2),
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
isError: !response.ok,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acceptedContent,
|
|
3
|
+
inputRequired,
|
|
4
|
+
type CallToolResult,
|
|
5
|
+
type InputRequiredResult,
|
|
6
|
+
type McpServer,
|
|
7
|
+
} from '@modelcontextprotocol/server';
|
|
8
|
+
import * as z from 'zod';
|
|
9
|
+
|
|
10
|
+
import { AskellClient, type AskellRequest } from '../client/askell-client.ts';
|
|
11
|
+
import { normalizeApiPath } from '../client/paths.ts';
|
|
12
|
+
import { isMutatingMethod } from '../client/response-formatter.ts';
|
|
13
|
+
import type { AppConfig } from '../config.ts';
|
|
14
|
+
import { operationRegistry } from '../openapi/registry.ts';
|
|
15
|
+
|
|
16
|
+
const confirmationSchema = z.object({
|
|
17
|
+
confirm: z.boolean().meta({ title: 'Confirm mutating Askell API request' }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const callInputSchema = z.object({
|
|
21
|
+
method: z
|
|
22
|
+
.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])
|
|
23
|
+
.describe('HTTP method'),
|
|
24
|
+
path: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe('API path relative to apiBaseUrl, e.g. /v2/subscription-contracts/'),
|
|
27
|
+
query: z
|
|
28
|
+
.record(z.string(), z.unknown())
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Query string parameters'),
|
|
31
|
+
body: z.unknown().optional().describe('JSON request body'),
|
|
32
|
+
apiKeyKind: z
|
|
33
|
+
.enum(['secret', 'public'])
|
|
34
|
+
.default('secret')
|
|
35
|
+
.describe('Which configured API key to use'),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function buildApprovalMessage(input: z.infer<typeof callInputSchema>): string {
|
|
39
|
+
const lines = [
|
|
40
|
+
`Approve this Askell API request?`,
|
|
41
|
+
'',
|
|
42
|
+
`${input.method} ${input.path}`,
|
|
43
|
+
`apiKeyKind: ${input.apiKeyKind}`,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
if (input.query && Object.keys(input.query).length > 0) {
|
|
47
|
+
lines.push('', 'Query:', JSON.stringify(input.query, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (input.body !== undefined) {
|
|
51
|
+
lines.push('', 'Body:', JSON.stringify(input.body, null, 2));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerCallTool(
|
|
58
|
+
server: McpServer,
|
|
59
|
+
client: AskellClient,
|
|
60
|
+
config: AppConfig,
|
|
61
|
+
): void {
|
|
62
|
+
server.registerTool(
|
|
63
|
+
'askell_call',
|
|
64
|
+
{
|
|
65
|
+
title: 'Call Askell API',
|
|
66
|
+
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.',
|
|
68
|
+
inputSchema: callInputSchema,
|
|
69
|
+
annotations: {
|
|
70
|
+
readOnlyHint: false,
|
|
71
|
+
destructiveHint: true,
|
|
72
|
+
idempotentHint: false,
|
|
73
|
+
openWorldHint: true,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
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
|
+
};
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { CallToolResult, McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
|
|
4
|
+
import { operationRegistry } from '../openapi/registry.ts';
|
|
5
|
+
|
|
6
|
+
const listInputSchema = z.object({
|
|
7
|
+
apiVersion: z
|
|
8
|
+
.enum(['v1', 'v2', 'all'])
|
|
9
|
+
.default('all')
|
|
10
|
+
.describe('Filter by API version'),
|
|
11
|
+
tag: z.string().optional().describe('Filter by OpenAPI tag'),
|
|
12
|
+
method: z
|
|
13
|
+
.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Filter by HTTP method'),
|
|
16
|
+
pathPrefix: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Filter paths starting with this prefix, e.g. /v2/billing-runs/'),
|
|
20
|
+
search: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(
|
|
24
|
+
'Case-insensitive search in id, path, summary, description, tags',
|
|
25
|
+
),
|
|
26
|
+
apiKeyKind: z
|
|
27
|
+
.enum(['secret', 'public'])
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Filter by required API key type'),
|
|
30
|
+
limit: z
|
|
31
|
+
.int()
|
|
32
|
+
.positive()
|
|
33
|
+
.max(200)
|
|
34
|
+
.default(50)
|
|
35
|
+
.describe('Maximum number of operations to return'),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const describeInputSchema = z.object({
|
|
39
|
+
operationId: z
|
|
40
|
+
.string()
|
|
41
|
+
.describe(
|
|
42
|
+
'Operation id from askell_list_operations, e.g. v2:GET:/v2/billing-runs/',
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const apiVersionSchema = z.enum(['v1', 'v2']);
|
|
47
|
+
const apiKeyKindSchema = z.enum(['secret', 'public']);
|
|
48
|
+
const httpMethodSchema = z.enum([
|
|
49
|
+
'GET',
|
|
50
|
+
'POST',
|
|
51
|
+
'PUT',
|
|
52
|
+
'PATCH',
|
|
53
|
+
'DELETE',
|
|
54
|
+
'HEAD',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const operationSummarySchema = z.object({
|
|
58
|
+
id: z.string(),
|
|
59
|
+
apiVersion: apiVersionSchema,
|
|
60
|
+
method: httpMethodSchema,
|
|
61
|
+
path: z.string(),
|
|
62
|
+
tags: z.array(z.string()),
|
|
63
|
+
summary: z.string(),
|
|
64
|
+
apiKeyKind: apiKeyKindSchema,
|
|
65
|
+
deprecated: z.boolean(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const listOperationsOutputSchema = z.object({
|
|
69
|
+
totalMatched: z.int(),
|
|
70
|
+
returned: z.int(),
|
|
71
|
+
availableTags: z.array(z.string()),
|
|
72
|
+
operations: z.array(operationSummarySchema),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const openApiParameterSchema = z.object({
|
|
76
|
+
name: z.string().optional(),
|
|
77
|
+
in: z.enum(['query', 'path', 'header', 'cookie']).optional(),
|
|
78
|
+
required: z.boolean().optional(),
|
|
79
|
+
description: z.string().optional(),
|
|
80
|
+
schema: z.unknown().optional(),
|
|
81
|
+
$ref: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const operationDetailSchema = z.object({
|
|
85
|
+
id: z.string(),
|
|
86
|
+
apiVersion: apiVersionSchema,
|
|
87
|
+
method: httpMethodSchema,
|
|
88
|
+
path: z.string(),
|
|
89
|
+
tags: z.array(z.string()),
|
|
90
|
+
summary: z.string(),
|
|
91
|
+
description: z.string().optional(),
|
|
92
|
+
parameters: z.array(openApiParameterSchema),
|
|
93
|
+
requestBody: z
|
|
94
|
+
.object({
|
|
95
|
+
required: z.boolean().optional(),
|
|
96
|
+
description: z.string().optional(),
|
|
97
|
+
contentTypes: z.array(z.string()),
|
|
98
|
+
schema: z.unknown().optional(),
|
|
99
|
+
})
|
|
100
|
+
.optional(),
|
|
101
|
+
apiKeyKind: apiKeyKindSchema,
|
|
102
|
+
deprecated: z.boolean().optional(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export function registerDiscoveryTools(server: McpServer): void {
|
|
106
|
+
server.registerTool(
|
|
107
|
+
'askell_list_operations',
|
|
108
|
+
{
|
|
109
|
+
title: 'List Askell API operations',
|
|
110
|
+
description:
|
|
111
|
+
'Discover Askell v1 and v2 API operations from bundled OpenAPI specs. Returns operation ids usable with askell_describe_operation. NOTE: results are capped by `limit` (default 50) — always check `totalMatched` in the response, not just the length of `operations`, to know if more results exist.',
|
|
112
|
+
inputSchema: listInputSchema,
|
|
113
|
+
outputSchema: listOperationsOutputSchema,
|
|
114
|
+
annotations: {
|
|
115
|
+
readOnlyHint: true,
|
|
116
|
+
openWorldHint: false,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
async (input): Promise<CallToolResult> => {
|
|
120
|
+
const allMatches = operationRegistry.find(input);
|
|
121
|
+
const matches = allMatches.slice(0, input.limit);
|
|
122
|
+
|
|
123
|
+
const payload: z.infer<typeof listOperationsOutputSchema> = {
|
|
124
|
+
totalMatched: allMatches.length,
|
|
125
|
+
returned: matches.length,
|
|
126
|
+
availableTags: operationRegistry.listTags(input.apiVersion),
|
|
127
|
+
operations: matches.map((operation) => ({
|
|
128
|
+
id: operation.id,
|
|
129
|
+
apiVersion: operation.apiVersion,
|
|
130
|
+
method: operation.method,
|
|
131
|
+
path: operation.path,
|
|
132
|
+
tags: operation.tags,
|
|
133
|
+
summary: operation.summary,
|
|
134
|
+
apiKeyKind: operation.apiKeyKind,
|
|
135
|
+
deprecated: operation.deprecated ?? false,
|
|
136
|
+
})),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
141
|
+
structuredContent: payload,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
server.registerTool(
|
|
147
|
+
'askell_describe_operation',
|
|
148
|
+
{
|
|
149
|
+
title: 'Describe Askell API operation',
|
|
150
|
+
description:
|
|
151
|
+
'Return full OpenAPI details for one operation: parameters, request body schema, auth requirements.',
|
|
152
|
+
inputSchema: describeInputSchema,
|
|
153
|
+
outputSchema: operationDetailSchema,
|
|
154
|
+
annotations: {
|
|
155
|
+
readOnlyHint: true,
|
|
156
|
+
openWorldHint: false,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
async ({ operationId }): Promise<CallToolResult> => {
|
|
160
|
+
const operation = operationRegistry.getById(operationId);
|
|
161
|
+
|
|
162
|
+
if (!operation) {
|
|
163
|
+
return {
|
|
164
|
+
content: [
|
|
165
|
+
{
|
|
166
|
+
type: 'text',
|
|
167
|
+
text: `Unknown operationId: ${operationId}. Use askell_list_operations to discover valid ids.`,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
isError: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: 'text', text: JSON.stringify(operation, null, 2) }],
|
|
176
|
+
structuredContent: operation,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
}
|