10 minute read

I use ChatGPT to discuss implementation ideas, challenge architectural decisions, and turn prospective plans into GitHub issues. It’s a convenient workflow: we work through a problem, and when we reach something actionable, I ask ChatGPT to capture it where the work will actually happen.

I wanted to do the same with Azure DevOps.

Specifically, I wanted ChatGPT to read work items and their attachments, understand the context of a discussion, and write decisions or proposed implementation tasks back to Azure DevOps. Microsoft’s Azure DevOps MCP server was already useful to me in VS Code. The existing ChatGPT integration didn’t give me the same experience.

There was one small complication: ChatGPT isn’t an approved enterprise AI tool in my work environment. I knew perfectly well that asking IT for this integration was unlikely to end in enthusiastic applause.

So naturally, I wondered how far I could get on the technical side.

In retrospect, this was an excellent way to spend a few hours designing a creature that would give an IT security department nightmares.

About this experiment: This article grew out of a long exploratory conversation with ChatGPT. I built and inspected the Docker image locally, but stopped before deploying or connecting the integration to Azure DevOps.

The first obstacle: Entra OAuth

Microsoft offers a remote Azure DevOps MCP server at:

https://mcp.dev.azure.com/{organization}

That’s an attractive starting point. No server to host, no package to run, and user authentication through Microsoft Entra ID. It even avoids the whole problem of running an MCP process on my laptop.

ChatGPT can connect to remote MCP servers too. Surely this is a matter of supplying a URL and signing in?

Unfortunately, saying that two systems support OAuth is a little like saying two applications support HTTP. It’s encouraging, but doesn’t tell you whether they agree on everything required to complete the interaction.

The investigation quickly moved into Entra app registrations, redirect URIs, authorization-code flows, PKCE, OAuth resource audiences, consent, and delegated scopes. A client can complete a login flow and still have a token that the intended resource refuses. An otherwise correct setup can fail because the identity provider and MCP client disagree on how the client is registered.

Microsoft’s remote MCP general-availability announcement explicitly discussed interoperability limitations involving third-party clients, including ChatGPT, and OAuth client registration requirements.

I considered manually registering an Entra application for ChatGPT, using its generated redirect URI and appropriate delegated permissions. That path led to enough unanswered questions that I went looking for another approach.

At this point I could have waited for the supported integration story to improve. Instead, I started collecting body parts.

A tunnel in the basement

The next candidate was OpenAI’s Secure MCP Tunnel.

The appeal was straightforward. Rather than expose my own MCP server over a public HTTPS endpoint, I could run a tunnel client in an environment I controlled. The client would communicate outward to OpenAI’s tunnel service and forward MCP requests to a private backend.

Crucially, the tunnel client supports a stdio MCP backend, not just HTTP. That’s a good fit for Microsoft’s local @azure-devops/mcp package: the tunnel runtime can launch it as a child process and exchange MCP messages through standard input and output.

The intended path looked like this:

flowchart TD
    ChatGPT[Personal ChatGPT account] --> Plugin[Private MCP integration]
    Plugin --> Tunnel[OpenAI-hosted Secure MCP Tunnel]
    subgraph ACA[Azure Container Apps - no ingress]
        Client[tunnel-client] -->|stdio| MCP[Microsoft Azure DevOps MCP server]
    end
    Client -->|Outbound HTTPS polling and responses| Tunnel
    MCP -->|PAT-authenticated API calls| ADO[Azure DevOps]

No public MCP listener. No reverse proxy. No custom stdio-to-HTTP adapter. For a backend developer who would rather not maintain yet another gateway, that sounded lovely.

But note what the tunnel actually solves: transport. It isn’t an Entra identity provider, and it doesn’t magically restore per-user Azure DevOps authorization. Keeping an integration private doesn’t replace controlling who can invoke the tunnel. A tunnel ID isn’t a password.

I set those questions aside while I worked through the rest of the design.

Assembling the creature

Give it somewhere to live

A local tunnel client would work only while my machine was running. I wanted something available independently of my laptop.

I considered a VM, an always-on Docker host, and Azure Container Apps. Kubernetes would be overkill for one personal instance. An Azure Container Apps Job was also a poor fit for a service that needs to remain connected and polling.

Azure Container Apps seemed the most convenient proposed host. The container needed outbound HTTPS to OpenAI and Azure DevOps, but no inbound connection from the internet.

