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
231 lines
6.9 KiB
Go
231 lines
6.9 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
const (
|
|
appServerMCPElicitationApprovalKindKey = "codex_approval_kind"
|
|
appServerMCPElicitationToolApprovalKind = "mcp_tool_call"
|
|
appServerMCPElicitationToolSuggestKind = "tool_suggestion"
|
|
appServerMCPElicitationPersistKey = "persist"
|
|
appServerMCPElicitationPersistSession = "session"
|
|
appServerMCPElicitationPersistAlways = "always"
|
|
)
|
|
|
|
func (a *CodexAppServerAdapter) appServerMCPElicitationRequested(
|
|
session Session,
|
|
turnID string,
|
|
requestID string,
|
|
params map[string]any,
|
|
) ([]activityshared.Event, *pendingInteractiveRequest, error) {
|
|
if err := validateMessageOnlyMCPElicitation(params); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
message, ok := params["message"].(string)
|
|
if !ok {
|
|
return nil, nil, errors.New("MCP elicitation message must be a string")
|
|
}
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
message = "MCP server requests permission."
|
|
}
|
|
serverName, ok := params["serverName"].(string)
|
|
if !ok {
|
|
return nil, nil, errors.New("MCP elicitation serverName must be a string")
|
|
}
|
|
serverName = strings.TrimSpace(serverName)
|
|
if serverName == "" {
|
|
return nil, nil, errors.New("MCP elicitation serverName is required")
|
|
}
|
|
meta := payloadObject(params["_meta"])
|
|
if asString(meta[appServerMCPElicitationApprovalKindKey]) == appServerMCPElicitationToolSuggestKind {
|
|
return nil, nil, errors.New("MCP tool-suggestion elicitation is not supported")
|
|
}
|
|
options := appServerMCPElicitationApprovalOptions(meta)
|
|
input := map[string]any{
|
|
"requestId": requestID,
|
|
"reason": message,
|
|
"serverName": serverName,
|
|
"mode": "form",
|
|
"requestedSchema": clonePayloadValue(params["requestedSchema"]),
|
|
"options": cloneOptionMaps(options),
|
|
}
|
|
pending := &pendingInteractiveRequest{
|
|
agentSessionID: strings.TrimSpace(session.AgentSessionID),
|
|
turnID: strings.TrimSpace(turnID),
|
|
requestID: requestID,
|
|
eventID: newID(),
|
|
callID: requestID,
|
|
callType: "approval",
|
|
input: input,
|
|
kind: "approval",
|
|
providerTurnID: strings.TrimSpace(
|
|
asString(params["turnId"]),
|
|
),
|
|
name: message,
|
|
toolName: serverName,
|
|
options: options,
|
|
response: make(chan pendingInteractiveResponse, 1),
|
|
}
|
|
a.storePendingRequest(pending)
|
|
return []activityshared.Event{
|
|
newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWaiting, "", "", map[string]any{
|
|
"phase": string(activityshared.TurnPhaseWaitingApproval),
|
|
"requestId": requestID,
|
|
}),
|
|
newTurnActivityEventWithID(
|
|
session,
|
|
pending.eventID,
|
|
EventCallStarted,
|
|
turnID,
|
|
SessionStatusWaiting,
|
|
"",
|
|
message,
|
|
map[string]any{
|
|
"callId": pending.callID,
|
|
"callType": pending.callType,
|
|
"name": pending.name,
|
|
"toolName": pending.toolName,
|
|
"status": string(activityshared.TurnPhaseWaitingApproval),
|
|
"input": clonePayload(input),
|
|
"metadata": map[string]any{
|
|
"callType": pending.callType,
|
|
"mcpServer": serverName,
|
|
},
|
|
},
|
|
),
|
|
normalizedInteractionRequestedEvent(session, turnID, pending),
|
|
}, pending, nil
|
|
}
|
|
|
|
func validateMessageOnlyMCPElicitation(params map[string]any) error {
|
|
mode := strings.TrimSpace(asString(params["mode"]))
|
|
if mode != "form" {
|
|
return fmt.Errorf("MCP elicitation mode %q is not supported", mode)
|
|
}
|
|
schema, ok := params["requestedSchema"].(map[string]any)
|
|
if !ok {
|
|
return errors.New("MCP elicitation requestedSchema must be an object")
|
|
}
|
|
if strings.TrimSpace(asString(schema["type"])) != "object" {
|
|
return errors.New("MCP elicitation requestedSchema type must be object")
|
|
}
|
|
properties, ok := schema["properties"].(map[string]any)
|
|
if !ok {
|
|
return errors.New("MCP elicitation requestedSchema properties must be an object")
|
|
}
|
|
if len(properties) > 0 {
|
|
return errors.New("MCP elicitation forms with fields are not supported")
|
|
}
|
|
if required, exists := schema["required"]; exists {
|
|
values, valid := appServerMCPStringArray(required)
|
|
if !valid {
|
|
return errors.New("MCP elicitation requestedSchema required must be a string array")
|
|
}
|
|
if len(values) > 0 {
|
|
return errors.New("MCP elicitation message-only schema cannot require fields")
|
|
}
|
|
}
|
|
for key := range schema {
|
|
switch key {
|
|
case "$schema", "type", "properties", "required":
|
|
default:
|
|
return fmt.Errorf("MCP elicitation requestedSchema field %q is not supported", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func appServerMCPElicitationApprovalOptions(meta map[string]any) []map[string]any {
|
|
options := []map[string]any{
|
|
{"optionId": "approve", "name": "Allow", "kind": "allow_once"},
|
|
}
|
|
if appServerMCPElicitationSupportsPersist(meta, appServerMCPElicitationPersistSession) {
|
|
options = append(options, map[string]any{
|
|
"optionId": "approve_for_session",
|
|
"name": "Allow for this session",
|
|
"kind": "allow_always",
|
|
})
|
|
}
|
|
if appServerMCPElicitationSupportsPersist(meta, appServerMCPElicitationPersistAlways) {
|
|
options = append(options, map[string]any{
|
|
"optionId": "approve_always",
|
|
"name": "Always allow",
|
|
"kind": "allow_always",
|
|
})
|
|
}
|
|
if asString(meta[appServerMCPElicitationApprovalKindKey]) == appServerMCPElicitationToolApprovalKind {
|
|
return append(options, map[string]any{
|
|
"optionId": "cancel",
|
|
"name": "Cancel",
|
|
"kind": "reject_once",
|
|
})
|
|
}
|
|
return append(options,
|
|
map[string]any{"optionId": "deny", "name": "Deny", "kind": "reject_once"},
|
|
map[string]any{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"},
|
|
)
|
|
}
|
|
|
|
func appServerMCPElicitationSupportsPersist(meta map[string]any, expected string) bool {
|
|
persist := meta[appServerMCPElicitationPersistKey]
|
|
if asString(persist) == expected {
|
|
return true
|
|
}
|
|
values, _ := appServerMCPStringArray(persist)
|
|
for _, value := range values {
|
|
if value == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func appServerMCPStringArray(value any) ([]string, bool) {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return nil, true
|
|
case []string:
|
|
return append([]string(nil), typed...), true
|
|
case []any:
|
|
values := make([]string, 0, len(typed))
|
|
for _, entry := range typed {
|
|
text, ok := entry.(string)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
values = append(values, text)
|
|
}
|
|
return values, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func appServerMCPElicitationResult(selection pendingInteractiveResponse) map[string]any {
|
|
result := map[string]any{}
|
|
switch strings.TrimSpace(selection.optionID) {
|
|
case "approve":
|
|
result["action"] = "accept"
|
|
case "approve_for_session":
|
|
result["action"] = "accept"
|
|
result["_meta"] = map[string]any{appServerMCPElicitationPersistKey: appServerMCPElicitationPersistSession}
|
|
case "approve_always":
|
|
result["action"] = "accept"
|
|
result["_meta"] = map[string]any{appServerMCPElicitationPersistKey: appServerMCPElicitationPersistAlways}
|
|
case "deny":
|
|
result["action"] = "decline"
|
|
case "cancel":
|
|
result["action"] = "cancel"
|
|
default:
|
|
result["action"] = "cancel"
|
|
}
|
|
return result
|
|
}
|