Connecting Private Systems to Claude: What I Learned Building an MCP Gateway


Bicycle

I wanted to find out whether our internal, VPN-only systems could be made usable from Claude, with real identity, and without the connection turning into a channel for pulling data out.

That sounds like one problem. It is really three, and they tend to get mushed together:

  1. Reachability. Anthropic's servers cannot route into a private network.
  2. Identity. Knowing who is asking, on the far side of a client I do not own.
  3. Containment. Making sure the connection is not a pipe for pulling data out.

The first one is the one most write-ups solve. The third is the one I found interesting, and it is where most of the work ended up.

What came out of it is a proof of concept: a small gateway that exposes a mock internal ticket system, and later a real Nextcloud instance, to claude.ai, with OAuth login, per-user permissions, response limits and an audit log. It was not benchmarked, not load-tested, not security-reviewed and never run in production. The architecture pattern is old and none of the individual ideas are mine. What might save you some time are the specific findings and the mistakes, so that is what this post is about.

The feature that does not apply

Starting with the negative result, because it would have saved me the most time had I found it earlier.

There is a feature called MCP Tunnels, designed for exactly this: reaching private networks. It does not work as a custom connector in claude.ai. It belongs to a different product surface, managed agents.1 Custom connectors in claude.ai have to be reachable over the public internet from Anthropic's network.

That rules out the option I reached for first. It also changes the question, which turned out to be the useful part. Instead of "how do we tunnel in?", the question becomes "what is the narrowest thing we can expose?" The second question is a lot easier to answer well.

The gateway

The gateway is one process, publicly reachable, sitting between the chat client and the private systems. Everything else stays unreachable from outside.

The direction of the connection is what makes this work. The gateway calls into the private network; the LLM provider never does. The provider only ever sees one HTTPS address. Nobody needs to be handed a VPN account, which is the alternative this replaces.

flowchart LR subgraph Anthropic["Anthropic network"] CLAUDE["claude.ai\ncustom connector"] end subgraph Public["Publicly reachable"] GW["MCP gateway\nholds the credentials\ntyped tools, scopes, audit log"] end subgraph Private["Private network\nno inbound route from outside"] TICKETS["Ticket system"] NC["Nextcloud"] end CLAUDE -->|"HTTPS + OAuth token\none address"| GW GW -->|"outbound call\ncredentials stay here"| TICKETS GW -->|"outbound call\ncredentials stay here"| NC

Every arrow points the same way. There is no arrow from Anthropic's network into the private one, and that is the whole design.

The controls are not a checklist copied from a framework. Each one is there because of a specific attack:

ControlThe attack it addresses
Narrow, typed tools only, no query(sql), no http_request(url)The model cannot express a request the gateway did not anticipate
The gateway holds the credentials, never the modelCredentials cannot be extracted from a context that never held them
No token passthrough, the inbound token stops at the gatewayConfused deputy. The MCP specification makes this a MUST NOT2
Acting user and scopes come from the token, never from a tool argumentThe caller cannot assert an identity, or a permission, by asking for it
Tools filtered by scope before being advertisedA tool absent from the model's context cannot be requested by an injection
Response size and count capsThe tool-result path is an exfiltration channel, and this is where it gets bounded
Every call audited with hashed argumentsForensic reconstruction without storing sensitive data a second time

Two more that did not make the table but are cheap to add: sensitive fields are only returned on single-record lookups and never in bulk listings, and there are rate limits per identity and per tool.

If I could keep only one row, it would be the first, and it is also the one most easily traded away. The trade sounds perfectly reasonable in a design review: let us just add a generic query tool, it is so much more flexible. But flexibility is the property you are trying not to have here. A generic query tool hands the model an expression language, and an expression language covers every request you might have thought to forbid.

The picture I keep coming back to is a service counter. You do not walk into the warehouse. You ask at the counter, and the person behind it does the things on their list, no matter how nicely you ask for something else. A VPN account, by comparison, is the run of the warehouse.

Identity travels, and it still is not the boundary

A human picks an identity in a browser. That subject arrives intact at a system the LLM provider cannot reach, and it shows up in both the gateway's audit log and the internal system's own log: two logs, same event, from two sides.

It then decides the data. Asked "which open tickets are assigned to me?", the assistant calls a whoami-style tool first, unprompted, and filters on the result. The audit log shows that sequence under one subject: identity lookup, then ticket search. The filter runs on an identity the caller did not supply and cannot choose. That the assistant looks it up on its own is convenience, not enforcement.

There was also a nice surprise I had not planned for. Asked about upcoming appointments while connected as a service account, the assistant pointed out on its own that it was acting as a machine account rather than as the person asking, and that one of the visible calendars was therefore probably not theirs. Nobody asked it to check. The identity was simply visible enough in the tool responses that it became part of the answer.

