Building a Secure Remote MCP Server with Azure Functions and EasyAuth
Why build a Remote MCP Server?
Tell an AI assistant to "Check why my pipeline build has failed" and it'll politely explain that it doesn't have access to your CI/CD system. That's the problem Model Context Protocol solves — it gives agents a standard way to call external tools.
Most MCP examples assume tools are running locally or sitting behind a public API with a simple key. That's not how most organisations work. Your CI/CD lives behind Entra ID. You can't hand out a Personal Access Token and call it a day.
This post walks through building a remote MCP server that exposes Azure DevOps pipeline operations to AI assistants, hosted on Azure Functions with authentication handled entirely by Entra ID and EasyAuth — with zero secrets to manage.
What I Built
A simple Azure DevOps MCP server with four tools, deployed on Azure Functions (Flex Consumption, Python 3.12), secured with EasyAuth, and provisioned entirely via Terraform.
| Tool | What it does |
|---|---|
list_pipeline_runs | List recent builds with statuses, branches, and durations |
get_run_failure_logs | Inspect a failed run — task names, error messages, log tails |
list_deployments | List Classic Release deployments by environment and status |
trigger_pipeline_run | Queue a new pipeline run with optional branch and parameters |
All tools accept an optional project parameter — the server supports multiple Azure DevOps projects, validated against a configured allowlist.
Why Azure Functions?
Azure Functions is the ideal platform for hosting remote MCP servers because of its built-in authentication, event-driven scaling from 0 to N, and serverless billing. This ensures your MCP tools are secure, cost-effective, and ready to handle any load.
The mcpToolTrigger extension binding is the Azure-native way to build MCP servers directly on Functions. The platform handles the MCP protocol mechanics for you — JSON-RPC message framing, tool discovery, Streamable HTTP transport — so all you write is the business logic.
The Extension Bundle
The mcpToolTrigger binding lives inside the Azure Functions extension bundle, not in your Python code. The stable bundle (v4.31.0+) ships with MCP extension v1.2.0 (the GA release), and the version range [4.0.0, 5.0.0) in host.json auto-resolves to the latest 4.x at deploy time — no pinning required.
What this means in practice: a single host.json configuration gives you a complete MCP server runtime. I didn't install an MCP SDK, write a transport layer, or manage JSON-RPC serialisation. The extension bundle owns all of that.
The func CLI and Local Development
Azure Functions Core Tools made the development experience genuinely fast. Running func start spins up the full Functions runtime locally and exposes the MCP server on localhost — I could point any MCP client at it and test immediately against real Azure DevOps APIs using my az login credentials. The feedback loop is tight: change the code, restart, test. When the tools were behaving correctly, func azure functionapp publish deployed everything to Azure in a single command.
How It Works
Building the server involves two key layers — the authentication flow and the tool implementation.
Authentication Flow
- MCP client sends an initialisation request to the Function App
- EasyAuth intercepts it and returns a 401 — this is a deliberate authentication challenge, not an error
- The client reads
/.well-known/oauth-protected-resource, discovers Entra ID as the authorisation server and the required scopes - Client performs a standard OAuth 2.0 PKCE flow against Entra ID — unauthorised users are blocked here and never receive a token
- Client retries the MCP request with the access token
- EasyAuth validates the token (signature, audience, issuer, expiry)
- Request reaches your function code — already authenticated
- Function code uses Managed Identity to call Azure DevOps APIs
The entire flow is configured with a single app setting:
WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES = api://<client-id>/access_as_user
No custom OAuth endpoints. No token caching layer. No session encryption.
One important note: unauthenticated_action must be set to "Return401", not the default redirect behaviour. MCP clients expect a 401 to trigger their OAuth flow — a redirect to a login page will break them.
Managed Identity to Azure DevOps
The Function App's User-Assigned Managed Identity is registered as a stakeholder in the ADO organisation and added to each project's Contributors group. It calls the ADO REST API under its own identity with scoped permissions — no PATs, no client secrets, nothing to rotate.
Implementing the MCP Tools
Each tool is an Azure Function using the mcpToolTrigger binding from the extension bundle:
@app.generic_trigger(
arg_name="context",
type="mcpToolTrigger",
toolName="list_pipeline_runs",
description="List recent pipeline runs. Returns build IDs, statuses, branches, and durations.",
toolProperties=json.dumps([
{"propertyName": "project", "propertyType": "string",
"description": "Azure DevOps project name. Defaults to the configured project."},
{"propertyName": "pipeline_id", "propertyType": "integer",
"description": "Filter to a specific pipeline ID."},
{"propertyName": "top", "propertyType": "integer",
"description": "Number of results to return (default 20, max 50)."},
]),
)
async def list_pipeline_runs(context: str) -> str:
...
toolPropertiesdefines the schema that MCP clients see when discovering available tools- The
contextparameter carriesname,arguments, and transport metadata including the caller's bearer token - Every tool returns a JSON string — the extension handles wrapping it in the MCP response format
Token Acquisition
The Managed Identity authenticates to Azure DevOps using a well-known resource scope. Locally, it falls back to your az login session — zero infrastructure dependencies during development:
_DEVOPS_SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default"
def _get_devops_token(mi_client_id: str | None) -> str:
if mi_client_id:
credential = DefaultAzureCredential(managed_identity_client_id=mi_client_id)
else:
credential = AzureCliCredential()
return credential.get_token(_DEVOPS_SCOPE).token
499b84ac-1321-427f-aa17-267ca6975798 is the Azure DevOps first-party application ID — every ADO token request uses it.
Observability
When AI assistants trigger production deployments through a shared Managed Identity, you need to know who asked for what. ADO only sees "the MI did it" — the audit trail in Application Insights is the only user attribution layer.
I decode the JWT payload directly from the Authorization header (safe — EasyAuth has already verified the signature) and log a structured entry for every tool invocation:
{
"tool_name": "trigger_pipeline_run",
"user": "[email protected]",
"principal_id": "abc-123-def",
"project": "MyProject",
"status": "started",
"run_id": 5678,
"duration_ms": 1234.5
}
Azure DevOps shows "MI triggered run 5678." Application Insights shows "[email protected] triggered run 5678 on pipeline deploy-prod." Without this, every action in ADO is untraceable back to a human.
Infrastructure as Code
The entire stack is Terraform — no portal clicking, no manual steps.
EasyAuth Configuration
auth_settings_v2 {
auth_enabled = true
require_authentication = true
unauthenticated_action = "Return401"
excluded_paths = ["/admin/host/status", "/api/health"]
active_directory_v2 {
client_id = azuread_application.mcp_server.client_id
tenant_auth_endpoint = "https://login.microsoftonline.com/${data.azuread_client_config.current.tenant_id}/v2.0"
allowed_audiences = [
"api://${azuread_application.mcp_server.client_id}",
azuread_application.mcp_server.client_id,
]
}
}
Group-Based Access Control
The service principal has app_role_assignment_required = true — only users explicitly assigned to the Entra group can obtain a token. Everyone else is blocked at token issuance, before they ever reach the Function App:
resource "azuread_group" "mcp_users" {
display_name = "${var.project_name}-users"
security_enabled = true
}
resource "azuread_app_role_assignment" "mcp_users_group" {
app_role_id = "00000000-0000-0000-0000-000000000000"
principal_object_id = azuread_group.mcp_users.object_id
resource_object_id = azuread_service_principal.mcp_server.object_id
}
Managed Identity in Azure DevOps
resource "azuredevops_service_principal_entitlement" "mcp_mi" {
origin = "aad"
origin_id = azurerm_user_assigned_identity.mcp.principal_id
account_license_type = "stakeholder"
}
resource "azuredevops_group_membership" "mcp_mi_contributors" {
for_each = toset(var.azure_devops_projects)
group = data.azuredevops_group.contributors[each.key].descriptor
mode = "add"
members = [azuredevops_service_principal_entitlement.mcp_mi.descriptor]
}
Add a project name to azure_devops_projects in your terraform.tfvars, run terraform apply, and the MI gets Contributors access automatically.
Get Started
Prerequisites
- An Azure subscription with Contributor + User Access Administrator permissions
- An Azure DevOps organisation with Project Collection Administrator permissions
- Terraform >= 1.5 with
azurerm,azuread,azuredevops, andrandomproviders - Azure Functions Core Tools (
funcCLI) v4+ - Python 3.12
Local Development
az login
func start
The MCP extension starts an endpoint on localhost. Point any MCP client at it and you're calling Azure DevOps as yourself, with zero infrastructure dependencies.
Deployment
# Deploy infrastructure
cd infra && terraform init && terraform apply
# Deploy function code
func azure functionapp publish <YOUR-FUNCTION-APP-NAME>
Then add the server to VS Code's MCP settings:
{
"servers": {
"azure-devops-pipelines": {
"type": "http",
"url": "https://<FUNCTION_APP_NAME>.azurewebsites.net/runtime/webhooks/mcp"
}
}
}
The URL must end with /runtime/webhooks/mcp — pointing at the root returns the default landing page and the MCP client will hang.
The full codebase — infrastructure and function code — is available in the repository