@aipermission/mcp 0.2.52 → 0.2.54

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @aipermission/mcp
2
2
 
3
- Local-first MCP bridge for the AIPermission connector gateway.
3
+ Local-only MCP bridge for the AIPermission connector gateway.
4
4
 
5
5
  AIPermission lets AI coding assistants use scoped connector actions through a
6
6
  local gateway without receiving SSH private keys, database passwords, API
@@ -86,8 +86,10 @@ does this automatically.
86
86
  ## Tools
87
87
 
88
88
  `AIPERMISSION_HTTP_TIMEOUT_MS` sets the gateway request deadline (default:
89
- 60000 milliseconds). It covers both response headers and the complete response
90
- body, including streamed bodies. A timeout does not prove that a submitted
89
+ 60000 milliseconds; maximum: 600000). The value must be a positive base-10
90
+ integer; invalid configuration stops the bridge instead of silently changing
91
+ the deadline. It covers both response headers and the complete response body,
92
+ including streamed bodies. A timeout does not prove that a submitted
91
93
  operation failed; do not retry mutations with a new idempotency key blindly.
92
94
  If a POST response is lost or incomplete, the bridge returns
93
95
  `status: outcome_unknown` and `code: gateway_transport_outcome_unknown`.
@@ -98,10 +100,18 @@ Local input-validation failures do not imply that execution occurred.
98
100
  Malformed or incomplete JSON responses are not treated as success. Invalid
99
101
  local headers are rejected before dispatch without echoing the API token;
100
102
  transport errors never echo raw response fragments or header values.
101
- Connector-action capacity errors retain HTTP `429`, the gateway error code,
102
- and `Retry-After`. Respect that delay; immediate retry loops cannot bypass the
103
- gateway's per-workspace/token rate, persisted-running concurrency, input, or
104
- atomic projected-storage limits.
103
+ Connector-action capacity errors cross the HTTP transport as MCP error results:
104
+ the gateway error code is retained and `Retry-After` becomes bounded
105
+ `retry_after_seconds` metadata. The numeric HTTP `429` itself is not part of the
106
+ MCP tool result. Respect the returned delay; immediate retry loops cannot bypass
107
+ the gateway's per-workspace/token rate, persisted-running concurrency, input,
108
+ or atomic projected-storage limits.
109
+
110
+ The normal `npm test` command requires a full Git clone so its test-manifest
111
+ ratchet can compare against trusted history. A GitHub source archive has no
112
+ history; use `npm run test:source-archive` there to run the complete current
113
+ behavior suite and current-manifest checks. That archive command intentionally
114
+ does not claim to verify the historical anti-regression ratchet.
105
115
 
106
116
  - `list_connector_targets`
107
117
  - `get_connector_help`
package/dist/api-error.js CHANGED
@@ -1,13 +1,20 @@
1
1
  const safeResultStatuses = new Set(["failed", "blocked", "stale", "declined", "canceled", "error", "outcome_unknown"]);
2
2
 
