Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

Build a Production Azure DevOps MCP Server with Entra OAuth 2.0

Microsoft shipped the Azure DevOps Remote MCP server in general availability on Aug 5, 2026. Here is how to build and secure a production-grade Azure DevOps MCP server with Entra OAuth 2.0.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 08, 2026 Published
|
Aug 08, 2026 Updated
|
8 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Expose Azure DevOps work items, PRs and pipelines through MCP tools.
  • Lock them with Microsoft Entra OAuth 2.0 device-code flow.
  • Deploy as a remote streamable HTTP server for scale and governance.

Build a Production Azure DevOps MCP Server with Entra OAuth 2.0

By Deepak Bagada, CEO at SaaSNext & AI Principal Architect.

On August 5, 2026, Microsoft promoted the Azure DevOps Remote MCP server from preview to general availability. It runs on the MCP streamable HTTP transport, authenticates agents with Microsoft Entra ID using the OAuth 2.0 authorization code flow with PKCE, and ships native tools for work items, pull requests, and pipelines. For platform teams this is a genuine milestone: remote MCP no longer depends on an opaque PAT pasted into a config file; it now sits on the same enterprise identity layer that governs the rest of Microsoft 365.

In this tutorial we register an Entra application, build a production Azure DevOps MCP server in TypeScript, define precise JSON schemas for every tool, secure the streamable HTTP transport end-to-end, and wire the finished server into Claude Desktop and Cursor. For more server blueprints, browse the MCP Directory, and for agentic orchestration patterns that compose these tools into workflows, see AI Workflows.

Why agentic CI/CD needs a remote MCP server

Conventional automation is pull-based: a script calls an API and reads a response. Agents invert that model. Claude Desktop and Cursor plan multi-step actions, inspect intermediate state, retry failed steps, and ask clarifying questions. That requires a tool surface that models the workflow — query work items, open a bug, review a pull request, kick a pipeline, watch it run — rather than a thin wrapper over REST.

Azure DevOps is an unusually good fit. Its work item tracker, git pull-request model, and YAML pipelines map cleanly onto MCP tools. The blocker was always identity and transport. Before GA, teams bridged through local stdio servers or third-party servers authenticated with PATs. A stdio server only runs on the machine that hosts the agent, and a PAT carries no consent, no tenant-level revocation, and no scopes that are actually enforced. The GA remote server solves both problems at once: because it is a remote MCP server it runs anywhere you can host HTTPS, and because it authenticates with Entra, your agent's access is governed by the same identity policies as every human user.

What the August 5 GA actually changes

Three changes matter to builders:

  • Streamable HTTP replaces SSE. The new transport keeps a single session, supports mcp-session-id-based session management, streams partial results, and handles server-initiated notifications over the same connection.
  • OAuth 2.0 discovery is built in. Per RFC 8414, clients resolve /.well-known/oauth-authorization-server at your endpoint to discover the authorization and token endpoints. No hard-coded URLs, no vendor-specific configuration.
  • Entra is the identity plane. Consent is explicit and scoped, access tokens are short-lived, refresh tokens rotate, and conditional access policies (MFA, device compliance, geo) apply to agents exactly as they apply to humans.

Architecture at a glance

  1. Claude Desktop or Cursor connects to https://mcp.yourorg.com/mcp over HTTPS.
  2. On first connect the client fetches the OAuth discovery metadata and redirects the user through Entra's consent screen.
  3. Entra returns an authorization code; the client exchanges it for access and refresh tokens using PKCE.
  4. Every JSON-RPC request carries Authorization: Bearer <token>.
  5. Your server validates the token, calls the Azure DevOps REST API on the user's behalf, and streams results back.

Registering the Entra application

The MCP spec treats the client as a public client: it cannot keep a secret, so the only safe flow is the authorization code flow with PKCE. Register the app in the Entra admin center under App registrations:

  • Redirect URIs. Add https://mcp.yourorg.com/callback for production and http://localhost:3000/callback for local testing as native (public client) redirects. Localhost is safe here because of PKCE, but never ship it.
  • Scopes. Request the Azure DevOps resource scope. The static resource application ID for Azure DevOps is 499b84ac-1321-427f-aa17-267ca6975798, so the requested scope is 499b84ac-1321-427f-aa17-267ca6975798/.default. That scope delegates to Azure DevOps the permissions of the signed-in user.
  • Client secret. Do not generate one. A secret embedded in a client config file is a credential leak waiting to happen. The token exchange happens inside your server with MSAL, not inside the agent.
  • Application ID URI. Define one if you later add custom scopes for per-agent least privilege.

The flow in detail: the client requests /authorize with response_type=code, a code_challenge (S256), the DevOps scope, and a state value. After consent, Entra redirects to your callback with the code. The client posts the code plus code_verifier to /token and receives access_token, refresh_token, and expires_in. PKCE makes the intercepted code useless to an attacker, and state binds the callback to the original session, defeating login CSRF.

