Skip to content

Add MCP Tools to support Extension Resource Types#19453

Merged
gary-x-li merged 18 commits intomainfrom
ligar/graph-extension
Apr 23, 2026
Merged

Add MCP Tools to support Extension Resource Types#19453
gary-x-li merged 18 commits intomainfrom
ligar/graph-extension

Conversation

@gary-x-li
Copy link
Copy Markdown
Contributor

@gary-x-li gary-x-li commented Apr 17, 2026

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 from list_az_resource_types_for_provider) — Lists Azure resource types for a provider namespace.
  • get_azure_resource_type_schema (renamed from get_az_resource_type_schema) — Gets the schema for an Azure resource type.

Checklist

Microsoft Reviewers: Open in CodeFlow

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

Test this change out locally with the following install scripts (Action run 24845757969)

VSCode
  • Mac/Linux
    bash <(curl -Ls https://aka.ms/bicep/nightly-vsix.sh) --run-id 24845757969
  • Windows
    iex "& { $(irm https://aka.ms/bicep/nightly-vsix.ps1) } -RunId 24845757969"
Azure CLI
  • Mac/Linux
    bash <(curl -Ls https://aka.ms/bicep/nightly-cli.sh) --run-id 24845757969
  • Windows
    iex "& { $(irm https://aka.ms/bicep/nightly-cli.ps1) } -RunId 24845757969"

}
}
```
1. Use `ListPublishedExtensions` to discover available extensions and their versions before configuring `bicepconfig.json`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as the client is concerned, they'll only see the tool names in snake case, since the MCP library converts them all:

Suggested change
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`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread src/Bicep.McpServer/BicepTools.cs Outdated

[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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed and added explaination.


namespace Bicep.McpServer.Extensions;

public class ExtensionTypeLoaderProvider
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there not an existing method to handle this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Bicep.McpServer/BicepTools.cs Outdated
Comment on lines +84 to +85
[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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also return the OCI path format for the extension?

E.g. br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning OciReference now.

Comment thread src/Bicep.McpServer/BicepTools.cs Outdated
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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Azure and Extension tools are separated now.

@gary-x-li gary-x-li changed the title Ligar/graph extension Add MCP Tools to support Extension Resource Types Apr 17, 2026

public TypesDefinitionResult LoadSingleResourceType(string fullyQualifiedResourceType, string apiVersion)
{
var loader = new AzTypeLoader();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

.AddSingleton<AzResourceTypeLoader>(provider => new(new AzTypeLoader()))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Registered AzTypeLoader as a singleton ITypeLoader in DI — both AzResourceTypeLoader and ResourceVisitor now share the same instance. No more new AzTypeLoader() per call.

Comment thread src/Bicep.McpServer/BicepTools.cs Outdated
return new ResourceTypeSchemaResult(JsonSchemaWriter.Write(typesDefinition));
}

[McpServerTool(Title = "List extension resource types", Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true, UseStructuredContent = true)]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should OpenWorld be true here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Set OpenWorld = true on both ListExtensionResourceTypes and GetExtensionResourceTypeSchema since they make network calls to MCR.

Comment on lines +109 to +225
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;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +92 to +95
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}";
Copy link
Copy Markdown
Member

@anthony-c-martin anthony-c-martin Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Cache key is now $"{extension.OciReference}:{tag}" (e.g., br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0).

Comment thread src/Bicep.McpServer/BicepTools.cs Outdated
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, []));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels misleading to return an empty list on failure. Perhaps we should instead return the failure message, so that it can be understood?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added nullable Error field to ExtensionInfo. On tag discovery failure, the error message is returned alongside empty tags so the caller can understand why.

Comment on lines +98 to +108
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());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

gary-x-li pushed a commit that referenced this pull request Apr 23, 2026
The goal for this PR is just to allow #19453 to use `IModuleDispatcher`
directly, rather than having to duplicate some of the registry logic
Gary Li and others added 3 commits April 23, 2026 10:27
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
@gary-x-li gary-x-li merged commit 769f3cb into main Apr 23, 2026
41 checks passed
@gary-x-li gary-x-li deleted the ligar/graph-extension branch April 23, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants