diff --git a/samples/cs/README.md b/samples/cs/README.md
index ad10a3c6..9207fe40 100644
--- a/samples/cs/README.md
+++ b/samples/cs/README.md
@@ -18,6 +18,7 @@ Both packages provide the same APIs, so the same source code works on all platfo
| [tool-calling-foundry-local-sdk](tool-calling-foundry-local-sdk/) | Use tool calling with native chat completions. |
| [tool-calling-foundry-local-web-server](tool-calling-foundry-local-web-server/) | Use tool calling with the local web server. |
| [model-management-example](model-management-example/) | Manage models, variant selection, and updates. |
+| [private-catalog](private-catalog/) | Register a private MDS-backed catalog with `AddCatalogAsync`, list public + private models, and chat with one. |
| [tutorial-chat-assistant](tutorial-chat-assistant/) | Build an interactive chat assistant (tutorial). |
| [tutorial-document-summarizer](tutorial-document-summarizer/) | Summarize documents with AI (tutorial). |
| [tutorial-tool-calling](tutorial-tool-calling/) | Create a tool-calling assistant (tutorial). |
diff --git a/samples/cs/private-catalog/PrivateCatalog.csproj b/samples/cs/private-catalog/PrivateCatalog.csproj
new file mode 100644
index 00000000..a49711c2
--- /dev/null
+++ b/samples/cs/private-catalog/PrivateCatalog.csproj
@@ -0,0 +1,72 @@
+
+
+
+ Exe
+ enable
+ enable
+
+
+
+
+ net9.0-windows10.0.26100
+ false
+ ARM64;x64
+ None
+ false
+
+
+
+
+ net9.0
+
+
+
+ $(NETCoreSdkRuntimeIdentifier)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+ $(MSBuildThisFileDirectory)..\..\..\..\neutron-server\artifacts\bin\Core\debug_net9.0_win-x64\Microsoft.AI.Foundry.Local.Core.dll
+
+
+
+
+
+ <_OverrideDest Include="$(OutputPath)" />
+ <_OverrideDest Include="$(OutputPath)$(RuntimeIdentifier)\" Condition="'$(RuntimeIdentifier)' != ''" />
+ <_OverrideDest Include="$(PublishDir)" Condition="'$(PublishDir)' != ''" />
+
+
+
+
+
diff --git a/samples/cs/private-catalog/PrivateCatalog.sln b/samples/cs/private-catalog/PrivateCatalog.sln
new file mode 100644
index 00000000..6d66e4fa
--- /dev/null
+++ b/samples/cs/private-catalog/PrivateCatalog.sln
@@ -0,0 +1,34 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrivateCatalog", "PrivateCatalog.csproj", "{B1C23D45-6789-4ABC-DEF0-123456789ABC}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|Any CPU.Build.0 = Debug|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x64.ActiveCfg = Debug|x64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x64.Build.0 = Debug|x64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x86.ActiveCfg = Debug|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Debug|x86.Build.0 = Debug|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|Any CPU.ActiveCfg = Release|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|Any CPU.Build.0 = Release|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x64.ActiveCfg = Release|x64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x64.Build.0 = Release|x64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x86.ActiveCfg = Release|ARM64
+ {B1C23D45-6789-4ABC-DEF0-123456789ABC}.Release|x86.Build.0 = Release|ARM64
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/samples/cs/private-catalog/Program.cs b/samples/cs/private-catalog/Program.cs
new file mode 100644
index 00000000..40f1aeca
--- /dev/null
+++ b/samples/cs/private-catalog/Program.cs
@@ -0,0 +1,254 @@
+using Microsoft.AI.Foundry.Local;
+using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+// ---------------------------------------------------------------------------
+// Private Catalog sample — registers a customer MDS catalog with a self-signed
+// JWT, lists models (public + private), lets you pick one, and runs a streaming
+// chat completion.
+//
+// Usage:
+// PrivateCatalog (interactive — pick from list)
+// PrivateCatalog --model phi-4 (pick by alias)
+// PrivateCatalog --model Phi-4-generic-cpu:1 (pick by exact variant id)
+// PrivateCatalog --list (list models and exit)
+// PrivateCatalog --customer cust2 (override MdsCustomer)
+// PrivateCatalog --prompt "Hello!" (custom prompt)
+// ---------------------------------------------------------------------------
+string? cliModel = null;
+string cliPrompt = "Why is the sky blue?";
+bool listOnly = false;
+string? cliCustomer = null;
+
+for (int i = 0; i < args.Length; i++)
+{
+ switch (args[i])
+ {
+ case "-m":
+ case "--model":
+ if (i + 1 < args.Length) cliModel = args[++i];
+ else { Console.WriteLine("Error: --model requires a value."); return; }
+ break;
+ case "-p":
+ case "--prompt":
+ if (i + 1 < args.Length) cliPrompt = args[++i];
+ else { Console.WriteLine("Error: --prompt requires a value."); return; }
+ break;
+ case "-c":
+ case "--customer":
+ if (i + 1 < args.Length) cliCustomer = args[++i];
+ else { Console.WriteLine("Error: --customer requires a value."); return; }
+ break;
+ case "-l":
+ case "--list":
+ listOnly = true;
+ break;
+ case "-h":
+ case "--help":
+ Console.WriteLine("Usage: PrivateCatalog [options]");
+ Console.WriteLine(" -m, --model Model alias or variant id");
+ Console.WriteLine(" -c, --customer Customer name (default: from appsettings)");
+ Console.WriteLine(" -p, --prompt Prompt (default: \"Why is the sky blue?\")");
+ Console.WriteLine(" -l, --list List models and exit");
+ return;
+ }
+}
+
+CancellationToken ct = default;
+
+// --- Load config ---
+var settings = JsonDocument.Parse(
+ File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))).RootElement;
+var mdsHost = settings.GetProperty("MdsHost").GetString()!;
+var mdsCustomer = cliCustomer ?? settings.GetProperty("MdsCustomer").GetString()!;
+var mdsKeyDir = settings.GetProperty("MdsKeyDir").GetString()!;
+
+// --- Derive customer resources (same convention as mds/scripts/download_model.py) ---
+var safeName = mdsCustomer.ToLower().Replace(" ", "").Replace("-", "");
+var registryName = $"mds-{mdsCustomer.ToLower()}-registry";
+var issuer = $"https://mds{safeName}jwks.blob.core.windows.net/jwks";
+var kid = $"mds-{mdsCustomer.ToLower()}-key-1";
+var keyPath = Path.Combine(mdsKeyDir, $"{mdsCustomer.ToLower()}-key.pem");
+
+if (!File.Exists(keyPath))
+{
+ Console.WriteLine($"Error: Private key not found at {keyPath}");
+ Console.WriteLine("Run mds/scripts/create_jwks_storage.py --customer first.");
+ return;
+}
+
+var jwt = SignJwt(keyPath, kid, issuer, registryName);
+Console.WriteLine($"Signed JWT for '{mdsCustomer}' (registry={registryName})");
+
+// --- Init Foundry Local ---
+await FoundryLocalManager.CreateAsync(
+ new Configuration { AppName = "private_catalog_sample", LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information },
+ Utils.GetAppLogger());
+var mgr = FoundryLocalManager.Instance;
+Console.WriteLine("Registering execution providers...");
+await mgr.DownloadAndRegisterEpsAsync();
+Console.WriteLine("Done.");
+
+// --- Register private catalog (falls back to public-only if it fails) ---
+var catalog = await mgr.GetCatalogAsync();
+
+Console.WriteLine($"\nRegistering private catalog at {mdsHost}...");
+bool privateRegistered = false;
+try
+{
+ await catalog.AddCatalogAsync("private", new Uri(mdsHost),
+ options: new Dictionary
+ {
+ ["BearerToken"] = jwt,
+ ["Audience"] = "model-distribution-service",
+ });
+ privateRegistered = true;
+ Console.WriteLine("Private catalog registered.");
+}
+catch (Exception ex)
+{
+ Console.WriteLine($"Warning: could not register private catalog ({ex.Message}).");
+ Console.WriteLine("Continuing with the public catalog only.");
+}
+
+// --- List models (grouped by origin) ---
+// Classify by the model's Uri: private MDS models have an
+// `azureml://registries//...` Uri, public ones point to the
+// built-in Azure ML registry. This is robust to neutron persisting
+// registered catalogs across runs (which would break a pre-snapshot approach).
+var allModels = await catalog.ListModelsAsync();
+var allVariants = allModels.SelectMany(m => m.Variants).ToList();
+
+bool IsPrivate(IModel v) =>
+ v.Info.Uri?.Contains(registryName, StringComparison.OrdinalIgnoreCase) == true;
+
+var publicVariants = allVariants.Where(v => !IsPrivate(v)).ToList();
+var privateVariants = allVariants.Where(IsPrivate).ToList();
+
+// Rebuild in display order (public first, then private) so numbered selection
+// in the interactive picker maps 1:1 to what's printed.
+allVariants = publicVariants.Concat(privateVariants).ToList();
+
+int idx = 0;
+Console.WriteLine($"\n=== Public Models ({publicVariants.Count}) ===");
+foreach (var v in publicVariants)
+ Console.WriteLine($" [{++idx}] {v.Alias} ({v.Id})");
+
+if (privateRegistered)
+{
+ Console.WriteLine($"\n=== Private Models ({privateVariants.Count}) ===");
+ if (privateVariants.Count == 0)
+ Console.WriteLine(" (none)");
+ foreach (var v in privateVariants)
+ Console.WriteLine($" [{++idx}] {v.Alias} ({v.Id})");
+}
+
+if (listOnly) return;
+
+// --- Resolve a model (from --model or interactive prompt) ---
+IModel? model = null;
+string? input = cliModel;
+
+if (string.IsNullOrWhiteSpace(input))
+{
+ Console.Write("\nEnter model number, alias, or variant id (q to quit): ");
+ input = Console.ReadLine()?.Trim();
+ if (string.IsNullOrEmpty(input) || input.Equals("q", StringComparison.OrdinalIgnoreCase)) return;
+
+ if (int.TryParse(input, out int n) && n >= 1 && n <= allVariants.Count)
+ input = allVariants[n - 1].Id;
+}
+
+model = await ResolveModel(catalog, allVariants, input!);
+if (model == null)
+{
+ Console.WriteLine($"\nModel '{input}' not found.");
+ return;
+}
+Console.WriteLine($"\nSelected: {model.Id}");
+
+// --- Download / load / chat ---
+await model.DownloadAsync(p =>
+{
+ Console.Write($"\rDownloading: {p:F1}%");
+ if (p >= 100f) Console.WriteLine();
+});
+
+Console.Write($"Loading {model.Id}...");
+await model.LoadAsync();
+Console.WriteLine(" done.");
+
+var chat = await model.GetChatClientAsync();
+var messages = new List { new() { Role = "user", Content = cliPrompt } };
+
+Console.WriteLine("Chat completion:");
+await foreach (var chunk in chat.CompleteChatStreamingAsync(messages, ct))
+{
+ Console.Write(chunk.Choices[0].Message.Content);
+ Console.Out.Flush();
+}
+Console.WriteLine();
+
+await model.UnloadAsync();
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+static async Task ResolveModel(
+ ICatalog catalog, List allVariants, string input)
+{
+ // Exact variant id
+ var model = await catalog.GetModelVariantAsync(input);
+ if (model != null) return model;
+
+ // Alias (prefer generic-cpu variant)
+ var resolved = await catalog.GetModelAsync(input);
+ if (resolved != null)
+ {
+ var pick = resolved.Variants.FirstOrDefault(v =>
+ v.Id.Contains("generic-cpu", StringComparison.OrdinalIgnoreCase))
+ ?? resolved.Variants[0];
+ return await catalog.GetModelVariantAsync(pick.Id);
+ }
+
+ // Substring match against the combined list
+ var match = allVariants.FirstOrDefault(v =>
+ v.Id.Contains(input, StringComparison.OrdinalIgnoreCase) ||
+ v.Alias.Contains(input, StringComparison.OrdinalIgnoreCase));
+ return match != null ? await catalog.GetModelVariantAsync(match.Id) : null;
+}
+
+static string SignJwt(string pemPath, string kid, string issuer, string registryName)
+{
+ using var rsa = RSA.Create();
+ rsa.ImportFromPem(File.ReadAllText(pemPath));
+
+ var now = DateTimeOffset.UtcNow;
+ var header = JsonSerializer.Serialize(new { alg = "RS256", typ = "JWT", kid });
+ var payload = JsonSerializer.Serialize(new Dictionary
+ {
+ ["iss"] = issuer,
+ ["sub"] = "foundry-local-sample",
+ ["aud"] = "model-distribution-service",
+ ["iat"] = now.ToUnixTimeSeconds(),
+ ["exp"] = now.AddHours(1).ToUnixTimeSeconds(),
+ ["registry_name"] = registryName,
+ ["entitlements"] = new Dictionary
+ {
+ ["models"] = new[] { "*" },
+ ["versions"] = new[] { "*" },
+ },
+ });
+
+ var h = B64Url(Encoding.UTF8.GetBytes(header));
+ var p = B64Url(Encoding.UTF8.GetBytes(payload));
+ var sig = rsa.SignData(Encoding.UTF8.GetBytes($"{h}.{p}"),
+ HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ return $"{h}.{p}.{B64Url(sig)}";
+}
+
+static string B64Url(byte[] data) =>
+ Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
diff --git a/samples/cs/private-catalog/README.md b/samples/cs/private-catalog/README.md
new file mode 100644
index 00000000..1e110123
--- /dev/null
+++ b/samples/cs/private-catalog/README.md
@@ -0,0 +1,81 @@
+# Private Catalog (C#)
+
+End-to-end sample: register a customer MDS catalog with Foundry Local using a
+self-signed RS256 JWT, list public + private models, download one, and run a
+streaming chat completion.
+
+## Prerequisites
+
+- .NET 9 SDK
+- Windows x64 (other RIDs work if you adjust `-r`)
+- A customer provisioned in MDS (registry + JWKS). See
+ [mds/docs/CUSTOMER_ONBOARDING.md](../../../../mds/docs/CUSTOMER_ONBOARDING.md).
+- The customer's **private key** (`-key.pem`) available locally.
+ The matching JWKS must already be published at
+ `https://mdsjwks.blob.core.windows.net/jwks/.well-known/jwks.json`.
+- A running Foundry Local (`neutron`) that supports `AddCatalogAsync`.
+ If it doesn't, the sample falls back to the public catalog only.
+
+## Configure
+
+Edit [appsettings.json](appsettings.json):
+
+```json
+{
+ "MdsHost": "https://mds-web-app-staging.azurewebsites.net",
+ "MdsCustomer": "emmanueltest1",
+ "MdsKeyDir": "C:/Users/eassumang/work/mds/scripts"
+}
+```
+
+- `MdsHost` — MDS endpoint (staging or prod).
+- `MdsCustomer` — customer name. Used to derive the registry
+ (`mds--registry`), JWKS URL, and key file name.
+- `MdsKeyDir` — folder containing `-key.pem`.
+
+## Build
+
+From this folder:
+
+```powershell
+dotnet build .\PrivateCatalog.csproj -r win-x64
+```
+
+> **Do not use `dotnet run`.** It rewrites DLLs in the output folder and
+> breaks the private-catalog registration path in the copied neutron binaries.
+> Always launch the `.exe` directly.
+
+## Run
+
+```powershell
+.\bin\Debug\net9.0-windows10.0.26100\win-x64\PrivateCatalog.exe
+```
+
+
+
+## What it does
+
+1. Loads `appsettings.json` and derives the customer's registry, issuer, and
+ key path.
+2. Signs an RS256 JWT with claims:
+ `iss`, `sub`, `aud=model-distribution-service`, `iat`, `exp`,
+ `registry_name`, `entitlements={models:["*"], versions:["*"]}`.
+3. Initializes Foundry Local and registers execution providers.
+4. Calls `catalog.AddCatalogAsync("private", mdsHost, { BearerToken, Audience })`.
+ If it fails (e.g. older neutron without this API), falls back to public-only.
+5. Lists all models, partitioned by `Uri`:
+ - **Public**: built-in Azure ML registry
+ - **Private**: `azureml://registries/mds--registry/...`
+6. Prompts you to pick one, downloads it, loads it, and streams a chat
+ completion.
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| `Private key not found at ...` | `MdsKeyDir` or customer name wrong | Check [appsettings.json](appsettings.json); ensure `-key.pem` exists |
+| `Warning: could not register private catalog (Unknown command)` | Neutron build predates `AddCatalogAsync` | Use a newer neutron; sample continues with public-only |
+| `401 Invalid token issuer` | JWKS not yet published, or wrong issuer URL | Verify `https://mdsjwks.blob.core.windows.net/jwks/.well-known/jwks.json` returns your key |
+| Private model appears in **Public** section | Model's registry Uri is `local://...` | Re-upload with `mds/scripts/upload_model.py` so registry stores proper blob info |
+| `Failed to download model` | Same as above, or SAS generation error | Check MDS logs; confirm `blob_prefix` tag on the registry entry |
+| `dotnet run` seems to break things | It does — see note above | Run `.\...\PrivateCatalog.exe` directly |
diff --git a/samples/cs/private-catalog/appsettings.json b/samples/cs/private-catalog/appsettings.json
new file mode 100644
index 00000000..aab3dbb2
--- /dev/null
+++ b/samples/cs/private-catalog/appsettings.json
@@ -0,0 +1,5 @@
+{
+ "MdsHost": "https://mds-model-distribution.azurewebsites.net",
+ "MdsCustomer": "your-customer-name",
+ "MdsKeyDir": "./keys"
+}
diff --git a/sdk/cs/src/Catalog.cs b/sdk/cs/src/Catalog.cs
index f33dcaff..e0d3400d 100644
--- a/sdk/cs/src/Catalog.cs
+++ b/sdk/cs/src/Catalog.cs
@@ -249,4 +249,74 @@ public void Dispose()
{
_lock.Dispose();
}
+
+ public async Task AddCatalogAsync(string name, Uri uri,
+ Dictionary? options = null,
+ CancellationToken? ct = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentNullException.ThrowIfNull(uri);
+
+ if (uri.Scheme != "https" && uri.Scheme != "http")
+ {
+ throw new ArgumentException($"Catalog URI must use http or https scheme, got '{uri.Scheme}'.", nameof(uri));
+ }
+
+ if (options != null && options.TryGetValue("TokenEndpoint", out var tokenEndpoint) && tokenEndpoint != null)
+ {
+ if (!Uri.TryCreate(tokenEndpoint, UriKind.Absolute, out var parsedEndpoint))
+ {
+ throw new ArgumentException($"Token endpoint is not a valid URL: '{tokenEndpoint}'.");
+ }
+ if (parsedEndpoint.Scheme != "https" && parsedEndpoint.Scheme != "http")
+ {
+ throw new ArgumentException($"Token endpoint must use http or https scheme, got '{parsedEndpoint.Scheme}'.");
+ }
+ }
+
+ await Utils.CallWithExceptionHandling(async () =>
+ {
+ // Start from caller-supplied options, then overlay Name/Uri/Type so they
+ // can't be silently overridden via options. Callers can still pass
+ // "Type" in options to target a non-default catalog implementation;
+ // the explicit assignment below honours that when present.
+ var p = new Dictionary(options ?? new Dictionary())
+ {
+ ["Name"] = name,
+ ["Uri"] = uri.ToString(),
+ };
+ if (!p.TryGetValue("Type", out var typeValue) || string.IsNullOrEmpty(typeValue))
+ {
+ p["Type"] = "AzurePrivate";
+ }
+ var request = new CoreInteropRequest { Params = p };
+
+ var result = await _coreInterop.ExecuteCommandAsync("add_catalog", request, ct)
+ .ConfigureAwait(false);
+ if (result.Error != null)
+ {
+ throw new FoundryLocalException($"Error adding catalog '{name}': {result.Error}", _logger);
+ }
+
+ // Force model list refresh to pick up new catalog's models
+ InvalidateCache();
+ await UpdateModels(ct).ConfigureAwait(false);
+ }, $"Error adding catalog '{name}'.", _logger).ConfigureAwait(false);
+ }
+
+ public async Task> GetCatalogNamesAsync(CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(async () =>
+ {
+ CoreInteropRequest? input = null;
+ var result = await _coreInterop.ExecuteCommandAsync("get_catalog_names", input, ct)
+ .ConfigureAwait(false);
+ if (result.Error != null)
+ {
+ throw new FoundryLocalException($"Error getting catalog names: {result.Error}", _logger);
+ }
+
+ return JsonSerializer.Deserialize(result.Data ?? "[]", JsonSerializationContext.Default.ListString) ?? [];
+ }, "Error getting catalog names.", _logger).ConfigureAwait(false);
+ }
}
diff --git a/sdk/cs/src/Detail/CoreInterop.cs b/sdk/cs/src/Detail/CoreInterop.cs
index b88f5597..ff8e3cc3 100644
--- a/sdk/cs/src/Detail/CoreInterop.cs
+++ b/sdk/cs/src/Detail/CoreInterop.cs
@@ -324,7 +324,6 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput,
if (response.Error != IntPtr.Zero && response.ErrorLength > 0)
{
result.Error = Marshal.PtrToStringUTF8(response.Error, response.ErrorLength)!;
- _logger.LogDebug($"Input:{commandInput ?? "null"}");
_logger.LogDebug($"Command: {commandName} Error: {result.Error}");
}
@@ -342,7 +341,7 @@ public Response ExecuteCommandImpl(string commandName, string? commandInput,
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
- var msg = $"Error executing command '{commandName}' with input {commandInput ?? "null"}";
+ var msg = $"Error executing command '{commandName}'";
throw new FoundryLocalException(msg, ex, _logger);
}
}
diff --git a/sdk/cs/src/Detail/JsonSerializationContext.cs b/sdk/cs/src/Detail/JsonSerializationContext.cs
index 0fe5e677..d0180d5a 100644
--- a/sdk/cs/src/Detail/JsonSerializationContext.cs
+++ b/sdk/cs/src/Detail/JsonSerializationContext.cs
@@ -1,4 +1,4 @@
-// --------------------------------------------------------------------------------------------------------------------
+// --------------------------------------------------------------------------------------------------------------------
//
// Copyright (c) Microsoft. All rights reserved.
//
@@ -41,6 +41,7 @@ namespace Microsoft.AI.Foundry.Local.Detail;
// which has AOT-incompatible JsonConverters, so we only register the raw deserialization type) ---
[JsonSerializable(typeof(LiveAudioTranscriptionRaw))]
[JsonSerializable(typeof(CoreErrorResponse))]
+[JsonSerializable(typeof(List))] // catalog names
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
internal partial class JsonSerializationContext : JsonSerializerContext
diff --git a/sdk/cs/src/ICatalog.cs b/sdk/cs/src/ICatalog.cs
index 4dca8e7d..4ad5b13e 100644
--- a/sdk/cs/src/ICatalog.cs
+++ b/sdk/cs/src/ICatalog.cs
@@ -1,4 +1,4 @@
-// --------------------------------------------------------------------------------------------------------------------
+// --------------------------------------------------------------------------------------------------------------------
//
// Copyright (c) Microsoft. All rights reserved.
//
@@ -61,4 +61,22 @@ public interface ICatalog
/// Optional CancellationToken.
/// The latest version of the model. Will match the input if it is the latest version.
Task GetLatestVersionAsync(IModel model, CancellationToken? ct = null);
+
+ ///
+ /// Add a private model catalog. The model list is refreshed automatically,
+ /// so models from the new catalog are available as soon as this call returns.
+ ///
+ /// Display name for the catalog (e.g. "my-private-catalog").
+ /// Base URL of the private catalog service.
+ /// Optional authentication and configuration parameters (e.g. ClientId, ClientSecret, BearerToken, TokenEndpoint, Audience). Pass "Type" to override the default catalog type ("AzurePrivate").
+ /// Optional CancellationToken.
+ Task AddCatalogAsync(string name, Uri uri, Dictionary? options = null,
+ CancellationToken? ct = null);
+
+ ///
+ /// Get the names of all registered catalogs.
+ ///
+ /// Optional CancellationToken.
+ /// List of catalog name strings.
+ Task> GetCatalogNamesAsync(CancellationToken? ct = null);
}
diff --git a/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs
new file mode 100644
index 00000000..7858f317
--- /dev/null
+++ b/sdk/cs/test/FoundryLocal.Tests/CatalogManagementTests.cs
@@ -0,0 +1,59 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft. All rights reserved.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace Microsoft.AI.Foundry.Local.Tests;
+
+using System.Text.Json;
+using Microsoft.AI.Foundry.Local.Detail;
+using Moq;
+
+public class CatalogManagementTests
+{
+ private static async Task CreateCatalogWithIntercepts(
+ List extra)
+ {
+ var logger = Utils.CreateCapturingLoggerMock([]);
+ var lm = new Mock();
+ lm.Setup(m => m.ListLoadedModelsAsync(It.IsAny())).ReturnsAsync(Array.Empty());
+
+ List intercepts =
+ [
+ new() { CommandName = "get_catalog_name", ResponseData = "Test" },
+ new() { CommandName = "get_model_list",
+ ResponseData = JsonSerializer.Serialize(Utils.TestCatalog.TestCatalog,
+ JsonSerializationContext.Default.ListModelInfo) },
+ new() { CommandName = "get_cached_models", ResponseData = "[]" },
+ .. extra
+ ];
+
+ var ci = Utils.CreateCoreInteropWithIntercept(Utils.CoreInterop, intercepts);
+ return await Catalog.CreateAsync(lm.Object, ci.Object, logger.Object);
+ }
+
+ [Test]
+ public async Task Test_AddCatalog()
+ {
+ using var catalog = await CreateCatalogWithIntercepts(
+ [
+ new() { CommandName = "add_catalog", ResponseData = "OK" }
+ ]);
+
+ await catalog.AddCatalogAsync("priv", new Uri("https://mds.example.com"),
+ new Dictionary { ["ClientId"] = "id", ["ClientSecret"] = "secret" });
+ await Assert.That(catalog).IsNotNull();
+ }
+
+ [Test]
+ public async Task Test_GetCatalogNames()
+ {
+ using var catalog = await CreateCatalogWithIntercepts(
+ [new() { CommandName = "get_catalog_names", ResponseData = "[\"public\",\"private\"]" }]);
+
+ var names = await catalog.GetCatalogNamesAsync();
+ await Assert.That(names.Count).IsEqualTo(2);
+ await Assert.That(names).Contains("private");
+ }
+}