From 0535cd96c25a1739bedd5c9e4c1a10689f5cc083 Mon Sep 17 00:00:00 2001 From: dovela Date: Sat, 7 Nov 2026 13:56:16 +0800 Subject: [PATCH] Split Cursor client identity between the catalog and agent runs. Desktop is required for the model list, while agent runs stay on the cli client so tool calls keep the protocol this plugin translates. Co-authored-by: Cursor --- conversation.go | 2 +- host_http.go | 5 ++++- identity_test.go | 28 ++++++++++++++++++++++++++++ models.go | 4 ++-- runtime.go | 11 ++++++++++- 5 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 identity_test.go diff --git a/conversation.go b/conversation.go index 4cfab0c..ea581d3 100644 --- a/conversation.go +++ b/conversation.go @@ -329,7 +329,7 @@ func buildRun(conv chatConversation, tools []chatTool, model modelResolution, id mid := randomID() user := protobuf(nil).text(1, conv.Last).text(2, mid).uint(4, 1) turn := protobuf(nil).bytes(1, protobuf(nil).bytes(1, put(user)).text(10, mid)) - state = state.bytes(8, put(turn)).uint(10, 1).text(22, "cli") + state = state.bytes(8, put(turn)).uint(10, 1).text(22, agentClientType) rc := protobuf(nil).bytes(4, cursorEnvironment()) var mcp protobuf for _, t := range tools { diff --git a/host_http.go b/host_http.go index dfb707c..a0fbcc3 100644 --- a/host_http.go +++ b/host_http.go @@ -208,6 +208,9 @@ func (b *hostBody) Close() error { return nil } func (s *requestScope) json(ctx context.Context, method, path, token string, body any) (map[string]any, error) { + return s.jsonClient(ctx, method, path, token, body, agentClientType) +} +func (s *requestScope) jsonClient(ctx context.Context, method, path, token string, body any, clientType string) (map[string]any, error) { url := path if len(path) > 0 && path[0] == '/' { url = cursorAPI + path @@ -216,7 +219,7 @@ func (s *requestScope) json(ctx context.Context, method, path, token string, bod if body != nil { payload = encodeJSON(body) } - h := headers(token) + h := headersWithType(token, clientType) if strings.HasPrefix(path, "/auth/") || strings.HasPrefix(path, "/oauth/") { h.Del("Connect-Protocol-Version") } diff --git a/identity_test.go b/identity_test.go new file mode 100644 index 0000000..35400fe --- /dev/null +++ b/identity_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "bytes" + "testing" +) + +func TestClientIdentitySplit(t *testing.T) { + if got := headers("").Get("X-Cursor-Client-Type"); got != "cli" { + t.Fatalf("agent header client type = %q", got) + } + if agentClientType != "cli" { + t.Fatalf("agent client type = %q", agentClientType) + } + if got := headersWithType("token", catalogClientType).Get("X-Cursor-Client-Type"); got != "desktop" { + t.Fatalf("catalog header client type = %q", got) + } + if headersWithType("token", catalogClientType).Get("Authorization") != "Bearer token" { + t.Fatal("catalog header dropped the bearer token") + } + run, _ := buildRun(chatConversation{Last: "ping"}, nil, modelResolution{ID: "grok"}, "conv") + if bytes.Contains(run, []byte("desktop")) { + t.Fatal("agent run payload advertises desktop") + } + if !bytes.Contains(run, []byte(agentClientType)) { + t.Fatal("agent run payload is missing the cli identity") + } +} diff --git a/models.go b/models.go index 6e8343d..0af5cd0 100644 --- a/models.go +++ b/models.go @@ -277,10 +277,10 @@ func (p *pluginRuntime) catalog(s *requestScope, token string) ([]modelEntry, er var usableErr error ready := make(chan struct{}) go func() { - usable, usableErr = s.json(s.ctx, http.MethodPost, "/agent.v1.AgentService/GetUsableModels", token, map[string]any{}) + usable, usableErr = s.jsonClient(s.ctx, http.MethodPost, "/agent.v1.AgentService/GetUsableModels", token, map[string]any{}, catalogClientType) close(ready) }() - picker, err := s.json(s.ctx, http.MethodPost, "/aiserver.v1.AiService/AvailableModels", token, map[string]any{"useModelParameters": true, "doNotUseMarkdown": true}) + picker, err := s.jsonClient(s.ctx, http.MethodPost, "/aiserver.v1.AiService/AvailableModels", token, map[string]any{"useModelParameters": true, "doNotUseMarkdown": true}, catalogClientType) <-ready if err != nil { return nil, err diff --git a/runtime.go b/runtime.go index 394826c..be94754 100644 --- a/runtime.go +++ b/runtime.go @@ -22,6 +22,12 @@ const cursorAPI = "https://api2.cursor.sh" const clientVersion = "cli-2026.09.23-86fc751" const maxWireBytes = 64 << 20 +// Some Cursor team accounts only list models such as Claude Opus when the +// catalog client type is desktop. Agent runs stay cli so Cursor keeps the +// MCP tool protocol this plugin translates. +const agentClientType = "cli" +const catalogClientType = "desktop" + type hostCaller func(string, []byte) ([]byte, error) type rpcError struct { Code string `json:"code"` @@ -276,7 +282,10 @@ func jwtExpiry(token string) int64 { return int64(num(m["exp"]) * 1000) } func headers(token string) http.Header { - h := http.Header{"Content-Type": {"application/json"}, "Connect-Protocol-Version": {"1"}, "X-Cursor-Client-Version": {clientVersion}, "X-Cursor-Client-Type": {"cli"}, "X-Ghost-Mode": {"true"}} + return headersWithType(token, agentClientType) +} +func headersWithType(token, clientType string) http.Header { + h := http.Header{"Content-Type": {"application/json"}, "Connect-Protocol-Version": {"1"}, "X-Cursor-Client-Version": {clientVersion}, "X-Cursor-Client-Type": {clientType}, "X-Ghost-Mode": {"true"}} if token != "" { h.Set("Authorization", "Bearer "+token) }