@cursor/july 0.1.12 → 0.1.15

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.
Files changed (168) hide show
  1. package/dist/bin/agent-serve.js +32 -9
  2. package/dist/channels/github/api.d.ts.map +1 -1
  3. package/dist/channels/github/api.js +11 -4
  4. package/dist/channels/github/cursor-account.d.ts +21 -1
  5. package/dist/channels/github/cursor-account.d.ts.map +1 -1
  6. package/dist/channels/github/cursor-account.js +29 -1
  7. package/dist/channels/github/github-channel.d.ts.map +1 -1
  8. package/dist/channels/github/github-channel.js +1 -1
  9. package/dist/channels/slack/api.d.ts +9 -0
  10. package/dist/channels/slack/api.d.ts.map +1 -1
  11. package/dist/channels/slack/api.js +40 -7
  12. package/dist/channels/slack/eval-directive.d.ts +3 -0
  13. package/dist/channels/slack/eval-directive.d.ts.map +1 -1
  14. package/dist/channels/slack/eval-directive.js +20 -1
  15. package/dist/channels/slack/post-update-delivery.d.ts +85 -0
  16. package/dist/channels/slack/post-update-delivery.d.ts.map +1 -0
  17. package/dist/docs/404.html +2 -2
  18. package/dist/docs/ab.html +3 -3
  19. package/dist/docs/assets/{app.Dh_JkJjI.js → app.DpdGZTLe.js} +1 -1
  20. package/dist/docs/assets/chunks/@localSearchIndexroot.D2d8CfFS.js +1 -0
  21. package/dist/docs/assets/chunks/{VPLocalSearchBox.CptQNLkR.js → VPLocalSearchBox.IB-0QqIT.js} +1 -1
  22. package/dist/docs/assets/chunks/{theme.DWflH-LQ.js → theme.-fYBoNtR.js} +2 -2
  23. package/dist/docs/assets/{evals.md.DAgEc_hL.js → evals.md.DYOjkRCX.js} +10 -4
  24. package/dist/docs/assets/{evals.md.DAgEc_hL.lean.js → evals.md.DYOjkRCX.lean.js} +1 -1
  25. package/dist/docs/assets/{reference_cli.md.B74NrVzb.js → reference_cli.md.DnYfr5V2.js} +4 -1
  26. package/dist/docs/assets/{reference_http-api.md.Dx_nmDG6.js → reference_http-api.md.BncLd3PZ.js} +2 -2
  27. package/dist/docs/assets/{reference_http-api.md.Dx_nmDG6.lean.js → reference_http-api.md.BncLd3PZ.lean.js} +1 -1
  28. package/dist/docs/building-with-agents.html +3 -3
  29. package/dist/docs/concepts.html +3 -3
  30. package/dist/docs/deployment.html +3 -3
  31. package/dist/docs/evals.html +13 -7
  32. package/dist/docs/example-agents/approval-buddy.html +3 -3
  33. package/dist/docs/example-agents/benny.html +3 -3
  34. package/dist/docs/example-agents/bugbot.html +3 -3
  35. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  36. package/dist/docs/example-agents/codeowners-review.html +3 -3
  37. package/dist/docs/example-agents/concierge.html +3 -3
  38. package/dist/docs/example-agents/fsd.html +3 -3
  39. package/dist/docs/example-agents/index.html +3 -3
  40. package/dist/docs/example-agents/knowledge-base.html +3 -3
  41. package/dist/docs/example-agents/oncall.html +3 -3
  42. package/dist/docs/example-agents/security-reviewer.html +3 -3
  43. package/dist/docs/example-agents/slack-agent.html +3 -3
  44. package/dist/docs/example-agents/weather-agent.html +3 -3
  45. package/dist/docs/guides/agent-to-agent.html +3 -3
  46. package/dist/docs/guides/cloud-runtime.html +3 -3
  47. package/dist/docs/guides/github.html +3 -3
  48. package/dist/docs/guides/human-in-the-loop.html +3 -3
  49. package/dist/docs/guides/mcp-oauth.html +3 -3
  50. package/dist/docs/guides/slack.html +3 -3
  51. package/dist/docs/guides/webhooks.html +3 -3
  52. package/dist/docs/hashmap.json +1 -1
  53. package/dist/docs/hillclimbing.html +3 -3
  54. package/dist/docs/index.html +3 -3
  55. package/dist/docs/quickstart.html +3 -3
  56. package/dist/docs/reference/agent-config.html +3 -3
  57. package/dist/docs/reference/channels.html +3 -3
  58. package/dist/docs/reference/cli.html +8 -5
  59. package/dist/docs/reference/connections.html +3 -3
  60. package/dist/docs/reference/hooks.html +3 -3
  61. package/dist/docs/reference/http-api.html +5 -5
  62. package/dist/docs/reference/instructions.html +3 -3
  63. package/dist/docs/reference/playground.html +3 -3
  64. package/dist/docs/reference/project-layout.html +3 -3
  65. package/dist/docs/reference/prompt.html +3 -3
  66. package/dist/docs/reference/schedules.html +3 -3
  67. package/dist/docs/reference/sessions.html +3 -3
  68. package/dist/docs/reference/skills.html +3 -3
  69. package/dist/docs/reference/subagents.html +3 -3
  70. package/dist/docs/reference/tools.html +3 -3
  71. package/dist/docs/scaffolding-agents.html +3 -3
  72. package/dist/docs/storage.html +3 -3
  73. package/dist/docs/troubleshooting.html +3 -3
  74. package/dist/evals.d.ts +49 -2
  75. package/dist/evals.d.ts.map +1 -1
  76. package/dist/evals.js +53 -16
  77. package/dist/internal/cli-ax.d.ts +4 -0
  78. package/dist/internal/cli-ax.d.ts.map +1 -1
  79. package/dist/internal/cli-ax.js +166 -33
  80. package/dist/internal/cli-deploy.d.ts +8 -0
  81. package/dist/internal/cli-deploy.d.ts.map +1 -1
  82. package/dist/internal/cli-deploy.js +92 -12
  83. package/dist/internal/cli-mcp-oauth.d.ts +14 -2
  84. package/dist/internal/cli-mcp-oauth.d.ts.map +1 -1
  85. package/dist/internal/cli-mcp-oauth.js +95 -2
  86. package/dist/internal/cursor/backend-client.d.ts +34 -0
  87. package/dist/internal/cursor/backend-client.d.ts.map +1 -1
  88. package/dist/internal/cursor/backend-client.js +73 -9
  89. package/dist/internal/cursor/credentials.d.ts +7 -1
  90. package/dist/internal/cursor/credentials.d.ts.map +1 -1
  91. package/dist/internal/cursor/credentials.js +9 -3
  92. package/dist/internal/cursor-account-mcp-auth.d.ts +55 -0
  93. package/dist/internal/cursor-account-mcp-auth.d.ts.map +1 -0
  94. package/dist/internal/cursor-account-mcp-auth.js +175 -0
  95. package/dist/internal/deploy-client.d.ts +6 -0
  96. package/dist/internal/deploy-client.d.ts.map +1 -1
  97. package/dist/internal/deploy-client.js +13 -0
  98. package/dist/internal/eval-run-store.d.ts +15 -0
  99. package/dist/internal/eval-run-store.d.ts.map +1 -1
  100. package/dist/internal/eval-run-store.js +94 -8
  101. package/dist/internal/eval-runner.d.ts +14 -0
  102. package/dist/internal/eval-runner.d.ts.map +1 -1
  103. package/dist/internal/eval-runner.js +111 -22
  104. package/dist/internal/evals-client.d.ts +9 -1
  105. package/dist/internal/evals-client.d.ts.map +1 -1
  106. package/dist/internal/evals-client.js +44 -1
  107. package/dist/internal/http-channel.d.ts.map +1 -1
  108. package/dist/internal/http-channel.js +74 -7
  109. package/dist/internal/map-pool.d.ts +6 -1
  110. package/dist/internal/map-pool.d.ts.map +1 -1
  111. package/dist/internal/map-pool.js +8 -1
  112. package/dist/internal/mcp-host.d.ts +13 -0
  113. package/dist/internal/mcp-host.d.ts.map +1 -1
  114. package/dist/internal/mcp-host.js +68 -13
  115. package/dist/internal/persistence-coordinator.d.ts +127 -0
  116. package/dist/internal/persistence-coordinator.d.ts.map +1 -0
  117. package/dist/internal/run-client.d.ts +5 -1
  118. package/dist/internal/run-client.d.ts.map +1 -1
  119. package/dist/internal/run-client.js +26 -5
  120. package/dist/internal/server.d.ts.map +1 -1
  121. package/dist/internal/server.js +57 -4
  122. package/dist/internal/session-engine.d.ts +13 -0
  123. package/dist/internal/session-engine.d.ts.map +1 -1
  124. package/dist/internal/session-engine.js +49 -0
  125. package/dist/internal/storage-coordinator.d.ts +7 -0
  126. package/dist/internal/storage-coordinator.d.ts.map +1 -1
  127. package/dist/internal/storage-coordinator.js +29 -4
  128. package/dist/persistence.d.ts +184 -0
  129. package/dist/persistence.d.ts.map +1 -0
  130. package/dist/playground/assets/{index-1dzluIcW.js → index-BVMOoiek.js} +44 -44
  131. package/dist/playground/assets/index-DYmP4b--.css +1 -0
  132. package/dist/playground/index.html +2 -2
  133. package/dist/types.d.ts +10 -0
  134. package/dist/types.d.ts.map +1 -1
  135. package/docs/evals.md +46 -11
  136. package/docs/reference/cli.md +3 -0
  137. package/docs/reference/http-api.md +2 -1
  138. package/package.json +1 -1
  139. package/skills/evals/SKILL.md +10 -0
  140. package/src/bin/agent-serve.ts +38 -8
  141. package/src/channels/github/api.ts +12 -1
  142. package/src/channels/github/cursor-account.ts +51 -1
  143. package/src/channels/github/github-channel.ts +4 -1
  144. package/src/channels/slack/api.ts +57 -8
  145. package/src/channels/slack/eval-directive.ts +24 -1
  146. package/src/evals.ts +112 -25
  147. package/src/internal/cli-ax.ts +195 -16
  148. package/src/internal/cli-deploy.ts +140 -13
  149. package/src/internal/cli-mcp-oauth.ts +122 -3
  150. package/src/internal/cursor/backend-client.ts +104 -1
  151. package/src/internal/cursor/credentials.ts +10 -2
  152. package/src/internal/cursor-account-mcp-auth.ts +241 -0
  153. package/src/internal/deploy-client.ts +16 -0
  154. package/src/internal/eval-run-store.ts +97 -6
  155. package/src/internal/eval-runner.ts +129 -18
  156. package/src/internal/evals-client.ts +82 -0
  157. package/src/internal/http-channel.ts +87 -6
  158. package/src/internal/map-pool.ts +8 -1
  159. package/src/internal/mcp-host.ts +74 -16
  160. package/src/internal/run-client.ts +32 -1
  161. package/src/internal/server.ts +69 -2
  162. package/src/internal/session-engine.ts +58 -0
  163. package/src/internal/storage-coordinator.ts +32 -6
  164. package/src/types.ts +10 -0
  165. package/dist/docs/assets/chunks/@localSearchIndexroot.Er_R50vY.js +0 -1
  166. package/dist/internal/json-dir-store.js +0 -100
  167. package/dist/playground/assets/index-kZjeJs1v.css +0 -1
  168. /package/dist/docs/assets/{reference_cli.md.B74NrVzb.lean.js → reference_cli.md.DnYfr5V2.lean.js} +0 -0
