package tools import ( "bufio" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/modelcontextprotocol/go-sdk/mcp" ) const ( ToolNameSendMessage = "isphere_send_message" EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH" EnvSendMessageIdempotencyPath = "ISPHERE_SEND_IDEMPOTENCY_PATH" sendMessageConnector = "implatform-sidecar" sendMessageConnectorMode = "sandbox-only-shell" sendMessagePreviewStage = "preview" sendMessageBlockedStage = "blocked" sendMessageProductionMode = "production" ) type SendMessageArgs struct { TargetType string `json:"target_type" jsonschema:"target type; direct or group"` TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"` ContentText string `json:"content_text" jsonschema:"message body to hash and preview; not stored in audit"` ContentSHA256 string `json:"content_sha256" jsonschema:"lowercase SHA256 hex of content_text"` IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; stored only as SHA256 in audit"` ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview or production; production is blocked until sandbox-send gate passes"` } type SendMessageAuditSink interface { AppendSendMessageAudit(ctx context.Context, event SendMessageAuditEvent) error } type SendMessageIdempotencyStore interface { ReserveSendMessageIdempotency(ctx context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) } type SendMessageAuditEvent struct { Tool string `json:"tool"` TargetType string `json:"target_type"` TargetID string `json:"target_id"` TargetRef string `json:"target_ref"` ExecutionMode string `json:"execution_mode"` Connector string `json:"connector"` ConnectorMode string `json:"connector_mode"` ConnectorStage string `json:"connector_stage"` ContentSHA256 string `json:"content_sha256"` ContentLength int `json:"content_length"` IdempotencyKeySHA256 string `json:"idempotency_key_sha256"` SendStatus string `json:"send_status"` Result string `json:"result"` AckRef string `json:"ack_ref,omitempty"` ErrorCode string `json:"error_code,omitempty"` ErrorMessage string `json:"error_message,omitempty"` GateReasonCode string `json:"gate_reason_code,omitempty"` GateReasonMessage string `json:"gate_reason_message,omitempty"` AuditRef string `json:"audit_ref"` StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` SideEffects map[string]any `json:"side_effects"` ContentText string `json:"-"` IdempotencyKey string `json:"-"` } type SendMessageIdempotencyRecord struct { Tool string `json:"tool"` TargetRef string `json:"target_ref"` ExecutionMode string `json:"execution_mode"` Connector string `json:"connector"` ConnectorStage string `json:"connector_stage"` ContentSHA256 string `json:"content_sha256"` IdempotencyKeySHA256 string `json:"idempotency_key_sha256"` AuditRef string `json:"audit_ref"` StartedAt string `json:"started_at"` } type SendMessageIdempotencyDecision struct { Duplicate bool Conflict bool FirstAuditRef string FirstStartedAt string } type fileSendMessageAuditSink struct { path string } type fileSendMessageIdempotencyStore struct { path string } var sendMessageIdempotencyFileMu sync.Mutex func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) { RegisterISphereSendMessageToolWithState(server, auditSink, nil) } func RegisterISphereSendMessageToolWithState(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore) { if auditSink == nil { auditSink = defaultSendMessageAuditSink() } if idempotencyStore == nil { idempotencyStore = defaultSendMessageIdempotencyStore() } RegisterISphereSendMessageToolWithStateAndConnector(server, auditSink, idempotencyStore, nil) } func RegisterISphereSendMessageToolWithStateAndConnector(server *mcp.Server, auditSink SendMessageAuditSink, idempotencyStore SendMessageIdempotencyStore, connector SendMessageConnector) { if auditSink == nil { auditSink = defaultSendMessageAuditSink() } if idempotencyStore == nil { idempotencyStore = defaultSendMessageIdempotencyStore() } mcp.AddTool[SendMessageArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameSendMessage, Description: "Preview iSphere message sends with idempotency/audit metadata; production send is blocked until sandbox evidence passes.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input SendMessageArgs) (*mcp.CallToolResult, map[string]any, error) { started := time.Now().UTC() normalized, err := normalizeSendMessageArgs(input) if err != nil { return nil, nil, err } finished := time.Now().UTC() gatePolicy := currentSendMessageGatePolicy(connector != nil) response, event := sendMessagePreviewResponse(normalized, started, finished, connector != nil, gatePolicy) decision, err := idempotencyStore.ReserveSendMessageIdempotency(ctx, sendMessageIdempotencyRecordFromAuditEvent(event)) if err != nil { return nil, nil, fmt.Errorf("%s idempotency reserve failed: %w", ToolNameSendMessage, err) } applySendMessageIdempotencyDecision(response, &event, decision) if normalized.ExecutionMode == sendMessageProductionMode && connector != nil && !decision.Duplicate && !decision.Conflict { result, connectorErr := connector.ExecuteSendMessage(ctx, sendMessageConnectorRequestFromNormalized(normalized)) applySendMessageConnectorResult(response, &event, result, connectorErr) } if err := auditSink.AppendSendMessageAudit(ctx, event); err != nil { return nil, nil, fmt.Errorf("%s audit append failed: %w", ToolNameSendMessage, err) } return nil, response, nil }) } func defaultSendMessageAuditSink() SendMessageAuditSink { if path := strings.TrimSpace(os.Getenv(EnvSendMessageAuditPath)); path != "" { return fileSendMessageAuditSink{path: path} } return fileSendMessageAuditSink{path: filepath.Join("runs", "send-audit", "send-message-preview.jsonl")} } func defaultSendMessageIdempotencyStore() SendMessageIdempotencyStore { if path := strings.TrimSpace(os.Getenv(EnvSendMessageIdempotencyPath)); path != "" { return fileSendMessageIdempotencyStore{path: path} } return fileSendMessageIdempotencyStore{path: filepath.Join("runs", "send-audit", "send-message-idempotency.jsonl")} } func currentSendMessageGatePolicy(connectorConfigured bool) SendMessageGatePolicy { return LoadSendMessageGatePolicy(map[string]string{ EnvSendMessageProductionEnabled: os.Getenv(EnvSendMessageProductionEnabled), }, false, connectorConfigured) } func (s fileSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error { if strings.TrimSpace(s.path) == "" { return nil } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } payload, err := json.Marshal(event) if err != nil { return err } file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err } defer file.Close() if _, err := file.Write(append(payload, '\n')); err != nil { return err } return nil } func (s fileSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) { if strings.TrimSpace(s.path) == "" { return SendMessageIdempotencyDecision{}, nil } if record.IdempotencyKeySHA256 == "" { return SendMessageIdempotencyDecision{}, fmt.Errorf("idempotency_key_sha256 is required") } sendMessageIdempotencyFileMu.Lock() defer sendMessageIdempotencyFileMu.Unlock() if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return SendMessageIdempotencyDecision{}, err } existing, err := readSendMessageIdempotencyRecords(s.path) if err != nil { return SendMessageIdempotencyDecision{}, err } for _, candidate := range existing { if candidate.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 { continue } decision := SendMessageIdempotencyDecision{ FirstAuditRef: candidate.AuditRef, FirstStartedAt: candidate.StartedAt, } if candidate.TargetRef == record.TargetRef && candidate.ContentSHA256 == record.ContentSHA256 { decision.Duplicate = true return decision, nil } decision.Conflict = true return decision, nil } payload, err := json.Marshal(record) if err != nil { return SendMessageIdempotencyDecision{}, err } file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return SendMessageIdempotencyDecision{}, err } defer file.Close() if _, err := file.Write(append(payload, '\n')); err != nil { return SendMessageIdempotencyDecision{}, err } return SendMessageIdempotencyDecision{}, nil } func readSendMessageIdempotencyRecords(path string) ([]SendMessageIdempotencyRecord, error) { file, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer file.Close() var records []SendMessageIdempotencyRecord scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var record SendMessageIdempotencyRecord if err := json.Unmarshal([]byte(line), &record); err != nil { return nil, fmt.Errorf("decode idempotency record: %w", err) } records = append(records, record) } if err := scanner.Err(); err != nil { return nil, err } return records, nil } type normalizedSendMessageArgs struct { TargetType string TargetID string TargetRef string ContentLength int ContentSHA256 string IdempotencyKeySHA256 string ExecutionMode string } func normalizeSendMessageArgs(input SendMessageArgs) (normalizedSendMessageArgs, error) { targetType, targetRefPrefix, err := normalizeSendMessageTargetType(input.TargetType) if err != nil { return normalizedSendMessageArgs{}, err } targetID := strings.TrimSpace(input.TargetID) if targetID == "" { return normalizedSendMessageArgs{}, fmt.Errorf("%s target_id is required", ToolNameSendMessage) } contentText := input.ContentText if strings.TrimSpace(contentText) == "" { return normalizedSendMessageArgs{}, fmt.Errorf("%s content_text is required", ToolNameSendMessage) } contentHash, err := validateSendMessageContentHash(contentText, input.ContentSHA256) if err != nil { return normalizedSendMessageArgs{}, err } idempotencyKey := strings.TrimSpace(input.IdempotencyKey) if idempotencyKey == "" { return normalizedSendMessageArgs{}, fmt.Errorf("%s idempotency_key is required", ToolNameSendMessage) } mode, err := normalizeSendMessageExecutionMode(input.ExecutionMode) if err != nil { return normalizedSendMessageArgs{}, err } return normalizedSendMessageArgs{ TargetType: targetType, TargetID: targetID, TargetRef: targetRefPrefix + ":" + targetID, ContentLength: len(contentText), ContentSHA256: contentHash, IdempotencyKeySHA256: sha256Hex(idempotencyKey), ExecutionMode: mode, }, nil } func normalizeSendMessageTargetType(value string) (string, string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "direct", "contact": return "direct", "contact", nil case "group", "groupchat": return "group", "group", nil default: return "", "", fmt.Errorf("%s target_type %q is not available; use direct or group", ToolNameSendMessage, value) } } func validateSendMessageContentHash(contentText string, provided string) (string, error) { actual := sha256Hex(contentText) normalized := strings.ToLower(strings.TrimSpace(provided)) if normalized == "" { return "", fmt.Errorf("%s content_sha256 is required", ToolNameSendMessage) } if len(normalized) != 64 || !isLowerHex(normalized) { return "", fmt.Errorf("%s content_sha256 must be a lowercase SHA256 hex value", ToolNameSendMessage) } if normalized != actual { return "", fmt.Errorf("%s content_sha256 does not match content_text", ToolNameSendMessage) } return actual, nil } func normalizeSendMessageExecutionMode(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "preview", "dry_run", "dry-run": return "preview", nil case sendMessageProductionMode: return sendMessageProductionMode, nil default: return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendMessage, value) } } func sendMessagePreviewResponse(input normalizedSendMessageArgs, started time.Time, finished time.Time, connectorAvailable bool, gatePolicy SendMessageGatePolicy) (map[string]any, SendMessageAuditEvent) { sideEffects := sendMessageNoSideEffects() status := "planned" stage := sendMessagePreviewStage ok := true blockedReason := any(nil) gateReasonCode := "" gateReasonMessage := "" if input.ExecutionMode == sendMessageProductionMode { if connectorAvailable { stage = sendMessagePreviewStage } else { ok = false status = "blocked" stage = sendMessageBlockedStage gateReasonCode = gatePolicy.ReasonCode gateReasonMessage = gatePolicy.ReasonMessage blockedReason = gateReasonMessage } } auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256) event := SendMessageAuditEvent{ Tool: ToolNameSendMessage, TargetType: input.TargetType, TargetID: input.TargetID, TargetRef: input.TargetRef, ExecutionMode: input.ExecutionMode, Connector: sendMessageConnector, ConnectorMode: sendMessageConnectorMode, ConnectorStage: stage, ContentSHA256: input.ContentSHA256, ContentLength: input.ContentLength, IdempotencyKeySHA256: input.IdempotencyKeySHA256, SendStatus: status, Result: status, GateReasonCode: gateReasonCode, GateReasonMessage: gateReasonMessage, AuditRef: auditRef, StartedAt: started.Format(time.RFC3339Nano), FinishedAt: finished.Format(time.RFC3339Nano), SideEffects: sideEffects, } response := map[string]any{ "ok": ok, "send_status": status, "blocked_reason": blockedReason, "execution_mode": input.ExecutionMode, "connector": sendMessageConnector, "connector_mode": sendMessageConnectorMode, "connector_stage": stage, "gate_reason_code": gateReasonCode, "gate_reason_message": gateReasonMessage, "production_send_enabled": false, "target": map[string]any{ "target_type": input.TargetType, "target_id": input.TargetID, "target_ref": input.TargetRef, }, "content": map[string]any{ "content_sha256": input.ContentSHA256, "content_length": input.ContentLength, }, "idempotency": map[string]any{ "idempotency_key_sha256": input.IdempotencyKeySHA256, "duplicate_detected": false, "conflict_detected": false, "first_audit_ref": nil, "first_started_at": nil, }, "side_effects": sideEffects, "audit": map[string]any{ "tool": ToolNameSendMessage, "source": sendMessageConnector, "execution_mode": input.ExecutionMode, "connector": sendMessageConnector, "connector_mode": sendMessageConnectorMode, "connector_stage": stage, "gate_reason_code": gateReasonCode, "gate_reason_message": gateReasonMessage, "content_sha256": input.ContentSHA256, "target_ref": input.TargetRef, "audit_ref": auditRef, "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": status, }, } return response, event } func applySendMessageConnectorResult(response map[string]any, event *SendMessageAuditEvent, result SendMessageConnectorResult, err error) { normalized := normalizeSendMessageConnectorResult(result, err) response["ok"] = normalized.Accepted response["send_status"] = normalized.Status response["connector_mode"] = normalized.ConnectorMode response["connector_stage"] = normalized.Status if normalized.Accepted { response["blocked_reason"] = nil } else if normalized.ErrorMessage != "" { response["blocked_reason"] = normalized.ErrorMessage } if normalized.AckRef != "" { response["ack_ref"] = normalized.AckRef } if normalized.ErrorCode != "" { response["error_code"] = normalized.ErrorCode } if normalized.ErrorMessage != "" { response["error_message"] = normalized.ErrorMessage } audit, _ := response["audit"].(map[string]any) if audit != nil { audit["result"] = normalized.Status audit["connector_mode"] = normalized.ConnectorMode audit["connector_stage"] = normalized.Status if normalized.AckRef != "" { audit["ack_ref"] = normalized.AckRef } if normalized.ErrorCode != "" { audit["error_code"] = normalized.ErrorCode } if normalized.ErrorMessage != "" { audit["error_message"] = normalized.ErrorMessage } } if event != nil { event.ConnectorMode = normalized.ConnectorMode event.ConnectorStage = normalized.Status event.SendStatus = normalized.Status event.Result = normalized.Status event.AckRef = normalized.AckRef event.ErrorCode = normalized.ErrorCode event.ErrorMessage = normalized.ErrorMessage } } func sendMessageIdempotencyRecordFromAuditEvent(event SendMessageAuditEvent) SendMessageIdempotencyRecord { return SendMessageIdempotencyRecord{ Tool: event.Tool, TargetRef: event.TargetRef, ExecutionMode: event.ExecutionMode, Connector: event.Connector, ConnectorStage: event.ConnectorStage, ContentSHA256: event.ContentSHA256, IdempotencyKeySHA256: event.IdempotencyKeySHA256, AuditRef: event.AuditRef, StartedAt: event.StartedAt, } } func applySendMessageIdempotencyDecision(response map[string]any, event *SendMessageAuditEvent, decision SendMessageIdempotencyDecision) { idempotency, _ := response["idempotency"].(map[string]any) if idempotency == nil { idempotency = map[string]any{} response["idempotency"] = idempotency } idempotency["duplicate_detected"] = decision.Duplicate idempotency["conflict_detected"] = decision.Conflict if decision.FirstAuditRef != "" { idempotency["first_audit_ref"] = decision.FirstAuditRef } if decision.FirstStartedAt != "" { idempotency["first_started_at"] = decision.FirstStartedAt } if !decision.Conflict { return } blockedReason := "idempotency key was already used for a different target or content" response["ok"] = false response["send_status"] = "blocked" response["blocked_reason"] = blockedReason response["connector_stage"] = sendMessageBlockedStage audit, _ := response["audit"].(map[string]any) if audit != nil { audit["result"] = "blocked" audit["connector_stage"] = sendMessageBlockedStage audit["blocked_reason"] = blockedReason } if event != nil { event.ConnectorStage = sendMessageBlockedStage event.SendStatus = "blocked" event.Result = "blocked" } } func sendMessageNoSideEffects() map[string]any { return map[string]any{ "sent_message": false, "sent_file": false, "uploaded_file": false, "clicked_ui": false, "typed_text": false, "captured_network": false, "attached_hook": false, "modified_client_data": false, } } func sendMessageAuditRef(contentSHA256 string, idempotencySHA256 string) string { return "send-message:" + shortHash(contentSHA256) + ":" + shortHash(idempotencySHA256) } func shortHash(value string) string { if len(value) < 12 { return value } return value[:12] } func sha256Hex(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } func isLowerHex(value string) bool { for _, ch := range value { if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) { return false } } return true }