The TypeScript server

The full server uses the official @modelcontextprotocol/sdk, express, and zod. Every tool reads the OAuth bearer token from the inbound request and forwards it to Azure DevOps, so the server stays a thin, stateless adapter.

import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const app = express();
app.use(express.json());

const server = new McpServer({ name: "azure-devops-mcp", version: "1.2.0" });
const ORG = process.env.AZURE_DEVOPS_ORG!;
const PROJECT = process.env.AZURE_DEVOPS_PROJECT!;
const API = `https://dev.azure.com/${ORG}/${PROJECT}/_apis`;

function bearerToken(headers: Record<string, string | undefined>) {
  const header = headers.authorization;
  const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
  if (!token) throw new Error("Missing OAuth bearer token");
  return token;
}

async function devops(path: string, init: RequestInit, token: string) {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      ...(init.headers ?? {}),
    },
  });
  if (!res.ok) throw new Error(`Azure DevOps ${res.status}: ${await res.text()}`);
  return res.status === 204 ? null : res.json();
}

Work item tools:

server.registerTool(
  "list_work_items",
  {
    title: "List Work Items by WIQL",
    description: "Run a Work Item Query Language query and return matching items.",
    inputSchema: {
      wiql: z
        .string()
        .describe("Full WIQL, e.g. SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active'"),
    },
  },
  async ({ wiql }, extra) => {
    const token = bearerToken(extra.request.headers);
    const data = await devops(
      `/wit/wiql?api-version=7.1-preview.2`,
      { method: "POST", body: JSON.stringify({ query: wiql }) },
      token
    );
    return { workItems: data.workItems ?? [] };
  }
);

server.registerTool(
  "create_work_item",
  {
    title: "Create Work Item",
    description: "Create a Bug, Task, or Feature with a title, description, and area path.",
    inputSchema: {
      type: z.enum(["Bug", "Task", "Feature"]),
      title: z.string().min(3),
      description: z.string().optional(),
      areaPath: z.string().optional(),
    },
  },
  async ({ type, title, description, areaPath }, extra) => {
    const token = bearerToken(extra.request.headers);
    const ops = [
      { op: "add", path: "/fields/System.Title", value: title },
      ...(description
        ? [{ op: "add", path: "/fields/System.Description", value: description }]
        : []),
      ...(areaPath ? [{ op: "add", path: "/fields/System.AreaPath", value: areaPath }] : []),
    ];
    const item = await devops(
      `/wit/workitems/$${type}?api-version=7.1-preview.3`,
      {
        method: "PATCH",
        headers: { "Content-Type": "application/json-patch+json" },
        body: JSON.stringify(ops),
      },
      token
    );
    return { id: item.id, url: item.url };
  }
);

Pull request and pipeline tools:

server.registerTool(
  "list_pull_requests",
  {
    title: "List Pull Requests",
    description: "List pull requests in a repository with author and status.",
    inputSchema: {
      repository: z.string().describe("Repository name"),
      status: z.enum(["active", "completed", "abandoned"]).default("active"),
    },
  },
  async ({ repository, status }, extra) => {
    const token = bearerToken(extra.request.headers);
    const data = await devops(
      `/git/repositories/${repository}/pullrequests?searchCriteria.status=${status}&api-version=7.1-preview.1`,
      {},
      token
    );
    return { pullRequests: data.value ?? [] };
  }
);

server.registerTool(
  "run_pipeline",
  {
    title: "Run Pipeline",
    description: "Queue a pipeline run on a branch and return the run id and web URL.",
    inputSchema: {
      pipelineId: z.number(),
      branch: z.string().default("refs/heads/main"),
    },
  },
  async ({ pipelineId, branch }, extra) => {
    const token = bearerToken(extra.request.headers);
    const run = await devops(
      `/pipelines/${pipelineId}/runs?api-version=7.1-preview.1`,
      {
        method: "POST",
        body: JSON.stringify({
          resources: { pipelines: { "self.repo": { refName: branch } } },
        }),
      },
      token
    );
    return { runId: run.id, url: run._links?.web?.href, state: run.state };
  }
);

Session-aware transport wiring plus the OAuth discovery endpoint:

const sessions = new Map<string, StreamableHTTPServerTransport>();

app.post("/mcp", async (req, res) => {
  const id = (req.headers["mcp-session-id"] as string) ?? randomUUID();
  let transport = sessions.get(id);
  if (!transport) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => id,
      enableJsonResponse: true,
    });
    sessions.set(id, transport);
    await server.connect(transport);
  }
  try {
    await transport.handleRequest(req, res);
  } catch (err) {
    res
      .status(500)
      .json({ jsonrpc: "2.0", error: { code: -32603, message: String(err) }, id: null });
  }
});

