Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,048 changes: 1,048 additions & 0 deletions GUIDE_AUTH_MCP_SERVER.md

Large diffs are not rendered by default.

144 changes: 140 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,54 @@ products:
- azure-app-service
- azure-postgresql
- azure-virtual-network
- ai-services
urlFragment: msdocs-fastapi-postgresql-sample-app
name: Deploy FastAPI application with PostgreSQL on Azure App Service (Python)
description: This project deploys a restaurant review web application using FastAPI with Python and Azure Database for PostgreSQL - Flexible Server. It's set up for easy deployment with the Azure Developer CLI.
name: Deploy FastAPI Application with PostgreSQL & MCP Server on Azure App Service with Entra Agent Identity Auth (Python)
description: This project deploys a restaurant review web application using FastAPI with Python, Azure Database for PostgreSQL - Flexible Server, and a Model Context Protocol (MCP) server secured with Microsoft Entra ID authentication. It demonstrates how to expose MCP tools to Azure AI Foundry agents using managed identity (agent identity) authentication.
---
<!-- YAML front-matter schema: https://review.learn.microsoft.com/en-us/help/contribute/samples/process/onboarding?branch=main#supported-metadata-fields-for-readmemd -->

# Deploy FastAPI application with PostgreSQL via Azure App Service
# Deploy FastAPI Application with PostgreSQL & MCP Server on Azure App Service with Entra Agent Identity Auth

This project deploys a web application for a restaurnant review site using FastAPI. The application can be deployed to Azure with Azure App Service using the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview).
This project deploys a web application for a restaurant review site using **FastAPI**. It includes a **Model Context Protocol (MCP)** server that exposes restaurant review tools, secured with **Microsoft Entra ID** authentication and preauthorized for **Azure AI Foundry** agent identities.

The application can be deployed to Azure with Azure App Service using the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview).

### Key Features

