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..394bba09f8
--- /dev/null
+++ b/servers/Azure.Mcp.Server/changelog-entries/1773697825677.yaml
@@ -0,0 +1,3 @@
+changes:
+ - 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/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/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/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/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/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 12a173d26f..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,18 +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").
+ ///
+ 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/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..4312946192 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;
@@ -13,21 +14,25 @@ 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 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";
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 =
"""
@@ -46,11 +51,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 +76,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 +91,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 +102,7 @@ public Task GetProjectTemplateAsync(
ProjectStructure = languageInfo.ProjectStructure
};
- return Task.FromResult(result);
+ return result;
}
public async Task GetTemplateListAsync(
@@ -165,13 +174,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)
@@ -222,28 +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. 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(
+ private async Task> FetchTemplateFilesAsync(
TemplateManifestEntry template,
string language,
string? runtimeVersion,
@@ -251,21 +250,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);
+
+ if (files.Count > 0)
+ {
+ await _cacheService.SetAsync(CacheGroup, cacheKey, files.ToList(), FunctionsCacheDurations.TemplateCacheDuration, cancellationToken);
+ }
+ }
- IReadOnlyList files = isRootOrLarge
- ? await FetchTemplateFilesViaArchiveAsync(template.RepositoryUrl, normalizedPath, cancellationToken)
- : await FetchTemplateFilesViaContentsApiAsync(template.RepositoryUrl, template.FolderPath, 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 +292,66 @@ 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));
- using var client = httpClientFactory.CreateClient();
- client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0");
+ var treeCacheKey = $"tree:{repoPath}:{DefaultBranch}";
+ var cachedTree = await _cacheService.GetAsync(CacheGroup, treeCacheKey, FunctionsCacheDurations.TemplateCacheDuration, cancellationToken);
- foreach (var entry in fileEntries)
+ GitHubTreeResponse treeResponse;
+ if (cachedTree is not null)
{
- if (entry.Size > MaxFileSizeBytes)
- {
- logger.LogWarning("Skipping file {Name} ({Size} bytes) - exceeds max size", entry.Name, entry.Size);
- continue;
- }
+ _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();
- // Validate URL points to GitHub domain (SSRF prevention)
- if (entry.DownloadUrl is null || !GitHubUrlValidator.IsValidGitHubUrl(entry.DownloadUrl))
+ foreach (var (fullPath, relativePath, size) in filePaths)
+ {
+ if (size > MaxFileSizeBytes)
{
- logger.LogWarning("Skipping file {Name} - invalid or missing download URL", entry.Name);
+ _logger.LogWarning("Skipping file {Name} ({Size} bytes) - exceeds max size", relativePath, size);
continue;
}
+ var rawUrl = BuildRawGitHubUrl(repoPath, 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 +359,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 +371,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 +379,134 @@ 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();
+
+ 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)
+ {
+ // 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 == HttpStatusCode.TooManyRequests ||
+ (response.StatusCode == HttpStatusCode.Forbidden && IsRateLimited(response)))
+ {
+ 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.");
+ }
+
+ // Handle other 403 errors (permissions, not found for private repos, etc.)
+ if (response.StatusCode == 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.");
+ }
+
+ // Use size-limited read to prevent OOM attacks
+ var json = await GitHubUrlValidator.ReadSizeLimitedStringAsync(response.Content, MaxTreeSizeBytes, cancellationToken);
+ GitHubTreeResponse? tree;
+
+ try
+ {
+ tree = JsonSerializer.Deserialize(json, FunctionsJsonContext.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.");
+ }
+
+ // 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.
+ ///
+ 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,
@@ -368,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 = _httpClientFactory.CreateClient();
- 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();
@@ -381,29 +531,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 +556,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 +584,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;
}
@@ -460,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;
}
@@ -478,112 +616,9 @@ 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);
- }
-
- return allFiles;
- }
}
-
-///
-/// AOT-safe JSON serialization context for CDN manifest and GitHub API deserialization.
-///
-[JsonSerializable(typeof(TemplateManifest))]
-[JsonSerializable(typeof(TemplateManifestEntry))]
-[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(GitHubContentEntry))]
-[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..789572d88f 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;
@@ -23,7 +24,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 +44,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 +54,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 +63,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!;
}
@@ -79,12 +79,11 @@ private async Task TryFetchManifestAsync(string url, Cancel
try
{
using var client = httpClientFactory.CreateClient();
- client.DefaultRequestHeaders.UserAgent.ParseAdd("Azure-MCP-Server/1.0");
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)
{
@@ -108,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/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..8d461e6eda
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/BaseFunctionsCommandLiveTests.cs
@@ -0,0 +1,26 @@
+// 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)
+{
+ ///
+ /// 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
new file mode 100644
index 0000000000..b594ee31c2
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Language/LanguageListCommandLiveTests.cs
@@ -0,0 +1,214 @@
+// 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.Attributes;
+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.
+///
+/// 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(
+ ITestOutputHelper output,
+ TestProxyFixture fixture,
+ LiveServerFixture liveServerFixture)
+ : BaseFunctionsCommandLiveTests(output, fixture, liveServerFixture)
+{
+ private static readonly string[] ExpectedLanguages = ["python", "typescript", "javascript", "csharp", "java", "powershell"];
+
+ #region Helper Methods
+
+ private async Task GetLanguageListAsync()
+ {
+ var result = await CallToolAsync("functions_language_list", new());
+ 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;
+ }
+
+ #endregion
+
+ #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()
+ {
+ // Act
+ var languageList = await GetLanguageListAsync();
+
+ // Assert
+ 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);
+ }
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ public async Task ExecuteAsync_ReturnsCorrectLanguageInfo_AllLanguages()
+ {
+ // Act
+ var languageList = await GetLanguageListAsync();
+
+ // 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);
+ }
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ public async Task ExecuteAsync_ReturnsRuntimeVersions_AllLanguages()
+ {
+ // Act
+ var languageList = await GetLanguageListAsync();
+
+ // Assert - All languages have valid runtime versions
+ foreach (var languageKey in ExpectedLanguages)
+ {
+ var language = GetLanguage(languageList, languageKey);
+
+ 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);
+ }
+ }
+
+ #endregion
+
+ #region Template Parameters Tests
+
+ [Fact]
+ [LiveTestOnly]
+ public async Task ExecuteAsync_TypeScript_HasNodeVersionParameter()
+ {
+ var languageList = await GetLanguageListAsync();
+ var language = GetLanguage(languageList, "typescript");
+
+ Assert.NotNull(language.Info.TemplateParameters);
+ Assert.Single(language.Info.TemplateParameters);
+
+ var param = language.Info.TemplateParameters[0];
+ 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 versions
+ foreach (var supported in language.RuntimeVersions.Supported)
+ {
+ Assert.Contains(supported, param.ValidValues);
+ }
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ public async Task ExecuteAsync_JavaScript_HasNodeVersionParameter()
+ {
+ var languageList = await GetLanguageListAsync();
+ var language = GetLanguage(languageList, "javascript");
+
+ Assert.NotNull(language.Info.TemplateParameters);
+ Assert.Single(language.Info.TemplateParameters);
+
+ var param = language.Info.TemplateParameters[0];
+ Assert.Equal("nodeVersion", param.Name);
+ Assert.NotEmpty(param.DefaultValue);
+ Assert.NotNull(param.ValidValues);
+ Assert.NotEmpty(param.ValidValues);
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ 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);
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ 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
new file mode 100644
index 0000000000..49f774f6d9
--- /dev/null
+++ b/tools/Azure.Mcp.Tools.Functions/tests/Azure.Mcp.Tools.Functions.LiveTests/Template/TemplateGetCommandLiveTests.cs
@@ -0,0 +1,326 @@
+// 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.Attributes;
+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. 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(
+ 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 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()
+ {
+ var templateList = await GetTemplateListAsync("python");
+
+ Assert.Equal("python", templateList.Language);
+ Assert.NotNull(templateList.Triggers);
+ Assert.NotEmpty(templateList.Triggers);
+ Output.WriteLine($"python: {templateList.Triggers.Count} templates available");
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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($"java: {templateList.Triggers.Count} templates available");
+ }
+
+ [Fact]
+ [LiveTestOnly]
+ 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 HTTP Trigger Tests - Template File Retrieval
+
+ [Fact]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ public async Task ExecuteAsync_LanguageListThenTemplate_UsesSharedCache()
+ {
+ // 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());
+
+ Assert.NotNull(langResult);
+ var langList = JsonSerializer.Deserialize(langResult.Value, FunctionsJsonContext.Default.ListLanguageListResult);
+ Assert.NotNull(langList);
+
+ // Act - Second call: template_get should use cached manifest (no CDN call)
+ var templateResult = await CallToolAsync(
+ "functions_template_get",
+ new() { { "language", "python" } });
+
+ // Assert - Both return valid results
+ Assert.NotNull(templateResult);
+ var template = JsonSerializer.Deserialize(templateResult.Value, FunctionsJsonContext.Default.TemplateGetCommandResult);
+ Assert.NotNull(template?.TemplateList);
+ Assert.Equal("python", template.TemplateList.Language);
+ }
+
+ #endregion
+
+ #region Runtime Version Replacement
+
+ [Fact]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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]
+ [LiveTestOnly]
+ 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..b124279794
--- /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": "Azure.Mcp.Tools.Functions.LiveTests_2cb581a898"
+}
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..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;
@@ -42,7 +43,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 +51,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",
@@ -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
{
@@ -272,67 +273,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 +351,91 @@ 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
+ // 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-Remaining"] = "0",
+ ["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 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);
+
+ 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 +537,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..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,118 +10,19 @@ 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);
- }
+ #region BuildRawGitHubUrl Tests
[Theory]
- [InlineData("")]
- [InlineData(" ")]
- [InlineData("not-a-github-url")]
- public void ConstructGitHubContentsApiUrl_InvalidRepoUrl_ThrowsArgumentException(string repoUrl)
+ [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)
{
- // 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]
- 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()
- {
- // 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