The moment you try to build an agent that queries your internal inventory database, you usually stop at one question: you do not want that database reachable from outside. Managed Agents are convenient, but as long as a tool points at a public endpoint, your internal services end up behind a VPN or a bastion host, and operations get heavy fast.
The June 2026 update lets Managed Agents connect to your own sandboxes and to private MCP servers. Self-hosted sandboxes are in public beta on the Claude Platform, and the MCP tunnel that reaches an internal MCP server is in research preview. This article walks through the connection design for letting an agent use services you would rather not publish, along with the authorization and failure-handling decisions that actually trip people up.
Where "not exposed" is actually enforced
Let me clear up one easy misconception first. The MCP tunnel is not a mechanism for publishing your internal server to the internet. The idea runs the other way: your internal side opens a single outbound connection, and the agent's tool calls travel back only along that path. The key benefit is that you never open an inbound port.
The boundaries line up like this.
| Layer | Exposure | What it protects |
|---|---|---|
| Sandbox | Self-hosted (you manage it) | Isolation of code execution; limited egress |
| MCP tunnel | Outbound only | The internal server staying private |
| MCP server | Reachable only via the tunnel | Tool authorization and scope |
| Backend (DB, etc.) | Only from the MCP server | Access control to real data |
As an indie developer, I take the same stance even when aggregating app revenue. The internal aggregation API is never public; a local job reaches it outbound. Once you decide that what you hand the agent is not a "public endpoint" but a "broker that holds an outbound path," the design stops drifting.
A minimal private MCP server
Start with the MCP server you place on the internal side. Here it exposes a single tool: an inventory lookup. Remember that the tunnel does not let the outside reach this server; the server reaches out.
# inventory_mcp.py — runs only inside the internal network
from mcp.server.fastmcp import FastMCP
import os
import asyncpg
mcp = FastMCP("inventory")
# Every target is an internal address. No public IP at all.
DB_DSN = os.environ["INTERNAL_DB_DSN"] # e.g. postgres://app@10.0.3.12:5432/inventory
@mcp.tool()
async def check_stock(sku: str) -> dict:
"""Return the quantity on hand and next restock date for a SKU."""
if not sku.isalnum():
# Always validate input on the tool side. Do not trust agent output.
raise ValueError("sku must be alphanumeric")
conn = await asyncpg.connect(DB_DSN)
try:
row = await conn.fetchrow(
"SELECT quantity, restock_date FROM stock WHERE sku = $1",
sku,
)
finally:
await conn.close()
if row is None:
return {"sku": sku, "found": False}
return {
"sku": sku,
"found": True,
"quantity": row["quantity"],
"restock_date": row["restock_date"].isoformat() if row["restock_date"] else None,
}
if __name__ == "__main__":
mcp.run()What I do deliberately here is put input validation for check_stock on the tool side. The arguments an agent generates deserve the same suspicion as input from outside. Passing the SKU through a placeholder rather than into SQL directly comes from the same instinct. When you treat the MCP server as the last checkpoint rather than a convenient extension of the agent, your permission design tightens up.
Open the tunnel and let the agent reach it
Next, make the server reachable to the agent. With the research-preview MCP tunnel, you start a tunnel client on the internal side that connects outbound to the Claude Platform. The server is assigned an identifier (a tunnel ID), and the agent references it as an MCP connector.
# Run on an internal host. No inbound port is ever opened.
export CLAUDE_TUNNEL_TOKEN="YOUR_TUNNEL_TOKEN"
claude-tunnel connect \
--target stdio:./inventory_mcp.py \
--name internal-inventory
# → prints the assigned tunnel id (e.g. tnl_internal_inventory)On the agent side, you pass that identifier as an MCP server definition. The important part is that the agent config holds no DB credentials and no internal IPs. All the agent should know is that a tool group called internal-inventory exists.
# agent_config.py — agent side. It holds none of the internal details.
agent = client.beta.agents.create(
model="claude-opus-4-8",
name="inventory-assistant",
instructions=(
"Use the check_stock tool for inventory questions. "
"If the tool returns found=false, do not guess a quantity."
),
mcp_servers=[
{
"type": "tunnel",
"tunnel_id": "tnl_internal_inventory",
"tool_allowlist": ["check_stock"], # explicitly narrow what can be used
}
],
sandbox={"type": "self_hosted", "id": "sbx_team_default"},
)I recommend always setting tool_allowlist. If the MCP server grows another tool later, the allowlist prevents the agent from picking it up by accident. Manage the list additively, starting from a state where nothing is usable by default.
Think about authorization in two stages
The hardest thing in production is authorization. If you settle it in a single stage—"the tunnel is connected, so we're fine"—it will break later. In my experience, splitting it into two stages keeps it clear.
The first stage is "who may invoke this agent." That lives at the agent's entry point, controlled by API keys or user sessions. The second stage is "what range of data this tool call may touch." That is decided on the MCP server, looking at a scope tied to the call context.
# Add authorization to inventory_mcp.py
from mcp.server.fastmcp import Context
@mcp.tool()
async def check_stock(sku: str, ctx: Context) -> dict:
# Pull the tenant from metadata carried over the tunnel
tenant = ctx.request_context.meta.get("tenant")
if tenant is None:
raise PermissionError("Calls without tenant context are rejected")
conn = await asyncpg.connect(DB_DSN)
try:
row = await conn.fetchrow(
"SELECT quantity, restock_date FROM stock "
"WHERE sku = $1 AND tenant_id = $2", # enforce the tenant boundary in SQL
sku, tenant,
)
finally:
await conn.close()
# the rest matches the earlier versionThe point is to enforce the tenant boundary as a SQL WHERE clause, not as an instruction to the agent. I treat a note in the prompt as something that may or may not be honored. A data boundary is best closed physically, in a layer below the prompt.
What happens when the tunnel drops
A research-preview tunnel can drop on network jitter. If you have not decided the degradation behavior, the agent confuses "I could not check stock" with "stock was zero," and returns a wrong answer.
There are two safeguards. One is to treat a tool failure explicitly as "unknown" and forbid guessing. The other is to give the tunnel client automatic reconnection and a health check.
# A thin wrapper around the tunnel client. Reconnect with exponential backoff.
import asyncio
async def run_tunnel_with_retry():
backoff = 1
while True:
try:
await start_tunnel(target="stdio:./inventory_mcp.py",
name="internal-inventory")
backoff = 1 # reset on a successful connection
except TunnelDisconnected as e:
# Treat a disconnect as an expected event, not an anomaly
wait = min(backoff, 30)
log.warning("tunnel disconnected: %s — reconnecting in %ds", e, wait)
await asyncio.sleep(wait)
backoff *= 2The instruction "do not guess a quantity if the tool fails" exists precisely for this disconnect. The worst pattern is an agent that reads the room and produces a plausible number during the tens of seconds it takes to reconnect. You need a state, designed in advance, where the agent can fall silent and say "I can't confirm that right now."
Why pair this with a self-hosted sandbox
Finally, a word on why you would also use a self-hosted sandbox. Where the tunnel closes the "path to the internal server," the self-hosted sandbox puts the "place where the agent runs code" under your management. They protect different things.
Suppose that after fetching inventory data, the agent writes and runs code to aggregate it. If that execution happens in a sandbox you manage rather than a shared one, the internal data you fetched stays inside your management boundary, execution environment and all. The tunnel closes the data's "entrance," and the sandbox closes the data's "place of processing"—a boundary closed twice.
The data I handle as an indie developer is never large, yet I still care about staying able to explain where code runs and where data flows out. Losing sight of those paths in exchange for convenience is the thing I most want to avoid over a long-running operation.
Between being able to connect and connecting safely sit the design layers of authorization, degradation, and isolation. I would start with a single small tool like an inventory lookup and build the whole loop—tunnel, allowlist, tenant boundary, reconnection—by hand once. Once you have a minimal working setup, you can extend the same shape as more services come online.