- **FastAPI web app** — Restaurant review CRUD with PostgreSQL backend
- **MCP server** — 4 tools exposed via the [Model Context Protocol](https://modelcontextprotocol.io/) (`/mcp/mcp` endpoint)
- **Entra ID authentication** — EasyAuth v2 with Return401, Protected Resource Metadata (PRM)
- **Azure AI Foundry integration** — Agent identities preauthorized via app role assignments (`MCP.Access`)

### MCP Tools

| Tool | Description |
|------|-------------|
| `list_restaurants_mcp` | List all restaurants with average rating and review count |
| `get_details_mcp` | Get a restaurant's details and all its reviews |
| `create_review_mcp` | Add a new review to a restaurant |
| `create_restaurant_mcp` | Create a new restaurant |

### Architecture

```
Azure AI Foundry Agent
│ client_credentials flow (MCP.Access app role)
Azure App Service (EasyAuth ~2, Return401)
│ JWT validated: issuer, audience, allowedClientApplications
FastAPI + gunicorn (lifespan: on)
│ /mcp/mcp → FastMCP (stateless_http)
MCP Tools → PostgreSQL
```

---

## Run the sample

Expand Down Expand Up @@ -52,6 +90,26 @@ This project has a [dev container configuration](.devcontainer/), which makes it

1. When you see the message `Your application running on port 8000 is available.`, click **Open in Browser**.

### Verify MCP server locally

Once the app is running, test the MCP endpoint:

```shell
curl -X POST http://localhost:8000/mcp/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0"}
}
}'
```

## Running locally

If you're running the app inside VS Code or GitHub Codespaces, you can use the "Run and Debug" button to start the app.
Expand Down Expand Up @@ -90,6 +148,84 @@ Steps for deployment:
azd deploy
```

## Secure with Entra ID and Connect Azure AI Foundry Agent

After deploying to Azure, follow these steps to secure the MCP endpoint with Entra ID authentication and authorize an Azure AI Foundry agent to call the MCP tools using its managed identity.

> For the complete step-by-step guide with all commands and troubleshooting, see [GUIDE_AUTH_MCP_SERVER.md](GUIDE_AUTH_MCP_SERVER.md).

### Step 1: Create an Entra ID App Registration

```shell
az ad app create --display-name "<your-app-name>-auth" --sign-in-audience AzureADMyOrg
```

- Set an Application ID URI: `api://<client-id>`
- Add a delegated scope: `user_impersonation`
- Add an application role: `MCP.Access` (allowedMemberTypes: `Application`) — required for agent identity auth
- Create a client secret
- **Create a service principal** (often missed):
```shell
az ad sp create --id <client-id>
```

### Step 2: Enable App Service Authentication (EasyAuth)

1. Store the client secret as an app setting:
```shell
az webapp config appsettings set --name <app> --resource-group <rg> \
--settings MICROSOFT_PROVIDER_AUTHENTICATION_SECRET="<secret>"
```

2. Configure EasyAuth v2 via the ARM API with:
- `runtimeVersion: "~2"` (must be v2 for proper enforcement)
- `unauthenticatedClientAction: "Return401"`
- `allowedAudiences`: both `api://<client-id>` and `<client-id>`
- `allowedClientApplications`: your Foundry agent and project identity IDs

3. Restart the app service after changing auth config.

### Step 3: Enable Protected Resource Metadata (PRM)

```shell
az webapp config appsettings set --name <app> --resource-group <rg> \
--settings WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES="api://<client-id>/user_impersonation"
```

This makes `/.well-known/oauth-protected-resource` available, telling MCP clients how to authenticate.

### Step 4: Preauthorize Foundry Agent Identities

Azure AI Foundry agents use **managed identities** (`ServiceIdentity` type) that authenticate via **client_credentials** flow. They cannot be added to `preAuthorizedApplications` — instead:

1. **Grant app role assignments** to each Foundry identity (agent + project):
```shell
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/<agent-principal-id>/appRoleAssignments" \
--headers "Content-Type=application/json" \
--body '{
"principalId": "<agent-principal-id>",
"resourceId": "<your-service-principal-id>",
"appRoleId": "<MCP.Access-role-id>"
}'
```

2. **Add their IDs to `allowedClientApplications`** in the EasyAuth config.

### Step 5: Verify in Azure AI Foundry

1. Create or update an agent in Azure AI Foundry.
2. Add an MCP Server tool pointing to `https://<app>.azurewebsites.net/mcp/mcp`.
3. Run the agent — the `mcp_list_tools` trace span should show `status: OK` with all 4 tools enumerated.

### Key Learnings

- **`runtimeVersion: "~2"`** is required — v1 doesn't enforce auth properly.
- **A service principal must exist** for the app registration before role assignments work.
- **`ServiceIdentity` principals** (Foundry agents) can't use `preAuthorizedApplications`; use app role assignments + `allowedClientApplications` instead.
- **`lifespan: "on"`** in the gunicorn worker config is critical — without it, the MCP session manager won't start in production.
- The PRM endpoint (`/.well-known/oauth-protected-resource`) is served by EasyAuth and is **exempt from authentication** by design.

## Getting help

If you're working with this project and running into issues, please post in [Issues](/issues).
4 changes: 4 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ param name string
@description('Primary location for all resources')
param location string

@description('Location for PostgreSQL Flexible Server (use a region without offer restrictions)')
param pgLocation string = 'centralus'

@secure()
@description('PostGreSQL Server administrator password')
param databasePassword string
Expand All @@ -32,6 +35,7 @@ module resources 'resources.bicep' = {
params: {
name: name
location: location
pgLocation: pgLocation
resourceToken: resourceToken
tags: tags
databasePassword: databasePassword
Expand Down
25 changes: 14 additions & 11 deletions infra/resources.bicep
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
param name string
param location string
param pgLocation string
param resourceToken string
param tags object
@secure()
Expand Down Expand Up @@ -299,16 +300,16 @@ module applicationInsightsResources 'appinsights.bicep' = {
}
}

resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-preview' = {
location: location
resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2023-03-01-preview' = {
location: pgLocation
tags: tags
name: pgServerName
sku: {
name: 'Standard_B1ms'
tier: 'Burstable'
}
properties: {
version: '12'
version: '16'
administratorLogin: 'postgresadmin'
administratorLoginPassword: databasePassword
storage: {
Expand All @@ -318,10 +319,6 @@ resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-pr
backupRetentionDays: 7
geoRedundantBackup: 'Disabled'
}
network: {
delegatedSubnetResourceId: virtualNetwork::databaseSubnet.id
privateDnsZoneArmResourceId: privateDnsZone.id
}
highAvailability: {
mode: 'Disabled'
}
Expand All @@ -332,13 +329,19 @@ resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-pr
startMinute: 0
}
}
}

dependsOn: [
privateDnsZoneLink
]
// Allow Azure services to access PostgreSQL (needed for cross-region App Service)
resource postgresFirewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = {
parent: postgresServer
name: 'AllowAllAzureServices'
properties: {
startIpAddress: '0.0.0.0'
endIpAddress: '0.0.0.0'
}
}