One thing I got wrong on the way there is probably more useful than the demo. The MCP framework I used, fastmcp, ships an in-memory OAuth provider for testing. It runs a complete, spec-shaped OAuth 2.1 flow, and it auto-approves every authorization request without recording who the user is.3 For testing transport that is fine. For anything to do with identity it is misleading, because it issues a valid-looking token with no subject attached. A whoami tool returns nothing, and the auth still looks like it is working.

A component can implement a protocol correctly and still be useless for the property you actually care about. "We have OAuth" turned out not to be the same sentence as "we know who is asking."

The injection that does nothing

One ticket in the fixture data contains a live prompt injection. It instructs the assistant to dump every ticket, reveal the internal API key, and POST customer data to an external address.

Nothing happens. Not because a filter caught it, but because none of those three things is a capability the gateway has. The bulk listing is capped at ten records and omits the customer field. The key never enters the model's context. There is no tool that takes a URL.

The injection is not detected. It simply has nothing to work with.

That difference is what the whole exercise is about. Detection is an arms race, and it is one you eventually lose, because instructions cannot be reliably separated from prose. Having nothing to redirect to does not degrade when the attacker gets more creative, because creativity still has to operate inside the available vocabulary, and against the ticket system that vocabulary is four typed tools.

A live run gave me the accidental version of this argument. The assistant called a tool before its schema had been loaded, and the chat showed a red error: this tool has not been loaded yet, call the tool-search function first. The assistant then did exactly that and retried successfully. This appears to be the chat client's own lazy loading of tool definitions; at some point it stops sending every schema up front. I saw the error once and inferred the mechanism from it, so take the mechanism as a guess. What is certain is that nothing on the server side causes it or could prevent it.

The part worth keeping: the audit log showed exactly one call for that tool in that session, outcome ok. The failed call never left the browser. The model got something wrong, and it was structurally uninteresting.

Permissions below the model

A write tool required a scope that only members of a specific group hold. A read-only user is not merely refused: the tool is never advertised to them, so the model is never told the capability exists.

That is two independent layers, and the redundancy is deliberate. The tool is absent from the tool list, and the token lacks the scope if it were somehow called anyway. The first layer is about the model's context, the second about the request path, and neither relies on the other holding.

Meeting reality: the Nextcloud login

Against a real system, the first decision was the authentication flow, and my first choice was the wrong one.

Nextcloud ships an OAuth2 app, which looks like the obvious pick. I ended up rejecting it for three reasons. There are no scopes: Nextcloud's own admin documentation says that "every token has full access to the complete account including read and write permission to the stored files", and goes on to say that "without scopes and restrictable access it is not recommended to use a Nextcloud instance as a user authentication service."4 When a vendor advises against its own feature that clearly, it is worth listening. There is also no PKCE, and there are reports of Authorization: Bearer failing against WebDAV endpoints on some versions.

What works instead is Login Flow v2, which is what the official desktop and mobile clients use.5 Three steps: the app POSTs to a login-flow endpoint anonymously and gets back a login URL plus a polling token; the user's browser goes to that URL and authenticates on Nextcloud's ordinary login page; the app polls until it receives the server URL, the login name and an app password.

Three things make this the better choice. There is no admin setup at all, so no OAuth client to register and no redirect URI to configure. 2FA and SSO work automatically, because it is literally the normal login page. And the app password is per-device and individually revocable, visible to the user in Nextcloud's own security settings.

The caveat belongs right next to the recommendation: the app password still grants full account access. Choosing a different flow does not create scopes that Nextcloud does not have.

Three things that broke

None of these are dramatic, but they are the kind of thing you hit on the same path.

The mock was too clean. My calendar listing filtered out system collections by name, which passed against a local mock server that only ever returned real calendars. A real instance also returns the collection home, the scheduling inbox and outbox, and the trash bin, and the home's last path segment is just the account name, so the account showed up as a calendar named after itself. The fix was to filter on the WebDAV resourcetype property instead, which needs no list of special cases. Then I deliberately made the mock messier, so the test now asserts that the extra collections get filtered out. A fixture that is tidier than production hides exactly the class of bug production will find.

Login Flow v2 reuses your browser session. Opening the login URL in a normal window silently offers the account you are already signed in as. On my first live test that authorised an admin session instead of the intended service account, and the flow completes successfully either way, so it is easy to miss. Sign out first, or use a private window. Worth knowing too: every completed login mints an app password that lives until revoked, so iterating leaves a trail to clean up.

A page that lied about what it did. The gateway's "waiting for you to sign in" page said a login page should have opened, while opening nothing at all. The fallback link below it was the only path that ever worked. I caught it by reading the page text before a live run, not through any test. Tests assert behaviour, and nobody had asserted that the sentence was true.