3
- export function gatewayAPIError(data, httpStatus) {
3
+ export function gatewayAPIError(data, httpStatus, retryAfterHeader = null) {
4
4
  const error = new Error(data?.error || `AIPermission API request failed with ${httpStatus}`);
5
5
  if (typeof data?.code === "string" && data.code.length <= 128) error.code = data.code;
6
6
  if (safeResultStatuses.has(data?.status)) error.resultStatus = data.status;
7
7
  if (Number.isSafeInteger(data?.request_id) && data.request_id > 0) error.requestID = data.request_id;
8
8
  if (typeof data?.assistant_hint === "string" && data.assistant_hint.length <= 2048) error.assistantHint = data.assistant_hint;
9
- if (Number.isSafeInteger(data?.retry_after_seconds) && data.retry_after_seconds >= 0 && data.retry_after_seconds <= 3600) {
10
- error.retryAfterSeconds = data.retry_after_seconds;
11
- }
9
+ const retryAfterSeconds = safeRetryAfterSeconds(data?.retry_after_seconds, retryAfterHeader);
10
+ if (retryAfterSeconds !== null) error.retryAfterSeconds = retryAfterSeconds;
12
11
  return error;
13
12
  }
13
+
14
+ function safeRetryAfterSeconds(bodyValue, headerValue) {
15
+ if (Number.isSafeInteger(bodyValue) && bodyValue >= 0 && bodyValue <= 3600) return bodyValue;
16
+ const raw = typeof headerValue === "string" ? headerValue.trim() : "";
17
+ if (!/^\d{1,4}$/.test(raw)) return null;
18
+ const value = Number(raw);
19
+ return value <= 3600 ? value : null;
20
+ }
package/dist/config.js ADDED
@@ -0,0 +1,15 @@
1
+ const defaultHTTPTimeoutMs = 60_000;
2
+ const maxHTTPTimeoutMs = 10 * 60_000;
3
+
4
+ export function parseHTTPTimeout(value) {
5
+ const raw = String(value ?? "").trim();
6
+ if (!raw) return defaultHTTPTimeoutMs;
7
+ if (!/^[1-9]\d*$/.test(raw)) {
8
+ throw new Error("AIPERMISSION_HTTP_TIMEOUT_MS must be a positive integer in milliseconds.");
9
+ }
10
+ const timeout = Number(raw);
11
+ if (!Number.isSafeInteger(timeout) || timeout > maxHTTPTimeoutMs) {
12
+ throw new Error(`AIPERMISSION_HTTP_TIMEOUT_MS must not exceed ${maxHTTPTimeoutMs} milliseconds.`);
13
+ }
14
+ return timeout;
15
+ }
@@ -0,0 +1,8 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { z } from "zod";
3
+
4
+ export const idempotencyKeySchema = z
5
+ .string()
6
+ .min(1)
7
+ .refine((value) => Buffer.byteLength(value, "utf8") <= 128, "Idempotency key must not exceed 128 UTF-8 bytes.")
8
+ .describe("Caller-stable key used to prevent duplicate action requests.");
@@ -0,0 +1,264 @@
1
+ import { z } from "zod";
2
+
3
+ const positiveID = z.number().int().positive();
4
+ const nonNegativeInteger = z.number().int().nonnegative();
5
+ const forbiddenMetadataField = /(?:^|_)(?:credential|password|passphrase|private_key|secret|token|api_key|access_key)(?:$|_)/;
6
+
7
+ function normalizeMetadataKey(key) {
8
+ return key
9
+ .trim()
10
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
11
+ .replace(/[^A-Za-z0-9]+/g, "_")
12
+ .toLowerCase();
13
+ }
14
+
15
+ function rejectSecretMetadataKeys(value, context) {
16
+ const pending = [value];
17
+ while (pending.length > 0) {
18
+ const current = pending.pop();
19
+ if (Array.isArray(current)) {
20
+ pending.push(...current);
21
+ continue;
22
+ }
23
+ if (!current || typeof current !== "object") continue;
24
+ for (const [key, item] of Object.entries(current)) {
25
+ const normalized = normalizeMetadataKey(key);
26
+ if (forbiddenMetadataField.test(normalized)) {
27
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "connector metadata contains a forbidden secret field" });
28
+ return;
29
+ }
30
+ pending.push(item);
31
+ }
32
+ }
33
+ }
34
+
35
+ const connectorMetadataSchema = z.record(z.unknown()).superRefine(rejectSecretMetadataKeys);
36
+
37
+ const actionGrantSchema = z
38
+ .object({
39
+ name: z.string(),
40
+ execution_rule: z.string(),
41
+ expires_at: z.string().optional(),
42
+ })
43
+ .strict();
44
+
45
+ const connectorTargetSchema = z
46
+ .object({
47
+ target_ref: z.string(),
48
+ project_id: positiveID,
49
+ project_name: z.string(),
50
+ project_slug: z.string(),
51
+ target_id: positiveID,
52
+ target_name: z.string(),
53
+ connector_kind: z.string(),
54
+ profile_id: positiveID,
55
+ profile_label: z.string(),
56
+ profile_kind: z.string(),
57
+ metadata: connectorMetadataSchema.optional(),
58
+ actions: z.array(actionGrantSchema),
59
+ hints: z.array(z.string()).optional(),
60
+ })
61
+ .strict();
62
+
63
+ const connectorHelpSchema = z
64
+ .object({
65
+ title: z.string(),
66
+ summary: z.string(),
67
+ usage: z.array(z.string()).optional(),
68
+ warnings: z.array(z.string()).optional(),
69
+ connector: z.string(),
70
+ connector_id: z.string(),
71
+ })
72
+ .strict();
73
+
74
+ const fieldOptionSchema = z.object({ value: z.string(), label: z.string() }).strict();
75
+ const fieldSchema = z
76
+ .object({
77
+ name: z.string(),
78
+ label: z.string(),
79
+ type: z.string(),
80
+ required: z.boolean().optional(),
81
+ preserve_whitespace: z.boolean().optional(),
82
+ secret: z.boolean().optional(),
83
+ description: z.string().optional(),
84
+ default: z.unknown().optional(),
85
+ options: z.array(fieldOptionSchema).optional(),
86
+ })
87
+ .strict();
88
+ const inputSchema = z.object({ fields: z.array(fieldSchema) }).strict();
89
+ const outputHintSchema = z
90
+ .object({
91
+ format: z.string().optional(),
92
+ sensitive_fields: z.array(z.string()).optional(),
93
+ temporary_capability_fields: z.array(z.string()).optional(),
94
+ max_rows: nonNegativeInteger.optional(),
95
+ max_bytes: nonNegativeInteger.optional(),
96
+ })
97
+ .strict();
98
+ const retryPolicySchema = z
99
+ .object({
100
+ class: z.string(),
101
+ precondition_fields: z.array(z.string()).optional(),
102
+ guidance: z.string(),
103
+ })
104
+ .strict();
105
+ const actionDefinitionSchema = z
106
+ .object({
107
+ name: z.string(),
108
+ label: z.string(),
109
+ description: z.string(),
110
+ category: z.string().optional(),
111
+ risk: z.string(),
112
+ input_schema: inputSchema,
113
+ sensitive_input_fields: z.array(z.string()).optional(),
114
+ output_hint: outputHintSchema.optional(),
115
+ retry_policy: retryPolicySchema,
116
+ max_input_bytes: positiveID,
117
+ })
118
+ .strict();
119
+
120
+ const connectorActionsSchema = z.object({ items: z.array(actionDefinitionSchema) }).strict();
121
+
122
+ const connectorActionResponseSchema = z
123
+ .object({
124
+ status: z.string(),
125
+ request_id: positiveID.optional(),
126
+ target_ref: z.string().optional(),
127
+ target_name: z.string().optional(),
128
+ connector_kind: z.string().optional(),
129
+ profile_label: z.string().optional(),
130
+ action_name: z.string().optional(),
131
+ input: z.record(z.unknown()).optional(),
132
+ // Connector-owned output is intentionally opaque. The gateway credential
133
+ // boundary redacts values; this schema owns only the shared MCP envelope.
134
+ output: z.unknown().optional(),
135
+ display_text: z.string().optional(),
136
+ error: z.string().optional(),
137
+ retry_policy: retryPolicySchema.optional(),
138
+ retry_after_seconds: nonNegativeInteger.optional(),
139
+ assistant_hint: z.string().optional(),
140
+ output_withheld: z.boolean().optional(),
141
+ replayed: z.boolean().optional(),
142
+ })
143
+ .strict();
144
+
145
+ const vaultItemSchema = z
146
+ .object({
147
+ vault_ref: z.string(),
148
+ item_id: positiveID,
149
+ project_ref: z.string(),
150
+ source_project_id: positiveID,
151
+ name: z.string(),
152
+ secret_type: z.string(),
153
+ status: z.string(),
154
+ expires_at: z.string().optional(),
155
+ value_version: positiveID,
156
+ metadata_revision: positiveID,
157
+ })
158
+ .strict();
159
+ const vaultItemsSchema = z
160
+ .object({
161
+ items: z.array(vaultItemSchema),
162
+ count: nonNegativeInteger,
163
+ truncated: z.boolean(),
164
+ secret_values_returned: z.literal(false),
165
+ })
166
+ .strict();
167
+
168
+ const usageNoteSchema = z.object({ location: z.string(), notes: z.string().optional() }).strict();
169
+ const vaultGenerateInputSchema = z
170
+ .object({
171
+ name: z.string(),
172
+ secret_type: z.string().optional(),
173
+ generator_kind: z.string(),
174
+ provider: z.string().optional(),
175
+ environment: z.string().optional(),
176
+ description: z.string().optional(),
177
+ expires_at: z.string().optional(),
178
+ expiry_warning_days: nonNegativeInteger.optional(),
179
+ tags: z.array(z.string()).optional(),
180
+ usage_notes: z.array(usageNoteSchema).optional(),
181
+ shared_project_ids: z.array(positiveID).optional(),
182
+ })
183
+ .strict();
184
+ const vaultSessionItemSchema = z
185
+ .object({
186
+ item_id: positiveID,
187
+ source_project_id: positiveID,
188
+ replace_existing: z.boolean().optional(),
189
+ })
190
+ .strict();
191
+ const vaultSessionInputSchema = z
192
+ .object({
193
+ target_ref: z.string(),
194
+ items: z.array(vaultSessionItemSchema),
195
+ })
196
+ .strict();
197
+
198
+ const generatedVaultItemSchema = z
199
+ .object({
200
+ vault_ref: z.string(),
201
+ item_id: positiveID,
202
+ project_id: positiveID,
203
+ name: z.string(),
204
+ secret_type: z.string(),
205
+ status: z.string(),
206
+ expires_at: z.string(),
207
+ value_version: positiveID,
208
+ metadata_revision: positiveID,
209
+ })
210
+ .strict();
211
+ const generatedVaultOutputSchema = z
212
+ .object({
213
+ item: generatedVaultItemSchema,
214
+ secret_returned: z.literal(false),
215
+ })
216
+ .strict();
217
+ const vaultSessionOutputSchema = z
218
+ .object({
219
+ session_id: positiveID,
220
+ session_generation: positiveID,
221
+ runtime_id: positiveID,
222
+ status: z.string(),
223
+ environment_names: z.array(z.string()),
224
+ expires_at: z.string(),
225
+ })
226
+ .strict();
227
+
228
+ const vaultActionResponseSchema = z
229
+ .object({
230
+ status: z.string(),
231
+ request_id: positiveID.optional(),
232
+ project_ref: z.string().optional(),
233
+ action_name: z.string().optional(),
234
+ input: z.union([vaultGenerateInputSchema, vaultSessionInputSchema]).optional(),
235
+ reason: z.string().optional(),
236
+ created_at: z.string().optional(),
237
+ expires_at: z.string().optional(),
238
+ secret_values_returned: z.literal(false).optional(),
239
+ output: z.union([generatedVaultOutputSchema, vaultSessionOutputSchema]).optional(),
240
+ output_withheld: z.boolean().optional(),
241
+ retry_after_seconds: nonNegativeInteger.optional(),
242
+ assistant_hint: z.string().optional(),
243
+ error: z.string().optional(),
244
+ })
245
+ .strict();
246
+
247
+ export const responseContracts = Object.freeze({
248
+ connectorTargets: z.array(connectorTargetSchema),
249
+ connectorHelp: connectorHelpSchema,
250
+ connectorActions: connectorActionsSchema,
251
+ connectorAction: connectorActionResponseSchema,
252
+ vaultItems: vaultItemsSchema,
253
+ vaultAction: vaultActionResponseSchema,
254
+ });
255
+
256
+ export function projectGatewaySuccess(schema, value) {
257
+ const result = schema.safeParse(value);
258
+ if (!result.success) {
259
+ const error = new Error("Gateway success response failed MCP contract validation.");
260
+ error.code = "gateway_response_contract_invalid";
261
+ throw error;
262
+ }
263
+ return result.data;
264
+ }
package/dist/results.js CHANGED
@@ -45,10 +45,34 @@ export function errorResult(error) {
45
45
  };
