Add MCP Tools to support Extension Resource Types#19453
Conversation
|
Test this change out locally with the following install scripts (Action run 24845757969) VSCode
Azure CLI
|
| } | ||
| } | ||
| ``` | ||
| 1. Use `ListPublishedExtensions` to discover available extensions and their versions before configuring `bicepconfig.json`. |
There was a problem hiding this comment.
As far as the client is concerned, they'll only see the tool names in snake case, since the MCP library converts them all:
| 1. Use `ListPublishedExtensions` to discover available extensions and their versions before configuring `bicepconfig.json`. | |
| 1. Use the `list_published_extensions` MCP tool to discover available extensions and their versions before configuring `bicepconfig.json`. |
| 1. If you hit warnings or errors with null properties, prefer solving them with the safe-dereference (`.?`) operator, in conjunction with the coalesce (`??`) operator. For example, `a.?b ?? c` is better than `a!.b` which may cause runtime errors, or `a != null ? a.b : c` which is unnecessarily verbose. | ||
|
|
||
| ### Extensions | ||
| 1. When creating Bicep files that use extension resources (e.g., Microsoft Graph via `extension microsoftGraphV1_0`), ensure a `bicepconfig.json` file exists in the current directory or an ancestor directory. If one does not exist, create it with the proper extension configuration. For example, for Microsoft Graph v1.0: |
There was a problem hiding this comment.
I wonder if it's worth explaining that the extension microsoftGraphV1_0 statement is also needed in the .bicep file using the extension resources, and that the identifier microsoftGraphV1_0 has to match the key in the bicepconfig.json file
|
|
||
| [McpServerTool(Title = "List published Bicep extensions", Destructive = false, Idempotent = true, OpenWorld = true, ReadOnly = true, UseStructuredContent = true)] | ||
| [Description(""" | ||
| Lists published Bicep extensions (e.g., Microsoft Graph) with their available versions. |
There was a problem hiding this comment.
I would recommend explaining somewhere that this isn't a complete list, and maybe renaming the tool to something like ListWellKnownExtensions? My worry is that reading this list could cause the agent to think that extensions not in the list are invalid
There was a problem hiding this comment.
Renamed and added explaination.
|
|
||
| namespace Bicep.McpServer.Extensions; | ||
|
|
||
| public class ExtensionTypeLoaderProvider |
There was a problem hiding this comment.
Most of the logic in this file appears to be duplicated from logic that's already in the Bicep.Core library. Especially for anything involving writing to the file system, we should be very careful to ensure we're reusing rather than duplicating. The core logic to handle writing to the filesystem cache is very careful to handle concurrency correctly without corrupting the cache through file system locks.
There was a problem hiding this comment.
I considered using OciArtifactRegistry.RestoreArtifacts directly, but it requires an OciArtifactReference which depends on BicepSourceFile (9-param constructor including full compilation artifacts). Creating a dummy BicepSourceFile felt brittle. Extracting the locking into a shared utility in Bicep.Core would be ideal long-term but felt like a bigger change for this PR.
I've updated the logic now to use the same locking logic as in ExternalArtifactRegistry.
| /// Lists tags for a repository using the standard OCI Distribution /v2/{repo}/tags/list endpoint. | ||
| /// The Azure SDK uses ACR-specific APIs that MCR doesn't support, so we call the standard endpoint directly. | ||
| /// </summary> | ||
| private static async Task<IReadOnlyList<string>> GetRepositoryTagsViaOciAsync(string registry, string repository) |
There was a problem hiding this comment.
Is there not an existing method to handle this?
There was a problem hiding this comment.
No existing method for standard OCI tag listing. AzureContainerRegistryManager.GetRepositoryTagsAsync uses the Azure SDK's GetAllManifestPropertiesAsync(), which calls an ACR-specific API (/acr/v1/{repo}/_manifests). MCR doesn't support this endpoint — it returns HTML instead of JSON, causing a JsonReaderException. The existing GetRepositoryTagsAsync is only used for private ACR registries (by PrivateAcrModuleMetadataProvider), never for MCR.
| [Description("The extension name for non-Azure resources (e.g., microsoftgraph/v1.0). Omit for Azure resources. Use ListPublishedExtensions to discover available extensions.")] string? extensionName = null, | ||
| [Description("The extension version/tag (e.g., 1.0.0). Required when extensionName is provided.")] string? extensionVersion = null) |
There was a problem hiding this comment.
I feel like it would be more consistent to accept the canonical extension reference identifier string format (e.g. br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0), since this is how it's going to appear elsewhere (in docs, bicepconfig, code samples)
There was a problem hiding this comment.
It makes sense to accept the canonical extension reference. When also taking this comment into account, I feel it's cleaner separating Azure and Extension resource types and schemas into different tools. What do you think?
| @@ -44,54 +47,95 @@ public record AvmMetadataResult( | |||
| [Description("List of Azure Verified Module metadata entries")] | |||
| ImmutableArray<AvmModuleMetadata> Modules); | |||
|
|
|||
| public record ExtensionInfo( | |||
| [Description("Extension name (e.g., microsoftgraph/v1.0)")] | |||
| string Name, | |||
There was a problem hiding this comment.
For Name, I think it makes sense to be consistent with what's defined in the config: https://github.com/microsoftgraph/msgraph-bicep-types/blob/178bbf9019f80d7c8cce2eb35e20bf43b35c9bb2/generated/microsoftgraph/microsoft.graph/beta/1.1.0-preview/index.json#L27
There was a problem hiding this comment.
Changed the value to match what's defined in the config now.
| @@ -44,54 +47,95 @@ public record AvmMetadataResult( | |||
| [Description("List of Azure Verified Module metadata entries")] | |||
| ImmutableArray<AvmModuleMetadata> Modules); | |||
|
|
|||
| public record ExtensionInfo( | |||
There was a problem hiding this comment.
Shouldn't we also return the OCI path format for the extension?
E.g. br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0
There was a problem hiding this comment.
Returning OciReference now.
| public ResourceTypeListResult ListAzResourceTypesForProvider( | ||
| [Description("The resource provider (or namespace) of the Azure resource; e.g. Microsoft.KeyVault")] string providerNamespace) | ||
| public async Task<ResourceTypeListResult> ListResourceTypes( | ||
| [Description("The resource provider (or namespace) of the resource; e.g. Microsoft.KeyVault or Microsoft.Graph")] string providerNamespace, |
There was a problem hiding this comment.
providerNamespace isn't quite correct for extension types, since there's no expectation they actually contain a providerNamespace.
I'm not sure how to refer to this generically - maybe we could call it something like "prefix filter", or only require it if the extension path isn't provided?
There was a problem hiding this comment.
Azure and Extension tools are separated now.
|
|
||
| public TypesDefinitionResult LoadSingleResourceType(string fullyQualifiedResourceType, string apiVersion) | ||
| { | ||
| var loader = new AzTypeLoader(); |
There was a problem hiding this comment.
This is extremely memory + compute intensive to load repeatedly.
I see that AzResourceTypeLoader is set up as a singleton here - can we use this instead?
There was a problem hiding this comment.
Fixed. Registered AzTypeLoader as a singleton ITypeLoader in DI — both AzResourceTypeLoader and ResourceVisitor now share the same instance. No more new AzTypeLoader() per call.
| return new ResourceTypeSchemaResult(JsonSchemaWriter.Write(typesDefinition)); | ||
| } | ||
|
|
||
| [McpServerTool(Title = "List extension resource types", Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, UseStructuredContent = true)] |
There was a problem hiding this comment.
Should OpenWorld be true here?
There was a problem hiding this comment.
Fixed. Set OpenWorld = true on both ListExtensionResourceTypes and GetExtensionResourceTypeSchema since they make network calls to MCR.
| try | ||
| { | ||
| var cacheDir = GetCacheDirectory(extension, tag); | ||
| if (cacheDir is null) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var typesTgzFile = cacheDir.GetFile("types.tgz"); | ||
| if (typesTgzFile.Exists()) | ||
| { | ||
| return ArchivedTypeLoader.FromFileHandle(typesTgzFile); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
| catch | ||
| { | ||
| // If the cached file is corrupted or inaccessible, fall through to MCR pull | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Writes the extension artifact to the file-system cache using file-based locking, | ||
| /// matching the concurrency pattern from ExternalArtifactRegistry. | ||
| /// </summary> | ||
| private async Task WriteToCacheWithLockAsync(WellKnownExtension extension, string tag, BinaryData data) | ||
| { | ||
| try | ||
| { | ||
| var cacheDir = GetCacheDirectory(extension, tag); | ||
| if (cacheDir is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| cacheDir.EnsureExists(); | ||
| var lockFile = cacheDir.GetFile("lock"); | ||
| var typesTgzFile = cacheDir.GetFile("types.tgz"); | ||
| var stopwatch = Stopwatch.StartNew(); | ||
|
|
||
| while (stopwatch.Elapsed < CacheContentionTimeout) | ||
| { | ||
| using (var @lock = lockFile.TryLock()) | ||
| { | ||
| if (@lock is not null) | ||
| { | ||
| // Double-check: another process may have already written | ||
| if (typesTgzFile.Exists()) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| typesTgzFile.Write(data); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| await Task.Delay(CacheContentionRetryInterval); | ||
| } | ||
|
|
||
| // Timeout exceeded — best-effort, don't fail the operation | ||
| } | ||
| catch | ||
| { | ||
| // Best-effort caching — don't fail the operation if disk write fails | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the cache directory for an extension artifact, matching the compiler's cache path convention. | ||
| /// Uses the same path encoding as OciArtifactRegistry (TagEncoder, registry/repo char replacement). | ||
| /// </summary> | ||
| private IDirectoryHandle? GetCacheDirectory(WellKnownExtension extension, string tag) | ||
| { | ||
| var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | ||
| if (string.IsNullOrEmpty(userProfile)) | ||
| { | ||
| // User profile may not be available in container environments | ||
| return null; | ||
| } | ||
|
|
||
| // Match the compiler's cache path convention from OciArtifactRegistry.GetArtifactDirectory: | ||
| // ~/.bicep/br/{registry with : replaced by $, lowered}/{repo with / replaced by $}/{TagEncoder.Encode(tag)} | ||
| var registry = extension.Registry.Replace(':', '$').ToLowerInvariant(); | ||
| var repository = extension.Repository.Replace('/', '$'); | ||
| var encodedTag = TagEncoder.Encode(tag); | ||
|
|
||
| var cacheRoot = fileExplorer.GetDirectory(IOUri.FromFilePath( | ||
| Path.Combine(userProfile, ".bicep", ArtifactReferenceSchemes.Oci))); | ||
|
|
||
| return cacheRoot.GetDirectory($"{registry}/{repository}/{encodedTag}"); | ||
| } | ||
|
|
||
| private static CloudConfiguration GetCloudConfiguration() | ||
| { | ||
| var builtInConfig = IConfigurationManager.GetBuiltInConfiguration(); | ||
| return builtInConfig.Cloud; | ||
| } |
There was a problem hiding this comment.
Can we find a way to use this code directly from Bicep Core, rather than duplicating? We want different consumers to agree on the implementation, and so I don't want to risk this diverging over time.
There was a problem hiding this comment.
Extracted the locking logic into ArtifactCacheHelper.WriteWithLockAsync() and the cache path encoding into ArtifactCacheHelper.EncodeCachePathSegments() — both in Bicep.Core/Registry/. ExternalArtifactRegistry and ExtensionTypeLoaderProvider now call the same shared methods. Zero duplicated locking or path encoding code.
| var extension = WellKnownExtension.TryGet(extensionName) | ||
| ?? throw new ArgumentException($"Unknown extension '{extensionName}'. Well-known extensions: {string.Join(", ", WellKnownExtension.All.Select(e => e.Name))}"); | ||
|
|
||
| var cacheKey = $"{extensionName}:{tag}"; |
There was a problem hiding this comment.
Why not just key this off the full OCI artifact address? (e.g. br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0)
There was a problem hiding this comment.
Fixed. Cache key is now $"{extension.OciReference}:{tag}" (e.g., br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0).
| catch (Exception ex) | ||
| { | ||
| Trace.WriteLine($"Failed to get tags for extension '{extension.Name}': {ex.Message}"); | ||
| extensions.Add(new ExtensionInfo(extension.Name, extension.Description, extension.OciReference, [])); |
There was a problem hiding this comment.
It feels misleading to return an empty list on failure. Perhaps we should instead return the failure message, so that it can be understood?
There was a problem hiding this comment.
Added nullable Error field to ExtensionInfo. On tag discovery failure, the error message is returned alongside empty tags so the caller can understand why.
| var cloud = GetCloudConfiguration(); | ||
| var acrManager = new AzureContainerRegistryManager(clientFactory); | ||
| var address = new SimpleOciAddress(extension.Registry, extension.Repository, tag, null); | ||
|
|
||
| var result = await acrManager.PullArtifactAsync(cloud, address); | ||
| var mainLayer = result.GetMainLayer(); | ||
|
|
||
| // Write to file-system cache using the same locking pattern as OciArtifactRegistry/ExternalArtifactRegistry | ||
| await WriteToCacheWithLockAsync(extension, tag, mainLayer.Data); | ||
|
|
||
| return ArchivedTypeLoader.FromStream(mainLayer.Data.ToStream()); |
There was a problem hiding this comment.
Shouldn't we be able to use the IModuleDispatcher interface here as-is instead of calling into the acrManager directly?
For example, it exposes the following methods which are used in the main Bicep code to handle artifact restoration:
To pull down and cache artifact(s):
Task<bool> RestoreArtifacts(IEnumerable<ArtifactReference> references, bool forceRestore);
To check whether restoration happened correctly:
ArtifactRestoreStatus GetArtifactRestoreStatus(ArtifactReference reference, out DiagnosticBuilder.DiagnosticBuilderDelegate? errorDetailBuilder);
To get a path to the entry-point file (the .tar.gz file in this case)
ResultWithDiagnosticBuilder<IFileHandle> TryGetLocalArtifactEntryPointFileHandle(ArtifactReference reference);
There was a problem hiding this comment.
We cannot easily use IModuleDispatcher because all its methods rely on ArtifactReference, which requires a BicepSourceFile in its constructor. In this scenario there is no source file — the MCP server works with extension references directly without a compilation context.
The goal for this PR is just to allow #19453 to use `IModuleDispatcher` directly, rather than having to duplicate some of the registry logic
Co-authored-by: Copilot <copilot@github.com>
Description
Extends the Bicep MCP server tools to support Microsoft Graph extension resource types by pulling type definitions from OCI artifacts on MCR. This enables AI assistants to discover, list, and retrieve schemas for MsGraph resources (e.g., Microsoft.Graph/applications@v1.0) alongside existing Azure resource types.
New MCP tools:
list_well_known_extensions— Lists well-known Bicep extensions (MicrosoftGraph, MicrosoftGraphBeta) with dynamically-discovered version tags from MCR. Returns OCI artifact references for use with extension resource type tools.list_extension_resource_types— Lists all available resource types for a Bicep extension, given a canonical OCI artifact reference (e.g.,br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0).get_extension_resource_type_schema— Retrieves the JSON schema for a specific extension resource type and API version.Renamed MCP tools (no behavior change):
list_azure_resource_types(renamed fromlist_az_resource_types_for_provider) — Lists Azure resource types for a provider namespace.get_azure_resource_type_schema(renamed fromget_az_resource_type_schema) — Gets the schema for an Azure resource type.Checklist
Microsoft Reviewers: Open in CodeFlow