@aipermission/mcp 0.2.53 → 0.2.55

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,7 +86,7 @@ does this automatically.
86
86
  ## Tools
87
87
 
88
88
  `AIPERMISSION_HTTP_TIMEOUT_MS` sets the gateway request deadline (default:
89
- 60000 milliseconds; maximum: 600000). The value must be a positive base-10
89
+ 95000 milliseconds; maximum: 600000). The value must be a positive base-10
90
90
  integer; invalid configuration stops the bridge instead of silently changing
91
91
  the deadline. It covers both response headers and the complete response body,
92
92
  including streamed bodies. A timeout does not prove that a submitted
@@ -100,10 +100,12 @@ Local input-validation failures do not imply that execution occurred.
100
100
  Malformed or incomplete JSON responses are not treated as success. Invalid
101
101
  local headers are rejected before dispatch without echoing the API token;
102
102
  transport errors never echo raw response fragments or header values.
103
- Connector-action capacity errors retain HTTP `429`, the gateway error code,
104
- and `Retry-After`. Respect that delay; immediate retry loops cannot bypass the
105
- gateway's per-workspace/token rate, persisted-running concurrency, input, or
106
- 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.
107
109
 
108
110
  The normal `npm test` command requires a full Git clone so its test-manifest
109
111
  ratchet can compare against trusted history. A GitHub source archive has no
@@ -226,8 +228,11 @@ disclose secrets. Never automatically retry `submission_unknown`.
226
228
 
227
229
  Connector responses can include `approval_pending` or `running`. Poll
228
230
  `get_connector_action_request(request_id)` until the request reaches a terminal
229
- status. `outcome_unknown` is terminal and means the gateway could not prove the
230
- remote outcome after interruption; inspect target state or ask the operator
231
+ status. Failed action calls carry the MCP `isError` flag; reading a recorded
232
+ failed request is a successful read and does not. A `stopped` call response is a
233
+ gateway control result, not a recorded request. `outcome_unknown` is terminal
234
+ and means the gateway could not prove the remote outcome after interruption;
235
+ inspect target state or ask the operator
231
236
  before retrying. Gateway API errors with that status retain their request id,
232
237
  assistant hint, and bounded retry delay in the MCP error envelope. MCP tool
233
238
  responses never include file contents, gateway
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- const defaultHTTPTimeoutMs = 60_000;
1
+ const defaultHTTPTimeoutMs = 95_000;
2
2
  const maxHTTPTimeoutMs = 10 * 60_000;
3
3
 