app.get("/mcp", async (req, res) => {
  const transport = sessions.get(req.headers["mcp-session-id"] as string);
  if (transport) await transport.handleRequest(req, res);
  else res.status(405).end();
});

app.delete("/mcp", async (req, res) => {
  const id = req.headers["mcp-session-id"] as string;
  const transport = sessions.get(id);
  if (transport) {
    sessions.delete(id);
    await transport.close();
  }
  res.status(204).end();
});

app.get("/.well-known/oauth-authorization-server", (_req, res) => {
  res.json({
    issuer: process.env.ENTRA_TENANT_ID,
    authorization_endpoint: `https://login.microsoftonline.com/${process.env.ENTRA_TENANT_ID}/oauth2/v2.0/authorize`,
    token_endpoint: `https://login.microsoftonline.com/${process.env.ENTRA_TENANT_ID}/oauth2/v2.0/token`,
    response_types_supported: ["code"],
    code_challenge_methods_supported: ["S256"],
    scopes_supported: ["499b84ac-1321-427f-aa17-267ca6975798/.default"],
  });
});

app.listen(process.env.PORT ?? 3000, () =>
  console.log("Azure DevOps MCP server listening")
);

The inputSchema contract

TypeScript and zod keep the runtime safe, but the client reads JSON Schema. This is the schema the agent actually sees for create_work_item:

{
  "name": "create_work_item",
  "description": "Create a Bug, Task, or Feature with a title, description, and area path.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "type": { "type": "string", "enum": ["Bug", "Task", "Feature"] },
      "title": { "type": "string", "minLength": 3 },
      "description": { "type": "string" },
      "areaPath": { "type": "string" }
    },
    "required": ["type", "title"]
  }
}

Always declare every field you accept. A schema wider than the tool actually supports is the most common cause of "the agent keeps guessing wrong parameters" reports.

Configuring Claude Desktop and Cursor

Claude Desktop discovers OAuth through the well-known endpoint, so the config is two lines:

{
  "mcpServers": {
    "azure-devops": {
      "url": "https://mcp.yourorg.com/mcp"
    }
  }
}

Cursor does the same through its CLI: cursor mcp add azure-devops https://mcp.yourorg.com/mcp. On first use, both clients open the Entra consent page. Approve once, and rotating refresh tokens keep the session alive without further prompts.

Security hardening checklist

  • Validate tokens server-side. Never trust the client; verify the JWT signature, audience, and expiry on every request, ideally with MSAL middleware.
  • Scope everything. Request the minimal scope per deployment. Consider custom Entra scopes that map to read-only vs. write access, and enforce them inside your tool handlers.
  • Vault secrets. Put ENTRA_CLIENT_ID, ENTRA_TENANT_ID, and any server-side secret in Azure Key Vault or a secrets manager — never in the MCP configuration.
  • Never log tokens or PATs. Redact Authorization headers in request logging.
  • TLS everywhere. The streamable HTTP transport must run behind TLS; terminate at a reverse proxy and expire idle sessions.
  • Rate limit /mcp to contain runaway agents, and add timeouts around every Azure DevOps call.
  • Audit. Stream server logs to your SIEM. Every pipeline run and work item mutation should be attributable to a signed-in identity.

Going from MVP to production

Start with the two-line Claude config and these four tools, then extend: a repository tool that lists repos and branches, a comment_pull_request tool that posts review comments, a get_pipeline_run tool that polls pipeline state so the agent can wait for a deployment, and richer result shaping that reads like a handoff rather than an API dump.

Plan the stateless boundary too. Keep the session map in Redis instead of memory so you can run multiple replicas behind a load balancer, and keep the Azure DevOps client separate from the MCP layer so every tool is unit-testable without an agent attached.

Frequently asked questions

Q: Do I still need a PAT if I use Entra OAuth?

A: No. The access token your server receives from MSAL is used directly against the Azure DevOps REST API, so PATs become unnecessary for this server.

Q: Does the MCP client store my Entra credentials?

A: No. Clients store refresh tokens in their secure credential storage; the access token travels only in the Authorization header.

Q: Can I restrict which repos an agent can touch?

A: Yes. Enforce repository-level checks in each tool handler and pair them with Entra conditional access policies.

Q: Does Claude Desktop support OAuth for remote MCP servers?

A: Yes. It performs RFC 8414 discovery and runs the authorization code flow with PKCE for any remote server that publishes the well-known endpoint.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
A: With Entra you get enterprise identity, device-code auth, scoped permissions (Repo.Read, Build.Read) and revocation without storing PATs. The hosted remote server from Microsoft also uses Entra, so the same identity model carries into your own build.
A: When you just want AI assistants to read and write work items fast, the hosted Remote MCP server works out of the box. Build your own when you need custom tools, audit logging, or data-residency control: the MCP TypeScript SDK plus an Entra app registration yields a fully featured server in about a day.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc