diff --git a/samples/applications/azure-sql-mcp/.gitignore b/samples/applications/azure-sql-mcp/.gitignore new file mode 100644 index 0000000000..6aab378106 --- /dev/null +++ b/samples/applications/azure-sql-mcp/.gitignore @@ -0,0 +1,3 @@ +.azure/ +dab-config.generated.json +infra/main.json diff --git a/samples/applications/azure-sql-mcp/README.md b/samples/applications/azure-sql-mcp/README.md new file mode 100644 index 0000000000..4794420a9d --- /dev/null +++ b/samples/applications/azure-sql-mcp/README.md @@ -0,0 +1,205 @@ +![](../../../media/solutions-microsoft-logo-small.png) + +# Azure SQL MCP Server with Connector Namespace + +Deploy a hosted Azure SQL Model Context Protocol (MCP) server to [Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp). The sample provisions Azure SQL Database, exposes a `BlogPosts` table through Data API Builder MCP, and configures managed identity access so MCP clients such as GitHub Copilot in Visual Studio Code can query the database. + +### Contents + +[About this sample](#about-this-sample)
+[Before you begin](#before-you-begin)
+[Run this sample](#run-this-sample)
+[Sample details](#sample-details)
+[Clean up](#clean-up)
+[Related links](#related-links)
+ + + +## About this sample + +- **Applies to:** Azure SQL Database +- **Key features:** Azure Connector Namespace, hosted MCP server, Data API Builder, managed identity, Application Insights +- **Workload:** AI agent data access +- **Programming Language:** Bicep, PowerShell, Bash, JSON + +This sample deploys a hosted `mcp-sql` server in Azure Connector Namespace. The hosted MCP server uses Data API Builder configuration to expose a SQL table through MCP tools. The SQL database is seeded with a `dbo.BlogPosts` table containing links to Microsoft Learn and .NET Blog posts. For more background, see [Hosted MCP servers in Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp). + + + +## Before you begin + +To run this sample, you need the following prerequisites. + +**Software prerequisites:** + +1. [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (`az`) +1. [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) (`azd`) +1. PowerShell 7+ on Windows, or Bash on Linux/macOS + +**Azure prerequisites:** + +1. An Azure subscription with permissions to create resource groups and resources. +1. Permission to create Azure SQL Database, Application Insights, Log Analytics workspace, and Connector Namespace resources. +1. Permission to create an Azure SQL Microsoft Entra administrator for the signed-in user. + + + +## Run this sample + +From this folder: + +```bash +azd auth login +azd init # only needed once; select "Use code in current directory" +azd up --location eastasia +``` + +These commands follow the [hosted MCP SQL quickstart](https://learn.microsoft.com/azure/logic-apps/connector-namespace/hosted-mcp-quickstart?pivots=sql) pattern and use this sample's Bicep templates and post-provision hooks. + +When prompted: + +- **Environment name:** Pick any name, for example `mcp-dev`. +- **Location:** Choose **East Asia** (`eastasia`). +- **`deployerLoginName` infrastructure parameter:** Enter your Azure sign-in email or user principal name, for example `user@contoso.com`. If you don't know the value, run the following command in a different terminal instance and use the result: + + ```bash + az account show --query user.name -o tsv + ``` + +The `deployerLoginName` value is used to create the Azure SQL server with Microsoft Entra-only authentication and set you as the SQL Entra admin. + +### Connect from Visual Studio Code + +After `azd up` completes, the MCP endpoint URL is printed. Add it to VS Code using the UI: + +1. In VS Code, open the Command Palette: + - Windows/Linux: Ctrl+Shift+P + - macOS: Cmd+Shift+P +1. Run **MCP: Add Server**. +1. Choose **HTTP** as the server type. +1. Paste the MCP endpoint URL printed by `azd up`. +1. Enter a server name, for example `sql-mcp`. +1. Choose whether to save the server in user settings or workspace settings. +1. Start the `sql-mcp` server when VS Code prompts you. + +VS Code prompts you to sign in with Microsoft. Then use Copilot Chat to query your database, for example: *"List the blog posts in the database."* + + + +## Sample details + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ Connector Namespace │ +│ (Microsoft.Web/connectorGateways) │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Hosted SQL MCP Server │ │ +│ │ (Data API Builder) │ │ +│ │ │ │ +│ │ SAMI ──► Azure SQL DB │ │ +│ └───────────────────────────────┘ │ +└─────────────────────────────────────┘ + ▲ + │ MCP (HTTP + SSE) + │ + VS Code / Copilot / MCP Client +``` + +### Resources deployed + +| Resource | Purpose | +|----------|---------| +| **Resource Group** | Container for all deployed resources | +| **Azure SQL Server** | Entra-only auth, with you as SQL admin | +| **Azure SQL Database** | Basic SKU database with a `BlogPosts` sample table used by the MCP server | +| **SQL Firewall Rules** | Allows Azure services/resources, Azure Portal Query Editor, and your public IP for setup | +| **Log Analytics Workspace** | Stores Application Insights telemetry | +| **Application Insights** | Collects telemetry from the hosted MCP server | +| **Connector Namespace** | Hosts MCP servers with system-assigned managed identity | +| **Hosted SQL MCP Server** | `mcp-sql` server config on the namespace | +| **MCP Access Policy** | Grants you access to invoke MCP tools | + +Resource names use the pattern `--` where possible, for example `sql-mcp-dev-a1b2c3d4`. The suffix is deterministic for the subscription, environment name, and location so names are readable and stable across redeployments. + +### What `azd up` does + +| Step | Action | +|------|--------| +| **Provision** | Deploys Azure SQL, SQL firewall rules, Log Analytics, Application Insights, Connector Namespace, hosted `mcp-sql`, and MCP access policy. | +| **Post-provision** | Allows your public IP through the SQL firewall, creates and seeds `dbo.BlogPosts`, creates the Connector Namespace managed identity SQL user, grants SQL permissions, generates `dab-config.generated.json`, and prints the MCP endpoint plus Azure Portal resource group link. | + +The hosted MCP server receives: + +- the included `dab-config.json` as `properties.hostedMcpServer.configuration.configFile` +- the generated SQL connection string as `SQL_CONNECTION_STRING` +- the Application Insights connection string as `APPLICATIONINSIGHTS_CONNECTION_STRING` + +No SQL or Application Insights connection string is checked in. + +This sample includes a ready-to-use `dab-config.json`. If you want to create or customize a Data API Builder configuration from scratch, install the DAB CLI and use it to generate a config file. For more information, see [Install the Data API Builder CLI](https://learn.microsoft.com/azure/data-api-builder/command-line/install). + +For details about the hosted MCP server resource model and supported server types, see [Hosted MCP servers in Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp). For a walkthrough focused on the SQL hosted MCP server, see [Hosted MCP server quickstart for SQL](https://learn.microsoft.com/azure/logic-apps/connector-namespace/hosted-mcp-quickstart?pivots=sql). + +### Inspect resources in Azure Portal + +After deployment, the post-provision output includes a link to the Azure resource group in the Azure Portal. Use that page to inspect the SQL server, Application Insights resource, Log Analytics workspace, Connector Namespace, and hosted MCP server. + +To allow additional users to connect to the MCP server: + +1. Open the deployed **Connector Namespace** resource in the Azure Portal. +1. Open the hosted MCP server configuration, for example `sql-mcp`. +1. Add an access policy for each additional user or group that should be allowed to invoke the MCP server. + +### Sample data + +The post-provision hook creates and seeds `dbo.BlogPosts` with these entries: + +| Title | Source | +|-------|--------| +| Hosted MCP servers in Azure Connector Namespace | Microsoft Learn | +| Durable Workflows in Microsoft Agent Framework | .NET Blog | + +### SQL firewall access + +The deployment configures two SQL firewall paths: + +| Rule | When | Purpose | +|------|------|---------| +| `AllowAzureServices` | During Bicep provisioning | Allows Azure services/resources, including Azure Portal Query Editor, to reach the SQL server. | +| `AllowDeployerIp` | During post-provision | Detects your current public IP and allows your local machine to seed and query the database. | + +If SQL reports a different blocked client IP during post-provision, the script adds that IP and retries. + + + +## Clean up + +Using azd: + +```bash +azd down --purge +``` + +Or with Azure CLI: + +```bash +# Replace with your azd environment name. +az group delete --name rg- --yes --no-wait + +# Optional: remove the subscription-scope deployment record. +az deployment sub delete --name +``` + + + +## Related links + +- [Hosted MCP servers in Azure Connector Namespace](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp) +- [Hosted MCP server quickstart for SQL](https://learn.microsoft.com/azure/logic-apps/connector-namespace/hosted-mcp-quickstart?pivots=sql) +- [Azure SQL MCP server support in Data API Builder](https://learn.microsoft.com/azure/data-api-builder/mcp/overview) +- [Install the Data API Builder CLI](https://learn.microsoft.com/azure/data-api-builder/command-line/install) +- [Connector Namespace overview](https://learn.microsoft.com/azure/logic-apps/connector-namespace/connector-namespace-overview) +- [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/) diff --git a/samples/applications/azure-sql-mcp/azure.yaml b/samples/applications/azure-sql-mcp/azure.yaml new file mode 100644 index 0000000000..eda0c16691 --- /dev/null +++ b/samples/applications/azure-sql-mcp/azure.yaml @@ -0,0 +1,15 @@ +name: azure-sql-mcp +metadata: + template: azure-sql-mcp +hooks: + postprovision: + windows: + shell: pwsh + run: ./scripts/post-provision.ps1 + interactive: true + continueOnError: false + posix: + shell: bash + run: ./scripts/post-provision.sh + interactive: true + continueOnError: false diff --git a/samples/applications/azure-sql-mcp/dab-config.json b/samples/applications/azure-sql-mcp/dab-config.json new file mode 100644 index 0000000000..71675e24db --- /dev/null +++ b/samples/applications/azure-sql-mcp/dab-config.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.7.90/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@env('SQL_CONNECTION_STRING')", + "options": { + "set-session-context": false + } + }, + "runtime": { + "rest": { + "enabled": false, + "path": "/api", + "request-body-strict": true + }, + "graphql": { + "enabled": false, + "path": "/graphql", + "allow-introspection": true + }, + "mcp": { + "enabled": true, + "path": "/mcp" + }, + "host": { + "cors": { + "origins": [], + "allow-credentials": false + }, + "authentication": { + "provider": "AppService" + }, + "mode": "development" + } + }, + "entities": { + "BlogPosts": { + "source": { + "object": "dbo.BlogPosts", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "BlogPost", + "plural": "BlogPosts" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/samples/applications/azure-sql-mcp/deploy.ps1 b/samples/applications/azure-sql-mcp/deploy.ps1 new file mode 100644 index 0000000000..8da72caf36 --- /dev/null +++ b/samples/applications/azure-sql-mcp/deploy.ps1 @@ -0,0 +1,260 @@ +<# +.SYNOPSIS + Deploy a Connector Namespace with a hosted SQL MCP Server. + +.DESCRIPTION + Single-script deployment: provisions Azure SQL, Connector Namespace, + hosted MCP server (mcp-sql), seeds the database, grants SAMI access, + and prints the MCP endpoint URL ready for VS Code. + +.PARAMETER DabConfigPath + Path to the DAB configuration file. Defaults to dab-config.json in the project root. + +.PARAMETER EnvironmentName + Name for the deployment environment. Used to generate unique resource names. + +.PARAMETER Location + Azure region for SQL and general resources. Default: eastasia. + +.PARAMETER ConnectorNamespaceLocation + Azure region for the Connector Namespace. Must be a preview region. + Default: eastasia. + +.PARAMETER DatabaseName + Name of the SQL database. Default: mcpdb. + +.EXAMPLE + .\deploy.ps1 -EnvironmentName mcp-dev + # Uses ./dab-config.json, deploys to eastasia + +.EXAMPLE + .\deploy.ps1 -EnvironmentName mcp-dev -DabConfigPath .\my-custom-dab.json -Location eastasia +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$EnvironmentName, + + [string]$DabConfigPath, + + [string]$Location = 'eastasia', + + [string]$ConnectorNamespaceLocation = 'eastasia', + + [string]$DatabaseName = 'mcpdb' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ProjectRoot = $PSScriptRoot + +# Resolve DAB config path +if (-not $DabConfigPath) { + $DabConfigPath = Join-Path $ProjectRoot 'dab-config.json' +} +if (-not (Test-Path $DabConfigPath)) { + Write-Error "DAB config not found at: $DabConfigPath. Provide -DabConfigPath or place dab-config.json in the project root." + exit 1 +} +$DabConfigPath = Resolve-Path $DabConfigPath +Write-Host "Using DAB config: $DabConfigPath" -ForegroundColor Cyan + +# ── Step 1: Detect deployer identity ────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 1/5: Detecting deployer identity" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$deployerLogin = az account show --query user.name -o tsv +if (-not $deployerLogin) { + Write-Error "Not logged in. Run 'az login' first." + exit 1 +} +Write-Host " Deployer: $deployerLogin" -ForegroundColor Green + +$deployerObjectId = az ad signed-in-user show --query id -o tsv +Write-Host " Object ID: $deployerObjectId" -ForegroundColor Green + +$deployerIp = try { (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 10) } catch { '' } +if ($deployerIp) { + Write-Host " Public IP: $deployerIp" -ForegroundColor Green +} else { + Write-Host " Public IP: (could not detect — SQL firewall rule skipped)" -ForegroundColor Yellow +} + +# ── Step 2: Deploy Bicep ────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 2/5: Deploying infrastructure (Bicep)" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$bicepFile = Join-Path $ProjectRoot 'infra' 'main.bicep' +if (-not (Test-Path $bicepFile)) { + Write-Error "Bicep file not found at: $bicepFile" + exit 1 +} + +# Copy DAB config to expected location for loadTextContent('../dab-config.json') +$expectedDabPath = Join-Path $ProjectRoot 'dab-config.json' +if ($DabConfigPath -ne (Resolve-Path $expectedDabPath -ErrorAction SilentlyContinue)) { + Write-Host " Copying DAB config to project root for Bicep..." -ForegroundColor Yellow + Copy-Item -Path $DabConfigPath -Destination $expectedDabPath -Force +} + +Write-Host " Deploying to subscription..." -ForegroundColor Yellow + +$deployment = az deployment sub create ` + --name "mcp-deploy-$EnvironmentName" ` + --location $Location ` + --template-file $bicepFile ` + --parameters environmentName=$EnvironmentName ` + location=$Location ` + connectorNamespaceLocation=$ConnectorNamespaceLocation ` + deployerLoginName=$deployerLogin ` + deployerPrincipalId=$deployerObjectId ` + deployerIpAddress=$deployerIp ` + databaseName=$DatabaseName ` + --query "properties.outputs" ` + -o json 2>&1 + +if ($LASTEXITCODE -ne 0) { + Write-Host $deployment -ForegroundColor Red + Write-Error "Bicep deployment failed." + exit 1 +} + +$outputs = $deployment | ConvertFrom-Json + +$sqlServerFqdn = $outputs.SQL_SERVER_FQDN.value +$sqlDbName = $outputs.SQL_DATABASE_NAME.value +$connectorNsName = $outputs.CONNECTOR_NAMESPACE_NAME.value +$connectorNsSami = $outputs.CONNECTOR_NAMESPACE_PRINCIPAL_ID.value +$mcpEndpointUrl = $outputs.MCP_ENDPOINT_URL.value +$rgName = $outputs.RESOURCE_GROUP_NAME.value + +Write-Host " Deployment succeeded!" -ForegroundColor Green +Write-Host " Resource Group: $rgName" +Write-Host " SQL Server: $sqlServerFqdn" +Write-Host " Connector Namespace: $connectorNsName" +Write-Host " MCP Endpoint: $mcpEndpointUrl" + +# ── Step 3: Seed the database ───────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 3/5: Seeding the database" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$token = az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv + +$seedSql = @" +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BlogPosts') +BEGIN + CREATE TABLE dbo.BlogPosts ( + Id int IDENTITY(1,1) PRIMARY KEY, + Title nvarchar(300) NOT NULL, + Url nvarchar(1000) NOT NULL, + Source nvarchar(100) NOT NULL + ); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Hosted MCP servers in Azure Connector Namespace', N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp', N'Microsoft Learn'); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Durable Workflows in Microsoft Agent Framework', N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/', N'.NET Blog'); +END +PRINT 'BlogPosts table seeded.'; +"@ + +$sqlSuccess = $false +try { + Invoke-Sqlcmd -ServerInstance $sqlServerFqdn -Database $sqlDbName -AccessToken $token -Query $seedSql + Write-Host " Database seeded." -ForegroundColor Green + $sqlSuccess = $true +} catch { + Write-Host " Invoke-Sqlcmd unavailable, trying sqlcmd CLI..." -ForegroundColor Yellow + try { + sqlcmd -S $sqlServerFqdn -d $sqlDbName -Q $seedSql --authentication-method=ActiveDirectoryDefault + Write-Host " Database seeded." -ForegroundColor Green + $sqlSuccess = $true + } catch { + Write-Host " Could not seed automatically." -ForegroundColor Red + } +} + +# ── Step 4: Grant SAMI access ───────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 4/5: Granting Connector Namespace SAMI database access" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + +$grantSql = @" +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$connectorNsName') +BEGIN + CREATE USER [$connectorNsName] FROM EXTERNAL PROVIDER; +END +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$connectorNsName'), 0) = 0 + ALTER ROLE db_datareader ADD MEMBER [$connectorNsName]; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$connectorNsName'), 0) = 0 + ALTER ROLE db_datawriter ADD MEMBER [$connectorNsName]; +GRANT VIEW DEFINITION TO [$connectorNsName]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$connectorNsName') + THROW 51000, 'Connector Namespace managed identity SQL user was not created.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$connectorNsName'), 0) <> 1 + THROW 51001, 'Connector Namespace managed identity is not a member of db_datareader.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$connectorNsName'), 0) <> 1 + THROW 51002, 'Connector Namespace managed identity is not a member of db_datawriter.', 1; +PRINT 'SAMI access granted.'; +"@ + +if ($sqlSuccess) { + try { + Invoke-Sqlcmd -ServerInstance $sqlServerFqdn -Database $sqlDbName -AccessToken $token -Query $grantSql + Write-Host " SAMI access granted." -ForegroundColor Green + } catch { + try { + sqlcmd -S $sqlServerFqdn -d $sqlDbName -Q $grantSql --authentication-method=ActiveDirectoryDefault + Write-Host " SAMI access granted." -ForegroundColor Green + } catch { + $sqlSuccess = $false + } + } +} + +if (-not $sqlSuccess) { + Write-Host "" + Write-Host " Run these SQL commands manually in Azure Portal Query Editor:" -ForegroundColor Yellow + Write-Host " (SQL Server → $sqlServerFqdn → Database → $sqlDbName → Query editor)" -ForegroundColor Yellow + Write-Host "" + Write-Host $seedSql -ForegroundColor White + Write-Host "" + Write-Host $grantSql -ForegroundColor White + Write-Host "" +} + +# ── Step 5: Done ────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " Step 5/5: Deployment Complete!" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" +Write-Host " MCP Endpoint: $mcpEndpointUrl" -ForegroundColor Green +$subscriptionId = az account show --query id -o tsv +$resourceGroupUrl = "https://portal.azure.com/#@/resource/subscriptions/$subscriptionId/resourceGroups/$rgName/overview" +Write-Host " Azure Portal: $resourceGroupUrl" -ForegroundColor Green +Write-Host "" +Write-Host " Use the MCP endpoint with any MCP client that supports HTTP transport." -ForegroundColor White +Write-Host "" +Write-Host " Clean up later with:" -ForegroundColor White +Write-Host " az group delete --name $rgName --yes" -ForegroundColor Gray +Write-Host "" diff --git a/samples/applications/azure-sql-mcp/infra/main.bicep b/samples/applications/azure-sql-mcp/infra/main.bicep new file mode 100644 index 0000000000..ed1db4f499 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/main.bicep @@ -0,0 +1,166 @@ +targetScope = 'subscription' + +// --------------------------------------------------------------------------- +// Parameters +// --------------------------------------------------------------------------- + +@minLength(1) +@maxLength(64) +@description('Name of the environment (used to generate unique resource names).') +param environmentName string + +@description('Primary location for SQL and other resources.') +@metadata({ + azd: { + type: 'location' + } +}) +param location string = 'eastasia' + +@description('Location for the Connector Namespace. Preview regions: westcentralus, eastasia, centralus, northeurope.') +param connectorNamespaceLocation string = 'eastasia' + +@description('Object ID of the deployer user. Used as Entra admin for SQL and for access policies.') +@metadata({ + azd: { + type: 'principalId' + } +}) +param deployerPrincipalId string = deployer().objectId + +@description('Login name (email) of the deployer user for SQL Entra admin.') +param deployerLoginName string + +@description('Name of the SQL Database to create.') +param databaseName string = 'mcpdb' + +@description('Optional public IP address to allow through the SQL firewall. The azd post-provision hook also configures this for local setup.') +param deployerIpAddress string = '' + +// --------------------------------------------------------------------------- +// Variables +// --------------------------------------------------------------------------- + +var readableEnvironmentName = take(toLower(replace(replace(replace(environmentName, '_', '-'), '.', '-'), ' ', '-')), 40) +var resourceToken = take(toLower(uniqueString(subscription().id, environmentName, location)), 8) +var tags = { 'azd-env-name': environmentName } +var resourceGroupName = 'rg-${readableEnvironmentName}' +var sqlServerName = 'sql-${readableEnvironmentName}-${resourceToken}' +var connectorNamespaceName = 'cn-${readableEnvironmentName}-${resourceToken}' +var logAnalyticsWorkspaceName = 'log-${readableEnvironmentName}-${resourceToken}' +var appInsightsName = 'appi-${readableEnvironmentName}-${resourceToken}' + +// Load the included DAB config and base64-encode it for the ARM API. +var dabConfigBase64 = base64(loadTextContent('../dab-config.json')) +var dabConnectionString = 'Server=${sql.outputs.sqlServerFqdn};Database=${databaseName};Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;' + +// --------------------------------------------------------------------------- +// Resource Group +// --------------------------------------------------------------------------- + +resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceGroupName + location: location + tags: tags +} + +// --------------------------------------------------------------------------- +// Azure SQL Server + Database +// --------------------------------------------------------------------------- + +module sql './modules/sql.bicep' = { + scope: rg + name: 'sql-${resourceToken}' + params: { + sqlServerName: sqlServerName + databaseName: databaseName + location: location + tags: tags + entraAdminObjectId: deployerPrincipalId + entraAdminLogin: deployerLoginName + deployerIpAddress: deployerIpAddress + } +} + +// --------------------------------------------------------------------------- +// Application Insights +// --------------------------------------------------------------------------- + +module appInsights './modules/appInsights.bicep' = { + scope: rg + name: 'appi-${resourceToken}' + params: { + workspaceName: logAnalyticsWorkspaceName + appInsightsName: appInsightsName + location: location + tags: tags + } +} + +// --------------------------------------------------------------------------- +// Connector Namespace (with System-Assigned Managed Identity) +// --------------------------------------------------------------------------- + +module connectorNamespace './modules/connectorNamespace.bicep' = { + scope: rg + name: 'cn-${resourceToken}' + params: { + name: connectorNamespaceName + location: connectorNamespaceLocation + tags: tags + } +} + +// --------------------------------------------------------------------------- +// Hosted SQL MCP Server + Access Policy +// --------------------------------------------------------------------------- + +module hostedMcpServer './modules/hostedMcpServer.bicep' = { + scope: rg + name: 'mcp-${resourceToken}' + params: { + connectorNamespaceName: connectorNamespaceName + name: 'sql-mcp' + deployerPrincipalId: deployerPrincipalId + dabConfigBase64: dabConfigBase64 + sqlConnectionString: dabConnectionString + applicationInsightsConnectionString: appInsights.outputs.connectionString + } + dependsOn: [ + connectorNamespace + ] +} + +// --------------------------------------------------------------------------- +// Outputs +// --------------------------------------------------------------------------- + +@description('The name of the resource group.') +output RESOURCE_GROUP_NAME string = rg.name + +@description('The name of the SQL Server.') +output SQL_SERVER_NAME string = sql.outputs.sqlServerName + +@description('The fully qualified domain name of the SQL Server.') +output SQL_SERVER_FQDN string = sql.outputs.sqlServerFqdn + +@description('The name of the SQL Database.') +output SQL_DATABASE_NAME string = sql.outputs.databaseName + +@description('The name of the Connector Namespace.') +output CONNECTOR_NAMESPACE_NAME string = connectorNamespace.outputs.name + +@description('The principal ID of the Connector Namespace SAMI.') +output CONNECTOR_NAMESPACE_PRINCIPAL_ID string = connectorNamespace.outputs.principalId + +@description('The name of the Application Insights resource.') +output APPLICATIONINSIGHTS_NAME string = appInsights.outputs.appInsightsName + +@description('The name of the Log Analytics workspace backing Application Insights.') +output LOG_ANALYTICS_WORKSPACE_NAME string = appInsights.outputs.workspaceName + +@description('Connection string for DAB config (SAMI-based).') +output DAB_CONNECTION_STRING string = dabConnectionString + +@description('MCP endpoint URL — point VS Code / MCP clients here.') +output MCP_ENDPOINT_URL string = hostedMcpServer.outputs.mcpEndpointUrl diff --git a/samples/applications/azure-sql-mcp/infra/main.parameters.json b/samples/applications/azure-sql-mcp/infra/main.parameters.json new file mode 100644 index 0000000000..fa2e41ef88 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/main.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "deployerLoginName": { + "value": "${AZURE_DEPLOYER_LOGIN}" + } + } +} diff --git a/samples/applications/azure-sql-mcp/infra/modules/appInsights.bicep b/samples/applications/azure-sql-mcp/infra/modules/appInsights.bicep new file mode 100644 index 0000000000..2c48cacf17 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/appInsights.bicep @@ -0,0 +1,47 @@ +@description('Name for the Log Analytics workspace backing Application Insights.') +param workspaceName string + +@description('Name for the Application Insights resource.') +param appInsightsName string + +@description('Location for monitoring resources.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: workspaceName + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: appInsightsName + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace.id + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +@description('The name of the Log Analytics workspace.') +output workspaceName string = workspace.name + +@description('The name of the Application Insights resource.') +output appInsightsName string = appInsights.name + +@description('The Application Insights connection string.') +output connectionString string = appInsights.properties.ConnectionString diff --git a/samples/applications/azure-sql-mcp/infra/modules/connectorNamespace.bicep b/samples/applications/azure-sql-mcp/infra/modules/connectorNamespace.bicep new file mode 100644 index 0000000000..62a4602a2b --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/connectorNamespace.bicep @@ -0,0 +1,27 @@ +@description('Name for the Connector Namespace.') +param name string + +@description('Location for the Connector Namespace. During preview, only select regions are supported: westcentralus, eastasia, centralus, northeurope.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' = { + name: name + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } + properties: {} +} + +@description('The resource ID of the Connector Namespace.') +output resourceId string = connectorNamespace.id + +@description('The name of the Connector Namespace.') +output name string = connectorNamespace.name + +@description('The principal ID of the system-assigned managed identity.') +output principalId string = connectorNamespace.identity.principalId diff --git a/samples/applications/azure-sql-mcp/infra/modules/hostedMcpServer.bicep b/samples/applications/azure-sql-mcp/infra/modules/hostedMcpServer.bicep new file mode 100644 index 0000000000..af45ac8047 --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/hostedMcpServer.bicep @@ -0,0 +1,75 @@ +@sys.description('Name of the parent Connector Namespace.') +param connectorNamespaceName string + +@sys.description('Name for the MCP server config (2-64 chars).') +@minLength(2) +@maxLength(64) +param name string + +@sys.description('Description shown to MCP clients.') +param mcpServerDescription string = 'SQL MCP server bound to DAB config with managed identity.' + +@sys.description('Object ID of the deployer user to grant MCP access.') +param deployerPrincipalId string + +@sys.description('Tenant ID for access policies.') +param tenantId string = tenant().tenantId + +@sys.description('Base64-encoded DAB configuration file content.') +param dabConfigBase64 string + +@secure() +@sys.description('SQL connection string exposed to the hosted MCP server as SQL_CONNECTION_STRING.') +param sqlConnectionString string + +@sys.description('Application Insights connection string exposed to the hosted MCP server as APPLICATIONINSIGHTS_CONNECTION_STRING.') +param applicationInsightsConnectionString string + +// Reference the existing Connector Namespace +resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' existing = { + name: connectorNamespaceName +} + +// Hosted MCP Server — runs the curated mcp-sql container image with DAB config +resource mcpServer 'Microsoft.Web/connectorGateways/mcpServerConfigs@2026-05-01-preview' = { + parent: connectorNamespace + name: name + kind: 'HostedMcpServer' + properties: { + description: mcpServerDescription + hostedMcpServer: { + hostedMcpServerId: 'mcp-sql' + configuration: { + configFile: dabConfigBase64 + SQL_CONNECTION_STRING: sqlConnectionString + APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsightsConnectionString + } + } + } +} + +// Grant the deployer access to invoke the MCP server tools. +// The access-policy name must equal the principal's objectId. +resource mcpAccessPolicy 'Microsoft.Web/connectorGateways/mcpServerConfigs/accessPolicies@2026-05-01-preview' = { + parent: mcpServer + name: deployerPrincipalId + properties: { + principal: { + type: 'ActiveDirectory' + identity: { + objectId: deployerPrincipalId + tenantId: tenantId + } + } + principalType: 'User' + } +} + +@sys.description('Resource ID of the MCP server config.') +output id string = mcpServer.id + +@sys.description('Name of the MCP server config.') +output mcpServerName string = mcpServer.name + +@sys.description('MCP endpoint URL for clients to connect to.') +output mcpEndpointUrl string = mcpServer.properties.mcpEndpointUrl diff --git a/samples/applications/azure-sql-mcp/infra/modules/sql.bicep b/samples/applications/azure-sql-mcp/infra/modules/sql.bicep new file mode 100644 index 0000000000..c345536b5e --- /dev/null +++ b/samples/applications/azure-sql-mcp/infra/modules/sql.bicep @@ -0,0 +1,86 @@ +@description('Azure SQL Server name.') +param sqlServerName string + +@description('Azure SQL Database name.') +param databaseName string + +@description('Location for resources.') +param location string + +@description('Tags to apply to resources.') +param tags object = {} + +@description('Object ID of the Entra ID admin for the SQL Server.') +param entraAdminObjectId string + +@description('Login name of the Entra ID admin (email or display name).') +param entraAdminLogin string + +@description('Tenant ID for Entra ID authentication.') +param tenantId string = tenant().tenantId + +@description('Public IP of the deployer for SQL firewall rule (allows post-provision scripts to connect). Leave empty to skip.') +param deployerIpAddress string = '' + +resource sqlServer 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: sqlServerName + location: location + tags: tags + properties: { + administrators: { + administratorType: 'ActiveDirectory' + azureADOnlyAuthentication: true + login: entraAdminLogin + sid: entraAdminObjectId + tenantId: tenantId + principalType: 'User' + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + } +} + +// Allow Azure services to access the SQL Server +resource firewallRuleAzure 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + parent: sqlServer + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +// Allow the deployer's IP to connect for post-provision seeding/granting +resource firewallRuleDeployer 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = if (!empty(deployerIpAddress)) { + parent: sqlServer + name: 'AllowDeployerIp' + properties: { + startIpAddress: deployerIpAddress + endIpAddress: deployerIpAddress + } +} + +resource database 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { + parent: sqlServer + name: databaseName + location: location + tags: tags + sku: { + name: 'Basic' + tier: 'Basic' + capacity: 5 + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + maxSizeBytes: 2147483648 + } +} + +@description('The fully qualified domain name of the SQL Server.') +output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName + +@description('The name of the SQL Server.') +output sqlServerName string = sqlServer.name + +@description('The name of the SQL Database.') +output databaseName string = database.name diff --git a/samples/applications/azure-sql-mcp/scripts/post-provision.ps1 b/samples/applications/azure-sql-mcp/scripts/post-provision.ps1 new file mode 100644 index 0000000000..1acbe71ccd --- /dev/null +++ b/samples/applications/azure-sql-mcp/scripts/post-provision.ps1 @@ -0,0 +1,253 @@ +#!/usr/bin/env pwsh +# post-provision.ps1 — Runs after `azd provision` to seed the database and +# generate the DAB config file. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Add-SqlFirewallRuleForIp { + param( + [Parameter(Mandatory)] + [string]$IpAddress, + + [Parameter(Mandatory)] + [string]$RuleName + ) + + az sql server firewall-rule create ` + --resource-group $resourceGroupName ` + --server $sqlServerName ` + --name $RuleName ` + --start-ip-address $IpAddress ` + --end-ip-address $IpAddress ` + --only-show-errors | Out-Null +} + +function Invoke-SqlcmdWithFirewallRetry { + param( + [Parameter(Mandatory)] + [string]$Query + ) + + $maxAttempts = 20 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + Invoke-Sqlcmd -ServerInstance $sqlServerFqdn -Database $databaseName -AccessToken $token -Query $Query + return + } catch { + $message = $_.Exception.Message + if ($message -match "Client with IP address '([^']+)' is not allowed") { + $blockedIp = $Matches[1] + Write-Host " SQL reported blocked client IP $blockedIp; adding firewall rule and retrying ($attempt/$maxAttempts)..." -ForegroundColor Yellow + Add-SqlFirewallRuleForIp -IpAddress $blockedIp -RuleName 'AllowSqlClientIp' + Start-Sleep -Seconds 15 + continue + } + + throw + } + } + + throw "Timed out waiting for SQL firewall rules to allow this client." +} + +# Read outputs from azd +$resourceGroupName = (azd env get-value RESOURCE_GROUP_NAME) +$sqlServerName = (azd env get-value SQL_SERVER_NAME) +$sqlServerFqdn = (azd env get-value SQL_SERVER_FQDN) +$databaseName = (azd env get-value SQL_DATABASE_NAME) +$connectorNsName = (azd env get-value CONNECTOR_NAMESPACE_NAME) +$connectorNsPrincipal = (azd env get-value CONNECTOR_NAMESPACE_PRINCIPAL_ID) +$dabConnectionString = (azd env get-value DAB_CONNECTION_STRING) + +Write-Host "" +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host " Post-Provision Setup" -ForegroundColor Cyan +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "SQL Server: $sqlServerFqdn" +Write-Host "Database: $databaseName" +Write-Host "Connector Namespace: $connectorNsName" +Write-Host "Connector NS SAMI ID: $connectorNsPrincipal" +Write-Host "" + +# --- Step 1: Allow this machine through the SQL firewall --- +Write-Host "[1/4] Configuring SQL firewall for this machine..." -ForegroundColor Yellow +try { + $detectedIps = @() + foreach ($uri in @('https://api.ipify.org', 'https://ifconfig.me/ip')) { + try { + $ip = (Invoke-RestMethod -Uri $uri -TimeoutSec 10) + if ($ip -and $ip -match '^\d{1,3}(\.\d{1,3}){3}$') { + $detectedIps += $ip + } + } catch { + Write-Host " Could not detect public IP from $uri." -ForegroundColor DarkYellow + } + } + + $index = 0 + foreach ($deployerIp in ($detectedIps | Select-Object -Unique)) { + $index++ + $ruleName = if ($index -eq 1) { 'AllowDeployerIp' } else { "AllowDeployerIp$index" } + Add-SqlFirewallRuleForIp -IpAddress $deployerIp -RuleName $ruleName + Write-Host " Allowed public IP: $deployerIp" -ForegroundColor Green + } +} catch { + Write-Host " WARNING: Could not detect or configure public IP. SQL commands may need to be run manually." -ForegroundColor Yellow +} + +# --- Step 2: Get an access token for Azure SQL --- +Write-Host "[2/4] Getting access token for Azure SQL..." -ForegroundColor Yellow +$token = az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv +if (-not $token) { + Write-Host "ERROR: Failed to get access token. Make sure you are logged in with 'az login'." -ForegroundColor Red + exit 1 +} + +# --- Step 3: Seed the database --- +Write-Host "[3/4] Seeding the database with BlogPosts table..." -ForegroundColor Yellow + +$seedSql = @" +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BlogPosts') +BEGIN + CREATE TABLE dbo.BlogPosts ( + Id int IDENTITY(1,1) PRIMARY KEY, + Title nvarchar(300) NOT NULL, + Url nvarchar(1000) NOT NULL, + Source nvarchar(100) NOT NULL + ); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Hosted MCP servers in Azure Connector Namespace', N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp', N'Microsoft Learn'); +END +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Durable Workflows in Microsoft Agent Framework', N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/', N'.NET Blog'); +END +PRINT 'BlogPosts table seeded.'; +"@ + +$grantSql = @" +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$connectorNsName') +BEGIN + CREATE USER [$connectorNsName] FROM EXTERNAL PROVIDER; +END +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$connectorNsName'), 0) = 0 + ALTER ROLE db_datareader ADD MEMBER [$connectorNsName]; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$connectorNsName'), 0) = 0 + ALTER ROLE db_datawriter ADD MEMBER [$connectorNsName]; +GRANT VIEW DEFINITION TO [$connectorNsName]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '$connectorNsName') + THROW 51000, 'Connector Namespace managed identity SQL user was not created.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '$connectorNsName'), 0) <> 1 + THROW 51001, 'Connector Namespace managed identity is not a member of db_datareader.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '$connectorNsName'), 0) <> 1 + THROW 51002, 'Connector Namespace managed identity is not a member of db_datawriter.', 1; +PRINT 'Granted SAMI access to database.'; +"@ + +if (-not (Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue)) { + Write-Host "ERROR: Invoke-Sqlcmd is required for automatic post-provision database setup." -ForegroundColor Red + Write-Host "Install the SqlServer PowerShell module or run the SQL commands manually in Azure Portal Query Editor." -ForegroundColor Red + exit 1 +} + +Invoke-SqlcmdWithFirewallRetry -Query $seedSql +Write-Host " Database seeded successfully." -ForegroundColor Green + +Write-Host "[4/4] Granting Connector Namespace SAMI access to database..." -ForegroundColor Yellow +Invoke-SqlcmdWithFirewallRetry -Query $grantSql +Write-Host " SAMI access granted." -ForegroundColor Green + +# --- Generate DAB config --- +Write-Host "" +Write-Host "Generating dab-config.json..." -ForegroundColor Yellow + +$dabConfig = @{ + '$schema' = 'https://github.com/Azure/data-api-builder/releases/download/v1.7.93/dab.draft.schema.json' + 'data-source' = @{ + 'database-type' = 'mssql' + 'connection-string' = $dabConnectionString + 'options' = @{ + 'set-session-context' = $false + } + } + 'runtime' = @{ + 'rest' = @{ + 'enabled' = $false + 'path' = '/api' + 'request-body-strict' = $true + } + 'graphql' = @{ + 'enabled' = $false + 'path' = '/graphql' + 'allow-introspection' = $true + } + 'mcp' = @{ + 'enabled' = $true + 'path' = '/mcp' + } + 'host' = @{ + 'cors' = @{ + 'origins' = @() + 'allow-credentials' = $false + } + 'authentication' = @{ + 'provider' = 'AppService' + } + 'mode' = 'development' + } + } + 'entities' = @{ + 'BlogPosts' = @{ + 'source' = @{ + 'object' = 'dbo.BlogPosts' + 'type' = 'table' + } + 'graphql' = @{ + 'enabled' = $true + 'type' = @{ + 'singular' = 'BlogPost' + 'plural' = 'BlogPosts' + } + } + 'rest' = @{ + 'enabled' = $true + } + 'permissions' = @( + @{ + 'role' = 'anonymous' + 'actions' = @( + @{ 'action' = '*' } + ) + } + ) + } + } +} | ConvertTo-Json -Depth 10 + +$dabConfig | Set-Content -Path (Join-Path $PSScriptRoot '..' 'dab-config.generated.json') -Encoding utf8 +Write-Host " Created dab-config.generated.json" -ForegroundColor Green + +# --- Print MCP endpoint and portal link --- +$mcpEndpointUrl = (azd env get-value MCP_ENDPOINT_URL) +$subscriptionId = (azd env get-value AZURE_SUBSCRIPTION_ID) +if (-not $subscriptionId) { + $subscriptionId = az account show --query id -o tsv +} +$resourceGroupUrl = "https://portal.azure.com/#@/resource/subscriptions/$subscriptionId/resourceGroups/$resourceGroupName/overview" + +Write-Host "" +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host " Deployment Complete!" -ForegroundColor Cyan +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "MCP Endpoint: $mcpEndpointUrl" -ForegroundColor Green +Write-Host "Azure Portal: $resourceGroupUrl" -ForegroundColor Green +Write-Host "" +Write-Host "Use the MCP endpoint with any MCP client that supports HTTP transport." -ForegroundColor White +Write-Host "" diff --git a/samples/applications/azure-sql-mcp/scripts/post-provision.sh b/samples/applications/azure-sql-mcp/scripts/post-provision.sh new file mode 100644 index 0000000000..e17b1cf99c --- /dev/null +++ b/samples/applications/azure-sql-mcp/scripts/post-provision.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# post-provision.sh — Runs after `azd provision` to seed the database and +# generate the DAB config file. + +set -euo pipefail + +add_sql_firewall_rule_for_ip() { + local ip_address="$1" + local rule_name="$2" + + az sql server firewall-rule create \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --server "$SQL_SERVER_NAME" \ + --name "$rule_name" \ + --start-ip-address "$ip_address" \ + --end-ip-address "$ip_address" \ + --only-show-errors >/dev/null +} + +run_sqlcmd_with_firewall_retry() { + local query="$1" + local output + local max_attempts=20 + local attempt=1 + + while [ "$attempt" -le "$max_attempts" ]; do + set +e + output=$(echo "$query" | sqlcmd -S "$SQL_SERVER_FQDN" -d "$DATABASE_NAME" -G 2>&1) + local exit_code=$? + set -e + + if [ "$exit_code" -eq 0 ]; then + echo "$output" + return 0 + fi + + if [[ "$output" =~ Client\ with\ IP\ address\ \'([0-9.]+)\'\ is\ not\ allowed ]]; then + local blocked_ip="${BASH_REMATCH[1]}" + echo " SQL reported blocked client IP $blocked_ip; adding firewall rule and retrying ($attempt/$max_attempts)..." + add_sql_firewall_rule_for_ip "$blocked_ip" "AllowSqlClientIp" + sleep 15 + attempt=$((attempt + 1)) + continue + fi + + echo "$output" + return "$exit_code" + done + + echo "Timed out waiting for SQL firewall rules to allow this client." + return 1 +} + +# Read outputs from azd +RESOURCE_GROUP_NAME=$(azd env get-value RESOURCE_GROUP_NAME) +SQL_SERVER_NAME=$(azd env get-value SQL_SERVER_NAME) +SQL_SERVER_FQDN=$(azd env get-value SQL_SERVER_FQDN) +DATABASE_NAME=$(azd env get-value SQL_DATABASE_NAME) +CONNECTOR_NS_NAME=$(azd env get-value CONNECTOR_NAMESPACE_NAME) +CONNECTOR_NS_PRINCIPAL=$(azd env get-value CONNECTOR_NAMESPACE_PRINCIPAL_ID) +DAB_CONNECTION_STRING=$(azd env get-value DAB_CONNECTION_STRING) + +echo "" +echo "============================================================" +echo " Post-Provision Setup" +echo "============================================================" +echo "" +echo "SQL Server: $SQL_SERVER_FQDN" +echo "Database: $DATABASE_NAME" +echo "Connector Namespace: $CONNECTOR_NS_NAME" +echo "Connector NS SAMI ID: $CONNECTOR_NS_PRINCIPAL" +echo "" + +# --- Step 1: Allow this machine through the SQL firewall --- +echo "[1/4] Configuring SQL firewall for this machine..." +DETECTED_IPS=() +for IP_ENDPOINT in "https://api.ipify.org" "https://ifconfig.me/ip"; do + DETECTED_IP=$(curl -s --max-time 10 "$IP_ENDPOINT" || true) + if [[ "$DETECTED_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + DETECTED_IPS+=("$DETECTED_IP") + fi +done + +if [ "${#DETECTED_IPS[@]}" -eq 0 ]; then + echo " WARNING: Could not detect public IP. SQL commands may need to be run manually." +else + INDEX=0 + printf "%s\n" "${DETECTED_IPS[@]}" | sort -u | while read -r DEPLOYER_IP; do + INDEX=$((INDEX + 1)) + RULE_NAME="AllowDeployerIp" + if [ "$INDEX" -gt 1 ]; then + RULE_NAME="AllowDeployerIp$INDEX" + fi + add_sql_firewall_rule_for_ip "$DEPLOYER_IP" "$RULE_NAME" + echo " Allowed public IP: $DEPLOYER_IP" + done +fi + +# --- Step 2: Get an access token for Azure SQL --- +echo "[2/4] Getting access token for Azure SQL..." +TOKEN=$(az account get-access-token --resource https://database.windows.net/ --query accessToken -o tsv) +if [ -z "$TOKEN" ]; then + echo "ERROR: Failed to get access token. Make sure you are logged in with 'az login'." + exit 1 +fi + +# --- Step 3: Seed the database --- +echo "[3/4] Seeding the database with BlogPosts table..." + +SEED_SQL="IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BlogPosts') +BEGIN + CREATE TABLE dbo.BlogPosts ( + Id int IDENTITY(1,1) PRIMARY KEY, + Title nvarchar(300) NOT NULL, + Url nvarchar(1000) NOT NULL, + Source nvarchar(100) NOT NULL + ); +END; +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Hosted MCP servers in Azure Connector Namespace', N'https://learn.microsoft.com/en-us/azure/logic-apps/connector-namespace/connector-namespace-hosted-mcp', N'Microsoft Learn'); +END; +IF NOT EXISTS (SELECT 1 FROM dbo.BlogPosts WHERE Url = N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/') +BEGIN + INSERT INTO dbo.BlogPosts (Title, Url, Source) + VALUES (N'Durable Workflows in Microsoft Agent Framework', N'https://devblogs.microsoft.com/dotnet/durable-workflows-in-microsoft-agent-framework/', N'.NET Blog'); +END;" + +GRANT_SQL="IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '${CONNECTOR_NS_NAME}') +BEGIN + CREATE USER [${CONNECTOR_NS_NAME}] FROM EXTERNAL PROVIDER; +END; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '${CONNECTOR_NS_NAME}'), 0) = 0 + ALTER ROLE db_datareader ADD MEMBER [${CONNECTOR_NS_NAME}]; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '${CONNECTOR_NS_NAME}'), 0) = 0 + ALTER ROLE db_datawriter ADD MEMBER [${CONNECTOR_NS_NAME}]; +GRANT VIEW DEFINITION TO [${CONNECTOR_NS_NAME}]; +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '${CONNECTOR_NS_NAME}') + THROW 51000, 'Connector Namespace managed identity SQL user was not created.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datareader', '${CONNECTOR_NS_NAME}'), 0) <> 1 + THROW 51001, 'Connector Namespace managed identity is not a member of db_datareader.', 1; +IF ISNULL(IS_ROLEMEMBER('db_datawriter', '${CONNECTOR_NS_NAME}'), 0) <> 1 + THROW 51002, 'Connector Namespace managed identity is not a member of db_datawriter.', 1;" + +if command -v sqlcmd &> /dev/null; then + run_sqlcmd_with_firewall_retry "$SEED_SQL" + echo " Database seeded." + echo "[4/4] Granting Connector Namespace SAMI access..." + run_sqlcmd_with_firewall_retry "$GRANT_SQL" + echo " SAMI access granted." +else + echo "WARNING: sqlcmd not found. Please run these SQL commands in the Azure Portal Query Editor:" + echo "" + echo "$SEED_SQL" + echo "" + echo "$GRANT_SQL" + exit 1 +fi + +# --- Generate DAB config --- +echo "" +echo "Generating dab-config.generated.json..." + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cat > "$SCRIPT_DIR/../dab-config.generated.json" <$null +if (-not $currentLogin) { + Write-Host "Detecting deployer login from Azure CLI..." -ForegroundColor Yellow + $login = az account show --query user.name -o tsv + if ($login) { + azd env set AZURE_DEPLOYER_LOGIN $login + Write-Host " Set AZURE_DEPLOYER_LOGIN=$login" -ForegroundColor Green + } else { + Write-Host "ERROR: Could not detect login. Run: azd env set AZURE_DEPLOYER_LOGIN your-email@example.com" -ForegroundColor Red + exit 1 + } +} + +# Auto-detect deployer public IP for SQL firewall +$currentIp = azd env get-value AZURE_DEPLOYER_IP 2>$null +if (-not $currentIp) { + Write-Host "Detecting deployer public IP..." -ForegroundColor Yellow + try { + $ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 10) + azd env set AZURE_DEPLOYER_IP $ip + Write-Host " Set AZURE_DEPLOYER_IP=$ip" -ForegroundColor Green + } catch { + Write-Host "WARNING: Could not detect public IP. SQL post-provision scripts may fail." -ForegroundColor Yellow + azd env set AZURE_DEPLOYER_IP '' + } +} diff --git a/samples/applications/azure-sql-mcp/scripts/pre-provision.sh b/samples/applications/azure-sql-mcp/scripts/pre-provision.sh new file mode 100644 index 0000000000..510212acc1 --- /dev/null +++ b/samples/applications/azure-sql-mcp/scripts/pre-provision.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# pre-provision.sh — Auto-detect deployer login and public IP before provisioning. + +set -euo pipefail + +# Auto-detect deployer login (email) if not already set +CURRENT_LOGIN=$(azd env get-value AZURE_DEPLOYER_LOGIN 2>/dev/null || true) +if [ -z "$CURRENT_LOGIN" ]; then + echo "Detecting deployer login from Azure CLI..." + LOGIN=$(az account show --query user.name -o tsv) + if [ -n "$LOGIN" ]; then + azd env set AZURE_DEPLOYER_LOGIN "$LOGIN" + echo " Set AZURE_DEPLOYER_LOGIN=$LOGIN" + else + echo "ERROR: Could not detect login. Run: azd env set AZURE_DEPLOYER_LOGIN your-email@example.com" + exit 1 + fi +fi + +# Auto-detect deployer public IP for SQL firewall +CURRENT_IP=$(azd env get-value AZURE_DEPLOYER_IP 2>/dev/null || true) +if [ -z "$CURRENT_IP" ]; then + echo "Detecting deployer public IP..." + IP=$(curl -s --max-time 10 https://api.ipify.org || true) + if [ -n "$IP" ]; then + azd env set AZURE_DEPLOYER_IP "$IP" + echo " Set AZURE_DEPLOYER_IP=$IP" + else + echo "WARNING: Could not detect public IP. SQL post-provision scripts may fail." + azd env set AZURE_DEPLOYER_IP "" + fi +fi