That meant no ACA ingress at all.

I had briefly considered protecting an HTTP endpoint with an additional API key. Once I recognized that the design had no need for an HTTP endpoint in the first place, that seemed like solving the wrong problem. A closed door is nice; not building the door is better.

Of course, disabling ACA ingress protects only the direct route to the workload. The tunnel is still an access path, governed by its own permissions.

Give it an identity

The local Azure DevOps MCP server supports a Personal Access Token authentication mode:

mcp-server-azuredevops my-organization --authentication pat

It reads PERSONAL_ACCESS_TOKEN, with one mildly surprising detail: the value is not the raw PAT. The MCP package expects the base64 encoding of a nonempty username followed by a colon and the PAT:

printf '%s' 'ado-mcp:YOUR_PAT' | base64 -w 0

This detail is documented in Microsoft’s MCP authentication setup. It’s distinct from using a PAT directly against the Azure DevOps REST API, where the Basic authentication username can be empty.

Base64 is not encryption. That value is a credential and belongs in a secret store, not in source control, a Dockerfile, or a Terraform output.

For the proposed single-user setup, a dedicated, expiring, narrowly scoped PAT would remove the need for the Entra OAuth flow. Initially, read-only permissions would be the sensible choice.

And this is where the first hint of smoke appeared. The credential would belong to me, while the program using it would run unattended in a container.

Put it all in a box

The proposed image combined the Node.js Azure DevOps MCP package with OpenAI’s tunnel runtime.

# Proposed image: version values pinned for the experiment.
FROM ghcr.io/openai/tunnel-client:v0.0.15 AS tunnel

FROM node:22-bookworm-slim
ARG ADO_MCP_VERSION=2.10.0
ARG ADO_ORGANIZATION=my-organization

RUN npm install --global \
      --omit=dev \
      --no-audit \
      --no-fund \
      "@azure-devops/mcp@${ADO_MCP_VERSION}" \
    && test -x /usr/local/bin/mcp-server-azuredevops

COPY --from=tunnel \
    /usr/bin/tunnel-client \
    /usr/local/bin/tunnel-client

RUN useradd --system \
      --uid 10001 \
      --create-home \
      --home-dir /home/mcp \
      mcp

USER mcp
WORKDIR /home/mcp

ENV NODE_ENV=production \
    LOG_LEVEL=info \
    LOG_FORMAT=json \
    HEALTH_LISTEN_ADDR=127.0.0.1:8080

ENV MCP_COMMAND="mcp-server-azuredevops ${ADO_ORGANIZATION} --authentication pat"

ENTRYPOINT ["/usr/local/bin/tunnel-client", "run"]

The image uses OpenAI’s tunnel-client:v0.0.15 and installs Microsoft’s Azure DevOps MCP package at build time.

To assemble the image, I ran this in PowerShell:

docker build --pull --progress=plain `
  --build-arg ADO_ORGANIZATION=my-organization `
  -t ado-mcp-tunnel:test .

The build succeeded. I then checked the two installed executables and the tunnel-client version without supplying any credentials:

