askell-mcp 0.2.0 → 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 +14 -4
- package/package.json +1 -1
- package/src/config.ts +27 -8
- package/src/resources/register.ts +53 -27
- package/src/server.ts +7 -6
- package/src/tools/call.ts +140 -59
- package/src/tools/mutation-gate.ts +74 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[MCP](https://modelcontextprotocol.io) server for the [Askell](https://askell.is) payment and subscription API.
|
|
4
4
|
|
|
5
|
-
Connect it to Cursor, Claude Desktop, or any MCP client to discover Askell endpoints, inspect customers/contracts/billing, and call the API
|
|
5
|
+
Connect it to Cursor, Claude Desktop, or any MCP client to discover Askell endpoints, inspect customers/contracts/billing, and call the API. Reads and writes are separate tools so clients can show their own approval UI on mutations.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -62,17 +62,26 @@ Restart the client after saving.
|
|
|
62
62
|
| `ASKELL_PUBLIC_API_KEY` | no | — | Public key for a few checkout/payment endpoints |
|
|
63
63
|
| `ASKELL_API_URL` | no | `https://askell.is/api` | API base URL (_or_ `ASKELL_API_BASE_URL`) |
|
|
64
64
|
| `ASKELL_RESPONSE_MAX_BYTES` | no | `64000` | Max response size returned to the model |
|
|
65
|
-
| `
|
|
65
|
+
| `ASKELL_MUTATION_GATE` | no | `auto` | `auto` / `elicit` / `off` — see below |
|
|
66
|
+
| `ASKELL_REQUIRE_MUTATION_APPROVAL` | no | — | Deprecated alias: `true`→`elicit`, `false`→`off` |
|
|
66
67
|
|
|
67
68
|
Askell has **no separate sandbox host** — production and test traffic use the same URL. Use the **Áskell Test Gateway** acquirer in your dashboard for safe payment testing. See [Askell getting started](https://docs.askell.is/en/getting_started/index.html).
|
|
68
69
|
|
|
70
|
+
`ASKELL_MUTATION_GATE`:
|
|
71
|
+
|
|
72
|
+
- **`auto` (default)** — confirmation form only if *this request's* `_meta` envelope declared form elicitation (MCP 2026-07-28). 2025-era clients (Cursor, most hosts) do not send that envelope, so the mutation runs and their own “allow this tool” UI is the gate.
|
|
73
|
+
- **`elicit`** — always return an elicitation form. The SDK refuses the call if the client cannot fulfil it (2026 envelope / 2025 initialize via the legacy shim).
|
|
74
|
+
- **`off`** — never ask (eval / trusted automation).
|
|
75
|
+
|
|
76
|
+
If both `ASKELL_MUTATION_GATE` and `ASKELL_REQUIRE_MUTATION_APPROVAL` are set, `ASKELL_MUTATION_GATE` wins.
|
|
77
|
+
|
|
69
78
|
## What you can do
|
|
70
79
|
|
|
71
80
|
Typical agent workflow:
|
|
72
81
|
|
|
73
82
|
1. **Discover** — `askell_list_operations` / `askell_describe_operation` (from bundled OpenAPI v1 + v2)
|
|
74
83
|
2. **Support tasks** — customer/contract/billing helpers below
|
|
75
|
-
3. **Anything else** — `askell_call` for
|
|
84
|
+
3. **Anything else** — `askell_call` for GET/HEAD, `askell_mutate` for POST/PUT/PATCH/DELETE
|
|
76
85
|
|
|
77
86
|
### Tools
|
|
78
87
|
|
|
@@ -80,7 +89,8 @@ Typical agent workflow:
|
|
|
80
89
|
| --------------------------- | ---------------------------------------- |
|
|
81
90
|
| `askell_list_operations` | Search bundled OpenAPI operations |
|
|
82
91
|
| `askell_describe_operation` | Params and body schema for one operation |
|
|
83
|
-
| `askell_call` |
|
|
92
|
+
| `askell_call` | GET/HEAD any v1/v2 endpoint |
|
|
93
|
+
| `askell_mutate` | POST/PUT/PATCH/DELETE any v1/v2 endpoint |
|
|
84
94
|
| `askell_paginate_all` | Follow paginated list endpoints |
|
|
85
95
|
| `askell_customer_overview` | v1 customer + subscriptions |
|
|
86
96
|
| `askell_contract_overview` | v2 subscription contract + billing runs |
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import * as z from 'zod';
|
|
2
2
|
|
|
3
|
+
export const MUTATION_GATES = ['auto', 'elicit', 'off'] as const;
|
|
4
|
+
export type MutationGate = (typeof MUTATION_GATES)[number];
|
|
5
|
+
|
|
3
6
|
const httpUrl = z
|
|
4
7
|
.url({ protocol: /^https?$/ })
|
|
5
8
|
.describe('Askell API base URL (default production host)');
|
|
6
9
|
|
|
10
|
+
const mutationGateAliases = z
|
|
11
|
+
.enum(['true', 'false', 'on', 'yes', 'no', '1', '0'])
|
|
12
|
+
.transform((value): MutationGate => {
|
|
13
|
+
return value === 'true' || value === 'on' || value === 'yes' || value === '1'
|
|
14
|
+
? 'elicit'
|
|
15
|
+
: 'off';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const MutationGateSchema = z
|
|
19
|
+
.union([
|
|
20
|
+
z.enum(MUTATION_GATES),
|
|
21
|
+
z.boolean().transform((value): MutationGate => (value ? 'elicit' : 'off')),
|
|
22
|
+
mutationGateAliases,
|
|
23
|
+
])
|
|
24
|
+
.default('auto')
|
|
25
|
+
.describe(
|
|
26
|
+
'Mutation confirmation: auto (elicit if client declared it), elicit (require form), off (never)',
|
|
27
|
+
);
|
|
28
|
+
|
|
7
29
|
export const ConfigSchema = z.object({
|
|
8
30
|
apiBaseUrl: httpUrl.default('https://askell.is/api'),
|
|
9
31
|
secretApiKey: z.string().min(1).describe('Secret (private) API key'),
|
|
@@ -18,10 +40,7 @@ export const ConfigSchema = z.object({
|
|
|
18
40
|
.positive()
|
|
19
41
|
.default(64_000)
|
|
20
42
|
.describe('Max response body size returned to the model'),
|
|
21
|
-
|
|
22
|
-
.union([z.boolean(), z.stringbool()])
|
|
23
|
-
.default(true)
|
|
24
|
-
.describe('Require operator confirmation before mutating requests'),
|
|
43
|
+
mutationGate: MutationGateSchema,
|
|
25
44
|
});
|
|
26
45
|
|
|
27
46
|
export type AppConfig = z.infer<typeof ConfigSchema>;
|
|
@@ -58,7 +77,9 @@ function loadConfigFromEnv(): unknown {
|
|
|
58
77
|
|
|
59
78
|
const apiBaseUrl = env.ASKELL_API_URL ?? env.ASKELL_API_BASE_URL;
|
|
60
79
|
const responseMaxBytes = env.ASKELL_RESPONSE_MAX_BYTES;
|
|
61
|
-
const
|
|
80
|
+
const mutationGateRaw =
|
|
81
|
+
env.ASKELL_MUTATION_GATE ?? env.ASKELL_REQUIRE_MUTATION_APPROVAL;
|
|
82
|
+
const mutationGate = mutationGateRaw?.trim().toLowerCase() || undefined;
|
|
62
83
|
|
|
63
84
|
return {
|
|
64
85
|
...(apiBaseUrl ? { apiBaseUrl } : {}),
|
|
@@ -67,9 +88,7 @@ function loadConfigFromEnv(): unknown {
|
|
|
67
88
|
? { publicApiKey: env.ASKELL_PUBLIC_API_KEY }
|
|
68
89
|
: {}),
|
|
69
90
|
...(responseMaxBytes ? { responseMaxBytes } : {}),
|
|
70
|
-
...(
|
|
71
|
-
? { requireMutationApproval }
|
|
72
|
-
: {}),
|
|
91
|
+
...(mutationGate !== undefined ? { mutationGate } : {}),
|
|
73
92
|
};
|
|
74
93
|
}
|
|
75
94
|
|
|
@@ -4,34 +4,60 @@ import { getBundledSpec } from '../openapi/registry.ts';
|
|
|
4
4
|
|
|
5
5
|
const WEBHOOK_EVENTS_DOC = `# Askell webhook events (reference)
|
|
6
6
|
|
|
7
|
-
Askell
|
|
7
|
+
Askell POSTs signed JSON to each URL you register. Verify \`Hook-HMAC\` before parsing.
|
|
8
8
|
|
|
9
9
|
Headers:
|
|
10
|
-
- Hook-HMAC: base64 HMAC-SHA512 of the raw body
|
|
11
|
-
- Hook-Event: event type
|
|
12
|
-
- Hook-API-Version: v1 for
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
10
|
+
- Hook-HMAC: base64 HMAC-SHA512 of the **raw body** (secret = \`hmac_secret\` from webhook create)
|
|
11
|
+
- Hook-Event: event type (\`subscription.renewed\`, \`payment.changed\`, or a family wildcard \`subscription.*\`)
|
|
12
|
+
- Hook-API-Version: \`v1\` for plan/subscription/customer/payment/checkout, \`v2\` for subscription_contract / billing_run
|
|
13
|
+
|
|
14
|
+
## Body shape (OpenAPI is wrong here)
|
|
15
|
+
|
|
16
|
+
JSON body **is the event object**. It is **not** \`{ event, data }\`.
|
|
17
|
+
|
|
18
|
+
Ignore \`POST /your-webhook-url/\` in the bundled v1 spec — its requestBody (\`SubscriptionMultiLite\`: \`{ customer, subscriptions[] }\`) does not match live webhooks.
|
|
19
|
+
|
|
20
|
+
Rare historical payloads used \`{ event, data, ref?, sender? }\`. If both \`event\` and \`data\` are objects, use \`data\`.
|
|
21
|
+
|
|
22
|
+
## Registering endpoints (v1 management API)
|
|
23
|
+
|
|
24
|
+
- GET/POST \`/webhooks/\` · GET/PUT/PATCH/DELETE \`/webhooks/{id}/\` (secret key)
|
|
25
|
+
- Create body: \`{ url, event }\` (\`event\` may be a specific type or a family wildcard like \`payment.*\`)
|
|
26
|
+
- Create/get response includes \`hmac_secret\` (store it; Askell will not show it again in a useful way if you lose it) and \`hmac_digest\` (typically \`SHA512\`)
|
|
27
|
+
|
|
28
|
+
Tools: \`askell_list_webhooks\`, \`askell_call\` (GET), \`askell_mutate\` (POST/PUT/PATCH/DELETE).
|
|
29
|
+
|
|
30
|
+
## Event families and payload fields
|
|
31
|
+
|
|
32
|
+
Live REST \`Subscription\` objects have extra fields the OpenAPI schema omits. Webhook bodies differ slightly from GET \`/subscriptions/\` (notably \`last_billing_log\` vs \`billing_logs[]\`).
|
|
33
|
+
|
|
34
|
+
### subscription.* (v1)
|
|
35
|
+
\`subscription.created\`, \`subscription.changed\`, \`subscription.renewed\`
|
|
36
|
+
|
|
37
|
+
\`id\`, \`plan\` (no \`payment_processor\` / membership-card / wallet-pass fields), \`customer\` (numeric id), \`customer_reference\`, \`trial_end\`, \`start_date\`, \`ended_at\`, \`reference\`, \`active\`, \`meta\` (JSON **string**, often \`"{}"\`), \`description\`, \`active_until\`, \`is_on_trial\`, \`token\`, \`is_failing\`, \`last_billing_log\` (single object or null — not \`billing_logs[]\`), \`delivery_address\`, \`amount\`. Live cancel/change events also send \`cancelled\`, \`cancel_date\`, \`has_payment_plan\`, \`payment_plan_info\`.
|
|
38
|
+
|
|
39
|
+
V2 migration: \`subscription.*\` is **not** aliased onto the new contract (payload is \`SubscriptionContract\`). \`subscription.canceled\` is not sent merely because billing moved to a V2 contract.
|
|
40
|
+
|
|
41
|
+
### subscription_contract.* (v2)
|
|
42
|
+
\`created\`, \`changed\`, \`renewed\`, \`migrated\`
|
|
43
|
+
|
|
44
|
+
\`id\`, \`customer\`, \`state\`, \`billing_anchor_at\`, \`next_billing_at\`, \`cancel_at\`, \`cancel_at_period_end\`, \`canceled_at\`, \`ended_at\`, \`currency\`, \`recurring\`, \`legacy_subscription\`, \`legacy_subscription_ids\`, \`migration_effective_at\`, \`billing_managed_by\`, \`created_at\`, \`updated_at\`.
|
|
45
|
+
|
|
46
|
+
### billing_run.* (v2)
|
|
47
|
+
\`created\`, \`changed\`, \`succeeded\`, \`failed\`, \`retry\`
|
|
48
|
+
|
|
49
|
+
\`id\`, \`contract\`, \`period_start_at\`, \`period_end_at\`, \`state\`, \`currency\`, \`subtotal_amount\`, \`tax_amount\`, \`total_amount\`, \`attempt_count\`, \`max_attempts\`, \`next_retry_at\`, \`last_attempt_at\`, \`transaction\`, \`created_at\`, \`updated_at\`.
|
|
50
|
+
|
|
51
|
+
### customer.* (v1)
|
|
52
|
+
\`created\`, \`changed\` — same shape as GET \`/customers/{ref}/\` (\`id\`, names, \`email\`, \`phone\`, \`customer_reference\`, address fields, \`payment_method[]\`).
|
|
53
|
+
|
|
54
|
+
### payment.* (v1)
|
|
55
|
+
\`created\`, \`changed\`, \`retry\`
|
|
56
|
+
|
|
57
|
+
\`uuid\`, \`amount\`, \`currency\`, \`description\`, \`reference\`, \`state\` (\`pending\` | \`settled\` | \`failed\` | \`retrying\`), \`created_at\`, \`updated_at\`, \`transactions[]\`.
|
|
58
|
+
|
|
59
|
+
### checkout.* (v1)
|
|
60
|
+
\`created\`, \`changed\` — \`token\`, \`checkout_url\`, \`status\`.
|
|
35
61
|
`;
|
|
36
62
|
|
|
37
63
|
export function registerResources(server: McpServer): void {
|
|
@@ -79,7 +105,7 @@ export function registerResources(server: McpServer): void {
|
|
|
79
105
|
'askell://docs/webhook-events',
|
|
80
106
|
{
|
|
81
107
|
title: 'Askell webhook events',
|
|
82
|
-
description: '
|
|
108
|
+
description: 'Inbound webhook events, payload shapes, HMAC, and /webhooks/ management',
|
|
83
109
|
mimeType: 'text/markdown',
|
|
84
110
|
},
|
|
85
111
|
async () => ({
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { AskellClient } from './client/askell-client.ts';
|
|
|
4
4
|
import type { AppConfig } from './config.ts';
|
|
5
5
|
import { registerResources } from './resources/register.ts';
|
|
6
6
|
import { registerAnalysisTools } from './tools/analysis.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { registerCallTools } from './tools/call.ts';
|
|
8
8
|
import { registerDiscoveryTools } from './tools/discovery.ts';
|
|
9
9
|
|
|
10
10
|
const SERVER_INSTRUCTIONS = `Askell MCP server for payment and subscription operations.
|
|
@@ -12,7 +12,7 @@ const SERVER_INSTRUCTIONS = `Askell MCP server for payment and subscription oper
|
|
|
12
12
|
Workflow:
|
|
13
13
|
1. Use askell_list_operations and askell_describe_operation to discover endpoints, parameters, and auth requirements.
|
|
14
14
|
2. Prefer analysis tools (askell_customer_overview, askell_contract_overview, askell_billing_run_triage, askell_paginate_all, askell_list_webhooks) for common support tasks.
|
|
15
|
-
3. Use askell_call
|
|
15
|
+
3. Use askell_call (GET/HEAD) or askell_mutate (POST/PUT/PATCH/DELETE) when no dedicated tool covers the request.
|
|
16
16
|
|
|
17
17
|
API models:
|
|
18
18
|
- v1 (legacy): PlanVariant + Subscription at paths like /subscriptions/, /customers/. Still supported for existing integrations.
|
|
@@ -40,18 +40,19 @@ Auth:
|
|
|
40
40
|
- Only temporary payment method and checkout status endpoints use the public key.
|
|
41
41
|
|
|
42
42
|
Safety:
|
|
43
|
-
-
|
|
43
|
+
- Writes go through askell_mutate (destructiveHint). Reads go through askell_call (readOnlyHint).
|
|
44
|
+
- mutationGate=auto (default): confirmation form only if this request's envelope declared form elicitation; otherwise the client's own tool-allow UI is the gate. elicit always returns a form (SDK refuses if the client cannot fulfil it). off never asks.
|
|
44
45
|
- Large list responses are compacted (index of id/dates/plan/customer) to fit responseMaxBytes before dropping rows; check meta.truncatedByMaxBytes, meta.compacted, and meta.compactedMode.
|
|
45
46
|
|
|
46
47
|
Resources:
|
|
47
48
|
- askell://spec/v1 and askell://spec/v2 — bundled OpenAPI
|
|
48
|
-
- askell://docs/webhook-events — webhook
|
|
49
|
+
- askell://docs/webhook-events — inbound webhook payloads (ignore OpenAPI /your-webhook-url/), HMAC-SHA512, /webhooks/ hmac_secret`;
|
|
49
50
|
|
|
50
51
|
export function createServer(config: AppConfig): McpServer {
|
|
51
52
|
const server = new McpServer(
|
|
52
53
|
{
|
|
53
54
|
name: 'askell-mcp',
|
|
54
|
-
version: '0.
|
|
55
|
+
version: '0.3.0',
|
|
55
56
|
},
|
|
56
57
|
{
|
|
57
58
|
instructions: SERVER_INSTRUCTIONS,
|
|
@@ -61,7 +62,7 @@ export function createServer(config: AppConfig): McpServer {
|
|
|
61
62
|
const client = new AskellClient(config);
|
|
62
63
|
|
|
63
64
|
registerDiscoveryTools(server);
|
|
64
|
-
|
|
65
|
+
registerCallTools(server, client, config);
|
|
65
66
|
registerAnalysisTools(server, client);
|
|
66
67
|
registerResources(server);
|
|
67
68
|
|
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,18 +10,19 @@ 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/'),
|
|
@@ -33,11 +35,34 @@ const callInputSchema = z.object({
|
|
|
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
|
}
|
|
@@ -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
|
+
}
|