4
4
  export function parseHTTPTimeout(value) {
@@ -0,0 +1,27 @@
1
+ // Code generated by backend/cmd/openapi. DO NOT EDIT.
2
+
3
+ export const connectorActionStatuses = Object.freeze([
4
+ "completed",
5
+ "failed",
6
+ "canceled",
7
+ "running",
8
+ "approval_pending",
9
+ "blocked",
10
+ "stale",
11
+ "declined",
12
+ "error",
13
+ "outcome_unknown",
14
+ ]);
15
+
16
+ export const connectorRetryClasses = Object.freeze(["read_only", "idempotent", "conditional", "non_idempotent"]);
17
+
18
+ export const executionRules = Object.freeze(["always_run", "approval_required", "blocked"]);
19
+
20
+ export const connectorActionResponseRequiredFields = Object.freeze([
21
+ "status",
22
+ "request_id",
23
+ "target_ref",
24
+ "connector_kind",
25
+ "action_name",
26
+ "retry_policy",
27
+ ]);
package/dist/init.js CHANGED
@@ -609,29 +609,40 @@ function removeTOMLServer(source, name) {
609
609
  }
610
610
 
611
611
  function scanTOMLHeader(line, state) {
612
- if (state.multiline) {
613
- if (hasMultilineDelimiter(line, state.multiline)) state.multiline = "";
614
- return null;
615
- }
616
612
  const trimmed = line.trimStart();
617
- if (trimmed.startsWith("[")) {
613
+ if (!state.multiline && trimmed.startsWith("[")) {
618
614
  try {
619
615
  return findMarkerPath(parseTOML(`${line}\n__aipermission_header_marker = true\n`));
620
616
  } catch {
621
- return null;
617
+ // A string can start with a bracket; scan it before the next line.
622
618
  }
623
619
  }
624
- for (const delimiter of ['"""', "'''"]) {
625
- if (!hasMultilineDelimiter(line, delimiter)) continue;
626
- if ((line.split(delimiter).length - 1) % 2 === 1) state.multiline = delimiter;
627
- break;
628
- }
620
+ scanTOMLStrings(line, state);
629
621
  return null;
630
622
  }
631
623
 
632
- function hasMultilineDelimiter(line, delimiter) {
633
- const comment = line.indexOf("#");
634
- return (comment < 0 ? line : line.slice(0, comment)).includes(delimiter);
624
+ function scanTOMLStrings(line, state) {
625
+ let quote = state.multiline;
626
+ for (let index = 0; index < line.length; index += 1) {
627
+ const character = line[index];
628
+ if (!quote && character === "#") break;
629
+ if (quote.startsWith('"') && character === "\\") {
630
+ index += 1;
631
+ continue;
632
+ }
633
+ if (quote) {
634
+ if (line.startsWith(quote, index)) {
635
+ index += quote.length - 1;
636
+ quote = "";
637
+ }
638
+ continue;
639
+ }
640
+ if (character !== '"' && character !== "'") continue;
641
+ const triple = character.repeat(3);
642
+ quote = line.startsWith(triple, index) ? triple : character;
643
+ index += quote.length - 1;
644
+ }
645
+ state.multiline = quote.length === 3 ? quote : "";
635
646
  }
636
647
 
637
648
  function findMarkerPath(value, pathParts = []) {
@@ -0,0 +1,277 @@
1
+ import { z } from "zod";
2
+ import { connectorActionStatuses, connectorRetryClasses } from "./generated-connector-contract.js";
3
+
4
+ const positiveID = z.number().int().positive();
5
+ const nonNegativeInteger = z.number().int().nonnegative();
6
+ const forbiddenMetadataField = /(?:^|_)(?:credential|password|passphrase|private_key|secret|token|api_key|access_key)(?:$|_)/;
7
+
8
+ function normalizeMetadataKey(key) {
9
+ return key
10
+ .trim()
11
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
12
+ .replace(/[^A-Za-z0-9]+/g, "_")
13
+ .toLowerCase();
14
+ }
15
+
16
+ function rejectSecretMetadataKeys(value, context) {
17
+ const pending = [value];
18
+ while (pending.length > 0) {
19
+ const current = pending.pop();
20
+ if (Array.isArray(current)) {
21
+ pending.push(...current);
22
+ continue;
23
+ }
24
+ if (!current || typeof current !== "object") continue;
25
+ for (const [key, item] of Object.entries(current)) {
26
+ const normalized = normalizeMetadataKey(key);
27
+ if (forbiddenMetadataField.test(normalized)) {
28
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "connector metadata contains a forbidden secret field" });
29
+ return;
30
+ }
31
+ pending.push(item);
32
+ }
33
+ }
34
+ }
35
+
36
+ const connectorMetadataSchema = z.record(z.unknown()).superRefine(rejectSecretMetadataKeys);
37
+
38
+ const actionGrantSchema = z
39
+ .object({
40
+ name: z.string(),
41
+ execution_rule: z.string(),
42
+ expires_at: z.string().optional(),
43
+ })
44
+ .strict();
45
+
46
+ const connectorTargetSchema = z
47
+ .object({
48
+ target_ref: z.string(),
49
+ project_id: positiveID,
50
+ project_name: z.string(),
51
+ project_slug: z.string(),
52
+ target_id: positiveID,
53
+ target_name: z.string(),
54
+ connector_kind: z.string(),
55
+ profile_id: positiveID,
56
+ profile_label: z.string(),
57
+ profile_kind: z.string(),
58
+ metadata: connectorMetadataSchema.optional(),
59
+ actions: z.array(actionGrantSchema),
60
+ hints: z.array(z.string()).optional(),
61
+ })
62
+ .strict();
63
+
64
+ const connectorHelpSchema = z
65
+ .object({
66
+ title: z.string(),
67
+ summary: z.string(),
68
+ usage: z.array(z.string()).optional(),
69
+ warnings: z.array(z.string()).optional(),
70
+ connector: z.string(),
71
+ connector_id: z.string(),
72
+ })
73
+ .strict();
74
+
75
+ const fieldOptionSchema = z.object({ value: z.string(), label: z.string() }).strict();
76
+ const fieldSchema = z
77
+ .object({
78
+ name: z.string(),
79
+ label: z.string(),
80
+ type: z.string(),
81
+ required: z.boolean().optional(),
82
+ preserve_whitespace: z.boolean().optional(),
83
+ secret: z.boolean().optional(),
84
+ description: z.string().optional(),
85
+ default: z.unknown().optional(),
86
+ options: z.array(fieldOptionSchema).optional(),
87
+ })
88
+ .strict();
89
+ const inputSchema = z.object({ fields: z.array(fieldSchema) }).strict();
90
+ const outputHintSchema = z
91
+ .object({
92
+ format: z.string().optional(),
93
+ sensitive_fields: z.array(z.string()).optional(),
94
+ temporary_capability_fields: z.array(z.string()).optional(),
95
+ max_rows: nonNegativeInteger.optional(),
96
+ max_bytes: nonNegativeInteger.optional(),
97
+ })
98
+ .strict();
99
+ const retryPolicySchema = z
100
+ .object({
101
+ class: z.enum([...connectorRetryClasses]),
102
+ precondition_fields: z.array(z.string()).optional(),
103
+ guidance: z.string(),
104
+ })
105
+ .strict();
106
+ const actionDefinitionSchema = z
107
+ .object({
108
+ name: z.string(),
109
+ label: z.string(),
110
+ description: z.string(),
111
+ category: z.string().optional(),
112
+ risk: z.string(),
113
+ input_schema: inputSchema,
114
+ sensitive_input_fields: z.array(z.string()).optional(),
115
+ output_hint: outputHintSchema.optional(),
116
+ retry_policy: retryPolicySchema,
117
+ max_input_bytes: positiveID,
118
+ })
119
+ .strict();
120
+
121
+ const connectorActionsSchema = z.object({ items: z.array(actionDefinitionSchema) }).strict();
122
+
123
+ const connectorActionRequestSchema = z
124
+ .object({
125
+ status: z.enum([...connectorActionStatuses]),
126
+ request_id: positiveID,
127
+ target_ref: z.string(),
128
+ target_name: z.string().optional(),
129
+ connector_kind: z.string(),
130
+ profile_label: z.string().optional(),
131
+ action_name: z.string(),
132
+ input: z.record(z.unknown()).optional(),
133
+ // Connector-owned output is intentionally opaque. The gateway credential
134
+ // boundary redacts values; this schema owns only the shared MCP envelope.
135
+ output: z.unknown().optional(),
136
+ display_text: z.string().optional(),
137
+ error: z.string().optional(),
138
+ retry_policy: retryPolicySchema,
139
+ retry_after_seconds: nonNegativeInteger.max(3600).optional(),
140
+ assistant_hint: z.string().optional(),
141
+ output_withheld: z.boolean().optional(),
142
+ replayed: z.boolean().optional(),
143
+ })
144
+ .strict()
145
+ .superRefine((value, context) => {
146
+ if (value.output_withheld) {
147
+ if (value.input !== undefined || value.output !== undefined || value.display_text !== undefined || value.error !== undefined) {
148
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "withheld action output must not contain request or result content" });
149
+ }
150
+ } else if (!value.target_ref || !value.connector_kind || !value.action_name) {
151
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "action response identity is required" });
152
+ }
153
+ });
154
+ const connectorActionStoppedSchema = z.object({ status: z.literal("stopped"), error: z.string().min(1) }).strict();
155
+ const connectorActionCallSchema = z.union([connectorActionRequestSchema, connectorActionStoppedSchema]);
156
+
157
+ const vaultItemSchema = z
158
+ .object({
159
+ vault_ref: z.string(),
160
+ item_id: positiveID,
161
+ project_ref: z.string(),
162
+ source_project_id: positiveID,
163
+ name: z.string(),
164
+ secret_type: z.string(),
165
+ status: z.string(),
166
+ expires_at: z.string().optional(),
167
+ value_version: positiveID,
168
+ metadata_revision: positiveID,
169
+ })
170
+ .strict();
171
+ const vaultItemsSchema = z
172
+ .object({
173
+ items: z.array(vaultItemSchema),
174
+ count: nonNegativeInteger,
175
+ truncated: z.boolean(),
176
+ secret_values_returned: z.literal(false),
177
+ })
178
+ .strict();
179
+
180
+ const usageNoteSchema = z.object({ location: z.string(), notes: z.string().optional() }).strict();
181
+ const vaultGenerateInputSchema = z
182
+ .object({
183
+ name: z.string(),
184
+ secret_type: z.string().optional(),
185
+ generator_kind: z.string(),
186
+ provider: z.string().optional(),
187
+ environment: z.string().optional(),
188
+ description: z.string().optional(),
189
+ expires_at: z.string().optional(),
190
+ expiry_warning_days: nonNegativeInteger.optional(),
191
+ tags: z.array(z.string()).optional(),
192
+ usage_notes: z.array(usageNoteSchema).optional(),
193
+ shared_project_ids: z.array(positiveID).optional(),
194
+ })
195
+ .strict();
196
+ const vaultSessionItemSchema = z
197
+ .object({
198
+ item_id: positiveID,
199
+ source_project_id: positiveID,
200
+ replace_existing: z.boolean().optional(),
201
+ })
202
+ .strict();
203
+ const vaultSessionInputSchema = z
204
+ .object({
205
+ target_ref: z.string(),
206
+ items: z.array(vaultSessionItemSchema),
207
+ })
208
+ .strict();
209
+
210
+ const generatedVaultItemSchema = z
211
+ .object({
212
+ vault_ref: z.string(),
213
+ item_id: positiveID,
214
+ project_id: positiveID,
215
+ name: z.string(),
216
+ secret_type: z.string(),
217
+ status: z.string(),
218
+ expires_at: z.string(),
219
+ value_version: positiveID,
220
+ metadata_revision: positiveID,
221
+ })
222
+ .strict();
223
+ const generatedVaultOutputSchema = z
224
+ .object({
225
+ item: generatedVaultItemSchema,
226
+ secret_returned: z.literal(false),
227
+ })
228
+ .strict();
229
+ const vaultSessionOutputSchema = z
230
+ .object({
231
+ session_id: positiveID,
232
+ session_generation: positiveID,
233
+ runtime_id: positiveID,
234
+ status: z.string(),
235
+ environment_names: z.array(z.string()),
236
+ expires_at: z.string(),
237
+ })
238
+ .strict();
239
+
240
+ const vaultActionResponseSchema = z
241
+ .object({
242
+ status: z.string(),
243
+ request_id: positiveID.optional(),
244
+ project_ref: z.string().optional(),
245
+ action_name: z.string().optional(),
246
+ input: z.union([vaultGenerateInputSchema, vaultSessionInputSchema]).optional(),
247
+ reason: z.string().optional(),
248
+ created_at: z.string().optional(),
249
+ expires_at: z.string().optional(),
250
+ secret_values_returned: z.literal(false).optional(),
251
+ output: z.union([generatedVaultOutputSchema, vaultSessionOutputSchema]).optional(),
252
+ output_withheld: z.boolean().optional(),
253
+ retry_after_seconds: nonNegativeInteger.optional(),
254
+ assistant_hint: z.string().optional(),
255
+ error: z.string().optional(),
256
+ })
257
+ .strict();
258
+
259
+ export const responseContracts = Object.freeze({
260
+ connectorTargets: z.array(connectorTargetSchema),
261
+ connectorHelp: connectorHelpSchema,
262
+ connectorActions: connectorActionsSchema,
263
+ connectorActionCall: connectorActionCallSchema,
264
+ connectorActionRequest: connectorActionRequestSchema,
265
+ vaultItems: vaultItemsSchema,
266
+ vaultAction: vaultActionResponseSchema,
267
+ });
268
+
269
+ export function projectGatewaySuccess(schema, value) {
270
+ const result = schema.safeParse(value);
271
+ if (!result.success) {
272
+ const error = new Error("Gateway success response failed MCP contract validation.");
273
+ error.code = "gateway_response_contract_invalid";
274
+ throw error;
275
+ }
276
+ return result.data;
277
+ }
package/dist/results.js CHANGED
@@ -45,10 +45,45 @@ 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, isFailure = () => false) {
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
+ const projected = project(value);
60
+ const result = textResult(projected);
61
+ return isFailure(projected) ? { ...result, isError: true } : result;
62
+ } catch (error) {
63
+ return errorResult(mutationContext ? projectionOutcomeUnknown(error, mutationContext) : error);
64
+ }
65
+ }
66
+
67
+ export function jsonActionToolResult(callback, project, mutationContext) {
68
+ return jsonToolResult(
69
+ callback,
70
+ project,
71
+ mutationContext,
72
+ (value) => !["completed", "approval_pending", "running"].includes(value.status),
73
+ );
74
+ }
75
+
76
+ function projectionOutcomeUnknown(cause, context) {
77
+ const error = new Error(
78
+ "The gateway accepted the mutation, but its response failed MCP contract validation. The operation may have completed.",
79
+ { cause },
80
+ );
81
+ error.resultStatus = "outcome_unknown";
82
+ error.code = "gateway_response_contract_outcome_unknown";
83
+ if (typeof context.idempotencyKey === "string") error.idempotencyKey = context.idempotencyKey;
84
+ if (Number.isSafeInteger(context.requestID) && context.requestID > 0) error.requestID = context.requestID;
85
+ error.assistantHint = context.idempotencyKey
86
+ ? "Reconcile the original request using the same idempotency key and unchanged input. Never retry with a new key blindly."
87
+ : "Inspect the original request status before repeating the operation.";
88
+ return error;
54
89
  }
package/dist/server.js CHANGED
@@ -16,7 +16,9 @@ import { gatewayAPIError } from "./api-error.js";
16
16
  import { parseHTTPTimeout } from "./config.js";
17
17
  import { idempotencyKeySchema } from "./idempotency-key.js";
18
18
  import { normalizeLocalAPIURL } from "./local-url.js";
19
- import { jsonToolResult } from "./results.js";
19
+ import { projectGatewaySuccess, responseContracts } from "./response-contracts.js";
20
+ import { jsonActionToolResult, jsonToolResult } from "./results.js";
21
+ import { externalActionAnnotations, localMutationAnnotations, localReadAnnotations } from "./tool-annotations.js";
20
22
 
21
23
  const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
22
24
  let apiUrl = "";
@@ -41,8 +43,12 @@ server.tool(
41
43
  "list_connector_targets",
42
44
  "List connector targets this AIPermission token can access. Credentials and secrets are never returned.",
43
45
  {},
44
- async () => {
45
- 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
+ );
46
52
  },
47
53
  );
48
54
 
@@ -52,11 +58,15 @@ server.tool(
52
58
  {
53
59
  target_ref: z.string().min(1).describe("Target ref from list_connector_targets in connector:target_id:profile_id format."),
54
60
  },
55
- async ({ target_ref }) => {
56
- return jsonToolResult(() => {
57
- const params = new URLSearchParams({ target_ref });
58
- return apiGet(`/api/mcp/connector-help?${params.toString()}`);
59
- });
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
+ );
60
70
  },