@@ -85,6 +85,48 @@ const mcpToolSchema = z.object({
85
85
  inputSchema: z.unknown().optional(),
86
86
  });
87
87
 
88
+ const getAvailableMcpServersResponseSchema = z.object({
89
+ servers: z
90
+ .array(
91
+ z.object({
92
+ id: z.number().optional(),
93
+ name: z.string().optional(),
94
+ isTeamServer: z.boolean().optional(),
95
+ serverIdentifier: z.string().optional(),
96
+ })
97
+ )
98
+ .optional(),
99
+ });
100
+
101
+ /** One connector row from the account's dashboard MCP listing. */
102
+ export interface AvailableMcpServer {
103
+ id: number;
104
+ name: string;
105
+ isTeamServer: boolean;
106
+ serverIdentifier?: string;
107
+ }
108
+
109
+ const checkHttpMcpStatusResponseSchema = z.object({
110
+ statuses: z
111
+ .array(
112
+ z.object({
113
+ id: z.number().optional(),
114
+ requiresAuth: z.boolean().optional(),
115
+ authUrl: z.string().optional(),
116
+ error: z.string().optional(),
117
+ })
118
+ )
119
+ .optional(),
120
+ });
121
+
122
+ /** Per-connector OAuth status (SA-scoped when `serviceAccountId` was sent). */
123
+ export interface McpConnectorStatus {
124
+ id: number;
125
+ requiresAuth: boolean;
126
+ authUrl?: string;
127
+ error?: string;
128
+ }
129
+
88
130
  const listSandMcpToolsResponseSchema = z.object({
89
131
  servers: z
90
132
  .array(
@@ -180,6 +222,7 @@ async function dashboardRpc(args: {
180
222
  bearer: string;
181
223
  body: unknown;
182
224
  timeoutMs: number;
225
+ extraHeaders?: Record<string, string>;
183
226
  }): Promise<Response> {
184
227
  try {
185
228
  return await args.fetchImpl(
@@ -193,6 +236,7 @@ async function dashboardRpc(args: {
193
236
  "x-cursor-client-version": CLIENT_VERSION,
194
237
  "x-cursor-client-type": "agent-serve",
195
238
  "x-request-id": randomUUID(),
239
+ ...args.extraHeaders,
196
240
  },
197
241
  body: JSON.stringify(args.body ?? {}),
198
242
  signal: AbortSignal.timeout(args.timeoutMs),
@@ -309,6 +353,58 @@ export class CursorBackendClient {
309
353
  return toCallResult(parsed.result ?? {});
310
354
  }
311
355
 
356
+ /**
357
+ * The account's dashboard MCP connector listing (id + name per server).
358
+ * `teamId` pins the ambient team so team-scoped connectors resolve for
359
+ * multi-team accounts.
360
+ */
361
+ async getAvailableMcpServers(options?: {
362
+ teamId?: number;
363
+ }): Promise<AvailableMcpServer[]> {
364
+ const raw = await this.rpc("GetAvailableMcpServers", {}, LIST_TIMEOUT_MS, {
365
+ teamId: options?.teamId,
366
+ });
367
+ const parsed = getAvailableMcpServersResponseSchema.parse(raw);
368
+ return (parsed.servers ?? [])
369
+ .filter((server) => server.id !== undefined && server.id > 0)
370
+ .map((server) => ({
371
+ id: server.id!,
372
+ name: server.name ?? "",
373
+ isTeamServer: server.isTeamServer === true,
374
+ serverIdentifier: server.serverIdentifier,
375
+ }));
376
+ }
377
+
378
+ /**
379
+ * Probe connectors for a service account and mint OAuth `authUrl`s where
380
+ * authorization is needed. This is the dashboard's Authenticate flow: the
381
+ * caller (a team member) opens the returned URL, the cursor.com callback
382
+ * stores the token backend-side against the service account, and repeat
383
+ * probes flip `requiresAuth` off.
384
+ */
385
+ async checkHttpMcpStatus(args: {
386
+ serverIds: number[];
387
+ oauthRedirectUri: string;
388
+ serviceAccountId: string;
389
+ }): Promise<McpConnectorStatus[]> {
390
+ const raw = await this.rpc(
391
+ "CheckHttpMcpStatus",
392
+ {
393
+ serverIds: args.serverIds,
394
+ oauthRedirectUri: args.oauthRedirectUri,
395
+ serviceAccountId: args.serviceAccountId,
396
+ },
397
+ LIST_TIMEOUT_MS
398
+ );
399
+ const parsed = checkHttpMcpStatusResponseSchema.parse(raw);
400
+ return (parsed.statuses ?? []).map((status) => ({
401
+ id: status.id ?? 0,
402
+ requiresAuth: status.requiresAuth === true,
403
+ authUrl: status.authUrl,
404
+ error: status.error,
405
+ }));
406
+ }
407
+
312
408
  /** Mint a short-lived GitHub token scoped to the requested repositories. */
313
409
  async getGithubToken(
314
410
  repos: readonly string[],
@@ -356,8 +452,13 @@ export class CursorBackendClient {
356
452
  private async rpc(
357
453
  method: string,
358
454
  body: unknown,
359
- timeoutMs = LIST_TIMEOUT_MS
455
+ timeoutMs = LIST_TIMEOUT_MS,
456
+ options?: { teamId?: number }
360
457
  ): Promise<unknown> {
458
+ const extraHeaders =
459
+ options?.teamId === undefined
460
+ ? undefined
461
+ : { "x-cursor-team-id": String(options.teamId) };
361
462
  const bearer = await this.getAccessToken();
362
463
  const first = await dashboardRpc({
363
464
  fetchImpl: this.fetchImpl,
@@ -366,6 +467,7 @@ export class CursorBackendClient {
366
467
  bearer,
367
468
  body,
368
469
  timeoutMs,
470
+ extraHeaders,
369
471
  });
370
472
  if (first.status !== 401) {
371
473
  return parseRpcResponse(method, first);
@@ -385,6 +487,7 @@ export class CursorBackendClient {
385
487
  bearer: retryBearer,
386
488
  body,
387
489
  timeoutMs,
490
+ extraHeaders,
388
491
  });
389
492
  if (second.status === 401 && this.token?.value === retryBearer) {
390
493
  // The freshly minted token was rejected too; do not keep serving it.
@@ -26,10 +26,18 @@ export const DEFAULT_CURSOR_BACKEND_URL = "https://api2.cursor.sh";
26
26
  export const DEFAULT_CURSOR_EXTERNAL_API_URL = "https://api.cursor.com";
27
27
  const DEFAULT_CURSOR_WEBSITE_URL = "https://cursor.com";
28
28
 
29
- /** Backend API base URL (`CURSOR_API_BASE_URL` override for local dev). */
29
+ /**
30
+ * Base URL for main-backend-only routes (key exchange, DashboardService —
31
+ * these 404 on api.cursor.com). Hosted pods pin `CURSOR_API_BASE_URL` to the
32
+ * external API, so `AGENT_SERVE_CURSOR_BACKEND_URL` re-pins this family
33
+ * there. Deliberately namespaced: pod env propagates to the SDK harness,
34
+ * which reads the generic `CURSOR_BACKEND_URL` for its own hosts.
35
+ */
30
36
  export function cursorBackendUrl(): string {
31
37
  return trimTrailingSlash(
32
- process.env.CURSOR_API_BASE_URL ?? DEFAULT_CURSOR_BACKEND_URL
38
+ process.env.AGENT_SERVE_CURSOR_BACKEND_URL ??
39
+ process.env.CURSOR_API_BASE_URL ??
40
+ DEFAULT_CURSOR_BACKEND_URL
33
41
  );
34
42
  }
35
43
 
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Service-account connector authorization for
3
+ * `defineConnection({ cursorAccount: true })` on hosted deployments.
4
+ *
5
+ * Hosted engines authenticate as their deployment's dedicated service
6
+ * account, whose connector OAuth tokens live backend-side. This flow is the
7
+ * dashboard's Authenticate button as a CLI loop: probe the connectors via
8
+ * `CheckHttpMcpStatus` (which mints a complete OAuth `authUrl` per connector
9
+ * needing auth), open each URL for the human to grant, and poll until the
10
+ * backend reports the token stored. Nothing token-shaped ever touches this
11
+ * process — the cursor.com callback stores tokens against the service
12
+ * account. Used by `agentkit mcp oauth <connection>` and the post-deploy
13
+ * hook in `cli-deploy.ts`.
14
+ */
15
+
16
+ import type {
17
+ AvailableMcpServer,
18
+ CursorBackendClient,
19
+ McpConnectorStatus,
20
+ } from "./cursor/backend-client.js";
21
+ import { LOG_PREFIX } from "./distribution.js";
22
+ import { openBrowser } from "./mcp-oauth.js";
23
+
24
+ const DEFAULT_POLL_INTERVAL_MS = 3_000;
25
+ const DEFAULT_POLL_TIMEOUT_MS = 5 * 60 * 1000;
26
+
27
+ /** Same default the dashboard uses; MCP_OAUTH_REDIRECT_URI overrides for dev. */
28
+ export function mcpOauthRedirectUri(): string {
29
+ const override = process.env.MCP_OAUTH_REDIRECT_URI;
30
+ if (override !== undefined && override.trim() !== "") {
31
+ return override.trim();
32
+ }
33
+ return "https://www.cursor.com/agents/mcp/oauth/callback";
34
+ }
35
+
36
+ export interface AuthorizeCursorAccountConnectorsArgs {
37
+ backendClient: Pick<
38
+ CursorBackendClient,
39
+ "getAvailableMcpServers" | "checkHttpMcpStatus"
40
+ >;
41
+ teamId: number;
42
+ slug: string;
43
+ /** The deployment's dedicated service account (`sa_…`). */
44
+ serviceAccountId: string;
45
+ /** Connection name, for log lines. */
46
+ connectionName: string;
47
+ /** The connection's `servers` filter; undefined = every team connector. */
48
+ servers?: string[] | undefined;
49
+ /** Open auth URLs and poll for completion (TTY); false = report only. */
50
+ interactive: boolean;
51
+ out: (text: string) => void;
52
+ err: (text: string) => void;
53
+ /** Test seams. */
54
+ openBrowserImpl?: (url: string) => void;
55
+ sleep?: (ms: number) => Promise<void>;
56
+ pollIntervalMs?: number;
57
+ pollTimeoutMs?: number;
58
+ }
59
+
60
+ export interface AuthorizeCursorAccountConnectorsResult {
61
+ /** Connectors with a stored credential after the flow. */
62
+ authorized: string[];
63
+ /** Connectors still requiring authorization. */
64
+ pending: string[];
65
+ /** Connectors the backend could not probe. */
66
+ errored: Array<{ name: string; error: string }>;
67
+ }
68
+
69
+ function sleepMs(ms: number): Promise<void> {
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+
73
+ /**
74
+ * Authorize the deployment service account for the connection's connectors.
75
+ * Already-authorized connectors are skipped; the rest get a browser OAuth
76
+ * each (sequentially), polled until the backend stores the token.
77
+ */
78
+ export async function authorizeCursorAccountConnectors(
79
+ args: AuthorizeCursorAccountConnectorsArgs
80
+ ): Promise<AuthorizeCursorAccountConnectorsResult> {
81
+ const { backendClient, teamId, serviceAccountId, connectionName, out, err } =
82
+ args;
83
+ const open = args.openBrowserImpl ?? openBrowser;
84
+ const sleep = args.sleep ?? sleepMs;
85
+ const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
86
+ const pollTimeoutMs = args.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
87
+ const redirectUri = mcpOauthRedirectUri();
88
+
89
+ // Team-scoped connectors only: the hosted bridge resolves the SA's team
90
+ // servers, so personal connectors can never apply to a deployment.
91
+ const available = (
92
+ await backendClient.getAvailableMcpServers({ teamId })
93
+ ).filter((server) => server.isTeamServer);
94
+
95
+ const selected = selectConnectors(available, args.servers);
96
+ for (const missing of selected.unmatched) {
97
+ err(
98
+ `${LOG_PREFIX} Connection "${connectionName}" lists connector ${JSON.stringify(missing)}, but the team has no such connector (dashboard → MCP).\n`
99
+ );
100
+ }
101
+ if (selected.servers.length === 0) {
102
+ err(
103
+ `${LOG_PREFIX} No matching team connectors for connection "${connectionName}".\n`
104
+ );
105
+ return { authorized: [], pending: [], errored: [] };
106
+ }
107
+
108
+ const nameById = new Map(
109
+ selected.servers.map((server) => [server.id, server.name])
110
+ );
111
+ const statuses = await backendClient.checkHttpMcpStatus({
112
+ serverIds: selected.servers.map((server) => server.id),
113
+ oauthRedirectUri: redirectUri,
114
+ serviceAccountId,
115
+ });
116
+
117
+ const result: AuthorizeCursorAccountConnectorsResult = {
118
+ authorized: [],
119
+ pending: [],
120
+ errored: [],
121
+ };
122
+
123
+ for (const status of statuses) {
124
+ const name = nameById.get(status.id) ?? `#${status.id}`;
125
+ if (!status.requiresAuth) {
126
+ if (status.error !== undefined && status.error !== "") {
127
+ err(`${LOG_PREFIX} ${name}: ${status.error}\n`);
128
+ result.errored.push({ name, error: status.error });
129
+ continue;
130
+ }
131
+ out(`${LOG_PREFIX} ${name}: already authorized ✓\n`);
132
+ result.authorized.push(name);
133
+ continue;
134
+ }
135
+ if (status.authUrl === undefined || status.authUrl === "") {
136
+ const error = status.error ?? "no authorization URL was minted";
137
+ err(`${LOG_PREFIX} ${name}: ${error}\n`);
138
+ result.errored.push({ name, error });
139
+ continue;
140
+ }
141
+ if (!args.interactive) {
142
+ result.pending.push(name);
143
+ continue;
144
+ }
145
+
146
+ out(
147
+ `${LOG_PREFIX} ${name}: authorization needed. You'll authorize ${name} as yourself; ` +
148
+ `anyone who can talk to this agent can act with that access.\n` +
149
+ `${LOG_PREFIX} Opening (or open manually):\n ${status.authUrl}\n`
150
+ );
151
+ open(status.authUrl);
152
+
153
+ const authorized = await pollUntilAuthorized({
154
+ backendClient,
155
+ serverId: status.id,
156
+ serviceAccountId,
157
+ redirectUri,
158
+ sleep,
159
+ pollIntervalMs,
160
+ pollTimeoutMs,
161
+ });
162
+ if (authorized) {
163
+ out(`${LOG_PREFIX} ${name}: authorized ✓\n`);
164
+ result.authorized.push(name);
165
+ } else {
166
+ err(
167
+ `${LOG_PREFIX} ${name}: still waiting on authorization; giving up for now.\n`
168
+ );
169
+ result.pending.push(name);
170
+ }
171
+ }
172
+
173
+ return result;
174
+ }
175
+
176
+ function selectConnectors(
177
+ available: AvailableMcpServer[],
178
+ filter: string[] | undefined
179
+ ): { servers: AvailableMcpServer[]; unmatched: string[] } {
180
+ if (filter === undefined || filter.length === 0) {
181
+ return { servers: available, unmatched: [] };
182
+ }
183
+ const servers: AvailableMcpServer[] = [];
184
+ const seen = new Set<number>();
185
+ const unmatched: string[] = [];
186
+ for (const name of filter) {
187
+ const wanted = name.trim().toLowerCase();
188
+ const matches = available.filter(
189
+ (server) =>
190
+ server.name.toLowerCase() === wanted ||
191
+ server.serverIdentifier?.toLowerCase() === wanted
192
+ );
193
+ if (matches.length === 0) {
194
+ unmatched.push(name);
195
+ continue;
196
+ }
197
+ for (const match of matches) {
198
+ if (!seen.has(match.id)) {
199
+ seen.add(match.id);
200
+ servers.push(match);
201
+ }
202
+ }
203
+ }
204
+ return { servers, unmatched };
205
+ }
206
+
207
+ async function pollUntilAuthorized(args: {
208
+ backendClient: Pick<CursorBackendClient, "checkHttpMcpStatus">;
209
+ serverId: number;
210
+ serviceAccountId: string;
211
+ redirectUri: string;
212
+ sleep: (ms: number) => Promise<void>;
213
+ pollIntervalMs: number;
214
+ pollTimeoutMs: number;
215
+ }): Promise<boolean> {
216
+ const deadline = Date.now() + args.pollTimeoutMs;
217
+ // Each unauthorized probe is a full backend connect to the provider, so
218
+ // back off while the human works through the OAuth pages.
219
+ let intervalMs = args.pollIntervalMs;
220
+ const maxIntervalMs = Math.max(args.pollIntervalMs, 15_000);
221
+ while (Date.now() < deadline) {
222
+ await args.sleep(intervalMs);
223
+ intervalMs = Math.min(intervalMs * 1.5, maxIntervalMs);
224
+ let statuses: McpConnectorStatus[];
225
+ try {
226
+ statuses = await args.backendClient.checkHttpMcpStatus({
227
+ serverIds: [args.serverId],
228
+ oauthRedirectUri: args.redirectUri,
229
+ serviceAccountId: args.serviceAccountId,
230
+ });
231
+ } catch {
232
+ // Transient probe errors; keep polling until the deadline.
233
+ continue;
234
+ }
235
+ const status = statuses.find((entry) => entry.id === args.serverId);
236
+ if (status !== undefined && !status.requiresAuth) {
237
+ return true;
238
+ }
239
+ }
240
+ return false;
241
+ }
@@ -175,6 +175,22 @@ export interface DeploymentDetail extends Deployment {
175
175
  engineAccess?: EngineAccess | null;
176
176
  }
177
177
 
178
+ /**
179
+ * The deployment's dedicated service-account id (`sa_…`), or undefined for
180
+ * legacy rows that predate service-account-backed deploys. Normalizes the
181
+ * wire shape (string in prod, number in old fixtures, null when unset).
182
+ */
183
+ export function deploymentServiceAccountId(
184
+ deployment: Pick<Deployment, "serviceAccountId">
185
+ ): string | undefined {
186
+ const raw = deployment.serviceAccountId;
187
+ if (raw === null || raw === undefined) {
188
+ return undefined;
189
+ }
190
+ const id = String(raw).trim();
191
+ return id === "" ? undefined : id;
192
+ }
193
+
178
194
  export interface StopAccepted {
179
195
  id: number;
180
196
  status: string;
@@ -63,10 +63,13 @@ const INTERRUPTED_BATCH_ERROR =
63
63
  "Eval run interrupted (serve process exited before completion)";
64
64
  const INTERRUPTED_CASE_ERROR =
65
65
  "Eval case interrupted (serve process exited before completion)";
66
+ const CANCELLED_BATCH_ERROR = "Eval run cancelled";
67
+ const CANCELLED_CASE_ERROR = "Eval case cancelled";
66
68
 
67
69
  export class EvalRunStore {
68
70
  private readonly runs = new Map<string, EvalRunSnapshot>();
69
71
  private activeRunId: string | undefined;
72
+ private readonly abortByRunId = new Map<string, AbortController>();
70
73
  private hydratePromise: Promise<void> | undefined;
71
74
  private hydrated = false;
72
75
  /** Filled after listen — e.g. `http://127.0.0.1:3000/weather-agent`. */
@@ -178,6 +181,8 @@ export class EvalRunStore {
178
181
  };
179
182
  this.runs.set(runId, snapshot);
180
183
  this.activeRunId = runId;
184
+ const abort = new AbortController();
185
+ this.abortByRunId.set(runId, abort);
181
186
  this.pruneOldRuns(config);
182
187
  this.persist(snapshot);
183
188
  this.logger(
@@ -188,6 +193,7 @@ export class EvalRunStore {
188
193
  baseUrl: loopbackUrl,
189
194
  evals: selected,
190
195
  config,
196
+ signal: abort.signal,
191
197
  ...(input.bearerToken === undefined
192
198
  ? {}
193
199
  : { bearerToken: input.bearerToken }),
@@ -198,6 +204,33 @@ export class EvalRunStore {
198
204
  return snapshot;
199
205
  }
200
206
 
207
+ /**
208
+ * Cancel a running batch by Eval ID (`runId`). Stops starting new cases and
209
+ * aborts in-flight turns. Idempotent when already cancelled.
210
+ */
211
+ async cancel(runId: string): Promise<EvalRunSnapshot> {
212
+ await this.hydrate();
213
+ const run = this.runs.get(runId);
214
+ if (run === undefined) {
215
+ throw new EvalRunUnknownError(runId);
216
+ }
217
+ if (run.status === "cancelled") {
218
+ return run;
219
+ }
220
+ if (run.status !== "running") {
221
+ throw new EvalRunNotRunningError(runId, run.status);
222
+ }
223
+ const abort = this.abortByRunId.get(runId);
224
+ abort?.abort();
225
+ markCancelledRun(run);
226
+ this.persist(run);
227
+ if (this.activeRunId === runId) {
228
+ this.activeRunId = undefined;
229
+ }
230
+ this.logger(`[agentkit] eval run ${runId}: cancelled`);
231
+ return run;
232
+ }
233
+
201
234
  private sortedRuns(): EvalRunSnapshot[] {
202
235
  return [...this.runs.values()].sort((a, b) =>
203
236
  b.startedAt.localeCompare(a.startedAt)
@@ -306,9 +339,11 @@ export class EvalRunStore {
306
339
  if (run === undefined || run.status !== "running") {
307
340
  return;
308
341
  }
342
+ this.abortByRunId.get(activeRunId)?.abort();
309
343
  markInterruptedRun(run);
310
344
  this.persist(run);
311
345
  this.activeRunId = undefined;
346
+ this.abortByRunId.delete(activeRunId);
312
347
  }
313
348
 
314
349
  private async execute(
@@ -320,6 +355,7 @@ export class EvalRunStore {
320
355
  bearerToken?: string;
321
356
  verbose?: boolean;
322
357
  timeoutMs?: number;
358
+ signal: AbortSignal;
323
359
  }
324
360
  ): Promise<void> {
325
361
  if (this.runs.get(runId) === undefined) {
@@ -330,6 +366,7 @@ export class EvalRunStore {
330
366
  projectRoot: this.projectRoot,
331
367
  baseUrl: opts.baseUrl,
332
368
  discovered: { evals: opts.evals, config: opts.config },
369
+ signal: opts.signal,
333
370
  ...(opts.bearerToken === undefined
334
371
  ? {}
335
372
  : { bearerToken: opts.bearerToken }),
@@ -337,7 +374,7 @@ export class EvalRunStore {
337
374
  ...(opts.timeoutMs === undefined ? {} : { timeoutMs: opts.timeoutMs }),
338
375
  onCaseStart: (id) => {
339
376
  const run = this.runs.get(runId);
340
- if (run === undefined) {
377
+ if (run === undefined || run.status === "cancelled") {
341
378
  return;
342
379
  }
343
380
  const c = run.cases.find((x) => x.id === id);
@@ -346,6 +383,10 @@ export class EvalRunStore {
346
383
  c.inputs = [];
347
384
  c.toolCalls = [];
348
385
  c.finalText = undefined;
386
+ c.metrics = undefined;
387
+ c.logs = undefined;
388
+ c.assertions = undefined;
389
+ c.error = undefined;
349
390
  }
350
391
  },
351
392
  onCaseEvent: (id, event) => {
@@ -387,7 +428,7 @@ export class EvalRunStore {
387
428
  },
388
429
  });
389
430
  const run = this.runs.get(runId);
390
- if (run === undefined) {
431
+ if (run === undefined || run.status === "cancelled") {
391
432
  return;
392
433
  }
393
434
  run.status = "completed";
@@ -398,15 +439,23 @@ export class EvalRunStore {
398
439
  );
399
440
  } catch (error) {
400
441
  const run = this.runs.get(runId);
401
- if (run === undefined) {
442
+ if (run === undefined || run.status === "cancelled") {
443
+ return;
444
+ }
445
+ const message = error instanceof Error ? error.message : String(error);
446
+ if (opts.signal.aborted || message === "cancelled") {
447
+ markCancelledRun(run);
448
+ this.persist(run);
449
+ this.logger(`[agentkit] eval run ${runId}: cancelled`);
402
450
  return;
403
451
  }
404
452
  run.status = "failed";
405
453
  run.finishedAt = new Date().toISOString();
406
- run.error = error instanceof Error ? error.message : String(error);
454
+ run.error = message;
407
455
  this.persist(run);
408
456
  this.logger(`[agentkit] eval run ${runId}: failed — ${run.error}`);
409
457
  } finally {
458
+ this.abortByRunId.delete(runId);
410
459
  if (this.activeRunId === runId) {
411
460
  this.activeRunId = undefined;
412
461
  }
@@ -422,11 +471,16 @@ export class EvalRunStore {
422
471
  if (c === undefined) {
423
472
  return;
424
473
  }
474
+ const alreadyCounted = c.status === "done";
425
475
  c.status = "done";
426
476
  c.ok = result.ok;
427
477
  c.assertions = result.assertions;
428
478
  c.durationMs = result.durationMs;
429
479
  c.logs = result.logs;
480
+ c.metrics =
481
+ Object.keys(result.metrics).length > 0
482
+ ? { ...result.metrics }
483
+ : undefined;
430
484
  c.inputs = result.inputs;
431
485
  if (result.toolCalls !== undefined) {
432
486
  c.toolCalls = result.toolCalls;
@@ -443,6 +497,10 @@ export class EvalRunStore {
443
497
  if (result.tools !== undefined) {
444
498
  c.tools = result.tools;
445
499
  }
500
+ // Batch already finalized by cancel — keep case details, skip recount.
501
+ if (run.status === "cancelled" || alreadyCounted) {
502
+ return;
503
+ }
446
504
  run.summary.done += 1;
447
505
  if (result.ok) {
448
506
  run.summary.passed += 1;
@@ -463,8 +521,24 @@ function markInterruptedRun(run: EvalRunSnapshot): void {
463
521
  c.error = c.error ?? INTERRUPTED_CASE_ERROR;
464
522
  }
465
523
  }
466
- // Reconcile the summary with the repaired case rows so a persisted
467
- // interrupted batch never claims pre-interruption pass counts.
524
+ reconcileRunSummary(run);
525
+ }
526
+
527
+ function markCancelledRun(run: EvalRunSnapshot): void {
528
+ run.status = "cancelled";
529
+ run.finishedAt = run.finishedAt ?? new Date().toISOString();
530
+ run.error = CANCELLED_BATCH_ERROR;
531
+ for (const c of run.cases) {
532
+ if (c.status === "pending" || c.status === "running") {
533
+ c.status = "done";
534
+ c.ok = false;
535
+ c.error = CANCELLED_CASE_ERROR;
536
+ }
537
+ }
538
+ reconcileRunSummary(run);
539
+ }
540
+
541
+ function reconcileRunSummary(run: EvalRunSnapshot): void {
468
542
  let passed = 0;
469
543
  let failed = 0;
470
544
  for (const c of run.cases) {
@@ -536,3 +610,20 @@ export class EvalRunEmptyError extends Error {
536
610
  this.name = "EvalRunEmptyError";
537
611
  }
538
612
  }
613
+
614
+ export class EvalRunUnknownError extends Error {
615
+ constructor(readonly runId: string) {
616
+ super(`Unknown eval run ${runId}`);
617
+ this.name = "EvalRunUnknownError";
618
+ }
619
+ }
620
+
621
+ export class EvalRunNotRunningError extends Error {
622
+ constructor(
623
+ readonly runId: string,
624
+ readonly status: EvalRunSnapshot["status"]
625
+ ) {
626
+ super(`Eval run ${runId} is not running (status: ${status})`);
627
+ this.name = "EvalRunNotRunningError";
628
+ }
629
+ }