diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 2361c0713..bf09f0114 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -165,6 +165,13 @@
# ServiceLabel: %area-Authorization
# ServiceOwners: @vurhanau
+# PRLabel: %area-Deploy
+/src/Areas/Deploy/ @qianwens @xiaofanzhou @Azure/azure-mcp
+/src/Areas/Quota/ @qianwens @xiaofanzhou @Azure/azure-mcp
+
+# ServiceLabel: %area-Deploy
+# ServiceOwners: @qianwens @xiaofanzhou
+
# PRLabel: %area-LoadTesting
/areas/loadtesting/ @nishtha489 @knarayanana @krchanda @johnsta @Azure/azure-mcp
diff --git a/.vscode/cspell.json b/.vscode/cspell.json
index 1ab298ebe..bef9feac5 100644
--- a/.vscode/cspell.json
+++ b/.vscode/cspell.json
@@ -189,127 +189,202 @@
],
"words": [
"1espt",
+ "acaenvironment",
"aarch",
"accesspolicy",
"ADMINPROVIDER",
+ "akscluster",
+ "aksservice",
"alcoop",
"Apim",
"AOAI",
"appconfig",
+ "appservice",
+ "australiacentral",
+ "australiaeast",
+ "australiasoutheast",
"Autorenewable",
+ "azapi",
+ "azcli",
"azext",
"azmcp",
"azqr",
"azsdk",
+ "aztfmod",
+ "azureaisearch",
+ "azureaiservices",
+ "azureapplicationinsights",
+ "azureappservice",
"azurebestpractices",
"azureblob",
+ "azurebotservice",
+ "azurecacheforredis",
+ "azurecaf",
+ "azurecontainerapp",
+ "azurecosmosdb",
+ "azuredatabaseformysql",
+ "azuredatabaseforpostgresql",
+ "azuredocs",
"azurefunctions",
"azureisv",
+ "azurekeyvault",
"azuremcp",
- "azureresources",
+ "azureopenai",
+ "azureprivateendpoint",
"azureresourcegroups",
- "azureterraformbestpractices",
+ "azureresources",
+ "azurerm",
"azuresdk",
+ "azureservicebus",
+ "azuresignalrservice",
+ "azuresqldatabase",
+ "azurestaticwebapps",
+ "azurestorage",
+ "azurestorageaccount",
+ "azureterraformbestpractices",
"azuretools",
+ "azurevirtualnetwork",
+ "azurewebpubsub",
"azurewebsites",
+ "backendservice",
"bdylan",
"bestpractices",
"bicepschema",
"binutils",
+ "brazilsouth",
+ "brazilsoutheast",
"breathability",
"Byol",
+ "canadacentral",
+ "canadaeast",
+ "centralindia",
+ "centralus",
+ "chilecentral",
+ "cicd",
"cloudarchitect",
"codegen",
"codeium",
"Codespace",
"codesign",
"CODEOWNERS",
+ "cognitiveservices",
+ "containerapp",
"containerapps",
"CONTENTAZUREFILECONNECTIONSTRING",
"CONTENTSHARE",
"contoso",
+ "copilotmd",
"Cosell",
"cslschema",
"csdevkit",
"cvzf",
- "dataplane",
"datalake",
+ "dataplane",
"datasource",
"datasources",
+ "dbforpostgresql",
"deallocate",
+ "DEBUGTELEMETRY",
"devcontainers",
+ "discoverability",
"Distributedtask",
- "dotnettools",
- "DEBUGTELEMETRY",
"dotenv",
+ "dotnettools",
"drawcord",
- "discoverability",
+ "eastasia",
+ "eastus2euap",
"enumerables",
"eslintcache",
+ "esrp",
+ "ESRPRELPACMANTEST",
"eventgrid",
"exfiltration",
+ "facetable",
"filefilters",
"fnames",
+ "francecentral",
+ "frontendservice",
"functionapp",
"functionapps",
+ "germanynorth",
"gethealth",
"grpcio",
"Gsaascend",
"Gsamas",
"GZRS",
"healthmodels",
+ "hnsw",
+ "hostings",
"hostpool",
"hostpools",
+ "idempotency",
+ "idtyp",
+ "indonesiacentral",
+ "israelcentral",
+ "italynorth",
+ "japaneast",
+ "japanwest",
+ "jioindiawest",
+ "jsonencode",
"jspm",
"kcsb",
"keyspace",
- "Kusto",
- "loadtest",
- "loadtesting",
- "loadtests",
- "esrp",
- "ESRPRELPACMANTEST",
- "facetable",
- "hnsw",
- "idempotency",
- "idtyp",
"keyvault",
+ "koreacentral",
+ "koreasouth",
"Kusto",
+ "kvps",
"ligar",
- "Linq",
"linkedservices",
+ "Linq",
"LINUXOS",
"LINUXPOOL",
"LINUXVMIMAGE",
"LLM",
+ "loadtest",
+ "loadtesting",
"loadtestrun",
+ "loadtests",
+ "MACOS",
+ "MACPOOL",
+ "MACVMIMAGE",
+ "malaysiawest",
+ "mexicocentral",
+ "Microbundle",
"midsole",
"monitoredresources",
"msal",
+ "MSRP",
"myaccount",
+ "myapp",
+ "mycluster",
"myfilesystem",
+ "mygroup",
"mysvc",
- "mycluster",
- "Microbundle",
- "MACOS",
- "MACPOOL",
- "MACVMIMAGE",
- "MSRP",
+ "myworkbook",
"Newtonsoft",
- "Npgsql",
+ "newzealandnorth",
"norequired",
+ "northcentralus",
+ "northeurope",
+ "norwayeast",
+ "norwaywest",
+ "Npgsql",
"npmjs",
"nuxt",
"odata",
"oidc",
"onboarded",
"openai",
+ "operationalinsights",
"packability",
"pageable",
"payg",
"paygo",
"pgrep",
"pids",
+ "piechart",
+ "polandcentral",
"portalsettings",
"predeploy",
"privatepreview",
@@ -329,11 +404,24 @@
"sessionhost",
"setparam",
"setpermission",
+ "siteextensions",
"skillset",
- "staticwebapp",
- "syslib",
"skillsets",
+ "southafricanorth",
+ "southcentralus",
+ "southeastasia",
+ "southindia",
+ "spaincentral",
+ "staticwebapp",
+ "staticwebapps",
+ "storageaccount",
+ "storageaccounts",
"submode",
+ "swedencentral",
+ "swedensouth",
+ "switzerlandnorth",
+ "switzerlandwest",
+ "syslib",
"testaccount",
"testacct",
"testfilesystem",
@@ -342,8 +430,13 @@
"testresource",
"testrun",
"testsettings",
+ "tfvars",
+ "timechart",
"timespan",
"toolsets",
+ "uaenorth",
+ "uksouth",
+ "ukwest",
"Upns",
"usersession",
"vectorizable",
@@ -351,20 +444,23 @@
"vectorizers",
"virtualdesktop",
"virtualmachines",
- "vuepress",
"Vnet",
- "vsts",
"vscodeignore",
"vsmarketplace",
+ "vsts",
+ "vuepress",
+ "westcentralus",
+ "westeurope",
"westus",
"westus2",
- "wscript",
+ "westus3",
"WINDOWSOS",
"WINDOWSPOOL",
"WINDOWSVMIMAGE",
"winget",
- "xvfb",
+ "wscript",
"Xunit",
+ "xvfb",
"operationalinsights",
"piechart",
"timechart",
diff --git a/AzureMcp.sln b/AzureMcp.sln
index 60d7c2354..ace1c0a18 100644
--- a/AzureMcp.sln
+++ b/AzureMcp.sln
@@ -309,6 +309,29 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.FunctionApp.UnitTe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.AppConfig.UnitTests", "areas\appconfig\tests\AzureMcp.AppConfig.UnitTests\AzureMcp.AppConfig.UnitTests.csproj", "{A3ADC1CC-6020-7233-DCFA-106CA917B0CD}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{546CED3D-7F74-09C6-3DFD-7EDC477A0556}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{311C78D5-1A27-A98E-17B9-D29F6B7DECD5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Deploy", "areas\deploy\src\AzureMcp.Deploy\AzureMcp.Deploy.csproj", "{EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C69FCF64-5DB8-4F7B-E427-FA8659F1F756}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Deploy.LiveTests", "areas\deploy\tests\AzureMcp.Deploy.LiveTests\AzureMcp.Deploy.LiveTests.csproj", "{FDB76269-A532-42C5-A644-D31EDC3044FE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Deploy.UnitTests", "areas\deploy\tests\AzureMcp.Deploy.UnitTests\AzureMcp.Deploy.UnitTests.csproj", "{3B1E3954-A8DB-4F39-9857-93FAF1C05376}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "quota", "quota", "{39FCE3A1-0566-13EE-296F-5B62330AD7F0}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9337CB4E-9EBA-5799-A82A-3624631C64B6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Quota", "areas\quota\src\AzureMcp.Quota\AzureMcp.Quota.csproj", "{DADE2EBB-412F-430D-A551-47D3E3CF77F6}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D6BC8A0D-6104-FA8D-53A6-10E79ED132DD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Quota.LiveTests", "areas\quota\tests\AzureMcp.Quota.LiveTests\AzureMcp.Quota.LiveTests.csproj", "{1DC07369-9133-4B02-B2DD-A7E277163CB4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureMcp.Quota.UnitTests", "areas\quota\tests\AzureMcp.Quota.UnitTests\AzureMcp.Quota.UnitTests.csproj", "{0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "virtualdesktop", "virtualdesktop", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3F159DE4-1438-4821-AA38-9BC3441661F0}"
@@ -1229,6 +1252,78 @@ Global
{A3ADC1CC-6020-7233-DCFA-106CA917B0CD}.Release|x64.Build.0 = Release|Any CPU
{A3ADC1CC-6020-7233-DCFA-106CA917B0CD}.Release|x86.ActiveCfg = Release|Any CPU
{A3ADC1CC-6020-7233-DCFA-106CA917B0CD}.Release|x86.Build.0 = Release|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x64.Build.0 = Debug|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Debug|x86.Build.0 = Debug|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x64.ActiveCfg = Release|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x64.Build.0 = Release|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x86.ActiveCfg = Release|Any CPU
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF}.Release|x86.Build.0 = Release|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x64.Build.0 = Debug|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Debug|x86.Build.0 = Debug|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x64.ActiveCfg = Release|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x64.Build.0 = Release|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x86.ActiveCfg = Release|Any CPU
+ {FDB76269-A532-42C5-A644-D31EDC3044FE}.Release|x86.Build.0 = Release|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x64.Build.0 = Debug|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Debug|x86.Build.0 = Debug|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x64.ActiveCfg = Release|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x64.Build.0 = Release|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x86.ActiveCfg = Release|Any CPU
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376}.Release|x86.Build.0 = Release|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x64.Build.0 = Debug|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Debug|x86.Build.0 = Debug|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x64.ActiveCfg = Release|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x64.Build.0 = Release|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x86.ActiveCfg = Release|Any CPU
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6}.Release|x86.Build.0 = Release|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x64.Build.0 = Debug|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Debug|x86.Build.0 = Debug|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x64.ActiveCfg = Release|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x64.Build.0 = Release|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x86.ActiveCfg = Release|Any CPU
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4}.Release|x86.Build.0 = Release|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x64.Build.0 = Debug|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Debug|x86.Build.0 = Debug|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x64.ActiveCfg = Release|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x64.Build.0 = Release|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x86.ActiveCfg = Release|Any CPU
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6}.Release|x86.Build.0 = Release|Any CPU
{7DA56A1F-EA8A-C768-9777-F26EDCA059B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DA56A1F-EA8A-C768-9777-F26EDCA059B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DA56A1F-EA8A-C768-9777-F26EDCA059B6}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -1445,6 +1540,18 @@ Global
{59A3843F-39AD-45C9-90A6-EBD40D644451} = {C1C50B06-1175-49A1-81C6-59842EEFC51B}
{5918EA72-9701-4223-B7BB-C64EB81B6351} = {C1C50B06-1175-49A1-81C6-59842EEFC51B}
{A3ADC1CC-6020-7233-DCFA-106CA917B0CD} = {7ECA6DB2-F8EF-407B-F2FD-DEF81B86CC73}
+ {546CED3D-7F74-09C6-3DFD-7EDC477A0556} = {87783708-79E3-AD60-C783-1D52BE7DE4BB}
+ {311C78D5-1A27-A98E-17B9-D29F6B7DECD5} = {546CED3D-7F74-09C6-3DFD-7EDC477A0556}
+ {EB8705D3-13E8-4FA8-9AD9-82F84AD815BF} = {311C78D5-1A27-A98E-17B9-D29F6B7DECD5}
+ {C69FCF64-5DB8-4F7B-E427-FA8659F1F756} = {546CED3D-7F74-09C6-3DFD-7EDC477A0556}
+ {FDB76269-A532-42C5-A644-D31EDC3044FE} = {C69FCF64-5DB8-4F7B-E427-FA8659F1F756}
+ {3B1E3954-A8DB-4F39-9857-93FAF1C05376} = {C69FCF64-5DB8-4F7B-E427-FA8659F1F756}
+ {39FCE3A1-0566-13EE-296F-5B62330AD7F0} = {87783708-79E3-AD60-C783-1D52BE7DE4BB}
+ {9337CB4E-9EBA-5799-A82A-3624631C64B6} = {39FCE3A1-0566-13EE-296F-5B62330AD7F0}
+ {DADE2EBB-412F-430D-A551-47D3E3CF77F6} = {9337CB4E-9EBA-5799-A82A-3624631C64B6}
+ {D6BC8A0D-6104-FA8D-53A6-10E79ED132DD} = {39FCE3A1-0566-13EE-296F-5B62330AD7F0}
+ {1DC07369-9133-4B02-B2DD-A7E277163CB4} = {D6BC8A0D-6104-FA8D-53A6-10E79ED132DD}
+ {0DDDCBF8-2DE9-466A-B94F-8A54687E1CC6} = {D6BC8A0D-6104-FA8D-53A6-10E79ED132DD}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {87783708-79E3-AD60-C783-1D52BE7DE4BB}
{3F159DE4-1438-4821-AA38-9BC3441661F0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{775A7320-3D27-4FF2-B0F1-1078A6221B7C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db062031a..592828174 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The Azure MCP Server updates automatically by default whenever a new release com
### Features Added
+- Added support for the following Azure Deploy operations and Azure Quota operations: [[#626](https://github.com/Azure/azure-mcp/pull/626)]
+ - `azmcp-deploy-app-logs-get` - Get logs from Azure applications deployed using azd.
+ - `azmcp-deploy-iac-rules-get` - Get Infrastructure as Code rules.
+ - `azmcp-deploy-pipeline-guidance-get` - Get guidance for creating CI/CD pipelines to provision Azure resources and deploy applications.
+ - `azmcp-deploy-plan-get` - Generate deployment plans to construct infrastructure and deploy applications on Azure.
+ - `azmcp-deploy-architecture-diagram-generate` - Generate Azure service architecture diagrams based on application topology.
+ - `azmcp-quota-region-availability-list` - List available Azure regions for specific resource types.
+ - `azmcp-quota-usage-check` - Check Azure resource usage and quota information for specific resource types and regions.
- Added support for listing Azure Function Apps via the command `azmcp-functionapp-list`. [[#863](https://github.com/Azure/azure-mcp/pull/863)]
- Added support for importing existing certificates into Azure Key Vault via the command `azmcp-keyvault-certificate-import`. This command accepts PFX or PEM certificate data (file path, base64, or raw PEM) with optional password protection. [[#968](https://github.com/Azure/azure-mcp/issues/968)]
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6f0a625aa..7c514649e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,7 +18,11 @@
+
+
+
+
@@ -27,10 +31,13 @@
+
+
+
@@ -58,6 +65,7 @@
+
diff --git a/README.md b/README.md
index 3df305782..e250c2c9c 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,14 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some
* Support for template discovery, template initialization, provisioning and deployment
* Cross-platform compatibility
+### 🚀 Azure Deploy
+
+* Generate azure service architecture diagrams from the source code
+* Create a deploy plan for provision and deploy the application
+* Get the application service log for a specific azd environment
+* Get the bicep or terraform file generation rules for the application
+* Get the github pipeline creation guideline for the application
+
### 🧮 Azure Foundry
* List Azure Foundry models
@@ -213,6 +221,11 @@ The Azure MCP Server supercharges your agents with Azure context. Here are some
* Scan Azure resources for compliance related recommendations
+### 📊 Azure Quota
+
+* List the available regions
+* Check the quota usage
+
### 🔴 Azure Redis Cache
* List Redis Cluster resources
diff --git a/areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs b/areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs
new file mode 100644
index 000000000..8eb69fad7
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("AzureMcp.AppConfig.UnitTests")]
+[assembly: InternalsVisibleTo("AzureMcp.AppConfig.LiveTests")]
diff --git a/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj b/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj
new file mode 100644
index 000000000..eb4727575
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/AzureMcp.Deploy.csproj
@@ -0,0 +1,30 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs
new file mode 100644
index 000000000..11ff24583
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/App/LogsGetCommand.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Commands;
+using AzureMcp.Core.Commands.Subscription;
+using AzureMcp.Core.Services.Telemetry;
+using AzureMcp.Deploy.Options;
+using AzureMcp.Deploy.Options.App;
+using AzureMcp.Deploy.Services;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Deploy.Commands.App;
+
+public sealed class LogsGetCommand(ILogger logger) : SubscriptionCommand()
+{
+ private const string CommandTitle = "Get AZD deployed App Logs";
+ private readonly ILogger _logger = logger;
+
+ private readonly Option _workspaceFolderOption = DeployOptionDefinitions.AzdAppLogOptions.WorkspaceFolder;
+ private readonly Option _azdEnvNameOption = DeployOptionDefinitions.AzdAppLogOptions.AzdEnvName;
+ private readonly Option _limitOption = DeployOptionDefinitions.AzdAppLogOptions.Limit;
+
+ public override string Name => "get";
+ public override string Title => CommandTitle;
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ public override string Description =>
+ """
+ This tool fetches logs from the Log Analytics workspace for Container Apps, App Services, and Function Apps deployed using azd. Use it after a successful azd up to check app status or troubleshoot errors in deployed applications.
+ """;
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_workspaceFolderOption);
+ command.AddOption(_azdEnvNameOption);
+ command.AddOption(_limitOption);
+ }
+
+ protected override LogsGetOptions BindOptions(ParseResult parseResult)
+ {
+ var options = base.BindOptions(parseResult);
+ options.WorkspaceFolder = parseResult.GetValueForOption(_workspaceFolderOption)!;
+ options.AzdEnvName = parseResult.GetValueForOption(_azdEnvNameOption)!;
+ options.Limit = parseResult.GetValueForOption(_limitOption);
+ return options;
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ var options = BindOptions(parseResult);
+
+ try
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return context.Response;
+ }
+
+ var deployService = context.GetService();
+ string result = await deployService.GetAzdResourceLogsAsync(
+ options.WorkspaceFolder!,
+ options.AzdEnvName!,
+ options.Subscription!,
+ options.Limit);
+
+ context.Response.Message = result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An exception occurred getting azd app logs.");
+ HandleException(context, ex);
+ }
+
+ return context.Response;
+ }
+
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs
new file mode 100644
index 000000000..f376b6eb0
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Architecture/DiagramGenerateCommand.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AzureMcp.Core.Commands;
+using AzureMcp.Core.Helpers;
+using AzureMcp.Deploy.Options;
+using AzureMcp.Deploy.Options.Architecture;
+using AzureMcp.Deploy.Services.Templates;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Deploy.Commands.Architecture;
+
+public sealed class DiagramGenerateCommand(ILogger logger) : BaseCommand()
+{
+ private const string CommandTitle = "Generate Architecture Diagram";
+ private readonly ILogger _logger = logger;
+
+ public override string Name => "generate";
+
+ private readonly Option _rawMcpToolInputOption = DeployOptionDefinitions.RawMcpToolInput.RawMcpToolInputOption;
+
+ public override string Description =>
+ "Generates an azure service architecture diagram for the application based on the provided app topology."
+ + "Call this tool when the user need recommend or design the azure architecture of their application."
+ + "Do not call this tool when the user need detailed design of the azure architecture, such as the network topology, security design, etc."
+ + "Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services, also find the environment variables that used to create the connection strings."
+ + "If it's a .NET Aspire application, check aspireManifest.json file if there is. Try your best to fulfill the input schema with your analyze result.";
+
+ public override string Title => "Generate Architecture Diagram";
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_rawMcpToolInputOption);
+ }
+
+ private DiagramGenerateOptions BindOptions(ParseResult parseResult)
+ {
+ var options = new DiagramGenerateOptions();
+ options.RawMcpToolInput = parseResult.GetValueForOption(_rawMcpToolInputOption);
+ return options;
+ }
+
+ public override Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ try
+ {
+ var options = BindOptions(parseResult);
+ var rawMcpToolInput = options.RawMcpToolInput;
+ if (string.IsNullOrWhiteSpace(rawMcpToolInput))
+ {
+ throw new ArgumentException("App topology cannot be null or empty.", nameof(options.RawMcpToolInput));
+ }
+
+ AppTopology appTopology;
+ try
+ {
+ appTopology = JsonSerializer.Deserialize(rawMcpToolInput, DeployJsonContext.Default.AppTopology)
+ ?? throw new ArgumentException("Failed to deserialize app topology.", nameof(rawMcpToolInput));
+ }
+ catch (JsonException ex)
+ {
+ throw new ArgumentException($"Invalid JSON format: {ex.Message}", nameof(rawMcpToolInput), ex);
+ }
+
+ _logger.LogInformation("Successfully parsed app topology with {ServiceCount} services", appTopology.Services.Length);
+
+ if (appTopology.Services.Length == 0)
+ {
+ _logger.LogWarning("No services detected in the app topology.");
+ context.Response.Status = 200;
+ context.Response.Message = "No service detected.";
+ return Task.FromResult(context.Response);
+ }
+
+ var chart = GenerateMermaidChart.GenerateChart(appTopology.WorkspaceFolder ?? "", appTopology);
+ if (string.IsNullOrWhiteSpace(chart))
+ {
+ throw new InvalidOperationException("Failed to generate architecture diagram. The chart content is empty.");
+ }
+
+ var usedServiceTypes = appTopology.Services
+ .SelectMany(service => service.Dependencies)
+ .Select(dep => dep.ServiceType)
+ .Where(serviceType => !string.IsNullOrWhiteSpace(serviceType))
+ .Where(serviceType => Enum.GetNames().Contains(serviceType, StringComparer.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x)
+ .ToArray();
+
+ var usedServiceTypesString = usedServiceTypes.Length > 0
+ ? string.Join(", ", usedServiceTypes)
+ : null;
+
+ var response = TemplateService.LoadTemplate("Architecture/architecture-diagram");
+ context.Response.Message = response.Replace("{{chart}}", chart)
+ .Replace("{{hostings}}", string.Join(", ", Enum.GetNames()));
+ if (!string.IsNullOrWhiteSpace(usedServiceTypesString))
+ {
+ context.Response.Message += $"Here is the full list of supported component service types for the topology: {usedServiceTypesString}.";
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to generate architecture diagram.");
+ HandleException(context, ex);
+ }
+
+ return Task.FromResult(context.Response);
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs
new file mode 100644
index 000000000..33f0322e1
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/DeployJsonContext.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Deploy.Models;
+using AzureMcp.Deploy.Options;
+
+namespace AzureMcp.Deploy.Commands;
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+)]
+[JsonSerializable(typeof(AppTopology))]
+[JsonSerializable(typeof(MermaidData))]
+[JsonSerializable(typeof(MermaidConfig))]
+[JsonSerializable(typeof(List))]
+internal sealed partial class DeployJsonContext : JsonSerializerContext
+{
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs
new file mode 100644
index 000000000..bf2277778
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/GenerateMermaidChart.cs
@@ -0,0 +1,246 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Immutable;
+using System.Text;
+using Azure.ResourceManager.Network.Models;
+using AzureMcp.Deploy.Options;
+using Microsoft.Extensions.ObjectPool;
+
+namespace AzureMcp.Deploy.Commands;
+
+public static class GenerateMermaidChart
+{
+ // used to create a subgraph for AKS cluster in the chart
+ private const string aksClusterInternalName = "akscluster";
+ private const string aksClusterName = "Azure Kubernetes Service (AKS) Cluster";
+
+ private const string acaEnvInternalName = "acaenvironment";
+ private const string acaEnvName = "Azure Container Apps Environment";
+
+ public static string GenerateChart(string workspaceFolder, AppTopology appTopology)
+ {
+ var chartComponents = new List();
+
+ chartComponents.Add("graph TD");
+
+ if (appTopology.Services.Any(s => s.AzureComputeHost == "aks"))
+ {
+ chartComponents.Add("classDef cluster fill:#ffffd0,stroke:#333,stroke-width:2px,color:#000");
+ }
+
+ var services = new List { "%% Services" };
+ var resources = new List { "%% Compute Resources" };
+ var dependencyResources = new List { "%% Binding Resources" };
+ var relationships = new List { "%% Relationships" };
+
+ foreach (var service in appTopology.Services)
+ {
+ var serviceName = new List { $"Name: {service.Name}" };
+
+ if (!string.IsNullOrWhiteSpace(workspaceFolder))
+ {
+ var projectRelativePath = Path.GetRelativePath(workspaceFolder, string.IsNullOrWhiteSpace(service.Path) ? workspaceFolder : service.Path);
+ serviceName.Add($"Path: {projectRelativePath}");
+ }
+ serviceName.Add($"Language: {service.Language}");
+ serviceName.Add($"Port: {service.Port}");
+
+ if (service.DockerSettings != null &&
+ string.Equals(service.AzureComputeHost, "azurecontainerapp", StringComparison.OrdinalIgnoreCase))
+ {
+ serviceName.Add($"DockerFile: {service.DockerSettings.DockerFilePath}");
+ serviceName.Add($"Docker Context: {service.DockerSettings.DockerContext}");
+ }
+
+ var serviceInternalName = $"svc-{service.Name}";
+
+ services.Add(CreateComponentName(serviceInternalName, string.Join("\n", serviceName), NodeShape.Rectangle));
+
+ relationships.Add(CreateRelationshipString(serviceInternalName, $"{FlattenServiceType(service.AzureComputeHost)}_{service.Name}", "hosted on", ArrowType.Solid));
+ }
+
+ var aksClusterExists = false;
+ var containerAppEnvExists = false;
+
+ foreach (var service in appTopology.Services)
+ {
+ string serviceResourceInternalName = $"{FlattenServiceType(service.AzureComputeHost)}_{service.Name}";
+ if (service.AzureComputeHost == "aks")
+ {
+ if (!aksClusterExists)
+ {
+ // Add AKS cluster as a subgraph
+ resources.Add($"subgraph {aksClusterInternalName} [\"{aksClusterName}\"]");
+ // containerized services share the same AKS cluster
+ foreach (var aksservice in appTopology.Services.Where(s => s.AzureComputeHost == "aks"))
+ {
+ resources.Add(CreateComponentName($"{aksservice.AzureComputeHost}_{aksservice.Name}", $"{aksservice.Name} (Containerized Service)", NodeShape.RoundedRectangle));
+ }
+ resources.Add("end");
+ resources.Add($"{aksClusterInternalName}:::cluster");
+ aksClusterExists = true;
+ }
+ }
+ else if (service.AzureComputeHost == "containerapp")
+ {
+ if (!containerAppEnvExists)
+ {
+ // Add Container App Environment as a subgraph
+ resources.Add($"subgraph {acaEnvInternalName} [\"{acaEnvName}\"]");
+ // containerized services share the same Container App Environment
+ foreach (var containerAppService in appTopology.Services.Where(s => s.AzureComputeHost == "containerapp"))
+ {
+ resources.Add(CreateComponentName($"{containerAppService.AzureComputeHost}_{containerAppService.Name}", $"{containerAppService.Name} (Container App)", NodeShape.RoundedRectangle));
+ }
+ resources.Add("end");
+ containerAppEnvExists = true;
+ }
+ }
+ // each service should have a compute resource type
+ else if (!resources.Any(r => r.Contains(serviceResourceInternalName)))
+ {
+ resources.Add(CreateComponentName(serviceResourceInternalName, $"{service.Name} ({GetFormalName(service.AzureComputeHost)})", NodeShape.RoundedRectangle));
+ }
+
+ foreach (var dependency in service.Dependencies)
+ {
+ var instanceInternalName = $"{FlattenServiceType(dependency.ServiceType)}.{dependency.Name}";
+ var instanceName = $"{dependency.Name} ({GetFormalName(dependency.ServiceType)})";
+
+ if (IsComputeResourceType(dependency.ServiceType))
+ {
+ if (!resources.Any(r => r.Contains(EnsureUrlFriendlyName(instanceInternalName))))
+ {
+ resources.Add(CreateComponentName(instanceInternalName, instanceName, NodeShape.RoundedRectangle));
+ }
+ }
+ else
+ {
+ dependencyResources.Add(CreateComponentName(instanceInternalName, instanceName, NodeShape.Rectangle));
+ }
+
+ relationships.Add(CreateRelationshipString(serviceResourceInternalName, instanceInternalName, dependency.ConnectionType, ArrowType.Dotted));
+ }
+ }
+
+ chartComponents.AddRange(services);
+ chartComponents.AddRange(resources);
+
+
+ chartComponents.Add("subgraph \"Compute Resources\"");
+ chartComponents.AddRange(resources);
+ chartComponents.Add("end");
+
+ chartComponents.Add("subgraph \"Dependency Resources\"");
+ chartComponents.AddRange(dependencyResources);
+ chartComponents.Add("end");
+
+ chartComponents.AddRange(relationships);
+
+ return string.Join("\n", chartComponents);
+ }
+
+ private static string CreateComponentName(string internalName, string name, NodeShape nodeShape)
+ {
+ var nodeShapeBrackets = GetNodeShapeBrackets(nodeShape);
+ return $"{EnsureUrlFriendlyName(internalName)}{nodeShapeBrackets[0]}\"`{name}`\"{nodeShapeBrackets[1]}";
+ }
+
+ private static string CreateRelationshipString(string sourceName, string targetName, string connectionDescription, ArrowType arrowType)
+ {
+ var arrowSymbol = GetArrowSymbol(arrowType);
+ return $"{EnsureUrlFriendlyName(sourceName)} {arrowSymbol} |\"{connectionDescription}\"| {EnsureUrlFriendlyName(targetName)}";
+ }
+
+ private static string EnsureUrlFriendlyName(string name)
+ {
+ return name.Replace('.', '_')
+ .Replace(" ", "_")
+ .Trim()
+ .ToLowerInvariant();
+ }
+
+ private static string[] GetNodeShapeBrackets(NodeShape nodeShape)
+ {
+ return nodeShape switch
+ {
+ NodeShape.Rectangle => ["[", "]"],
+ NodeShape.Circle => ["((", "))"],
+ NodeShape.RoundedRectangle => ["(", ")"],
+ NodeShape.Cylinder => ["[(", ")]"],
+ NodeShape.Hexagon => ["{{", "}}"],
+ _ => ["[", "]"]
+ };
+ }
+
+ private static string GetArrowSymbol(ArrowType arrowType)
+ {
+ return arrowType switch
+ {
+ ArrowType.Solid => "-->",
+ ArrowType.Open => "->",
+ ArrowType.Dotted => "-.->",
+ _ => "-->"
+ };
+ }
+
+ private static string GetFormalName(string name)
+ {
+ if (ResourceTypeConverter.TryGetValue(name, out var formalName))
+ {
+ return formalName;
+ }
+ return name;
+
+ }
+
+ private static string FlattenServiceType(string serviceType)
+ {
+ return serviceType.ToLowerInvariant().Replace("azure", "");
+ }
+
+ private static bool IsComputeResourceType(string serviceType)
+ {
+ return Enum.GetNames().Contains(serviceType, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static IDictionary ResourceTypeConverter = new Dictionary
+ {
+ { "appservice", "Azure App Service" },
+ { "containerapp", "Azure Container Apps" },
+ { "functionapp", "Azure Functions" },
+ { "staticwebapp", "Azure Static Web Apps" },
+ { "aks", "Azure Kubernetes Services" },
+ { "azureaisearch", "Azure AI Search" },
+ { "azureaiservices", "Azure AI Services" },
+ { "azureapplicationinsights", "Azure Application Insights" },
+ { "azurebotservice", "Azure Bot Service" },
+ { "azurecosmosdb", "Azure Cosmos DB" },
+ { "azurekeyvault", "Azure Key Vault" },
+ { "azuredatabaseformysql", "Azure Database for MySQL" },
+ { "azureopenai", "Azure OpenAI" },
+ { "azuredatabaseforpostgresql", "Azure Database for PostgreSQL" },
+ { "azuresqldatabase", "Azure SQL Database" },
+ { "azurecacheforredis", "Azure Cache For Redis"},
+ { "azurestorageaccount", "Azure Storage Account" },
+ { "azureservicebus", "Azure Service Bus" },
+ { "azurewebpubsub", "Azure Web PubSub"}
+ };
+}
+
+public enum NodeShape
+{
+ Rectangle,
+ Circle,
+ RoundedRectangle,
+ Cylinder,
+ Hexagon
+}
+
+public enum ArrowType
+{
+ Solid,
+ Open,
+ Dotted
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs
new file mode 100644
index 000000000..19b77f1c3
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Infrastructure/RulesGetCommand.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics.CodeAnalysis;
+using AzureMcp.Core.Commands;
+using AzureMcp.Deploy.Models;
+using AzureMcp.Deploy.Options;
+using AzureMcp.Deploy.Options.Infrastructure;
+using AzureMcp.Deploy.Services.Util;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Deploy.Commands.Infrastructure;
+
+public sealed class RulesGetCommand(ILogger logger)
+ : BaseCommand()
+{
+ private const string CommandTitle = "Get Iac(Infrastructure as Code) Rules";
+ private readonly ILogger _logger = logger;
+
+ private readonly Option _deploymentToolOption = DeployOptionDefinitions.IaCRules.DeploymentTool;
+ private readonly Option _iacTypeOption = DeployOptionDefinitions.IaCRules.IacType;
+ private readonly Option _resourceTypesOption = DeployOptionDefinitions.IaCRules.ResourceTypes;
+
+ public override string Name => "get";
+ public override string Title => CommandTitle;
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ public override string Description =>
+ """
+ This tool offers guidelines for creating Bicep/Terraform files to deploy applications on Azure. The guidelines outline rules to improve the quality of Infrastructure as Code files, ensuring they are compatible with the azd tool and adhere to best practices.
+ """;
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_deploymentToolOption);
+ command.AddOption(_iacTypeOption);
+ command.AddOption(_resourceTypesOption);
+ }
+
+ private RulesGetOptions BindOptions(ParseResult parseResult)
+ {
+ var options = new RulesGetOptions();
+ options.DeploymentTool = parseResult.GetValueForOption(_deploymentToolOption) ?? string.Empty;
+ options.IacType = parseResult.GetValueForOption(_iacTypeOption) ?? string.Empty;
+ options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty;
+
+ return options;
+ }
+
+ public override Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ var options = BindOptions(parseResult);
+ try
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return Task.FromResult(context.Response);
+ }
+
+ var resourceTypes = options.ResourceTypes.Split(',')
+ .Select(rt => rt.Trim())
+ .Where(rt => !string.IsNullOrWhiteSpace(rt))
+ .ToArray();
+
+ string iacRules = IaCRulesTemplateUtil.GetIaCRules(
+ options.DeploymentTool,
+ options.IacType,
+ resourceTypes);
+
+ context.Response.Message = iacRules;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An exception occurred while retrieving IaC rules.");
+ HandleException(context, ex);
+ }
+ return Task.FromResult(context.Response);
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs
new file mode 100644
index 000000000..9db555e4f
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Pipeline/GuidanceGetCommand.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Commands;
+using AzureMcp.Core.Commands.Subscription;
+using AzureMcp.Core.Services.Telemetry;
+using AzureMcp.Deploy.Options;
+using AzureMcp.Deploy.Options.Pipeline;
+using AzureMcp.Deploy.Services.Util;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Deploy.Commands.Pipeline;
+
+public sealed class GuidanceGetCommand(ILogger logger)
+ : SubscriptionCommand()
+{
+ private const string CommandTitle = "Get Azure Deployment CICD Pipeline Guidance";
+ private readonly ILogger _logger = logger;
+
+ private readonly Option _useAZDPipelineConfigOption = DeployOptionDefinitions.PipelineGenerateOptions.UseAZDPipelineConfig;
+ private readonly Option _organizationNameOption = DeployOptionDefinitions.PipelineGenerateOptions.OrganizationName;
+ private readonly Option _repositoryNameOption = DeployOptionDefinitions.PipelineGenerateOptions.RepositoryName;
+ private readonly Option _githubEnvironmentNameOption = DeployOptionDefinitions.PipelineGenerateOptions.GithubEnvironmentName;
+
+ public override string Name => "get";
+
+ public override string Description =>
+ """
+ Guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure. Use this tool BEFORE generating/creating a Github actions workflow file for DEPLOYMENT on Azure. Infrastructure files should be ready and the application should be ready to be containerized.
+ """;
+
+ public override string Title => CommandTitle;
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_useAZDPipelineConfigOption);
+ command.AddOption(_organizationNameOption);
+ command.AddOption(_repositoryNameOption);
+ command.AddOption(_githubEnvironmentNameOption);
+ }
+
+ protected override GuidanceGetOptions BindOptions(ParseResult parseResult)
+ {
+ var options = base.BindOptions(parseResult);
+ options.UseAZDPipelineConfig = parseResult.GetValueForOption(_useAZDPipelineConfigOption);
+ options.OrganizationName = parseResult.GetValueForOption(_organizationNameOption);
+ options.RepositoryName = parseResult.GetValueForOption(_repositoryNameOption);
+ options.GithubEnvironmentName = parseResult.GetValueForOption(_githubEnvironmentNameOption);
+ return options;
+ }
+
+ public override Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ var options = BindOptions(parseResult);
+
+ try
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return Task.FromResult(context.Response);
+ }
+ var result = PipelineGenerationUtil.GeneratePipelineGuidelines(options);
+
+ context.Response.Message = result;
+ context.Response.Status = 200;
+ }
+ catch (Exception ex)
+ {
+ HandleException(context, ex);
+ }
+ return Task.FromResult(context.Response);
+ }
+
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs b/areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs
new file mode 100644
index 000000000..a6c9284b2
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Commands/Plan/GetCommand.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics.CodeAnalysis;
+using AzureMcp.Core.Commands;
+using AzureMcp.Deploy.Options;
+using AzureMcp.Deploy.Options.Plan;
+using AzureMcp.Deploy.Services.Util;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Deploy.Commands.Plan;
+
+public sealed class GetCommand(ILogger logger)
+ : BaseCommand()
+{
+ private const string CommandTitle = "Generate Azure Deployment Plan";
+ private readonly ILogger _logger = logger;
+
+ private readonly Option _workspaceFolderOption = DeployOptionDefinitions.PlanGet.WorkspaceFolder;
+ private readonly Option _projectNameOption = DeployOptionDefinitions.PlanGet.ProjectName;
+ private readonly Option _deploymentTargetServiceOption = DeployOptionDefinitions.PlanGet.TargetAppService;
+ private readonly Option _provisioningToolOption = DeployOptionDefinitions.PlanGet.ProvisioningTool;
+ private readonly Option _azdIacOptionsOption = DeployOptionDefinitions.PlanGet.AzdIacOptions;
+
+ public override string Name => "get";
+
+ public override string Description =>
+ """
+ Generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services.
+ """;
+
+ public override string Title => CommandTitle;
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_workspaceFolderOption);
+ command.AddOption(_projectNameOption);
+ command.AddOption(_deploymentTargetServiceOption);
+ command.AddOption(_provisioningToolOption);
+ command.AddOption(_azdIacOptionsOption);
+ }
+
+ private GetOptions BindOptions(ParseResult parseResult)
+ {
+ return new GetOptions
+ {
+ WorkspaceFolder = parseResult.GetValueForOption(_workspaceFolderOption) ?? string.Empty,
+ ProjectName = parseResult.GetValueForOption(_projectNameOption) ?? string.Empty,
+ TargetAppService = parseResult.GetValueForOption(_deploymentTargetServiceOption) ?? string.Empty,
+ ProvisioningTool = parseResult.GetValueForOption(_provisioningToolOption) ?? string.Empty,
+ AzdIacOptions = parseResult.GetValueForOption(_azdIacOptionsOption) ?? string.Empty
+ };
+ }
+
+ public override Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ var options = BindOptions(parseResult);
+
+ try
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return Task.FromResult(context.Response);
+ }
+
+ var planTemplate = DeploymentPlanTemplateUtil.GetPlanTemplate(options.ProjectName, options.TargetAppService, options.ProvisioningTool, options.AzdIacOptions);
+
+ context.Response.Message = planTemplate;
+ context.Response.Status = 200;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error generating deployment plan");
+ HandleException(context, ex);
+ }
+ return Task.FromResult(context.Response);
+
+ }
+
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs
new file mode 100644
index 000000000..11f7c6d51
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/DeploySetup.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Areas;
+using AzureMcp.Core.Commands;
+using AzureMcp.Deploy.Commands.App;
+using AzureMcp.Deploy.Commands.Architecture;
+using AzureMcp.Deploy.Commands.Infrastructure;
+using AzureMcp.Deploy.Commands.Pipeline;
+using AzureMcp.Deploy.Commands.Plan;
+using AzureMcp.Deploy.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Deploy;
+
+public sealed class DeploySetup : IAreaSetup
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddSingleton();
+ }
+
+ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory)
+ {
+ var deploy = new CommandGroup("deploy", "Deploy commands for deploying applications to Azure, including sub commands: "
+ + "- plan get: generates a deployment plan to construct the infrastructure and deploy the application on Azure. Agent should read its output and generate a deploy plan in '.azure/plan.copilotmd' for execution steps, recommended azure services based on the information agent detected from project. Before calling this tool, please scan this workspace to detect the services to deploy and their dependent services; "
+ + "- iac rules get: offers guidelines for creating Bicep/Terraform files to deploy applications on Azure; "
+ + "- app logs get: fetch logs from log analytics workspace for Container Apps, App Services, function apps that were deployed through azd; "
+ + "- pipeline guidance get: guidance to create a CI/CD pipeline which provision Azure resources and build and deploy applications to Azure; "
+ + "- architecture diagram generate: generates an azure service architecture diagram for the application based on the provided app topology; ");
+ rootGroup.AddSubGroup(deploy);
+
+ // Application-specific commands
+ // This command will be deprecated when 'azd cli' supports the same functionality
+ var appGroup = new CommandGroup("app", "Application-specific deployment tools");
+ var logsGroup = new CommandGroup("logs", "Application logs management");
+ logsGroup.AddCommand("get", new LogsGetCommand(loggerFactory.CreateLogger()));
+ appGroup.AddSubGroup(logsGroup);
+ deploy.AddSubGroup(appGroup);
+
+ // Infrastructure as Code commands
+ var iacGroup = new CommandGroup("iac", "Infrastructure as Code operations");
+ var rulesGroup = new CommandGroup("rules", "Infrastructure as Code rules and guidelines");
+ rulesGroup.AddCommand("get", new RulesGetCommand(loggerFactory.CreateLogger()));
+ iacGroup.AddSubGroup(rulesGroup);
+ deploy.AddSubGroup(iacGroup);
+
+ // CI/CD Pipeline commands
+ var pipelineGroup = new CommandGroup("pipeline", "CI/CD pipeline operations");
+ var guidanceGroup = new CommandGroup("guidance", "CI/CD pipeline guidance");
+ guidanceGroup.AddCommand("get", new GuidanceGetCommand(loggerFactory.CreateLogger()));
+ pipelineGroup.AddSubGroup(guidanceGroup);
+ deploy.AddSubGroup(pipelineGroup);
+
+ // Deployment planning commands
+ var planGroup = new CommandGroup("plan", "Deployment planning operations");
+ planGroup.AddCommand("get", new GetCommand(loggerFactory.CreateLogger()));
+ deploy.AddSubGroup(planGroup);
+
+ // Architecture diagram commands
+ var architectureGroup = new CommandGroup("architecture", "Architecture diagram operations");
+ var diagramGroup = new CommandGroup("diagram", "Architecture diagram generation");
+ diagramGroup.AddCommand("generate", new DiagramGenerateCommand(loggerFactory.CreateLogger()));
+ architectureGroup.AddSubGroup(diagramGroup);
+ deploy.AddSubGroup(architectureGroup);
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs b/areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs
new file mode 100644
index 000000000..85a476736
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/GlobalUsings.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+global using System.CommandLine;
+global using System.CommandLine.Parsing;
+global using System.Text.Json;
+global using AzureMcp.Core.Extensions;
+global using AzureMcp.Core.Models;
+global using AzureMcp.Core.Models.Command;
+global using ModelContextProtocol.Server;
diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs
new file mode 100644
index 000000000..f271ddb8c
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Models/Consts.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Deploy.Commands;
+
+public static class AzureServiceConstants
+{
+ public enum AzureComputeServiceType
+ {
+ AppService,
+ FunctionApp,
+ ContainerApp,
+ StaticWebApp,
+ AKS
+ }
+
+ public enum AzureServiceType
+ {
+ AzureAISearch,
+ AzureAIServices,
+ AppService,
+ AzureApplicationInsights,
+ AzureBotService,
+ AzureContainerApp,
+ AzureCosmosDB,
+ AzureFunctionApp,
+ AzureKeyVault,
+ AKS,
+ AzureDatabaseForMySQL,
+ AzureOpenAI,
+ AzureDatabaseForPostgreSQL,
+ AzurePrivateEndpoint,
+ AzureCacheForRedis,
+ AzureSQLDatabase,
+ AzureStorageAccount,
+ StaticWebApp,
+ AzureServiceBus,
+ AzureSignalRService,
+ AzureVirtualNetwork,
+ AzureWebPubSub
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/IaCRulesParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/IaCRulesParameters.cs
new file mode 100644
index 000000000..56230b967
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Models/IaCRulesParameters.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Nodes;
+
+namespace AzureMcp.Deploy.Models;
+
+
+public static class DeploymentTool
+{
+ public const string Azd = "AZD";
+ public const string AzCli = "AzCli";
+}
+
+public static class IacType
+{
+ public const string Bicep = "bicep";
+ public const string Terraform = "terraform";
+}
+
+public static class AzureServiceNames
+{
+ public const string AzureContainerApp = "containerapp";
+ public const string AzureAppService = "appservice";
+ public const string AzureFunctionApp = "function";
+}
+
diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/MermaidData.cs b/areas/deploy/src/AzureMcp.Deploy/Models/MermaidData.cs
new file mode 100644
index 000000000..27661774e
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Models/MermaidData.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace AzureMcp.Deploy.Commands;
+
+public sealed class MermaidData
+{
+ [JsonPropertyName("code")]
+ public string Code { get; set; } = string.Empty;
+
+ [JsonPropertyName("mermaid")]
+ public MermaidConfig Mermaid { get; set; } = new();
+}
+
+public sealed class MermaidConfig
+{
+ [JsonPropertyName("theme")]
+ public string Theme { get; set; } = "default";
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs
new file mode 100644
index 000000000..aa9f9a96f
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/DeploymentPlanTemplateParameters.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Deploy.Models.Templates;
+
+///
+/// Parameters for generating deployment plan templates.
+///
+public sealed class DeploymentPlanTemplateParameters
+{
+ ///
+ /// The title of the deployment plan.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// The name of the project being deployed.
+ ///
+ public string ProjectName { get; set; } = string.Empty;
+
+ ///
+ /// The target Azure service (ContainerApp, WebApp, FunctionApp, AKS).
+ ///
+ public string TargetAppService { get; set; } = string.Empty;
+
+ ///
+ /// The provisioning tool (AZD, AzCli).
+ ///
+ public string ProvisioningTool { get; set; } = string.Empty;
+
+ ///
+ /// The Infrastructure as Code type (bicep, terraform).
+ ///
+ public string IacType { get; set; } = string.Empty;
+
+ ///
+ /// The Azure compute host display name.
+ ///
+ public string AzureComputeHost { get; set; } = string.Empty;
+
+ ///
+ /// The execution steps for the deployment.
+ ///
+ public string ExecutionSteps { get; set; } = string.Empty;
+
+ ///
+ /// Converts the parameters to a dictionary for template processing.
+ ///
+ /// A dictionary with parameter names as keys and their values.
+ public Dictionary ToDictionary()
+ {
+ return new Dictionary
+ {
+ { nameof(Title), Title },
+ { nameof(ProjectName), ProjectName },
+ { nameof(TargetAppService), TargetAppService },
+ { nameof(ProvisioningTool), ProvisioningTool },
+ { nameof(IacType), IacType },
+ { nameof(AzureComputeHost), AzureComputeHost },
+ { nameof(ExecutionSteps), ExecutionSteps },
+ };
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/Templates/IaCRulesTemplateParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/IaCRulesTemplateParameters.cs
new file mode 100644
index 000000000..60c4a3bd8
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/IaCRulesTemplateParameters.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Deploy.Models.Templates;
+
+///
+/// Parameters for IaC rules template generation.
+///
+public sealed class IaCRulesTemplateParameters
+{
+ public string DeploymentTool { get; set; } = string.Empty;
+ public string IacType { get; set; } = string.Empty;
+ public string[] ResourceTypes { get; set; } = [];
+ public string ResourceTypesDisplay { get; set; } = string.Empty;
+ public string DeploymentToolRules { get; set; } = string.Empty;
+ public string IacTypeRules { get; set; } = string.Empty;
+ public string ResourceSpecificRules { get; set; } = string.Empty;
+ public string FinalInstructions { get; set; } = string.Empty;
+ public string RequiredTools { get; set; } = string.Empty;
+ public string AdditionalNotes { get; set; } = string.Empty;
+ public string OutputFileName { get; set; } = string.Empty;
+ public string ContainerRegistryOutput { get; set; } = string.Empty;
+ public string RoleAssignmentResource { get; set; } = string.Empty;
+ public string ImageProperty { get; set; } = string.Empty;
+ public string CorsConfiguration { get; set; } = string.Empty;
+ public string LogAnalyticsConfiguration { get; set; } = string.Empty;
+ public string DiagnosticSettingsResource { get; set; } = string.Empty;
+
+ ///
+ /// Converts the parameters to a dictionary for template processing.
+ ///
+ /// A dictionary with parameter names as keys and their values.
+ public Dictionary ToDictionary()
+ {
+ return new Dictionary
+ {
+ { nameof(DeploymentTool), DeploymentTool },
+ { nameof(IacType), IacType },
+ { nameof(ResourceTypesDisplay), ResourceTypesDisplay },
+ { nameof(DeploymentToolRules), DeploymentToolRules },
+ { nameof(IacTypeRules), IacTypeRules },
+ { nameof(ResourceSpecificRules), ResourceSpecificRules },
+ { nameof(FinalInstructions), FinalInstructions },
+ { nameof(RequiredTools), RequiredTools },
+ { nameof(AdditionalNotes), AdditionalNotes },
+ { nameof(OutputFileName), OutputFileName },
+ { nameof(ContainerRegistryOutput), ContainerRegistryOutput },
+ { nameof(RoleAssignmentResource), RoleAssignmentResource },
+ { nameof(ImageProperty), ImageProperty },
+ { nameof(CorsConfiguration), CorsConfiguration },
+ { nameof(LogAnalyticsConfiguration), LogAnalyticsConfiguration },
+ { nameof(DiagnosticSettingsResource), DiagnosticSettingsResource },
+ };
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs
new file mode 100644
index 000000000..d42792939
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Models/Templates/PipelineTemplateParameters.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Deploy.Models.Templates;
+
+///
+/// Parameters for generating pipeline templates.
+///
+public sealed class PipelineTemplateParameters
+{
+ ///
+ /// Environment name prompt text.
+ ///
+ public string EnvironmentNamePrompt { get; set; } = string.Empty;
+
+ ///
+ /// Subscription ID prompt text.
+ ///
+ public string SubscriptionIdPrompt { get; set; } = string.Empty;
+
+ ///
+ /// GitHub environment create command.
+ ///
+ public string EnvironmentCreateCommand { get; set; } = string.Empty;
+
+ ///
+ /// JSON parameters for federated credentials.
+ ///
+ public string JsonParameters { get; set; } = string.Empty;
+
+ ///
+ /// Environment argument for GitHub CLI commands.
+ ///
+ public string EnvironmentArg { get; set; } = string.Empty;
+
+ ///
+ /// Converts the parameters to a dictionary for template processing.
+ ///
+ /// A dictionary containing the parameter values.
+ public Dictionary ToDictionary()
+ {
+ return new Dictionary
+ {
+ { nameof(EnvironmentNamePrompt), EnvironmentNamePrompt },
+ { nameof(SubscriptionIdPrompt), SubscriptionIdPrompt },
+ { nameof(EnvironmentCreateCommand), EnvironmentCreateCommand },
+ { nameof(JsonParameters), JsonParameters },
+ { nameof(EnvironmentArg), EnvironmentArg }
+ };
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs
new file mode 100644
index 000000000..4f8cda621
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/App/LogsGetOptions.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Options;
+
+namespace AzureMcp.Deploy.Options.App;
+
+public class LogsGetOptions : SubscriptionOptions
+{
+ [JsonPropertyName("workspaceFolder")]
+ public string WorkspaceFolder { get; set; } = string.Empty;
+
+ [JsonPropertyName("azdEnvName")]
+ public string AzdEnvName { get; set; } = string.Empty;
+
+ [JsonPropertyName("limit")]
+ public int? Limit { get; set; }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/AppTopology.cs b/areas/deploy/src/AzureMcp.Deploy/Options/AppTopology.cs
new file mode 100644
index 000000000..8cc2e7a7b
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/AppTopology.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Options;
+
+namespace AzureMcp.Deploy.Options;
+
+public class AppTopology
+{
+ [JsonPropertyName("workspaceFolder")]
+ public string? WorkspaceFolder { get; set; }
+
+ [JsonPropertyName("projectName")]
+ public string? ProjectName { get; set; }
+
+ [JsonPropertyName("services")]
+ public ServiceConfig[] Services { get; set; } = [];
+}
+
+public class ServiceConfig
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("path")]
+ public string Path { get; set; } = "";
+
+ [JsonPropertyName("language")]
+ public string Language { get; set; } = "";
+
+ [JsonPropertyName("port")]
+ public string Port { get; set; } = "";
+
+ [JsonPropertyName("azureComputeHost")]
+ public string AzureComputeHost { get; set; } = "";
+
+ [JsonPropertyName("dependencies")]
+ public DependencyConfig[] Dependencies { get; set; } = [];
+
+ [JsonPropertyName("settings")]
+ public string[] Settings { get; set; } = [];
+
+ [JsonPropertyName("dockerSettings")]
+ public DockerSettings? DockerSettings { get; set; }
+}
+
+public class DockerSettings
+{
+ [JsonPropertyName("dockerFilePath")]
+ public string DockerFilePath { get; set; } = "";
+
+ [JsonPropertyName("dockerContext")]
+ public string DockerContext { get; set; } = "";
+}
+
+public class DependencyConfig
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("serviceType")]
+ public string ServiceType { get; set; } = "";
+
+ [JsonPropertyName("connectionType")]
+ public string ConnectionType { get; set; } = "";
+
+ [JsonPropertyName("environmentVariables")]
+ public string[] EnvironmentVariables { get; set; } = [];
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/Architecture/DiagramGenerateOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Architecture/DiagramGenerateOptions.cs
new file mode 100644
index 000000000..d345b4bc9
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/Architecture/DiagramGenerateOptions.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Areas.Server.Commands;
+using AzureMcp.Core.Areas.Server.Commands.ToolLoading;
+using AzureMcp.Core.Options;
+
+namespace AzureMcp.Deploy.Options.Architecture;
+
+public class DiagramGenerateOptions : GlobalOptions
+{
+ [JsonPropertyName(CommandFactoryToolLoader.RawMcpToolInputOptionName)]
+ public string? RawMcpToolInput { get; set; }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs
new file mode 100644
index 000000000..d2b208983
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/DeployOptionDefinitions.cs
@@ -0,0 +1,172 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Areas.Server.Commands.ToolLoading;
+using AzureMcp.Core.Options;
+using AzureMcp.Deploy.Services.Util;
+
+namespace AzureMcp.Deploy.Options;
+
+public static class DeployOptionDefinitions
+{
+ public static class RawMcpToolInput
+ {
+ public const string RawMcpToolInputName = CommandFactoryToolLoader.RawMcpToolInputOptionName;
+
+ public static readonly Option RawMcpToolInputOption = new(
+ $"--{RawMcpToolInputName}",
+ JsonSchemaLoader.LoadAppTopologyJsonSchema()
+ )
+ {
+ IsRequired = true
+ };
+ }
+
+ public class AzdAppLogOptions : SubscriptionOptions
+ {
+ public const string WorkspaceFolderName = "workspace-folder";
+ public const string AzdEnvNameName = "azd-env-name";
+ public const string LimitName = "limit";
+
+ public static readonly Option WorkspaceFolder = new(
+ $"--{WorkspaceFolderName}",
+ "The full path of the workspace folder."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option AzdEnvName = new(
+ $"--{AzdEnvNameName}",
+ "The name of the environment created by azd (AZURE_ENV_NAME) during `azd init` or `azd up`. If not provided in context, try to find it in the .azure directory in the workspace or use 'azd env list'."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option Limit = new(
+ $"--{LimitName}",
+ () => 200,
+ "The maximum row number of logs to retrieve. Use this to get a specific number of logs or to avoid the retrieved logs from reaching token limit. Default is 200."
+ )
+ {
+ IsRequired = false
+ };
+ }
+
+ public class PipelineGenerateOptions : SubscriptionOptions
+ {
+ public const string UseAZDPipelineConfigName = "use-azd-pipeline-config";
+ public const string OrganizationNameName = "organization-name";
+ public const string RepositoryNameName = "repository-name";
+ public const string GithubEnvironmentNameName = "github-environment-name";
+
+ public static readonly Option UseAZDPipelineConfig = new(
+ $"--{UseAZDPipelineConfigName}",
+ () => false,
+ "Whether to use azd tool to set up the deployment pipeline. Set to true ONLY if azure.yaml is provided or the context suggests AZD tools."
+ )
+ {
+ IsRequired = false
+ };
+
+ public static readonly Option OrganizationName = new(
+ $"--{OrganizationNameName}",
+ "The name of the organization or the user account name of the current Github repository. DO NOT fill this in if you're not sure."
+ )
+ {
+ IsRequired = false
+ };
+
+ public static readonly Option RepositoryName = new(
+ $"--{RepositoryNameName}",
+ "The name of the current Github repository. DO NOT fill this in if you're not sure."
+ )
+ {
+ IsRequired = false
+ };
+
+ public static readonly Option GithubEnvironmentName = new(
+ $"--{GithubEnvironmentNameName}",
+ "The name of the environment to which the deployment pipeline will be deployed. DO NOT fill this in if you're not sure."
+ )
+ {
+ IsRequired = false
+ };
+
+ }
+
+ public static class PlanGet
+ {
+ public const string WorkspaceFolderName = "workspace-folder";
+ public const string ProjectNameName = "project-name";
+ public const string TargetAppServiceName = "target-app-service";
+ public const string ProvisioningToolName = "provisioning-tool";
+ public const string AzdIacOptionsName = "azd-iac-options";
+
+ public static readonly Option WorkspaceFolder = new(
+ $"--{WorkspaceFolderName}",
+ "The full path of the workspace folder."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option ProjectName = new(
+ $"--{ProjectNameName}",
+ "The name of the project to generate the deployment plan for. If not provided, will be inferred from the workspace."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option TargetAppService = new(
+ $"--{TargetAppServiceName}",
+ "The Azure service to deploy the application. Valid values: ContainerApp, WebApp, FunctionApp, AKS. Recommend one based on user application."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option ProvisioningTool = new(
+ $"--{ProvisioningToolName}",
+ "The tool to use for provisioning Azure resources. Valid values: AZD, AzCli. Use AzCli if TargetAppService is AKS."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option AzdIacOptions = new(
+ $"--{AzdIacOptionsName}",
+ "The Infrastructure as Code option for azd. Valid values: bicep, terraform. Leave empty if Deployment tool is AzCli."
+ )
+ {
+ IsRequired = false
+ };
+ }
+
+ public static class IaCRules
+ {
+ public static readonly Option DeploymentTool = new(
+ "--deployment-tool",
+ "The deployment tool to use. Valid values: AZD, AzCli")
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option IacType = new(
+ "--iac-type",
+ "The Infrastructure as Code type. Valid values: bicep, terraform. Leave empty if deployment-tool is AzCli.")
+ {
+ IsRequired = false
+ };
+
+ public static readonly Option ResourceTypes = new(
+ "--resource-types",
+ "Specifies the Azure resource types to retrieve IaC rules for. It should be comma-separated. Supported values are: 'appservice', 'containerapp', 'function', 'aks'. If none of these services are used, this parameter can be left empty.")
+ {
+ IsRequired = false,
+ AllowMultipleArgumentsPerToken = true
+ };
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/Infrastructure/RulesGetOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Infrastructure/RulesGetOptions.cs
new file mode 100644
index 000000000..3b512c39f
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/Infrastructure/RulesGetOptions.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Deploy.Options.Infrastructure;
+
+public sealed class RulesGetOptions
+{
+ public string DeploymentTool { get; set; } = string.Empty;
+ public string IacType { get; set; } = string.Empty;
+ public string ResourceTypes { get; set; } = string.Empty;
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/Pipeline/GuidanceGetOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Pipeline/GuidanceGetOptions.cs
new file mode 100644
index 000000000..6f294fb79
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/Pipeline/GuidanceGetOptions.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Options;
+
+namespace AzureMcp.Deploy.Options.Pipeline;
+
+public class GuidanceGetOptions : SubscriptionOptions
+{
+ [JsonPropertyName("useAZDPipelineConfig")]
+ public bool UseAZDPipelineConfig { get; set; }
+
+ [JsonPropertyName("organizationName")]
+ public string? OrganizationName { get; set; }
+
+ [JsonPropertyName("repositoryName")]
+ public string? RepositoryName { get; set; }
+
+ [JsonPropertyName("githubEnvironmentName")]
+ public string? GithubEnvironmentName { get; set; }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Options/Plan/GetOptions.cs b/areas/deploy/src/AzureMcp.Deploy/Options/Plan/GetOptions.cs
new file mode 100644
index 000000000..4b6e48057
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Options/Plan/GetOptions.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace AzureMcp.Deploy.Options.Plan;
+
+public sealed class GetOptions
+{
+ [JsonPropertyName("workspaceFolder")]
+ public string WorkspaceFolder { get; set; } = string.Empty;
+
+ [JsonPropertyName("projectName")]
+ public string ProjectName { get; set; } = string.Empty;
+
+ [JsonPropertyName("targetAppService")]
+ public string TargetAppService { get; set; } = string.Empty;
+
+ [JsonPropertyName("provisioningTool")]
+ public string ProvisioningTool { get; set; } = string.Empty;
+
+ [JsonPropertyName("azdIacOptions")]
+ public string? AzdIacOptions { get; set; } = string.Empty;
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json b/areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json
new file mode 100644
index 000000000..a0e6c4cfa
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Schemas/deploy-app-topology-schema.json
@@ -0,0 +1,152 @@
+{
+ "type": "object",
+ "properties": {
+ "workspaceFolder": {
+ "type": "string",
+ "description": "The full path of the workspace folder."
+ },
+ "projectName": {
+ "type": "string",
+ "description": "The name of the project. This is used to generate the resource names."
+ },
+ "services": {
+ "type": "array",
+ "description": "An array of service parameters.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the service."
+ },
+ "path": {
+ "type": "string",
+ "description": "The relative path of the service main project folder"
+ },
+ "language": {
+ "type": "string",
+ "description": "The programming language of the service."
+ },
+ "port": {
+ "type": "string",
+ "description": "The port number the service uses. Get this from Dockerfile for container apps. If not available, default to \u002780\u0027."
+ },
+ "azureComputeHost": {
+ "type": "string",
+ "description": "The appropriate azure service that should be used to host this service. Use containerapp if the service is containerized and has a Dockerfile.",
+ "enum": [
+ "appservice",
+ "containerapp",
+ "function",
+ "staticwebapp",
+ "aks"
+ ]
+ },
+ "dockerSettings": {
+ "type": "object",
+ "description": "Docker settings for the service. This is only needed if the service\u0027s azureComputeHost is containerapp.",
+ "properties": {
+ "dockerFilePath": {
+ "type": "string",
+ "description": "The absolute path to the Dockerfile for the service. If the service\u0027s azureComputeHost is not containerapp, leave blank."
+ },
+ "dockerContext": {
+ "type": "string",
+ "description": "The absolute path to the Docker build context for the service. If the service\u0027s azureComputeHost is not containerapp, leave blank."
+ }
+ },
+ "required": [
+ "dockerFilePath",
+ "dockerContext"
+ ]
+ },
+ "dependencies": {
+ "type": "array",
+ "description": "An array of dependent services. A compute service may have a dependency on another compute service.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the dependent service. Can be arbitrary, or must reference another service in the services array if referencing appservice, containerapp, staticwebapps, aks, or functionapp."
+ },
+ "serviceType": {
+ "type": "string",
+ "description": "The name of the azure service that can be used for this dependent service.",
+ "enum": [
+ "azureaisearch",
+ "azureaiservices",
+ "appservice",
+ "azureapplicationinsights",
+ "azurebotservice",
+ "containerapp",
+ "azurecosmosdb",
+ "functionapp",
+ "azurekeyvault",
+ "aks",
+ "azuredatabaseformysql",
+ "azureopenai",
+ "azuredatabaseforpostgresql",
+ "azureprivateendpoint",
+ "azurecacheforredis",
+ "azuresqldatabase",
+ "azurestorageaccount",
+ "staticwebapp",
+ "azureservicebus",
+ "azuresignalrservice",
+ "azurevirtualnetwork",
+ "azurewebpubsub"
+ ]
+ },
+ "connectionType": {
+ "type": "string",
+ "description": "The connection authentication type of the dependency.",
+ "enum": [
+ "http",
+ "secret",
+ "system-identity",
+ "user-identity",
+ "bot-connection"
+ ]
+ },
+ "environmentVariables": {
+ "type": "array",
+ "description": "An array of environment variables defined in source code to set up the connection.",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "name",
+ "serviceType",
+ "connectionType",
+ "environmentVariables"
+ ]
+ }
+ },
+ "settings": {
+ "type": "array",
+ "description": "An array of environment variables needed to run this service. Please search the entire codebase to find environment variables.",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "name",
+ "path",
+ "azureComputeHost",
+ "language",
+ "port",
+ "dependencies",
+ "settings"
+ ]
+ }
+ }
+ },
+ "required": [
+ "workspaceFolder",
+ "services"
+ ]
+}
\ No newline at end of file
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs
new file mode 100644
index 000000000..96ae72c3d
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/DeployService.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics.CodeAnalysis;
+using Areas.Deploy.Services.Util;
+using Azure.Core;
+using AzureMcp.Core.Services.Azure;
+
+namespace AzureMcp.Deploy.Services;
+
+public class DeployService() : BaseAzureService, IDeployService
+{
+
+ public async Task GetAzdResourceLogsAsync(
+ string workspaceFolder,
+ string azdEnvName,
+ string subscriptionId,
+ int? limit = null)
+ {
+ TokenCredential credential = await GetCredential();
+ string result = await AzdResourceLogService.GetAzdResourceLogsAsync(
+ credential,
+ workspaceFolder,
+ azdEnvName,
+ subscriptionId,
+ limit);
+ return result;
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/IDeployService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/IDeployService.cs
new file mode 100644
index 000000000..25b368f11
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/IDeployService.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Options;
+using AzureMcp.Deploy.Models;
+
+namespace AzureMcp.Deploy.Services;
+
+public interface IDeployService
+{
+ Task GetAzdResourceLogsAsync(
+ string workspaceFolder,
+ string azdEnvName,
+ string subscriptionId,
+ int? limit = null);
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs
new file mode 100644
index 000000000..62acbbfbc
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Templates/TemplateService.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using System.Text;
+
+namespace AzureMcp.Deploy.Services.Templates;
+
+///
+/// Service for loading and processing embedded template resources.
+///
+public static class TemplateService
+{
+ private static readonly Assembly _assembly = Assembly.GetExecutingAssembly();
+ private const string TemplateNamespace = "AzureMcp.Deploy.Templates";
+
+ ///
+ /// Loads an embedded template resource by name.
+ ///
+ /// The name of the template file (without extension).
+ /// The template content as a string.
+ /// Thrown when the template is not found.
+ public static string LoadTemplate(string templateName)
+ {
+ string fileNamespace = TemplateNamespace;
+ if (templateName.Contains("/"))
+ {
+ fileNamespace += "." + templateName.Split("/")[0];
+ templateName = templateName.Split("/")[1];
+ }
+ var resourceName = $"{fileNamespace}.{templateName}.md";
+
+ using var stream = _assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ {
+ throw new FileNotFoundException($"Template '{templateName}' not found in embedded resources.");
+ }
+
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ ///
+ /// Loads a template and replaces placeholders with provided values.
+ ///
+ /// The name of the template file (without extension).
+ /// Dictionary of placeholder names and their replacement values.
+ /// The processed template with placeholders replaced.
+ public static string ProcessTemplate(string templateName, Dictionary replacements)
+ {
+ var template = LoadTemplate(templateName);
+ return ProcessTemplateContent(template, replacements);
+ }
+
+ ///
+ /// Processes template content by replacing placeholders with provided values.
+ ///
+ /// The template content to process.
+ /// Dictionary of placeholder names and their replacement values.
+ /// The processed template with placeholders replaced.
+ public static string ProcessTemplateContent(string templateContent, Dictionary replacements)
+ {
+ var result = new StringBuilder(templateContent);
+
+ foreach (var (placeholder, value) in replacements)
+ {
+ var token = $"{{{{{placeholder}}}}}"; // {{placeholder}}
+ result.Replace(token, value);
+ }
+
+ return result.ToString();
+ }
+
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs
new file mode 100644
index 000000000..721dc63e4
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdAppLogRetriever.cs
@@ -0,0 +1,239 @@
+using Azure.Core;
+using Azure.Monitor.Query;
+using Azure.Monitor.Query.Models;
+using Azure.ResourceManager;
+using Azure.ResourceManager.AppContainers;
+using Azure.ResourceManager.AppService;
+using Azure.ResourceManager.Resources;
+
+namespace Areas.Deploy.Services.Util;
+
+public class AzdAppLogRetriever(TokenCredential credential, string subscriptionId, string azdEnvName)
+{
+ private readonly string _subscriptionId = subscriptionId;
+ private readonly string _azdEnvName = azdEnvName;
+ private readonly Dictionary _apps = new();
+ private readonly Dictionary _logs = new();
+ private readonly List _logAnalyticsWorkspaceIds = new();
+ private string _resourceGroupName = string.Empty;
+
+ private ArmClient? _armClient;
+ private LogsQueryClient? _queryClient;
+
+ public async Task InitializeAsync()
+ {
+ _armClient = new ArmClient(credential, _subscriptionId);
+ _queryClient = new LogsQueryClient(credential);
+
+ _resourceGroupName = await GetResourceGroupNameAsync();
+ if (string.IsNullOrEmpty(_resourceGroupName))
+ {
+ throw new InvalidOperationException($"No resource group with tag {{\"azd-env-name\": {_azdEnvName}}} found.");
+ }
+ }
+
+ public async Task GetLogAnalyticsWorkspacesInfoAsync()
+ {
+ var subscription = _armClient!.GetSubscriptionResource(new($"/subscriptions/{_subscriptionId}"));
+ var resourceGroup = await subscription.GetResourceGroupAsync(_resourceGroupName);
+
+ var filter = "resourceType eq 'Microsoft.OperationalInsights/workspaces'";
+
+ await foreach (var resource in resourceGroup.Value.GetGenericResourcesAsync(filter: filter))
+ {
+ _logAnalyticsWorkspaceIds.Add(resource.Id.ToString());
+ }
+
+ if (_logAnalyticsWorkspaceIds.Count == 0)
+ {
+ throw new InvalidOperationException($"No log analytics workspaces found for resource group {_resourceGroupName}. Logs cannot be retrieved using this tool.");
+ }
+ }
+
+ public async Task RegisterAppAsync(ResourceType resourceType, string serviceName)
+ {
+ var subscription = _armClient!.GetSubscriptionResource(new($"/subscriptions/{_subscriptionId}"));
+ var resourceGroup = await subscription.GetResourceGroupAsync(_resourceGroupName);
+
+ var filter = $"tagName eq 'azd-service-name' and tagValue eq '{serviceName}'";
+ var apps = new List();
+
+ await foreach (var resource in resourceGroup.Value.GetGenericResourcesAsync(filter: filter))
+ {
+ var resourceTypeString = resourceType.GetResourceTypeString();
+ var parts = resourceTypeString.Split('|');
+ var type = parts[0];
+ var kind = parts.Length > 1 ? parts[1] : null;
+
+ if (resource.Data.ResourceType.ToString() == type &&
+ (kind == null || resource.Data.Kind?.StartsWith(kind) == true))
+ {
+ _logs[resource.Id.ToString()] = string.Empty;
+ apps.Add(resource);
+ }
+ }
+
+ return apps.Count switch
+ {
+ 0 => throw new InvalidOperationException($"No resources found for resource type {resourceType} with tag azd-service-name={serviceName}"),
+ > 1 => throw new InvalidOperationException($"Multiple resources found for resource type {resourceType} with tag azd-service-name={serviceName}"),
+ _ => apps[0]
+ };
+ }
+
+ private static string GetContainerAppLogsQuery(string containerAppName, int limit) =>
+ $"ContainerAppConsoleLogs_CL | where ContainerAppName_s == '{containerAppName}' | order by _timestamp_d desc | project TimeGenerated, Log_s | take {limit}";
+
+ private static string GetAppServiceLogsQuery(string appServiceResourceId, int limit) =>
+ $"AppServiceConsoleLogs | where _ResourceId == '{appServiceResourceId.ToLowerInvariant()}' | order by TimeGenerated desc | project TimeGenerated, ResultDescription | take {limit}";
+
+ private static string GetFunctionAppLogsQuery(string functionAppName, int limit) =>
+ $"AppTraces | where AppRoleName == '{functionAppName}' | order by TimeGenerated desc | project TimeGenerated, Message | take {limit}";
+
+ public async Task QueryAppLogsAsync(ResourceType resourceType, string serviceName, int? limit = null)
+ {
+ var app = await RegisterAppAsync(resourceType, serviceName);
+ var getLogErrors = new List();
+ var getLogSuccess = false;
+ var logSearchQuery = string.Empty;
+ DateTimeOffset? lastDeploymentTime = null;
+
+ var actualLimit = limit ?? 200;
+ DateTimeOffset endTime = DateTime.UtcNow;
+ DateTimeOffset startTime = endTime.AddHours(-4);
+
+ switch (resourceType)
+ {
+ case ResourceType.ContainerApps:
+ logSearchQuery = GetContainerAppLogsQuery(app.Data.Name, actualLimit);
+ // Get last deployment time for container apps
+ var containerAppResource = _armClient!.GetContainerAppResource(app.Id);
+ var containerApp = await containerAppResource.GetAsync();
+
+ await foreach (var revision in containerApp.Value.GetContainerAppRevisions())
+ {
+ var revisionData = await revision.GetAsync();
+ if (revisionData.Value.Data.IsActive == true)
+ {
+ lastDeploymentTime = revisionData.Value.Data.CreatedOn;
+ break;
+ }
+ }
+ break;
+
+ case ResourceType.AppService:
+ case ResourceType.FunctionApp:
+ var webSiteResource = _armClient!.GetWebSiteResource(app.Id);
+
+ await foreach (var deployment in webSiteResource.GetSiteDeployments())
+ {
+ var deploymentData = await deployment.GetAsync();
+ if (deploymentData.Value.Data.IsActive == true)
+ {
+ lastDeploymentTime = deploymentData.Value.Data.StartOn;
+ break;
+ }
+ }
+
+ logSearchQuery = resourceType == ResourceType.AppService
+ ? GetAppServiceLogsQuery(app.Id.ToString(), actualLimit)
+ : GetFunctionAppLogsQuery(app.Data.Name, actualLimit);
+ break;
+
+ default:
+ throw new ArgumentException($"Unsupported resource type: {resourceType}");
+ }
+
+ // startTime is now, endTime is 1 hour ago
+
+ if (lastDeploymentTime.HasValue && lastDeploymentTime > startTime)
+ {
+ startTime = lastDeploymentTime ?? startTime;
+ }
+
+ foreach (var logAnalyticsId in _logAnalyticsWorkspaceIds)
+ {
+ try
+ {
+ var timeRange = new QueryTimeRange(startTime, endTime);
+ var response = await _queryClient!.QueryResourceAsync(new(logAnalyticsId), logSearchQuery, timeRange);
+
+ if (response.Value.Status == LogsQueryResultStatus.Success)
+ {
+ foreach (var table in response.Value.AllTables)
+ {
+ foreach (var row in table.Rows)
+ {
+ _logs[app.Id.ToString()] += $"[{row[0]}] {row[1]}\n";
+ }
+ }
+ getLogSuccess = true;
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ getLogErrors.Add($"Error retrieving logs for {app.Data.Name} from {logAnalyticsId}: {ex.Message}");
+ }
+ }
+
+ if (!getLogSuccess)
+ {
+ throw new InvalidOperationException($"Errors: {string.Join(", ", getLogErrors)}");
+ }
+
+ return $"Console Logs for {serviceName} with resource ID {app.Id} between {startTime} and {endTime}:\n{_logs[app.Id.ToString()]}";
+ }
+
+ private async Task GetResourceGroupNameAsync()
+ {
+ var subscription = _armClient!.GetSubscriptionResource(new($"/subscriptions/{_subscriptionId}"));
+
+ await foreach (var resourceGroup in subscription.GetResourceGroups())
+ {
+ if (resourceGroup.Data.Tags.TryGetValue("azd-env-name", out var envName) && envName == _azdEnvName)
+ {
+ return resourceGroup.Data.Name;
+ }
+ }
+
+ return string.Empty;
+ }
+
+}
+
+public enum ResourceType
+{
+ AppService,
+ ContainerApps,
+ FunctionApp
+}
+
+public static class ResourceTypeExtensions
+{
+ private static readonly Dictionary HostToResourceType = new()
+ {
+ { "containerapp", ResourceType.ContainerApps },
+ { "appservice", ResourceType.AppService },
+ { "function", ResourceType.FunctionApp }
+ };
+
+ private static readonly Dictionary ResourceTypeToString = new()
+ {
+ { ResourceType.AppService, "Microsoft.Web/sites|app" },
+ { ResourceType.ContainerApps, "Microsoft.App/containerApps" },
+ { ResourceType.FunctionApp, "Microsoft.Web/sites|functionapp" }
+ };
+
+ public static ResourceType GetResourceTypeFromHost(string host)
+ {
+ return HostToResourceType.TryGetValue(host, out var resourceType)
+ ? resourceType
+ : throw new ArgumentException($"Unknown host type: {host}");
+ }
+
+ public static string GetResourceTypeString(this ResourceType resourceType)
+ {
+ return ResourceTypeToString[resourceType];
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs
new file mode 100644
index 000000000..404c21b49
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/AzdResourceLogService.cs
@@ -0,0 +1,193 @@
+using System.Diagnostics.CodeAnalysis;
+using Azure.Core;
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+
+namespace Areas.Deploy.Services.Util;
+
+public static class AzdResourceLogService
+{
+ private const string AzureYamlFileName = "azure.yaml";
+
+ public static async Task GetAzdResourceLogsAsync(
+ TokenCredential credential,
+ string workspaceFolder,
+ string azdEnvName,
+ string subscriptionId,
+ int? limit = null)
+ {
+ var toolErrorLogs = new List();
+ var appLogs = new List();
+
+ try
+ {
+ var azdAppLogRetriever = new AzdAppLogRetriever(credential, subscriptionId, azdEnvName);
+ await azdAppLogRetriever.InitializeAsync();
+ await azdAppLogRetriever.GetLogAnalyticsWorkspacesInfoAsync();
+
+ var services = GetServicesFromAzureYaml(workspaceFolder);
+
+ foreach (var (serviceName, service) in services)
+ {
+ try
+ {
+ if (service.Host != null)
+ {
+ var resourceType = ResourceTypeExtensions.GetResourceTypeFromHost(service.Host);
+ var logs = await azdAppLogRetriever.QueryAppLogsAsync(resourceType, serviceName, limit);
+ appLogs.Add(logs);
+ }
+ }
+ catch (Exception ex)
+ {
+ toolErrorLogs.Add($"Error finding app logs for service {serviceName}: {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ toolErrorLogs.Add(ex.Message);
+ }
+
+ if (appLogs.Count > 0)
+ {
+ return $"App logs retrieved:\n{string.Join("\n\n", appLogs)}";
+ }
+
+ if (toolErrorLogs.Count > 0)
+ {
+ return $"Error during retrieval of app logs of azd project:\n{string.Join("\n", toolErrorLogs)}";
+ }
+
+ return "No logs found.";
+ }
+
+ private static Dictionary GetServicesFromAzureYaml(string workspaceFolder)
+ {
+ var azureYamlPath = Path.Combine(workspaceFolder, AzureYamlFileName);
+
+ if (!File.Exists(azureYamlPath))
+ {
+ throw new FileNotFoundException($"Azure YAML file not found at {azureYamlPath}");
+ }
+
+ var yamlContent = File.ReadAllText(azureYamlPath);
+
+ using var stringReader = new StringReader(yamlContent);
+ var parser = new YamlDotNet.Core.Parser(stringReader);
+
+ return ParseAzureYamlServices(parser);
+ }
+
+ private static Dictionary ParseAzureYamlServices(YamlDotNet.Core.Parser parser)
+ {
+ var result = new Dictionary();
+
+ parser.Consume();
+
+ parser.Consume();
+
+ parser.Consume();
+
+ while (parser.Accept(out _) == false)
+ {
+ var key = parser.Consume().Value;
+
+ if (key == "services")
+ {
+ parser.Consume();
+
+ while (parser.Accept(out _) == false)
+ {
+ var serviceName = parser.Consume().Value;
+
+ parser.Consume();
+
+ string? host = null;
+ string? project = null;
+ string? language = null;
+
+ while (parser.Accept(out _) == false)
+ {
+ var propertyKey = parser.Consume().Value;
+ // Only accept properties host, project, and language which are scalars
+ if (parser.Accept(out _))
+ {
+ var propertyValue = parser.Consume().Value;
+ switch (propertyKey)
+ {
+ case "host":
+ host = propertyValue;
+ break;
+ case "project":
+ project = propertyValue;
+ break;
+ case "language":
+ language = propertyValue;
+ break;
+ }
+ }
+ else
+ {
+ SkipValue(parser);
+ }
+ }
+
+ parser.Consume();
+
+ result[serviceName] = new Service(
+ Host: host,
+ Project: project,
+ Language: language
+ );
+ }
+
+ parser.Consume();
+ }
+ else
+ {
+ SkipValue(parser);
+ }
+ }
+
+ if (result.Count == 0)
+ {
+ throw new InvalidOperationException("No services section found in azure.yaml");
+ }
+
+ return result;
+ }
+
+ private static void SkipValue(YamlDotNet.Core.Parser parser)
+ {
+ if (parser.Accept(out _))
+ {
+ parser.Consume();
+ }
+ else if (parser.Accept(out _))
+ {
+ parser.Consume();
+ while (!parser.Accept(out _))
+ {
+ SkipValue(parser);
+ SkipValue(parser);
+ }
+ parser.Consume();
+ }
+ else if (parser.Accept(out _))
+ {
+ parser.Consume();
+ while (!parser.Accept(out _))
+ {
+ SkipValue(parser);
+ }
+ parser.Consume();
+ }
+ }
+}
+
+public record Service(
+ string? Host = null,
+ string? Project = null,
+ string? Language = null
+);
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/DeploymentPlanTemplateUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/DeploymentPlanTemplateUtil.cs
new file mode 100644
index 000000000..545abf9d6
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/DeploymentPlanTemplateUtil.cs
@@ -0,0 +1,173 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Deploy.Models;
+using AzureMcp.Deploy.Models.Templates;
+using AzureMcp.Deploy.Services.Templates;
+
+namespace AzureMcp.Deploy.Services.Util;
+
+///
+/// Refactored utility class for generating deployment plan templates using embedded resources.
+///
+public static class DeploymentPlanTemplateUtil
+{
+ ///
+ /// Generates a deployment plan template using embedded template resources.
+ ///
+ /// The name of the project. Can be null or empty.
+ /// The target Azure service.
+ /// The provisioning tool.
+ /// The Infrastructure as Code options for AZD.
+ /// A formatted deployment plan template string.
+ public static string GetPlanTemplate(string projectName, string targetAppService, string provisioningTool, string? azdIacOptions = "")
+ {
+ // Default values for optional parameters
+ if (provisioningTool == "azd" && string.IsNullOrWhiteSpace(azdIacOptions))
+ {
+ azdIacOptions = "bicep";
+ }
+
+ DeploymentPlanTemplateParameters parameters = CreateTemplateParameters(projectName, targetAppService, provisioningTool, azdIacOptions);
+ var executionSteps = GenerateExecutionSteps(parameters);
+
+ parameters.ExecutionSteps = executionSteps;
+
+ return TemplateService.ProcessTemplate("Plan/deployment-plan-base", parameters.ToDictionary());
+ }
+
+ ///
+ /// Creates template parameters from the provided inputs.
+ ///
+ private static DeploymentPlanTemplateParameters CreateTemplateParameters(
+ string projectName,
+ string targetAppService,
+ string provisioningTool,
+ string? azdIacOptions)
+ {
+ var azureComputeHost = GetAzureComputeHost(targetAppService);
+ var title = string.IsNullOrWhiteSpace(projectName)
+ ? "Azure Deployment Plan"
+ : $"Azure Deployment Plan for {projectName} Project";
+
+ return new DeploymentPlanTemplateParameters
+ {
+ Title = title,
+ ProjectName = projectName,
+ TargetAppService = targetAppService,
+ ProvisioningTool = provisioningTool,
+ IacType = azdIacOptions ?? "bicep",
+ AzureComputeHost = azureComputeHost,
+ };
+ }
+
+ ///
+ /// Gets the Azure compute host display name from the target app service.
+ ///
+ private static string GetAzureComputeHost(string targetAppService)
+ {
+ return targetAppService.ToLowerInvariant() switch
+ {
+ "containerapp" => "Azure Container Apps",
+ "webapp" => "Azure Web App Service",
+ "functionapp" => "Azure Functions",
+ "aks" => "Azure Kubernetes Service",
+ _ => "Azure Container Apps"
+ };
+ }
+
+ ///
+ /// Generates execution steps based on the deployment parameters.
+ ///
+ private static string GenerateExecutionSteps(DeploymentPlanTemplateParameters parameters)
+ {
+ var steps = new List();
+ var isAks = parameters.TargetAppService.ToLowerInvariant() == "aks";
+
+ if (parameters.ProvisioningTool.ToLowerInvariant() == "azd")
+ {
+ steps.AddRange(GenerateAzdSteps(parameters, isAks));
+ }
+ else if (parameters.ProvisioningTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase))
+ {
+ steps.AddRange(GenerateAzCliSteps(parameters, isAks));
+ }
+
+ return string.Join(Environment.NewLine, steps);
+ }
+
+ ///
+ /// Generates AZD-specific execution steps.
+ ///
+ private static List GenerateAzdSteps(DeploymentPlanTemplateParameters parameters, bool isAks)
+ {
+ var steps = new List();
+
+ var deployTitle = isAks ? "" : " And Deploy the Application";
+ var checkLog = isAks ? "" : "6. Check the application log with tool `azd-app-log-get` to ensure the services are running.";
+
+ var azdStepReplacements = new Dictionary
+ {
+ { "DeployTitle", deployTitle },
+ { "IacType", parameters.IacType },
+ { "CheckLog", checkLog }
+ };
+
+ var azdSteps = TemplateService.ProcessTemplate("Plan/azd-steps", azdStepReplacements);
+ steps.Add(azdSteps);
+
+ if (isAks)
+ {
+ steps.Add(TemplateService.LoadTemplate("Plan/aks-steps"));
+ steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } }));
+ }
+ else
+ {
+ steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "2" } }));
+ }
+
+ return steps;
+ }
+
+ ///
+ /// Generates Azure CLI-specific execution steps.
+ ///
+ private static List GenerateAzCliSteps(DeploymentPlanTemplateParameters parameters, bool isAks)
+ {
+ var steps = new List();
+
+ steps.Add(TemplateService.LoadTemplate("Plan/azcli-steps"));
+
+ if (isAks)
+ {
+ steps.Add(TemplateService.LoadTemplate("Plan/aks-steps"));
+ }
+ else
+ {
+ var isContainerApp = parameters.TargetAppService.ToLowerInvariant() == "containerapp";
+ if (isContainerApp)
+ {
+ var containerAppReplacements = new Dictionary
+ {
+ { "AzureComputeHost", parameters.AzureComputeHost }
+ };
+ steps.Add(TemplateService.ProcessTemplate("Plan/containerapp-steps", containerAppReplacements));
+ }
+ else
+ {
+ // For other app services, generate basic deployment steps
+ var basicSteps = $"""
+ 2. Build and Deploy the Application:
+ 1. Deploy to {parameters.AzureComputeHost}: Use Azure CLI command to deploy the application
+ 3. Validation:
+ 1. Verify command output to ensure the application is deployed successfully
+ """;
+ steps.Add(basicSteps);
+ }
+ }
+
+ steps.Add(TemplateService.ProcessTemplate("Plan/summary-steps", new Dictionary { { "StepNumber", "4" } }));
+
+ return steps;
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs
new file mode 100644
index 000000000..c509807cc
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/IaCRulesTemplateUtil.cs
@@ -0,0 +1,214 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Deploy.Models;
+using AzureMcp.Deploy.Models.Templates;
+using AzureMcp.Deploy.Services.Templates;
+
+namespace AzureMcp.Deploy.Services.Util;
+
+///
+/// Utility class for generating IaC rules using embedded templates.
+///
+public static class IaCRulesTemplateUtil
+{
+ ///
+ /// Generates IaC rules using embedded templates.
+ ///
+ /// The deployment tool (azd, azcli).
+ /// The IaC type (bicep, terraform).
+ /// Array of resource types.
+ /// A formatted IaC rules string.
+ public static string GetIaCRules(string deploymentTool, string iacType, string[] resourceTypes)
+ {
+ var parameters = CreateTemplateParameters(deploymentTool, iacType, resourceTypes);
+ var deploymentToolRules = GenerateDeploymentToolRules(parameters);
+ if (deploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase))
+ {
+ return TemplateService.LoadTemplate("IaCRules/azcli-rules");
+ }
+ // Default values for optional parameters
+ if (string.IsNullOrWhiteSpace(iacType))
+ {
+ iacType = "bicep";
+ }
+ var iacTypeRules = GenerateIaCTypeRules(parameters);
+ var resourceSpecificRules = GenerateResourceSpecificRules(parameters);
+ var finalInstructions = GenerateFinalInstructions(parameters);
+
+ parameters.DeploymentToolRules = deploymentToolRules;
+ parameters.IacTypeRules = iacTypeRules;
+ parameters.ResourceSpecificRules = resourceSpecificRules;
+ parameters.FinalInstructions = finalInstructions;
+ parameters.RequiredTools = BuildRequiredTools(deploymentTool, resourceTypes);
+ parameters.AdditionalNotes = BuildAdditionalNotes(deploymentTool, iacType);
+
+ return TemplateService.ProcessTemplate("IaCRules/base-iac-rules", parameters.ToDictionary());
+ }
+
+ ///
+ /// Creates template parameters from the provided inputs.
+ ///
+ private static IaCRulesTemplateParameters CreateTemplateParameters(
+ string deploymentTool,
+ string iacType,
+ string[] resourceTypes)
+ {
+ var parameters = new IaCRulesTemplateParameters
+ {
+ DeploymentTool = deploymentTool,
+ IacType = iacType,
+ ResourceTypes = resourceTypes,
+ ResourceTypesDisplay = string.Join(", ", resourceTypes)
+ };
+
+ // Set IaC type specific parameters
+ SetIaCTypeSpecificParameters(parameters);
+
+ return parameters;
+ }
+
+ ///
+ /// Sets IaC type specific parameters.
+ ///
+ private static void SetIaCTypeSpecificParameters(IaCRulesTemplateParameters parameters)
+ {
+ parameters.OutputFileName = parameters.IacType == IacType.Bicep ? "main.bicep" : "outputs.tf";
+ parameters.RoleAssignmentResource = parameters.IacType == IacType.Bicep
+ ? "Microsoft.Authorization/roleAssignments"
+ : "azurerm_role_assignment";
+ parameters.ImageProperty = parameters.IacType == IacType.Bicep
+ ? "properties.template.containers.image"
+ : "azurerm_container_app.template.container.image";
+ parameters.DiagnosticSettingsResource = parameters.IacType == IacType.Bicep
+ ? "Microsoft.Insights/diagnosticSettings"
+ : "azurerm_monitor_diagnostic_setting";
+
+ // Set CORS configuration based on IaC type
+ if (parameters.IacType == IacType.Bicep)
+ {
+ parameters.CorsConfiguration = "- Enable CORS via properties.configuration.ingress.corsPolicy.";
+ }
+ else if (parameters.IacType == IacType.Terraform)
+ {
+ parameters.CorsConfiguration = "- Create an ***azapi_resource_action*** resource using :type `Microsoft.App/containerApps`, method `PATCH`, and body `properties.configuration.ingress.corsPolicy` property to enable CORS for all origins, headers, and methods. Use 'azure/azapi' provider version *2.0*. DO NOT use jsonencode() for the body.";
+ }
+
+ // Set Log Analytics configuration based on IaC type
+ if (parameters.IacType == IacType.Bicep)
+ {
+ parameters.LogAnalyticsConfiguration = "- Container App Environment must be connected to Log Analytics Workspace. Use logAnalyticsConfiguration -> customerId=logAnalytics.properties.customerId and sharedKey=logAnalytics.listKeys().primarySharedKey.";
+ }
+ else
+ {
+ parameters.LogAnalyticsConfiguration = "- Container App Environment must be connected to Log Analytics Workspace. Use logs_destination=\"log-analytics\" azurerm_container_app_environment.log_analytics_workspace_id = azurerm_log_analytics_workspace..id.";
+ }
+ }
+
+ ///
+ /// Generates deployment tool specific rules.
+ ///
+ private static string GenerateDeploymentToolRules(IaCRulesTemplateParameters parameters)
+ {
+ if (parameters.DeploymentTool.Equals(DeploymentTool.Azd, StringComparison.OrdinalIgnoreCase))
+ {
+ var containerRegistryOutput = parameters.ResourceTypes.Contains(AzureServiceNames.AzureContainerApp)
+ ? "\n- Expected output in " + parameters.OutputFileName + ": AZURE_CONTAINER_REGISTRY_ENDPOINT representing the URI of the container registry endpoint."
+ : string.Empty;
+
+ var azdReplacements = new Dictionary
+ {
+ { "IacType", parameters.IacType },
+ { "OutputFileName", parameters.OutputFileName },
+ { "ContainerRegistryOutput", containerRegistryOutput }
+ };
+
+ return TemplateService.ProcessTemplate("IaCRules/azd-rules", azdReplacements);
+ }
+ else if (parameters.DeploymentTool.Equals(DeploymentTool.AzCli, StringComparison.OrdinalIgnoreCase))
+ {
+ return TemplateService.LoadTemplate("IaCRules/azcli-rules");
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ /// Generates IaC type specific rules.
+ ///
+ private static string GenerateIaCTypeRules(IaCRulesTemplateParameters parameters)
+ {
+ return parameters.IacType switch
+ {
+ IacType.Bicep => TemplateService.LoadTemplate("IaCRules/bicep-rules"),
+ IacType.Terraform => TemplateService.LoadTemplate("IaCRules/terraform-rules"),
+ _ => string.Empty
+ };
+ }
+
+ ///
+ /// Generates resource specific rules.
+ ///
+ private static string GenerateResourceSpecificRules(IaCRulesTemplateParameters parameters)
+ {
+ var rules = new List();
+
+ if (parameters.ResourceTypes.Contains(AzureServiceNames.AzureContainerApp))
+ {
+ rules.Add(TemplateService.ProcessTemplate("IaCRules/containerapp-rules", parameters.ToDictionary()));
+ }
+
+ if (parameters.ResourceTypes.Contains(AzureServiceNames.AzureAppService))
+ {
+ rules.Add(TemplateService.ProcessTemplate("IaCRules/appservice-rules", parameters.ToDictionary()));
+ }
+
+ if (parameters.ResourceTypes.Contains(AzureServiceNames.AzureFunctionApp))
+ {
+ rules.Add(TemplateService.ProcessTemplate("IaCRules/functionapp-rules", parameters.ToDictionary()));
+ }
+
+ return string.Join(Environment.NewLine, rules);
+ }
+
+ ///
+ /// Generates final instructions for the IaC rules.
+ ///
+ private static string GenerateFinalInstructions(IaCRulesTemplateParameters parameters)
+ {
+ return TemplateService.ProcessTemplate("IaCRules/final-instructions", parameters.ToDictionary());
+ }
+
+ ///
+ /// Builds the required tools list based on deployment tool and resource types.
+ ///
+ private static string BuildRequiredTools(string deploymentTool, string[] resourceTypes)
+ {
+ var tools = new List { "az cli (az --version)" };
+
+ if (deploymentTool == DeploymentTool.Azd)
+ {
+ tools.Add("azd (azd version)");
+ }
+
+ if (resourceTypes.Contains(AzureServiceNames.AzureContainerApp))
+ {
+ tools.Add("docker (docker --version)");
+ }
+
+ return string.Join(", ", tools) + ".";
+ }
+
+ ///
+ /// Builds additional notes based on deployment tool and IaC type.
+ ///
+ private static string BuildAdditionalNotes(string deploymentTool, string iacType)
+ {
+ if (iacType == IacType.Terraform && deploymentTool == DeploymentTool.Azd)
+ {
+ return "Note: Do not use Terraform CLI.";
+ }
+
+ return string.Empty;
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs
new file mode 100644
index 000000000..fbf8a9f40
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/JsonSchemaLoader.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using AzureMcp.Core.Helpers;
+
+namespace AzureMcp.Deploy.Services.Util;
+
+public static class JsonSchemaLoader
+{
+ public static string LoadAppTopologyJsonSchema()
+ {
+ return LoadFileText("deploy-app-topology-schema.json");
+ }
+
+ private static string LoadFileText(string resourceFileName)
+ {
+ Assembly assembly = typeof(JsonSchemaLoader).Assembly;
+ string resourceName = EmbeddedResourceHelper.FindEmbeddedResource(assembly, resourceFileName);
+ return EmbeddedResourceHelper.ReadEmbeddedResource(assembly, resourceName);
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs b/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs
new file mode 100644
index 000000000..861c46188
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Services/Util/PipelineGenerationUtil.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Deploy.Models.Templates;
+using AzureMcp.Deploy.Options.Pipeline;
+using AzureMcp.Deploy.Services.Templates;
+
+namespace AzureMcp.Deploy.Services.Util;
+
+///
+/// Utility class for generating pipeline guidelines using embedded template resources.
+///
+public static class PipelineGenerationUtil
+{
+ ///
+ /// Generates pipeline guidelines based on the provided options.
+ ///
+ /// The guidance options containing pipeline configuration.
+ /// A formatted pipeline guidelines string.
+ public static string GeneratePipelineGuidelines(GuidanceGetOptions options)
+ {
+ if (options.UseAZDPipelineConfig)
+ {
+ return TemplateService.LoadTemplate("Pipeline/azd-pipeline");
+ }
+ else
+ {
+ var parameters = CreatePipelineParameters(options);
+ return TemplateService.ProcessTemplate("Pipeline/azcli-pipeline", parameters.ToDictionary());
+ }
+ }
+
+ ///
+ /// Creates pipeline template parameters from the provided options.
+ ///
+ private static PipelineTemplateParameters CreatePipelineParameters(GuidanceGetOptions options)
+ {
+ const string defaultEnvironment = "dev";
+ var environmentNamePrompt = !string.IsNullOrEmpty(options.GithubEnvironmentName)
+ ? $"Use {options.GithubEnvironmentName} for environment name of the deployment job."
+ : $"Use '{defaultEnvironment}' for the $environment for the deployment job.";
+
+ var subscriptionIdPrompt = !string.IsNullOrEmpty(options.Subscription) && CheckGUIDFormat(options.Subscription)
+ ? $"User is deploying to subscription {options.Subscription}"
+ : "Use \"az account show --query id -o tsv\" as default subscription ID.";
+
+ var organizationName = !string.IsNullOrEmpty(options.OrganizationName) ? options.OrganizationName : "{$organization-of-repo}";
+ var repositoryName = !string.IsNullOrEmpty(options.RepositoryName) ? options.RepositoryName : "{$repository-name}";
+ var environmentName = !string.IsNullOrEmpty(options.GithubEnvironmentName) ? options.GithubEnvironmentName : defaultEnvironment;
+
+ var subjectConfig = $"repo:{organizationName}/{repositoryName}:environment:{environmentName}";
+ var environmentArg = !string.IsNullOrEmpty(options.GithubEnvironmentName) ? $"--env {options.GithubEnvironmentName}" : "--env dev";
+ var environmentCreateCommand = $"gh api --method PUT -H \"Accept: application/vnd.github+json\" repos/{organizationName}/{repositoryName}/environments/{environmentName}";
+ var jsonParameters = $"{{\"name\":\"github-federated\",\"issuer\":\"https://token.actions.githubusercontent.com\",\"subject\":\"{subjectConfig}\",\"audiences\":[\"api://AzureADTokenExchange\"]}}";
+
+ return new PipelineTemplateParameters
+ {
+ EnvironmentNamePrompt = environmentNamePrompt,
+ SubscriptionIdPrompt = subscriptionIdPrompt,
+ EnvironmentCreateCommand = environmentCreateCommand,
+ JsonParameters = jsonParameters,
+ EnvironmentArg = environmentArg
+ };
+ }
+
+ ///
+ /// Checks if the provided string is a valid GUID format.
+ ///
+ private static bool CheckGUIDFormat(string input)
+ {
+ return Guid.TryParse(input, out _);
+ }
+}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md
new file mode 100644
index 000000000..2d41d3d12
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Architecture/architecture-diagram.md
@@ -0,0 +1,6 @@
+Here is the user's mermaid diagram. Write a reminder to the user to install a Mermaid preview extension to be able to render the diagram.
+Please write this into .azure/architecture.copilotmd WITHOUT additional explanations on the deployment. Explain only the architecture and data flow.
+Make changes if these do not fulfill requirements (do not use in strings when generating the diagram):
+ ```mermaid\n{{chart}}\n```.
+Ask user if the topology is expected, if not, you should directly update the generated diagram with the user's updated instructions.
+Please inform the user that here are the supported hosting technologies: {{hostings}}.
\ No newline at end of file
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md
new file mode 100644
index 000000000..e28d5a7cc
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/appservice-rules.md
@@ -0,0 +1,2 @@
+App Service Rules:
+- App Service Site Extension (Microsoft.Web/sites/siteextensions) is required for App Service deployments.
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md
new file mode 100644
index 000000000..f7ff53fc2
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azcli-rules.md
@@ -0,0 +1,4 @@
+- If creating AzCli script, the script should stop if any command fails. Fix the error before proceeding.
+- Azure resource Naming: All resources should be named like {resourcePrefix}{resourceToken}{instance}. Alphanumeric only! Don't use special characters! resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. resourceToken is a random string and = 5 characters. It should be used for all azure resources in a resource group. instance is a number that can be used when there are multiple resource with the same type. For example, resourceToken=abcde, then resource name: myRg(resource group), myKv(keyvault), myServer(sql), myApp1(container app 1), myApp2(container app 2). Full resource name must be less than 32 characters.
+- Kubernetes (K8s) YAML naming: only Lowercase letters (a-z), digits (0-9), hyphens (-) is allowed. Must start and end with a letter or digit. Less than 20 characters.
+
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azd-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azd-rules.md
new file mode 100644
index 000000000..148423620
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/azd-rules.md
@@ -0,0 +1,5 @@
+- Ensure an User-Assigned Managed Identity exists.
+- Resource Group resource (if exists) must have tag "azd-env-name" = environmentName. Apply this tag to resource group resource ONLY.
+- Expected parameters in {{IacType}} parameters: environmentName='${{AZURE_ENV_NAME}}', location='${{AZURE_LOCATION}}'. resourceGroupName='rg-${{AZURE_ENV_NAME}}' is required if scope is subscription.
+- All container apps, app services, function apps, static web apps (and nothing else) must have tag "azd-service-name" matching the service name in azure.yaml.
+- Expected output in {{OutputFileName}}: RESOURCE_GROUP_ID.{{ContainerRegistryOutput}}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/base-iac-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/base-iac-rules.md
new file mode 100644
index 000000000..4ea1663a7
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/base-iac-rules.md
@@ -0,0 +1,16 @@
+Mandatory rules for deployment. You must implement every rule exactly as stated, with no exceptions or omissions, even if it is not a common pattern or seems redundant. Do not use your own judgment to simplify, skip, or modify any rule. If a rule is present, it must be enforced in the code, regardless of context. Adjust {{IacType}} files to align with these rules.
+
+## Deployment Tool {{DeploymentTool}} rules:
+{{DeploymentToolRules}}
+
+## IaC Type: {{IacType}} rules:
+{{IacTypeRules}}
+
+## Resources: {{ResourceTypesDisplay}}
+{{ResourceSpecificRules}}
+
+{{FinalInstructions}}
+
+## Tools needed: {{RequiredTools}}
+
+{{AdditionalNotes}}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md
new file mode 100644
index 000000000..548068175
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/bicep-rules.md
@@ -0,0 +1,3 @@
+- Expected files: main.bicep, main.parameters.json (with parameters from main.bicep).
+- Resource token format: 'uniqueString(subscription().id, resourceGroup().id, location, environmentName)' (scope = resourceGroup) or 'uniqueString(subscription().id, location, environmentName)' (scope = subscription).
+- All resources should be named like az{resourcePrefix}{resourceToken}, where resourcePrefix is a prefix for the resource (ex. 'kv' for key vault) and <= 3 characters. Alphanumeric only. Entire resource name should be 32 characters maximum. ResourceToken is the string generated by uniqueString as per earlier.
\ No newline at end of file
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/containerapp-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/containerapp-rules.md
new file mode 100644
index 000000000..1d0e10b61
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/containerapp-rules.md
@@ -0,0 +1,9 @@
+=== Additional requirements for Container Apps:
+- Attach User-Assigned Managed Identity.
+- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the AcrPull (7f951dda-4ed3-4680-a7ca-43fe172d538d) role to the user-assigned managed identity (Only one instance is required per-container registry. Define this BEFORE any container apps.).
+- Use this identity (NOT system) to connect to the container registry. A registry connection needs to be created even if we are using a template base image.
+- Container Apps MUST use base container image mcr.microsoft.com/azuredocs/containerapps-helloworld:latest. The property is set via {{ImageProperty}}.
+{{CorsConfiguration}}
+- Define all used secrets; Use Key Vault if possible.
+{{LogAnalyticsConfiguration}}
+===
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/final-instructions.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/final-instructions.md
new file mode 100644
index 000000000..5baae9f0a
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/final-instructions.md
@@ -0,0 +1 @@
+Call get_errors every time you make code changes, otherwise your deployment will fail. You must follow ALL of the previously mentioned rules. DO NOT IGNORE ANY RULES. Call this tool again if need to get the rules again. Show the user a report line-by-line of each rule that was applied. Only skip a rule if there is no corresponding resource (e.g. no function app). Do not stop at error-free code, you must apply all the rules.
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/functionapp-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/functionapp-rules.md
new file mode 100644
index 000000000..aac1181e8
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/functionapp-rules.md
@@ -0,0 +1,10 @@
+=== Additional requirements for Function Apps:
+- Attach User-Assigned Managed Identity.
+- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Blob Data Owner (b7e6dc6d-f1e8-4753-8033-0f276bb0955b) role to the user-assigned managed identity.
+- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Blob Data Contributor (ba92f5b4-2d11-453d-a403-e96b0029c9fe) role to the user-assigned managed identity.
+- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Queue Data Contributor (974c5e8b-45b9-4653-ba55-5f855dd0fb88) role to the user-assigned managed identity.
+- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Storage Table Data Contributor (0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3) role to the user-assigned managed identity.
+- MANDATORY: Add a {{RoleAssignmentResource}} resource to assign the Monitoring Metrics Publisher (3913510d-42f4-4e42-8a64-420c390055eb) role to the user-assigned managed identity.
+- Create a storage account and connect to the function app.
+- Define diagnostic settings to save logs. The resource type is {{DiagnosticSettingsResource}}.
+===
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/terraform-rules.md b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/terraform-rules.md
new file mode 100644
index 000000000..25eeae467
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/IaCRules/terraform-rules.md
@@ -0,0 +1,2 @@
+- Expected files: main.tf, main.tfvars.json (with the minimally required parameters), outputs.tf.
+- Resource names should use Azure CAF naming convention. This is required for deployments. Add aztfmod/azurecaf in the required provider. DO NOT use random_length. NO suffixes needed.
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md
new file mode 100644
index 000000000..24fb17f91
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azcli-pipeline.md
@@ -0,0 +1,21 @@
+Help the user to set up a CI/CD pipeline to deploy to Azure with the following steps IN ORDER. **RUN the commands directly and DO NOT just give instructions. DO NOT ask user to provide information.**
+
+ 1. First generate a Github Actions workflow file to deploy to Azure. {{EnvironmentNamePrompt}} The pipeline at least contains these steps in order:
+ a. Azure login: login with a service principal using OIDC. DO NOT use secret.
+ b. Docker build
+ c. Deploy infrastructure: Use AZ CLI "az deployment sub/group create" command. Use "az deployment sub/group wait" to wait the deployment to finish. Refer to the infra files to set the correct parameters.
+ d. Azure Container Registry login: login into the container registry created in the previous step. Use "az acr list" to get the correct registry name if you are not sure.
+ e. Push app images to ACR
+ f. Deploy to hosting service. Use the infra deployment output or AZ CLI to list hosting resources. Find the name or ID of the hosting resources from "az list" if you are not sure.
+
+ Pay attention to the name of the branches to which the pipeline is triggered.
+
+ 2. Run '{{EnvironmentCreateCommand}}' to create the environment in the repository.
+
+ 3. - {{SubscriptionIdPrompt}}
+ - Run "az ad sp create-for-rbac" command to create a service principal. Grant the service principal *Contributor* role of the subscription. Also grant the service principal *User Access Administrator*
+ **Use Federated credentials in order to authenticate to Azure services from GitHub Actions workflows. The command is **az ad app federated-credential create --id <$service-principal-app-id> --parameters '{{JsonParameters}}'**. You MUST use ' and \"(DO NOT forget the slash \) in the command. Use the current Github org/repo to fill in the subject property.
+
+ 4. Run command "gh secret set --body {{EnvironmentArg}}" to configure the AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID of the service principal in Github secrets using Github CLI.
+
+ ** DO NOT prompt user for any information. Find them on your own. **
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md
new file mode 100644
index 000000000..630fb0a50
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Pipeline/azd-pipeline.md
@@ -0,0 +1 @@
+Run "azd pipeline config" to help the user create a deployment pipeline.
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/aks-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/aks-steps.md
new file mode 100644
index 000000000..74f84a41c
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/aks-steps.md
@@ -0,0 +1,6 @@
+2. Build and Deploy the Application
+ 1. Build and Push Docker Image: {Agent should check if Dockerfile exists, if not add the step: "generate a Dockerfile for the application deployment", if does, list the Dockerfile path}.
+ 2. Prepare Kubernetes Manifests: {Agent should check if Kubernetes YAML files exists, if not add the step: "generate for the application deployment", if does, list the yaml files path}.
+ 3. Deploy to AKS: Use `kubectl apply` to deploy manifests to the AKS cluster
+3. Validation:
+ 1. Verify pods are running and services are exposed
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azcli-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azcli-steps.md
new file mode 100644
index 000000000..f24f6fc26
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azcli-steps.md
@@ -0,0 +1,4 @@
+1. Provision Azure Infrastructure:
+ 1. Generate Azure CLI scripts for required azure resources based on the plan.
+ 2. Check and fix the generated Azure CLI scripts for grammar errors.
+ 3. Run the Azure CLI scripts to provision the resources and confirm each resource is created or already exists
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azd-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azd-steps.md
new file mode 100644
index 000000000..a646fde8b
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/azd-steps.md
@@ -0,0 +1,7 @@
+1. Provision Azure Infrastructure{{DeployTitle}}:
+ 1. Based on following required Azure resources in plan, get the IaC rules from the tool `iac-rules-get`
+ 2. Generate IaC ({{IacType}} files) for required azure resources based on the plan.
+ 3. Pre-check: use `get_errors` tool to check generated Bicep grammar errors. Fix the errors if exist.
+ 4. Run the AZD command `azd up` to provision the resources and confirm each resource is created or already exists.
+ 5. Check the deployment output to ensure the resources are provisioned successfully.
+ {{CheckLog}}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/containerapp-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/containerapp-steps.md
new file mode 100644
index 000000000..976dd0eb1
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/containerapp-steps.md
@@ -0,0 +1,5 @@
+2. Build and Deploy the Application:
+ 1. Build and Push Docker Image: Agent should check if Dockerfile exists, if not add the step: 'generate a Dockerfile for the application deployment', if it does, list the Dockerfile path
+ 2. Deploy to {{AzureComputeHost}}: Use Azure CLI command to deploy the application
+3. Validation:
+ 1. Verify command output to ensure the application is deployed successfully
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md
new file mode 100644
index 000000000..786bb780a
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/deployment-plan-base.md
@@ -0,0 +1,61 @@
+# {{Title}}
+
+{Agent should fill in and polish the markdown template below to generate a deployment plan for the project. Then save it to '.azure/plan.copilotmd' file. Don't add cost estimation! Don't add extra validation steps unless it is required! Don't change the tool name!}
+
+## **Goal**
+Based on the project to provide a plan to deploy the project to {{AzureComputeHost}} using {{ProvisioningTool}}.
+
+## **Project Information**
+{
+briefly summarize the project structure, services, and configurations, example:
+AppName: web
+- **Technology Stack**: ASP.NET Core 7.0 Razor Pages application
+- **Application Type**: Task Manager web application with client-side JavaScript
+- **Containerization**: Ready for deployment with existing Dockerfile
+- **Dependencies**: No external dependencies detected (database, APIs, etc.)
+- **Hosting Recommendation**: Azure Container Apps for scalable, serverless container hosting
+}
+
+## **Azure Resources Architecture**
+> **Install the mermaid extension in IDE to view the architecture.**
+{a mermaid graph of following recommended azure resource architecture. Only keep the most important edges to make structure clear and readable.}
+{
+List how data flows between the services, example:
+- The container app gets its image from the Azure Container Registry.
+- The container app gets requests and interacts with the Azure SQL Database for data storage and retrieval.
+}
+
+## **Recommended Azure Resources**
+
+Recommended App service hosting the project //agent should fulfill this for each app instance
+- Application {{ProjectName}}
+ - Hosting Service Type: {{AzureComputeHost}} // it can be Azure Container Apps, Web App Service, Azure Functions, Azure Kubernetes Service. Recommend one based on the project.
+ - SKU // recommend a sku based on the project, show its performance. Don't estimate the cost.
+ - Configuration:
+ - language: {language} //detect from the project, it can be nodejs, python, dotnet, etc.
+ - dockerFilePath: {dockerFilePath}// fulfill this if service.azureComputeHost is ContainerApp
+ - dockerContext: {dockerContext}// fulfill this if service.azureComputeHost is ContainerApp
+ - Environment Variables: [] // the env variables that are used in the project/required by service
+ - Dependencies Resource
+ - Dependency Name
+ - SKU // recommend a sku, show its performance.
+ - Service Type // it can be Azure SQL, Azure Cosmos DB, Azure Storage, etc.
+ - Connection Type // it can be connection string, managed identity, etc.
+ - Environment Variables: [] // the env variables that are used in the project/required by dependency
+
+Recommended Supporting Services
+- Application Insights
+- Log Analytics Workspace: set all app service to connect to this
+- Key Vault(Optional): If there are dependencies such as postgresql/sql/mysql, create a Key Vault to store connection string. If not, the resource should not show.
+If there is a Container App, the following resources are required:
+- Container Registry
+
+
+Recommended Security Configurations
+If there is a Container App
+- User managed identity: Must be assigned to the container app.
+- AcrPull role assignment: User managed identity must have **AcrPull** role ("7f951dda-4ed3-4680-a7ca-43fe172d538d") assigned to the container registry.
+
+## **Execution Step**
+> **Below are the steps for Copilot to follow; ask Copilot to update or execute this plan.**
+{{ExecutionSteps}}
diff --git a/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/summary-steps.md b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/summary-steps.md
new file mode 100644
index 000000000..5ab29e67f
--- /dev/null
+++ b/areas/deploy/src/AzureMcp.Deploy/Templates/Plan/summary-steps.md
@@ -0,0 +1,2 @@
+{{StepNumber}}: Summary:
+ 1. Summarize the deployment result and save to '.azure/summary.copilotmd'. It should list all changes deployment files and brief description of each file. Then have a diagram showing the provisioned azure resource.
diff --git a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj
new file mode 100644
index 000000000..2ef019eb3
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/AzureMcp.Deploy.LiveTests.csproj
@@ -0,0 +1,17 @@
+
+
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs
new file mode 100644
index 000000000..6e1fe4e82
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.LiveTests/DeployCommandTests.cs
@@ -0,0 +1,172 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using AzureMcp.Deploy.Models;
+using AzureMcp.Deploy.Services;
+using AzureMcp.Tests.Client;
+using AzureMcp.Tests.Client.Helpers;
+using ModelContextProtocol.Client;
+using Xunit;
+
+namespace AzureMcp.Deploy.LiveTests;
+
+public class DeployCommandTests : CommandTestsBase,
+ IClassFixture
+{
+ private readonly string _subscriptionId;
+
+ public DeployCommandTests(LiveTestFixture liveTestFixture, ITestOutputHelper output) : base(liveTestFixture, output)
+ {
+ _subscriptionId = Settings.SubscriptionId;
+ }
+
+ [Fact]
+ public async Task Should_get_plan()
+ {
+ // act
+ var result = await CallToolMessageAsync(
+ "azmcp_deploy_plan_get",
+ new()
+ {
+ { "workspace-folder", "C:/" },
+ { "project-name", "django" },
+ { "target-app-service", "ContainerApp" },
+ { "provisioning-tool", "AZD" },
+ { "azd-iac-options", "bicep" }
+ });
+ // assert
+ Assert.StartsWith("# Azure Deployment Plan for django Project", result);
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_code_rules()
+ {
+ // arrange
+ var parameters = new
+ {
+ deploymentTool = "azd",
+ iacType = "bicep",
+ resourceTypes = new[] { "appservice", "azurestorage" }
+ };
+
+ // act
+ var result = await CallToolMessageAsync(
+ "azmcp_deploy_iac_rules_get",
+ new()
+ {
+ { "deployment-tool", "azd" },
+ { "iac-type", "bicep" },
+ { "resource-types", "appservice, azurestorage" }
+ });
+
+ Assert.Contains("Deployment Tool azd rules", result ?? String.Empty, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_rules_for_terraform()
+ {
+ // act
+ var result = await CallToolMessageAsync(
+ "azmcp_deploy_iac_rules_get",
+ new()
+ {
+ { "deployment-tool", "azd" },
+ { "iac-type", "terraform" },
+ { "resource-types", "containerapp, azurecosmosdb" }
+ });
+
+ // assert
+ Assert.Contains("IaC Type: terraform rules", result ?? String.Empty, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_generate_pipeline()
+ {
+ // act
+ var result = await CallToolMessageAsync(
+ "azmcp_deploy_pipeline_guidance_get",
+ new()
+ {
+ { "subscription", _subscriptionId },
+ { "use-azd-pipeline-config", true }
+ });
+
+ // assert
+ Assert.Contains("Run \"azd pipeline config\" to help the user create a deployment pipeline.", result);
+ }
+
+ [Fact]
+ public async Task Should_generate_pipeline_with_github_details()
+ {
+ // act
+ var result = await CallToolMessageAsync(
+ "azmcp_deploy_pipeline_guidance_get",
+ new()
+ {
+ { "subscription", _subscriptionId },
+ { "use-azd-pipeline-config", false },
+ { "organization-name", "test-org" },
+ { "repository-name", "test-repo" },
+ { "github-environment-name", "production" }
+ });
+
+ // assert
+ Assert.StartsWith("Help the user to set up a CI/CD pipeline", result ?? String.Empty);
+ }
+
+ // skip as this test need local files
+ // [Fact]
+ // public async Task Should_get_azd_app_logs()
+ // {
+ // // act
+ // var result = await CallToolMessageAsync(
+ // "azmcp_deploy_app_logs_get",
+ // new()
+ // {
+ // { "subscription", _subscriptionId },
+ // { "workspace-folder", "C:/Users/" },
+ // { "azd-env-name", "dotnet-demo" },
+ // { "limit", 10 }
+ // });
+
+ // // assert
+ // Assert.StartsWith("App logs retrieved:", result);
+ // }
+
+
+ private async Task CallToolMessageAsync(string command, Dictionary parameters)
+ {
+ // Output will be streamed, so if we're not in debug mode, hold the debug output for logging in the failure case
+ Action writeOutput = Settings.DebugOutput
+ ? s => Output.WriteLine(s)
+ : s => FailureOutput.AppendLine(s);
+
+ writeOutput($"request: {JsonSerializer.Serialize(new { command, parameters })}");
+
+ var result = await Client.CallToolAsync(command, parameters);
+
+ var content = McpTestUtilities.GetFirstText(result.Content);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ Output.WriteLine($"response: {JsonSerializer.Serialize(result)}");
+ throw new Exception("No JSON content found in the response.");
+ }
+
+ var root = JsonSerializer.Deserialize(content!);
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ Output.WriteLine($"response: {JsonSerializer.Serialize(result)}");
+ throw new Exception("Invalid JSON response.");
+ }
+
+ // Remove the `args` property and log the content
+ var trimmed = root.Deserialize()!;
+ trimmed.Remove("args");
+ writeOutput($"response content: {trimmed.ToJsonString(new JsonSerializerOptions { WriteIndented = true })}");
+
+ return root.TryGetProperty("message", out var property) ? property.GetString() : null;
+ }
+
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj
new file mode 100644
index 000000000..f2cc61643
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/AzureMcp.Deploy.UnitTests.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/App/LogsGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/App/LogsGetCommandTests.cs
new file mode 100644
index 000000000..303cca1b1
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/App/LogsGetCommandTests.cs
@@ -0,0 +1,201 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine.Parsing;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Deploy.Commands.App;
+using AzureMcp.Deploy.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using Xunit;
+
+namespace AzureMcp.Deploy.UnitTests.Commands.App;
+
+
+public class LogsGetCommandTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+ private readonly IDeployService _deployService;
+ private readonly Parser _parser;
+ private readonly CommandContext _context;
+ private readonly LogsGetCommand _command;
+
+ public LogsGetCommandTests()
+ {
+ _logger = Substitute.For>();
+ _deployService = Substitute.For();
+
+ var collection = new ServiceCollection();
+ collection.AddSingleton(_deployService);
+ _serviceProvider = collection.BuildServiceProvider();
+ _context = new(_serviceProvider);
+ _command = new(_logger);
+ _parser = new(_command.GetCommand());
+ }
+
+ [Fact]
+ public async Task Should_get_azd_app_logs()
+ {
+ // arrange
+ var expectedLogs = "App logs retrieved:\n[2024-01-01 10:00:00] Application started\n[2024-01-01 10:01:00] Processing request";
+ _deployService.GetAzdResourceLogsAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(expectedLogs);
+
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--workspace-folder", "C:/Users/",
+ "--azd-env-name", "dotnet-demo",
+ "--limit", "10"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.StartsWith("App logs retrieved:", result.Message);
+ Assert.Contains("Application started", result.Message);
+
+ }
+
+ [Fact]
+ public async Task Should_get_azd_app_logs_with_default_limit()
+ {
+ // arrange
+ var expectedLogs = "App logs retrieved:\nSample log entry";
+ _deployService.GetAzdResourceLogsAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(expectedLogs);
+
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--workspace-folder", "C:/project",
+ "--azd-env-name", "my-env"
+ // No limit specified - should use default
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.StartsWith("App logs retrieved:", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_handle_no_logs_found()
+ {
+ // arrange
+ _deployService.GetAzdResourceLogsAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns("No logs found.");
+
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--workspace-folder", "C:/empty-project",
+ "--azd-env-name", "empty-env",
+ "--limit", "50"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.Equal("No logs found.", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_handle_error_during_log_retrieval()
+ {
+ // arrange
+ var errorMessage = "Error during retrieval of app logs of azd project:\nNo resource group with tag {\"azd-env-name\": test-env} found.";
+ _deployService.GetAzdResourceLogsAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(errorMessage);
+
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--workspace-folder", "C:/invalid-project",
+ "--azd-env-name", "test-env"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.StartsWith("Error during retrieval of app logs", result.Message);
+ Assert.Contains("test-env", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_handle_service_exception()
+ {
+ // arrange
+ _deployService.GetAzdResourceLogsAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .ThrowsAsync(new InvalidOperationException("Failed to connect to Azure"));
+
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--workspace-folder", "C:/project",
+ "--azd-env-name", "test-env"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.NotEqual(200, result.Status); // Should be an error status
+ Assert.NotNull(result.Message);
+ Assert.Contains("Failed to connect to Azure", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_validate_required_parameters()
+ {
+ // arrange - missing required workspace-folder parameter
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--azd-env-name", "test-env"
+ // Missing workspace-folder
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.NotEqual(200, result.Status); // Should fail validation
+ }
+
+
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs
new file mode 100644
index 000000000..6876c8d5b
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Architecture/DiagramGenerateCommandTests.cs
@@ -0,0 +1,154 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine;
+using System.CommandLine.Parsing;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Deploy.Commands;
+using AzureMcp.Deploy.Commands.Architecture;
+using AzureMcp.Deploy.Options;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace AzureMcp.Deploy.UnitTests.Commands.Architecture;
+
+
+public class DiagramGenerateCommandTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ public DiagramGenerateCommandTests()
+ {
+ _logger = Substitute.For>();
+
+ var collection = new ServiceCollection();
+ _serviceProvider = collection.BuildServiceProvider();
+ }
+
+
+ [Fact]
+ public async Task GenerateArchitectureDiagram_ShouldReturnNoServiceDetected()
+ {
+ var command = new DiagramGenerateCommand(_logger);
+ var args = command.GetCommand().Parse(["--raw-mcp-tool-input", "{\"projectName\": \"test\",\"services\": []}"]);
+ var context = new CommandContext(_serviceProvider);
+ var response = await command.ExecuteAsync(context, args);
+ Assert.NotNull(response);
+ Assert.Equal(200, response.Status);
+ Assert.Contains("No service detected", response.Message);
+ }
+
+ [Fact]
+ public async Task GenerateArchitectureDiagram_InvalidJsonInput()
+ {
+ var command = new DiagramGenerateCommand(_logger);
+ var args = command.GetCommand().Parse(["--raw-mcp-tool-input", "test"]);
+ var context = new CommandContext(_serviceProvider);
+ var response = await command.ExecuteAsync(context, args);
+ Assert.NotNull(response);
+ Assert.Equal(500, response.Status);
+ Assert.Contains("Invalid JSON format", response.Message);
+ }
+
+ [Fact]
+ public async Task GenerateArchitectureDiagram_ShouldReturnEncryptedDiagramUrl()
+ {
+ var command = new DiagramGenerateCommand(_logger);
+ var appTopology = new AppTopology()
+ {
+ WorkspaceFolder = "testWorkspace",
+ ProjectName = "testProject",
+ Services = new ServiceConfig[]
+ {
+ new ServiceConfig
+ {
+ Name = "website",
+ AzureComputeHost = "appservice",
+ Language = "dotnet",
+ Port = "80",
+ Dependencies = new DependencyConfig[]
+ {
+ new DependencyConfig { Name = "store", ConnectionType = "system-identity", ServiceType = "azurestorageaccount" }
+ },
+ },
+ new ServiceConfig
+ {
+ Name = "frontend",
+ Path = "testWorkspace/web",
+ AzureComputeHost = "containerapp",
+ Language = "js",
+ Port = "8080",
+ Dependencies = new DependencyConfig[]
+ {
+ new DependencyConfig { Name = "backend", ConnectionType = "http", ServiceType = "containerapp" }
+ }
+ },
+ new ServiceConfig
+ {
+ Name = "backend",
+ Path = "testWorkspace/api",
+ AzureComputeHost = "containerapp",
+ Language = "python",
+ Port = "3000",
+ Dependencies = new DependencyConfig[]
+ {
+ new DependencyConfig { Name = "db", ConnectionType = "secret", ServiceType = "azurecosmosdb" },
+ new DependencyConfig { Name = "secretStore", ConnectionType = "system-identity", ServiceType = "azurekeyvault" }
+ }
+ },
+ new ServiceConfig
+ {
+ Name = "frontendservice",
+ Path = "testWorkspace/web",
+ AzureComputeHost = "aks",
+ Language = "ts",
+ Port = "3001",
+ Dependencies = new DependencyConfig[]
+ {
+ new DependencyConfig { Name = "backendservice", ConnectionType = "user-identity", ServiceType = "aks"}
+ }
+ },
+ new ServiceConfig
+ {
+ Name = "backendservice",
+ Path = "testWorkspace/api",
+ AzureComputeHost = "aks",
+ Language = "python",
+ Port = "3000",
+ Dependencies = new DependencyConfig[]
+ {
+ new DependencyConfig { Name = "database", ConnectionType = "user-identity", ServiceType = "azurecacheforredis" }
+ }
+ }
+ }
+ };
+
+ var args = command.GetCommand().Parse(["--raw-mcp-tool-input", JsonSerializer.Serialize(appTopology)]);
+ var context = new CommandContext(_serviceProvider);
+ var response = await command.ExecuteAsync(context, args);
+ Assert.NotNull(response);
+ Assert.Equal(200, response.Status);
+ // Extract the URL from the response message
+ var graphStartPattern = "```mermaid";
+ var graphStartIndex = response.Message.IndexOf(graphStartPattern);
+ Assert.True(graphStartIndex >= 0, "Graph data starting with '```mermaid' should be present in the response");
+
+ // Extract the full graph (assuming it ends at whitespace or end of string)
+ var graphStartPosition = graphStartIndex;
+ var graphEndPosition = response.Message.IndexOf("```", graphStartIndex + 1);
+
+ if (graphEndPosition == -1)
+ graphEndPosition = response.Message.Length;
+
+ var extractedGraph = response.Message.Substring(graphStartPosition, graphEndPosition - graphStartPosition);
+ Assert.StartsWith(graphStartPattern, extractedGraph);
+ Assert.NotEmpty(extractedGraph);
+ Assert.Contains("website", extractedGraph);
+ Assert.Contains("store", extractedGraph);
+ }
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Infrastructure/RulesGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Infrastructure/RulesGetCommandTests.cs
new file mode 100644
index 000000000..1da450a06
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Infrastructure/RulesGetCommandTests.cs
@@ -0,0 +1,181 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine.Parsing;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Deploy.Commands.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace AzureMcp.Deploy.UnitTests.Commands.Infrastructure;
+
+
+public class RulesGetCommandTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+ private readonly Parser _parser;
+ private readonly CommandContext _context;
+ private readonly RulesGetCommand _command;
+
+ public RulesGetCommandTests()
+ {
+ _logger = Substitute.For>();
+
+ var collection = new ServiceCollection();
+ _serviceProvider = collection.BuildServiceProvider();
+ _context = new(_serviceProvider);
+ _command = new(_logger);
+ _parser = new(_command.GetCommand());
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_code_rules()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "azd",
+ "--iac-type", "bicep",
+ "--resource-types", "appservice, azurestorage"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Deployment Tool azd rules", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_rules_for_terraform()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "azd",
+ "--iac-type", "terraform",
+ "--resource-types", "containerapp, azurecosmosdb"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Expected parameters in terraform parameters", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_rules_for_function_app()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "azd",
+ "--iac-type", "bicep",
+ "--resource-types", "function"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Additional requirements for Function Apps", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Storage Blob Data Owner", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_rules_for_container_app()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "azd",
+ "--iac-type", "bicep",
+ "--resource-types", "containerapp"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Additional requirements for Container Apps", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("mcr.microsoft.com/azuredocs/containerapps-helloworld:latest", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_get_infrastructure_rules_for_azcli_deployment_tool()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "AzCli",
+ "--iac-type", "",
+ "--resource-types", "aks"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("If creating AzCli script, the script should stop if any command fails.", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_include_necessary_tools_in_response()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "azd",
+ "--iac-type", "terraform",
+ "--resource-types", "containerapp"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Tools needed:", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("az cli", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("azd", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("docker", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Should_handle_multiple_resource_types()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--deployment-tool", "azd",
+ "--iac-type", "bicep",
+ "--resource-types", "appservice,containerapp,function"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Resources: appservice, containerapp, function", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("App Service Rules", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Additional requirements for Container Apps", result.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Additional requirements for Function Apps", result.Message, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Pipeline/GuidanceGetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Pipeline/GuidanceGetCommandTests.cs
new file mode 100644
index 000000000..acef5a2d7
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Pipeline/GuidanceGetCommandTests.cs
@@ -0,0 +1,178 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine.Parsing;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Deploy.Commands.Pipeline;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace AzureMcp.Deploy.UnitTests.Commands.Pipeline;
+
+
+public class GuidanceGetCommandTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+ private readonly Parser _parser;
+ private readonly CommandContext _context;
+ private readonly GuidanceGetCommand _command;
+
+ public GuidanceGetCommandTests()
+ {
+ _logger = Substitute.For>();
+
+ var collection = new ServiceCollection();
+ _serviceProvider = collection.BuildServiceProvider();
+ _context = new(_serviceProvider);
+ _command = new(_logger);
+ _parser = new(_command.GetCommand());
+ }
+
+ [Fact]
+ public async Task Should_generate_pipeline()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--use-azd-pipeline-config", "true"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Run \"azd pipeline config\" to help the user create a deployment pipeline.", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_generate_pipeline_with_github_details()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--use-azd-pipeline-config", "false",
+ "--organization-name", "test-org",
+ "--repository-name", "test-repo",
+ "--github-environment-name", "production"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Help the user to set up a CI/CD pipeline", result.Message);
+ Assert.Contains("test-org", result.Message);
+ Assert.Contains("test-repo", result.Message);
+ Assert.Contains("production", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_generate_pipeline_with_default_azd_pipeline_config()
+ {
+ // arrange - not providing use-azd-pipeline-config should default to false
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Help the user to set up a CI/CD pipeline", result.Message);
+ Assert.Contains("Github Actions workflow", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_generate_pipeline_with_minimal_github_info()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--use-azd-pipeline-config", "false"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("Help the user to set up a CI/CD pipeline", result.Message);
+ Assert.Contains("{$organization-of-repo}", result.Message);
+ Assert.Contains("{$repository-name}", result.Message);
+ Assert.Contains("dev", result.Message); // default environment
+ }
+
+ [Fact]
+ public async Task Should_handle_guid_subscription_id()
+ {
+ // arrange
+ var guidSubscriptionId = "12345678-1234-1234-1234-123456789abc";
+ var args = _parser.Parse([
+ "--subscription", guidSubscriptionId,
+ "--use-azd-pipeline-config", "false"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains($"User is deploying to subscription {guidSubscriptionId}", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_handle_non_guid_subscription_id()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--subscription", "my-subscription-name",
+ "--use-azd-pipeline-config", "false"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("az account show --query id -o tsv", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_include_service_principal_creation_steps()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--subscription", "test-subscription-id",
+ "--use-azd-pipeline-config", "false"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("az ad sp create-for-rbac", result.Message);
+ Assert.Contains("federated-credential create", result.Message);
+ Assert.Contains("gh secret set", result.Message);
+ }
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Plan/GetCommandTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Plan/GetCommandTests.cs
new file mode 100644
index 000000000..561588ad4
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/Commands/Plan/GetCommandTests.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine.Parsing;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Deploy.Commands.Plan;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace AzureMcp.Deploy.UnitTests.Commands.Plan;
+
+
+public class GetCommandTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+ private readonly Parser _parser;
+ private readonly CommandContext _context;
+ private readonly GetCommand _command;
+
+ public GetCommandTests()
+ {
+ _logger = Substitute.For>();
+
+ var collection = new ServiceCollection();
+ _serviceProvider = collection.BuildServiceProvider();
+ _context = new(_serviceProvider);
+ _command = new(_logger);
+ _parser = new(_command.GetCommand());
+ }
+
+ [Fact]
+ public async Task GetPlan_Should_Return_Expected_Result()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--workspace-folder", "C:/",
+ "--project-name", "django",
+ "--target-app-service", "ContainerApp",
+ "--provisioning-tool", "AZD",
+ "--azd-iac-options", "bicep"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("# Azure Deployment Plan for django Project", result.Message);
+ Assert.Contains("Azure Container Apps", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_get_plan_with_default_iac_options()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--workspace-folder", "C:/test",
+ "--project-name", "myapp",
+ "--target-app-service", "WebApp",
+ "--provisioning-tool", "azd"
+ // No azd-iac-options provided - should default to "bicep"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("# Azure Deployment Plan for myapp Project", result.Message);
+ Assert.Contains("Azure Web App Service", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_get_plan_for_kubernetes()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--workspace-folder", "C:/k8s-project",
+ "--project-name", "k8s-app",
+ "--target-app-service", "AKS",
+ "--provisioning-tool", "azcli"
+ ]);
+
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("# Azure Deployment Plan for k8s-app Project", result.Message);
+ Assert.Contains("Azure Kubernetes Service", result.Message);
+ }
+
+ [Fact]
+ public async Task Should_get_plan_with_default_target_service()
+ {
+ // arrange
+ var args = _parser.Parse([
+ "--workspace-folder", "C:/",
+ "--project-name", "default-app",
+ "--target-app-service", "unknown-service", // This should default to Container Apps
+ "--provisioning-tool", "AZD"
+ ]);
+ // act
+ var result = await _command.ExecuteAsync(_context, args);
+
+ // assert
+ Assert.NotNull(result);
+ Assert.Equal(200, result.Status);
+ Assert.NotNull(result.Message);
+ Assert.Contains("# Azure Deployment Plan for default-app Project", result.Message);
+ Assert.Contains("Azure Container Apps", result.Message); // Should default to Container Apps
+ }
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/DeploymentPlanTemplateUtilV2Tests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/DeploymentPlanTemplateUtilV2Tests.cs
new file mode 100644
index 000000000..b2fb73e43
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/DeploymentPlanTemplateUtilV2Tests.cs
@@ -0,0 +1,151 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Deploy.Services.Util;
+using Xunit;
+
+namespace AzureMcp.Deploy.Services.Util;
+
+public sealed class DeploymentPlanTemplateUtilV2Tests
+{
+ [Theory]
+ [InlineData("TestProject", "ContainerApp", "AZD", "bicep")]
+ [InlineData("", "WebApp", "AzCli", "")]
+ [InlineData("MyApp", "AKS", "AZD", "terraform")]
+ public void GetPlanTemplate_ValidInputs_ReturnsFormattedTemplate(
+ string projectName,
+ string targetAppService,
+ string provisioningTool,
+ string azdIacOptions)
+ {
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ projectName,
+ targetAppService,
+ provisioningTool,
+ azdIacOptions);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEmpty(result);
+
+ // Should contain expected sections
+ Assert.Contains("## **Goal**", result);
+ Assert.Contains("## **Project Information**", result);
+ Assert.Contains("## **Azure Resources Architecture**", result);
+ Assert.Contains("## **Recommended Azure Resources**", result);
+ Assert.Contains("## **Execution Step**", result);
+
+ // Should not contain unprocessed placeholders for main content
+ Assert.DoesNotContain("{{Title}}", result);
+ Assert.DoesNotContain("{{ProvisioningTool}}", result);
+
+ // Should contain appropriate provisioning tool
+ if (provisioningTool.ToLowerInvariant() == "azd")
+ {
+ Assert.Contains("azd up", result);
+ }
+ else
+ {
+ Assert.Contains("Azure CLI", result);
+ }
+ }
+
+ [Fact]
+ public void GetPlanTemplate_EmptyProjectName_UsesDefaultTitle()
+ {
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ "",
+ "ContainerApp",
+ "AZD",
+ "bicep");
+
+ // Assert
+ Assert.Contains("Azure Deployment Plan", result);
+ Assert.DoesNotContain("Azure Deployment Plan for Project", result);
+ }
+
+ [Fact]
+ public void GetPlanTemplate_WithProjectName_UsesProjectSpecificTitle()
+ {
+ // Arrange
+ var projectName = "MyTestProject";
+
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ projectName,
+ "ContainerApp",
+ "AZD",
+ "bicep");
+
+ // Assert
+ Assert.Contains($"Azure Deployment Plan for {projectName} Project", result);
+ }
+
+ [Theory]
+ [InlineData("containerapp", "Azure Container Apps")]
+ [InlineData("webapp", "Azure Web App Service")]
+ [InlineData("functionapp", "Azure Functions")]
+ [InlineData("aks", "Azure Kubernetes Service")]
+ [InlineData("unknown", "Azure Container Apps")] // Default case
+ public void GetPlanTemplate_DifferentTargetServices_MapsToCorrectAzureHost(
+ string targetAppService,
+ string expectedAzureHost)
+ {
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ "TestProject",
+ targetAppService,
+ "AZD",
+ "bicep");
+
+ // Assert
+ Assert.Contains(expectedAzureHost, result);
+ }
+
+ [Fact]
+ public void GetPlanTemplate_AzdWithoutIacOptions_DefaultsToBicep()
+ {
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ "TestProject",
+ "ContainerApp",
+ "azd",
+ "");
+
+ // Assert
+ Assert.Contains("bicep", result);
+ }
+
+ [Fact]
+ public void GetPlanTemplate_AksTarget_IncludesKubernetesSteps()
+ {
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ "TestProject",
+ "AKS",
+ "AZD",
+ "bicep");
+
+ // Assert
+ Assert.Contains("kubectl apply", result);
+ Assert.Contains("Kubernetes", result);
+ Assert.Contains("pods are running", result);
+ }
+
+ [Fact]
+ public void GetPlanTemplate_ContainerAppWithAzCli_IncludesDockerSteps()
+ {
+ // Act
+ var result = DeploymentPlanTemplateUtil.GetPlanTemplate(
+ "TestProject",
+ "ContainerApp",
+ "AzCli",
+ "");
+
+ // Assert
+ Assert.Contains("Build and Push Docker Image", result);
+ Assert.Contains("Dockerfile", result);
+ }
+}
diff --git a/areas/deploy/tests/AzureMcp.Deploy.UnitTests/TemplateServiceTests.cs b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/TemplateServiceTests.cs
new file mode 100644
index 000000000..2d2bf51f2
--- /dev/null
+++ b/areas/deploy/tests/AzureMcp.Deploy.UnitTests/TemplateServiceTests.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Deploy.Services.Templates;
+using Xunit;
+
+namespace AzureMcp.Deploy.Services.Templates;
+
+public sealed class TemplateServiceTests
+{
+ [Fact]
+ public void LoadTemplate_ValidTemplate_ReturnsContent()
+ {
+ // Arrange
+ var templateName = "Plan/deployment-plan-base";
+
+ // Act
+ var result = TemplateService.LoadTemplate(templateName);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEmpty(result);
+ Assert.Contains("{{Title}}", result);
+ Assert.Contains("{{ProjectName}}", result);
+ }
+
+ [Fact]
+ public void LoadTemplate_InvalidTemplate_ThrowsFileNotFoundException()
+ {
+ // Arrange
+ var templateName = "Plan/non-existent-template";
+
+ // Act & Assert
+ Assert.Throws(() => TemplateService.LoadTemplate(templateName));
+ }
+
+ [Fact]
+ public void ProcessTemplate_WithReplacements_ReplacesPlaceholders()
+ {
+ // Arrange
+ var templateName = "Plan/deployment-plan-base";
+ var replacements = new Dictionary
+ {
+ { "Title", "Test Deployment Plan" },
+ { "ProjectName", "TestProject" },
+ { "ProvisioningTool", "AZD" }
+ };
+
+ // Act
+ var result = TemplateService.ProcessTemplate(templateName, replacements);
+
+ // Assert
+ Assert.Contains("Test Deployment Plan", result);
+ Assert.Contains("TestProject", result);
+ Assert.Contains("AZD", result);
+ Assert.DoesNotContain("{{Title}}", result);
+ Assert.DoesNotContain("{{ProjectName}}", result);
+ Assert.DoesNotContain("{{ProvisioningTool}}", result);
+ }
+
+ [Fact]
+ public void ProcessTemplateContent_WithReplacements_ReplacesPlaceholders()
+ {
+ // Arrange
+ var templateContent = "Hello {{Name}}, welcome to {{Project}}!";
+ var replacements = new Dictionary
+ {
+ { "Name", "John" },
+ { "Project", "Azure MCP" }
+ };
+
+ // Act
+ var result = TemplateService.ProcessTemplateContent(templateContent, replacements);
+
+ // Assert
+ Assert.Equal("Hello John, welcome to Azure MCP!", result);
+ }
+
+}
diff --git a/areas/deploy/tests/test-resources-post.ps1 b/areas/deploy/tests/test-resources-post.ps1
new file mode 100644
index 000000000..b02b0a155
--- /dev/null
+++ b/areas/deploy/tests/test-resources-post.ps1
@@ -0,0 +1,28 @@
+param(
+ [string] $TenantId,
+ [string] $TestApplicationId,
+ [string] $ResourceGroupName,
+ [string] $BaseName,
+ [hashtable] $DeploymentOutputs
+)
+
+$ErrorActionPreference = "Stop"
+
+. "$PSScriptRoot/../../../eng/common/scripts/common.ps1"
+. "$PSScriptRoot/../../../eng/scripts/helpers/TestResourcesHelpers.ps1"
+
+$testSettings = New-TestSettings @PSBoundParameters -OutputPath $PSScriptRoot
+
+# $testSettings contains:
+# - TenantId
+# - TenantName
+# - SubscriptionId
+# - SubscriptionName
+# - ResourceGroupName
+# - ResourceBaseName
+
+# $DeploymentOutputs keys are all UPPERCASE
+
+# Add your post deployment steps here
+# For example, you might want to configure resources or run additional scripts.
+
diff --git a/areas/deploy/tests/test-resources.bicep b/areas/deploy/tests/test-resources.bicep
new file mode 100644
index 000000000..943ea908c
--- /dev/null
+++ b/areas/deploy/tests/test-resources.bicep
@@ -0,0 +1,22 @@
+// Live test runs require a resource file, so we use an empty one here.
+targetScope = 'resourceGroup'
+
+@minLength(3)
+@maxLength(24)
+@description('The base resource name.')
+param baseName string
+
+@description('The client OID to grant access to test resources.')
+param testApplicationOid string = deployer().objectId
+
+var location string = resourceGroup().location
+var tenantId string = subscription().tenantId
+
+// Add any additional resources and role assignments needed for live tests here.
+
+
+// Outputs will be available in test-resources-post.ps1
+output location string = location
+
+// Their keys will be uppercase
+// $DeploymentOutputs.LOCATION
diff --git a/areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs b/areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs
new file mode 100644
index 000000000..8eb69fad7
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("AzureMcp.AppConfig.UnitTests")]
+[assembly: InternalsVisibleTo("AzureMcp.AppConfig.LiveTests")]
diff --git a/areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj b/areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj
new file mode 100644
index 000000000..c3b8864a7
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/AzureMcp.Quota.csproj
@@ -0,0 +1,33 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs b/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs
new file mode 100644
index 000000000..d9c552c6b
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Commands/QuotaJsonContext.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Quota.Commands;
+using AzureMcp.Quota.Commands.Region;
+using AzureMcp.Quota.Commands.Usage;
+using AzureMcp.Quota.Services.Util;
+
+namespace AzureMcp.Quota.Commands;
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+)]
+[JsonSerializable(typeof(CheckCommand.UsageCheckCommandResult))]
+[JsonSerializable(typeof(AvailabilityListCommand.RegionCheckCommandResult))]
+[JsonSerializable(typeof(UsageInfo))]
+[JsonSerializable(typeof(Dictionary>))]
+internal sealed partial class QuotaJsonContext : JsonSerializerContext
+{
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs
new file mode 100644
index 000000000..2d44edc2c
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Commands/Region/AvailabilityListCommand.cs
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Commands;
+using AzureMcp.Core.Commands.Subscription;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Core.Services.Telemetry;
+using AzureMcp.Quota.Options;
+using AzureMcp.Quota.Options.Region;
+using AzureMcp.Quota.Services;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota.Commands.Region;
+
+public sealed class AvailabilityListCommand(ILogger logger) : SubscriptionCommand()
+{
+ private const string CommandTitle = "Get available regions for Azure resource types";
+ private readonly ILogger _logger = logger;
+
+ private readonly Option _resourceTypesOption = QuotaOptionDefinitions.RegionCheck.ResourceTypes;
+ private readonly Option _cognitiveServiceModelNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelName;
+ private readonly Option _cognitiveServiceModelVersionOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceModelVersion;
+ private readonly Option _cognitiveServiceDeploymentSkuNameOption = QuotaOptionDefinitions.RegionCheck.CognitiveServiceDeploymentSkuName;
+
+ public override string Name => "list";
+
+ public override string Description =>
+ """
+ Given a list of Azure resource types, this tool will return a list of regions where the resource types are available. Always get the user's subscription ID before calling this tool.
+ """;
+
+ public override string Title => CommandTitle;
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_resourceTypesOption);
+ command.AddOption(_cognitiveServiceModelNameOption);
+ command.AddOption(_cognitiveServiceModelVersionOption);
+ command.AddOption(_cognitiveServiceDeploymentSkuNameOption);
+ }
+
+ protected override AvailabilityListOptions BindOptions(ParseResult parseResult)
+ {
+ var options = base.BindOptions(parseResult);
+ options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty;
+ options.CognitiveServiceModelName = parseResult.GetValueForOption(_cognitiveServiceModelNameOption);
+ options.CognitiveServiceModelVersion = parseResult.GetValueForOption(_cognitiveServiceModelVersionOption);
+ options.CognitiveServiceDeploymentSkuName = parseResult.GetValueForOption(_cognitiveServiceDeploymentSkuNameOption);
+ return options;
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ var options = BindOptions(parseResult);
+
+ try
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return context.Response;
+ }
+
+ var resourceTypes = options.ResourceTypes.Split(',')
+ .Select(rt => rt.Trim())
+ .Where(rt => !string.IsNullOrWhiteSpace(rt))
+ .ToArray();
+
+ if (resourceTypes.Length == 0)
+ {
+ throw new ArgumentException("Resource types cannot be empty.", nameof(options.ResourceTypes));
+ }
+
+ var quotaService = context.GetService();
+ List toolResult = await quotaService.GetAvailableRegionsForResourceTypesAsync(
+ resourceTypes,
+ options.Subscription!,
+ options.CognitiveServiceModelName,
+ options.CognitiveServiceModelVersion,
+ options.CognitiveServiceDeploymentSkuName);
+
+ _logger.LogInformation("Region check result: {ToolResult}", toolResult);
+
+ context.Response.Results = toolResult?.Count > 0 ?
+ ResponseResult.Create(
+ new RegionCheckCommandResult(toolResult),
+ QuotaJsonContext.Default.RegionCheckCommandResult) :
+ null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An exception occurred checking available Azure regions.");
+ HandleException(context, ex);
+ }
+
+ return context.Response;
+ }
+
+ public record RegionCheckCommandResult(List AvailableRegions);
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs b/areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs
new file mode 100644
index 000000000..35a5e6639
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Commands/Usage/CheckCommand.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Commands;
+using AzureMcp.Core.Commands.Subscription;
+using AzureMcp.Core.Models.Command;
+using AzureMcp.Core.Services.Telemetry;
+using AzureMcp.Quota.Options;
+using AzureMcp.Quota.Options.Usage;
+using AzureMcp.Quota.Services;
+using AzureMcp.Quota.Services.Util;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota.Commands.Usage;
+
+public class CheckCommand(ILogger logger) : SubscriptionCommand()
+{
+ private const string CommandTitle = "Check Azure resources usage and quota in a region";
+ private readonly ILogger _logger = logger;
+
+ private readonly Option _regionOption = QuotaOptionDefinitions.QuotaCheck.Region;
+ private readonly Option _resourceTypesOption = QuotaOptionDefinitions.QuotaCheck.ResourceTypes;
+
+ public override string Name => "check";
+
+ public override string Description =>
+ """
+ This tool will check the usage and quota information for Azure resources in a region.
+ """;
+
+ public override string Title => CommandTitle;
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.AddOption(_regionOption);
+ command.AddOption(_resourceTypesOption);
+ }
+
+ protected override CheckOptions BindOptions(ParseResult parseResult)
+ {
+ var options = base.BindOptions(parseResult);
+ options.Region = parseResult.GetValueForOption(_regionOption) ?? string.Empty;
+ options.ResourceTypes = parseResult.GetValueForOption(_resourceTypesOption) ?? string.Empty;
+ return options;
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult)
+ {
+ var options = BindOptions(parseResult);
+
+ try
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return context.Response;
+ }
+
+ var ResourceTypes = options.ResourceTypes.Split(',')
+ .Select(rt => rt.Trim())
+ .Where(rt => !string.IsNullOrWhiteSpace(rt))
+ .ToList();
+ var quotaService = context.GetService();
+ Dictionary> toolResult = await quotaService.GetAzureQuotaAsync(
+ ResourceTypes,
+ options.Subscription!,
+ options.Region);
+
+ _logger.LogInformation("Quota check result: {ToolResult}", toolResult);
+
+ context.Response.Results = toolResult?.Count > 0 ?
+ ResponseResult.Create(
+ new UsageCheckCommandResult(toolResult),
+ QuotaJsonContext.Default.UsageCheckCommandResult) :
+ null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking Azure resource usage");
+ HandleException(context, ex);
+ }
+ return context.Response;
+
+ }
+
+ public record UsageCheckCommandResult(Dictionary> UsageInfo);
+
+}
diff --git a/areas/quota/src/AzureMcp.Quota/GlobalUsings.cs b/areas/quota/src/AzureMcp.Quota/GlobalUsings.cs
new file mode 100644
index 000000000..85a476736
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/GlobalUsings.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+global using System.CommandLine;
+global using System.CommandLine.Parsing;
+global using System.Text.Json;
+global using AzureMcp.Core.Extensions;
+global using AzureMcp.Core.Models;
+global using AzureMcp.Core.Models.Command;
+global using ModelContextProtocol.Server;
diff --git a/areas/quota/src/AzureMcp.Quota/Models/CognitiveServiceProperties.cs b/areas/quota/src/AzureMcp.Quota/Models/CognitiveServiceProperties.cs
new file mode 100644
index 000000000..713296c4b
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Models/CognitiveServiceProperties.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Quota.Models;
+
+public class CognitiveServiceProperties
+{
+ public string? ModelName { get; set; } = string.Empty;
+
+ public string? ModelVersion { get; set; } = string.Empty;
+
+ public string? DeploymentSkuName { get; set; } = string.Empty;
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Options/QuotaOptionDefinitions.cs b/areas/quota/src/AzureMcp.Quota/Options/QuotaOptionDefinitions.cs
new file mode 100644
index 000000000..b577fb2ca
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Options/QuotaOptionDefinitions.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Quota.Options;
+
+public static class QuotaOptionDefinitions
+{
+ public static class QuotaCheck
+ {
+ public const string RegionName = "region";
+ public const string ResourceTypesName = "resource-types";
+
+ public static readonly Option Region = new(
+ $"--{RegionName}",
+ "The valid Azure region where the resources will be deployed. E.g. 'eastus', 'westus', etc."
+ )
+ {
+ IsRequired = true
+ };
+
+ public static readonly Option ResourceTypes = new(
+ $"--{ResourceTypesName}",
+ "The valid Azure resource types that are going to be deployed(comma-separated). E.g. 'Microsoft.App/containerApps,Microsoft.Web/sites,Microsoft.CognitiveServices/accounts', etc."
+ )
+ {
+ IsRequired = true,
+ AllowMultipleArgumentsPerToken = true
+ };
+ }
+
+ public static class RegionCheck
+ {
+ public const string ResourceTypesName = "resource-types";
+ public const string CognitiveServiceModelNameName = "cognitive-service-model-name";
+ public const string CognitiveServiceModelVersionName = "cognitive-service-model-version";
+ public const string CognitiveServiceDeploymentSkuNameName = "cognitive-service-deployment-sku-name";
+
+ public static readonly Option ResourceTypes = new(
+ $"--{ResourceTypesName}",
+ "Comma-separated list of Azure resource types to check available regions for. The valid Azure resource types. E.g. 'Microsoft.App/containerApps, Microsoft.Web/sites, Microsoft.CognitiveServices/accounts'."
+ )
+ {
+ IsRequired = true,
+ AllowMultipleArgumentsPerToken = true
+ };
+
+ public static readonly Option CognitiveServiceModelName = new(
+ $"--{CognitiveServiceModelNameName}",
+ "Optional model name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types."
+ )
+ {
+ IsRequired = false
+ };
+
+ public static readonly Option CognitiveServiceModelVersion = new(
+ $"--{CognitiveServiceModelVersionName}",
+ "Optional model version for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types."
+ )
+ {
+ IsRequired = false
+ };
+
+ public static readonly Option CognitiveServiceDeploymentSkuName = new(
+ $"--{CognitiveServiceDeploymentSkuNameName}",
+ "Optional deployment SKU name for cognitive services. Only needed when Microsoft.CognitiveServices is included in resource types."
+ )
+ {
+ IsRequired = false
+ };
+ }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Options/Region/AvailabilityListOptions.cs b/areas/quota/src/AzureMcp.Quota/Options/Region/AvailabilityListOptions.cs
new file mode 100644
index 000000000..f2f7d4613
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Options/Region/AvailabilityListOptions.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Options;
+
+namespace AzureMcp.Quota.Options.Region;
+
+public sealed class AvailabilityListOptions : SubscriptionOptions
+{
+ [JsonPropertyName("resourceTypes")]
+ public string ResourceTypes { get; set; } = string.Empty;
+
+ [JsonPropertyName("modelName")]
+ public string? CognitiveServiceModelName { get; set; }
+
+ [JsonPropertyName("modelVersion")]
+ public string? CognitiveServiceModelVersion { get; set; }
+
+ [JsonPropertyName("deploymentSkuName")]
+ public string? CognitiveServiceDeploymentSkuName { get; set; }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Options/Usage/CheckOptions.cs b/areas/quota/src/AzureMcp.Quota/Options/Usage/CheckOptions.cs
new file mode 100644
index 000000000..213cc05a0
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Options/Usage/CheckOptions.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+using AzureMcp.Core.Options;
+
+namespace AzureMcp.Quota.Options.Usage;
+
+public sealed class CheckOptions : SubscriptionOptions
+{
+ [JsonPropertyName("region")]
+ public string Region { get; set; } = string.Empty;
+
+ [JsonPropertyName("resourceTypes")]
+ public string ResourceTypes { get; set; } = string.Empty;
+}
diff --git a/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs
new file mode 100644
index 000000000..fdfc64569
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/QuotaSetup.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Core.Areas;
+using AzureMcp.Core.Commands;
+using AzureMcp.Core.Extensions;
+using AzureMcp.Core.Services.Http;
+using AzureMcp.Quota.Commands.Region;
+using AzureMcp.Quota.Commands.Usage;
+using AzureMcp.Quota.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota;
+
+public sealed class QuotaSetup : IAreaSetup
+{
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddHttpClientServices();
+
+ services.AddTransient(serviceProvider =>
+ new QuotaService(serviceProvider.GetService(), serviceProvider.GetRequiredService()));
+ }
+
+ public void RegisterCommands(CommandGroup rootGroup, ILoggerFactory loggerFactory)
+ {
+ var quota = new CommandGroup("quota", "Quota commands for getting the available regions of specific Azure resource types"
+ + " or checking Azure resource quota and usage");
+ rootGroup.AddSubGroup(quota);
+
+ var usageGroup = new CommandGroup("usage", "Resource usage and quota operations");
+ usageGroup.AddCommand("check", new CheckCommand(loggerFactory.CreateLogger()));
+ quota.AddSubGroup(usageGroup);
+
+ var regionGroup = new CommandGroup("region", "Region availability operations");
+ var availabilityGroup = new CommandGroup("availability", "Region availability information");
+ availabilityGroup.AddCommand("list", new AvailabilityListCommand(loggerFactory.CreateLogger()));
+ regionGroup.AddSubGroup(availabilityGroup);
+ quota.AddSubGroup(regionGroup);
+ }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Services/IQuotaService.cs b/areas/quota/src/AzureMcp.Quota/Services/IQuotaService.cs
new file mode 100644
index 000000000..f0b510309
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Services/IQuotaService.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using AzureMcp.Quota.Services.Util;
+
+namespace AzureMcp.Quota.Services;
+
+public interface IQuotaService
+{
+ Task>> GetAzureQuotaAsync(
+ List resourceTypes,
+ string subscriptionId,
+ string location);
+
+ Task> GetAvailableRegionsForResourceTypesAsync(
+ string[] resourceTypes,
+ string subscriptionId,
+ string? cognitiveServiceModelName = null,
+ string? cognitiveServiceModelVersion = null,
+ string? cognitiveServiceDeploymentSkuName = null);
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs b/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs
new file mode 100644
index 000000000..3d6f4da7c
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Services/QuotaService.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Azure.ResourceManager;
+using AzureMcp.Core.Services.Azure;
+using AzureMcp.Core.Services.Http;
+using AzureMcp.Quota.Models;
+using AzureMcp.Quota.Services.Util;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota.Services;
+
+public class QuotaService(ILoggerFactory? loggerFactory = null, IHttpClientService? httpClientService = null) : BaseAzureService(loggerFactory: loggerFactory), IQuotaService
+{
+ private readonly IHttpClientService _httpClientService = httpClientService ?? throw new ArgumentNullException(nameof(httpClientService));
+
+ public async Task>> GetAzureQuotaAsync(
+ List resourceTypes,
+ string subscriptionId,
+ string location)
+ {
+ TokenCredential credential = await GetCredential();
+ Dictionary> quotaByResourceTypes = await AzureQuotaService.GetAzureQuotaAsync(
+ credential,
+ resourceTypes,
+ subscriptionId,
+ location,
+ LoggerFactory,
+ _httpClientService
+ );
+ return quotaByResourceTypes;
+ }
+
+ public async Task> GetAvailableRegionsForResourceTypesAsync(
+ string[] resourceTypes,
+ string subscriptionId,
+ string? cognitiveServiceModelName = null,
+ string? cognitiveServiceModelVersion = null,
+ string? cognitiveServiceDeploymentSkuName = null)
+ {
+ ArmClient armClient = await CreateArmClientAsync();
+
+ // Create cognitive service properties if any of the parameters are provided
+ CognitiveServiceProperties? cognitiveServiceProperties = null;
+ if (!string.IsNullOrWhiteSpace(cognitiveServiceModelName) ||
+ !string.IsNullOrWhiteSpace(cognitiveServiceModelVersion) ||
+ !string.IsNullOrWhiteSpace(cognitiveServiceDeploymentSkuName))
+ {
+ cognitiveServiceProperties = new CognitiveServiceProperties
+ {
+ ModelName = cognitiveServiceModelName,
+ ModelVersion = cognitiveServiceModelVersion,
+ DeploymentSkuName = cognitiveServiceDeploymentSkuName
+ };
+ }
+
+ var availableRegions = await AzureRegionService.GetAvailableRegionsForResourceTypesAsync(armClient, resourceTypes, subscriptionId, LoggerFactory, cognitiveServiceProperties);
+ var allRegions = availableRegions.Values
+ .Where(regions => regions.Count > 0)
+ .SelectMany(regions => regions)
+ .Distinct()
+ .ToList();
+
+ List commonValidRegions = availableRegions.Values
+ .Aggregate((current, next) => current.Intersect(next).ToList());
+
+ return commonValidRegions;
+ }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs
new file mode 100644
index 000000000..29c35ea82
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureRegionChecker.cs
@@ -0,0 +1,230 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure;
+using Azure.Core;
+using Azure.ResourceManager;
+using Azure.ResourceManager.CognitiveServices;
+using Azure.ResourceManager.CognitiveServices.Models;
+using Azure.ResourceManager.PostgreSql.FlexibleServers;
+using Azure.ResourceManager.PostgreSql.FlexibleServers.Models;
+using AzureMcp.Quota.Models;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota.Services.Util;
+
+public interface IRegionChecker
+{
+ Task> GetAvailableRegionsAsync(string resourceType);
+}
+
+public abstract class AzureRegionChecker : IRegionChecker
+{
+ protected readonly string SubscriptionId;
+ protected readonly ArmClient ResourceClient;
+ protected readonly ILogger Logger;
+
+ protected AzureRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger)
+ {
+ SubscriptionId = subscriptionId;
+ ResourceClient = armClient;
+ Logger = logger;
+ }
+
+ public abstract Task> GetAvailableRegionsAsync(string resourceType);
+}
+
+public class DefaultRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger) : AzureRegionChecker(armClient, subscriptionId, logger)
+{
+ public override async Task> GetAvailableRegionsAsync(string resourceType)
+ {
+ try
+ {
+ var parts = resourceType.Split('/');
+ var providerNamespace = parts[0];
+ var resourceTypeName = parts[1];
+
+ var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}"));
+ var provider = await subscription.GetResourceProviderAsync(providerNamespace);
+
+ if (provider?.Value?.Data?.ResourceTypes == null)
+ {
+ return [];
+ }
+
+ var resourceTypeInfo = provider.Value.Data.ResourceTypes
+ .FirstOrDefault(rt => rt.ResourceType.Equals(resourceTypeName, StringComparison.OrdinalIgnoreCase));
+
+ if (resourceTypeInfo?.Locations == null)
+ {
+ return [];
+ }
+
+ return resourceTypeInfo.Locations
+ .Select(location => location.Replace(" ", "").ToLowerInvariant())
+ .ToList();
+ }
+ catch (Exception error)
+ {
+ throw new InvalidOperationException($"Failed to fetch available regions for resource type '{resourceType}'. Please verify the resource type name and your subscription permissions.", error);
+ }
+ }
+}
+
+public class CognitiveServicesRegionChecker : AzureRegionChecker
+{
+ private readonly string? _skuName;
+ private readonly string? _apiVersion;
+ private readonly string? _modelName;
+
+ public CognitiveServicesRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger, string? skuName = null, string? apiVersion = null, string? modelName = null)
+ : base(armClient, subscriptionId, logger)
+ {
+ _skuName = skuName;
+ _apiVersion = apiVersion;
+ _modelName = modelName;
+ }
+
+ public override async Task> GetAvailableRegionsAsync(string resourceType)
+ {
+ var parts = resourceType.Split('/');
+ var providerNamespace = parts[0];
+ var resourceTypeName = parts[1];
+
+ var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}"));
+ var provider = await subscription.GetResourceProviderAsync(providerNamespace);
+
+ List regions = provider?.Value?.Data?.ResourceTypes?
+ .FirstOrDefault(rt => rt.ResourceType.Equals(resourceTypeName, StringComparison.OrdinalIgnoreCase))
+ ?.Locations?
+ .Select(location => location.Replace(" ", "").ToLowerInvariant())
+ .ToList() ?? new List();
+
+ var tasks = regions.Select(async region =>
+ {
+ try
+ {
+ var quotas = subscription.GetModelsAsync(region);
+
+ await foreach (CognitiveServicesModel modelElement in quotas)
+ {
+ var nameMatch = string.IsNullOrEmpty(_modelName) ||
+ (modelElement.Model?.Name == _modelName);
+
+ var versionMatch = string.IsNullOrEmpty(_apiVersion) ||
+ (modelElement.Model?.Version == _apiVersion);
+
+ var skuMatch = string.IsNullOrEmpty(_skuName) ||
+ (modelElement.Model?.Skus?.Any(sku => sku.Name == _skuName) ?? false);
+
+ if (nameMatch && versionMatch && skuMatch)
+ {
+ return region;
+ }
+ }
+ }
+ catch (Exception error)
+ {
+ Logger.LogWarning("Error checking cognitive services models for region {Region}: {Error}", region, error.Message);
+ }
+ return null;
+ });
+
+ var results = await Task.WhenAll(tasks);
+ return results.Where(region => region != null).ToList()!;
+ }
+}
+
+public class PostgreSqlRegionChecker(ArmClient armClient, string subscriptionId, ILogger logger) : AzureRegionChecker(armClient, subscriptionId, logger)
+{
+ public override async Task> GetAvailableRegionsAsync(string resourceType)
+ {
+ var parts = resourceType.Split('/');
+ var providerNamespace = parts[0];
+ var resourceTypeName = parts[1];
+
+ var subscription = ResourceClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}"));
+ var provider = await subscription.GetResourceProviderAsync(providerNamespace);
+ var regions = provider?.Value?.Data?.ResourceTypes?
+ .FirstOrDefault(rt => rt.ResourceType.Equals(resourceTypeName, StringComparison.OrdinalIgnoreCase))
+ ?.Locations?
+ .Select(location => location.Replace(" ", "").ToLowerInvariant())
+ .ToList() ?? new List();
+
+ var tasks = regions.Select(async region =>
+ {
+ try
+ {
+ AsyncPageable result = subscription.ExecuteLocationBasedCapabilitiesAsync(region);
+ await foreach (var capability in result)
+ {
+ if (capability.SupportedServerEditions?.Any() == true)
+ {
+ return region;
+ }
+ }
+ }
+ catch (Exception error)
+ {
+ Logger.LogWarning("Error checking PostgreSQL capabilities for region {Region}: {Error}", region, error.Message);
+ }
+ return null;
+ });
+
+ var results = await Task.WhenAll(tasks);
+ return results.Where(region => region != null).ToList()!;
+ }
+}
+
+public static class RegionCheckerFactory
+{
+ public static IRegionChecker CreateRegionChecker(
+ ArmClient armClient,
+ string subscriptionId,
+ string resourceType,
+ ILoggerFactory loggerFactory,
+ CognitiveServiceProperties? properties = null)
+ {
+ var provider = resourceType.Split('/')[0].ToLowerInvariant();
+
+ return provider switch
+ {
+ "microsoft.cognitiveservices" => new CognitiveServicesRegionChecker(
+ armClient,
+ subscriptionId,
+ loggerFactory.CreateLogger(),
+ properties?.DeploymentSkuName,
+ properties?.ModelVersion,
+ properties?.ModelName),
+ "microsoft.dbforpostgresql" => new PostgreSqlRegionChecker(
+ armClient,
+ subscriptionId,
+ loggerFactory.CreateLogger()),
+ _ => new DefaultRegionChecker(
+ armClient,
+ subscriptionId,
+ loggerFactory.CreateLogger())
+ };
+ }
+}
+
+public static class AzureRegionService
+{
+ public static async Task>> GetAvailableRegionsForResourceTypesAsync(
+ ArmClient armClient,
+ string[] resourceTypes,
+ string subscriptionId,
+ ILoggerFactory loggerFactory,
+ CognitiveServiceProperties? cognitiveServiceProperties = null)
+ {
+ var tasks = resourceTypes.Select(async resourceType =>
+ {
+ var checker = RegionCheckerFactory.CreateRegionChecker(armClient, subscriptionId, resourceType, loggerFactory, cognitiveServiceProperties);
+ var regions = await checker.GetAvailableRegionsAsync(resourceType);
+ return new KeyValuePair>(resourceType, regions);
+ });
+
+ var results = await Task.WhenAll(tasks);
+ return results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs
new file mode 100644
index 000000000..c1d59c5ce
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Services/Util/AzureUsageChecker.cs
@@ -0,0 +1,163 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Azure.Core;
+using Azure.ResourceManager;
+using AzureMcp.Core.Services.Azure.Authentication;
+using AzureMcp.Core.Services.Http;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota.Services.Util;
+
+// For simplicity, we currently apply a single rule for all Azure resource providers:
+// - Any resource provider not listed in the enum is treated as having no quota limitations.
+// Ideally, we'd differentiate between the following cases:
+// 1. The resource provider has no quota limitations.
+// 2. The resource provider has quota limitations but does not expose a quota API.
+// 3. The resource provider exposes a quota API, but it's not yet supported by the checker.
+
+public enum ResourceProvider
+{
+ CognitiveServices,
+ Compute,
+ Storage,
+ ContainerApp,
+ Network,
+ MachineLearning,
+ PostgreSQL,
+ HDInsight,
+ Search,
+ ContainerInstance
+}
+
+public record UsageInfo(
+ string Name,
+ int Limit,
+ int Used,
+ string? Unit = null,
+ string? Description = null
+);
+
+public interface IUsageChecker
+{
+ Task> GetUsageForLocationAsync(string location);
+}
+
+// Abstract base class for checking Azure quotas
+public abstract class AzureUsageChecker : IUsageChecker
+{
+ protected readonly string SubscriptionId;
+ protected readonly ArmClient ResourceClient;
+ protected readonly TokenCredential Credential;
+ protected readonly ILogger Logger;
+ protected const string managementEndpoint = "https://management.azure.com";
+
+ protected AzureUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger)
+ {
+ SubscriptionId = subscriptionId;
+ Credential = credential ?? throw new ArgumentNullException(nameof(credential));
+ ResourceClient = new ArmClient(credential, subscriptionId);
+ Logger = logger;
+ }
+
+ public abstract Task> GetUsageForLocationAsync(string location);
+
+}
+
+// Factory function to create usage checkers
+public static class UsageCheckerFactory
+{
+ private static readonly Dictionary ProviderMapping = new()
+ {
+ { "Microsoft.CognitiveServices", ResourceProvider.CognitiveServices },
+ { "Microsoft.Compute", ResourceProvider.Compute },
+ { "Microsoft.Storage", ResourceProvider.Storage },
+ { "Microsoft.App", ResourceProvider.ContainerApp },
+ { "Microsoft.Network", ResourceProvider.Network },
+ { "Microsoft.MachineLearningServices", ResourceProvider.MachineLearning },
+ { "Microsoft.DBforPostgreSQL", ResourceProvider.PostgreSQL },
+ { "Microsoft.HDInsight", ResourceProvider.HDInsight },
+ { "Microsoft.Search", ResourceProvider.Search },
+ { "Microsoft.ContainerInstance", ResourceProvider.ContainerInstance }
+ };
+
+ public static IUsageChecker CreateUsageChecker(TokenCredential credential, string provider, string subscriptionId, ILoggerFactory loggerFactory, IHttpClientService httpClientService)
+ {
+ if (!ProviderMapping.TryGetValue(provider, out var resourceProvider))
+ {
+ throw new ArgumentException($"Unsupported resource provider: {provider}");
+ }
+
+ return resourceProvider switch
+ {
+ ResourceProvider.Compute => new ComputeUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.CognitiveServices => new CognitiveServicesUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.Storage => new StorageUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.ContainerApp => new ContainerAppUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.Network => new NetworkUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.MachineLearning => new MachineLearningUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.PostgreSQL => new PostgreSQLUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger(), httpClientService),
+ ResourceProvider.HDInsight => new HDInsightUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.Search => new SearchUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ ResourceProvider.ContainerInstance => new ContainerInstanceUsageChecker(credential, subscriptionId, loggerFactory.CreateLogger()),
+ _ => throw new ArgumentException($"No implementation for provider: {provider}")
+ };
+ }
+}
+
+// Service to get Azure quota for a list of resource types
+public static class AzureQuotaService
+{
+ public static async Task>> GetAzureQuotaAsync(
+ TokenCredential credential,
+ List resourceTypes,
+ string subscriptionId,
+ string location,
+ ILoggerFactory loggerFactory,
+ IHttpClientService httpClientService)
+ {
+ // Group resource types by provider to avoid duplicate processing
+ var providerToResourceTypes = resourceTypes
+ .GroupBy(rt => rt.Split('/')[0])
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ var logger = loggerFactory.CreateLogger(typeof(AzureQuotaService));
+
+ // Use Select to create tasks and await them all
+ var quotaTasks = providerToResourceTypes.Select(async kvp =>
+ {
+ var (provider, resourceTypesForProvider) = (kvp.Key, kvp.Value);
+ try
+ {
+ var usageChecker = UsageCheckerFactory.CreateUsageChecker(credential, provider, subscriptionId, loggerFactory, httpClientService);
+ var quotaInfo = await usageChecker.GetUsageForLocationAsync(location);
+ logger.LogDebug("Retrieved quota info for provider {Provider}: {ItemCount} items", provider, quotaInfo.Count);
+
+ return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, quotaInfo));
+ }
+ catch (ArgumentException ex) when (ex.Message.Contains("Unsupported resource provider", StringComparison.OrdinalIgnoreCase))
+ {
+ return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List(){
+ new UsageInfo(rt, 0, 0, Description: "No Limit")
+ }));
+ }
+ catch (Exception error)
+ {
+ logger.LogWarning("Error fetching quota for provider {Provider}: {Error}", provider, error.Message);
+ return resourceTypesForProvider.Select(rt => new KeyValuePair>(rt, new List()
+ {
+ new UsageInfo(rt, 0, 0, Description: error.Message)
+ }));
+ }
+ });
+
+ var results = await Task.WhenAll(quotaTasks);
+
+ // Flatten the results into a single dictionary
+ return results
+ .SelectMany(i => i)
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs
new file mode 100644
index 000000000..15d68d518
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Services/Util/JsonElementHelper.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace AzureMcp.Quota.Services.Util;
+
+public static class JsonElementHelper
+{
+ public static string GetStringSafe(this JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString() ?? string.Empty,
+ JsonValueKind.Undefined => string.Empty,
+ JsonValueKind.Null => string.Empty,
+ _ => string.Empty
+ };
+ }
+}
diff --git a/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs
new file mode 100644
index 000000000..f0c5a2968
--- /dev/null
+++ b/areas/quota/src/AzureMcp.Quota/Services/Util/Usage/CognitiveServicesUsageChecker.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Azure.ResourceManager.CognitiveServices;
+using Azure.ResourceManager.CognitiveServices.Models;
+using Microsoft.Extensions.Logging;
+
+namespace AzureMcp.Quota.Services.Util;
+
+public class CognitiveServicesUsageChecker(TokenCredential credential, string subscriptionId, ILogger logger) : AzureUsageChecker(credential, subscriptionId, logger)
+{
+ public override async Task