package tools import ( "context" "fmt" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" ) const ToolNameReceiveFiles = "isphere_receive_files" type ReceiveFileDownloadQuery struct { FileRef string OutputDir string Preview bool } type ReceiveFileDownloadResolution struct { Found bool Source string MatchedScore int BlockedReasonCode string BlockedReasonMessage string } type ReceiveFileDownloadResolver interface { ResolveReceiveFileDownload(context.Context, ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) } type receiveFileDownloadResolverFunc func(context.Context, ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) func (f receiveFileDownloadResolverFunc) ResolveReceiveFileDownload(ctx context.Context, query ReceiveFileDownloadQuery) (ReceiveFileDownloadResolution, error) { return f(ctx, query) } type ReceiveFilesArgs struct { ConversationID string `json:"conversation_id,omitempty" jsonschema:"conversation id filter for file listing"` MessageID string `json:"message_id,omitempty" jsonschema:"message id filter for file listing"` FileID string `json:"file_id,omitempty" jsonschema:"file id filter for file listing"` FileRef string `json:"file_ref,omitempty" jsonschema:"download file reference; defaults to file_id when omitted"` NameContains string `json:"name_contains,omitempty" jsonschema:"case-insensitive filename substring filter"` Mode string `json:"mode,omitempty" jsonschema:"file receive mode; C14 supports list only"` OutputDir string `json:"output_dir,omitempty" jsonschema:"download output directory; not accepted in current list-only mode"` Cursor string `json:"cursor,omitempty" jsonschema:"pagination cursor; currently only empty cursor is supported"` SourcePreference string `json:"source_preference,omitempty" jsonschema:"source selector; currently supports auto or local_readonly only"` Preview bool `json:"preview,omitempty" jsonschema:"accepted for contract compatibility in list mode"` Limit int `json:"limit,omitempty" jsonschema:"maximum number of files to return; zero or negative returns all matches"` } func RegisterISphereFileTools(server *mcp.Server, source ReceiveMessagesSource) { RegisterISphereFileToolsWithDownloadResolver(server, source, nil) } func RegisterISphereFileToolsWithDownloadResolver(server *mcp.Server, source ReceiveMessagesSource, downloadResolver ReceiveFileDownloadResolver) { if source == nil { source = isphere.EncryptedPacketLogSource{} } mcp.AddTool[ReceiveFilesArgs, map[string]any](server, &mcp.Tool{ Name: ToolNameReceiveFiles, Description: "List iSphere file metadata candidates from the configured read-only message source; download preview returns structured blocked/planned status until cache mapping is validated.", }, func(ctx context.Context, _ *mcp.CallToolRequest, input ReceiveFilesArgs) (*mcp.CallToolResult, map[string]any, error) { started := time.Now().UTC() mode := strings.ToLower(strings.TrimSpace(input.Mode)) if mode == "" { mode = "list" } if mode != "list" && mode != "download" { return nil, nil, fmt.Errorf("isphere_receive_files mode %q is not available; supported modes are list and download preview", input.Mode) } if err := validateLocalReadonlySourceAndCursor(ToolNameReceiveFiles, input.SourcePreference, input.Cursor); err != nil { return nil, nil, err } if mode == "download" { return nil, receiveFileDownloadPreviewResult(ctx, input, downloadResolver, started), nil } if strings.TrimSpace(input.OutputDir) != "" { return nil, nil, fmt.Errorf("isphere_receive_files output_dir is only available for download mode") } messages, err := source.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: 0}) if err != nil { return nil, nil, err } files := isphere.ListFilesFromMessages(messages.Messages, isphere.ListFilesQuery{ ConversationID: input.ConversationID, MessageID: input.MessageID, FileID: input.FileID, NameContains: input.NameContains, Limit: input.Limit, }) return nil, receiveFilesResultToMap(files, started, time.Now().UTC()), nil }) } func receiveFileDownloadPreviewResult(ctx context.Context, input ReceiveFilesArgs, resolver ReceiveFileDownloadResolver, started time.Time) map[string]any { fileRef := strings.TrimSpace(input.FileRef) if fileRef == "" { fileRef = strings.TrimSpace(input.FileID) } if fileRef == "" { return receiveFileDownloadBlockedResult(fileRef, "file_ref_required", "download preview requires file_ref or file_id", started, time.Now().UTC()) } if !input.Preview { return receiveFileDownloadBlockedResult(fileRef, "file_download_production_disabled", "real file download is blocked; pass preview=true for a no-copy plan", started, time.Now().UTC()) } if resolver == nil { return receiveFileDownloadBlockedResult(fileRef, "file_cache_mapping_missing", "no accepted receive-file cache mapping is configured", started, time.Now().UTC()) } resolution, err := resolver.ResolveReceiveFileDownload(ctx, ReceiveFileDownloadQuery{ FileRef: fileRef, OutputDir: strings.TrimSpace(input.OutputDir), Preview: true, }) if err != nil { return receiveFileDownloadBlockedResult(fileRef, "file_cache_mapping_error", err.Error(), started, time.Now().UTC()) } if !resolution.Found { code := strings.TrimSpace(resolution.BlockedReasonCode) if code == "" { code = "file_cache_mapping_missing" } message := strings.TrimSpace(resolution.BlockedReasonMessage) if message == "" { message = "no accepted receive-file cache mapping matched the requested file_ref" } return receiveFileDownloadBlockedResult(fileRef, code, message, started, time.Now().UTC()) } return map[string]any{ "ok": true, "mode": "download", "download_status": "planned", "file_ref": fileRef, "output_dir_requested": strings.TrimSpace(input.OutputDir) != "", "mapping_source": resolution.Source, "matched_score": resolution.MatchedScore, "preview": true, "file_contents_read": false, "file_copied": false, "real_download_attempted": false, "raw_paths_returned": false, "audit": receiveFileDownloadAudit("planned", started, time.Now().UTC()), } } func receiveFileDownloadBlockedResult(fileRef string, reasonCode string, reasonMessage string, started time.Time, finished time.Time) map[string]any { return map[string]any{ "ok": false, "mode": "download", "download_status": "blocked", "blocked_reason_code": reasonCode, "blocked_reason_message": reasonMessage, "file_ref": fileRef, "preview": true, "file_contents_read": false, "file_copied": false, "real_download_attempted": false, "raw_paths_returned": false, "audit": receiveFileDownloadAudit("blocked", started, finished), } } func receiveFileDownloadAudit(result string, started time.Time, finished time.Time) map[string]any { return map[string]any{ "tool": ToolNameReceiveFiles, "source": "local_readonly", "execution_mode": "download_preview", "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": result, } } func receiveFilesResultToMap(result isphere.ListFilesResult, started time.Time, finished time.Time) map[string]any { files := make([]map[string]any, 0, len(result.Files)) for _, file := range result.Files { files = append(files, map[string]any{ "file_id": file.FileID, "message_id": nullableString(file.MessageID), "conversation_id": nullableString(file.ConversationID), "file_name": file.FileName, "size_bytes": file.SizeBytes, "mime_type": file.MimeType, "created_at": nullableString(file.CreatedAt), "download_ref": nullableString(file.DownloadRef), "saved_path": nullableString(file.SavedPath), "sha256": nullableString(file.SHA256), "source": file.Source, "raw_ref": file.RawRef, }) } return map[string]any{ "ok": true, "mode": "list", "files": files, "next_cursor": nil, "audit": map[string]any{ "tool": ToolNameReceiveFiles, "source": "local_readonly", "execution_mode": "read", "started_at": started.Format(time.RFC3339Nano), "finished_at": finished.Format(time.RFC3339Nano), "result": "ok", }, } } func nullableString(value string) any { if strings.TrimSpace(value) == "" { return nil } return value }