package tools import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" ) const ( ToolNameSendMessage = "isphere_send_message" EnvSendMessageAuditPath = "ISPHERE_SEND_AUDIT_PATH" sendMessageConnector = "implatform-sidecar" 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 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"` 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"` 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 fileSendMessageAuditSink struct { path string } func RegisterISphereSendMessageTool(server *mcp.Server, auditSink SendMessageAuditSink) { if auditSink == nil { auditSink = defaultSendMessageAuditSink() } 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() response, event := sendMessagePreviewResponse(normalized, started, finished) 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 (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 } 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) (map[string]any, SendMessageAuditEvent) { sideEffects := sendMessageNoSideEffects() status := "planned" stage := sendMessagePreviewStage ok := true blockedReason := any(nil) if input.ExecutionMode == sendMessageProductionMode { ok = false status = "blocked" stage = sendMessageBlockedStage blockedReason = "production send is blocked until dynamic observation, idempotency audit, and one approved sandbox send gate pass" } auditRef := sendMessageAuditRef(input.ContentSHA256, input.IdempotencyKeySHA256) event := SendMessageAuditEvent{ Tool: ToolNameSendMessage, TargetType: input.TargetType, TargetID: input.TargetID, TargetRef: input.TargetRef, ExecutionMode: input.ExecutionMode, Connector: sendMessageConnector, ConnectorStage: stage, ContentSHA256: input.ContentSHA256, ContentLength: input.ContentLength, IdempotencyKeySHA256: input.IdempotencyKeySHA256, SendStatus: status, Result: status, 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_stage": stage, "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, }, "side_effects": sideEffects, "audit": map[string]any{ "tool": ToolNameSendMessage, "source": sendMessageConnector, "execution_mode": input.ExecutionMode, "connector": sendMessageConnector, "connector_stage": stage, "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 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 }