Files
tutti/packages/agent/daemon/runtime/cursor_acp_question_mcp.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

457 lines
16 KiB
Go

package agentruntime
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
const (
cursorACPQuestionMCPServerName = "tutti-interaction"
cursorACPQuestionMCPToolName = "AskUserQuestion"
cursorACPQuestionMCPPath = "/mcp/cursor-interaction"
cursorACPQuestionMCPVersion = "2025-06-18"
cursorACPQuestionMCPMaxBody = 1 << 20
)
type cursorACPQuestionMCPActiveTurn struct {
generation uint64
session Session
turnID string
emit EventSink
}
// cursorACPQuestionMCPBridge supplies the AskUserQuestion capability that the
// Cursor CLI exposes on its interactive CLI surface but currently omits from
// its ACP tool catalog. It uses Cursor's supported session-scoped HTTP MCP
// extension rather than patching or impersonating the provider binary.
type cursorACPQuestionMCPBridge struct {
adapter *standardACPAdapter
mu sync.Mutex
listener net.Listener
server *http.Server
baseURL string
authorities map[string]Session
activeTurns map[string]cursorACPQuestionMCPActiveTurn
nextTurnEpoch uint64
}
func newCursorACPQuestionMCPBridge(adapter *standardACPAdapter) *cursorACPQuestionMCPBridge {
return &cursorACPQuestionMCPBridge{
adapter: adapter,
authorities: make(map[string]Session),
activeTurns: make(map[string]cursorACPQuestionMCPActiveTurn),
}
}
func (bridge *cursorACPQuestionMCPBridge) Bind(_ context.Context, session Session) (MCPServerBinding, func(), error) {
if bridge == nil || bridge.adapter == nil || strings.TrimSpace(session.AgentSessionID) == "" {
return MCPServerBinding{}, nil, errors.New("cursor interaction MCP binding identity is required")
}
token, err := cursorACPQuestionMCPToken(32)
if err != nil {
return MCPServerBinding{}, nil, err
}
bridge.mu.Lock()
if err := bridge.startLocked(); err != nil {
bridge.mu.Unlock()
return MCPServerBinding{}, nil, err
}
bridge.authorities[token] = session
baseURL := bridge.baseURL
bridge.mu.Unlock()
var once sync.Once
release := func() { once.Do(func() { bridge.releaseToken(token) }) }
return MCPServerBinding{
Name: cursorACPQuestionMCPServerName,
Type: "http",
URL: baseURL,
Headers: map[string]string{
"Authorization": "Bearer " + token,
},
}, release, nil
}
func (bridge *cursorACPQuestionMCPBridge) startLocked() error {
if bridge.server != nil && bridge.listener != nil {
return nil
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("listen for Cursor interaction MCP: %w", err)
}
server := &http.Server{Handler: bridge, ReadHeaderTimeout: 5 * time.Second}
bridge.listener = listener
bridge.server = server
bridge.baseURL = "http://" + listener.Addr().String() + cursorACPQuestionMCPPath
go func() { _ = server.Serve(listener) }()
return nil
}
func (bridge *cursorACPQuestionMCPBridge) releaseToken(token string) {
if bridge == nil || token == "" {
return
}
bridge.mu.Lock()
delete(bridge.authorities, token)
if len(bridge.authorities) != 0 {
bridge.mu.Unlock()
return
}
server := bridge.server
bridge.listener = nil
bridge.server = nil
bridge.baseURL = ""
bridge.activeTurns = make(map[string]cursorACPQuestionMCPActiveTurn)
bridge.mu.Unlock()
if server != nil {
_ = server.Close()
}
}
func (bridge *cursorACPQuestionMCPBridge) ActivateTurn(session Session, turnID string, emit EventSink) func() {
if bridge == nil || strings.TrimSpace(session.AgentSessionID) == "" || strings.TrimSpace(turnID) == "" {
return func() {}
}
bridge.mu.Lock()
bridge.nextTurnEpoch++
active := cursorACPQuestionMCPActiveTurn{
generation: bridge.nextTurnEpoch,
session: session,
turnID: strings.TrimSpace(turnID),
emit: emit,
}
bridge.activeTurns[session.AgentSessionID] = active
bridge.mu.Unlock()
return func() {
bridge.mu.Lock()
current, ok := bridge.activeTurns[session.AgentSessionID]
if ok && current.generation == active.generation {
delete(bridge.activeTurns, session.AgentSessionID)
}
bridge.mu.Unlock()
}
}
func (bridge *cursorACPQuestionMCPBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if bridge == nil || request.URL.Path != cursorACPQuestionMCPPath || !cursorACPQuestionMCPLoopbackHost(request.Host) {
http.NotFound(writer, request)
return
}
if !cursorACPQuestionMCPLoopbackOrigin(request.Header.Get("Origin")) {
cursorACPQuestionMCPWriteError(writer, http.StatusForbidden, nil, -32020, "Origin is not allowed")
return
}
if request.Method != http.MethodPost {
writer.Header().Set("Allow", http.MethodPost)
cursorACPQuestionMCPWriteError(writer, http.StatusMethodNotAllowed, nil, -32600, "Cursor interaction MCP only accepts POST")
return
}
session, active, ok := bridge.authorize(request.Header.Get("Authorization"))
if !ok {
writer.Header().Set("WWW-Authenticate", "Bearer")
cursorACPQuestionMCPWriteError(writer, http.StatusUnauthorized, nil, -32001, "Cursor interaction MCP authentication is required")
return
}
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, cursorACPQuestionMCPMaxBody))
decoder.DisallowUnknownFields()
var rpc cursorACPQuestionMCPRequest
if err := decoder.Decode(&rpc); err != nil || decoder.Decode(&struct{}{}) != io.EOF || rpc.JSONRPC != "2.0" || strings.TrimSpace(rpc.Method) == "" {
cursorACPQuestionMCPWriteError(writer, http.StatusBadRequest, cursorACPQuestionMCPNullID(rpc.ID), -32600, "Invalid MCP request")
return
}
switch rpc.Method {
case "notifications/initialized", "notifications/cancelled":
writer.WriteHeader(http.StatusAccepted)
case "initialize":
cursorACPQuestionMCPWriteResult(writer, rpc.ID, map[string]any{
"protocolVersion": cursorACPQuestionMCPVersion,
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": cursorACPQuestionMCPServerName, "version": "1"},
})
case "ping":
cursorACPQuestionMCPWriteResult(writer, rpc.ID, map[string]any{})
case "resources/list":
cursorACPQuestionMCPWriteResult(writer, rpc.ID, map[string]any{"resources": []any{}})
case "resources/templates/list":
cursorACPQuestionMCPWriteResult(writer, rpc.ID, map[string]any{"resourceTemplates": []any{}})
case "tools/list":
cursorACPQuestionMCPWriteResult(writer, rpc.ID, map[string]any{"tools": []any{cursorACPQuestionMCPTool()}})
case "tools/call":
if active.turnID == "" || active.session.AgentSessionID != session.AgentSessionID {
cursorACPQuestionMCPWriteError(writer, http.StatusOK, rpc.ID, -32000, "AskUserQuestion is only available during an active Cursor turn")
return
}
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if json.Unmarshal(rpc.Params, &params) != nil || params.Name != cursorACPQuestionMCPToolName {
cursorACPQuestionMCPWriteError(writer, http.StatusOK, rpc.ID, -32602, "Invalid AskUserQuestion arguments")
return
}
result, err := bridge.adapter.handleCursorMCPAskQuestion(request.Context(), active.session, active.turnID, params.Arguments, active.emit)
if err != nil {
cursorACPQuestionMCPWriteError(writer, http.StatusOK, rpc.ID, -32000, err.Error())
return
}
cursorACPQuestionMCPWriteResult(writer, rpc.ID, result)
default:
cursorACPQuestionMCPWriteError(writer, http.StatusOK, rpc.ID, -32601, "Method not found")
}
}
func (bridge *cursorACPQuestionMCPBridge) authorize(header string) (Session, cursorACPQuestionMCPActiveTurn, bool) {
token, ok := strings.CutPrefix(strings.TrimSpace(header), "Bearer ")
if !ok || token == "" {
return Session{}, cursorACPQuestionMCPActiveTurn{}, false
}
bridge.mu.Lock()
defer bridge.mu.Unlock()
session, exists := bridge.authorities[token]
if !exists {
return Session{}, cursorACPQuestionMCPActiveTurn{}, false
}
return session, bridge.activeTurns[session.AgentSessionID], true
}
func (a *standardACPAdapter) handleCursorMCPAskQuestion(
ctx context.Context,
session Session,
turnID string,
arguments map[string]any,
emit EventSink,
) (map[string]any, error) {
callID, requestID := newID(), newID()
input := clonePayload(arguments)
input["toolCallId"] = callID
raw, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("encode AskUserQuestion arguments: %w", err)
}
parsed, normalized, err := parseCursorAskQuestionRequest(raw)
if err != nil {
return nil, err
}
normalized["requestId"] = requestID
title := firstNonEmpty(strings.TrimSpace(parsed.Title), cursorACPQuestionMCPToolName)
pending := &pendingACPApproval{
agentSessionID: strings.TrimSpace(session.AgentSessionID),
requestID: requestID,
eventID: newID(),
callID: callID,
callType: "interactive",
turnID: strings.TrimSpace(turnID),
input: clonePayload(normalized),
kind: "ask-user",
providerMethod: cursorACPMethodAskQuestion,
name: title,
toolName: cursorACPQuestionMCPToolName,
response: make(chan pendingInteractiveResponse, 1),
interactionRequested: true,
}
pending.prompt = &SessionInteractivePrompt{
Kind: "ask-user",
RequestID: requestID,
ToolName: cursorACPQuestionMCPToolName,
Status: "waiting_input",
Input: clonePayload(normalized),
Metadata: map[string]any{
"callType": "interactive",
"interactiveKind": "ask-user",
"toolName": cursorACPQuestionMCPToolName,
"providerMethod": "mcp/tools/call",
},
}
a.storePendingApproval(pending)
if a.getPendingApproval(session.AgentSessionID, turnID, requestID) != pending {
return nil, errors.New("cursor AskUserQuestion session is no longer active")
}
if emit != nil {
emit([]activityshared.Event{
newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWaiting, "", "", map[string]any{
"phase": string(activityshared.TurnPhaseWaitingApproval), "requestId": requestID,
}),
newTurnActivityEventWithID(session, pending.eventID, EventCallStarted, turnID, SessionStatusWaiting, "", title, map[string]any{
"callId": callID, "callType": "interactive", "name": title,
"toolName": cursorACPQuestionMCPToolName, "status": "waiting_input", "input": clonePayload(normalized),
}),
normalizedInteractionRequestedEvent(session, turnID, pending),
})
}
selection, err := pending.wait(ctx)
if err != nil {
pending.finish(pendingInteractiveRequestStateInterrupted)
if emit != nil {
events := normalizedPermissionResolvedEvents(session, turnID, pending, pendingInteractiveResponse{}, err)
events = append(events, newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWorking, "", "", map[string]any{
"phase": string(activityshared.TurnPhaseWorking), "requestId": requestID,
}))
emit(events)
}
return nil, err
}
if !pending.finish(pendingInteractiveRequestStateAnswered) {
return nil, errors.New("cursor AskUserQuestion response is no longer live")
}
if emit != nil {
emit(normalizedPermissionResolvedEvents(session, turnID, pending, selection, nil))
}
return cursorACPQuestionMCPResult(selection), nil
}
func cursorACPQuestionMCPResult(selection pendingInteractiveResponse) map[string]any {
outcome := normalizePermissionOptionToken(selection.action)
payload := map[string]any{"outcome": firstNonEmpty(outcome, "answered")}
if answers := payloadObject(selection.payload["answersByQuestionId"]); len(answers) > 0 {
payload["outcome"] = "answered"
payload["answersByQuestionId"] = answers
}
raw, _ := json.Marshal(payload)
return map[string]any{"content": []any{map[string]any{"type": "text", "text": string(raw)}}}
}
func cursorACPQuestionMCPPermissionDecision(raw json.RawMessage) string {
var request struct {
ToolCall struct {
Title string `json:"title"`
Kind string `json:"kind"`
} `json:"toolCall"`
}
if json.Unmarshal(raw, &request) != nil || !strings.EqualFold(strings.TrimSpace(request.ToolCall.Kind), "other") {
return ""
}
title := strings.TrimSpace(request.ToolCall.Title)
plainTitle := cursorACPQuestionMCPServerName + ": " + cursorACPQuestionMCPToolName
cursorTitle := cursorACPQuestionMCPServerName + "-" + cursorACPQuestionMCPToolName + ": " + cursorACPQuestionMCPToolName
if strings.EqualFold(title, plainTitle) || strings.EqualFold(title, cursorTitle) {
return "approved"
}
return ""
}
func cursorACPQuestionMCPTool() map[string]any {
option := map[string]any{
"type": "object", "additionalProperties": false,
"properties": map[string]any{
"id": map[string]any{"type": "string", "minLength": 1},
"label": map[string]any{"type": "string", "minLength": 1},
},
"required": []string{"id", "label"},
}
question := map[string]any{
"type": "object", "additionalProperties": false,
"properties": map[string]any{
"id": map[string]any{"type": "string", "minLength": 1},
"question": map[string]any{"type": "string", "minLength": 1},
"options": map[string]any{"type": "array", "minItems": 2, "maxItems": 3, "items": option},
},
"required": []string{"id", "question", "options"},
}
return map[string]any{
"name": cursorACPQuestionMCPToolName,
"description": "Ask the user one to three blocking questions and wait for their answers. Use this whenever user input or a choice is needed.",
"inputSchema": map[string]any{
"type": "object", "additionalProperties": false,
"properties": map[string]any{
"title": map[string]any{"type": "string"},
"questions": map[string]any{"type": "array", "minItems": 1, "maxItems": 3, "items": question},
},
"required": []string{"questions"},
},
"annotations": map[string]any{
"readOnlyHint": true, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false,
},
}
}
type cursorACPQuestionMCPRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type cursorACPQuestionMCPResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *cursorACPQuestionMCPError `json:"error,omitempty"`
}
type cursorACPQuestionMCPError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func cursorACPQuestionMCPWriteResult(writer http.ResponseWriter, id json.RawMessage, result any) {
cursorACPQuestionMCPWrite(writer, http.StatusOK, cursorACPQuestionMCPResponse{JSONRPC: "2.0", ID: cursorACPQuestionMCPNullID(id), Result: result})
}
func cursorACPQuestionMCPWriteError(writer http.ResponseWriter, status int, id json.RawMessage, code int, message string) {
cursorACPQuestionMCPWrite(writer, status, cursorACPQuestionMCPResponse{
JSONRPC: "2.0", ID: cursorACPQuestionMCPNullID(id), Error: &cursorACPQuestionMCPError{Code: code, Message: message},
})
}
func cursorACPQuestionMCPWrite(writer http.ResponseWriter, status int, response cursorACPQuestionMCPResponse) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "private, no-store")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(response)
}
func cursorACPQuestionMCPNullID(id json.RawMessage) json.RawMessage {
if len(id) == 0 {
return json.RawMessage("null")
}
return id
}
func cursorACPQuestionMCPToken(size int) (string, error) {
buffer := make([]byte, size)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate Cursor interaction MCP secret: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func cursorACPQuestionMCPLoopbackHost(value string) bool {
host := value
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
func cursorACPQuestionMCPLoopbackOrigin(value string) bool {
origin := strings.TrimSpace(value)
if origin == "" || origin == "null" {
return true
}
parsed, err := url.Parse(origin)
if err != nil {
return false
}
host := strings.Trim(strings.ToLower(parsed.Hostname()), "[]")
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}