package tools import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" ) const ( ToolNameSendFile = "isphere_send_file" EnvSendFileAllowedDir = "ISPHERE_SEND_FILE_ALLOWED_DIR" sendFileConnector = "implatform-file-transfer" sendFileConnectorMode = "preview-only" sendFilePreviewStage = "preview" sendFileBlockedStage = "blocked" sendFileProductionMode = "production" ) type SendFileArgs struct { TargetType string `json:"target_type" jsonschema:"target type; direct/contact or group/groupchat"` TargetID string `json:"target_id" jsonschema:"contact or group id returned by iSphere search tools"` FilePath string `json:"file_path" jsonschema:"local file path under ISPHERE_SEND_FILE_ALLOWED_DIR to hash and preview"` FileSHA256 string `json:"file_sha256,omitempty" jsonschema:"optional lowercase SHA256 hex of file contents; checked when supplied"` IdempotencyKey string `json:"idempotency_key" jsonschema:"required idempotency key; returned only as SHA256"` ExecutionMode string `json:"execution_mode,omitempty" jsonschema:"preview/dry_run or production; production is blocked until file-send evidence passes"` Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility; current implementation is preview-only"` } type normalizedSendFileArgs struct { TargetType string TargetID string TargetRef string FileName string FileSHA256 string FileSizeBytes int64 IdempotencyKeySHA256 string ExecutionMode string } func RegisterISphereSendFileTool(server *mcp.Server) { mcp.AddTool[SendFileArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameSendFile, Description: "Preview iSphere file sends with target/file hash/idempotency metadata; production file upload is blocked until sandbox evidence passes.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input SendFileArgs) (*mcp.CallToolResult, map[string]any, error) { _ = ctx started := time.Now().UTC() normalized, blockedReason, err := normalizeSendFileArgs(input) if err != nil { return nil, nil, err } if blockedReason != "" { return nil, sendFileResponse(normalized, started, time.Now().UTC(), false, blockedReason), nil } if normalized.ExecutionMode == sendFileProductionMode { return nil, sendFileResponse(normalized, started, time.Now().UTC(), false, "production file upload is blocked until send-file sandbox evidence passes"), nil } return nil, sendFileResponse(normalized, started, time.Now().UTC(), true, ""), nil }) } func normalizeSendFileArgs(input SendFileArgs) (normalizedSendFileArgs, string, error) { targetType, targetRefPrefix, err := normalizeSendFileTargetType(input.TargetType) if err != nil { return normalizedSendFileArgs{}, "", err } targetID := strings.TrimSpace(input.TargetID) if targetID == "" { return normalizedSendFileArgs{}, "", fmt.Errorf("%s target_id is required", ToolNameSendFile) } idempotencyKey := strings.TrimSpace(input.IdempotencyKey) if idempotencyKey == "" { return normalizedSendFileArgs{}, "", fmt.Errorf("%s idempotency_key is required", ToolNameSendFile) } mode, err := normalizeSendFileExecutionMode(input.ExecutionMode) if err != nil { return normalizedSendFileArgs{}, "", err } normalized := normalizedSendFileArgs{ TargetType: targetType, TargetID: targetID, TargetRef: targetRefPrefix + ":" + targetID, FileName: filepath.Base(strings.TrimSpace(input.FilePath)), IdempotencyKeySHA256: sha256Hex(idempotencyKey), ExecutionMode: mode, } fileSHA256, fileSize, blockedReason, err := inspectSendFilePath(input.FilePath, input.FileSHA256) if err != nil { return normalizedSendFileArgs{}, "", err } normalized.FileSHA256 = fileSHA256 normalized.FileSizeBytes = fileSize return normalized, blockedReason, nil } func normalizeSendFileTargetType(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", ToolNameSendFile, value) } } func normalizeSendFileExecutionMode(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "preview", "dry_run", "dry-run": return "preview", nil case sendFileProductionMode: return sendFileProductionMode, nil default: return "", fmt.Errorf("%s execution_mode %q is not available; use preview or production", ToolNameSendFile, value) } } func inspectSendFilePath(filePath string, providedSHA256 string) (string, int64, string, error) { trimmedPath := strings.TrimSpace(filePath) if trimmedPath == "" { return "", 0, "", fmt.Errorf("%s file_path is required", ToolNameSendFile) } allowedDir := strings.TrimSpace(os.Getenv(EnvSendFileAllowedDir)) if allowedDir == "" { return "", 0, fmt.Sprintf("%s is required before previewing local file paths", EnvSendFileAllowedDir), nil } allowedAbs, err := filepath.Abs(allowedDir) if err != nil { return "", 0, "", fmt.Errorf("%s allowed dir: %w", ToolNameSendFile, err) } fileAbs, err := filepath.Abs(trimmedPath) if err != nil { return "", 0, "", fmt.Errorf("%s file_path: %w", ToolNameSendFile, err) } if !sendFilePathInsideDir(fileAbs, allowedAbs) { return "", 0, fmt.Sprintf("file_path must stay inside %s", EnvSendFileAllowedDir), nil } info, err := os.Stat(fileAbs) if err != nil { if os.IsNotExist(err) { return "", 0, "file_path does not exist under allowed directory", nil } return "", 0, "", fmt.Errorf("%s stat file_path: %w", ToolNameSendFile, err) } if info.IsDir() { return "", 0, "file_path points to a directory; choose one file", nil } fileHash, err := sha256FileHex(fileAbs) if err != nil { return "", 0, "", fmt.Errorf("%s hash file_path: %w", ToolNameSendFile, err) } normalizedProvided := strings.ToLower(strings.TrimSpace(providedSHA256)) if normalizedProvided != "" { if len(normalizedProvided) != 64 || !isLowerHex(normalizedProvided) { return "", 0, "", fmt.Errorf("%s file_sha256 must be a lowercase SHA256 hex value", ToolNameSendFile) } if normalizedProvided != fileHash { return "", 0, "", fmt.Errorf("%s file_sha256 does not match file contents", ToolNameSendFile) } } return fileHash, info.Size(), "", nil } func sendFilePathInsideDir(fileAbs string, allowedAbs string) bool { rel, err := filepath.Rel(filepath.Clean(allowedAbs), filepath.Clean(fileAbs)) if err != nil { return false } if rel == "." { return true } return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel) } func sha256FileHex(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } func sendFileResponse(input normalizedSendFileArgs, started time.Time, finished time.Time, planned bool, blockedReason string) map[string]any { sideEffects := sendMessageNoSideEffects() status := "planned" stage := sendFilePreviewStage ok := true blocked := any(nil) if !planned { status = "blocked" stage = sendFileBlockedStage ok = false blocked = blockedReason } auditRef := "send-file:" + shortHash(input.FileSHA256) + ":" + shortHash(input.IdempotencyKeySHA256) return map[string]any{ "ok": ok, "send_status": status, "blocked_reason": blocked, "execution_mode": input.ExecutionMode, "connector": sendFileConnector, "connector_mode": sendFileConnectorMode, "connector_stage": stage, "production_send_enabled": false, "real_send_attempted": false, "target_ref": input.TargetRef, "file_sha256": input.FileSHA256, "file_size_bytes": input.FileSizeBytes, "target": map[string]any{ "target_type": input.TargetType, "target_id": input.TargetID, "target_ref": input.TargetRef, }, "file": map[string]any{ "file_name": input.FileName, "file_sha256": input.FileSHA256, "file_size_bytes": input.FileSizeBytes, "allowed_dir_enforced": true, "file_content_returned": false, }, "idempotency": map[string]any{ "idempotency_key_sha256": input.IdempotencyKeySHA256, }, "side_effect_flags": sideEffects, "side_effects": sideEffects, "audit": map[string]any{ "tool": ToolNameSendFile, "source": sendFileConnector, "execution_mode": input.ExecutionMode, "connector": sendFileConnector, "connector_mode": sendFileConnectorMode, "connector_stage": stage, "file_sha256": input.FileSHA256, "file_size_bytes": input.FileSizeBytes, "target_ref": input.TargetRef, "audit_ref": auditRef, "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": status, "real_send_attempted": false, }, } }