61
71
  );
62
72
 
@@ -66,11 +76,15 @@ server.tool(
66
76
  {
67
77
  target_ref: z.string().min(1).describe("Target ref from list_connector_targets in connector:target_id:profile_id format."),
68
78
  },
69
- async ({ target_ref }) => {
70
- return jsonToolResult(() => {
71
- const params = new URLSearchParams({ target_ref });
72
- return apiGet(`/api/mcp/connector-actions?${params.toString()}`);
73
- });
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
+ );
74
88
  },
75
89
  );
76
90
 
@@ -84,15 +98,23 @@ server.tool(
84
98
  reason: z.string().optional().describe("Why this connector action is needed."),
85
99
  idempotency_key: idempotencyKeySchema,
86
100
  },
87
- async ({ target_ref, action_name, input, reason, idempotency_key }) => {
88
- return jsonToolResult(() =>
89
- apiPost("/api/mcp/connector-actions/call", {
90
- target_ref,
91
- action_name,
92
- input: input || {},
93
- reason: reason || "",
94
- idempotency_key,
95
- }),
101
+ externalActionAnnotations,
102
+ async ({ target_ref, action_name, input, reason, idempotency_key }, { signal }) => {
103
+ return jsonActionToolResult(
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.connectorActionCall, value),
117
+ { idempotencyKey: idempotency_key },
96
118
  );
97
119
  },
98
120
  );