resource pythonAppDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2022-01-20-preview' = {
resource pythonAppDatabase 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-03-01-preview' = {
parent: postgresServer
name: 'pythonapp'
}
Expand Down
4 changes: 3 additions & 1 deletion src/fastapi_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@


# Setup FastAPI app:
app = FastAPI()
from .mcp_server import mcp, mcp_lifespan
app = FastAPI(lifespan=mcp_lifespan)
app.mount("/mcp", mcp.streamable_http_app())
parent_path = pathlib.Path(__file__).parent.parent
app.mount("/mount", StaticFiles(directory=parent_path / "static"), name="static")
templates = Jinja2Templates(directory=parent_path / "templates")
Expand Down
102 changes: 102 additions & 0 deletions src/fastapi_app/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import asyncio
import contextlib
from contextlib import asynccontextmanager

from mcp.server.fastmcp import FastMCP
from sqlalchemy.sql import func
from sqlmodel import Session, select

from .models import Restaurant, Review, engine

# Create a FastMCP server. Use stateless_http=True for simple mounting. Default path is .../mcp
mcp = FastMCP("RestaurantReviewsMCP", stateless_http=True)

# Lifespan context manager to start/stop the MCP session manager with the FastAPI app
@asynccontextmanager
async def mcp_lifespan(app):
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
yield

# MCP tool: List all restaurants with their average rating and review count
@mcp.tool()
async def list_restaurants_mcp() -> list[dict]:
"""List restaurants with their average rating and review count."""

def sync():
with Session(engine) as session:
statement = (
select(
Restaurant,
func.avg(Review.rating).label("avg_rating"),
func.count(Review.id).label("review_count"),
)
.outerjoin(Review, Review.restaurant == Restaurant.id)
.group_by(Restaurant.id)
)
results = session.exec(statement).all()
rows = []
for restaurant, avg_rating, review_count in results:
r = restaurant.dict()
r["avg_rating"] = float(avg_rating) if avg_rating is not None else None
r["review_count"] = review_count
r["stars_percent"] = (
round((float(avg_rating) / 5.0) * 100) if review_count > 0 and avg_rating is not None else 0
)
rows.append(r)
return rows

return await asyncio.to_thread(sync)

# MCP tool: Get a restaurant and all its reviews by restaurant_id
@mcp.tool()
async def get_details_mcp(restaurant_id: int) -> dict:
"""Return the restaurant and its related reviews as objects."""

def sync():
with Session(engine) as session:
restaurant = session.exec(select(Restaurant).where(Restaurant.id == restaurant_id)).first()
if restaurant is None:
return None
reviews = session.exec(select(Review).where(Review.restaurant == restaurant_id)).all()
return {"restaurant": restaurant.dict(), "reviews": [r.dict() for r in reviews]}

return await asyncio.to_thread(sync)

# MCP tool: Create a new review for a restaurant
@mcp.tool()
async def create_review_mcp(restaurant_id: int, user_name: str, rating: int, review_text: str) -> dict:
"""Create a new review for a restaurant and return the created review dict."""

def sync():
with Session(engine) as session:
review = Review()
review.restaurant = restaurant_id
review.review_date = __import__("datetime").datetime.now()
review.user_name = user_name
review.rating = int(rating)
review.review_text = review_text
session.add(review)
session.commit()
session.refresh(review)
return review.dict()

return await asyncio.to_thread(sync)

# MCP tool: Create a new restaurant
@mcp.tool()
async def create_restaurant_mcp(restaurant_name: str, street_address: str, description: str) -> dict:
"""Create a new restaurant and return the created restaurant dict."""

def sync():
with Session(engine) as session:
restaurant = Restaurant()
restaurant.name = restaurant_name
restaurant.street_address = street_address
restaurant.description = description
session.add(restaurant)
session.commit()
session.refresh(restaurant)
return restaurant.dict()

return await asyncio.to_thread(sync)
2 changes: 1 addition & 1 deletion src/my_uvicorn_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ class MyUvicornWorker(UvicornWorker):
CONFIG_KWARGS = {
"loop": "asyncio",
"http": "auto",
"lifespan": "off",
"lifespan": "on",
"log_config": logconfig_dict,
}
1 change: 1 addition & 0 deletions src/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies = [
"python-multipart",
"psycopg2",
"sqlmodel",
"mcp[cli]",
]

[build-system]
Expand Down
Loading