From cc8d3804aa49823d384a8d61e1cc61f5dfd963cc Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:44:53 -0700 Subject: [PATCH 1/6] Refactor Functions toolset: CDN manifest, rate limiting, and live tests - Fetch templates manifest from Azure CDN with GitHub fallback - Add runtime version configuration to language metadata - Add FunctionsCacheDurations for 12-hour template caching - Add rate limit handling for GitHub template downloads - Remove deprecated GitHubContentEntry model - Add TemplateManifest model for CDN manifest structure - Refactor FunctionsService and LanguageMetadataProvider for CDN support - Update unit tests for new HTTP-based manifest fetching - Add live tests for language list and template get commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Models/GitHubContentEntry.cs | 31 -- .../src/Models/TemplateManifest.cs | 7 + .../src/Services/FunctionsCacheDurations.cs | 15 + .../src/Services/FunctionsService.cs | 368 ++++++++++-------- .../src/Services/ILanguageMetadataProvider.cs | 13 +- .../src/Services/LanguageMetadataProvider.cs | 253 +++++++----- .../src/Services/ManifestService.cs | 7 +- ...Azure.Mcp.Tools.Functions.LiveTests.csproj | 15 + .../BaseFunctionsCommandLiveTests.cs | 23 ++ .../Language/LanguageListCommandLiveTests.cs | 188 +++++++++ .../Template/TemplateGetCommandLiveTests.cs | 274 +++++++++++++ .../assets.json | 6 + .../Language/LanguageListCommandTests.cs | 21 +- .../Services/FunctionsServiceHttpTests.cs | 197 +++++++--- .../Services/FunctionsServiceTests.cs | 58 --- 15 files changed, 1058 insertions(+), 418 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.Functions/src/Models/GitHubContentEntry.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsCacheDurations.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Azure.Mcp.Tools.Functions.LiveTests.csproj create mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubContentEntry.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubContentEntry.cs deleted file mode 100644 index f24bea2517..0000000000 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubContentEntry.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Serialization; - -namespace Azure.Mcp.Tools.Functions.Models; - -/// -/// Represents a single entry from the GitHub Contents API response. -/// Used to discover files in a template directory. -/// -public sealed class GitHubContentEntry -{ - [JsonPropertyName("name")] - public required string Name { get; init; } - - [JsonPropertyName("path")] - public required string Path { get; init; } - - [JsonPropertyName("type")] - public required string Type { get; init; } - - [JsonPropertyName("download_url")] - public string? DownloadUrl { get; init; } - - [JsonPropertyName("size")] - public long Size { get; init; } - - [JsonPropertyName("url")] - public string? Url { get; init; } -} diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs index 12a173d26f..8b33ceb083 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs @@ -25,4 +25,11 @@ public sealed class TemplateManifest [JsonPropertyName("templates")] public IReadOnlyList Templates { get; init; } = []; + + /// + /// Runtime version information for each supported language. + /// Keys are language names (e.g., "Python", "JavaScript", "TypeScript", "Java", "CSharp", "PowerShell"). + /// + [JsonPropertyName("runtimeVersions")] + public IReadOnlyDictionary? RuntimeVersions { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsCacheDurations.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsCacheDurations.cs new file mode 100644 index 0000000000..68629521fe --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsCacheDurations.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Functions.Services; + +/// +/// Cache duration constants for Azure Functions template services. +/// +internal static class FunctionsCacheDurations +{ + /// + /// 12-hour cache for manifest, tree, and template files. + /// + public static readonly TimeSpan TemplateCacheDuration = TimeSpan.FromHours(12); +} diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs index 04f2fe4cdb..3238f2585b 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs @@ -13,18 +13,19 @@ namespace Azure.Mcp.Tools.Functions.Services; /// /// Service for Azure Functions template operations. -/// Fetches template data from the CDN manifest and GitHub repository. -/// Language metadata (Tool 1) uses small static data. -/// Project templates (Tool 2+) fetch live data from CDN + GitHub. /// public sealed class FunctionsService( IHttpClientFactory httpClientFactory, ILanguageMetadataProvider languageMetadata, IManifestService manifestService, + ICacheService cacheService, ILogger logger) : IFunctionsService { private readonly ILanguageMetadataProvider _languageMetadata = languageMetadata ?? throw new ArgumentNullException(nameof(languageMetadata)); private readonly IManifestService _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); + private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + + private const string CacheGroup = "functions-templates"; private const string DefaultBranch = "main"; private const long MaxFileSizeBytes = 1_048_576; // 1 MB @@ -46,11 +47,15 @@ public sealed class FunctionsService( - C#: Place files in the project root alongside the .csproj """; - public Task GetLanguageListAsync(CancellationToken cancellationToken = default) + public async Task GetLanguageListAsync(CancellationToken cancellationToken = default) { + // Fetch manifest to get runtime versions + var manifest = await _manifestService.FetchManifestAsync(cancellationToken); + var runtimeVersions = manifest.RuntimeVersions; + var languages = new List(); - foreach (var kvp in _languageMetadata.GetAllLanguages()) + foreach (var kvp in _languageMetadata.GetAllLanguages(runtimeVersions)) { languages.Add(new LanguageDetails { @@ -67,10 +72,10 @@ public Task GetLanguageListAsync(CancellationToken cancellat Languages = languages }; - return Task.FromResult(result); + return result; } - public Task GetProjectTemplateAsync( + public async Task GetProjectTemplateAsync( string language, CancellationToken cancellationToken = default) { @@ -82,9 +87,9 @@ public Task GetProjectTemplateAsync( $"Invalid language: \"{language}\". Valid languages are: {string.Join(", ", _languageMetadata.SupportedLanguages)}."); } - // Return static metadata only - no HTTP calls needed - // Agents can create the actual files based on this information - var languageInfo = _languageMetadata.GetLanguageInfo(normalizedLanguage)!; + // Fetch manifest to get runtime versions + var manifest = await _manifestService.FetchManifestAsync(cancellationToken); + var languageInfo = _languageMetadata.GetLanguageInfo(normalizedLanguage, manifest.RuntimeVersions)!; var result = new ProjectTemplateResult { @@ -93,7 +98,7 @@ public Task GetProjectTemplateAsync( ProjectStructure = languageInfo.ProjectStructure }; - return Task.FromResult(result); + return result; } public async Task GetTemplateListAsync( @@ -165,13 +170,13 @@ public async Task GetFunctionTemplateAsync( $"Invalid language: \"{language}\". Valid languages are: {string.Join(", ", _languageMetadata.SupportedLanguages)}."); } + var manifest = await _manifestService.FetchManifestAsync(cancellationToken); + if (runtimeVersion is not null) { - _languageMetadata.ValidateRuntimeVersion(normalizedLanguage, runtimeVersion); + _languageMetadata.ValidateRuntimeVersion(normalizedLanguage, runtimeVersion, manifest.RuntimeVersions); } - var manifest = await _manifestService.FetchManifestAsync(cancellationToken); - var entry = manifest.Templates .Where(t => t.Language.Equals(normalizedLanguage, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(t.FolderPath) @@ -240,8 +245,7 @@ internal static string ConvertToRawGitHubUrl(string repositoryUrl, string folder } /// - /// Fetches all files from a template directory. Uses GitHub's zipball API for root/large folders, - /// or the Contents API for specific subdirectories. + /// Fetches all files from a template directory. Results are cached. /// internal async Task> FetchTemplateFilesAsync( TemplateManifestEntry template, @@ -251,21 +255,37 @@ internal async Task> FetchTemplateFilesAsync( { var normalizedPath = template.FolderPath.Trim().TrimStart('/'); var isRootOrLarge = string.IsNullOrEmpty(normalizedPath) || normalizedPath == "." || normalizedPath == ".."; + var cacheKey = $"{template.RepositoryUrl}:{normalizedPath}"; - var langInfo = _languageMetadata.GetLanguageInfo(language); - var hasTemplateParams = langInfo?.TemplateParameters is not null; - var shouldReplace = runtimeVersion is not null && hasTemplateParams; + var cachedFiles = await _cacheService.GetAsync>(CacheGroup, cacheKey, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); + IReadOnlyList files; + + if (cachedFiles is not null && cachedFiles.Count > 0) + { + logger.LogDebug("Using cached template files for {Language}/{Path}", language, normalizedPath); + files = cachedFiles; + } + else + { + logger.LogDebug("Fetching template files from GitHub for {Language}/{Path}", language, normalizedPath); + files = isRootOrLarge + ? await FetchTemplateFilesViaArchiveAsync(template.RepositoryUrl, normalizedPath, cancellationToken) + : await FetchTemplateFilesViaTreeApiAsync(template.RepositoryUrl, template.FolderPath, cancellationToken); - IReadOnlyList files = isRootOrLarge - ? await FetchTemplateFilesViaArchiveAsync(template.RepositoryUrl, normalizedPath, cancellationToken) - : await FetchTemplateFilesViaContentsApiAsync(template.RepositoryUrl, template.FolderPath, cancellationToken); + if (files.Count > 0) + { + await _cacheService.SetAsync(CacheGroup, cacheKey, files.ToList(), FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); + } + } + + var langInfo = _languageMetadata.GetLanguageInfo(language); + var shouldReplace = runtimeVersion is not null && langInfo?.TemplateParameters is not null; if (!shouldReplace) { return files; } - // Apply runtime version replacements var result = new List(files.Count); foreach (var file in files) { @@ -277,50 +297,67 @@ internal async Task> FetchTemplateFilesAsync( } /// - /// Fetches files using the Contents API (efficient for small template folders). + /// Fetches files using GitHub's Tree API + raw URLs. Cached. /// - private async Task> FetchTemplateFilesViaContentsApiAsync( + private async Task> FetchTemplateFilesViaTreeApiAsync( string repositoryUrl, string folderPath, CancellationToken cancellationToken) { - var contentsUrl = ConstructGitHubContentsApiUrl(repositoryUrl, folderPath); - var fileEntries = await ListGitHubDirectoryAsync(contentsUrl, cancellationToken); + var repoPath = GitHubUrlValidator.ExtractGitHubRepoPath(repositoryUrl) + ?? throw new ArgumentException("Invalid repository URL format.", nameof(repositoryUrl)); - var files = new List(); - var folderPrefix = folderPath.TrimEnd('/') + "/"; + var normalizedFolder = GitHubUrlValidator.NormalizeFolderPath(folderPath) + ?? throw new ArgumentException("Folder path must specify a valid subdirectory.", nameof(folderPath)); + + var treeCacheKey = $"tree:{repoPath}:{DefaultBranch}"; + var cachedTree = await _cacheService.GetAsync(CacheGroup, treeCacheKey, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); + + GitHubTreeResponse treeResponse; + if (cachedTree is not null) + { + logger.LogDebug("Using cached tree for {Repo}", repoPath); + treeResponse = cachedTree; + } + else + { + logger.LogDebug("Fetching tree from GitHub for {Repo}", repoPath); + var treeUrl = $"https://api.github.com/repos/{repoPath}/git/trees/{DefaultBranch}?recursive=1"; + treeResponse = await FetchTreeFromGitHubAsync(treeUrl, repoPath, cancellationToken); + await _cacheService.SetAsync(CacheGroup, treeCacheKey, treeResponse, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); + } + var filePaths = FilterTreeToFolder(treeResponse, normalizedFolder); + if (filePaths.Count == 0) + { + throw new InvalidOperationException( + $"No template files found in folder '{normalizedFolder}'. The template may not exist or the repository structure has changed."); + } + + var files = new List(); using var client = httpClientFactory.CreateClient(); client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); - foreach (var entry in fileEntries) + foreach (var (fullPath, relativePath, size) in filePaths) { - if (entry.Size > MaxFileSizeBytes) + if (size > MaxFileSizeBytes) { - logger.LogWarning("Skipping file {Name} ({Size} bytes) - exceeds max size", entry.Name, entry.Size); + logger.LogWarning("Skipping file {Name} ({Size} bytes) - exceeds max size", relativePath, size); continue; } - // Validate URL points to GitHub domain (SSRF prevention) - if (entry.DownloadUrl is null || !GitHubUrlValidator.IsValidGitHubUrl(entry.DownloadUrl)) - { - logger.LogWarning("Skipping file {Name} - invalid or missing download URL", entry.Name); - continue; - } + var rawUrl = $"https://raw.githubusercontent.com/{repoPath}/{DefaultBranch}/{fullPath}"; try { - using var response = await client.GetAsync(new Uri(entry.DownloadUrl), cancellationToken); + using var response = await client.GetAsync(new Uri(rawUrl), cancellationToken); if (!response.IsSuccessStatusCode) { - logger.LogWarning( - "Failed to fetch {Name} from {Url} (HTTP {Status})", - entry.Name, entry.DownloadUrl, response.StatusCode); + logger.LogWarning("Failed to fetch {Name} from raw URL (HTTP {Status})", relativePath, response.StatusCode); continue; } - // Use size-limited reading to prevent DoS (protects against missing Content-Length header) string content; try { @@ -328,17 +365,10 @@ private async Task> FetchTemplateFilesViaCont } catch (InvalidOperationException) { - logger.LogWarning("Skipping file {Name} - size exceeds limit", entry.Name); + logger.LogWarning("Skipping file {Name} - size exceeds limit", relativePath); continue; } - // Use relative path from the template folder root - var relativePath = entry.Path; - if (relativePath.StartsWith(folderPrefix, StringComparison.OrdinalIgnoreCase)) - { - relativePath = relativePath[folderPrefix.Length..]; - } - files.Add(new ProjectTemplateFile { FileName = relativePath, @@ -347,7 +377,7 @@ private async Task> FetchTemplateFilesViaCont } catch (HttpRequestException ex) { - logger.LogWarning(ex, "Error fetching template file {Name}", entry.Name); + logger.LogWarning(ex, "Error fetching template file {Name}", relativePath); } } @@ -355,7 +385,102 @@ private async Task> FetchTemplateFilesViaCont } /// - /// Fetches files by downloading the repository as a zipball (efficient for root or large folders). + /// Fetches tree response from GitHub API. + /// + private async Task FetchTreeFromGitHubAsync(string treeUrl, string repoPath, CancellationToken cancellationToken) + { + using var client = httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); + + HttpResponseMessage response; + try + { + response = await client.GetAsync(new Uri(treeUrl), cancellationToken); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch tree from {Url}", treeUrl); + throw new InvalidOperationException( + $"Failed to fetch template files from GitHub. Check your network connection. Details: {ex.Message}", ex); + } + + using (response) + { + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden || + response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + var resetHeader = response.Headers.TryGetValues("X-RateLimit-Reset", out var values) + ? values.FirstOrDefault() : null; + var resetInfo = resetHeader != null && long.TryParse(resetHeader, out var resetTime) + ? $" Rate limit resets at {DateTimeOffset.FromUnixTimeSeconds(resetTime):HH:mm:ss UTC}." + : ""; + + throw new InvalidOperationException( + $"GitHub API rate limit exceeded.{resetInfo} Try again later."); + } + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"GitHub API returned {(int)response.StatusCode} {response.ReasonPhrase}. Unable to fetch template files."); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + GitHubTreeResponse? tree; + + try + { + tree = JsonSerializer.Deserialize(json, FunctionTemplatesManifestJsonContext.Default.GitHubTreeResponse); + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to parse tree response from {Url}", treeUrl); + throw new InvalidOperationException( + $"Failed to parse GitHub response. Details: {ex.Message}", ex); + } + + if (tree?.Tree is null || tree.Tree.Count == 0) + { + logger.LogWarning("Empty tree response from {Repo}", repoPath); + throw new InvalidOperationException( + $"GitHub returned empty file tree for '{repoPath}'. The repository may be empty or inaccessible."); + } + + return tree; + } + } + + /// + /// Filters tree items to files within the specified folder. + /// + private static IReadOnlyList<(string FullPath, string RelativePath, long Size)> FilterTreeToFolder( + GitHubTreeResponse treeResponse, + string folderPrefix) + { + var folderPrefixWithSlash = folderPrefix.TrimEnd('/') + "/"; + var results = new List<(string, string, long)>(); + + foreach (var item in treeResponse.Tree) + { + if (item.Type != "blob" || item.Path is null) + { + continue; + } + + if (!item.Path.StartsWith(folderPrefixWithSlash, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relativePath = item.Path[folderPrefixWithSlash.Length..]; + results.Add((item.Path, relativePath, item.Size)); + } + + return results; + } + + /// + /// Fetches files by downloading the repository zipball. /// internal async Task> FetchTemplateFilesViaArchiveAsync( string repositoryUrl, @@ -381,29 +506,23 @@ internal async Task> FetchTemplateFilesViaArc await using var zipStream = await response.Content.ReadAsStreamAsync(cancellationToken); using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); - // GitHub zipball has a root folder like "owner-repo-commitsha/" - // We need to strip this prefix and optionally filter by folderPath string? rootPrefix = null; foreach (var entry in archive.Entries) { - // Skip directories (entries ending with /) if (string.IsNullOrEmpty(entry.Name)) { continue; } - // Detect root prefix from first file rootPrefix ??= GetZipRootPrefix(entry.FullName); - // Get path relative to the root prefix var relativePath = entry.FullName; if (rootPrefix is not null && relativePath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) { relativePath = relativePath[rootPrefix.Length..]; } - // Filter by folder path if specified if (!string.IsNullOrEmpty(normalizedFolder) && normalizedFolder != "." && normalizedFolder != "..") { if (!relativePath.StartsWith(normalizedFolder + "/", StringComparison.OrdinalIgnoreCase) && @@ -412,33 +531,27 @@ internal async Task> FetchTemplateFilesViaArc continue; } - // Remove the folder prefix from the relative path if (relativePath.StartsWith(normalizedFolder + "/", StringComparison.OrdinalIgnoreCase)) { relativePath = relativePath[(normalizedFolder.Length + 1)..]; } } - // Security: Reject paths with traversal sequences to prevent Zip Slip attacks if (relativePath.Contains("..", StringComparison.Ordinal)) { - logger.LogWarning("Skipping file {Name} - contains path traversal sequence", entry.FullName); + logger.LogWarning("Skipping file {Name} - path traversal detected", entry.FullName); continue; } - // Skip files exceeding max size (note: entry.Length is uncompressed size) if (entry.Length > MaxFileSizeBytes) { - logger.LogWarning("Skipping file {Name} ({Size} bytes uncompressed) - exceeds max size", relativePath, entry.Length); + logger.LogWarning("Skipping file {Name} - exceeds max size", relativePath); continue; } try { using var stream = entry.Open(); - - // Read with size limit to prevent ZIP bomb attacks - // Use int for buffer size (MaxFileSizeBytes is well under int.MaxValue) var bufferSize = (int)MaxFileSizeBytes + 1; var buffer = new char[bufferSize]; using var reader = new StreamReader(stream); @@ -446,7 +559,7 @@ internal async Task> FetchTemplateFilesViaArc if (charsRead > MaxFileSizeBytes) { - logger.LogWarning("Skipping file {Name} - uncompressed content exceeds max size", relativePath); + logger.LogWarning("Skipping file {Name} - exceeds max size", relativePath); continue; } @@ -478,104 +591,35 @@ internal async Task> FetchTemplateFilesViaArc return firstSlash > 0 ? entryPath[..(firstSlash + 1)] : null; } - /// - /// Lists all files in a GitHub directory recursively using the Contents API. - /// - internal async Task> ListGitHubDirectoryAsync( - string contentsUrl, - CancellationToken cancellationToken) - { - using var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); - - try - { - return await ListGitHubDirectoryRecursiveAsync(client, contentsUrl, depth: 0, cancellationToken); - } - catch (HttpRequestException ex) - { - logger.LogError(ex, "Failed to list GitHub directory at {Url}", contentsUrl); - throw new InvalidOperationException( - $"Could not list template files from GitHub. Details: {ex.Message}", ex); - } - catch (JsonException ex) - { - logger.LogError(ex, "Failed to parse GitHub API response from {Url}", contentsUrl); - throw new InvalidOperationException( - $"Could not parse GitHub directory listing. Details: {ex.Message}", ex); - } - } - - /// - /// Constructs a GitHub Contents API URL from a repository URL and folder path. - /// - internal static string ConstructGitHubContentsApiUrl(string repositoryUrl, string folderPath) - { - var repoPath = GitHubUrlValidator.ExtractGitHubRepoPath(repositoryUrl) - ?? throw new ArgumentException("Invalid repository URL format.", nameof(repositoryUrl)); - - var normalizedPath = GitHubUrlValidator.NormalizeFolderPath(folderPath) - ?? throw new ArgumentException("Folder path must specify a valid subdirectory, not the repository root.", nameof(folderPath)); - - return $"https://api.github.com/repos/{repoPath}/contents/{normalizedPath}"; - } - /// /// Gets the template name from a manifest entry. /// Always uses entry.Id for consistency - folderPath is only used for download logic. /// internal static string ExtractTemplateName(TemplateManifestEntry entry) => entry.Id ?? string.Empty; +} - private const int MaxRecursionDepth = 10; - - private async Task> ListGitHubDirectoryRecursiveAsync( - HttpClient client, - string contentsUrl, - int depth, - CancellationToken cancellationToken) - { - if (depth > MaxRecursionDepth) - { - logger.LogWarning("Max recursion depth {MaxDepth} exceeded for URL {Url}", MaxRecursionDepth, contentsUrl); - return []; // Prevent infinite recursion from circular links or deep nesting - } - - var allFiles = new List(); - const long maxDirectoryListingSize = 1 * 1024 * 1024; // 1MB limit for directory listings - - try - { - using var response = await client.GetAsync(new Uri(contentsUrl), cancellationToken); - response.EnsureSuccessStatusCode(); - - var json = await GitHubUrlValidator.ReadSizeLimitedStringAsync(response.Content, maxDirectoryListingSize, cancellationToken); - var entries = JsonSerializer.Deserialize(json, FunctionTemplatesManifestJsonContext.Default.ListGitHubContentEntry) - ?? []; - - foreach (var entry in entries) - { - if (entry.Type == "file") - { - allFiles.Add(entry); - } - else if (entry.Type == "dir" && entry.Url is not null && GitHubUrlValidator.IsValidGitHubUrl(entry.Url)) - { - var subFiles = await ListGitHubDirectoryRecursiveAsync(client, entry.Url, depth + 1, cancellationToken); - allFiles.AddRange(subFiles); - } - } - } - catch (HttpRequestException ex) - { - logger.LogWarning(ex, "HTTP error listing GitHub directory at {Url}", contentsUrl); - } - catch (JsonException ex) - { - logger.LogWarning(ex, "JSON parsing error for GitHub directory response from {Url}", contentsUrl); - } +/// +/// GitHub Tree API response model. +/// +internal sealed class GitHubTreeResponse +{ + public string? Sha { get; set; } + public string? Url { get; set; } + public List Tree { get; set; } = []; + public bool Truncated { get; set; } +} - return allFiles; - } +/// +/// Individual item in GitHub Tree API response. +/// +internal sealed class GitHubTreeItem +{ + public string? Path { get; set; } + public string? Mode { get; set; } + public string? Type { get; set; } // "blob" for files, "tree" for directories + public string? Sha { get; set; } + public long Size { get; set; } + public string? Url { get; set; } } /// @@ -583,7 +627,9 @@ private async Task> ListGitHubDirectoryRecursi /// [JsonSerializable(typeof(TemplateManifest))] [JsonSerializable(typeof(TemplateManifestEntry))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(GitHubContentEntry))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(GitHubTreeResponse))] +[JsonSerializable(typeof(GitHubTreeItem))] +[JsonSerializable(typeof(List))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal partial class FunctionTemplatesManifestJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/ILanguageMetadataProvider.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/ILanguageMetadataProvider.cs index f739291c50..7c31cf5990 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/ILanguageMetadataProvider.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/ILanguageMetadataProvider.cs @@ -34,17 +34,19 @@ public interface ILanguageMetadataProvider bool IsValidLanguage(string language); /// - /// Gets the language info for a specific language. + /// Gets the language info for a specific language, optionally applying manifest runtime versions. /// /// The language key (case-insensitive). + /// Optional runtime versions from manifest to override defaults. /// The language info or null if not found. - LanguageInfo? GetLanguageInfo(string language); + LanguageInfo? GetLanguageInfo(string language, IReadOnlyDictionary? manifestRuntimeVersions = null); /// - /// Gets all language info entries. + /// Gets all language info entries, optionally applying manifest runtime versions. /// + /// Optional runtime versions from manifest to override defaults. /// All language info entries as key-value pairs. - IEnumerable> GetAllLanguages(); + IEnumerable> GetAllLanguages(IReadOnlyDictionary? manifestRuntimeVersions = null); /// /// Gets the set of known project-level filenames (e.g., requirements.txt, package.json). @@ -57,8 +59,9 @@ public interface ILanguageMetadataProvider /// /// The language (case-insensitive). /// The runtime version to validate. + /// Optional runtime versions from manifest to use for validation. /// Thrown if the runtime version is invalid. - void ValidateRuntimeVersion(string language, string runtimeVersion); + void ValidateRuntimeVersion(string language, string runtimeVersion, IReadOnlyDictionary? manifestRuntimeVersions = null); /// /// Replaces runtime version placeholders in template content. diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/LanguageMetadataProvider.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/LanguageMetadataProvider.cs index 9b41f24bd7..cd1559a705 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/LanguageMetadataProvider.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/LanguageMetadataProvider.cs @@ -7,7 +7,7 @@ namespace Azure.Mcp.Tools.Functions.Services; /// /// Provides language metadata for Azure Functions development. -/// This is the single source of truth for all language-related data. +/// Static language info is defined here; runtime versions come from manifest.json. /// public sealed class LanguageMetadataProvider : ILanguageMetadataProvider { @@ -28,18 +28,28 @@ public sealed class LanguageMetadataProvider : ILanguageMetadataProvider ["host.json", "local.settings.json", ".funcignore", ".gitignore"]; /// - /// Complete language information including runtime versions, setup instructions, - /// project structure, and template parameters. + /// Maps language keys to manifest runtime version keys. + /// Manifest uses PascalCase keys (e.g., "Python", "CSharp"). /// - /// - /// Update these values when new runtime versions are released or deprecated. - /// @see https://learn.microsoft.com/azure/azure-functions/functions-versions - /// @see https://learn.microsoft.com/azure/azure-functions/supported-languages - /// - private static readonly IReadOnlyDictionary s_languageInfo = - new Dictionary(StringComparer.OrdinalIgnoreCase) + private static readonly IReadOnlyDictionary s_languageToManifestKey = + new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["python"] = new LanguageInfo + ["python"] = "Python", + ["typescript"] = "TypeScript", + ["javascript"] = "JavaScript", + ["java"] = "Java", + ["csharp"] = "CSharp", + ["powershell"] = "PowerShell" + }; + + /// + /// Static language information excluding runtime versions. + /// Runtime versions are provided by manifest.json. + /// + private static readonly IReadOnlyDictionary s_languageInfo = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["python"] = new LanguageInfoStatic { Name = "Python", Runtime = "python", @@ -50,12 +60,6 @@ public sealed class LanguageMetadataProvider : ILanguageMetadataProvider RunCommand = "func start", BuildCommand = null, ProjectFiles = ["requirements.txt"], - RuntimeVersions = new RuntimeVersionInfo - { - Supported = ["3.10", "3.11", "3.12", "3.13"], - Preview = ["3.14"], - Default = "3.11" - }, InitInstructions = """ ## Python Azure Functions Project Setup @@ -87,9 +91,10 @@ func start ".gitignore # Git ignore patterns", ".funcignore # Files to exclude from deployment" ], - TemplateParameters = null + TemplateParameterName = null, + RecommendationNotes = null }, - ["typescript"] = new LanguageInfo + ["typescript"] = new LanguageInfoStatic { Name = "Node.js - TypeScript", Runtime = "node", @@ -100,12 +105,6 @@ func start RunCommand = "npm start", BuildCommand = "npm run build", ProjectFiles = ["package.json", "tsconfig.json"], - RuntimeVersions = new RuntimeVersionInfo - { - Supported = ["20", "22"], - Preview = ["24"], - Default = "22" - }, InitInstructions = """ ## TypeScript Azure Functions Project Setup @@ -138,19 +137,10 @@ npm run watch ".gitignore # Git ignore patterns", ".funcignore # Files to exclude from deployment" ], - TemplateParameters = - [ - new TemplateParameter - { - Name = "nodeVersion", - Description = "Node.js version for @types/node. Detect from user environment or ask preference.", - DefaultValue = "20", - ValidValues = ["20", "22", "24"] - } - ], + TemplateParameterName = "nodeVersion", RecommendationNotes = "Recommended for Node.js runtime for type safety and better tooling support." }, - ["javascript"] = new LanguageInfo + ["javascript"] = new LanguageInfoStatic { Name = "Node.js - JavaScript", Runtime = "node", @@ -161,12 +151,6 @@ npm run watch RunCommand = "npm start", BuildCommand = null, ProjectFiles = ["package.json"], - RuntimeVersions = new RuntimeVersionInfo - { - Supported = ["20", "22"], - Preview = ["24"], - Default = "22" - }, InitInstructions = """ ## JavaScript Azure Functions Project Setup @@ -197,18 +181,10 @@ func start ".gitignore # Git ignore patterns", ".funcignore # Files to exclude from deployment" ], - TemplateParameters = - [ - new TemplateParameter - { - Name = "nodeVersion", - Description = "Node.js version. Detect from user environment or ask preference.", - DefaultValue = "20", - ValidValues = ["20", "22", "24"] - } - ] + TemplateParameterName = "nodeVersion", + RecommendationNotes = null }, - ["java"] = new LanguageInfo + ["java"] = new LanguageInfoStatic { Name = "Java", Runtime = "java", @@ -219,12 +195,6 @@ func start RunCommand = "mvn clean package && mvn azure-functions:run", BuildCommand = "mvn clean package", ProjectFiles = ["pom.xml"], - RuntimeVersions = new RuntimeVersionInfo - { - Supported = ["8", "11", "17", "21"], - Preview = ["25"], - Default = "21" - }, InitInstructions = """ ## Java Azure Functions Project Setup @@ -252,18 +222,10 @@ 2. Create your functions in `src/main/java/com/function/` directory ".gitignore # Git ignore patterns", ".funcignore # Files to exclude from deployment" ], - TemplateParameters = - [ - new TemplateParameter - { - Name = "javaVersion", - Description = "Java version for compilation and runtime. Detect from user environment or ask preference.", - DefaultValue = "21", - ValidValues = ["8", "11", "17", "21", "25"] - } - ] + TemplateParameterName = "javaVersion", + RecommendationNotes = null }, - ["csharp"] = new LanguageInfo + ["csharp"] = new LanguageInfoStatic { Name = "dotnet-isolated - C#", Runtime = "dotnet", @@ -274,13 +236,6 @@ 2. Create your functions in `src/main/java/com/function/` directory RunCommand = "func start", BuildCommand = "dotnet build", ProjectFiles = [], - RuntimeVersions = new RuntimeVersionInfo - { - Supported = ["8", "9", "10"], - Deprecated = ["6", "7"], - Default = "8", - FrameworkSupported = ["4.8.1"] - }, InitInstructions = """ ## C# Azure Functions Project Setup @@ -311,9 +266,10 @@ templates which create the .csproj file with proper dependencies. ".gitignore # Git ignore patterns", ".funcignore # Files to exclude from deployment" ], - TemplateParameters = null + TemplateParameterName = null, + RecommendationNotes = null }, - ["powershell"] = new LanguageInfo + ["powershell"] = new LanguageInfoStatic { Name = "PowerShell", Runtime = "powershell", @@ -324,12 +280,6 @@ templates which create the .csproj file with proper dependencies. RunCommand = "func start", BuildCommand = null, ProjectFiles = ["requirements.psd1", "profile.ps1"], - RuntimeVersions = new RuntimeVersionInfo - { - Supported = ["7.4"], - Deprecated = ["7.2"], - Default = "7.4" - }, InitInstructions = """ ## PowerShell Azure Functions Project Setup @@ -358,10 +308,25 @@ func start ".gitignore # Git ignore patterns", ".funcignore # Files to exclude from deployment" ], - TemplateParameters = null + TemplateParameterName = null, + RecommendationNotes = null } }; + /// + /// Fallback runtime versions used when manifest doesn't provide them. + /// + private static readonly IReadOnlyDictionary s_fallbackRuntimeVersions = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["python"] = new RuntimeVersionInfo { Supported = ["3.13"], Default = "3.13" }, + ["typescript"] = new RuntimeVersionInfo { Supported = ["20"], Default = "20" }, + ["javascript"] = new RuntimeVersionInfo { Supported = ["20"], Default = "20" }, + ["java"] = new RuntimeVersionInfo { Supported = ["21"], Default = "21" }, + ["csharp"] = new RuntimeVersionInfo { Supported = ["8"], Default = "8" }, + ["powershell"] = new RuntimeVersionInfo { Supported = ["7.4"], Default = "7.4" } + }; + /// /// Flat set of known project-level filenames used to separate project files /// from function-specific files in template get mode. @@ -380,25 +345,40 @@ public bool IsValidLanguage(string language) => s_languageInfo.ContainsKey(language); /// - public LanguageInfo? GetLanguageInfo(string language) => - s_languageInfo.TryGetValue(language, out var info) ? info : null; + public LanguageInfo? GetLanguageInfo(string language, IReadOnlyDictionary? manifestRuntimeVersions = null) + { + if (!s_languageInfo.TryGetValue(language, out var staticInfo)) + { + return null; + } + + var runtimeVersions = GetRuntimeVersions(language, manifestRuntimeVersions); + return BuildLanguageInfo(staticInfo, runtimeVersions); + } /// - public IEnumerable> GetAllLanguages() => - s_languageInfo; + public IEnumerable> GetAllLanguages(IReadOnlyDictionary? manifestRuntimeVersions = null) + { + foreach (var kvp in s_languageInfo) + { + var runtimeVersions = GetRuntimeVersions(kvp.Key, manifestRuntimeVersions); + var languageInfo = BuildLanguageInfo(kvp.Value, runtimeVersions); + yield return new KeyValuePair(kvp.Key, languageInfo); + } + } /// public IReadOnlySet KnownProjectFiles => s_knownProjectFiles.Value; /// - public void ValidateRuntimeVersion(string language, string runtimeVersion) + public void ValidateRuntimeVersion(string language, string runtimeVersion, IReadOnlyDictionary? manifestRuntimeVersions = null) { - if (!s_languageInfo.TryGetValue(language, out var languageInfo)) + if (!s_languageInfo.ContainsKey(language)) { return; } - var runtime = languageInfo.RuntimeVersions; + var runtime = GetRuntimeVersions(language, manifestRuntimeVersions); var allVersions = new List(runtime.Supported); if (runtime.Preview is not null) { @@ -441,4 +421,91 @@ public string ReplaceRuntimeVersion(string content, string language, string runt return content; } + + /// + /// Gets runtime versions for a language, preferring manifest data over fallback. + /// + private RuntimeVersionInfo GetRuntimeVersions(string language, IReadOnlyDictionary? manifestRuntimeVersions) + { + // Try manifest first using the PascalCase key mapping + if (manifestRuntimeVersions is not null && + s_languageToManifestKey.TryGetValue(language, out var manifestKey) && + manifestRuntimeVersions.TryGetValue(manifestKey, out var manifestVersions)) + { + return manifestVersions; + } + + // Fall back to hardcoded defaults + return s_fallbackRuntimeVersions.TryGetValue(language, out var fallback) + ? fallback + : new RuntimeVersionInfo { Supported = [], Default = string.Empty }; + } + + /// + /// Builds a LanguageInfo from static data and runtime versions. + /// + private static LanguageInfo BuildLanguageInfo(LanguageInfoStatic staticInfo, RuntimeVersionInfo runtimeVersions) + { + // Build template parameters with valid values from runtime versions + IReadOnlyList? templateParameters = null; + if (staticInfo.TemplateParameterName is not null) + { + var allVersions = new List(runtimeVersions.Supported); + if (runtimeVersions.Preview is not null) + { + allVersions.AddRange(runtimeVersions.Preview); + } + + templateParameters = + [ + new TemplateParameter + { + Name = staticInfo.TemplateParameterName, + Description = staticInfo.TemplateParameterName == "javaVersion" + ? "Java version for compilation and runtime. Detect from user environment or ask preference." + : "Node.js version. Detect from user environment or ask preference.", + DefaultValue = runtimeVersions.Default, + ValidValues = allVersions + } + ]; + } + + return new LanguageInfo + { + Name = staticInfo.Name, + Runtime = staticInfo.Runtime, + ProgrammingModel = staticInfo.ProgrammingModel, + Prerequisites = staticInfo.Prerequisites, + DevelopmentTools = staticInfo.DevelopmentTools, + InitCommand = staticInfo.InitCommand, + RunCommand = staticInfo.RunCommand, + BuildCommand = staticInfo.BuildCommand, + ProjectFiles = staticInfo.ProjectFiles, + RuntimeVersions = runtimeVersions, + InitInstructions = staticInfo.InitInstructions, + ProjectStructure = staticInfo.ProjectStructure, + TemplateParameters = templateParameters, + RecommendationNotes = staticInfo.RecommendationNotes + }; + } + + /// + /// Internal record for static language info without runtime versions. + /// + private sealed record LanguageInfoStatic + { + public required string Name { get; init; } + public required string Runtime { get; init; } + public required string ProgrammingModel { get; init; } + public required IReadOnlyList Prerequisites { get; init; } + public required IReadOnlyList DevelopmentTools { get; init; } + public required string InitCommand { get; init; } + public required string RunCommand { get; init; } + public string? BuildCommand { get; init; } + public required IReadOnlyList ProjectFiles { get; init; } + public required string InitInstructions { get; init; } + public required IReadOnlyList ProjectStructure { get; init; } + public string? TemplateParameterName { get; init; } + public string? RecommendationNotes { get; init; } + } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs index ccb5025c90..ef86a07cc5 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs @@ -23,7 +23,6 @@ public sealed class ManifestService( private const long MaxManifestSizeBytes = 10_485_760; // 10 MB private const string CacheGroup = "functions"; private const string ManifestCacheKey = "manifest"; - private static readonly TimeSpan s_manifestCacheDuration = TimeSpan.FromHours(12); private readonly string _manifestUrl = options.Value.ManifestUrl; private readonly string _fallbackManifestUrl = options.Value.FallbackManifestUrl; @@ -44,7 +43,7 @@ private sealed record ManifestFetchResult /// public async Task FetchManifestAsync(CancellationToken cancellationToken) { - var cached = await cacheService.GetAsync(CacheGroup, ManifestCacheKey, s_manifestCacheDuration, cancellationToken); + var cached = await cacheService.GetAsync(CacheGroup, ManifestCacheKey, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); if (cached?.Templates?.Count > 0) { return cached; @@ -54,7 +53,7 @@ public async Task FetchManifestAsync(CancellationToken cancell var primaryResult = await TryFetchManifestAsync(_manifestUrl, cancellationToken); if (primaryResult.IsSuccess) { - await cacheService.SetAsync(CacheGroup, ManifestCacheKey, primaryResult.Manifest!, s_manifestCacheDuration, cancellationToken); + await cacheService.SetAsync(CacheGroup, ManifestCacheKey, primaryResult.Manifest!, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); return primaryResult.Manifest!; } @@ -63,7 +62,7 @@ public async Task FetchManifestAsync(CancellationToken cancell var fallbackResult = await TryFetchManifestAsync(_fallbackManifestUrl, cancellationToken); if (fallbackResult.IsSuccess) { - await cacheService.SetAsync(CacheGroup, ManifestCacheKey, fallbackResult.Manifest!, s_manifestCacheDuration, cancellationToken); + await cacheService.SetAsync(CacheGroup, ManifestCacheKey, fallbackResult.Manifest!, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); return fallbackResult.Manifest!; } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Azure.Mcp.Tools.Functions.LiveTests.csproj b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Azure.Mcp.Tools.Functions.LiveTests.csproj new file mode 100644 index 0000000000..b6805cebe1 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Azure.Mcp.Tools.Functions.LiveTests.csproj @@ -0,0 +1,15 @@ + + + true + Exe + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs new file mode 100644 index 0000000000..cb41867f23 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.Functions.LiveTests; + +/// +/// Base class for Azure Functions MCP tool live tests. +/// These tests validate HTTP calls to GitHub and Azure CDN for template fetching. +/// +public abstract class BaseFunctionsCommandLiveTests( + ITestOutputHelper output, + TestProxyFixture fixture, + LiveServerFixture liveServerFixture) + : RecordedCommandTestsBase(output, fixture, liveServerFixture) +{ + // No additional sanitizers needed for Functions tests since they don't + // contain Azure resource-specific sensitive data like subscription IDs + // or IP addresses. The base class sanitizers handle standard patterns. +} diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs new file mode 100644 index 0000000000..62a0041df3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tools.Functions.Commands; +using Azure.Mcp.Tools.Functions.Models; +using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.Functions.LiveTests.Language; + +/// +/// Live tests for the LanguageListCommand which fetches supported languages +/// and runtime versions from the Azure Functions manifest. +/// +[Trait("Command", "LanguageListCommand")] +public class LanguageListCommandLiveTests( + ITestOutputHelper output, + TestProxyFixture fixture, + LiveServerFixture liveServerFixture) + : BaseFunctionsCommandLiveTests(output, fixture, liveServerFixture) +{ + private static readonly string[] ExpectedLanguages = ["python", "typescript", "javascript", "csharp", "java", "powershell"]; + + [Fact] + public async Task ExecuteAsync_ReturnsAllSupportedLanguages() + { + // Act + var result = await CallToolAsync("functions_language_list", new()); + + // Assert + Assert.NotNull(result); + var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(languageResults); + Assert.Single(languageResults); + + var languageList = languageResults[0]; + Assert.NotEmpty(languageList.FunctionsRuntimeVersion); + Assert.NotEmpty(languageList.ExtensionBundleVersion); + + // Verify all 6 expected languages are present + Assert.Equal(6, languageList.Languages.Count); + var languageNames = languageList.Languages.Select(l => l.Language).ToList(); + foreach (var expected in ExpectedLanguages) + { + Assert.Contains(expected, languageNames); + } + } + + [Theory] + [InlineData("python", "Python", "python", "v2 (Decorator-based)")] + [InlineData("typescript", "Node.js - TypeScript", "node", "v4 (Schema-based)")] + [InlineData("javascript", "Node.js - JavaScript", "node", "v4 (Schema-based)")] + [InlineData("java", "Java", "java", "Annotations-based")] + [InlineData("csharp", "dotnet-isolated - C#", "dotnet", "Isolated worker process")] + [InlineData("powershell", "PowerShell", "powershell", "Script-based")] + public async Task ExecuteAsync_ReturnsCorrectLanguageInfo( + string languageKey, + string expectedName, + string expectedRuntime, + string expectedModel) + { + // Act + var result = await CallToolAsync("functions_language_list", new()); + + // Assert + Assert.NotNull(result); + var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(languageResults); + + var languageList = languageResults[0]; + var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); + Assert.NotNull(language); + + // Verify LanguageInfo properties + Assert.Equal(expectedName, language.Info.Name); + Assert.Equal(expectedRuntime, language.Info.Runtime); + Assert.Equal(expectedModel, language.Info.ProgrammingModel); + Assert.NotEmpty(language.Info.Prerequisites); + Assert.NotEmpty(language.Info.DevelopmentTools); + Assert.NotEmpty(language.Info.InitCommand); + Assert.NotEmpty(language.Info.RunCommand); + Assert.NotEmpty(language.Info.InitInstructions); + Assert.NotEmpty(language.Info.ProjectStructure); + } + + [Theory] + [InlineData("python")] + [InlineData("typescript")] + [InlineData("javascript")] + [InlineData("java")] + [InlineData("csharp")] + [InlineData("powershell")] + public async Task ExecuteAsync_ReturnsRuntimeVersionsFromManifest(string languageKey) + { + // Act + var result = await CallToolAsync("functions_language_list", new()); + + // Assert + Assert.NotNull(result); + var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(languageResults); + + var languageList = languageResults[0]; + var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); + Assert.NotNull(language); + + // Verify RuntimeVersions from manifest - don't hardcode specific versions + Assert.NotNull(language.RuntimeVersions); + Assert.NotEmpty(language.RuntimeVersions.Supported); + Assert.NotEmpty(language.RuntimeVersions.Default); + + // Default should be one of the supported versions + Assert.Contains(language.RuntimeVersions.Default, language.RuntimeVersions.Supported); + + // Same versions should be in Info.RuntimeVersions + Assert.NotNull(language.Info.RuntimeVersions); + Assert.Equal(language.RuntimeVersions.Default, language.Info.RuntimeVersions.Default); + Assert.Equal(language.RuntimeVersions.Supported, language.Info.RuntimeVersions.Supported); + } + + [Theory] + [InlineData("typescript", "nodeVersion")] + [InlineData("javascript", "nodeVersion")] + [InlineData("java", "javaVersion")] + public async Task ExecuteAsync_ReturnsTemplateParametersWithValidValues(string languageKey, string expectedParamName) + { + // Act + var result = await CallToolAsync("functions_language_list", new()); + + // Assert + Assert.NotNull(result); + var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(languageResults); + + var languageList = languageResults[0]; + var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); + Assert.NotNull(language); + + // Verify TemplateParameters are populated from runtime versions + Assert.NotNull(language.Info.TemplateParameters); + Assert.Single(language.Info.TemplateParameters); + + var param = language.Info.TemplateParameters[0]; + Assert.Equal(expectedParamName, param.Name); + Assert.NotEmpty(param.Description); + Assert.NotEmpty(param.DefaultValue); + Assert.NotNull(param.ValidValues); + Assert.NotEmpty(param.ValidValues); + + // ValidValues should include all supported + preview versions + var runtimeVersions = language.RuntimeVersions; + foreach (var supported in runtimeVersions.Supported) + { + Assert.Contains(supported, param.ValidValues); + } + if (runtimeVersions.Preview is not null) + { + foreach (var preview in runtimeVersions.Preview) + { + Assert.Contains(preview, param.ValidValues); + } + } + } + + [Theory] + [InlineData("python")] + [InlineData("csharp")] + [InlineData("powershell")] + public async Task ExecuteAsync_LanguagesWithoutTemplateParameters_ReturnsNull(string languageKey) + { + // Act + var result = await CallToolAsync("functions_language_list", new()); + + // Assert + Assert.NotNull(result); + var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(languageResults); + + var languageList = languageResults[0]; + var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); + Assert.NotNull(language); + + // These languages don't have template parameters + Assert.Null(language.Info.TemplateParameters); + } +} diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs new file mode 100644 index 0000000000..f55c7af798 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Tools.Functions.Commands; +using Azure.Mcp.Tools.Functions.Models; +using Microsoft.Mcp.Tests.Client; +using Microsoft.Mcp.Tests.Client.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.Functions.LiveTests.Template; + +/// +/// Live tests for the TemplateGetCommand. Minimized test set to avoid GitHub API rate limits. +/// Tests focus on: all languages work, basic triggers, caching, and error handling. +/// +[Trait("Command", "TemplateGetCommand")] +public class TemplateGetCommandLiveTests( + ITestOutputHelper output, + TestProxyFixture fixture, + LiveServerFixture liveServerFixture) + : BaseFunctionsCommandLiveTests(output, fixture, liveServerFixture) +{ + #region Helper Methods + + private async Task GetTemplateListAsync(string language) + { + var result = await CallToolAsync( + "functions_template_get", + new() { { "language", language } }); + + Assert.NotNull(result); + var templateResult = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(templateResult?.TemplateList); + return templateResult.TemplateList; + } + + private static string? FindTemplateByPattern(TemplateListResult templateList, string pattern) + { + return templateList.Triggers? + .FirstOrDefault(t => t.TemplateName.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + ?.TemplateName; + } + + #endregion + + #region All Languages - Template List + + [Theory] + [InlineData("python")] + [InlineData("typescript")] + [InlineData("javascript")] + [InlineData("csharp")] + [InlineData("java")] + public async Task ExecuteAsync_ListTemplates_AllLanguages_ReturnsTemplates(string language) + { + // Act - List templates for each language (no file download, just manifest read) + var templateList = await GetTemplateListAsync(language); + + // Assert + Assert.Equal(language, templateList.Language); + Assert.NotNull(templateList.Triggers); + Assert.NotEmpty(templateList.Triggers); + Output.WriteLine($"{language}: {templateList.Triggers.Count} templates available"); + } + + #endregion + + #region Basic Trigger Tests - One Language Each + + [Fact] + public async Task ExecuteAsync_HttpTrigger_Python_ReturnsTemplateWithFiles() + { + // Arrange + var templateList = await GetTemplateListAsync("python"); + var httpTemplate = FindTemplateByPattern(templateList, "http-trigger-python"); + Assert.NotNull(httpTemplate); + + // Act + var result = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "python" }, + { "template", httpTemplate } + }); + + // Assert + Assert.NotNull(result); + var templateResult = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(templateResult?.FunctionTemplate); + Assert.Equal("python", templateResult.FunctionTemplate.Language); + Assert.NotNull(templateResult.FunctionTemplate.FunctionFiles); + } + + [Fact] + public async Task ExecuteAsync_HttpTrigger_TypeScript_ReturnsTemplateWithFiles() + { + // Arrange + var templateList = await GetTemplateListAsync("typescript"); + var httpTemplate = FindTemplateByPattern(templateList, "http"); + Assert.NotNull(httpTemplate); + + // Act + var result = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "typescript" }, + { "template", httpTemplate } + }); + + // Assert + Assert.NotNull(result); + var templateResult = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(templateResult?.FunctionTemplate); + Assert.Equal("typescript", templateResult.FunctionTemplate.Language); + } + + [Fact] + public async Task ExecuteAsync_HttpTrigger_CSharp_ReturnsTemplateWithFiles() + { + // Arrange + var templateList = await GetTemplateListAsync("csharp"); + var httpTemplate = FindTemplateByPattern(templateList, "http"); + Assert.NotNull(httpTemplate); + + // Act + var result = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "csharp" }, + { "template", httpTemplate } + }); + + // Assert + Assert.NotNull(result); + var templateResult = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(templateResult?.FunctionTemplate); + Assert.Equal("csharp", templateResult.FunctionTemplate.Language); + } + + #endregion + + #region Caching Tests + + [Fact] + public async Task ExecuteAsync_SameTemplate_TwoCalls_SecondUsesCache() + { + // Arrange - Get template list for Python + var templateList = await GetTemplateListAsync("python"); + var httpTemplate = FindTemplateByPattern(templateList, "http-trigger-python"); + Assert.NotNull(httpTemplate); + + // Act - First call (fetches from GitHub) + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result1 = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "python" }, + { "template", httpTemplate } + }); + stopwatch.Stop(); + var firstCallMs = stopwatch.ElapsedMilliseconds; + + // Act - Second call (should use cache) + stopwatch.Restart(); + var result2 = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "python" }, + { "template", httpTemplate } + }); + stopwatch.Stop(); + var secondCallMs = stopwatch.ElapsedMilliseconds; + + // Assert - Both return valid results + Assert.NotNull(result1); + Assert.NotNull(result2); + + // Second call should be faster (cached) - log times for debugging + Output.WriteLine($"First call: {firstCallMs}ms, Second call: {secondCallMs}ms"); + + // Verify content is identical + var template1 = JsonSerializer.Deserialize(result1.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + var template2 = JsonSerializer.Deserialize(result2.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(template1?.FunctionTemplate); + Assert.NotNull(template2?.FunctionTemplate); + Assert.Equal(template1.FunctionTemplate.FunctionFiles?.Count, template2.FunctionTemplate.FunctionFiles?.Count); + } + + #endregion + + #region Runtime Version Replacement + + [Fact] + public async Task ExecuteAsync_WithRuntimeVersion_ReplacesPlaceholders() + { + // Get valid runtime version from language list + var langResult = await CallToolAsync("functions_language_list", new()); + Assert.NotNull(langResult); + var langList = JsonSerializer.Deserialize(langResult.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(langList); + + var pythonLang = langList[0].Languages.FirstOrDefault(l => l.Language == "python"); + Assert.NotNull(pythonLang?.RuntimeVersions?.Supported); + var runtimeVersion = pythonLang.RuntimeVersions.Supported[0]; + + // Get a Python template + var templateList = await GetTemplateListAsync("python"); + var httpTemplate = FindTemplateByPattern(templateList, "http-trigger-python"); + Assert.NotNull(httpTemplate); + + // Act - Request with runtime version + var result = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "python" }, + { "template", httpTemplate }, + { "runtime-version", runtimeVersion } + }); + + // Assert + Assert.NotNull(result); + var templateResult = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(templateResult?.FunctionTemplate); + + // Verify no unreplaced placeholders + foreach (var file in templateResult.FunctionTemplate.FunctionFiles ?? []) + { + Assert.DoesNotContain("{{pythonVersion}}", file.Content); + } + } + + #endregion + + #region Error Handling + + [Fact] + public async Task ExecuteAsync_InvalidLanguage_ReturnsError() + { + // Act - Invalid language returns validation error (no "results" property) + var result = await CallToolAsync( + "functions_template_get", + new() { { "language", "invalid_language" } }); + + // Validation errors return null (status 400, no results property) + Assert.Null(result); + } + + [Fact] + public async Task ExecuteAsync_InvalidTemplate_ReturnsError() + { + // Act - Invalid template name returns error with details + var result = await CallToolAsync( + "functions_template_get", + new() + { + { "language", "python" }, + { "template", "NonExistentTemplate12345" } + }); + + // Service errors include "results" with error details + Assert.NotNull(result); + var json = result.Value.ToString(); + Assert.Contains("not found", json); + } + + #endregion +} diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json new file mode 100644 index 0000000000..5be8dd43db --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json @@ -0,0 +1,6 @@ +{ + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "", + "TagPrefix": "Azure.Mcp.Tools.Functions.LiveTests", + "Tag": "" +} diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Language/LanguageListCommandTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Language/LanguageListCommandTests.cs index bb74d8824b..19b547bab4 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Language/LanguageListCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Language/LanguageListCommandTests.cs @@ -182,12 +182,29 @@ public async Task ExecuteAsync_HandlesServiceErrors() public async Task ExecuteAsync_DeserializationValidation() { // Arrange - use the real service to verify actual data shape - // GetLanguageListAsync uses only static data, no HTTP calls needed + // GetLanguageListAsync now fetches manifest for runtime versions var mockHttpClientFactory = Substitute.For(); var languageMetadata = new LanguageMetadataProvider(); var mockManifestService = Substitute.For(); var mockLogger = Substitute.For>(); - var realService = new FunctionsService(mockHttpClientFactory, languageMetadata, mockManifestService, mockLogger); + + // Set up manifest with runtime versions + var manifest = new TemplateManifest + { + RuntimeVersions = new Dictionary + { + ["Python"] = new RuntimeVersionInfo { Supported = ["3.10", "3.11", "3.12", "3.13"], Preview = ["3.14"], Default = "3.11" }, + ["TypeScript"] = new RuntimeVersionInfo { Supported = ["20", "22"], Preview = ["24"], Default = "22" }, + ["JavaScript"] = new RuntimeVersionInfo { Supported = ["20", "22"], Preview = ["24"], Default = "22" }, + ["Java"] = new RuntimeVersionInfo { Supported = ["8", "11", "17", "21"], Preview = ["25"], Default = "21" }, + ["CSharp"] = new RuntimeVersionInfo { Supported = ["8", "9", "10"], FrameworkSupported = ["4.8.1"], Default = "8" }, + ["PowerShell"] = new RuntimeVersionInfo { Supported = ["7.4"], Default = "7.4" } + } + }; + mockManifestService.FetchManifestAsync(Arg.Any()).Returns(Task.FromResult(manifest)); + + var mockCacheService = Substitute.For(); + var realService = new FunctionsService(mockHttpClientFactory, languageMetadata, mockManifestService, mockCacheService, mockLogger); var realResult = await realService.GetLanguageListAsync(TestContext.Current.CancellationToken); _service.GetLanguageListAsync(Arg.Any()).Returns(Task.FromResult(realResult)); diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs index fb8305e61a..578b118874 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs @@ -42,7 +42,7 @@ public FunctionsServiceHttpTests() } private FunctionsService CreateService(IHttpClientFactory httpClientFactory) => - new(httpClientFactory, _languageMetadata, _manifestService, _logger); + new(httpClientFactory, _languageMetadata, _manifestService, _cacheService, _logger); private ManifestService CreateManifestService(IHttpClientFactory httpClientFactory) { @@ -50,10 +50,10 @@ private ManifestService CreateManifestService(IHttpClientFactory httpClientFacto return new ManifestService(httpClientFactory, _cacheService, _functionsOptions, manifestLogger); } - private static TemplateManifestEntry CreateTestEntry(string language = "python", string folderPath = "templates/python/HttpTrigger") => + private static TemplateManifestEntry CreateTestEntry(string language = "python", string folderPath = "templates/python/HttpTrigger", string id = "HttpTrigger") => new() { - Id = "test-id", + Id = id, DisplayName = "Test Template", Language = language, RepositoryUrl = "https://github.com/Azure/templates", @@ -272,67 +272,6 @@ public async Task FetchManifestAsync_UsesPrimary_WhenPrimarySucceeds() #endregion - #region ListGitHubDirectoryAsync Tests - - [Fact] - public async Task ListGitHubDirectoryAsync_ReturnsFiles_WhenGitHubReturnsValidJson() - { - // Arrange - var entries = new[] - { - new { name = "function.py", path = "templates/python/HttpTrigger/function.py", type = "file", size = 100, download_url = "https://raw.github.com/file.py" } - }; - var json = JsonSerializer.Serialize(entries); - var handler = new MockHttpMessageHandler(json, HttpStatusCode.OK); - var httpClientFactory = CreateHttpClientFactory(handler); - var service = CreateService(httpClientFactory); - - // Act - var result = await service.ListGitHubDirectoryAsync( - "https://api.github.com/repos/Azure/test/contents/templates", - CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Single(result); - } - - [Fact] - public async Task ListGitHubDirectoryAsync_ReturnsEmpty_WhenGitHubReturns404() - { - // Arrange - var handler = new MockHttpMessageHandler("Not Found", HttpStatusCode.NotFound); - var httpClientFactory = CreateHttpClientFactory(handler); - var service = CreateService(httpClientFactory); - - // Act - var result = await service.ListGitHubDirectoryAsync( - "https://api.github.com/repos/Azure/test/contents/nonexistent", - CancellationToken.None); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task ListGitHubDirectoryAsync_ReturnsEmpty_WhenJsonMalformed() - { - // Arrange - var handler = new MockHttpMessageHandler("{ not an array }", HttpStatusCode.OK); - var httpClientFactory = CreateHttpClientFactory(handler); - var service = CreateService(httpClientFactory); - - // Act - var result = await service.ListGitHubDirectoryAsync( - "https://api.github.com/repos/Azure/test/contents/templates", - CancellationToken.None); - - // Assert - Assert.Empty(result); - } - - #endregion - #region FetchTemplateFilesViaArchiveAsync Tests [Fact] @@ -411,6 +350,86 @@ public async Task FetchTemplateFilesViaArchiveAsync_SkipsOversizedFiles() #endregion + #region Tree API Error Handling Tests + + [Fact] + public async Task GetFunctionTemplateAsync_ThrowsInvalidOperationException_WhenRateLimited() + { + // Arrange - mock rate limit response for Tree API + var handler = new MockHttpMessageHandlerWithHeaders( + "Rate limit exceeded", + HttpStatusCode.Forbidden, + new Dictionary { ["X-RateLimit-Reset"] = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString() }); + + var httpClientFactory = CreateHttpClientFactory(handler); + + // Set up manifest with a template entry + var manifest = new TemplateManifest + { + Version = "1.0", + Templates = [CreateTestEntry("python", "templates/python/HttpTrigger")] + }; + _manifestService.FetchManifestAsync(Arg.Any()).Returns(Task.FromResult(manifest)); + + var service = CreateService(httpClientFactory); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => service.GetFunctionTemplateAsync("python", "HttpTrigger", null, CancellationToken.None)); + + Assert.Contains("rate limit", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetFunctionTemplateAsync_ThrowsInvalidOperationException_WhenNetworkFails() + { + // Arrange - mock network failure + var handler = new ThrowingHttpMessageHandler(new HttpRequestException("Connection refused")); + var httpClientFactory = CreateHttpClientFactory(handler); + + var manifest = new TemplateManifest + { + Version = "1.0", + Templates = [CreateTestEntry("python", "templates/python/HttpTrigger")] + }; + _manifestService.FetchManifestAsync(Arg.Any()).Returns(Task.FromResult(manifest)); + + var service = CreateService(httpClientFactory); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => service.GetFunctionTemplateAsync("python", "HttpTrigger", null, CancellationToken.None)); + + Assert.Contains("network", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetFunctionTemplateAsync_ThrowsInvalidOperationException_WhenEmptyTreeReturned() + { + // Arrange - mock empty tree response + var emptyTree = new { sha = "abc", tree = Array.Empty(), truncated = false }; + var json = JsonSerializer.Serialize(emptyTree); + var handler = new MockHttpMessageHandler(json, HttpStatusCode.OK); + var httpClientFactory = CreateHttpClientFactory(handler); + + var manifest = new TemplateManifest + { + Version = "1.0", + Templates = [CreateTestEntry("python", "templates/python/HttpTrigger")] + }; + _manifestService.FetchManifestAsync(Arg.Any()).Returns(Task.FromResult(manifest)); + + var service = CreateService(httpClientFactory); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + () => service.GetFunctionTemplateAsync("python", "HttpTrigger", null, CancellationToken.None)); + + Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + #region Helper Methods private static IHttpClientFactory CreateHttpClientFactory(HttpMessageHandler handler) @@ -512,5 +531,55 @@ protected override Task SendAsync(HttpRequestMessage reques } } + /// + /// Mock HTTP handler with custom response headers (for rate limit testing). + /// + private sealed class MockHttpMessageHandlerWithHeaders : HttpMessageHandler + { + private readonly string _content; + private readonly HttpStatusCode _statusCode; + private readonly Dictionary _headers; + + public MockHttpMessageHandlerWithHeaders(string content, HttpStatusCode statusCode, Dictionary headers) + { + _content = content; + _statusCode = statusCode; + _headers = headers; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_content, Encoding.UTF8, "application/json") + }; + + foreach (var (key, value) in _headers) + { + response.Headers.TryAddWithoutValidation(key, value); + } + + return Task.FromResult(response); + } + } + + /// + /// Mock HTTP handler that throws an exception (for network failure testing). + /// + private sealed class ThrowingHttpMessageHandler : HttpMessageHandler + { + private readonly Exception _exception; + + public ThrowingHttpMessageHandler(Exception exception) + { + _exception = exception; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw _exception; + } + } + #endregion } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs index 6db30f3b4d..23d3c5eb53 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs @@ -10,64 +10,6 @@ namespace Azure.Mcp.Tools.Functions.UnitTests.Services; public sealed class FunctionsServiceTests { - #region ConstructGitHubContentsApiUrl Tests - - [Fact] - public void ConstructGitHubContentsApiUrl_ValidInputs_ReturnsCorrectUrl() - { - // Arrange - var repoUrl = "https://github.com/Azure/azure-functions-templates"; - var folderPath = "templates/python/HttpTrigger"; - - // Act - var result = FunctionsService.ConstructGitHubContentsApiUrl(repoUrl, folderPath); - - // Assert - Assert.Equal("https://api.github.com/repos/Azure/azure-functions-templates/contents/templates/python/HttpTrigger", result); - } - - [Fact] - public void ConstructGitHubContentsApiUrl_TrimsLeadingSlash() - { - // Arrange - var repoUrl = "https://github.com/Azure/repo"; - var folderPath = "/templates/python"; - - // Act - var result = FunctionsService.ConstructGitHubContentsApiUrl(repoUrl, folderPath); - - // Assert - Assert.Equal("https://api.github.com/repos/Azure/repo/contents/templates/python", result); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("not-a-github-url")] - public void ConstructGitHubContentsApiUrl_InvalidRepoUrl_ThrowsArgumentException(string repoUrl) - { - // Act & Assert - var ex = Assert.Throws(() => - FunctionsService.ConstructGitHubContentsApiUrl(repoUrl, "templates/python")); - Assert.Equal("repositoryUrl", ex.ParamName); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(".")] - [InlineData("..")] - public void ConstructGitHubContentsApiUrl_RootFolderPath_ThrowsArgumentException(string folderPath) - { - // Act & Assert - var ex = Assert.Throws(() => - FunctionsService.ConstructGitHubContentsApiUrl("https://github.com/Azure/repo", folderPath)); - Assert.Equal("folderPath", ex.ParamName); - Assert.Contains("subdirectory", ex.Message); - } - - #endregion - #region ConvertToRawGitHubUrl Tests [Fact] From d432e9dad57a36235b97599a6d3e984017ff0e9f Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:51:30 -0700 Subject: [PATCH 2/6] Add changelog entry for Functions toolset improvements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml diff --git a/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml b/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml new file mode 100644 index 0000000000..880702f684 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Handle GitHub API rate limiting, add runtime configuration, and live tests for Azure Functions toolset" \ No newline at end of file From b0730a9584c4031d9337b824adfe858821df068e Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:39:06 -0700 Subject: [PATCH 3/6] Address Copilot code review feedback for Functions toolset - Add MaxTreeSizeBytes (5MB) size limit for Tree API response to prevent OOM - Add IsRateLimited() helper to check X-RateLimit-Remaining header for 403 - Handle truncated tree response with warning log - Move GitHubTreeResponse and GitHubTreeItem to separate model files - Refactor live tests for better recording coverage (20 recorded vs 1 skipped) - Add implicit cache verification test using test proxy recordings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Resources/consolidated-tools.json | 6 +- .../src/Models/GitHubTreeItem.cs | 40 +++ .../src/Models/GitHubTreeResponse.cs | 31 +++ .../src/Services/FunctionsService.cs | 64 ++--- .../BaseFunctionsCommandLiveTests.cs | 9 +- .../Language/LanguageListCommandLiveTests.cs | 227 +++++++++--------- .../Template/TemplateGetCommandLiveTests.cs | 139 ++++++----- .../assets.json | 2 +- .../Services/FunctionsServiceHttpTests.cs | 7 +- 9 files changed, 328 insertions(+), 197 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeItem.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeResponse.cs diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index c3deaf18a9..fa784ce007 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -68,8 +68,8 @@ ] }, { - "name": "get_azure_functions_template_info", - "description": "Get Azure Functions project templates, supported languages, and template source code for bootstrapping new Azure Functions projects.", + "name": "get_azure_functions_info", + "description": "Get Azure Functions project samples and templates, supported languages, and source code for bootstrapping new Azure Functions projects.", "toolMetadata": { "destructive": { "value": false, @@ -81,7 +81,7 @@ }, "openWorld": { "value": false, - "description": "This tool's domain of interaction is closed and well-defined, limited to Azure Functions templates." + "description": "This tool's domain of interaction is closed and well-defined, limited to Azure Functions samples and templates." }, "readOnly": { "value": true, diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeItem.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeItem.cs new file mode 100644 index 0000000000..d1b5577278 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeItem.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Functions.Models; + +/// +/// Individual item in GitHub Tree API response. +/// +public sealed class GitHubTreeItem +{ + /// + /// Gets or sets the path of the item relative to the repository root. + /// + public string? Path { get; set; } + + /// + /// Gets or sets the file mode (e.g., "100644" for regular file). + /// + public string? Mode { get; set; } + + /// + /// Gets or sets the type of item: "blob" for files, "tree" for directories. + /// + public string? Type { get; set; } + + /// + /// Gets or sets the SHA of the item. + /// + public string? Sha { get; set; } + + /// + /// Gets or sets the size of the file in bytes (only for blobs). + /// + public long Size { get; set; } + + /// + /// Gets or sets the API URL for this item. + /// + public string? Url { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeResponse.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeResponse.cs new file mode 100644 index 0000000000..9ad9a7ef21 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/GitHubTreeResponse.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.Functions.Models; + +/// +/// GitHub Tree API response model. +/// +public sealed class GitHubTreeResponse +{ + /// + /// Gets or sets the SHA of the tree. + /// + public string? Sha { get; set; } + + /// + /// Gets or sets the URL of the tree. + /// + public string? Url { get; set; } + + /// + /// Gets or sets the list of items in the tree. + /// + public List Tree { get; set; } = []; + + /// + /// Gets or sets whether the tree was truncated due to size. + /// When true, the tree response is incomplete and some files may be missing. + /// + public bool Truncated { get; set; } +} diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs index 3238f2585b..98a43a2b3f 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs @@ -29,6 +29,7 @@ public sealed class FunctionsService( private const string DefaultBranch = "main"; private const long MaxFileSizeBytes = 1_048_576; // 1 MB + private const long MaxTreeSizeBytes = 5_242_880; // 5 MB for tree API response private const string FunctionTemplateMergeInstructions = """ @@ -406,8 +407,10 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, using (response) { - if (response.StatusCode == System.Net.HttpStatusCode.Forbidden || - response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + // Check for rate limiting - verify via X-RateLimit-Remaining header + // HTTP 403 can also mean permission denied, so we check the header + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests || + (response.StatusCode == System.Net.HttpStatusCode.Forbidden && IsRateLimited(response))) { var resetHeader = response.Headers.TryGetValues("X-RateLimit-Reset", out var values) ? values.FirstOrDefault() : null; @@ -419,13 +422,21 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, $"GitHub API rate limit exceeded.{resetInfo} Try again later."); } + // Handle other 403 errors (permissions, not found for private repos, etc.) + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new InvalidOperationException( + "GitHub API access forbidden. The repository may be private or you may lack permissions."); + } + if (!response.IsSuccessStatusCode) { throw new InvalidOperationException( $"GitHub API returned {(int)response.StatusCode} {response.ReasonPhrase}. Unable to fetch template files."); } - var json = await response.Content.ReadAsStringAsync(cancellationToken); + // Use size-limited read to prevent OOM attacks + var json = await GitHubUrlValidator.ReadSizeLimitedStringAsync(response.Content, MaxTreeSizeBytes, cancellationToken); GitHubTreeResponse? tree; try @@ -446,10 +457,33 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, $"GitHub returned empty file tree for '{repoPath}'. The repository may be empty or inaccessible."); } + // Check for truncated response - GitHub may not return all files for large repos + if (tree.Truncated) + { + logger.LogWarning("GitHub tree response was truncated for {Repo}. Some files may be missing.", repoPath); + } + return tree; } } + /// + /// Checks if a 403 response indicates GitHub rate limiting by examining the X-RateLimit-Remaining header. + /// GitHub returns 403 with X-RateLimit-Remaining: 0 when rate limited (unlike standard 429). + /// + private static bool IsRateLimited(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var values)) + { + var remaining = values.FirstOrDefault(); + if (remaining != null && int.TryParse(remaining, out var count) && count == 0) + { + return true; + } + } + return false; + } + /// /// Filters tree items to files within the specified folder. /// @@ -598,30 +632,6 @@ internal async Task> FetchTemplateFilesViaArc internal static string ExtractTemplateName(TemplateManifestEntry entry) => entry.Id ?? string.Empty; } -/// -/// GitHub Tree API response model. -/// -internal sealed class GitHubTreeResponse -{ - public string? Sha { get; set; } - public string? Url { get; set; } - public List Tree { get; set; } = []; - public bool Truncated { get; set; } -} - -/// -/// Individual item in GitHub Tree API response. -/// -internal sealed class GitHubTreeItem -{ - public string? Path { get; set; } - public string? Mode { get; set; } - public string? Type { get; set; } // "blob" for files, "tree" for directories - public string? Sha { get; set; } - public long Size { get; set; } - public string? Url { get; set; } -} - /// /// AOT-safe JSON serialization context for CDN manifest and GitHub API deserialization. /// diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs index cb41867f23..8d461e6eda 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs @@ -17,7 +17,10 @@ public abstract class BaseFunctionsCommandLiveTests( LiveServerFixture liveServerFixture) : RecordedCommandTestsBase(output, fixture, liveServerFixture) { - // No additional sanitizers needed for Functions tests since they don't - // contain Azure resource-specific sensitive data like subscription IDs - // or IP addresses. The base class sanitizers handle standard patterns. + /// + /// Disable default sanitizer additions since Functions tests don't have + /// Azure resources (no ResourceBaseName or SubscriptionId environment variables). + /// Using empty strings in regex sanitizers causes test proxy 400 errors. + /// + public override bool EnableDefaultSanitizerAdditions => false; } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs index 62a0041df3..4db889ffc8 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs @@ -23,19 +23,36 @@ public class LanguageListCommandLiveTests( { private static readonly string[] ExpectedLanguages = ["python", "typescript", "javascript", "csharp", "java", "powershell"]; - [Fact] - public async Task ExecuteAsync_ReturnsAllSupportedLanguages() + #region Helper Methods + + private async Task GetLanguageListAsync() { - // Act var result = await CallToolAsync("functions_language_list", new()); - - // Assert Assert.NotNull(result); var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); Assert.NotNull(languageResults); Assert.Single(languageResults); + return languageResults[0]; + } + + private static LanguageDetails GetLanguage(LanguageListResult languageList, string languageKey) + { + var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); + Assert.NotNull(language); + return language; + } - var languageList = languageResults[0]; + #endregion + + #region Core Language List Tests + + [Fact] + public async Task ExecuteAsync_ReturnsAllSupportedLanguages() + { + // Act + var languageList = await GetLanguageListAsync(); + + // Assert Assert.NotEmpty(languageList.FunctionsRuntimeVersion); Assert.NotEmpty(languageList.ExtensionBundleVersion); @@ -48,141 +65,135 @@ public async Task ExecuteAsync_ReturnsAllSupportedLanguages() } } - [Theory] - [InlineData("python", "Python", "python", "v2 (Decorator-based)")] - [InlineData("typescript", "Node.js - TypeScript", "node", "v4 (Schema-based)")] - [InlineData("javascript", "Node.js - JavaScript", "node", "v4 (Schema-based)")] - [InlineData("java", "Java", "java", "Annotations-based")] - [InlineData("csharp", "dotnet-isolated - C#", "dotnet", "Isolated worker process")] - [InlineData("powershell", "PowerShell", "powershell", "Script-based")] - public async Task ExecuteAsync_ReturnsCorrectLanguageInfo( - string languageKey, - string expectedName, - string expectedRuntime, - string expectedModel) + [Fact] + public async Task ExecuteAsync_ReturnsCorrectLanguageInfo_AllLanguages() { // Act - var result = await CallToolAsync("functions_language_list", new()); - - // Assert - Assert.NotNull(result); - var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); - Assert.NotNull(languageResults); - - var languageList = languageResults[0]; - var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); - Assert.NotNull(language); + var languageList = await GetLanguageListAsync(); - // Verify LanguageInfo properties - Assert.Equal(expectedName, language.Info.Name); - Assert.Equal(expectedRuntime, language.Info.Runtime); - Assert.Equal(expectedModel, language.Info.ProgrammingModel); - Assert.NotEmpty(language.Info.Prerequisites); - Assert.NotEmpty(language.Info.DevelopmentTools); - Assert.NotEmpty(language.Info.InitCommand); - Assert.NotEmpty(language.Info.RunCommand); - Assert.NotEmpty(language.Info.InitInstructions); - Assert.NotEmpty(language.Info.ProjectStructure); + // Assert - Verify each language has correct metadata + var expectations = new Dictionary + { + ["python"] = ("Python", "python", "v2 (Decorator-based)"), + ["typescript"] = ("Node.js - TypeScript", "node", "v4 (Schema-based)"), + ["javascript"] = ("Node.js - JavaScript", "node", "v4 (Schema-based)"), + ["java"] = ("Java", "java", "Annotations-based"), + ["csharp"] = ("dotnet-isolated - C#", "dotnet", "Isolated worker process"), + ["powershell"] = ("PowerShell", "powershell", "Script-based") + }; + + foreach (var (languageKey, expected) in expectations) + { + var language = GetLanguage(languageList, languageKey); + Assert.Equal(expected.Name, language.Info.Name); + Assert.Equal(expected.Runtime, language.Info.Runtime); + Assert.Equal(expected.Model, language.Info.ProgrammingModel); + Assert.NotEmpty(language.Info.Prerequisites); + Assert.NotEmpty(language.Info.DevelopmentTools); + Assert.NotEmpty(language.Info.InitCommand); + Assert.NotEmpty(language.Info.RunCommand); + Assert.NotEmpty(language.Info.InitInstructions); + Assert.NotEmpty(language.Info.ProjectStructure); + } } - [Theory] - [InlineData("python")] - [InlineData("typescript")] - [InlineData("javascript")] - [InlineData("java")] - [InlineData("csharp")] - [InlineData("powershell")] - public async Task ExecuteAsync_ReturnsRuntimeVersionsFromManifest(string languageKey) + [Fact] + public async Task ExecuteAsync_ReturnsRuntimeVersions_AllLanguages() { // Act - var result = await CallToolAsync("functions_language_list", new()); + var languageList = await GetLanguageListAsync(); - // Assert - Assert.NotNull(result); - var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); - Assert.NotNull(languageResults); - - var languageList = languageResults[0]; - var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); - Assert.NotNull(language); + // Assert - All languages have valid runtime versions + foreach (var languageKey in ExpectedLanguages) + { + var language = GetLanguage(languageList, languageKey); - // Verify RuntimeVersions from manifest - don't hardcode specific versions - Assert.NotNull(language.RuntimeVersions); - Assert.NotEmpty(language.RuntimeVersions.Supported); - Assert.NotEmpty(language.RuntimeVersions.Default); + Assert.NotNull(language.RuntimeVersions); + Assert.NotEmpty(language.RuntimeVersions.Supported); + Assert.NotEmpty(language.RuntimeVersions.Default); - // Default should be one of the supported versions - Assert.Contains(language.RuntimeVersions.Default, language.RuntimeVersions.Supported); + // Default should be one of the supported versions + Assert.Contains(language.RuntimeVersions.Default, language.RuntimeVersions.Supported); - // Same versions should be in Info.RuntimeVersions - Assert.NotNull(language.Info.RuntimeVersions); - Assert.Equal(language.RuntimeVersions.Default, language.Info.RuntimeVersions.Default); - Assert.Equal(language.RuntimeVersions.Supported, language.Info.RuntimeVersions.Supported); + // Same versions should be in Info.RuntimeVersions + Assert.NotNull(language.Info.RuntimeVersions); + Assert.Equal(language.RuntimeVersions.Default, language.Info.RuntimeVersions.Default); + Assert.Equal(language.RuntimeVersions.Supported, language.Info.RuntimeVersions.Supported); + } } - [Theory] - [InlineData("typescript", "nodeVersion")] - [InlineData("javascript", "nodeVersion")] - [InlineData("java", "javaVersion")] - public async Task ExecuteAsync_ReturnsTemplateParametersWithValidValues(string languageKey, string expectedParamName) - { - // Act - var result = await CallToolAsync("functions_language_list", new()); + #endregion - // Assert - Assert.NotNull(result); - var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); - Assert.NotNull(languageResults); + #region Template Parameters Tests - var languageList = languageResults[0]; - var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); - Assert.NotNull(language); + [Fact] + public async Task ExecuteAsync_TypeScript_HasNodeVersionParameter() + { + var languageList = await GetLanguageListAsync(); + var language = GetLanguage(languageList, "typescript"); - // Verify TemplateParameters are populated from runtime versions Assert.NotNull(language.Info.TemplateParameters); Assert.Single(language.Info.TemplateParameters); var param = language.Info.TemplateParameters[0]; - Assert.Equal(expectedParamName, param.Name); + Assert.Equal("nodeVersion", param.Name); Assert.NotEmpty(param.Description); Assert.NotEmpty(param.DefaultValue); Assert.NotNull(param.ValidValues); Assert.NotEmpty(param.ValidValues); - // ValidValues should include all supported + preview versions - var runtimeVersions = language.RuntimeVersions; - foreach (var supported in runtimeVersions.Supported) + // ValidValues should include all supported versions + foreach (var supported in language.RuntimeVersions.Supported) { Assert.Contains(supported, param.ValidValues); } - if (runtimeVersions.Preview is not null) - { - foreach (var preview in runtimeVersions.Preview) - { - Assert.Contains(preview, param.ValidValues); - } - } } - [Theory] - [InlineData("python")] - [InlineData("csharp")] - [InlineData("powershell")] - public async Task ExecuteAsync_LanguagesWithoutTemplateParameters_ReturnsNull(string languageKey) + [Fact] + public async Task ExecuteAsync_JavaScript_HasNodeVersionParameter() { - // Act - var result = await CallToolAsync("functions_language_list", new()); + var languageList = await GetLanguageListAsync(); + var language = GetLanguage(languageList, "javascript"); - // Assert - Assert.NotNull(result); - var languageResults = JsonSerializer.Deserialize(result.Value, FunctionsJsonContext.Default.ListLanguageListResult); - Assert.NotNull(languageResults); + Assert.NotNull(language.Info.TemplateParameters); + Assert.Single(language.Info.TemplateParameters); - var languageList = languageResults[0]; - var language = languageList.Languages.FirstOrDefault(l => l.Language == languageKey); - Assert.NotNull(language); + var param = language.Info.TemplateParameters[0]; + Assert.Equal("nodeVersion", param.Name); + Assert.NotEmpty(param.DefaultValue); + Assert.NotNull(param.ValidValues); + Assert.NotEmpty(param.ValidValues); + } + + [Fact] + public async Task ExecuteAsync_Java_HasJavaVersionParameter() + { + var languageList = await GetLanguageListAsync(); + var language = GetLanguage(languageList, "java"); + + Assert.NotNull(language.Info.TemplateParameters); + Assert.Single(language.Info.TemplateParameters); + + var param = language.Info.TemplateParameters[0]; + Assert.Equal("javaVersion", param.Name); + Assert.NotEmpty(param.DefaultValue); + Assert.NotNull(param.ValidValues); + Assert.NotEmpty(param.ValidValues); + } - // These languages don't have template parameters - Assert.Null(language.Info.TemplateParameters); + [Fact] + public async Task ExecuteAsync_LanguagesWithoutTemplateParameters() + { + var languageList = await GetLanguageListAsync(); + + // Python, C#, and PowerShell don't have template parameters + var languagesWithoutParams = new[] { "python", "csharp", "powershell" }; + foreach (var languageKey in languagesWithoutParams) + { + var language = GetLanguage(languageList, languageKey); + Assert.Null(language.Info.TemplateParameters); + } } + + #endregion } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs index f55c7af798..e0b242420b 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs @@ -11,8 +11,8 @@ namespace Azure.Mcp.Tools.Functions.LiveTests.Template; /// -/// Live tests for the TemplateGetCommand. Minimized test set to avoid GitHub API rate limits. -/// Tests focus on: all languages work, basic triggers, caching, and error handling. +/// Live tests for the TemplateGetCommand. Tests template listing and file retrieval +/// for all supported languages. /// [Trait("Command", "TemplateGetCommand")] public class TemplateGetCommandLiveTests( @@ -44,29 +44,77 @@ private async Task GetTemplateListAsync(string language) #endregion - #region All Languages - Template List + #region Template List Tests - All Languages - [Theory] - [InlineData("python")] - [InlineData("typescript")] - [InlineData("javascript")] - [InlineData("csharp")] - [InlineData("java")] - public async Task ExecuteAsync_ListTemplates_AllLanguages_ReturnsTemplates(string language) + [Fact] + public async Task ExecuteAsync_ListTemplates_Python_ReturnsTemplates() { - // Act - List templates for each language (no file download, just manifest read) - var templateList = await GetTemplateListAsync(language); + var templateList = await GetTemplateListAsync("python"); - // Assert - Assert.Equal(language, templateList.Language); + Assert.Equal("python", templateList.Language); + Assert.NotNull(templateList.Triggers); + Assert.NotEmpty(templateList.Triggers); + Output.WriteLine($"python: {templateList.Triggers.Count} templates available"); + } + + [Fact] + public async Task ExecuteAsync_ListTemplates_TypeScript_ReturnsTemplates() + { + var templateList = await GetTemplateListAsync("typescript"); + + Assert.Equal("typescript", templateList.Language); + Assert.NotNull(templateList.Triggers); + Assert.NotEmpty(templateList.Triggers); + Output.WriteLine($"typescript: {templateList.Triggers.Count} templates available"); + } + + [Fact] + public async Task ExecuteAsync_ListTemplates_JavaScript_ReturnsTemplates() + { + var templateList = await GetTemplateListAsync("javascript"); + + Assert.Equal("javascript", templateList.Language); + Assert.NotNull(templateList.Triggers); + Assert.NotEmpty(templateList.Triggers); + Output.WriteLine($"javascript: {templateList.Triggers.Count} templates available"); + } + + [Fact] + public async Task ExecuteAsync_ListTemplates_CSharp_ReturnsTemplates() + { + var templateList = await GetTemplateListAsync("csharp"); + + Assert.Equal("csharp", templateList.Language); + Assert.NotNull(templateList.Triggers); + Assert.NotEmpty(templateList.Triggers); + Output.WriteLine($"csharp: {templateList.Triggers.Count} templates available"); + } + + [Fact] + public async Task ExecuteAsync_ListTemplates_Java_ReturnsTemplates() + { + var templateList = await GetTemplateListAsync("java"); + + Assert.Equal("java", templateList.Language); Assert.NotNull(templateList.Triggers); Assert.NotEmpty(templateList.Triggers); - Output.WriteLine($"{language}: {templateList.Triggers.Count} templates available"); + Output.WriteLine($"java: {templateList.Triggers.Count} templates available"); + } + + [Fact] + public async Task ExecuteAsync_ListTemplates_PowerShell_ReturnsTemplates() + { + var templateList = await GetTemplateListAsync("powershell"); + + Assert.Equal("powershell", templateList.Language); + Assert.NotNull(templateList.Triggers); + Assert.NotEmpty(templateList.Triggers); + Output.WriteLine($"powershell: {templateList.Triggers.Count} templates available"); } #endregion - #region Basic Trigger Tests - One Language Each + #region HTTP Trigger Tests - Template File Retrieval [Fact] public async Task ExecuteAsync_HttpTrigger_Python_ReturnsTemplateWithFiles() @@ -146,50 +194,33 @@ public async Task ExecuteAsync_HttpTrigger_CSharp_ReturnsTemplateWithFiles() #region Caching Tests [Fact] - public async Task ExecuteAsync_SameTemplate_TwoCalls_SecondUsesCache() + public async Task ExecuteAsync_LanguageListThenTemplate_UsesSharedCache() { - // Arrange - Get template list for Python - var templateList = await GetTemplateListAsync("python"); - var httpTemplate = FindTemplateByPattern(templateList, "http-trigger-python"); - Assert.NotNull(httpTemplate); + // This test verifies that the manifest cache is shared between commands. + // First call fetches and caches the manifest, second call uses cached manifest. + // + // IMPLICIT VERIFICATION: The test proxy records all HTTP calls during Record mode. + // If caching didn't work, template_get would make a CDN manifest call that gets recorded. + // In Playback mode, if caching breaks, it would try to make a CDN call that wasn't + // recorded, causing the test to fail. This implicitly asserts no CDN call is made. + + // Act - First call: language_list fetches and caches the manifest + var langResult = await CallToolAsync("functions_language_list", new()); - // Act - First call (fetches from GitHub) - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - var result1 = await CallToolAsync( - "functions_template_get", - new() - { - { "language", "python" }, - { "template", httpTemplate } - }); - stopwatch.Stop(); - var firstCallMs = stopwatch.ElapsedMilliseconds; + Assert.NotNull(langResult); + var langList = JsonSerializer.Deserialize(langResult.Value, FunctionsJsonContext.Default.ListLanguageListResult); + Assert.NotNull(langList); - // Act - Second call (should use cache) - stopwatch.Restart(); - var result2 = await CallToolAsync( + // Act - Second call: template_get should use cached manifest (no CDN call) + var templateResult = await CallToolAsync( "functions_template_get", - new() - { - { "language", "python" }, - { "template", httpTemplate } - }); - stopwatch.Stop(); - var secondCallMs = stopwatch.ElapsedMilliseconds; + new() { { "language", "python" } }); // Assert - Both return valid results - Assert.NotNull(result1); - Assert.NotNull(result2); - - // Second call should be faster (cached) - log times for debugging - Output.WriteLine($"First call: {firstCallMs}ms, Second call: {secondCallMs}ms"); - - // Verify content is identical - var template1 = JsonSerializer.Deserialize(result1.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); - var template2 = JsonSerializer.Deserialize(result2.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); - Assert.NotNull(template1?.FunctionTemplate); - Assert.NotNull(template2?.FunctionTemplate); - Assert.Equal(template1.FunctionTemplate.FunctionFiles?.Count, template2.FunctionTemplate.FunctionFiles?.Count); + Assert.NotNull(templateResult); + var template = JsonSerializer.Deserialize(templateResult.Value, FunctionsJsonContext.Default.TemplateGetCommandResult); + Assert.NotNull(template?.TemplateList); + Assert.Equal("python", template.TemplateList.Language); } #endregion diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json index 5be8dd43db..d076e5343d 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.Functions.LiveTests", - "Tag": "" + "Tag": "Azure.Mcp.Tools.Functions.LiveTests_cd6ea0180d" } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs index 578b118874..a2eee0665a 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs @@ -356,10 +356,15 @@ public async Task FetchTemplateFilesViaArchiveAsync_SkipsOversizedFiles() public async Task GetFunctionTemplateAsync_ThrowsInvalidOperationException_WhenRateLimited() { // Arrange - mock rate limit response for Tree API + // Include X-RateLimit-Remaining: 0 to indicate rate limiting (not just permission denied) var handler = new MockHttpMessageHandlerWithHeaders( "Rate limit exceeded", HttpStatusCode.Forbidden, - new Dictionary { ["X-RateLimit-Reset"] = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString() }); + new Dictionary + { + ["X-RateLimit-Remaining"] = "0", + ["X-RateLimit-Reset"] = DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString() + }); var httpClientFactory = CreateHttpClientFactory(handler); From 385a4e0103e26747e80dd92944559fc67ed91426 Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:25:09 -0700 Subject: [PATCH 4/6] Mark cache-dependent Functions tests as LiveTestOnly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Language/LanguageListCommandLiveTests.cs | 11 +++++++++++ .../Template/TemplateGetCommandLiveTests.cs | 17 +++++++++++++++++ .../assets.json | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs index 4db889ffc8..aa325be143 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Azure.Mcp.Tools.Functions.Commands; using Azure.Mcp.Tools.Functions.Models; +using Microsoft.Mcp.Tests.Attributes; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; using Xunit; @@ -46,6 +47,10 @@ private static LanguageDetails GetLanguage(LanguageListResult languageList, stri #region Core Language List Tests + /// + /// Primary test that fetches from CDN and can be recorded. + /// All other tests depend on cached manifest and are marked [LiveTestOnly]. + /// [Fact] public async Task ExecuteAsync_ReturnsAllSupportedLanguages() { @@ -66,6 +71,7 @@ public async Task ExecuteAsync_ReturnsAllSupportedLanguages() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ReturnsCorrectLanguageInfo_AllLanguages() { // Act @@ -98,6 +104,7 @@ public async Task ExecuteAsync_ReturnsCorrectLanguageInfo_AllLanguages() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ReturnsRuntimeVersions_AllLanguages() { // Act @@ -127,6 +134,7 @@ public async Task ExecuteAsync_ReturnsRuntimeVersions_AllLanguages() #region Template Parameters Tests [Fact] + [LiveTestOnly] public async Task ExecuteAsync_TypeScript_HasNodeVersionParameter() { var languageList = await GetLanguageListAsync(); @@ -150,6 +158,7 @@ public async Task ExecuteAsync_TypeScript_HasNodeVersionParameter() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_JavaScript_HasNodeVersionParameter() { var languageList = await GetLanguageListAsync(); @@ -166,6 +175,7 @@ public async Task ExecuteAsync_JavaScript_HasNodeVersionParameter() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_Java_HasJavaVersionParameter() { var languageList = await GetLanguageListAsync(); @@ -182,6 +192,7 @@ public async Task ExecuteAsync_Java_HasJavaVersionParameter() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_LanguagesWithoutTemplateParameters() { var languageList = await GetLanguageListAsync(); diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs index e0b242420b..8a43b7729a 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Azure.Mcp.Tools.Functions.Commands; using Azure.Mcp.Tools.Functions.Models; +using Microsoft.Mcp.Tests.Attributes; using Microsoft.Mcp.Tests.Client; using Microsoft.Mcp.Tests.Client.Helpers; using Xunit; @@ -46,6 +47,10 @@ private async Task GetTemplateListAsync(string language) #region Template List Tests - All Languages + /// + /// Primary test that fetches from CDN and can be recorded. + /// Other list tests depend on cached manifest and are marked [LiveTestOnly]. + /// [Fact] public async Task ExecuteAsync_ListTemplates_Python_ReturnsTemplates() { @@ -58,6 +63,7 @@ public async Task ExecuteAsync_ListTemplates_Python_ReturnsTemplates() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ListTemplates_TypeScript_ReturnsTemplates() { var templateList = await GetTemplateListAsync("typescript"); @@ -69,6 +75,7 @@ public async Task ExecuteAsync_ListTemplates_TypeScript_ReturnsTemplates() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ListTemplates_JavaScript_ReturnsTemplates() { var templateList = await GetTemplateListAsync("javascript"); @@ -80,6 +87,7 @@ public async Task ExecuteAsync_ListTemplates_JavaScript_ReturnsTemplates() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ListTemplates_CSharp_ReturnsTemplates() { var templateList = await GetTemplateListAsync("csharp"); @@ -91,6 +99,7 @@ public async Task ExecuteAsync_ListTemplates_CSharp_ReturnsTemplates() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ListTemplates_Java_ReturnsTemplates() { var templateList = await GetTemplateListAsync("java"); @@ -102,6 +111,7 @@ public async Task ExecuteAsync_ListTemplates_Java_ReturnsTemplates() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_ListTemplates_PowerShell_ReturnsTemplates() { var templateList = await GetTemplateListAsync("powershell"); @@ -117,6 +127,7 @@ public async Task ExecuteAsync_ListTemplates_PowerShell_ReturnsTemplates() #region HTTP Trigger Tests - Template File Retrieval [Fact] + [LiveTestOnly] public async Task ExecuteAsync_HttpTrigger_Python_ReturnsTemplateWithFiles() { // Arrange @@ -142,6 +153,7 @@ public async Task ExecuteAsync_HttpTrigger_Python_ReturnsTemplateWithFiles() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_HttpTrigger_TypeScript_ReturnsTemplateWithFiles() { // Arrange @@ -166,6 +178,7 @@ public async Task ExecuteAsync_HttpTrigger_TypeScript_ReturnsTemplateWithFiles() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_HttpTrigger_CSharp_ReturnsTemplateWithFiles() { // Arrange @@ -194,6 +207,7 @@ public async Task ExecuteAsync_HttpTrigger_CSharp_ReturnsTemplateWithFiles() #region Caching Tests [Fact] + [LiveTestOnly] public async Task ExecuteAsync_LanguageListThenTemplate_UsesSharedCache() { // This test verifies that the manifest cache is shared between commands. @@ -228,6 +242,7 @@ public async Task ExecuteAsync_LanguageListThenTemplate_UsesSharedCache() #region Runtime Version Replacement [Fact] + [LiveTestOnly] public async Task ExecuteAsync_WithRuntimeVersion_ReplacesPlaceholders() { // Get valid runtime version from language list @@ -272,6 +287,7 @@ public async Task ExecuteAsync_WithRuntimeVersion_ReplacesPlaceholders() #region Error Handling [Fact] + [LiveTestOnly] public async Task ExecuteAsync_InvalidLanguage_ReturnsError() { // Act - Invalid language returns validation error (no "results" property) @@ -284,6 +300,7 @@ public async Task ExecuteAsync_InvalidLanguage_ReturnsError() } [Fact] + [LiveTestOnly] public async Task ExecuteAsync_InvalidTemplate_ReturnsError() { // Act - Invalid template name returns error with details diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json index d076e5343d..d3333bdb76 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.Functions.LiveTests", - "Tag": "Azure.Mcp.Tools.Functions.LiveTests_cd6ea0180d" + "Tag": "Azure.Mcp.Tools.Functions.LiveTests_1654c50d0b" } From b92953a9261ced12cc37acc4728d7c46e483e706 Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:23:37 -0700 Subject: [PATCH 5/6] Address PR review feedback for Functions toolset - Add null checks for httpClientFactory and logger with _ prefix - Add using System.Net and use HttpStatusCode.TooManyRequests - Make FetchTemplateFilesAsync private (only used internally) - Add catch for InvalidOperationException in ManifestService - Change changelog section to Other Changes - Add comment documenting raw.githubusercontent.com rate limiting (~5000/hr) - Remove unused ConvertToRawGitHubUrl method - Consolidate FunctionTemplatesManifestJsonContext into FunctionsJsonContext - Remove redundant [JsonPropertyName] attributes from 11 model files - Fix tests to use FunctionsJsonContext for serialization - Create HttpClientHelper for User-Agent with real assembly version - Add doc comments explaining [LiveTestOnly] pattern in live tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../changelog-entries/1773697825677.yaml | 2 +- .../src/Commands/FunctionsJsonContext.cs | 8 ++ .../src/Models/FunctionTemplateResult.cs | 11 --- .../src/Models/LanguageDetails.cs | 5 - .../src/Models/LanguageInfo.cs | 17 ---- .../src/Models/LanguageListResult.cs | 5 - .../src/Models/ProjectTemplateFile.cs | 4 - .../src/Models/ProjectTemplateResult.cs | 5 - .../src/Models/RuntimeVersionInfo.cs | 7 -- .../src/Models/TemplateListResult.cs | 6 -- .../src/Models/TemplateManifest.cs | 8 -- .../src/Models/TemplateManifestEntry.cs | 17 ---- .../src/Models/TemplateParameter.cs | 6 -- .../src/Models/TemplateSummary.cs | 7 -- .../src/Services/FunctionsService.cs | 91 +++++++------------ .../src/Services/Helpers/HttpClientHelper.cs | 47 ++++++++++ .../src/Services/ManifestService.cs | 11 ++- .../Language/LanguageListCommandLiveTests.cs | 4 + .../Template/TemplateGetCommandLiveTests.cs | 4 + .../Services/FunctionsServiceHttpTests.cs | 15 +-- .../Services/FunctionsServiceTests.cs | 57 ++---------- .../Services/HttpClientHelperTests.cs | 70 ++++++++++++++ 22 files changed, 193 insertions(+), 214 deletions(-) create mode 100644 tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs create mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs diff --git a/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml b/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml index 880702f684..394bba09f8 100644 --- a/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml +++ b/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml @@ -1,3 +1,3 @@ changes: - - section: "Features Added" + - section: "Other Changes" description: "Handle GitHub API rate limiting, add runtime configuration, and live tests for Azure Functions toolset" \ No newline at end of file diff --git a/tools/Azure.Mcp.Tools.Functions/src/Commands/FunctionsJsonContext.cs b/tools/Azure.Mcp.Tools.Functions/src/Commands/FunctionsJsonContext.cs index 7c0216d876..de8c41e5c6 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Commands/FunctionsJsonContext.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Commands/FunctionsJsonContext.cs @@ -4,9 +4,13 @@ using System.Text.Json.Serialization; using Azure.Mcp.Tools.Functions.Commands.Template; using Azure.Mcp.Tools.Functions.Models; +using Azure.Mcp.Tools.Functions.Services; namespace Azure.Mcp.Tools.Functions.Commands; +/// +/// AOT-safe JSON serialization context for Functions commands, CDN manifest, and GitHub API. +/// [JsonSerializable(typeof(LanguageListResult))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ProjectTemplateResult))] @@ -17,5 +21,9 @@ namespace Azure.Mcp.Tools.Functions.Commands; [JsonSerializable(typeof(TemplateListResult))] [JsonSerializable(typeof(FunctionTemplateResult))] [JsonSerializable(typeof(TemplateSummary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(GitHubTreeResponse))] +[JsonSerializable(typeof(GitHubTreeItem))] +[JsonSerializable(typeof(List))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] internal partial class FunctionsJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/FunctionTemplateResult.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/FunctionTemplateResult.cs index e1124a201b..6dd9522a55 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/FunctionTemplateResult.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/FunctionTemplateResult.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,30 +9,21 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class FunctionTemplateResult { - [JsonPropertyName("language")] public required string Language { get; init; } - [JsonPropertyName("templateName")] public required string TemplateName { get; init; } - [JsonPropertyName("displayName")] public string? DisplayName { get; init; } - [JsonPropertyName("description")] public string? Description { get; init; } - [JsonPropertyName("bindingType")] public string? BindingType { get; init; } - [JsonPropertyName("resource")] public string? Resource { get; init; } - [JsonPropertyName("functionFiles")] public IReadOnlyList FunctionFiles { get; init; } = []; - [JsonPropertyName("projectFiles")] public IReadOnlyList ProjectFiles { get; init; } = []; - [JsonPropertyName("mergeInstructions")] public string MergeInstructions { get; init; } = string.Empty; } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageDetails.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageDetails.cs index 750ba6403e..95e2d2bd87 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageDetails.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageDetails.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,12 +9,9 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class LanguageDetails { - [JsonPropertyName("language")] public required string Language { get; init; } - [JsonPropertyName("info")] public required LanguageInfo Info { get; init; } - [JsonPropertyName("runtimeVersions")] public required RuntimeVersionInfo RuntimeVersions { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageInfo.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageInfo.cs index 01dfc1bec4..7347776557 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageInfo.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageInfo.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -10,59 +8,46 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class LanguageInfo { - [JsonPropertyName("name")] public required string Name { get; init; } - [JsonPropertyName("runtime")] public required string Runtime { get; init; } - [JsonPropertyName("programmingModel")] public required string ProgrammingModel { get; init; } - [JsonPropertyName("prerequisites")] public required IReadOnlyList Prerequisites { get; init; } - [JsonPropertyName("developmentTools")] public required IReadOnlyList DevelopmentTools { get; init; } - [JsonPropertyName("initCommand")] public required string InitCommand { get; init; } - [JsonPropertyName("runCommand")] public required string RunCommand { get; init; } - [JsonPropertyName("buildCommand")] public string? BuildCommand { get; init; } /// /// Project-level files that initialize a new Azure Functions project for this language. /// - [JsonPropertyName("projectFiles")] public required IReadOnlyList ProjectFiles { get; init; } /// /// Supported runtime versions for this language. /// - [JsonPropertyName("runtimeVersions")] public required RuntimeVersionInfo RuntimeVersions { get; init; } /// /// Step-by-step setup instructions for this language. /// - [JsonPropertyName("initInstructions")] public required string InitInstructions { get; init; } /// /// Description of the project directory structure for this language. /// - [JsonPropertyName("projectStructure")] public required IReadOnlyList ProjectStructure { get; init; } /// /// Template parameters for placeholder replacement (e.g., {{javaVersion}}). /// Null if this language has no configurable placeholders. /// - [JsonPropertyName("templateParameters")] public IReadOnlyList? TemplateParameters { get; init; } /// @@ -70,7 +55,5 @@ public sealed class LanguageInfo /// For example, "Recommended for Node.js runtime for type safety and better tooling." /// Null if not a recommended choice. /// - [JsonPropertyName("recommendationNotes")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? RecommendationNotes { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageListResult.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageListResult.cs index e95d0c44a5..03bfc34832 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageListResult.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/LanguageListResult.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,12 +9,9 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class LanguageListResult { - [JsonPropertyName("functionsRuntimeVersion")] public required string FunctionsRuntimeVersion { get; init; } - [JsonPropertyName("extensionBundleVersion")] public required string ExtensionBundleVersion { get; init; } - [JsonPropertyName("languages")] public required IReadOnlyList Languages { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateFile.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateFile.cs index 1d3ca1d175..c8617674a5 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateFile.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateFile.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,9 +9,7 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class ProjectTemplateFile { - [JsonPropertyName("fileName")] public required string FileName { get; init; } - [JsonPropertyName("content")] public required string Content { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateResult.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateResult.cs index 4857ed62e3..11ec42958b 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateResult.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/ProjectTemplateResult.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,12 +9,9 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class ProjectTemplateResult { - [JsonPropertyName("language")] public required string Language { get; init; } - [JsonPropertyName("initInstructions")] public required string InitInstructions { get; init; } - [JsonPropertyName("projectStructure")] public required IReadOnlyList ProjectStructure { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/RuntimeVersionInfo.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/RuntimeVersionInfo.cs index ea02560050..3df5777a31 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/RuntimeVersionInfo.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/RuntimeVersionInfo.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,18 +9,13 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class RuntimeVersionInfo { - [JsonPropertyName("supported")] public required IReadOnlyList Supported { get; init; } - [JsonPropertyName("preview")] public IReadOnlyList? Preview { get; init; } - [JsonPropertyName("deprecated")] public IReadOnlyList? Deprecated { get; init; } - [JsonPropertyName("default")] public required string Default { get; init; } - [JsonPropertyName("frameworkSupported")] public IReadOnlyList? FrameworkSupported { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateListResult.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateListResult.cs index baa3527077..0dd76d3caa 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateListResult.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateListResult.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,15 +9,11 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class TemplateListResult { - [JsonPropertyName("language")] public required string Language { get; init; } - [JsonPropertyName("triggers")] public IReadOnlyList Triggers { get; init; } = []; - [JsonPropertyName("inputBindings")] public IReadOnlyList InputBindings { get; init; } = []; - [JsonPropertyName("outputBindings")] public IReadOnlyList OutputBindings { get; init; } = []; } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs index 8b33ceb083..6e33b81ccc 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifest.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,25 +9,19 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class TemplateManifest { - [JsonPropertyName("generatedAt")] public string? GeneratedAt { get; init; } - [JsonPropertyName("version")] public string? Version { get; init; } - [JsonPropertyName("totalTemplates")] public int TotalTemplates { get; init; } - [JsonPropertyName("languages")] public IReadOnlyList Languages { get; init; } = []; - [JsonPropertyName("templates")] public IReadOnlyList Templates { get; init; } = []; /// /// Runtime version information for each supported language. /// Keys are language names (e.g., "Python", "JavaScript", "TypeScript", "Java", "CSharp", "PowerShell"). /// - [JsonPropertyName("runtimeVersions")] public IReadOnlyDictionary? RuntimeVersions { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifestEntry.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifestEntry.cs index 1ebfb1e7e7..d81a8d3da0 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifestEntry.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateManifestEntry.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,48 +9,33 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class TemplateManifestEntry { - [JsonPropertyName("id")] public required string Id { get; init; } - [JsonPropertyName("displayName")] public required string DisplayName { get; init; } - [JsonPropertyName("shortDescription")] public string? ShortDescription { get; init; } - [JsonPropertyName("longDescription")] public string? LongDescription { get; init; } - [JsonPropertyName("language")] public required string Language { get; init; } - [JsonPropertyName("bindingType")] public string? BindingType { get; init; } - [JsonPropertyName("resource")] public string? Resource { get; init; } - [JsonPropertyName("iac")] public string? Iac { get; init; } - [JsonPropertyName("priority")] public int Priority { get; init; } - [JsonPropertyName("categories")] public IReadOnlyList Categories { get; init; } = []; - [JsonPropertyName("tags")] public IReadOnlyList Tags { get; init; } = []; - [JsonPropertyName("author")] public string? Author { get; init; } - [JsonPropertyName("repositoryUrl")] public required string RepositoryUrl { get; init; } - [JsonPropertyName("folderPath")] public required string FolderPath { get; init; } - [JsonPropertyName("whatsIncluded")] public IReadOnlyList WhatsIncluded { get; init; } = []; } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateParameter.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateParameter.cs index a99f31361d..a098c21867 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateParameter.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateParameter.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -12,15 +10,11 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class TemplateParameter { - [JsonPropertyName("name")] public required string Name { get; init; } - [JsonPropertyName("description")] public required string Description { get; init; } - [JsonPropertyName("defaultValue")] public required string DefaultValue { get; init; } - [JsonPropertyName("validValues")] public IReadOnlyList? ValidValues { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateSummary.cs b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateSummary.cs index aa3522db6c..57e88417e0 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateSummary.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Models/TemplateSummary.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; - namespace Azure.Mcp.Tools.Functions.Models; /// @@ -11,22 +9,17 @@ namespace Azure.Mcp.Tools.Functions.Models; /// public sealed class TemplateSummary { - [JsonPropertyName("templateName")] public required string TemplateName { get; init; } - [JsonPropertyName("displayName")] public required string DisplayName { get; init; } - [JsonPropertyName("description")] public string? Description { get; init; } - [JsonPropertyName("resource")] public string? Resource { get; init; } /// /// Infrastructure type. means code-only, /// other values indicate the template includes infrastructure files and is azd-ready for deployment. /// - [JsonPropertyName("infrastructure")] public InfrastructureType Infrastructure { get; init; } } diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs index 98a43a2b3f..b158e89182 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System.IO.Compression; +using System.Net; using System.Text.Json; -using System.Text.Json.Serialization; using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Tools.Functions.Commands; using Azure.Mcp.Tools.Functions.Models; using Azure.Mcp.Tools.Functions.Services.Helpers; using Microsoft.Extensions.Logging; @@ -21,9 +22,11 @@ public sealed class FunctionsService( ICacheService cacheService, ILogger logger) : IFunctionsService { + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); private readonly ILanguageMetadataProvider _languageMetadata = languageMetadata ?? throw new ArgumentNullException(nameof(languageMetadata)); private readonly IManifestService _manifestService = manifestService ?? throw new ArgumentNullException(nameof(manifestService)); private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private const string CacheGroup = "functions-templates"; @@ -228,27 +231,18 @@ public async Task GetFunctionTemplateAsync( } /// - /// Converts a GitHub repository URL and folder path into a raw.githubusercontent.com URL. + /// Builds a raw.githubusercontent.com URL from pre-validated repo path and file path. + /// Raw URLs have higher rate limits (~5000/hour) compared to API (60/hour unauthenticated). /// - internal static string ConvertToRawGitHubUrl(string repositoryUrl, string folderPath) + internal static string BuildRawGitHubUrl(string repoPath, string filePath) { - // repositoryUrl: "https://github.com/Azure/azure-functions-templates-mcp-server" - // folderPath: "templates/python/BlobTriggerWithEventGrid" - // result: "https://raw.githubusercontent.com/Azure/azure-functions-templates-mcp-server/main/templates/python/..." - - var repoPath = GitHubUrlValidator.ExtractGitHubRepoPath(repositoryUrl) - ?? throw new ArgumentException("Invalid repository URL format.", nameof(repositoryUrl)); - - var normalizedPath = GitHubUrlValidator.NormalizeFolderPath(folderPath) - ?? throw new ArgumentException("Folder path must specify a valid subdirectory, not the repository root.", nameof(folderPath)); - - return $"https://raw.githubusercontent.com/{repoPath}/{DefaultBranch}/{normalizedPath}"; + return $"https://raw.githubusercontent.com/{repoPath}/{DefaultBranch}/{filePath}"; } /// /// Fetches all files from a template directory. Results are cached. /// - internal async Task> FetchTemplateFilesAsync( + private async Task> FetchTemplateFilesAsync( TemplateManifestEntry template, string language, string? runtimeVersion, @@ -263,12 +257,12 @@ internal async Task> FetchTemplateFilesAsync( if (cachedFiles is not null && cachedFiles.Count > 0) { - logger.LogDebug("Using cached template files for {Language}/{Path}", language, normalizedPath); + _logger.LogDebug("Using cached template files for {Language}/{Path}", language, normalizedPath); files = cachedFiles; } else { - logger.LogDebug("Fetching template files from GitHub for {Language}/{Path}", language, normalizedPath); + _logger.LogDebug("Fetching template files from GitHub for {Language}/{Path}", language, normalizedPath); files = isRootOrLarge ? await FetchTemplateFilesViaArchiveAsync(template.RepositoryUrl, normalizedPath, cancellationToken) : await FetchTemplateFilesViaTreeApiAsync(template.RepositoryUrl, template.FolderPath, cancellationToken); @@ -317,12 +311,12 @@ private async Task> FetchTemplateFilesViaTree GitHubTreeResponse treeResponse; if (cachedTree is not null) { - logger.LogDebug("Using cached tree for {Repo}", repoPath); + _logger.LogDebug("Using cached tree for {Repo}", repoPath); treeResponse = cachedTree; } else { - logger.LogDebug("Fetching tree from GitHub for {Repo}", repoPath); + _logger.LogDebug("Fetching tree from GitHub for {Repo}", repoPath); var treeUrl = $"https://api.github.com/repos/{repoPath}/git/trees/{DefaultBranch}?recursive=1"; treeResponse = await FetchTreeFromGitHubAsync(treeUrl, repoPath, cancellationToken); await _cacheService.SetAsync(CacheGroup, treeCacheKey, treeResponse, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken); @@ -336,18 +330,17 @@ private async Task> FetchTemplateFilesViaTree } var files = new List(); - using var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); + using var client = HttpClientHelper.CreateClientWithUserAgent(_httpClientFactory); foreach (var (fullPath, relativePath, size) in filePaths) { if (size > MaxFileSizeBytes) { - logger.LogWarning("Skipping file {Name} ({Size} bytes) - exceeds max size", relativePath, size); + _logger.LogWarning("Skipping file {Name} ({Size} bytes) - exceeds max size", relativePath, size); continue; } - var rawUrl = $"https://raw.githubusercontent.com/{repoPath}/{DefaultBranch}/{fullPath}"; + var rawUrl = BuildRawGitHubUrl(repoPath, fullPath); try { @@ -355,7 +348,7 @@ private async Task> FetchTemplateFilesViaTree if (!response.IsSuccessStatusCode) { - logger.LogWarning("Failed to fetch {Name} from raw URL (HTTP {Status})", relativePath, response.StatusCode); + _logger.LogWarning("Failed to fetch {Name} from raw URL (HTTP {Status})", relativePath, response.StatusCode); continue; } @@ -366,7 +359,7 @@ private async Task> FetchTemplateFilesViaTree } catch (InvalidOperationException) { - logger.LogWarning("Skipping file {Name} - size exceeds limit", relativePath); + _logger.LogWarning("Skipping file {Name} - size exceeds limit", relativePath); continue; } @@ -378,7 +371,7 @@ private async Task> FetchTemplateFilesViaTree } catch (HttpRequestException ex) { - logger.LogWarning(ex, "Error fetching template file {Name}", relativePath); + _logger.LogWarning(ex, "Error fetching template file {Name}", relativePath); } } @@ -390,8 +383,7 @@ private async Task> FetchTemplateFilesViaTree /// private async Task FetchTreeFromGitHubAsync(string treeUrl, string repoPath, CancellationToken cancellationToken) { - using var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); + using var client = HttpClientHelper.CreateClientWithUserAgent(_httpClientFactory); HttpResponseMessage response; try @@ -400,7 +392,7 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, } catch (HttpRequestException ex) { - logger.LogError(ex, "Failed to fetch tree from {Url}", treeUrl); + _logger.LogError(ex, "Failed to fetch tree from {Url}", treeUrl); throw new InvalidOperationException( $"Failed to fetch template files from GitHub. Check your network connection. Details: {ex.Message}", ex); } @@ -409,8 +401,8 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, { // Check for rate limiting - verify via X-RateLimit-Remaining header // HTTP 403 can also mean permission denied, so we check the header - if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests || - (response.StatusCode == System.Net.HttpStatusCode.Forbidden && IsRateLimited(response))) + if (response.StatusCode == HttpStatusCode.TooManyRequests || + (response.StatusCode == HttpStatusCode.Forbidden && IsRateLimited(response))) { var resetHeader = response.Headers.TryGetValues("X-RateLimit-Reset", out var values) ? values.FirstOrDefault() : null; @@ -423,7 +415,7 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, } // Handle other 403 errors (permissions, not found for private repos, etc.) - if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + if (response.StatusCode == HttpStatusCode.Forbidden) { throw new InvalidOperationException( "GitHub API access forbidden. The repository may be private or you may lack permissions."); @@ -441,18 +433,18 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, try { - tree = JsonSerializer.Deserialize(json, FunctionTemplatesManifestJsonContext.Default.GitHubTreeResponse); + tree = JsonSerializer.Deserialize(json, FunctionsJsonContext.Default.GitHubTreeResponse); } catch (JsonException ex) { - logger.LogError(ex, "Failed to parse tree response from {Url}", treeUrl); + _logger.LogError(ex, "Failed to parse tree response from {Url}", treeUrl); throw new InvalidOperationException( $"Failed to parse GitHub response. Details: {ex.Message}", ex); } if (tree?.Tree is null || tree.Tree.Count == 0) { - logger.LogWarning("Empty tree response from {Repo}", repoPath); + _logger.LogWarning("Empty tree response from {Repo}", repoPath); throw new InvalidOperationException( $"GitHub returned empty file tree for '{repoPath}'. The repository may be empty or inaccessible."); } @@ -460,7 +452,7 @@ private async Task FetchTreeFromGitHubAsync(string treeUrl, // Check for truncated response - GitHub may not return all files for large repos if (tree.Truncated) { - logger.LogWarning("GitHub tree response was truncated for {Repo}. Some files may be missing.", repoPath); + _logger.LogWarning("GitHub tree response was truncated for {Repo}. Some files may be missing.", repoPath); } return tree; @@ -527,10 +519,9 @@ internal async Task> FetchTemplateFilesViaArc var zipUrl = $"https://api.github.com/repos/{repoPath}/zipball/{DefaultBranch}"; var normalizedFolder = GitHubUrlValidator.NormalizeFolderPath(folderPath, allowRoot: true) ?? string.Empty; - using var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); + using var client = HttpClientHelper.CreateClientWithUserAgent(_httpClientFactory); - logger.LogInformation("Downloading repository archive from {Url}", zipUrl); + _logger.LogInformation("Downloading repository archive from {Url}", zipUrl); using var response = await client.GetAsync(new Uri(zipUrl), HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); @@ -573,13 +564,13 @@ internal async Task> FetchTemplateFilesViaArc if (relativePath.Contains("..", StringComparison.Ordinal)) { - logger.LogWarning("Skipping file {Name} - path traversal detected", entry.FullName); + _logger.LogWarning("Skipping file {Name} - path traversal detected", entry.FullName); continue; } if (entry.Length > MaxFileSizeBytes) { - logger.LogWarning("Skipping file {Name} - exceeds max size", relativePath); + _logger.LogWarning("Skipping file {Name} - exceeds max size", relativePath); continue; } @@ -593,7 +584,7 @@ internal async Task> FetchTemplateFilesViaArc if (charsRead > MaxFileSizeBytes) { - logger.LogWarning("Skipping file {Name} - exceeds max size", relativePath); + _logger.LogWarning("Skipping file {Name} - exceeds max size", relativePath); continue; } @@ -607,11 +598,11 @@ internal async Task> FetchTemplateFilesViaArc } catch (Exception ex) { - logger.LogWarning(ex, "Error reading file {Name} from archive", entry.FullName); + _logger.LogWarning(ex, "Error reading file {Name} from archive", entry.FullName); } } - logger.LogInformation("Extracted {Count} files from archive", files.Count); + _logger.LogInformation("Extracted {Count} files from archive", files.Count); return files; } @@ -631,15 +622,3 @@ internal async Task> FetchTemplateFilesViaArc /// internal static string ExtractTemplateName(TemplateManifestEntry entry) => entry.Id ?? string.Empty; } - -/// -/// AOT-safe JSON serialization context for CDN manifest and GitHub API deserialization. -/// -[JsonSerializable(typeof(TemplateManifest))] -[JsonSerializable(typeof(TemplateManifestEntry))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(GitHubTreeResponse))] -[JsonSerializable(typeof(GitHubTreeItem))] -[JsonSerializable(typeof(List))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -internal partial class FunctionTemplatesManifestJsonContext : JsonSerializerContext; diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs new file mode 100644 index 0000000000..4b1eca6efa --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; + +namespace Azure.Mcp.Tools.Functions.Services.Helpers; + +/// +/// Helper for creating HTTP clients with standard configuration. +/// +internal static class HttpClientHelper +{ + private static readonly string s_version = GetVersion(); + + private static readonly string s_userAgent = $"Azure-MCP-Server/{s_version}"; + + /// + /// Creates an HTTP client with the standard User-Agent header. + /// + /// The HTTP client factory. + /// A configured HTTP client. + public static HttpClient CreateClientWithUserAgent(IHttpClientFactory httpClientFactory) + { + var client = httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd(s_userAgent); + return client; + } + + /// + /// Gets the version from the entry assembly (server) or falls back to the current assembly. + /// + private static string GetVersion() + { + // Prefer entry assembly (the server) for version when running as part of the server + var entryAssembly = Assembly.GetEntryAssembly(); + var version = entryAssembly?.GetCustomAttribute()?.Version; + + if (!string.IsNullOrEmpty(version)) + { + return version; + } + + // Fall back to current assembly version (for unit tests or standalone usage) + return typeof(HttpClientHelper).Assembly + .GetCustomAttribute()?.Version ?? "unknown"; + } +} diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs index ef86a07cc5..cc2a77bd60 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Tools.Functions.Commands; using Azure.Mcp.Tools.Functions.Models; using Azure.Mcp.Tools.Functions.Options; using Azure.Mcp.Tools.Functions.Services.Helpers; @@ -77,13 +78,12 @@ private async Task TryFetchManifestAsync(string url, Cancel try { - using var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0"); + using var client = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); using var response = await client.GetAsync(uri, cancellationToken); response.EnsureSuccessStatusCode(); var json = await GitHubUrlValidator.ReadSizeLimitedStringAsync(response.Content, MaxManifestSizeBytes, cancellationToken); - var manifest = JsonSerializer.Deserialize(json, FunctionTemplatesManifestJsonContext.Default.TemplateManifest); + var manifest = JsonSerializer.Deserialize(json, FunctionsJsonContext.Default.TemplateManifest); if (manifest is null) { @@ -107,5 +107,10 @@ private async Task TryFetchManifestAsync(string url, Cancel logger.LogError(ex, "Request timed out fetching manifest from {Url}", url); return ManifestFetchResult.Failure("Request timed out"); } + catch (InvalidOperationException ex) + { + logger.LogError(ex, "Manifest response from {Url} exceeded size limit", url); + return ManifestFetchResult.Failure(ex.Message); + } } } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs index aa325be143..b594ee31c2 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs @@ -14,6 +14,10 @@ namespace Azure.Mcp.Tools.Functions.LiveTests.Language; /// /// Live tests for the LanguageListCommand which fetches supported languages /// and runtime versions from the Azure Functions manifest. +/// +/// Note: Most tests are marked [LiveTestOnly] because they depend on in-memory cached +/// CDN manifest data. Only the first test (ExecuteAsync_ReturnsAllSupportedLanguages) +/// fetches from CDN and can be recorded. Recorded tests with non-deterministic execution order would fail. /// [Trait("Command", "LanguageListCommand")] public class LanguageListCommandLiveTests( diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs index 8a43b7729a..49f774f6d9 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs @@ -14,6 +14,10 @@ namespace Azure.Mcp.Tools.Functions.LiveTests.Template; /// /// Live tests for the TemplateGetCommand. Tests template listing and file retrieval /// for all supported languages. +/// +/// Note: Most tests are marked [LiveTestOnly] because they depend on in-memory cached +/// CDN manifest data. Only the first test (ExecuteAsync_ReturnsAllSupportedLanguages) +/// fetches from CDN and can be recorded. Recorded tests with non-deterministic execution order would fail. /// [Trait("Command", "TemplateGetCommand")] public class TemplateGetCommandLiveTests( diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs index a2eee0665a..8e0bb0828c 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceHttpTests.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Tools.Functions.Commands; using Azure.Mcp.Tools.Functions.Models; using Azure.Mcp.Tools.Functions.Options; using Azure.Mcp.Tools.Functions.Services; @@ -72,7 +73,7 @@ public async Task FetchManifestAsync_ReturnsTemplates_WhenCdnReturnsValidJson() Version = "1.0", Templates = [CreateTestEntry()] }; - var json = JsonSerializer.Serialize(manifest); + var json = JsonSerializer.Serialize(manifest, FunctionsJsonContext.Default.TemplateManifest); var handler = new MockHttpMessageHandler(json, HttpStatusCode.OK); var httpClientFactory = CreateHttpClientFactory(handler); var service = CreateManifestService(httpClientFactory); @@ -150,7 +151,7 @@ public async Task FetchManifestAsync_FetchesFresh_WhenCacheHasEmptyTemplates() Version = "fresh", Templates = [CreateTestEntry()] }; - var json = JsonSerializer.Serialize(freshManifest); + var json = JsonSerializer.Serialize(freshManifest, FunctionsJsonContext.Default.TemplateManifest); var handler = new MockHttpMessageHandler(json, HttpStatusCode.OK); var httpClientFactory = CreateHttpClientFactory(handler); var service = CreateManifestService(httpClientFactory); @@ -173,7 +174,7 @@ public async Task FetchManifestAsync_UsesFallback_WhenPrimaryFails() Version = "fallback", Templates = [CreateTestEntry("fallback-lang")] }; - var fallbackJson = JsonSerializer.Serialize(fallbackManifest); + var fallbackJson = JsonSerializer.Serialize(fallbackManifest, FunctionsJsonContext.Default.TemplateManifest); var responses = new Dictionary { @@ -202,7 +203,7 @@ public async Task FetchManifestAsync_UsesFallback_WhenPrimaryReturnsMalformedJso Version = "fallback-after-malformed", Templates = [CreateTestEntry()] }; - var fallbackJson = JsonSerializer.Serialize(fallbackManifest); + var fallbackJson = JsonSerializer.Serialize(fallbackManifest, FunctionsJsonContext.Default.TemplateManifest); var responses = new Dictionary { @@ -251,7 +252,7 @@ public async Task FetchManifestAsync_UsesPrimary_WhenPrimarySucceeds() Version = "primary", Templates = [CreateTestEntry("primary-lang")] }; - var primaryJson = JsonSerializer.Serialize(primaryManifest); + var primaryJson = JsonSerializer.Serialize(primaryManifest, FunctionsJsonContext.Default.TemplateManifest); var responses = new Dictionary { @@ -412,8 +413,8 @@ public async Task GetFunctionTemplateAsync_ThrowsInvalidOperationException_WhenN public async Task GetFunctionTemplateAsync_ThrowsInvalidOperationException_WhenEmptyTreeReturned() { // Arrange - mock empty tree response - var emptyTree = new { sha = "abc", tree = Array.Empty(), truncated = false }; - var json = JsonSerializer.Serialize(emptyTree); + var emptyTree = new GitHubTreeResponse { Sha = "abc", Tree = [], Truncated = false }; + var json = JsonSerializer.Serialize(emptyTree, FunctionsJsonContext.Default.GitHubTreeResponse); var handler = new MockHttpMessageHandler(json, HttpStatusCode.OK); var httpClientFactory = CreateHttpClientFactory(handler); diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs index 23d3c5eb53..c2d1bcd42d 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/FunctionsServiceTests.cs @@ -10,60 +10,19 @@ namespace Azure.Mcp.Tools.Functions.UnitTests.Services; public sealed class FunctionsServiceTests { - #region ConvertToRawGitHubUrl Tests + #region BuildRawGitHubUrl Tests - [Fact] - public void ConvertToRawGitHubUrl_ValidInputs_ReturnsCorrectUrl() - { - // Arrange - var repoUrl = "https://github.com/Azure/azure-functions-templates-mcp-server"; - var folderPath = "templates/python/BlobTrigger"; - - // Act - var result = FunctionsService.ConvertToRawGitHubUrl(repoUrl, folderPath); - - // Assert - Assert.Equal("https://raw.githubusercontent.com/Azure/azure-functions-templates-mcp-server/main/templates/python/BlobTrigger", result); - } - - [Fact] - public void ConvertToRawGitHubUrl_TrimsLeadingSlash() + [Theory] + [InlineData("Azure/repo", "templates/python/file.py", "https://raw.githubusercontent.com/Azure/repo/main/templates/python/file.py")] + [InlineData("Azure/azure-functions-templates", "path/to/file.txt", "https://raw.githubusercontent.com/Azure/azure-functions-templates/main/path/to/file.txt")] + [InlineData("Azure-Samples/repo", "folder/file.json", "https://raw.githubusercontent.com/Azure-Samples/repo/main/folder/file.json")] + public void BuildRawGitHubUrl_ValidInputs_ReturnsCorrectUrl(string repoPath, string filePath, string expected) { - // Arrange - var repoUrl = "https://github.com/Azure/repo"; - var folderPath = "/templates/typescript"; - // Act - var result = FunctionsService.ConvertToRawGitHubUrl(repoUrl, folderPath); + var result = FunctionsService.BuildRawGitHubUrl(repoPath, filePath); // Assert - Assert.Equal("https://raw.githubusercontent.com/Azure/repo/main/templates/typescript", result); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("invalid-url")] - public void ConvertToRawGitHubUrl_InvalidRepoUrl_ThrowsArgumentException(string repoUrl) - { - // Act & Assert - var ex = Assert.Throws(() => - FunctionsService.ConvertToRawGitHubUrl(repoUrl, "templates/python")); - Assert.Equal("repositoryUrl", ex.ParamName); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(".")] - [InlineData("..")] - public void ConvertToRawGitHubUrl_RootFolderPath_ThrowsArgumentException(string folderPath) - { - // Act & Assert - var ex = Assert.Throws(() => - FunctionsService.ConvertToRawGitHubUrl("https://github.com/Azure/repo", folderPath)); - Assert.Equal("folderPath", ex.ParamName); - Assert.Contains("subdirectory", ex.Message); + Assert.Equal(expected, result); } #endregion diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs new file mode 100644 index 0000000000..03a90ed03e --- /dev/null +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.Functions.Services.Helpers; +using Xunit; + +namespace Azure.Mcp.Tools.Functions.UnitTests.Services; + +public sealed class HttpClientHelperTests +{ + /// + /// A simple test IHttpClientFactory implementation. + /// + private sealed class TestHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + [Fact] + public void CreateClientWithUserAgent_SetsUserAgentHeader() + { + // Arrange + var httpClientFactory = new TestHttpClientFactory(); + + // Act + using var client = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); + + // Assert + Assert.NotNull(client); + var userAgent = client.DefaultRequestHeaders.UserAgent.ToString(); + Assert.StartsWith("Azure-MCP-Server/", userAgent); + } + + [Fact] + public void CreateClientWithUserAgent_HasValidVersion() + { + // Arrange + var httpClientFactory = new TestHttpClientFactory(); + + // Act + using var client = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); + + // Assert + var userAgent = client.DefaultRequestHeaders.UserAgent.ToString(); + + // Version should not be "unknown" - assembly should have a version + Assert.DoesNotContain("unknown", userAgent); + + // Version should be a valid semver-like format (e.g., "1.0.0.0" or "2.0.0-beta.28") + // In unit tests, falls back to toolset version; in server, uses server version + var version = userAgent.Replace("Azure-MCP-Server/", ""); + Assert.Matches(@"^\d+\.\d+", version); // At least major.minor + } + + [Fact] + public void CreateClientWithUserAgent_ReturnsSameVersionAcrossCalls() + { + // Arrange + var httpClientFactory = new TestHttpClientFactory(); + + // Act + using var client1 = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); + using var client2 = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); + + // Assert - version should be consistent (static field) + var userAgent1 = client1.DefaultRequestHeaders.UserAgent.ToString(); + var userAgent2 = client2.DefaultRequestHeaders.UserAgent.ToString(); + Assert.Equal(userAgent1, userAgent2); + } +} From 196bca42dbdd76411f3b9ea3e04037ffbc9b35ca Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:51:06 -0700 Subject: [PATCH 6/6] Remove HttpClientHelper - factory already sets User-Agent HttpClientFactoryConfigurator in core already sets User-Agent header: azmcp/{version} azmcp-{transport}/{version} (framework; os) Removed redundant HttpClientHelper and its tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Services/FunctionsService.cs | 6 +- .../src/Services/Helpers/HttpClientHelper.cs | 47 ------------- .../src/Services/ManifestService.cs | 2 +- .../assets.json | 2 +- .../Services/HttpClientHelperTests.cs | 70 ------------------- 5 files changed, 5 insertions(+), 122 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs delete mode 100644 tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs index b158e89182..4312946192 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/FunctionsService.cs @@ -330,7 +330,7 @@ private async Task> FetchTemplateFilesViaTree } var files = new List(); - using var client = HttpClientHelper.CreateClientWithUserAgent(_httpClientFactory); + using var client = _httpClientFactory.CreateClient(); foreach (var (fullPath, relativePath, size) in filePaths) { @@ -383,7 +383,7 @@ private async Task> FetchTemplateFilesViaTree /// private async Task FetchTreeFromGitHubAsync(string treeUrl, string repoPath, CancellationToken cancellationToken) { - using var client = HttpClientHelper.CreateClientWithUserAgent(_httpClientFactory); + using var client = _httpClientFactory.CreateClient(); HttpResponseMessage response; try @@ -519,7 +519,7 @@ internal async Task> FetchTemplateFilesViaArc var zipUrl = $"https://api.github.com/repos/{repoPath}/zipball/{DefaultBranch}"; var normalizedFolder = GitHubUrlValidator.NormalizeFolderPath(folderPath, allowRoot: true) ?? string.Empty; - using var client = HttpClientHelper.CreateClientWithUserAgent(_httpClientFactory); + using var client = _httpClientFactory.CreateClient(); _logger.LogInformation("Downloading repository archive from {Url}", zipUrl); diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs deleted file mode 100644 index 4b1eca6efa..0000000000 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/Helpers/HttpClientHelper.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Reflection; - -namespace Azure.Mcp.Tools.Functions.Services.Helpers; - -/// -/// Helper for creating HTTP clients with standard configuration. -/// -internal static class HttpClientHelper -{ - private static readonly string s_version = GetVersion(); - - private static readonly string s_userAgent = $"Azure-MCP-Server/{s_version}"; - - /// - /// Creates an HTTP client with the standard User-Agent header. - /// - /// The HTTP client factory. - /// A configured HTTP client. - public static HttpClient CreateClientWithUserAgent(IHttpClientFactory httpClientFactory) - { - var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd(s_userAgent); - return client; - } - - /// - /// Gets the version from the entry assembly (server) or falls back to the current assembly. - /// - private static string GetVersion() - { - // Prefer entry assembly (the server) for version when running as part of the server - var entryAssembly = Assembly.GetEntryAssembly(); - var version = entryAssembly?.GetCustomAttribute()?.Version; - - if (!string.IsNullOrEmpty(version)) - { - return version; - } - - // Fall back to current assembly version (for unit tests or standalone usage) - return typeof(HttpClientHelper).Assembly - .GetCustomAttribute()?.Version ?? "unknown"; - } -} diff --git a/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs b/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs index cc2a77bd60..789572d88f 100644 --- a/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs +++ b/tools/Azure.Mcp.Tools.Functions/src/Services/ManifestService.cs @@ -78,7 +78,7 @@ private async Task TryFetchManifestAsync(string url, Cancel try { - using var client = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); + using var client = httpClientFactory.CreateClient(); using var response = await client.GetAsync(uri, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json index d3333bdb76..b124279794 100644 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json +++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "", "TagPrefix": "Azure.Mcp.Tools.Functions.LiveTests", - "Tag": "Azure.Mcp.Tools.Functions.LiveTests_1654c50d0b" + "Tag": "Azure.Mcp.Tools.Functions.LiveTests_2cb581a898" } diff --git a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs deleted file mode 100644 index 03a90ed03e..0000000000 --- a/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.UnitTests/Services/HttpClientHelperTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Mcp.Tools.Functions.Services.Helpers; -using Xunit; - -namespace Azure.Mcp.Tools.Functions.UnitTests.Services; - -public sealed class HttpClientHelperTests -{ - /// - /// A simple test IHttpClientFactory implementation. - /// - private sealed class TestHttpClientFactory : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(); - } - - [Fact] - public void CreateClientWithUserAgent_SetsUserAgentHeader() - { - // Arrange - var httpClientFactory = new TestHttpClientFactory(); - - // Act - using var client = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); - - // Assert - Assert.NotNull(client); - var userAgent = client.DefaultRequestHeaders.UserAgent.ToString(); - Assert.StartsWith("Azure-MCP-Server/", userAgent); - } - - [Fact] - public void CreateClientWithUserAgent_HasValidVersion() - { - // Arrange - var httpClientFactory = new TestHttpClientFactory(); - - // Act - using var client = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); - - // Assert - var userAgent = client.DefaultRequestHeaders.UserAgent.ToString(); - - // Version should not be "unknown" - assembly should have a version - Assert.DoesNotContain("unknown", userAgent); - - // Version should be a valid semver-like format (e.g., "1.0.0.0" or "2.0.0-beta.28") - // In unit tests, falls back to toolset version; in server, uses server version - var version = userAgent.Replace("Azure-MCP-Server/", ""); - Assert.Matches(@"^\d+\.\d+", version); // At least major.minor - } - - [Fact] - public void CreateClientWithUserAgent_ReturnsSameVersionAcrossCalls() - { - // Arrange - var httpClientFactory = new TestHttpClientFactory(); - - // Act - using var client1 = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); - using var client2 = HttpClientHelper.CreateClientWithUserAgent(httpClientFactory); - - // Assert - version should be consistent (static field) - var userAgent1 = client1.DefaultRequestHeaders.UserAgent.ToString(); - var userAgent2 = client2.DefaultRequestHeaders.UserAgent.ToString(); - Assert.Equal(userAgent1, userAgent2); - } -}