@@ -103,8 +125,12 @@ server.tool(
103
125
  {
104
126
  request_id: z.number().int().positive().describe("Request id returned by call_connector_action."),
105
127
  },
106
- async ({ request_id }) => {
107
- 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.connectorActionRequest, value),
133
+ );
108
134
  },
109
135
  );
110
136
 
@@ -112,13 +138,17 @@ server.tool(
112
138
  "list_vault_items",
113
139
  "List secret names and bounded non-secret Vault metadata for projects this token can read. Secret values are never returned.",
114
140
  listVaultItemsSchema,
115
- async ({ project_ref }) => {
116
- return jsonToolResult(() => {
117
- const params = new URLSearchParams();
118
- if (project_ref) params.set("project_ref", project_ref);
119
- const query = params.toString();
120
- return apiGet(`/api/mcp/vault-items${query ? `?${query}` : ""}`);
121
- });
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
+ );
122
152
  },
123
153
  );
124
154
 
@@ -126,15 +156,23 @@ server.tool(
126
156
  "call_vault_action",
127
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.",
128
158
  callVaultActionSchema,
129
- async ({ project_ref, action_name, input, reason, idempotency_key }) => {
130
- return jsonToolResult(() =>
131
- apiPost("/api/mcp/vault-actions/call", {
132
- project_ref,
133
- action_name,
134
- input,
135
- reason,
136
- idempotency_key,
137
- }),
159
+ externalActionAnnotations,
160
+ async ({ project_ref, action_name, input, reason, idempotency_key }, { signal }) => {
161
+ return jsonActionToolResult(
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 },
138
176
  );
139
177
  },
140
178
  );
@@ -143,8 +181,12 @@ server.tool(
143
181
  "get_vault_action_request",
144
182
  "Read one Vault action request after call_vault_action returns approval_pending. Responses never include secret values.",
145
183
  vaultActionRequestSchema,
146
- async ({ request_id }) => {
147
- 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
+ );
148
190
  },
149
191
  );
