@cursor/july 0.1.87 → 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 +8 -1
- package/dist/internal/advertise-tools.d.ts.map +1 -1
- package/dist/internal/advertise-tools.js +4 -2
- 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/cli-docs.js +11 -0
- package/dist/internal/cloud-merge.d.ts +3 -1
- package/dist/internal/cloud-merge.d.ts.map +1 -1
- package/dist/internal/cloud-merge.js +10 -2
- 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 +12 -0
- package/dist/internal/cursor/backend-client.d.ts.map +1 -1
- package/dist/internal/cursor/backend-client.js +27 -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/discovery.d.ts.map +1 -1
- package/dist/internal/discovery.js +87 -11
- package/dist/internal/docs-site.d.ts +13 -2
- package/dist/internal/docs-site.d.ts.map +1 -1
- package/dist/internal/docs-site.js +76 -13
- package/dist/internal/grokbot/runner.d.ts +61 -0
- package/dist/internal/grokbot/runner.d.ts.map +1 -0
- package/dist/internal/grokbot/runner.js +278 -0
- package/dist/internal/mcp-endpoint.js +6 -3
- package/dist/internal/mcp-host.d.ts +14 -1
- package/dist/internal/mcp-host.d.ts.map +1 -1
- package/dist/internal/mcp-host.js +41 -2
- package/dist/internal/runtime-dispatch-runner.d.ts +30 -0
- package/dist/internal/runtime-dispatch-runner.d.ts.map +1 -0
- package/dist/internal/runtime-dispatch-runner.js +60 -0
- package/dist/internal/sdk-runner.d.ts.map +1 -1
- package/dist/internal/sdk-runner.js +7 -0
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +24 -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 +121 -46
- 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 +86 -2
- 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 +9 -1
- package/src/internal/advertise-tools.ts +7 -0
- package/src/internal/cli-ax.ts +1 -1
- package/src/internal/cli-deploy.ts +123 -15
- package/src/internal/cli-docs.ts +11 -0
- package/src/internal/cloud-merge.ts +12 -2
- package/src/internal/cursor/account-mcp.ts +15 -1
- package/src/internal/cursor/backend-client.ts +37 -0
- package/src/internal/deploy-client.ts +122 -2
- package/src/internal/discovery.ts +133 -13
- package/src/internal/docs-site.ts +83 -13
- package/src/internal/grokbot/runner.ts +361 -0
- package/src/internal/mcp-endpoint.ts +6 -1
- package/src/internal/mcp-host.ts +48 -0
- package/src/internal/runtime-dispatch-runner.ts +63 -0
- package/src/internal/sdk-runner.ts +9 -0
- package/src/internal/server.ts +32 -4
- package/src/internal/session-engine.ts +126 -21
- package/src/internal/tool-policy.ts +80 -1
- package/src/types.ts +84 -2
- package/dist/playground/assets/index-C0_5hOsf.css +0 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grokbot runtime's remote client — a thin client like `"cloud"`.
|
|
3
|
+
* Turns execute on Cursor's hosted Grok Bot (Sand) harness (the Temporal
|
|
4
|
+
* turn workflow and the account's own computer), reached through the
|
|
5
|
+
* public `/v0/grokbot` session API.
|
|
6
|
+
*
|
|
7
|
+
* Sand delivery contract, preserved end to end: only content the agent
|
|
8
|
+
* delivers through SendToUser becomes the user-visible reply. The hosted
|
|
9
|
+
* transcript exposes those deliveries as `send-message` entries; the
|
|
10
|
+
* agent's internal monologue never appears in the transcript, so it can
|
|
11
|
+
* never leak into the session reply.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import type { RunnerTurnRequest, TurnOutcome } from "../../types.js";
|
|
16
|
+
import { cursorBackendUrl } from "../cursor/credentials.js";
|
|
17
|
+
import type { AgentRunner } from "./../sdk-runner.js";
|
|
18
|
+
|
|
19
|
+
export interface GrokBotRunnerOptions {
|
|
20
|
+
/** Cursor API key. Defaults to the `CURSOR_API_KEY` environment variable. */
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
/** Test seam. Defaults to global fetch. */
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
/** Test seam. Defaults to {@link cursorBackendUrl}. */
|
|
25
|
+
backendUrl?: string;
|
|
26
|
+
/** Test seam. Poll interval while a turn runs. */
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
/** Test seam. Hard turn deadline. */
|
|
29
|
+
turnTimeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One transcript entry as `/v0/grokbot/sessions/:id/entries` exposes it. */
|
|
33
|
+
export interface GrokBotEntry {
|
|
34
|
+
seq: string;
|
|
35
|
+
updatedSeq: string;
|
|
36
|
+
kind: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
text?: string;
|
|
39
|
+
createdAtMs: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GrokBotTurnStatus {
|
|
43
|
+
idle: boolean;
|
|
44
|
+
inFlight: boolean;
|
|
45
|
+
queued: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EntriesPage {
|
|
49
|
+
entries: GrokBotEntry[];
|
|
50
|
+
latestUpdatedSeq: string;
|
|
51
|
+
turn: GrokBotTurnStatus;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface HostedAgent {
|
|
55
|
+
/** The `/v0/grokbot` session id (the hosted agent's durable id). */
|
|
56
|
+
remoteId: string;
|
|
57
|
+
/** Transcript cursor: newest `updatedSeq` this client has consumed. */
|
|
58
|
+
lastUpdatedSeq: bigint;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_POLL_INTERVAL_MS = 750;
|
|
62
|
+
const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Idle polls required after a send before the turn is considered settled
|
|
66
|
+
* when the workflow never reported busy — covers the window between the
|
|
67
|
+
* accepted dispatch and the workflow picking the signal up.
|
|
68
|
+
*/
|
|
69
|
+
const MIN_IDLE_POLLS_WITHOUT_BUSY = 4;
|
|
70
|
+
|
|
71
|
+
class GrokBotApiError extends Error {
|
|
72
|
+
constructor(
|
|
73
|
+
readonly status: number,
|
|
74
|
+
readonly path: string,
|
|
75
|
+
body: string
|
|
76
|
+
) {
|
|
77
|
+
super(
|
|
78
|
+
`Grok Bot API ${path} failed (HTTP ${status}): ${body.slice(0, 300)}`
|
|
79
|
+
);
|
|
80
|
+
this.name = "GrokBotApiError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Minimal `/v0/grokbot` client: bearer API key, JSON in/out. */
|
|
85
|
+
class GrokBotApiClient {
|
|
86
|
+
private readonly fetchImpl: typeof fetch;
|
|
87
|
+
private readonly baseUrl: string;
|
|
88
|
+
private readonly apiKey: string;
|
|
89
|
+
|
|
90
|
+
constructor(options: GrokBotRunnerOptions) {
|
|
91
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
92
|
+
this.baseUrl = (options.backendUrl ?? cursorBackendUrl()).replace(
|
|
93
|
+
/\/$/,
|
|
94
|
+
""
|
|
95
|
+
);
|
|
96
|
+
const apiKey = options.apiKey ?? process.env.CURSOR_API_KEY;
|
|
97
|
+
if (apiKey === undefined || apiKey === "") {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'runtime: "grokbot" needs a Cursor API key (CURSOR_API_KEY or agent-sdk login) to reach the hosted Grok Bot harness.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
this.apiKey = apiKey;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async request<T>(args: {
|
|
106
|
+
method: string;
|
|
107
|
+
path: string;
|
|
108
|
+
body?: unknown;
|
|
109
|
+
}): Promise<T> {
|
|
110
|
+
const headers: Record<string, string> = {
|
|
111
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
112
|
+
};
|
|
113
|
+
if (args.body !== undefined) {
|
|
114
|
+
headers["content-type"] = "application/json";
|
|
115
|
+
}
|
|
116
|
+
const response = await this.fetchImpl(`${this.baseUrl}${args.path}`, {
|
|
117
|
+
method: args.method,
|
|
118
|
+
headers,
|
|
119
|
+
body: args.body === undefined ? undefined : JSON.stringify(args.body),
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new GrokBotApiError(
|
|
123
|
+
response.status,
|
|
124
|
+
args.path,
|
|
125
|
+
await response.text().catch(() => "")
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return (await response.json()) as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async createSession(args: {
|
|
132
|
+
name: string;
|
|
133
|
+
instructions: string | undefined;
|
|
134
|
+
}): Promise<{ id: string; latestUpdatedSeq: string }> {
|
|
135
|
+
return await this.request({
|
|
136
|
+
method: "POST",
|
|
137
|
+
path: "/v0/grokbot/sessions",
|
|
138
|
+
body: { name: args.name, instructions: args.instructions },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async sendMessage(args: {
|
|
143
|
+
sessionId: string;
|
|
144
|
+
text: string;
|
|
145
|
+
}): Promise<{ messageId: string; delivery: string }> {
|
|
146
|
+
return await this.request({
|
|
147
|
+
method: "POST",
|
|
148
|
+
path: `/v0/grokbot/sessions/${args.sessionId}/messages`,
|
|
149
|
+
body: { text: args.text, messageId: randomUUID() },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async listEntries(args: {
|
|
154
|
+
sessionId: string;
|
|
155
|
+
afterUpdatedSeq: bigint;
|
|
156
|
+
}): Promise<EntriesPage> {
|
|
157
|
+
return await this.request({
|
|
158
|
+
method: "GET",
|
|
159
|
+
path: `/v0/grokbot/sessions/${args.sessionId}/entries?afterUpdatedSeq=${args.afterUpdatedSeq}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async interrupt(args: { sessionId: string; reason: string }): Promise<void> {
|
|
164
|
+
await this.request({
|
|
165
|
+
method: "POST",
|
|
166
|
+
path: `/v0/grokbot/sessions/${args.sessionId}/interrupt`,
|
|
167
|
+
body: { reason: args.reason },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class GrokBotRunner implements AgentRunner {
|
|
173
|
+
/**
|
|
174
|
+
* One hosted Grok Bot agent per SDK agent name — the backend
|
|
175
|
+
* get-or-creates by name, so every SDK session forwards into the same
|
|
176
|
+
* hosted agent and conversation, and the Grok Bot app shows one agent
|
|
177
|
+
* rather than one per session.
|
|
178
|
+
*/
|
|
179
|
+
private readonly agents = new Map<string, HostedAgent>();
|
|
180
|
+
/**
|
|
181
|
+
* Per-hosted-agent turn chain. Sessions share one hosted conversation
|
|
182
|
+
* and one transcript cursor, so turns run one at a time per agent:
|
|
183
|
+
* deliveries attribute to the turn that sent them, and an abort only
|
|
184
|
+
* interrupts its own turn — mirroring the hosted workflow's own queue.
|
|
185
|
+
*/
|
|
186
|
+
private readonly turns = new Map<string, Promise<unknown>>();
|
|
187
|
+
private client: GrokBotApiClient | undefined;
|
|
188
|
+
|
|
189
|
+
constructor(private readonly options: GrokBotRunnerOptions = {}) {}
|
|
190
|
+
|
|
191
|
+
async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
|
|
192
|
+
if (request.runtime !== "grokbot") {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Session ${request.sessionId} is a ${request.runtime} turn, which the Grok Bot runner cannot execute.`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (request.images !== undefined && request.images.length > 0) {
|
|
198
|
+
// Attachments ride the hosted box's upload path, which the /v0
|
|
199
|
+
// session API does not expose yet. Refuse rather than silently
|
|
200
|
+
// dropping what the user attached.
|
|
201
|
+
throw new Error(
|
|
202
|
+
"Image attachments are not supported on grokbot turns yet."
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const name = hostedAgentName(request);
|
|
206
|
+
const previous = this.turns.get(name) ?? Promise.resolve();
|
|
207
|
+
const run = previous.then(() => this.executeTurn(request));
|
|
208
|
+
this.turns.set(
|
|
209
|
+
name,
|
|
210
|
+
run.catch(() => {})
|
|
211
|
+
);
|
|
212
|
+
return await run;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async executeTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
|
|
216
|
+
const signal = request.signal;
|
|
217
|
+
if (signal?.aborted === true) {
|
|
218
|
+
return { status: "cancelled" };
|
|
219
|
+
}
|
|
220
|
+
const client = this.ensureClient();
|
|
221
|
+
const entry = await this.getAgent(client, request);
|
|
222
|
+
|
|
223
|
+
const send = await client.sendMessage({
|
|
224
|
+
sessionId: entry.remoteId,
|
|
225
|
+
text: request.prompt,
|
|
226
|
+
});
|
|
227
|
+
if (
|
|
228
|
+
send.delivery !== "accepted_temporal" &&
|
|
229
|
+
send.delivery !== "duplicate"
|
|
230
|
+
) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`The hosted Grok Bot harness did not accept the turn (delivery: ${send.delivery}).`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const pollIntervalMs =
|
|
237
|
+
this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
238
|
+
const turnTimeoutMs = this.options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
239
|
+
const deadline = Date.now() + turnTimeoutMs;
|
|
240
|
+
const delivered: string[] = [];
|
|
241
|
+
let sawBusy = false;
|
|
242
|
+
let idlePolls = 0;
|
|
243
|
+
|
|
244
|
+
while (true) {
|
|
245
|
+
// On abort: interrupt the hosted run, drain entries once so a reply
|
|
246
|
+
// that already landed still streams out, then cancel.
|
|
247
|
+
const aborted = isAborted(signal);
|
|
248
|
+
if (aborted) {
|
|
249
|
+
await client
|
|
250
|
+
.interrupt({
|
|
251
|
+
sessionId: entry.remoteId,
|
|
252
|
+
reason: "The user sent a new message.",
|
|
253
|
+
})
|
|
254
|
+
.catch(() => {});
|
|
255
|
+
}
|
|
256
|
+
const page = await client.listEntries({
|
|
257
|
+
sessionId: entry.remoteId,
|
|
258
|
+
afterUpdatedSeq: entry.lastUpdatedSeq,
|
|
259
|
+
});
|
|
260
|
+
entry.lastUpdatedSeq = BigInt(page.latestUpdatedSeq);
|
|
261
|
+
for (const item of page.entries) {
|
|
262
|
+
if (item.kind !== "send-message" || item.text === undefined) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const delta = delivered.length === 0 ? item.text : `\n\n${item.text}`;
|
|
266
|
+
delivered.push(item.text);
|
|
267
|
+
// The emit callback may be async; a rejection must never escape
|
|
268
|
+
// the poll loop.
|
|
269
|
+
void Promise.resolve(
|
|
270
|
+
request.onUpdate({ type: "text-delta", text: delta })
|
|
271
|
+
).catch(() => {});
|
|
272
|
+
}
|
|
273
|
+
if (aborted) {
|
|
274
|
+
return { status: "cancelled" };
|
|
275
|
+
}
|
|
276
|
+
if (page.turn.inFlight || page.turn.queued > 0) {
|
|
277
|
+
sawBusy = true;
|
|
278
|
+
} else if (page.turn.idle) {
|
|
279
|
+
idlePolls += 1;
|
|
280
|
+
if (sawBusy || idlePolls >= MIN_IDLE_POLLS_WITHOUT_BUSY) {
|
|
281
|
+
return {
|
|
282
|
+
status: "finished",
|
|
283
|
+
result: delivered.length === 0 ? undefined : delivered.join("\n\n"),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (Date.now() >= deadline) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`The grokbot turn for session ${request.sessionId} did not settle within ${Math.round(turnTimeoutMs / 60_000)} minutes.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
await this.sleep(pollIntervalMs, signal);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async dispose(): Promise<void> {
|
|
297
|
+
// Hosted agents live server-side (the account's Grok Bot agents);
|
|
298
|
+
// nothing in this process holds conversation state.
|
|
299
|
+
this.agents.clear();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private ensureClient(): GrokBotApiClient {
|
|
303
|
+
this.client ??= new GrokBotApiClient(this.options);
|
|
304
|
+
return this.client;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async getAgent(
|
|
308
|
+
client: GrokBotApiClient,
|
|
309
|
+
request: RunnerTurnRequest
|
|
310
|
+
): Promise<HostedAgent> {
|
|
311
|
+
const name = hostedAgentName(request);
|
|
312
|
+
const existing = this.agents.get(name);
|
|
313
|
+
if (existing !== undefined) {
|
|
314
|
+
return existing;
|
|
315
|
+
}
|
|
316
|
+
// Get-or-create: a reconnect (new process, same agent name) lands on
|
|
317
|
+
// the existing hosted agent. The returned tail seq starts the cursor
|
|
318
|
+
// after the hosted conversation's history so an old transcript never
|
|
319
|
+
// replays into this turn's reply.
|
|
320
|
+
const created = await client.createSession({
|
|
321
|
+
name,
|
|
322
|
+
instructions: request.instructions,
|
|
323
|
+
});
|
|
324
|
+
const entry: HostedAgent = {
|
|
325
|
+
remoteId: created.id,
|
|
326
|
+
lastUpdatedSeq: BigInt(created.latestUpdatedSeq),
|
|
327
|
+
};
|
|
328
|
+
this.agents.set(name, entry);
|
|
329
|
+
return entry;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async sleep(
|
|
333
|
+
ms: number,
|
|
334
|
+
signal: AbortSignal | undefined
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
await new Promise<void>((resolve) => {
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
signal?.removeEventListener("abort", onAbort);
|
|
339
|
+
resolve();
|
|
340
|
+
}, ms);
|
|
341
|
+
const onAbort = (): void => {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
resolve();
|
|
344
|
+
};
|
|
345
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function hostedAgentName(request: RunnerTurnRequest): string {
|
|
351
|
+
return request.agentName ?? "Agent SDK agent";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `AbortSignal.aborted` is readonly to TypeScript, so an inline recheck
|
|
356
|
+
* after an await narrows to the stale pre-await value; the function
|
|
357
|
+
* boundary keeps the recheck honest.
|
|
358
|
+
*/
|
|
359
|
+
function isAborted(signal: AbortSignal | undefined): boolean {
|
|
360
|
+
return signal?.aborted === true;
|
|
361
|
+
}
|
|
@@ -304,6 +304,10 @@ function buildConnectionBridgeMcpServer(
|
|
|
304
304
|
...(tool.outputSchema === undefined
|
|
305
305
|
? {}
|
|
306
306
|
: { outputSchema: tool.outputSchema }),
|
|
307
|
+
// Already bounded to the MCP-spec fields at listing time (mcp-host).
|
|
308
|
+
...(tool.annotations === undefined
|
|
309
|
+
? {}
|
|
310
|
+
: { annotations: tool.annotations }),
|
|
307
311
|
})),
|
|
308
312
|
};
|
|
309
313
|
});
|
|
@@ -314,8 +318,9 @@ function buildConnectionBridgeMcpServer(
|
|
|
314
318
|
}
|
|
315
319
|
// The one tool path that never reaches `executeServerTool`, so it asks
|
|
316
320
|
// the policy itself.
|
|
317
|
-
const answer = engine.bridgedCallAnswer({
|
|
321
|
+
const answer = await engine.bridgedCallAnswer({
|
|
318
322
|
sessionId: sessionId ?? "",
|
|
323
|
+
connectionName,
|
|
319
324
|
toolName: request.params.name,
|
|
320
325
|
});
|
|
321
326
|
if (answer.answered) {
|
package/src/internal/mcp-host.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
HostMcpRegistry,
|
|
21
21
|
HostMcpToolInfo,
|
|
22
22
|
JsonObject,
|
|
23
|
+
McpToolAnnotations,
|
|
23
24
|
} from "../types.js";
|
|
24
25
|
import { CLI_COMMAND_NAME as CLI } from "./distribution.js";
|
|
25
26
|
import { createHostMcpOAuthProvider } from "./mcp-oauth.js";
|
|
@@ -213,12 +214,58 @@ export function isStaleMcpSessionError(error: unknown): boolean {
|
|
|
213
214
|
return code === 404;
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
/** Longest server-supplied annotation title carried off a listing. */
|
|
218
|
+
const MAX_TOOL_ANNOTATION_TITLE_LENGTH = 256;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bound a server's `Tool.annotations` to the MCP-spec fields: the four
|
|
222
|
+
* boolean hints plus a length-capped `title`. A server cannot stuff
|
|
223
|
+
* arbitrary payloads into every listing, and malformed values (a non-boolean
|
|
224
|
+
* hint, a non-object) behave like absent ones so consumers fail closed.
|
|
225
|
+
*
|
|
226
|
+
* Mirrors `@anysphere/mcp-core/mcp-tool-annotations` (`toolAnnotationsJsonField`),
|
|
227
|
+
* which this package cannot import: it publishes to npm and cannot take a
|
|
228
|
+
* workspace-only runtime dependency. Keep the two in sync.
|
|
229
|
+
*
|
|
230
|
+
* Exported for unit tests.
|
|
231
|
+
*/
|
|
232
|
+
export function boundedToolAnnotations(
|
|
233
|
+
annotations: unknown
|
|
234
|
+
): McpToolAnnotations | undefined {
|
|
235
|
+
if (
|
|
236
|
+
typeof annotations !== "object" ||
|
|
237
|
+
annotations === null ||
|
|
238
|
+
Array.isArray(annotations)
|
|
239
|
+
) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const record = annotations as Record<string, unknown>;
|
|
243
|
+
const bounded: McpToolAnnotations = {};
|
|
244
|
+
if (typeof record.title === "string" && record.title.length > 0) {
|
|
245
|
+
bounded.title = record.title.slice(0, MAX_TOOL_ANNOTATION_TITLE_LENGTH);
|
|
246
|
+
}
|
|
247
|
+
for (const key of [
|
|
248
|
+
"readOnlyHint",
|
|
249
|
+
"destructiveHint",
|
|
250
|
+
"idempotentHint",
|
|
251
|
+
"openWorldHint",
|
|
252
|
+
] as const) {
|
|
253
|
+
const value = record[key];
|
|
254
|
+
if (typeof value === "boolean") {
|
|
255
|
+
bounded[key] = value;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return Object.keys(bounded).length === 0 ? undefined : bounded;
|
|
259
|
+
}
|
|
260
|
+
|
|
216
261
|
function toToolInfo(tool: {
|
|
217
262
|
name: string;
|
|
218
263
|
description?: string;
|
|
219
264
|
inputSchema?: unknown;
|
|
220
265
|
outputSchema?: unknown;
|
|
266
|
+
annotations?: unknown;
|
|
221
267
|
}): HostMcpToolInfo {
|
|
268
|
+
const annotations = boundedToolAnnotations(tool.annotations);
|
|
222
269
|
return {
|
|
223
270
|
name: tool.name,
|
|
224
271
|
...(tool.description === undefined
|
|
@@ -230,6 +277,7 @@ function toToolInfo(tool: {
|
|
|
230
277
|
...(tool.outputSchema === undefined
|
|
231
278
|
? {}
|
|
232
279
|
: { outputSchema: tool.outputSchema as JsonObject }),
|
|
280
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
233
281
|
};
|
|
234
282
|
}
|
|
235
283
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes each turn to the runner that owns its runtime: local and cloud
|
|
3
|
+
* turns stay on the Cursor SDK runner, grokbot turns go to the Grok Bot
|
|
4
|
+
* remote client (`internal/grokbot/runner.ts`), which drives the hosted
|
|
5
|
+
* Grok Bot (Sand) harness through the public `/v0/grokbot` session API.
|
|
6
|
+
* Both runners ship in the published package.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RunnerTurnRequest, TurnOutcome } from "../types.js";
|
|
10
|
+
import type { GrokBotRunnerOptions } from "./grokbot/runner.js";
|
|
11
|
+
import { GrokBotRunner } from "./grokbot/runner.js";
|
|
12
|
+
import type {
|
|
13
|
+
AgentRunner,
|
|
14
|
+
PrewarmOutcome,
|
|
15
|
+
RunnerPrewarmRequest,
|
|
16
|
+
} from "./sdk-runner.js";
|
|
17
|
+
|
|
18
|
+
export interface RuntimeDispatchingRunnerOptions {
|
|
19
|
+
sdkRunner: AgentRunner;
|
|
20
|
+
grokbot?: GrokBotRunnerOptions;
|
|
21
|
+
/** Test seam — replaces the real Grok Bot remote client. */
|
|
22
|
+
createGrokBotRunner?: (options: GrokBotRunnerOptions) => AgentRunner;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class RuntimeDispatchingRunner implements AgentRunner {
|
|
26
|
+
private grokbotRunner: AgentRunner | undefined;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly options: RuntimeDispatchingRunnerOptions) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The Grok Bot remote client, created on first use; a missing API key
|
|
32
|
+
* surfaces on the first grokbot turn, not at serve start.
|
|
33
|
+
*/
|
|
34
|
+
ensureGrokBotRunner(): AgentRunner {
|
|
35
|
+
const options = this.options.grokbot ?? {};
|
|
36
|
+
this.grokbotRunner ??=
|
|
37
|
+
this.options.createGrokBotRunner?.(options) ?? new GrokBotRunner(options);
|
|
38
|
+
return this.grokbotRunner;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
|
|
42
|
+
if (request.runtime === "grokbot") {
|
|
43
|
+
return this.ensureGrokBotRunner().runTurn(request);
|
|
44
|
+
}
|
|
45
|
+
return this.options.sdkRunner.runTurn(request);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async prewarm(request: RunnerPrewarmRequest): Promise<PrewarmOutcome> {
|
|
49
|
+
// Prewarming is a local-harness concern; the engine only calls it for
|
|
50
|
+
// local-runtime agents.
|
|
51
|
+
return (
|
|
52
|
+
this.options.sdkRunner.prewarm?.(request) ?? {
|
|
53
|
+
warmed: false,
|
|
54
|
+
reason: "the Cursor SDK runner does not support prewarming",
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async dispose(): Promise<void> {
|
|
60
|
+
await this.options.sdkRunner.dispose?.();
|
|
61
|
+
await this.grokbotRunner?.dispose?.();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -199,6 +199,15 @@ export class CursorSdkRunner implements AgentRunner {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
|
|
202
|
+
// Fail closed: this runner only knows the Cursor SDK's local and cloud
|
|
203
|
+
// harnesses. A grokbot turn reaching it means the serve wiring skipped
|
|
204
|
+
// the Grok Bot runner — running it on the local harness would silently
|
|
205
|
+
// swap the box (and the SendToUser contract) out from under the agent.
|
|
206
|
+
if (request.runtime === "grokbot") {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Session ${request.sessionId} is a grokbot turn, which the Cursor SDK runner cannot execute. Serve must route grokbot turns to the Grok Bot runner.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
202
211
|
let sawUpdate = false;
|
|
203
212
|
const tracked: RunnerTurnRequest = {
|
|
204
213
|
...request,
|
package/src/internal/server.ts
CHANGED
|
@@ -175,6 +175,7 @@ import {
|
|
|
175
175
|
validateMountConnectionsConfig,
|
|
176
176
|
} from "./resolved-connections.js";
|
|
177
177
|
import { Router } from "./router.js";
|
|
178
|
+
import { RuntimeDispatchingRunner } from "./runtime-dispatch-runner.js";
|
|
178
179
|
import { ScheduleRunner, UnknownScheduleError } from "./schedule-runner.js";
|
|
179
180
|
import { type AgentRunner, CursorSdkRunner } from "./sdk-runner.js";
|
|
180
181
|
import {
|
|
@@ -493,12 +494,39 @@ export async function startServer(
|
|
|
493
494
|
"[agent-sdk] warning: --allow-anonymous admits every HTTP caller as the same principal; use only on a trusted network (e.g. Tailscale) and prefer --bearer-token for shared hosts"
|
|
494
495
|
);
|
|
495
496
|
}
|
|
496
|
-
const
|
|
497
|
+
const sdkRunner =
|
|
497
498
|
options.runner ??
|
|
498
|
-
new CursorSdkRunner({
|
|
499
|
-
|
|
500
|
-
|
|
499
|
+
new CursorSdkRunner({ apiKey: accountKey?.apiKey, logger });
|
|
500
|
+
// Grok Bot turns run on Cursor's hosted Grok Bot (Sand) harness through
|
|
501
|
+
// the /v0/grokbot session API; the SDK side is a thin remote client. The
|
|
502
|
+
// dispatching runner is only interposed when a mount needs it. An
|
|
503
|
+
// injected test runner receives every turn, grokbot included.
|
|
504
|
+
const hasGrokBotMounts = mounts.some(
|
|
505
|
+
(mount) => mount.project.agent.runtime === "grokbot"
|
|
506
|
+
);
|
|
507
|
+
// Fail closed like cursor-account GitHub/MCP: grokbot turns execute on the
|
|
508
|
+
// signed-in account's hosted computer, so anonymous admission needs its
|
|
509
|
+
// own opt-in.
|
|
510
|
+
if (hasGrokBotMounts && options.allowAnonymous === true) {
|
|
511
|
+
if (options.allowAnonymousGrokbot !== true) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
'runtime: "grokbot" cannot be served with --allow-anonymous because admitted callers could drive the signed-in account\'s hosted Grok Bot computer. If every admitted caller is trusted (e.g. behind an SSO proxy), opt in with --allow-anonymous-grokbot.'
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
logger(
|
|
517
|
+
"[agent-sdk] warning: --allow-anonymous-grokbot — every admitted HTTP caller can drive turns on the signed-in account's hosted Grok Bot computer; serve this only behind a trusted boundary (e.g. an SSO proxy)"
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
let runner: AgentRunner = sdkRunner;
|
|
521
|
+
if (hasGrokBotMounts && options.runner === undefined) {
|
|
522
|
+
runner = new RuntimeDispatchingRunner({
|
|
523
|
+
sdkRunner,
|
|
524
|
+
grokbot: { apiKey: accountKey?.apiKey },
|
|
501
525
|
});
|
|
526
|
+
logger(
|
|
527
|
+
"[agent-sdk] grokbot runtime: remote client (turns run on the hosted Grok Bot harness)"
|
|
528
|
+
);
|
|
529
|
+
}
|
|
502
530
|
|
|
503
531
|
const router = new Router();
|
|
504
532
|
const runtimes: MountedRuntime[] = [];
|