What is still wrong with it

This is the part I would want to read first in someone else's post.

The app password grants full account access. Nextcloud has no scopes to hand out, so the credential could not be restricted. What I could restrict is the interface: six read-only operations against Nextcloud, no write path to it at all, size and count caps, and a credential that never leaves the gateway process. So I did not contain the credential, I contained the interface. Compromise the gateway and you have the account. Compromise the model and you have six read calls, all logged. Those are very different blast radii, and keeping them apart is the point.

Wrapping third-party text in a "this is untrusted data" marker is a hint, not a boundary. Instructions cannot be reliably separated from prose. The real defence is having nothing worth redirecting to.

The demo identity provider is not an identity provider. No passwords, and it issues the tokens it also validates, which makes the validation circular. Good enough to show that a subject survives the trip, and evidence of nothing else.

The operational bits are proof-of-concept grade. The rate limiter is in-process and single worker, state is in memory so a restart resets everything, and the tunnel used for public exposure rotates its hostname, which invalidates the connector configuration each time.

The Nextcloud verification also ran against exactly one instance, version 34.0.1, with one service account, so I cannot say anything about older versions. And the test suites check that the described behaviours hold against a running endpoint, which is functional testing rather than adversarial testing. No penetration test was performed.

If you want to try this

The build is smaller than the topic makes it sound, and the shape is roughly this:

  • Start from the questions people actually ask, not from the API you happen to have. Two or three are plenty for a first version.
  • Write one typed tool per question. Resist the generic one.
  • Put the gateway on a public HTTPS endpoint and let it call inward. Keep the credentials in the gateway.
  • Take the acting user out of the token, and filter the tool list by scope before advertising it.
  • Cap response sizes and counts, and log every call.

The code is not the hard part. The design decisions are, and they are worth taking slowly, because the tool surface is what decides the set of things that can happen. That set is fixed in a text editor, before any model ever sees it. You do not make an LLM integration safe by getting the model to behave well; you make it safe by leaving misbehaviour with nothing interesting to do.

If you are looking at something similar, whether that is deciding which internal services are worth exposing, what the tool surface should look like, or how to run it without handing out VPN accounts, we at Infralovers are happy to help think it through, especially in regulated or security-conscious environments. We also run courses on relevant AI topics if you want to get your team up to speed.


  1. The documented half of this: "When you add a custom connector, Claude connects to your remote MCP server from Anthropic's cloud infrastructure, rather than from your local device," and "your MCP server must be reachable over the public internet from Anthropic's IP ranges." support.claude.com, verified against the article. That MCP Tunnels do not serve as a transport for claude.ai custom connectors is an absence, and absences do not have a URL: it rests on the tunnel feature being documented for managed agents and on the custom-connector documentation offering no such path. Product surfaces change, so re-check before relying on it. ↩︎

  2. Model Context Protocol, Authorization specification, revision 2025-11-25. "MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens," and, on upstream calls, "The MCP server MUST NOT pass through the token it received from the MCP client." modelcontextprotocol.io, verified against the specification text. Linked to a dated revision on purpose, since the draft and latest paths move. ↩︎

  3. fastmcp 3.4.5 ships an in-memory OAuth provider intended for local development and testing. Observed behaviour during this build: it runs a complete OAuth 2.1 authorization-code flow and auto-approves every authorization request without capturing a user identity, so the issued token carries no subject. This is a testing utility behaving as documented rather than a defect. The point is that it is hard to tell apart from working authentication unless you look for the subject. Version-specific, so check it against whatever you are running. ↩︎

  4. Nextcloud admin manual, OAuth2 configuration. Both quoted sentences verified verbatim against docs.nextcloud.com; the page tracks the current release, so wording can change. PKCE support is tracked as nextcloud/server#12881, "Implement OAUTH2 Authorization code with PKCE", opened December 2018 and still open at the time of writing. The Authorization: Bearer failure against WebDAV is nextcloud/server#5512, "No 'Authorization: Bearer' header found." That one is closed, so treat it as evidence that the failure mode existed and is version-dependent, not as proof that it is present in a given release. Both issue states verified. ↩︎

  5. Nextcloud developer manual, Login Flow v2. Anonymous POST to the login-flow endpoint returns a login URL and a poll token; the user authenticates in the default browser, including 2FA, against a session that lives for five minutes; the client polls the poll endpoint, which returns 404 until authentication succeeds and then returns the server address, the login name and an app password. Verified against docs.nextcloud.com and against one instance running version 34.0.1. ↩︎

Go Back explore our courses

We are here for you

You are interested in our courses or you simply have a question that needs answering? You can contact us at anytime! We will do our best to answer all your questions.

Contact us