150
192
 
@@ -152,18 +194,28 @@ server.tool(
152
194
  "cancel_vault_action_request",
153
195
  "Cancel one approval_pending Vault action request owned by this token. Running or terminal requests cannot be canceled.",
154
196
  vaultActionRequestSchema,
155
- async ({ request_id }) => {
156
- return jsonToolResult(() => apiPost(`/api/mcp/vault-action-requests/${request_id}/cancel`, {}, { requestID: request_id }));
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
+ );
157
204
  },
158
205
  );
159
206
 
160
207
  const transport = new StdioServerTransport();
161
208
  await server.connect(transport);
162
209
 
163
- async function apiGet(path) {
164
- return apiRequest(path, {
165
- method: "GET",
166
- });
210
+ async function apiGet(path, context = {}) {
211
+ return apiRequest(
212
+ path,
213
+ {
214
+ method: "GET",
215
+ },
216
+ undefined,
217
+ context,
218
+ );
167
219
  }
168
220
 
169
221
  async function apiPost(path, body, context = {}) {
@@ -199,9 +251,20 @@ async function apiRequest(path, options, idempotencyKey, context = {}) {
199
251
  }
200
252
  const timeout = apiTimeoutMs;
201
253
  const controller = new AbortController();
202
- 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 });
203
261
  let bodyReceived = false;
262
+ let dispatchStarted = false;
204
263
  try {
264
+ if (context.signal?.aborted) {
265
+ throw new Error("MCP request was canceled.");
266
+ }
267
+ dispatchStarted = true;
205
268
  const response = await fetch(request, { signal: controller.signal });
206
269
  const text = await response.text();
207
270
  const data = response.status === 204 ? null : parseResponseBody(text);
@@ -212,9 +275,12 @@ async function apiRequest(path, options, idempotencyKey, context = {}) {
212
275
  return data;
213
276
  } catch (error) {
214
277
  let failure = bodyReceived ? error : new Error("Gateway response unavailable or invalid.", { cause: error });
215
- if (controller.signal.aborted) {
278
+ if (timedOut) {
216
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 });
217
282
  }
283
+ if (!dispatchStarted) throw failure;
218
284
  const definitelyNotDispatched = !bodyReceived && isDefinitePredispatchTransportError(error);
219
285
  if (definitelyNotDispatched) {
220
286
  throw new Error("AIPermission gateway connection failed before request dispatch.", { cause: error });
@@ -233,6 +299,7 @@ async function apiRequest(path, options, idempotencyKey, context = {}) {
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
+ });
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@aipermission/mcp",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
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",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "1.30.0",
53
53
  "smol-toml": "1.8.0",
54
- "yaml": "2.9.0",
54
+ "yaml": "2.9.1",
55
55
  "zod": "3.25.76"
56
56
  },
57
57
  "devDependencies": {
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.53",
5
+ "description": "Local-only MCP bridge for the AIPermission gateway.",
6
+ "version": "0.2.55",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@aipermission/mcp",
11
- "version": "0.2.53",
11
+ "version": "0.2.55",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  }