46
46
  }
47
47
 
48
- export async function jsonToolResult(callback) {
48
+ export async function jsonToolResult(callback, project, mutationContext = null) {
49
+ if (typeof project !== "function") {
50
+ return errorResult(new Error("MCP tool result projector is required."));
51
+ }
52
+ let value;
49
53
  try {
50
- return textResult(await callback());
54
+ value = await callback();
51
55
  } catch (error) {
52
56
  return errorResult(error);
53
57
  }
58
+ try {
59
+ return textResult(project(value));
60
+ } catch (error) {
61
+ return errorResult(mutationContext ? projectionOutcomeUnknown(error, mutationContext) : error);
62
+ }
63
+ }
64
+
65
+ function projectionOutcomeUnknown(cause, context) {
66
+ const error = new Error(
67
+ "The gateway accepted the mutation, but its response failed MCP contract validation. The operation may have completed.",
68
+ { cause },
69
+ );
70
+ error.resultStatus = "outcome_unknown";
71
+ error.code = "gateway_response_contract_outcome_unknown";
72
+ if (typeof context.idempotencyKey === "string") error.idempotencyKey = context.idempotencyKey;
73
+ if (Number.isSafeInteger(context.requestID) && context.requestID > 0) error.requestID = context.requestID;
74
+ error.assistantHint = context.idempotencyKey
75
+ ? "Reconcile the original request using the same idempotency key and unchanged input. Never retry with a new key blindly."
76
+ : "Inspect the original request status before repeating the operation.";
77
+ return error;
54
78
  }
package/dist/server.js CHANGED
@@ -13,8 +13,12 @@ import { z } from "zod";
13
13
  import { callVaultActionSchema, listVaultItemsSchema, vaultActionRequestSchema } from "./vault-tools.js";
14
14
  import { MCP_SERVER_INSTRUCTIONS } from "./instructions.js";
15
15
  import { gatewayAPIError } from "./api-error.js";
16
+ import { parseHTTPTimeout } from "./config.js";
17
+ import { idempotencyKeySchema } from "./idempotency-key.js";
16
18
  import { normalizeLocalAPIURL } from "./local-url.js";
19
+ import { projectGatewaySuccess, responseContracts } from "./response-contracts.js";
17
20
  import { jsonToolResult } from "./results.js";
21
+ import { externalActionAnnotations, localMutationAnnotations, localReadAnnotations } from "./tool-annotations.js";
18
22
 
19
23
  const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
20
24
  let apiUrl = "";
@@ -25,7 +29,7 @@ try {
25
29
  apiURLConfigurationError = new Error("Invalid local gateway URL configuration; update AIPERMISSION_API_URL.", { cause: error });
26
30
  }
27
31
  const apiToken = process.env.AIPERMISSION_API_TOKEN || "";
28
- const apiTimeoutMs = Number.parseInt(process.env.AIPERMISSION_HTTP_TIMEOUT_MS || "60000", 10);
32
+ const apiTimeoutMs = parseHTTPTimeout(process.env.AIPERMISSION_HTTP_TIMEOUT_MS);
29
33
 
30
34
  const server = new McpServer(
31
35
  {
@@ -39,8 +43,12 @@ server.tool(
39
43
  "list_connector_targets",
40
44
  "List connector targets this AIPermission token can access. Credentials and secrets are never returned.",
41
45
  {},
42
- async () => {
43
- return jsonToolResult(() => apiGet("/api/mcp/connector-targets"));
46
+ localReadAnnotations,
47
+ async (_args, { signal }) => {
48
+ return jsonToolResult(
49
+ () => apiGet("/api/mcp/connector-targets", { signal }),
50
+ (value) => projectGatewaySuccess(responseContracts.connectorTargets, value),
51
+ );
44
52
  },
45
53
  );
46
54
 
@@ -50,11 +58,15 @@ server.tool(
50
58
  {
51
59
  target_ref: z.string().min(1).describe("Target ref from list_connector_targets in connector:target_id:profile_id format."),
52
60
  },
53
- async ({ target_ref }) => {
54
- return jsonToolResult(() => {
55
- const params = new URLSearchParams({ target_ref });
56
- return apiGet(`/api/mcp/connector-help?${params.toString()}`);
57
- });
61
+ localReadAnnotations,
62
+ async ({ target_ref }, { signal }) => {
63
+ return jsonToolResult(
64
+ () => {
65
+ const params = new URLSearchParams({ target_ref });
66
+ return apiGet(`/api/mcp/connector-help?${params.toString()}`, { signal });
67
+ },
68
+ (value) => projectGatewaySuccess(responseContracts.connectorHelp, value),
69
+ );
58
70
  },
59
71
  );
60
72
 
@@ -64,11 +76,15 @@ server.tool(
64
76
  {
65
77
  target_ref: z.string().min(1).describe("Target ref from list_connector_targets in connector:target_id:profile_id format."),
66
78
  },
67
- async ({ target_ref }) => {
68
- return jsonToolResult(() => {
69
- const params = new URLSearchParams({ target_ref });
70
- return apiGet(`/api/mcp/connector-actions?${params.toString()}`);
71
- });
79
+ localReadAnnotations,
80
+ async ({ target_ref }, { signal }) => {
81
+ return jsonToolResult(
82
+ () => {
83
+ const params = new URLSearchParams({ target_ref });
84
+ return apiGet(`/api/mcp/connector-actions?${params.toString()}`, { signal });
85
+ },
86
+ (value) => projectGatewaySuccess(responseContracts.connectorActions, value),
87
+ );
72
88
  },
73
89
  );
74
90
 
@@ -80,21 +96,25 @@ server.tool(
80
96
  action_name: z.string().min(1).describe("Action name from get_connector_actions."),
81
97
  input: z.record(z.unknown()).optional().describe("Connector-specific action input."),
82
98
  reason: z.string().optional().describe("Why this connector action is needed."),
83
- idempotency_key: z
84
- .string()
85
- .min(1)
86
- .max(128)
87
- .describe("Caller-stable key that makes retries return the original request without running twice."),
99
+ idempotency_key: idempotencyKeySchema,
88
100
  },
89
- async ({ target_ref, action_name, input, reason, idempotency_key }) => {
90
- return jsonToolResult(() =>
91
- apiPost("/api/mcp/connector-actions/call", {
92
- target_ref,
93
- action_name,
94
- input: input || {},
95
- reason: reason || "",
96
- idempotency_key,
97
- }),
101
+ externalActionAnnotations,
102
+ async ({ target_ref, action_name, input, reason, idempotency_key }, { signal }) => {
103
+ return jsonToolResult(
104
+ () =>
105
+ apiPost(
106
+ "/api/mcp/connector-actions/call",
107
+ {
108
+ target_ref,
109
+ action_name,
110
+ input: input || {},
111
+ reason: reason || "",
112
+ idempotency_key,
113
+ },
114
+ { signal },
115
+ ),
116
+ (value) => projectGatewaySuccess(responseContracts.connectorAction, value),
117
+ { idempotencyKey: idempotency_key },
98
118
  );
99
119
  },
100
120
  );
@@ -105,8 +125,12 @@ server.tool(
105
125
  {
106
126
  request_id: z.number().int().positive().describe("Request id returned by call_connector_action."),
107
127
  },
108
- async ({ request_id }) => {
109
- return jsonToolResult(() => apiGet(`/api/mcp/connector-action-requests/${request_id}`));
128
+ localReadAnnotations,
129
+ async ({ request_id }, { signal }) => {
130
+ return jsonToolResult(
131
+ () => apiGet(`/api/mcp/connector-action-requests/${request_id}`, { signal }),
132
+ (value) => projectGatewaySuccess(responseContracts.connectorAction, value),
133
+ );
110
134
  },
111
135
  );
112
136
 
@@ -114,13 +138,17 @@ server.tool(
114
138
  "list_vault_items",
115
139
  "List secret names and bounded non-secret Vault metadata for projects this token can read. Secret values are never returned.",
116
140
  listVaultItemsSchema,
117
- async ({ project_ref }) => {
118
- return jsonToolResult(() => {
119
- const params = new URLSearchParams();
120
- if (project_ref) params.set("project_ref", project_ref);
121
- const query = params.toString();
122
- return apiGet(`/api/mcp/vault-items${query ? `?${query}` : ""}`);
123
- });
141
+ localReadAnnotations,
142
+ async ({ project_ref }, { signal }) => {
143
+ return jsonToolResult(
144
+ () => {
145
+ const params = new URLSearchParams();
146
+ if (project_ref) params.set("project_ref", project_ref);
147
+ const query = params.toString();
148
+ return apiGet(`/api/mcp/vault-items${query ? `?${query}` : ""}`, { signal });
149
+ },
150
+ (value) => projectGatewaySuccess(responseContracts.vaultItems, value),
151
+ );
124
152
  },
125
153
  );
126
154
 
@@ -128,15 +156,23 @@ server.tool(
128
156
  "call_vault_action",
129
157
  "Run a Vault action under the configured project capability. Prompt waits for local approval; Always executes immediately through the same tracked request path. generate_item input accepts name, secret_type, generator_kind, provider, environment, description, expires_at, expiry_warning_days, tags (string array), usage_notes (array of {location, notes}), and shared_project_ids (integer array). restart_session_with_environment input requires target_ref and items with item_id, source_project_id, and optional replace_existing. Never include raw secret values.",
130
158
  callVaultActionSchema,
131
- async ({ project_ref, action_name, input, reason, idempotency_key }) => {
132
- return jsonToolResult(() =>
133
- apiPost("/api/mcp/vault-actions/call", {
134
- project_ref,
135
- action_name,
136
- input,
137
- reason,
138
- idempotency_key,
139
- }),
159
+ externalActionAnnotations,
160
+ async ({ project_ref, action_name, input, reason, idempotency_key }, { signal }) => {
161
+ return jsonToolResult(
162
+ () =>
163
+ apiPost(
164
+ "/api/mcp/vault-actions/call",
165
+ {
166
+ project_ref,
167
+ action_name,
168
+ input,
169
+ reason,
170
+ idempotency_key,
171
+ },
172
+ { signal },
173
+ ),
174
+ (value) => projectGatewaySuccess(responseContracts.vaultAction, value),
175
+ { idempotencyKey: idempotency_key },
140
176
  );
141
177
  },
142
178
  );
@@ -145,8 +181,12 @@ server.tool(
145
181
  "get_vault_action_request",
146
182
  "Read one Vault action request after call_vault_action returns approval_pending. Responses never include secret values.",
147
183
  vaultActionRequestSchema,
148
- async ({ request_id }) => {
149
- return jsonToolResult(() => apiGet(`/api/mcp/vault-action-requests/${request_id}`));
184
+ localReadAnnotations,
185
+ async ({ request_id }, { signal }) => {
186
+ return jsonToolResult(
187
+ () => apiGet(`/api/mcp/vault-action-requests/${request_id}`, { signal }),
188
+ (value) => projectGatewaySuccess(responseContracts.vaultAction, value),
189
+ );
150
190
  },
151
191
  );
152
192
 
@@ -154,21 +194,31 @@ server.tool(
154
194
  "cancel_vault_action_request",
155
195
  "Cancel one approval_pending Vault action request owned by this token. Running or terminal requests cannot be canceled.",
156
196
  vaultActionRequestSchema,
157
- async ({ request_id }) => {
158
- return jsonToolResult(() => apiPost(`/api/mcp/vault-action-requests/${request_id}/cancel`, {}));
197
+ localMutationAnnotations,
198
+ async ({ request_id }, { signal }) => {
199
+ return jsonToolResult(
200
+ () => apiPost(`/api/mcp/vault-action-requests/${request_id}/cancel`, {}, { requestID: request_id, signal }),
201
+ (value) => projectGatewaySuccess(responseContracts.vaultAction, value),
202
+ { requestID: request_id },
203
+ );
159
204
  },
160
205
  );
161
206
 
162
207
  const transport = new StdioServerTransport();
163
208
  await server.connect(transport);
164
209
 
165
- async function apiGet(path) {
166
- return apiRequest(path, {
167
- method: "GET",
168
- });
210
+ async function apiGet(path, context = {}) {
211
+ return apiRequest(
212
+ path,
213
+ {
214
+ method: "GET",
215
+ },
216
+ undefined,
217
+ context,
218
+ );
169
219
  }
170
220
 
171
- async function apiPost(path, body) {
221
+ async function apiPost(path, body, context = {}) {
172
222
  return apiRequest(
173
223
  path,
174
224
  {
@@ -179,10 +229,11 @@ async function apiPost(path, body) {
179
229
  body: JSON.stringify(body),
180
230
  },
181
231
  body?.idempotency_key,
232
+ context,
182
233
  );
183
234
  }
184
235
 
185
- async function apiRequest(path, options, idempotencyKey) {
236
+ async function apiRequest(path, options, idempotencyKey, context = {}) {
186
237
  if (apiURLConfigurationError) {
187
238
  throw apiURLConfigurationError;
188
239
  }
@@ -198,24 +249,38 @@ async function apiRequest(path, options, idempotencyKey) {
198
249
  } catch {
199
250
  throw new Error("Invalid local gateway request configuration; check the API URL and token.");
200
251
  }
201
- const timeout = Number.isFinite(apiTimeoutMs) && apiTimeoutMs > 0 ? apiTimeoutMs : 60000;
252
+ const timeout = apiTimeoutMs;
202
253
  const controller = new AbortController();
203
- const timer = setTimeout(() => controller.abort(), timeout);
254
+ let timedOut = false;
255
+ const timer = setTimeout(() => {
256
+ timedOut = true;
257
+ controller.abort();
258
+ }, timeout);
259
+ const cancelRequest = () => controller.abort();
260
+ context.signal?.addEventListener("abort", cancelRequest, { once: true });
204
261
  let bodyReceived = false;
262
+ let dispatchStarted = false;
205
263
  try {
264
+ if (context.signal?.aborted) {
265
+ throw new Error("MCP request was canceled.");
266
+ }
267
+ dispatchStarted = true;
206
268
  const response = await fetch(request, { signal: controller.signal });
207
269
  const text = await response.text();
208
270
  const data = response.status === 204 ? null : parseResponseBody(text);
209
271
  bodyReceived = true;
210
272
  if (!response.ok) {
211
- throw gatewayAPIError(data, response.status);
273
+ throw gatewayAPIError(data, response.status, response.headers.get("Retry-After"));
212
274
  }
213
275
  return data;
214
276
  } catch (error) {
215
277
  let failure = bodyReceived ? error : new Error("Gateway response unavailable or invalid.", { cause: error });
216
- if (controller.signal.aborted) {
278
+ if (timedOut) {
217
279
  failure = new Error(`AIPermission API request timed out after ${timeout}ms`, { cause: error });
280
+ } else if (context.signal?.aborted) {
281
+ failure = new Error("MCP request was canceled.", { cause: error });
218
282
  }
283
+ if (!dispatchStarted) throw failure;
219
284
  const definitelyNotDispatched = !bodyReceived && isDefinitePredispatchTransportError(error);
220
285
  if (definitelyNotDispatched) {
221
286
  throw new Error("AIPermission gateway connection failed before request dispatch.", { cause: error });
@@ -225,6 +290,7 @@ async function apiRequest(path, options, idempotencyKey) {
225
290
  uncertain.resultStatus = "outcome_unknown";
226
291
  uncertain.code = "gateway_transport_outcome_unknown";
227
292
  uncertain.idempotencyKey = idempotencyKey;
293
+ if (Number.isSafeInteger(context.requestID) && context.requestID > 0) uncertain.requestID = context.requestID;
228
294
  uncertain.assistantHint = idempotencyKey
229
295
  ? "The gateway response was lost; execution may have occurred. Reconcile the original request using the same idempotency key and unchanged input. Never retry with a new key blindly."
230
296
  : "The gateway response was lost; execution may have occurred. Inspect the original request status before repeating the operation.";
@@ -233,6 +299,7 @@ async function apiRequest(path, options, idempotencyKey) {
233
299
  throw failure;
234
300
  } finally {
235
301
  clearTimeout(timer);
302
+ context.signal?.removeEventListener("abort", cancelRequest);
236
303
  }
237
304
  }
238
305
 
@@ -0,0 +1,20 @@
1
+ export const localReadAnnotations = Object.freeze({
2
+ readOnlyHint: true,
3
+ destructiveHint: false,
4
+ idempotentHint: true,
5
+ openWorldHint: false,
6
+ });
7
+
8
+ export const externalActionAnnotations = Object.freeze({
9
+ readOnlyHint: false,
10
+ destructiveHint: true,
11
+ idempotentHint: false,
12
+ openWorldHint: true,
13
+ });
14
+
15
+ export const localMutationAnnotations = Object.freeze({
16
+ readOnlyHint: false,
17
+ destructiveHint: true,
18
+ idempotentHint: false,
19
+ openWorldHint: false,
20
+ });
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { idempotencyKeySchema } from "./idempotency-key.js";
2
3
 
3
4
  export const listVaultItemsSchema = {
4
5
  project_ref: z.string().min(1).optional().describe("Optional project id or slug. Omit to list every readable project."),
@@ -9,7 +10,7 @@ export const callVaultActionSchema = {
9
10
  action_name: z.enum(["generate_item", "restart_session_with_environment"]),
10
11
  input: z.record(z.unknown()).describe("Action input. Never include raw secret values."),
11
12
  reason: z.string().min(1).describe("Why this Vault action is needed."),
12
- idempotency_key: z.string().min(1).max(128).describe("Caller-stable key used to prevent duplicate action requests."),
13
+ idempotency_key: idempotencyKeySchema,
13
14
  };
14
15
 
15
16
  export const vaultActionRequestSchema = {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@aipermission/mcp",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "mcpName": "io.github.aipermission/aipermission-mcp",
5
- "description": "Local-first MCP bridge for the aipermission gateway.",
5
+ "description": "Local-only MCP bridge for the aipermission gateway.",
6
6
  "license": "AGPL-3.0-only",
7
7
  "type": "module",
8
8
  "homepage": "https://github.com/aipermission/aipermission/tree/main/packages/mcp#readme",
@@ -33,6 +33,7 @@
33
33
  "lint": "eslint src test scripts",
34
34
  "prepack": "npm run build",
35
35
  "test": "npm run build && node scripts/run-tests.js",
36
+ "test:source-archive": "npm run build && node scripts/run-tests.js --current-only",
36
37
  "test:windows-acl": "node scripts/run-windows-acl-tests.js",
37
38
  "start": "node dist/cli.js",
38
39
  "dev": "node src/cli.js"
package/server.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.aipermission/aipermission-mcp",
4
4
  "title": "AIPermission",
5
- "description": "Local-first MCP bridge for the AIPermission gateway.",
6
- "version": "0.2.52",
5
+ "description": "Local-only MCP bridge for the AIPermission gateway.",
6
+ "version": "0.2.54",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@aipermission/mcp",
11
- "version": "0.2.52",
11
+ "version": "0.2.54",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  }