@magpie-community/opencode-cursor-auth 0.2.19 → 0.2.21
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 +7 -0
- package/index.mjs +30 -11
- package/native-session.mjs +121 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,6 +109,13 @@ A step's calls must all arrive within 30 seconds of each other: Cursor sends
|
|
|
109
109
|
each one once the model has written it, so a step of many long calls can take
|
|
110
110
|
longer than that in all (#155).
|
|
111
111
|
|
|
112
|
+
A Run Cursor stops answering ends with a 502 that says so, retained or
|
|
113
|
+
stateless: 60 seconds with no message at all, or 4 minutes with only
|
|
114
|
+
Cursor's heartbeats (yetone/magpie#2672, where a Run sent some thinking and
|
|
115
|
+
then nothing, and the reply waited until the agent gave up after 5 minutes).
|
|
116
|
+
Only time spent waiting on Cursor counts: a retained Run waiting for the
|
|
117
|
+
caller's tool results is bounded by the 120 seconds above instead.
|
|
118
|
+
|
|
112
119
|
Cursor's count of a step's tool calls includes calls the plugin answers itself:
|
|
113
120
|
a tool the request doesn't have is refused to the model, and Cursor's own
|
|
114
121
|
`get_mcp_tools` is answered with the request's tools. The step's calls are the
|
package/index.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
//
|
|
29
29
|
// OpenCode speaks chat completions to it (@ai-sdk/openai-compatible); the
|
|
30
30
|
// fetch here answers them.
|
|
31
|
-
import { NATIVE_LIMITS, nativeSessions, NativeSessionError, nativeFields, nativeMcpArgs } from "./native-session.mjs"
|
|
31
|
+
import { NATIVE_LIMITS, nativeSessions, NativeSessionError, nativeFields, nativeMcpArgs, Quiet } from "./native-session.mjs"
|
|
32
32
|
import { isDeepStrictEqual } from "node:util"
|
|
33
33
|
import http2 from "node:http2"
|
|
34
34
|
import { STATUS_CODES } from "node:http"
|
|
@@ -1179,6 +1179,19 @@ function toolsOf(chat) {
|
|
|
1179
1179
|
})
|
|
1180
1180
|
}
|
|
1181
1181
|
|
|
1182
|
+
// NS is the MCP server the caller's tools are served as, the way Cursor's
|
|
1183
|
+
// own client names a user's server by its mcp.json label (providerIdentifier,
|
|
1184
|
+
// with "<label>-<tool>" as a call's name). It goes to Cursor in the prompt,
|
|
1185
|
+
// each call and each tool definition, so it names nothing of the plugin or
|
|
1186
|
+
// the app it runs in. Calls are read back under NAMESPACES: before 0.2.20
|
|
1187
|
+
// the server was named "magpie", and a session begun then can still call it.
|
|
1188
|
+
const NS = "tools"
|
|
1189
|
+
const NAMESPACES = [NS, "magpie"]
|
|
1190
|
+
const bareName = (s) => {
|
|
1191
|
+
for (const ns of NAMESPACES) if (s.startsWith(ns + "-")) return s.slice(ns.length + 1)
|
|
1192
|
+
return s
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1182
1195
|
// catalog lists the caller's tools for the model. It sees MCP tools only
|
|
1183
1196
|
// through Cursor's GetDynamicTools and CallDynamicTool, and Cursor names
|
|
1184
1197
|
// them in a prompt of its own, which this conversation goes without:
|
|
@@ -1188,8 +1201,8 @@ function toolsOf(chat) {
|
|
|
1188
1201
|
// those calls went nowhere (#11).
|
|
1189
1202
|
function catalog(tools) {
|
|
1190
1203
|
if (!tools.length) return ""
|
|
1191
|
-
let s = `\n\n<dynamic_tool_catalog>\nThe MCP namespace "
|
|
1192
|
-
for (const t of tools) s += `<call>${CALL}({"namespace":"
|
|
1204
|
+
let s = `\n\n<dynamic_tool_catalog>\nThe MCP namespace "${NS}" has the tools below. They are reached only through \`${CALL}\`, never as tools of their own. Each entry is the exact call to make; its arguments must match the schema given. The schemas are complete, so there is no need to call \`GetDynamicTools\` first.\n`
|
|
1205
|
+
for (const t of tools) s += `<call>${CALL}({"namespace":"${NS}","toolName":"${t.name}","arguments":{...}})\n${t.description}\narguments schema: ${t.schemaText}\n</call>\n`
|
|
1193
1206
|
return s + "</dynamic_tool_catalog>"
|
|
1194
1207
|
}
|
|
1195
1208
|
|
|
@@ -1263,7 +1276,7 @@ function conversation(chat, tools) {
|
|
|
1263
1276
|
if (c?.type && c.type !== "function") continue
|
|
1264
1277
|
const args = parseArgs(c.function?.arguments)
|
|
1265
1278
|
size += (c.function?.arguments ?? "").length + (c.function?.name ?? "").length
|
|
1266
|
-
content.push({ type: "tool-call", toolCallId: callID(c.id), toolName: CALL, args: { namespace:
|
|
1279
|
+
content.push({ type: "tool-call", toolCallId: callID(c.id), toolName: CALL, args: { namespace: NS, toolName: c.function?.name, arguments: args } })
|
|
1267
1280
|
pending.push(c.id)
|
|
1268
1281
|
}
|
|
1269
1282
|
if (content.length) add({ role: "assistant", content })
|
|
@@ -1303,7 +1316,7 @@ function toolDef(t) {
|
|
|
1303
1316
|
const d = pb().str(1, t.name)
|
|
1304
1317
|
if (t.description) d.str(2, t.description)
|
|
1305
1318
|
d.bytes(3, pbValue(t.schema))
|
|
1306
|
-
return d.str(4,
|
|
1319
|
+
return d.str(4, NS).str(5, t.name).str(6, t.schemaText).done()
|
|
1307
1320
|
}
|
|
1308
1321
|
|
|
1309
1322
|
const env = () => pb().str(1, GOOS).str(2, tmpdir()).str(10, "UTC")
|
|
@@ -2265,7 +2278,8 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
|
|
|
2265
2278
|
book.done()
|
|
2266
2279
|
return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
|
|
2267
2280
|
}
|
|
2268
|
-
|
|
2281
|
+
// a Run Cursor stops answering ends, as a retained one does (#2672)
|
|
2282
|
+
const it = decode(new Quiet(NATIVE_LIMITS, close).frames(frames(req)), {
|
|
2269
2283
|
send,
|
|
2270
2284
|
blobs,
|
|
2271
2285
|
tools,
|
|
@@ -2479,6 +2493,11 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
|
|
|
2479
2493
|
}
|
|
2480
2494
|
yield { error: { status: 502, message: "the reply broke off: EOF" } }
|
|
2481
2495
|
} catch (e) {
|
|
2496
|
+
// gone quiet is no answer, calls or not: the step's count never came
|
|
2497
|
+
if (e?.quiet) {
|
|
2498
|
+
yield { error: { status: e.status, message: e.message } }
|
|
2499
|
+
return
|
|
2500
|
+
}
|
|
2482
2501
|
if (calls > 0 && /EOF/.test(e?.message ?? "")) {
|
|
2483
2502
|
yield await settled()
|
|
2484
2503
|
return
|
|
@@ -2527,13 +2546,13 @@ function exec(msg, tools, send, closeExec, refused, native = false) {
|
|
|
2527
2546
|
if (native) for (const e of es) if (e.num === 11) {
|
|
2528
2547
|
if (e.wire !== 2) throw new NativeSessionError(502, "invalid native MCP argument envelope")
|
|
2529
2548
|
const { fields: a, args } = nativeMcpArgs(e.data)
|
|
2530
|
-
const name = pbStr(a, 5) || pbStr(a, 1)
|
|
2549
|
+
const name = pbStr(a, 5) || bareName(pbStr(a, 1))
|
|
2531
2550
|
const provider = pbStr(a, 4)
|
|
2532
2551
|
const server = pbStr(a, 9)
|
|
2533
|
-
if (provider && provider
|
|
2552
|
+
if (provider && !NAMESPACES.includes(provider) || server && !NAMESPACES.includes(server)) nativeUnavailable = true
|
|
2534
2553
|
if (pbNum(a, 7) || field(a, 6)) nativeUnavailable = true
|
|
2535
2554
|
if (!tools.some((t) => t.name === name)) nativeUnavailable = true
|
|
2536
|
-
if (!pbStr(a, 5) && pbStr(a, 1)
|
|
2555
|
+
if (!pbStr(a, 5) && !NAMESPACES.some((ns) => pbStr(a, 1) === ns + "-" + name)) nativeUnavailable = true
|
|
2537
2556
|
const call = { requestID: id, execID, nativeID: pbStr(a, 3), name, args }
|
|
2538
2557
|
if (nativeCall && !isDeepStrictEqual(nativeCall, call)) throw new NativeSessionError(502, "ambiguous repeated native MCP argument envelopes")
|
|
2539
2558
|
nativeCall ??= call
|
|
@@ -2565,7 +2584,7 @@ function exec(msg, tools, send, closeExec, refused, native = false) {
|
|
|
2565
2584
|
const v = field(ef, 2)
|
|
2566
2585
|
args[pbStr(ef, 1)] = v ? pbAny(v.data) : null
|
|
2567
2586
|
}
|
|
2568
|
-
const name = pbStr(a, 5) || pbStr(a, 1)
|
|
2587
|
+
const name = pbStr(a, 5) || bareName(pbStr(a, 1))
|
|
2569
2588
|
// an OpenAI model's id is its call's and its item's, a line apart,
|
|
2570
2589
|
// which no caller would take as an id: the line goes as __
|
|
2571
2590
|
const cid = pbStr(a, 3).replaceAll("\n", "__") || "call_" + randomBytes(12).toString("hex")
|
|
@@ -2573,7 +2592,7 @@ function exec(msg, tools, send, closeExec, refused, native = false) {
|
|
|
2573
2592
|
}
|
|
2574
2593
|
case 36: {
|
|
2575
2594
|
// mcp_state_exec_args: the tools there are
|
|
2576
|
-
const srv = pb().str(1,
|
|
2595
|
+
const srv = pb().str(1, NS).str(2, NS).str(7, "connected")
|
|
2577
2596
|
for (const t of tools) srv.bytes(5, toolDef(t))
|
|
2578
2597
|
answer(36, pb().bytes(1, pb().bytes(1, srv)))
|
|
2579
2598
|
return null
|
package/native-session.mjs
CHANGED
|
@@ -19,6 +19,14 @@ export const NATIVE_LIMITS = {
|
|
|
19
19
|
// How long a KV reply waits for room in the outbound queue (Cursor's own
|
|
20
20
|
// flow control taking the writes before it) before the Run fails.
|
|
21
21
|
writeWait: 30_000,
|
|
22
|
+
// How long a Run waits on Cursor before it ends as gone quiet
|
|
23
|
+
// (yetone/magpie#2672): idle, with no message at all, as @cursor/sdk's
|
|
24
|
+
// stall detector ends one (30 s there; Cursor's client retries, this one
|
|
25
|
+
// can't once output went out); quiet, with heartbeats and nothing else,
|
|
26
|
+
// under the 5 minutes magpie keeps a silent reply alive. Only time spent
|
|
27
|
+
// waiting on Cursor counts, never a Run parked for the caller's results.
|
|
28
|
+
idle: 60_000,
|
|
29
|
+
quiet: 240_000,
|
|
22
30
|
perAccount: 8,
|
|
23
31
|
perProcess: 32,
|
|
24
32
|
}
|
|
@@ -43,6 +51,113 @@ export class NativeSessionError extends Error {
|
|
|
43
51
|
const invalid = (message, status = 409) => { throw new NativeSessionError(status, message) }
|
|
44
52
|
const failureOf = (e) => ({ status: e?.status ?? 502, message: e?.message ?? String(e) })
|
|
45
53
|
|
|
54
|
+
// kindOf is what a frame of AgentServerMessage is, for Quiet: a heartbeat
|
|
55
|
+
// alone, or what an error names as the last thing Cursor sent
|
|
56
|
+
const UPDATES = { 1: "text", 4: "thinking", 14: "turn end", 27: "tool count" }
|
|
57
|
+
const MESSAGES = { 2: "tool call", 3: "checkpoint", 4: "blob request" }
|
|
58
|
+
function kindOf(f) {
|
|
59
|
+
if (f.end) return "stream end"
|
|
60
|
+
try {
|
|
61
|
+
const fs = nativeFields(f.data)
|
|
62
|
+
const updates = fs.filter((m) => m.num === 1).flatMap((m) => nativeFields(m.data))
|
|
63
|
+
if (fs.length && fs.every((m) => m.num === 1) && updates.length && updates.every((u) => u.num === 13)) return null
|
|
64
|
+
const u = updates.find((u) => u.num !== 13)
|
|
65
|
+
if (u) return UPDATES[u.num] ?? `update #${u.num}`
|
|
66
|
+
return MESSAGES[fs[0]?.num] ?? `message #${fs[0]?.num}`
|
|
67
|
+
} catch {
|
|
68
|
+
return "malformed message"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Quiet ends a Run Cursor has stopped answering. Cursor can leave a Run's
|
|
73
|
+
// stream open after some thinking with nothing more to come, no text, call,
|
|
74
|
+
// count or TurnEnded, while the plugin's heartbeats keep it alive: the
|
|
75
|
+
// reply then waited until the agent gave up (305 s, yetone/magpie#2672).
|
|
76
|
+
// Only time spent waiting on Cursor's next frame counts: not this side
|
|
77
|
+
// handling one, not a slow reader of the reply, not a paused Run.
|
|
78
|
+
export class Quiet {
|
|
79
|
+
constructor(limits, stop) {
|
|
80
|
+
this.limits = limits
|
|
81
|
+
this.stop = stop
|
|
82
|
+
this.heard = 0 // ms waited since Cursor's last frame that wasn't a heartbeat
|
|
83
|
+
this.heartbeats = 0
|
|
84
|
+
this.last = ""
|
|
85
|
+
this.since = null
|
|
86
|
+
this.paused = false
|
|
87
|
+
this.waiting = false
|
|
88
|
+
this.why = ""
|
|
89
|
+
}
|
|
90
|
+
arm() {
|
|
91
|
+
clearTimeout(this.timer)
|
|
92
|
+
if (this.paused || !this.waiting || this.why || this.done) return
|
|
93
|
+
this.since = Date.now()
|
|
94
|
+
this.timer = later(() => this.fire(), Math.max(0, Math.min(this.limits.idle, this.limits.quiet - this.heard)))
|
|
95
|
+
}
|
|
96
|
+
// waited stops the clock: Cursor answered, or the Run paused
|
|
97
|
+
waited() {
|
|
98
|
+
clearTimeout(this.timer)
|
|
99
|
+
if (this.since != null) this.heard += Date.now() - this.since
|
|
100
|
+
this.since = null
|
|
101
|
+
}
|
|
102
|
+
fire() {
|
|
103
|
+
const gap = Date.now() - this.since
|
|
104
|
+
const what = this.heard + gap >= this.limits.quiet ? `${this.heartbeats} heartbeats and nothing else for ${Math.round((this.heard + gap) / 1000)} s` : `no message for ${Math.round(gap / 1000)} s`
|
|
105
|
+
this.why = `Cursor went quiet: ${what}${this.last ? ", its last message " + this.last : ""} (try again)`
|
|
106
|
+
this.gone?.()
|
|
107
|
+
this.stop({ status: 502, message: this.why })
|
|
108
|
+
}
|
|
109
|
+
// pause stops the clock while the Run waits on the caller's tool results;
|
|
110
|
+
// resume starts it again from nothing, for Cursor's next model call
|
|
111
|
+
pause() {
|
|
112
|
+
this.waited()
|
|
113
|
+
this.paused = true
|
|
114
|
+
}
|
|
115
|
+
resume() {
|
|
116
|
+
this.paused = false
|
|
117
|
+
this.heard = 0
|
|
118
|
+
this.heartbeats = 0
|
|
119
|
+
this.arm()
|
|
120
|
+
}
|
|
121
|
+
// frames passes on a Run's frames; once Cursor has gone quiet, the read
|
|
122
|
+
// that was waiting ends in its error, whether or not the stream does
|
|
123
|
+
async *frames(stream) {
|
|
124
|
+
const it = (stream[Symbol.asyncIterator] ?? stream[Symbol.iterator]).call(stream)
|
|
125
|
+
try {
|
|
126
|
+
for (;;) {
|
|
127
|
+
this.waiting = true
|
|
128
|
+
this.arm()
|
|
129
|
+
let r
|
|
130
|
+
try {
|
|
131
|
+
const next = Promise.resolve(it.next())
|
|
132
|
+
next.catch(() => {}) // a read left behind by gone fails unheard
|
|
133
|
+
// a wake of this read's own: one that lived as long as the stream
|
|
134
|
+
// would hold every race's result, each frame, until it ended
|
|
135
|
+
r = await Promise.race([next, new Promise((wake) => { this.gone = wake })])
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (!this.why) throw e
|
|
138
|
+
} finally {
|
|
139
|
+
this.gone = null
|
|
140
|
+
this.waiting = false
|
|
141
|
+
this.waited()
|
|
142
|
+
}
|
|
143
|
+
if (this.why) throw Object.assign(new NativeSessionError(502, this.why), { quiet: true })
|
|
144
|
+
if (r.done) return
|
|
145
|
+
const kind = kindOf(r.value)
|
|
146
|
+
if (kind == null) this.heartbeats++
|
|
147
|
+
else [this.heard, this.heartbeats, this.last] = [0, 0, kind]
|
|
148
|
+
yield r.value
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
this.done = true
|
|
152
|
+
clearTimeout(this.timer)
|
|
153
|
+
// not waited for: a read may still be pending on a stream that didn't
|
|
154
|
+
// end, and the reader's error handling (a failed protocol check failing
|
|
155
|
+
// the Run before a sealed batch is read) must not wait behind it
|
|
156
|
+
Promise.resolve(it.return?.()).catch(() => {})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
46
161
|
// Unlike the compatibility codec, malformed protobuf tails must fail a live
|
|
47
162
|
// continuation. Dropping one could turn a truncated batch into a valid handoff.
|
|
48
163
|
export function nativeFields(input, maxFields = Infinity) {
|
|
@@ -472,6 +587,7 @@ class NativeSession {
|
|
|
472
587
|
this.segment = new Segment(this, prepared.chat)
|
|
473
588
|
this.retain(bytes(prepared.chat))
|
|
474
589
|
this.expireIn(this.limits.lifetime)
|
|
590
|
+
this.quiet = new Quiet(this.limits, (e) => this.fail(e))
|
|
475
591
|
}
|
|
476
592
|
// expireIn sets the Run's absolute deadline ms from now: from its start,
|
|
477
593
|
// the whole lifetime; a test brings it in after the first answer
|
|
@@ -508,6 +624,7 @@ class NativeSession {
|
|
|
508
624
|
clearTimeout(this.resultTimer)
|
|
509
625
|
clearTimeout(this.drainTimer)
|
|
510
626
|
clearTimeout(this.lifetimeTimer)
|
|
627
|
+
this.quiet.pause()
|
|
511
628
|
this.ac.abort()
|
|
512
629
|
this.transport?.close()
|
|
513
630
|
}
|
|
@@ -605,6 +722,7 @@ class NativeSession {
|
|
|
605
722
|
this.execClose(call.requestID)
|
|
606
723
|
}
|
|
607
724
|
this.state = "running"
|
|
725
|
+
this.quiet.resume()
|
|
608
726
|
} catch (e) {
|
|
609
727
|
const error = failureOf(e)
|
|
610
728
|
this.fail({ status: error.status, message: `${error.message}; native result submission failed; its outcome is uncertain and cannot be replayed` })
|
|
@@ -706,6 +824,8 @@ class NativeSession {
|
|
|
706
824
|
clearTimeout(this.batchTimer)
|
|
707
825
|
this.batchTimer = null
|
|
708
826
|
this.state = "awaiting-results"
|
|
827
|
+
// Cursor waits on the caller now: resultWait bounds the wait
|
|
828
|
+
this.quiet.pause()
|
|
709
829
|
this.batches ??= []
|
|
710
830
|
this.batches.push(segment)
|
|
711
831
|
for (const c of segment.calls) this.pool.handles.set(c.id, { owner: this, batch: segment })
|
|
@@ -855,7 +975,7 @@ class NativeSession {
|
|
|
855
975
|
async pump(stream) {
|
|
856
976
|
const { pb, exec, failure } = this.wire
|
|
857
977
|
try {
|
|
858
|
-
for await (const f of stream) {
|
|
978
|
+
for await (const f of this.quiet.frames(stream)) {
|
|
859
979
|
if (this.error || this.state === "complete") return
|
|
860
980
|
if (f.end) {
|
|
861
981
|
const e = failure(200, f.data)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpie-community/opencode-cursor-auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"description": "Cursor subscriptions (Pro, Pro+, Ultra, Teams) in OpenCode and magpie, through the API cursor-agent talks to",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|