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/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 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/'),
27
29
  query: z
28
- .record(z.string(), z.unknown())
30
+ .record(z.string(), z.json())
29
31
  .optional()
30
32
  .describe('Query string parameters'),
31
- body: z.unknown().optional().describe('JSON request body'),
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
- 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
  }
@@ -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.unknown().optional(),
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.unknown().optional(),
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(operation, null, 2) }],
176
- structuredContent: operation,
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
+ }