Commit 600efeff authored by shrabbit's avatar shrabbit
Browse files

feat(NetworkTools): 新增 HttpDownload 下载工具并抽取 headers 解析逻辑

新增 HttpDownload 工具:从 URL 下载文件到本地路径,支持自定义 headers 和超时,返回保存路径、文件大小、状态码、耗时及最终 URI 等完整信息。
将 HttpGet 中 headers JSON 解析逻辑抽取为私有方法 ParseHeaders 复用,统一错误处理并消除重复代码。
parent 83b8e9b9
Loading
Loading
Loading
Loading
+161 −25
Original line number Diff line number Diff line
@@ -23,31 +23,9 @@ public class NetworkTools(IRestClient client)
        }

        Dictionary<string, string> headerList = [];
        try
        {
            var json = JsonNode.Parse(headers);
            
            if (json is null)
                return new HttpResponse($"Parse JSON headers error: JSON is null.");

            if (json.GetValueKind() != JsonValueKind.Object)
                return new HttpResponse($"Parse JSON headers error: Root JSON type is not Object.");

            foreach (var kv in json.AsObject())
            {
                if (kv.Value is null)
                    return new HttpResponse($"Parse JSON headers error: JSON key {kv.Key} is null.");

                if (kv.Value.GetValueKind() != JsonValueKind.String)
                    return new HttpResponse($"Parse JSON headers error: JSON key {kv.Key} type is not String.");
                
                headerList[kv.Key] = kv.Value.ToString();
            }
        }
        catch (JsonException ex)
        {
            return new HttpResponse($"Parse JSON headers error: {ex.Message}");
        }
        var headerError = ParseHeaders(headers, headerList);
        if (headerError is not null)
            return new HttpResponse(headerError);

        var request = new RestRequest(url);
        request.AddHeaders(headerList);
@@ -90,6 +68,121 @@ public class NetworkTools(IRestClient client)
            };
        }
    }

    [McpServerTool, Description("Download a file from the specified URL and save it to the local path. Returns save path, file size, status code, headers, elapsed time, final URI after redirects, and error info. Use this for downloading files, binaries, or any remote resource to disk.")]
    public async Task<HttpDownloadResult> HttpDownload(
        [Description("Target download URL (must include scheme, e.g. https://example.com/file.zip)")] string url,
        [Description("Local path where the downloaded file will be saved, e.g. C:\\downloads\\file.zip")] string savePath,
        [Description("Request headers as a JSON object string, e.g. {\"Authorization\":\"Bearer xxx\"}")] string headers = "{\"Content-Type\":\"application/json\"}",
        [Description("Request timeout in milliseconds (default 30000 = 30s)")] int timeout = 30000)
    {
        if (string.IsNullOrEmpty(url))
        {
            return new HttpDownloadResult("URL is empty");
        }

        if (string.IsNullOrEmpty(savePath))
        {
            return new HttpDownloadResult("Save path is empty");
        }

        Dictionary<string, string> headerList = [];
        var headerError = ParseHeaders(headers, headerList);
        if (headerError is not null)
        {
            return new HttpDownloadResult(headerError);
        }

        var request = new RestRequest(url);
        request.AddHeaders(headerList);

        var ct = new CancellationTokenSource(timeout);
        var sw = Stopwatch.StartNew();
        try
        {
            var response = await client.ExecuteAsync(request, ct.Token);
            sw.Stop();

            if (!response.IsSuccessful)
            {
                return new HttpDownloadResult(response.ErrorMessage ?? response.ErrorException?.Message ?? "Unknown Error.")
                {
                    StatusCode = (int)response.StatusCode,
                    ElapsedMs = sw.ElapsedMilliseconds,
                    FinalUri = response.ResponseUri?.ToString() ?? url
                };
            }

            var bytes = response.RawBytes ?? [];
            var directory = Path.GetDirectoryName(savePath);
            if (!string.IsNullOrEmpty(directory))
            {
                Directory.CreateDirectory(directory);
            }

            await File.WriteAllBytesAsync(savePath, bytes, ct.Token);

            return new HttpDownloadResult
            {
                IsSuccess = true,
                StatusCode = (int)response.StatusCode,
                SavePath = savePath,
                FileSize = new ByteSize(bytes.LongLength).ToString(),
                FileSizeRaw = bytes.LongLength,
                ContentType = response.ContentType,
                Headers = response.Headers?.ToDictionary(h => h.Name, h => h.Value.ToString()) ?? [],
                ElapsedMs = sw.ElapsedMilliseconds,
                FinalUri = response.ResponseUri?.ToString() ?? url
            };
        }
        catch (OperationCanceledException)
        {
            sw.Stop();
            return new HttpDownloadResult("Request timeout")
            {
                ElapsedMs = sw.ElapsedMilliseconds
            };
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
        {
            sw.Stop();
            return new HttpDownloadResult($"Failed to save file: {ex.Message}")
            {
                ElapsedMs = sw.ElapsedMilliseconds
            };
        }
    }

    private static string? ParseHeaders(string headers, Dictionary<string, string> headerList)
    {
        try
        {
            var json = JsonNode.Parse(headers);

            if (json is null)
                return "Parse JSON headers error: JSON is null.";

            if (json.GetValueKind() != JsonValueKind.Object)
                return "Parse JSON headers error: Root JSON type is not Object.";

            foreach (var kv in json.AsObject())
            {
                if (kv.Value is null)
                    return $"Parse JSON headers error: JSON key {kv.Key} is null.";

                if (kv.Value.GetValueKind() != JsonValueKind.String)
                    return $"Parse JSON headers error: JSON key {kv.Key} type is not String.";

                headerList[kv.Key] = kv.Value.ToString();
            }

            return null;
        }
        catch (JsonException ex)
        {
            return $"Parse JSON headers error: {ex.Message}";
        }
    }
}