docker run --rm --entrypoint /bin/sh ado-mcp-tunnel:test `
  -c 'command -v tunnel-client && command -v mcp-server-azuredevops && tunnel-client --version'

That returned /usr/local/bin/tunnel-client, /usr/local/bin/mcp-server-azuredevops, and tunnel-client version 0.0.15. Both executables were present in the final image. For a reproducible deployment, I’d also pin the base images by digest.

The running container would receive three important values:

Setting Role
CONTROL_PLANE_TUNNEL_ID Identifies the OpenAI tunnel
CONTROL_PLANE_API_KEY Authenticates the tunnel runtime
PERSONAL_ACCESS_TOKEN Provides the encoded Azure DevOps credential

The runtime API key and PAT should be stored as secrets, with separate permissions and rotation lifecycles. An OpenAI administrative key used to manage tunnels wouldn’t belong in this application.

Keep exactly one creature alive

A subtle deployment constraint appeared in the tunnel-client configuration documentation.

When the tunnel launches a stdio MCP server, only one active tunnel-client process can use a tunnel ID. Two instances each have their own MCP child process and initialization state. The request that initializes a session could reach one instance while a subsequent tool call reaches the other.

Setting Azure Container Apps to one minimum and one maximum replica isn’t necessarily sufficient. A rollout can briefly have overlapping revisions.

A reliable deployment would need to avoid concurrent active instances, potentially stopping the old revision before bringing up the new one. That introduces some downtime and operational care, even for this tiny service.

Add secret injection, egress, logs, and a non-root process, and the proposed shape was complete.

The pieces fit together into an oddly satisfying assembly.

It Could Work!

The direct OAuth route had stalled, but I had an alternative that used the existing MCP server, an outbound tunnel, and a small container. No custom MCP implementation, no public endpoint, and no bespoke gateway.

And the pieces were no longer entirely theoretical. The Docker image had built, and both executables were in place. That was enough to make the idea tempting.

Looking at the architecture, I couldn’t help thinking of Young Frankenstein.

Dr. Frankenstein in Young Frankenstein: It could work!

All I had done was solve the requirements I had chosen to look at.

But Should We Build It?

Now imagine handing the exact same diagram to somebody from IT Security.

The tunnel is private? Fine. Who is authorized to invoke it?

The container has no public ingress? Good. What does the tunnel allow someone to reach anyway?

The PAT has narrow scopes? Sensible. Why is an always-on workload using an employee’s personal identity?

The container can read work items and attachments? That’s what I wanted. Is that information authorized to be shared with this AI service?

The integration is only for me? Perhaps. Who approved it, and what happens when credentials or access permissions change?

Even with perfect network isolation and user attribution, the underlying issue would remain: ChatGPT isn’t an approved enterprise AI tool in my work environment.

The PAT approach adds its own problems. Azure DevOps requests would use the PAT owner’s privileges and identity. An authorized caller reaching the tunnel wouldn’t acquire their own delegated Azure DevOps identity just because the MCP session originated in ChatGPT. The backend auditing wouldn’t necessarily reveal which ChatGPT caller caused a particular action.

A read-only PAT limits the damage an integration can do to Azure DevOps, but it doesn’t settle questions about reading proprietary data and moving it into another system. Nor does safely storing a secret make the decision to use it appropriate.

Microsoft’s PAT guidance explicitly advises against using PATs as long-term credentials for production applications and services. We shouldn’t quietly rebrand a personal PAT as a production service identity merely because it’s convenient.

I asked ChatGPT to rate how annoyed IT would be with the proposed, unapproved arrangement. It returned an entirely unscientific 8/10 on the Pissed-Off IT Scale.

Some individual choices earned praise. Azure hosting? Fine. No ingress? Good. Secret storage? Excellent. An unapproved AI integration operating continuously with my personal Azure DevOps permissions? That’s probably when someone schedules a meeting.

None of the components is inherently the problem. It’s the combination that creates a trust and governance model nobody approved.

From one point of view, the architecture is a neat sequence of technical compromises.

From the other, it’s shadow IT with access to source code, work items, and internal documents.

Same diagram. Different requirements.

Deciding not to pull the switch

So did I unleash the creature? No.

I got what I wanted from the exploration: a clearer picture of how to assemble the integration, and of the price of doing so.

And I’m not particularly motivated to push it further. I’m already trying to convince my team to move our work tracking to GitHub Issues.

That would give me the workflow I wanted in the first place: discussing work in ChatGPT and then reading or updating the corresponding issues through the GitHub integration I already use. It wouldn’t make GitHub magically safer than Azure DevOps or replace enterprise approval where it’s required, but it would remove the need for this particular custom bridge.

In other words, after spending time designing another piece of infrastructure, the more attractive option may be to stop needing it.

Recap

The interesting engineering problems were real: Entra OAuth interoperability, a secure outbound tunnel to a stdio server, PAT encoding, container hosting without ingress, and the deployment constraint of a stateful tunnel client. They’re worth understanding even if this exact integration never runs.

But technical feasibility and organizational acceptability are different questions. A system assembled from legitimate, useful components can still have a trust model nobody intended to authorize.

Victor Frankenstein pulled the switch. I’m leaving mine alone.

P.S. If you’re one of my amazing colleagues from IT and Security reading this, don’t worry. I’m trying to convince my team to move to GitHub Issues instead. No monsters were brought to life. 🎃

This article was produced using an AI-assisted editorial process. Read about my editorial process.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!