askell-mcp 0.1.2 → 0.3.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/README.md +16 -5
- package/package.json +9 -8
- package/spec/openapi-v2.json +435 -49
- package/src/client/response-formatter.ts +113 -32
- package/src/config.ts +36 -27
- package/src/openapi/registry.ts +17 -7
- package/src/resources/register.ts +53 -27
- package/src/server.ts +13 -7
- package/src/tools/analysis.ts +38 -10
- package/src/tools/call.ts +142 -61
- package/src/tools/discovery.ts +9 -5
- package/src/tools/mutation-gate.ts +74 -0
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,35 +10,59 @@ 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
|
|
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/'),
|
|
27
29
|
query: z
|
|
28
|
-
.record(z.string(), z.
|
|
30
|
+
.record(z.string(), z.json())
|
|
29
31
|
.optional()
|
|
30
32
|
.describe('Query string parameters'),
|
|
31
|
-
body: z.
|
|
33
|
+
body: z.json().optional().describe('JSON request body'),
|
|
32
34
|
apiKeyKind: z
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|
package/src/tools/discovery.ts
CHANGED
|
@@ -77,11 +77,11 @@ const openApiParameterSchema = z.object({
|
|
|
77
77
|
in: z.enum(['query', 'path', 'header', 'cookie']).optional(),
|
|
78
78
|
required: z.boolean().optional(),
|
|
79
79
|
description: z.string().optional(),
|
|
80
|
-
schema: z.
|
|
80
|
+
schema: z.json().optional(),
|
|
81
81
|
$ref: z.string().optional(),
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
const operationDetailSchema = z.object({
|
|
84
|
+
export const operationDetailSchema = z.object({
|
|
85
85
|
id: z.string(),
|
|
86
86
|
apiVersion: apiVersionSchema,
|
|
87
87
|
method: httpMethodSchema,
|
|
@@ -95,7 +95,7 @@ const operationDetailSchema = z.object({
|
|
|
95
95
|
required: z.boolean().optional(),
|
|
96
96
|
description: z.string().optional(),
|
|
97
97
|
contentTypes: z.array(z.string()),
|
|
98
|
-
schema: z.
|
|
98
|
+
schema: z.json().optional(),
|
|
99
99
|
})
|
|
100
100
|
.optional(),
|
|
101
101
|
apiKeyKind: apiKeyKindSchema,
|
|
@@ -171,9 +171,13 @@ export function registerDiscoveryTools(server: McpServer): void {
|
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Strip OpenAPI extras (style/explode/…) so structuredContent matches
|
|
175
|
+
// the output JSON Schema (additionalProperties: false).
|
|
176
|
+
const payload = operationDetailSchema.parse(operation);
|
|
177
|
+
|
|
174
178
|
return {
|
|
175
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
176
|
-
structuredContent:
|
|
179
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
180
|
+
structuredContent: payload,
|
|
177
181
|
};
|
|
178
182
|
},
|
|
179
183
|
);
|
|
@@ -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
|
+
}
|