public class HttpResponse(string errorMessage = "No Errors.")
@@ -134,3 +227,46 @@ public class HttpResponse(string errorMessage = "No Errors.")
    [UsedImplicitly]
    public string? FinalUri { get; set; }
}

public class HttpDownloadResult(string errorMessage = "No Errors.")
{
    [Description("Error message. \"No Errors.\" means success; otherwise contains failure reason (e.g. timeout, DNS failure, parse error, file save error).")]
    [UsedImplicitly]
    public string ErrorMessage { get; set; } = errorMessage;

    [Description("HTTP status code. -1 means no response received (e.g. timeout, DNS failure, connection refused).")]
    [UsedImplicitly]
    public int StatusCode { get; set; } = -1;

    [Description("Whether the download succeeded and the file was saved to SavePath.")]
    [UsedImplicitly]
    public bool IsSuccess { get; set; }

    [Description("Local path where the downloaded file was saved. Null when the download failed before saving.")]
    [UsedImplicitly]
    public string? SavePath { get; set; }

    [Description("Downloaded file size as a human-readable string (e.g. 1.20MB). \"N/A\" when the file was not saved.")]
    [UsedImplicitly]
    public string FileSize { get; set; } = "N/A";

    [Description("Downloaded file size in raw bytes. -1 when the file was not saved.")]
    [UsedImplicitly]
    public long FileSizeRaw { get; set; } = -1;

    [Description("Response Content-Type header value (e.g. application/octet-stream). Null if not provided by server.")]
    [UsedImplicitly]
    public string? ContentType { get; set; }

    [Description("Response headers as a dictionary (header name → value).")]
    [UsedImplicitly]
    public Dictionary<string, string> Headers { get; set; } = [];

    [Description("Elapsed time of the entire download in milliseconds (including DNS, connect, transfer, and file save).")]
    [UsedImplicitly]
    public long ElapsedMs { get; set; }

    [Description("Final URI after any redirects. If no redirect occurred, equals the original request URL.")]
    [UsedImplicitly]
    public string? FinalUri { get; set; }
}
 No newline at end of file