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
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