@cursor/july 0.1.88 → 0.1.89
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/dist/bin/agent-serve.js +1 -1
- package/dist/internal/advertise-tools.d.ts.map +1 -1
- package/dist/internal/advertise-tools.js +1 -1
- package/dist/internal/cli-ax.js +1 -1
- package/dist/internal/cli-deploy.d.ts +3 -2
- package/dist/internal/cli-deploy.d.ts.map +1 -1
- package/dist/internal/cli-deploy.js +84 -9
- package/dist/internal/cursor/account-mcp.d.ts.map +1 -1
- package/dist/internal/cursor/account-mcp.js +8 -4
- package/dist/internal/cursor/backend-client.d.ts +8 -0
- package/dist/internal/cursor/backend-client.d.ts.map +1 -1
- package/dist/internal/cursor/backend-client.js +23 -1
- package/dist/internal/deploy-client.d.ts +42 -1
- package/dist/internal/deploy-client.d.ts.map +1 -1
- package/dist/internal/deploy-client.js +70 -2
- package/dist/internal/mcp-endpoint.js +2 -1
- package/dist/internal/session-engine.d.ts +9 -2
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +85 -28
- package/dist/internal/tool-policy.d.ts +33 -1
- package/dist/internal/tool-policy.d.ts.map +1 -1
- package/dist/internal/tool-policy.js +53 -0
- package/dist/playground/assets/{index-Bqn91tW4.js → index-BmiIjFlM.js} +45 -45
- package/dist/playground/assets/index-DQGZnAI0.css +1 -0
- package/dist/playground/index.html +2 -2
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/debug/SKILL.md +1 -2
- package/skills/framework-map/SKILL.md +1 -2
- package/src/bin/agent-serve.ts +1 -1
- package/src/internal/advertise-tools.ts +1 -0
- package/src/internal/cli-ax.ts +1 -1
- package/src/internal/cli-deploy.ts +123 -15
- package/src/internal/cursor/account-mcp.ts +15 -1
- package/src/internal/cursor/backend-client.ts +33 -0
- package/src/internal/deploy-client.ts +122 -2
- package/src/internal/mcp-endpoint.ts +2 -1
- package/src/internal/session-engine.ts +86 -6
- package/src/internal/tool-policy.ts +80 -1
- package/src/types.ts +19 -0
- package/dist/playground/assets/index-C0_5hOsf.css +0 -1
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
+
import type { McpToolAnnotations } from "../../types.js";
|
|
19
|
+
import { boundedToolAnnotations } from "../mcp-host.js";
|
|
18
20
|
|
|
19
21
|
const CLIENT_VERSION = "agent-serve-0.1.0";
|
|
20
22
|
const EXCHANGE_PATH = "/auth/exchange_user_api_key";
|
|
@@ -85,6 +87,8 @@ const mcpToolSchema = z.object({
|
|
|
85
87
|
inputSchema: z.unknown().optional(),
|
|
86
88
|
/** Flat JSON-string output schema (`output_schema_json` on the proto). */
|
|
87
89
|
outputSchemaJson: z.string().optional(),
|
|
90
|
+
/** Flat JSON-string MCP annotations (`annotations_json` on the proto). */
|
|
91
|
+
annotationsJson: z.string().optional(),
|
|
88
92
|
});
|
|
89
93
|
|
|
90
94
|
const getAvailableMcpServersResponseSchema = z.object({
|
|
@@ -154,6 +158,13 @@ export interface AccountMcpTool {
|
|
|
154
158
|
* matching the optional MCP `Tool.outputSchema` field.
|
|
155
159
|
*/
|
|
156
160
|
outputSchema?: Record<string, unknown>;
|
|
161
|
+
/**
|
|
162
|
+
* MCP spec tool annotations as the connector's server reported them,
|
|
163
|
+
* bounded to the spec fields at parse time. Absent stays absent —
|
|
164
|
+
* untrusted hints, and the fail-closed signal for consumers that classify
|
|
165
|
+
* on them (dry-run effect resolution).
|
|
166
|
+
*/
|
|
167
|
+
annotations?: McpToolAnnotations;
|
|
157
168
|
}
|
|
158
169
|
|
|
159
170
|
export interface AccountMcpServer {
|
|
@@ -331,11 +342,13 @@ export class CursorBackendClient {
|
|
|
331
342
|
status: server.status,
|
|
332
343
|
tools: (server.tools ?? []).map((tool) => {
|
|
333
344
|
const outputSchema = parseOutputSchemaJson(tool.outputSchemaJson);
|
|
345
|
+
const annotations = parseAnnotationsJson(tool.annotationsJson);
|
|
334
346
|
return {
|
|
335
347
|
name: tool.name,
|
|
336
348
|
description: tool.description ?? "",
|
|
337
349
|
inputSchema: normalizeInputSchema(tool.inputSchema),
|
|
338
350
|
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
351
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
339
352
|
};
|
|
340
353
|
}),
|
|
341
354
|
}));
|
|
@@ -707,6 +720,26 @@ function parseOutputSchemaJson(
|
|
|
707
720
|
: undefined;
|
|
708
721
|
}
|
|
709
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Parse the flat JSON-string annotations off the wire and bound them to the
|
|
725
|
+
* MCP-spec fields. Malformed or non-object payloads behave like absent ones,
|
|
726
|
+
* so readers fail closed.
|
|
727
|
+
*/
|
|
728
|
+
function parseAnnotationsJson(
|
|
729
|
+
annotationsJson: string | undefined
|
|
730
|
+
): McpToolAnnotations | undefined {
|
|
731
|
+
if (annotationsJson === undefined) {
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
let parsed: unknown;
|
|
735
|
+
try {
|
|
736
|
+
parsed = JSON.parse(annotationsJson);
|
|
737
|
+
} catch {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
return boundedToolAnnotations(parsed);
|
|
741
|
+
}
|
|
742
|
+
|
|
710
743
|
type McpResultJson = z.infer<typeof mcpResultSchema>;
|
|
711
744
|
|
|
712
745
|
function toCallResult(result: McpResultJson): AccountMcpCallResult {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Thin client for the Agent Serve deployment management API
|
|
3
|
-
* (`/internal/agent-serve/deployments` on the Cursor backend)
|
|
3
|
+
* (`/internal/agent-serve/deployments` on the Cursor backend) and the v2
|
|
4
|
+
* AgentSDK accept path (`/internal/agentkit/deployments`). Pure
|
|
4
5
|
* request/response + error mapping — no console I/O, no polling loops;
|
|
5
6
|
* rendering lives in `cli-deploy.ts`.
|
|
6
7
|
*
|
|
@@ -212,6 +213,48 @@ export interface StopAccepted {
|
|
|
212
213
|
|
|
213
214
|
export type DeleteAccepted = StopAccepted;
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* `POST /internal/agentkit/deployments` 202 body. Distinct from v1
|
|
218
|
+
* {@link DeployAccepted}: the v2 control plane returns catalog ids, not
|
|
219
|
+
* an engine alias or one-shot alias token.
|
|
220
|
+
*/
|
|
221
|
+
export interface AgentkitDeployAccepted {
|
|
222
|
+
applicationId: string;
|
|
223
|
+
releaseId: string;
|
|
224
|
+
status: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Customer-visible v2 deploy status (`GET /internal/agentkit/deployments/:slug`). */
|
|
228
|
+
export type AgentkitCustomerStatus =
|
|
229
|
+
| "pending"
|
|
230
|
+
| "deploying"
|
|
231
|
+
| "running"
|
|
232
|
+
| "failed"
|
|
233
|
+
| "stopped";
|
|
234
|
+
|
|
235
|
+
export type AgentkitDeployProgress =
|
|
236
|
+
| "queued"
|
|
237
|
+
| "creating"
|
|
238
|
+
| "building"
|
|
239
|
+
| "starting"
|
|
240
|
+
| "finalizing"
|
|
241
|
+
| "running"
|
|
242
|
+
| "failed"
|
|
243
|
+
| "stopped";
|
|
244
|
+
|
|
245
|
+
export interface AgentkitDeployStatus {
|
|
246
|
+
applicationId: string;
|
|
247
|
+
releaseId: string;
|
|
248
|
+
slug: string;
|
|
249
|
+
teamId: number;
|
|
250
|
+
status: AgentkitCustomerStatus;
|
|
251
|
+
progress: AgentkitDeployProgress;
|
|
252
|
+
statusMessage: string | null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const TERMINAL_AGENTKIT_DEPLOY_STATUSES: ReadonlySet<AgentkitCustomerStatus> =
|
|
256
|
+
new Set(["running", "failed", "stopped"]);
|
|
257
|
+
|
|
215
258
|
/** One secret's metadata from the list endpoint — names only, never values. */
|
|
216
259
|
export interface SecretInfo {
|
|
217
260
|
name: string;
|
|
@@ -260,6 +303,35 @@ const deployAcceptedSchema = z
|
|
|
260
303
|
})
|
|
261
304
|
.passthrough();
|
|
262
305
|
|
|
306
|
+
const agentkitDeployAcceptedSchema = z
|
|
307
|
+
.object({
|
|
308
|
+
applicationId: z.string().min(1),
|
|
309
|
+
releaseId: z.string().min(1),
|
|
310
|
+
status: z.string(),
|
|
311
|
+
})
|
|
312
|
+
.passthrough();
|
|
313
|
+
|
|
314
|
+
const agentkitDeployStatusSchema = z
|
|
315
|
+
.object({
|
|
316
|
+
applicationId: z.string().min(1),
|
|
317
|
+
releaseId: z.string().min(1),
|
|
318
|
+
slug: z.string().min(1),
|
|
319
|
+
teamId: z.number(),
|
|
320
|
+
status: z.enum(["pending", "deploying", "running", "failed", "stopped"]),
|
|
321
|
+
progress: z.enum([
|
|
322
|
+
"queued",
|
|
323
|
+
"creating",
|
|
324
|
+
"building",
|
|
325
|
+
"starting",
|
|
326
|
+
"finalizing",
|
|
327
|
+
"running",
|
|
328
|
+
"failed",
|
|
329
|
+
"stopped",
|
|
330
|
+
]),
|
|
331
|
+
statusMessage: z.string().nullable(),
|
|
332
|
+
})
|
|
333
|
+
.passthrough();
|
|
334
|
+
|
|
263
335
|
const deploymentSchema = z.object({
|
|
264
336
|
id: z.number(),
|
|
265
337
|
slug: z.string(),
|
|
@@ -482,6 +554,54 @@ export class AgentServeDeployClient {
|
|
|
482
554
|
return deployAcceptedSchema.parse(raw);
|
|
483
555
|
}
|
|
484
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Create or redeploy onto the v2 control plane
|
|
559
|
+
* (`POST /internal/agentkit/deployments`). 202 means accepted; poll
|
|
560
|
+
* {@link getAgentkitDeploy} until a terminal status.
|
|
561
|
+
*/
|
|
562
|
+
async deployAgentkit(args: {
|
|
563
|
+
teamId: number;
|
|
564
|
+
slug: string;
|
|
565
|
+
gitRepoUrl: string;
|
|
566
|
+
gitRef?: string;
|
|
567
|
+
agentPath?: string;
|
|
568
|
+
repositories?: string[];
|
|
569
|
+
}): Promise<AgentkitDeployAccepted> {
|
|
570
|
+
const slugError = validateDeploymentSlug(args.slug);
|
|
571
|
+
if (slugError !== undefined) {
|
|
572
|
+
throw new DeployApiError(slugError, 400);
|
|
573
|
+
}
|
|
574
|
+
const raw = await this.request({
|
|
575
|
+
method: "POST",
|
|
576
|
+
path: "/internal/agentkit/deployments",
|
|
577
|
+
body: {
|
|
578
|
+
teamId: args.teamId,
|
|
579
|
+
slug: args.slug,
|
|
580
|
+
gitRepoUrl: args.gitRepoUrl,
|
|
581
|
+
gitRef: args.gitRef,
|
|
582
|
+
agentPath: args.agentPath,
|
|
583
|
+
repositories:
|
|
584
|
+
args.repositories !== undefined && args.repositories.length > 0
|
|
585
|
+
? args.repositories
|
|
586
|
+
: undefined,
|
|
587
|
+
},
|
|
588
|
+
context: { verb: "deploy", slug: args.slug },
|
|
589
|
+
});
|
|
590
|
+
return agentkitDeployAcceptedSchema.parse(raw);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async getAgentkitDeploy(args: {
|
|
594
|
+
teamId: number;
|
|
595
|
+
slug: string;
|
|
596
|
+
}): Promise<AgentkitDeployStatus> {
|
|
597
|
+
const raw = await this.request({
|
|
598
|
+
method: "GET",
|
|
599
|
+
path: `/internal/agentkit/deployments/${encodeURIComponent(args.slug)}?teamId=${args.teamId}`,
|
|
600
|
+
context: { verb: "get deployment", slug: args.slug },
|
|
601
|
+
});
|
|
602
|
+
return agentkitDeployStatusSchema.parse(raw);
|
|
603
|
+
}
|
|
604
|
+
|
|
485
605
|
async listDeployments(args: { teamId: number }): Promise<Deployment[]> {
|
|
486
606
|
const raw = await this.request({
|
|
487
607
|
method: "GET",
|
|
@@ -756,7 +876,7 @@ function mapErrorResponse(
|
|
|
756
876
|
// (api2 vs api.cursor.com), not a missing deployment / closed gate.
|
|
757
877
|
if (/Route \S+ not found/i.test(bodyText)) {
|
|
758
878
|
return new DeployApiError(
|
|
759
|
-
`Agent Serve deploy API not found on this host — use the default api.cursor.com (unset CURSOR_API_BASE_URL), or point CURSOR_API_BASE_URL at a backend that mounts /internal/agent-serve/*.`,
|
|
879
|
+
`Agent Serve deploy API not found on this host — use the default api.cursor.com (unset CURSOR_API_BASE_URL), or point CURSOR_API_BASE_URL at a backend that mounts /internal/agent-serve/* and /internal/agentkit/*.`,
|
|
760
880
|
404
|
|
761
881
|
);
|
|
762
882
|
}
|
|
@@ -318,8 +318,9 @@ function buildConnectionBridgeMcpServer(
|
|
|
318
318
|
}
|
|
319
319
|
// The one tool path that never reaches `executeServerTool`, so it asks
|
|
320
320
|
// the policy itself.
|
|
321
|
-
const answer = engine.bridgedCallAnswer({
|
|
321
|
+
const answer = await engine.bridgedCallAnswer({
|
|
322
322
|
sessionId: sessionId ?? "",
|
|
323
|
+
connectionName,
|
|
323
324
|
toolName: request.params.name,
|
|
324
325
|
});
|
|
325
326
|
if (answer.answered) {
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
isHostOauthConnectionTransport,
|
|
56
56
|
isSymbolicConnectionTransport,
|
|
57
57
|
type JsonValue,
|
|
58
|
+
type McpToolAnnotations,
|
|
58
59
|
type PendingApproval,
|
|
59
60
|
type ReceiveInput,
|
|
60
61
|
type ResolvedAgent,
|
|
@@ -2480,6 +2481,14 @@ export class SessionEngine {
|
|
|
2480
2481
|
const decision = decideToolCall({
|
|
2481
2482
|
dryRun: args.record.dryRun === true,
|
|
2482
2483
|
effect: args.tool.definition.effect,
|
|
2484
|
+
...(args.tool.advertised === true
|
|
2485
|
+
? {
|
|
2486
|
+
mcpTool: {
|
|
2487
|
+
name: args.tool.name,
|
|
2488
|
+
annotations: args.tool.annotations,
|
|
2489
|
+
},
|
|
2490
|
+
}
|
|
2491
|
+
: {}),
|
|
2483
2492
|
dryRunResult: args.tool.definition.dryRunResult,
|
|
2484
2493
|
input: validation.value,
|
|
2485
2494
|
});
|
|
@@ -2490,6 +2499,17 @@ export class SessionEngine {
|
|
|
2490
2499
|
);
|
|
2491
2500
|
return normalizeToolResult(decision.result);
|
|
2492
2501
|
}
|
|
2502
|
+
if (decision.kind === "refuse") {
|
|
2503
|
+
this.logger(
|
|
2504
|
+
`[session] tool ${args.tool.name} refused, effect unclassified (${toolCallId})`
|
|
2505
|
+
);
|
|
2506
|
+
// The coverage signal: a run reports how much of itself was runnable.
|
|
2507
|
+
args.emit({
|
|
2508
|
+
type: "action.effect_unclassified",
|
|
2509
|
+
data: { callId: toolCallId, toolName: args.tool.name },
|
|
2510
|
+
});
|
|
2511
|
+
return toolErrorResult(decision.message);
|
|
2512
|
+
}
|
|
2493
2513
|
const needsGate = await evaluateNeedsApproval(
|
|
2494
2514
|
args.tool.definition.needsApproval,
|
|
2495
2515
|
validation.value
|
|
@@ -2778,6 +2798,9 @@ export class SessionEngine {
|
|
|
2778
2798
|
const decision = decideToolCall({
|
|
2779
2799
|
dryRun: record?.dryRun === true,
|
|
2780
2800
|
effect: tool.definition.effect,
|
|
2801
|
+
...(tool.advertised === true
|
|
2802
|
+
? { mcpTool: { name: tool.name, annotations: tool.annotations } }
|
|
2803
|
+
: {}),
|
|
2781
2804
|
dryRunResult: tool.definition.dryRunResult,
|
|
2782
2805
|
input: validation.value,
|
|
2783
2806
|
});
|
|
@@ -2798,7 +2821,17 @@ export class SessionEngine {
|
|
|
2798
2821
|
}).catch(() => {});
|
|
2799
2822
|
};
|
|
2800
2823
|
try {
|
|
2801
|
-
if (decision.kind === "
|
|
2824
|
+
if (decision.kind === "refuse") {
|
|
2825
|
+
this.logger(
|
|
2826
|
+
`[session] tool ${toolName} refused, effect unclassified (direct ${callId})`
|
|
2827
|
+
);
|
|
2828
|
+
emit?.({
|
|
2829
|
+
type: "action.effect_unclassified",
|
|
2830
|
+
data: { callId, toolName },
|
|
2831
|
+
});
|
|
2832
|
+
result = normalizeToolResult(toolErrorResult(decision.message));
|
|
2833
|
+
isError = true;
|
|
2834
|
+
} else if (decision.kind === "answer") {
|
|
2802
2835
|
this.logger(
|
|
2803
2836
|
`[session] tool ${toolName} answered by host (direct ${callId})`
|
|
2804
2837
|
);
|
|
@@ -2896,28 +2929,75 @@ export class SessionEngine {
|
|
|
2896
2929
|
* bridged reads too; resolving effects from a server's own listing is the
|
|
2897
2930
|
* next unit's work.
|
|
2898
2931
|
*/
|
|
2899
|
-
bridgedCallAnswer(args: {
|
|
2932
|
+
async bridgedCallAnswer(args: {
|
|
2900
2933
|
sessionId: string;
|
|
2934
|
+
connectionName: string;
|
|
2901
2935
|
toolName: string;
|
|
2902
|
-
}):
|
|
2936
|
+
}): Promise<
|
|
2937
|
+
{ answered: true; result: SDKCustomToolResult } | { answered: false }
|
|
2938
|
+
> {
|
|
2903
2939
|
// Read off the in-flight turn's context rather than the session store:
|
|
2904
2940
|
// the bridge already refuses a call with no active turn, so the record is
|
|
2905
2941
|
// in memory. That keeps this free for the sessions that are not dry runs,
|
|
2906
|
-
// which is every session today
|
|
2907
|
-
|
|
2942
|
+
// which is every session today — the listing below is only paid inside a
|
|
2943
|
+
// dry run.
|
|
2944
|
+
const context = this.activeToolContexts.get(args.sessionId);
|
|
2945
|
+
if (context?.record.dryRun !== true) {
|
|
2946
|
+
return { answered: false };
|
|
2947
|
+
}
|
|
2948
|
+
// A bridged tool carries no `defineTool`, so its effect comes from the
|
|
2949
|
+
// server's own listing annotations; a tool the listing cannot classify
|
|
2950
|
+
// refuses rather than being stubbed. A failed listing classifies nothing,
|
|
2951
|
+
// which lands on the same refusal — never on a run.
|
|
2952
|
+
const annotations = await this.bridgedToolAnnotations(
|
|
2953
|
+
args.connectionName,
|
|
2954
|
+
args.toolName
|
|
2955
|
+
);
|
|
2908
2956
|
const decision = decideToolCall({
|
|
2909
|
-
dryRun:
|
|
2957
|
+
dryRun: true,
|
|
2910
2958
|
effect: undefined,
|
|
2959
|
+
mcpTool: { name: args.toolName, annotations },
|
|
2911
2960
|
});
|
|
2912
2961
|
if (decision.kind === "run") {
|
|
2913
2962
|
return { answered: false };
|
|
2914
2963
|
}
|
|
2964
|
+
if (decision.kind === "refuse") {
|
|
2965
|
+
this.logger(
|
|
2966
|
+
`[session] bridged tool ${args.toolName} refused, effect unclassified (${args.sessionId})`
|
|
2967
|
+
);
|
|
2968
|
+
context.emit({
|
|
2969
|
+
type: "action.effect_unclassified",
|
|
2970
|
+
data: {
|
|
2971
|
+
callId: newToolCallId(args.toolName),
|
|
2972
|
+
toolName: args.toolName,
|
|
2973
|
+
connection: args.connectionName,
|
|
2974
|
+
},
|
|
2975
|
+
});
|
|
2976
|
+
return { answered: true, result: toolErrorResult(decision.message) };
|
|
2977
|
+
}
|
|
2915
2978
|
this.logger(
|
|
2916
2979
|
`[session] bridged tool ${args.toolName} answered by host (${args.sessionId})`
|
|
2917
2980
|
);
|
|
2918
2981
|
return { answered: true, result: normalizeToolResult(decision.result) };
|
|
2919
2982
|
}
|
|
2920
2983
|
|
|
2984
|
+
/**
|
|
2985
|
+
* The bounded annotations a connection's listing declares for one tool, or
|
|
2986
|
+
* undefined when the tool is unlisted, unannotated, or the listing fails —
|
|
2987
|
+
* all of which the policy reads as unclassified, the refusing state.
|
|
2988
|
+
*/
|
|
2989
|
+
private async bridgedToolAnnotations(
|
|
2990
|
+
connectionName: string,
|
|
2991
|
+
toolName: string
|
|
2992
|
+
): Promise<McpToolAnnotations | undefined> {
|
|
2993
|
+
try {
|
|
2994
|
+
const listed = await this.host.mcp.listTools(connectionName);
|
|
2995
|
+
return listed.find((tool) => tool.name === toolName)?.annotations;
|
|
2996
|
+
} catch {
|
|
2997
|
+
return undefined;
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
|
|
2921
3001
|
// ==========================================================================
|
|
2922
3002
|
// Events: append, dispatch to channel handlers + hooks
|
|
2923
3003
|
// ==========================================================================
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
|
+
McpToolAnnotations,
|
|
13
14
|
ToolEffect,
|
|
14
15
|
ToolEffectDeclaration,
|
|
15
16
|
ToolExecuteResult,
|
|
@@ -20,7 +21,14 @@ export type ToolDecision =
|
|
|
20
21
|
/** Run the tool body. */
|
|
21
22
|
| { kind: "run" }
|
|
22
23
|
/** Do not run it; return this to the model instead. */
|
|
23
|
-
| { kind: "answer"; result: ToolExecuteResult }
|
|
24
|
+
| { kind: "answer"; result: ToolExecuteResult }
|
|
25
|
+
/**
|
|
26
|
+
* Do not run it; fail the call with this message. Only ever produced in a
|
|
27
|
+
* dry run, for an MCP tool nothing classifies: stubbing it would let the
|
|
28
|
+
* run reach a confident conclusion from no data, and running it could leak
|
|
29
|
+
* a real write — refusing is the one failure that is loud.
|
|
30
|
+
*/
|
|
31
|
+
| { kind: "refuse"; message: string };
|
|
24
32
|
|
|
25
33
|
/** What a policy decision needs to know about the call. */
|
|
26
34
|
export interface ToolCallFacts {
|
|
@@ -28,6 +36,14 @@ export interface ToolCallFacts {
|
|
|
28
36
|
dryRun: boolean;
|
|
29
37
|
/** The tool's declaration, if it made one. */
|
|
30
38
|
effect: ToolEffectDeclaration<never> | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Present when the call targets a tool nobody authored — an advertised MCP
|
|
41
|
+
* passthrough or a bridged MCP call. Consulted only when {@link effect} is
|
|
42
|
+
* undeclared: the server's own annotations classify the call, and a tool
|
|
43
|
+
* they cannot classify refuses in a dry run instead of being stubbed as a
|
|
44
|
+
* write the way an undeclared authored tool is.
|
|
45
|
+
*/
|
|
46
|
+
mcpTool?: { name: string; annotations?: McpToolAnnotations };
|
|
31
47
|
/** What a stubbed write should answer with, if the tool supplied one. */
|
|
32
48
|
dryRunResult?: ToolExecuteResult | ((input: never) => ToolExecuteResult);
|
|
33
49
|
/**
|
|
@@ -64,11 +80,74 @@ export function resolveEffect(
|
|
|
64
80
|
return declared === "read" ? "read" : "write";
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The effect an MCP server's own annotations declare, fail-closed.
|
|
85
|
+
*
|
|
86
|
+
* `readOnlyHint: true` without `destructiveHint: true` is a read; either
|
|
87
|
+
* negative signal is a write; anything else — absent annotations, or
|
|
88
|
+
* annotations that carry no effect signal (a bare `title`) — is `undefined`,
|
|
89
|
+
* unclassified. Mirrors `@anysphere/mcp-core/mcp-tool-annotations`
|
|
90
|
+
* (`classifyMcpToolAnnotationRisk`), which this package cannot import (npm
|
|
91
|
+
* publish, workspace-only dependency); keep the two in sync.
|
|
92
|
+
*/
|
|
93
|
+
export function classifyAnnotationsEffect(
|
|
94
|
+
annotations: McpToolAnnotations | undefined
|
|
95
|
+
): ToolEffect | undefined {
|
|
96
|
+
if (annotations === undefined) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
annotations.destructiveHint !== true &&
|
|
101
|
+
annotations.readOnlyHint === true
|
|
102
|
+
) {
|
|
103
|
+
return "read";
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
annotations.readOnlyHint === false ||
|
|
107
|
+
annotations.destructiveHint === true
|
|
108
|
+
) {
|
|
109
|
+
return "write";
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What a dry run answers when nothing classifies an MCP tool. Deliberately
|
|
116
|
+
* reads as a strict API rather than disclosing the session's posture (see
|
|
117
|
+
* {@link ACKNOWLEDGED}), and names what is missing so the fix — the server
|
|
118
|
+
* annotating, or a server-side classification entry — is one step away.
|
|
119
|
+
*/
|
|
120
|
+
function unclassifiedRefusal(toolName: string): string {
|
|
121
|
+
return (
|
|
122
|
+
`Tool "${toolName}" is not available in this session: its MCP server ` +
|
|
123
|
+
`does not declare whether it reads or writes (readOnlyHint/` +
|
|
124
|
+
`destructiveHint), and no server-side classification exists for it, so ` +
|
|
125
|
+
`this session cannot tell whether calling it would change anything ` +
|
|
126
|
+
`outside the session. Use a tool that declares its effect.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
/** Decide what to do with one tool call. Pure; no I/O, no session state. */
|
|
68
131
|
export function decideToolCall(facts: ToolCallFacts): ToolDecision {
|
|
69
132
|
if (!facts.dryRun) {
|
|
70
133
|
return { kind: "run" };
|
|
71
134
|
}
|
|
135
|
+
if (facts.effect === undefined && facts.mcpTool !== undefined) {
|
|
136
|
+
const effect = classifyAnnotationsEffect(facts.mcpTool.annotations);
|
|
137
|
+
if (effect === undefined) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "refuse",
|
|
140
|
+
message: unclassifiedRefusal(facts.mcpTool.name),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (effect === "read") {
|
|
144
|
+
return { kind: "run" };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
kind: "answer",
|
|
148
|
+
result: dryRunAnswer(facts.dryRunResult, facts.input),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
72
151
|
if (resolveEffect(facts.effect, facts.input) === "read") {
|
|
73
152
|
return { kind: "run" };
|
|
74
153
|
}
|
package/src/types.ts
CHANGED
|
@@ -1439,6 +1439,17 @@ export type SessionEventPayload =
|
|
|
1439
1439
|
parentCallId?: string;
|
|
1440
1440
|
};
|
|
1441
1441
|
}
|
|
1442
|
+
| {
|
|
1443
|
+
/**
|
|
1444
|
+
* A dry-run call was refused because nothing classifies the tool's
|
|
1445
|
+
* effect: the MCP server sent no readOnlyHint/destructiveHint and no
|
|
1446
|
+
* server-side classification exists for it. The per-run count of these
|
|
1447
|
+
* is the coverage signal — how much of a backtest was actually
|
|
1448
|
+
* runnable, reported rather than assumed.
|
|
1449
|
+
*/
|
|
1450
|
+
type: "action.effect_unclassified";
|
|
1451
|
+
data: { callId: string; toolName: string; connection?: string };
|
|
1452
|
+
}
|
|
1442
1453
|
| {
|
|
1443
1454
|
type: "subagent.called";
|
|
1444
1455
|
data: { callId: string; name?: string; description?: string };
|
|
@@ -2620,6 +2631,14 @@ export interface DiscoveredTool {
|
|
|
2620
2631
|
* classification can read them; absent is the fail-closed state.
|
|
2621
2632
|
*/
|
|
2622
2633
|
annotations?: McpToolAnnotations;
|
|
2634
|
+
/**
|
|
2635
|
+
* True for a 1:1 MCP passthrough synthesized from an advertised
|
|
2636
|
+
* connection's listing. The policy treats these differently from authored
|
|
2637
|
+
* tools when nothing declares an effect: an undeclared authored tool is
|
|
2638
|
+
* stubbed as a write in a dry run, an unclassifiable advertised tool is
|
|
2639
|
+
* refused — see `decideToolCall`.
|
|
2640
|
+
*/
|
|
2641
|
+
advertised?: true;
|
|
2623
2642
|
/**
|
|
2624
2643
|
* Agent tools: script body materialized under
|
|
2625
2644
|
* `.agent-serve/tools/<name>.sh`.
|