Artificial Intelligence (AI) Azure

MCPHub on Ubuntu 24.04 on Azure User Guide

| Product: MCPHub on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and operation of MCPHub on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. MCPHub is an open source, Apache-2.0 licensed gateway for the Model Context Protocol (MCP). You connect MCP servers to it once, local stdio servers started with npx or uvx as well as remote SSE and streamable HTTP servers, and MCPHub publishes them to AI clients such as Claude Code, Cursor, Cherry Studio and OpenWebUI through stable, authenticated MCP endpoints, with a web dashboard to manage servers, tools, groups, users and keys.

What makes MCPHub different. cloudimg also publishes the MetaMCP and ContextForge MCP gateways. MCPHub is organised around routes and groups:

  • One endpoint per scope. /mcp serves every server, /mcp/<group> serves one group, and /mcp/<server> serves a single server, so each client or agent can be pointed at exactly the set of servers it needs.
  • Per tool control. Each tool of each server can be switched off or have its description rewritten, and a group can include only chosen tools from a server, for example the read only tools of a database server.
  • Scoped bearer keys. A key can be limited to particular groups or servers, so an agent's key cannot reach anything else.
  • Per user credentials. Users can bind their own API keys to a shared server, stored encrypted, so one server definition serves many people with their own accounts.
  • Smart routing (optional). Upstream's $smart endpoint finds the right tool by semantic search. It needs PostgreSQL with pgvector and an embedding API, which this image does not include; see Step 11.

It is also the lightest of the three: a single Node.js process with one JSON settings file, no database server and no container runtime.

This image builds MCPHub v1.1.4 from the upstream release tag (the commit is verified) with the upstream lockfile, runs it as an unprivileged mcphub service account on loopback only, and puts nginx in front of it with HTTPS. Out of the box upstream MCPHub creates its admin account with a random password that it prints to the server log. On this image the password is generated for your VM on first boot, passed to MCPHub from a root only file so it is never logged, and a per VM MCP bearer key is created; nginx does not start until first boot has proved the password works and that MCP refuses callers without the key. Backed by 24/7 cloudimg support.

MCPHub is an independent open source project by samanhappy and its contributors. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by the MCPHub project or its contributors. It ships the free and open source Apache-2.0 licensed software, unmodified.

The firstboot, MCPHub and nginx services active, nginx listening on ports 80 and 443, MCPHub listening on 127.0.0.1:3000 only, and both example MCP servers connected

What is included:

  • MCPHub v1.1.4 built from the upstream tag with pnpm --frozen-lockfile, production dependencies only, with its LICENSE in /usr/share/doc/mcphub
  • Node.js 22 LTS and uv/uvx from their official releases, pinned by checksum, so you can add npx and uvx based MCP servers
  • Two example MCP servers from the Model Context Protocol project, memory (a small knowledge graph) and sequential-thinking, installed locally from a pinned lockfile. They need no network and no API key, and are grouped as starter
  • nginx serving the dashboard and MCP endpoints over HTTPS on port 443 with a certificate generated for your VM; port 80 only redirects to HTTPS
  • A unique admin password, MCP bearer key, JWT signing secret, credential encryption key and TLS certificate generated per VM on first boot, never baked into the image
  • mcphub-selftest, a one command end to end check of the gateway

Prerequisites

  • An Azure subscription with permission to create VMs
  • A Standard_B2s VM or larger. MCPHub itself is light; size the VM for the MCP servers you will run on it (each stdio server is its own process)
  • Inbound TCP 443 (and optionally 80, which only redirects) open to the networks your AI clients and administrators connect from
  • Outbound internet access if you will add servers with npx or uvx: they download packages from registry.npmjs.org and pypi.org the first time they start. The two example servers need no network
  • An SSH key pair for administrative access

Step 1: Deploy from the Azure Portal

  1. In the Azure Portal, search the Marketplace for MCPHub on Ubuntu 24.04 LTS by cloudimg and select Create.
  2. Choose your subscription, resource group and region.
  3. Set the VM size to Standard_B2s or larger.
  4. Set the authentication type to SSH public key and supply your key.
  5. Under Inbound port rules, allow SSH (22) and HTTPS (443). Add HTTP (80) if you want plain http:// links to redirect.
  6. On the Disks tab, StandardSSD_LRS is a good balance of cost and performance.
  7. Select Review + create, then Create.

First boot takes well under a minute. During it the appliance generates your VM's credentials and certificate, creates the admin account and proves that MCP refuses unauthenticated callers; nginx does not start until that has been proved.

Step 2: Deploy from the Azure CLI

az vm image terms accept --urn cloudimg1647283583153:mcphub-ubuntu-24-04:default:latest

az vm create \
  --resource-group my-resource-group \
  --name mcphub-gateway \
  --image cloudimg1647283583153:mcphub-ubuntu-24-04:default:latest \
  --size Standard_B2s \
  --storage-sku StandardSSD_LRS \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

az vm open-port \
  --resource-group my-resource-group \
  --name mcphub-gateway \
  --port 443 --priority 1001

Restrict that rule to your own address ranges in production. A gateway can run tools on your behalf, so the fewer networks that can reach it, the better.

Step 3: Connect to your VM

ssh azureuser@<vm-ip>

Step 4: Confirm the services are running

sudo systemctl is-active mcphub-firstboot.service mcphub.service nginx.service

Expected output:

active
active
active

nginx listens on 80 and 443. MCPHub listens on port 3000 on loopback only:

sudo ss -lntp | grep -E ':(80|443|3000)\b'

Expected output (process ids differ):

LISTEN 0  511        0.0.0.0:443     0.0.0.0:*  users:(("nginx",pid=4549,fd=7),...)
LISTEN 0  511        0.0.0.0:80      0.0.0.0:*  users:(("nginx",pid=4549,fd=5),...)
LISTEN 0  511      127.0.0.1:3000    0.0.0.0:*  users:(("node",pid=4668,fd=24))
LISTEN 0  511           [::]:443        [::]:*  users:(("nginx",pid=4549,fd=8),...)
LISTEN 0  511           [::]:80         [::]:*  users:(("nginx",pid=4549,fd=6),...)

Upstream MCPHub has no listen address option, so the appliance starts it with a small shim (/opt/mcphub/loopback-listen.cjs) that binds it to 127.0.0.1. You can prove from the VM that port 3000 answers on loopback and not on the VM's own address, while nginx serves HTTPS and port 80 only redirects:

curl -s -o /dev/null -w 'loopback 127.0.0.1:3000 -> HTTP %{http_code}\n' http://127.0.0.1:3000/health
curl -s -o /dev/null -m 4 -w "own address $(hostname -I | awk '{print $1}'):3000 -> HTTP %{http_code}\n" "http://$(hostname -I | awk '{print $1}'):3000/health" || true
curl -s -o /dev/null -w 'http://127.0.0.1/mcp -> HTTP %{http_code} %{redirect_url}\n' http://127.0.0.1/mcp
curl -sk https://127.0.0.1/health | jq -c .servers

Expected output (your private address differs):

loopback 127.0.0.1:3000 -> HTTP 200
own address 10.0.0.10:3000 -> HTTP 000
http://127.0.0.1/mcp -> HTTP 301 https://127.0.0.1/mcp
{"total":2,"connected":2,"disconnected":0}

HTTP 000 means nothing answered: nothing outside the VM can reach MCPHub except through nginx.

Step 5: Read your VM's credentials

Every credential on this appliance was generated for this VM on its first boot.

sudo cat /root/mcphub-credentials.txt

Expected output (your values differ):

# MCPHub - generated for this VM on first boot by mcphub-firstboot.service.
# Nothing here is shared with any other VM. Changing the password or keys in the
# dashboard does not update this file.

MCPHUB_URL=https://<your VM's public IP>/
MCPHUB_ADMIN_USERNAME=admin
MCPHUB_ADMIN_PASSWORD=<24 characters generated on your VM>
MCPHUB_BEARER_KEY=mcphub_<64 hex characters generated on your VM>
MCPHUB_MCP_URL=https://<your VM's public IP>/mcp
MCPHUB_GROUP_MCP_URL=https://<your VM's public IP>/mcp/starter
MCPHUB_TLS_SHA256_FINGERPRINT=<unique to your VM>

The per VM credentials file, readable only by root, holding the dashboard URL, the admin password, the MCP bearer key, the MCP endpoint URLs and the certificate fingerprint

The JWT signing secret MCPHub uses for dashboard sessions is in /etc/mcphub/mcphub.env, readable only by root and the mcphub service account. The settings file /var/lib/mcphub/mcp_settings.json stores the admin password only as a bcrypt hash.

Step 6: Sign in to the dashboard

Open MCPHUB_URL from the credentials file in your browser. The certificate is generated for your VM and is self-signed, so the browser warns once; compare the fingerprint it shows with MCPHUB_TLS_SHA256_FINGERPRINT before you accept it. Sign in as admin with MCPHUB_ADMIN_PASSWORD.

The MCPHub sign in page served over HTTPS by the appliance, with the admin username entered and the password masked

The dashboard shows how many servers are online, their tools, and an MCP Endpoints card with the URLs to give your clients: every server, each group, and smart routing. The URLs use your VM's public address, set on first boot under Settings → Installation → Base URL; change it there if you give the VM a DNS name. The sidebar holds Servers, Groups, Prompts, Resources, Market, Users, Logs, Credentials and Settings.

The MCPHub dashboard after signing in as admin, with both example servers online and the MCP endpoint URLs for every server, each group and smart routing

Change the admin password under Settings if you want one of your own, and record it somewhere safe; the credentials file keeps the original.

Step 7: Check the MCP endpoints

Every MCP endpoint requires a bearer key. Without one, or with a wrong one, MCPHub answers 401:

for p in /mcp /mcp/starter /mcp/memory; do
  printf 'anonymous POST %-13s -> HTTP %s\n' "$p" "$(curl -sk -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' https://127.0.0.1$p)"
done
printf 'anonymous GET  %-13s -> HTTP %s\n' /sse "$(curl -sk -o /dev/null -m 5 -w '%{http_code}' -H 'Accept: text/event-stream' https://127.0.0.1/sse)"

Expected output:

anonymous POST /mcp          -> HTTP 401
anonymous POST /mcp/starter  -> HTTP 401
anonymous POST /mcp/memory   -> HTTP 401
anonymous GET  /sse          -> HTTP 401

/mcp/memory is the single server route: every connected server also gets its own endpoint.

mcphub-selftest then proves the whole path with your VM's key: no key and a wrong key are refused on both the all servers endpoint and the starter group endpoint, and with the key each one completes an MCP initialize, lists the tools and runs a real tool call:

sudo mcphub-selftest

Expected output:

PASS /mcp: refused without a key and with a wrong key (401)
PASS /mcp/starter: refused without a key and with a wrong key (401)
PASS /mcp: initialize (mcphub), 10 tools listed, tools/call sequential-thinking-sequentialthinking returned a result
PASS /mcp/starter: initialize (mcphub_starter_group), 10 tools listed, tools/call sequential-thinking-sequentialthinking returned a result
MCPHUB_SELFTEST_OK

You can drive the protocol yourself with curl. MCPHub's streamable HTTP endpoint returns a session id on initialize, which later requests send back. This stores a fact in the memory server and reads the knowledge graph back:

KEY=$(sudo sed -n 's/^MCPHUB_BEARER_KEY=//p' /root/mcphub-credentials.txt)
H=(-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
SID=$(curl -sk -D - -o /dev/null "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' https://127.0.0.1/mcp | sed -n 's/^mcp-session-id: //Ip' | tr -d '\r')
curl -sk "${H[@]}" -H "mcp-session-id: $SID" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' https://127.0.0.1/mcp | sed -n 's/^data: //p' | jq -r '.result.tools[].name'
curl -sk "${H[@]}" -H "mcp-session-id: $SID" -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"memory-create_entities","arguments":{"entities":[{"name":"cloudimg","entityType":"organization","observations":["publishes the MCPHub image"]}]}}}' https://127.0.0.1/mcp >/dev/null
curl -sk "${H[@]}" -H "mcp-session-id: $SID" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"memory-read_graph","arguments":{}}}' https://127.0.0.1/mcp | sed -n 's/^data: //p' | jq -r '.result.content[0].text'

Expected output:

memory-create_entities
memory-create_relations
memory-add_observations
memory-delete_entities
memory-delete_observations
memory-delete_relations
memory-read_graph
memory-search_nodes
memory-open_nodes
sequential-thinking-sequentialthinking
{
  "entities": [
    {
      "name": "cloudimg",
      "entityType": "organization",
      "observations": [
        "publishes the MCPHub image"
      ]
    }
  ],
  "relations": []
}

Tools are named <server>-<tool>, so tools from different servers never collide. The memory server keeps its graph in /var/lib/mcphub/memory.jsonl.

MCP endpoints refusing anonymous callers, mcphub-selftest passing on the all servers and group endpoints, and the dashboard API refusing the upstream development password

Step 8: Connect an AI client

Point any MCP client that supports streamable HTTP (or SSE at /sse) at MCPHUB_MCP_URL (every server) or MCPHUB_GROUP_MCP_URL (one group), with the header Authorization: Bearer <MCPHUB_BEARER_KEY>.

Claude Code:

claude mcp add --transport http mcphub https://<vm-ip>/mcp \
  --header "Authorization: Bearer mcphub_your-key"

Cursor (~/.cursor/mcp.json) and other clients that take a JSON server list:

{
  "mcpServers": {
    "mcphub": {
      "url": "https://<vm-ip>/mcp",
      "headers": { "Authorization": "Bearer mcphub_your-key" }
    }
  }
}

Until you install a certificate from a public authority (Step 12), clients must trust this VM's self-signed certificate. Copy /etc/mcphub/tls/mcphub.crt to the client machine and trust it (for Node.js based clients, set NODE_EXTRA_CA_CERTS to that file). MCPHub also includes an OAuth 2.0 server, so clients that support MCP OAuth can sign in through the dashboard instead of using a static key.

Step 9: Add your own MCP servers

Open Servers and select Add Server, or install one from Market, the catalogue bundled with MCPHub. A server is one of:

  • stdio: a command MCPHub starts on the VM, typically npx -y <package> or uvx <package>, with any environment variables (API keys) it needs
  • SSE or streamable HTTP: a remote MCP server URL, with headers or OAuth

The Servers page with the memory server expanded, showing its tools, each of which can be switched off individually

Each server's tools are listed under it. Switch a tool off to hide it from every endpoint, or edit its description to steer how AI clients use it. Changes apply without a restart.

Egress. stdio servers started with npx or uvx download their package from registry.npmjs.org or pypi.org the first time they start, and run as the unprivileged mcphub account. Add only servers you trust: an MCP server runs code on this VM and can use whatever credentials you give it. MCPHub also checks registry.npmjs.org for a newer MCPHub release so the dashboard can tell you about it.

Step 10: Groups, per tool selection and scoped keys

A group is a named set of servers with its own endpoint, /mcp/<group>, and a group can include only some of a server's tools. Combined with a bearer key scoped to that group, this gives an agent exactly the tools it needs and nothing else. Create them in the dashboard under Groups and Settings → Keys, or with the API as below. This signs in with the admin password, creates a group memory-readonly holding just the three read only tools of the memory server, creates a key that may use only that group, and compares what each key can see:

PW=$(sudo sed -n 's/^MCPHUB_ADMIN_PASSWORD=//p' /root/mcphub-credentials.txt)
KEY=$(sudo sed -n 's/^MCPHUB_BEARER_KEY=//p' /root/mcphub-credentials.txt)
B=https://127.0.0.1
T=$(printf '{"username":"admin","password":"%s"}' "$PW" | curl -sk -H 'Content-Type: application/json' --data-binary @- $B/api/auth/login | jq -r .token)
curl -sk -H "x-auth-token: $T" -H 'Content-Type: application/json' \
  -d '{"name":"memory-readonly","description":"Read-only knowledge graph tools","servers":[{"name":"memory","tools":["read_graph","search_nodes","open_nodes"]}]}' \
  $B/api/groups | jq -c '{success, group: .data.name}'
SCOPED=$(curl -sk -H "x-auth-token: $T" -H 'Content-Type: application/json' \
  -d '{"name":"reporting-agent","accessType":"groups","allowedGroups":["memory-readonly"]}' $B/api/auth/keys | jq -r .data.token)
tools() {
  local H=(-H "Authorization: Bearer $2" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
  local code sid
  code=$(curl -sk -o /dev/null -D /tmp/mcp-h -w '%{http_code}' "${H[@]}" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"guide","version":"1"}}}' "$B$1")
  [ "$code" = 200 ] || { echo "$1 -> HTTP $code"; return; }
  sid=$(sed -n 's/^mcp-session-id: //Ip' /tmp/mcp-h | tr -d '\r')
  curl -sk "${H[@]}" -H "mcp-session-id: $sid" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' "$B$1" \
    | sed -n 's/^data: //p' | jq -r --arg p "$1" '"\($p) -> \(.result.tools | length) tools"'
}
echo "default key:";  tools /mcp "$KEY"; tools /mcp/memory-readonly "$KEY"
echo "scoped key:";   tools /mcp/memory-readonly "$SCOPED"; tools /mcp "$SCOPED"; tools /mcp/starter "$SCOPED"
rm -f /tmp/mcp-h

Expected output:

{"success":true,"group":"memory-readonly"}
default key:
/mcp -> 10 tools
/mcp/memory-readonly -> 3 tools
scoped key:
/mcp/memory-readonly -> 3 tools
/mcp -> HTTP 401
/mcp/starter -> HTTP 401

The new key is printed by the API only once, when it is created; the dashboard shows it masked afterwards.

The Groups page with the starter group and the memory-readonly group, which exposes only three tools of the memory server

A group exposing three chosen tools, the default key reaching every endpoint, and a key scoped to that group reaching only it

Step 11: Smart routing (optional)

MCPHub's /mcp/$smart endpoint lets an AI client describe what it wants to do and find the right tool by semantic search across every connected server, instead of loading every tool definition. It stores tool embeddings in PostgreSQL with the pgvector extension and needs an embedding API (OpenAI or a compatible service). This image runs MCPHub in its file mode and does not include a database. To use smart routing, provide a PostgreSQL server with pgvector (for example Azure Database for PostgreSQL flexible server with the vector extension allowed) and set DB_URL in /etc/mcphub/mcphub.env. Setting DB_URL switches MCPHub to its database mode: on its next start it migrates the servers, groups, users and keys from the JSON settings file into PostgreSQL, so back up /var/lib/mcphub first (see Day to day operation). Then restart mcphub.service and configure the embedding provider under Settings → Smart Routing. Follow the upstream documentation at https://docs.mcphub.app/features/smart-routing and https://docs.mcphub.app/configuration/database-configuration.

Step 12: Use your own domain and certificate

Point a DNS name at the VM's public IP, then replace the self-signed certificate with one from a public authority. For example, with certbot's webroot plugin (install certbot first):

sudo certbot certonly --webroot -w /var/www/html -d mcp.example.com
sudo ln -sf /etc/letsencrypt/live/mcp.example.com/fullchain.pem /etc/mcphub/tls/mcphub.crt
sudo ln -sf /etc/letsencrypt/live/mcp.example.com/privkey.pem /etc/mcphub/tls/mcphub.key
sudo nginx -t && sudo systemctl reload nginx

For that challenge to pass, add a location /.well-known/acme-challenge/ { root /var/www/html; } block to the port 80 server in /etc/nginx/sites-available/mcphub.conf and open port 80. Clients then use https://mcp.example.com/mcp with normal certificate verification.

Step 13: What is in the image, and under what licence

jq -r .version /opt/mcphub/app/package.json
node --version
uv --version
cat /usr/share/doc/mcphub/SOURCE

Expected output:

1.1.4
v22.23.3
uv 0.13.1 (x86_64-unknown-linux-gnu)
MCPHub v1.1.4, built from the unmodified upstream source.
Source: https://github.com/samanhappy/mcphub/tree/b3ec898335c5fb8269adaa99bf6ce7804684139e
Release: https://github.com/samanhappy/mcphub/releases/tag/v1.1.4
Licence: Apache License 2.0 (see LICENSE in this directory).
Built with the upstream pnpm-lock.yaml (--frozen-lockfile) on Node.js v22.23.3; the
package.json version is stamped from the tag, as upstream's own release builds do.

Example MCP servers in /opt/mcphub/servers: @modelcontextprotocol/server-memory 1.0.0 and
@modelcontextprotocol/server-sequential-thinking 1.0.0 from
https://github.com/modelcontextprotocol/servers (see example-servers-LICENSE).

MCPHub is licensed under the Apache License 2.0; the example servers are released by the Model Context Protocol project under Apache-2.0 and MIT. The licence texts are in /usr/share/doc/mcphub.

Day to day operation

sudo systemctl status mcphub.service --no-pager
sudo journalctl -u mcphub.service -n 30 --no-pager
sudo ls -l /var/lib/mcphub/
  • Restart the gateway with sudo systemctl restart mcphub.service, and nginx with sudo systemctl reload nginx. MCPHub restarts every stdio server it manages.
  • Configuration lives in /var/lib/mcphub/mcp_settings.json: servers, groups, users, keys and system settings. The dashboard edits it for you; if you edit it by hand, restart the service afterwards.
  • Back up by copying the state directory, which holds the settings, the credential encryption key and the memory server's data:
sudo tar czf /root/mcphub-backup-$(date +%F).tgz -C /var/lib/mcphub .

Keep /etc/mcphub/mcphub.env with any backup you restore elsewhere, and keep mcp_settings.json.credentials.key with the settings: it decrypts the per user credentials stored in them.

  • Upgrades. Unattended upgrades keep Ubuntu and nginx patched. To move MCPHub to a newer upstream release, back up first, read its release notes, and rebuild /opt/mcphub/app from the new tag the same way (see SOURCE), or contact cloudimg support.

Security hardening

  • Restrict ports 443 and 80 to your own networks in the network security group. MCP servers run tools with the credentials you give them.
  • Give each client or agent its own key, scoped to the groups or servers it needs, and delete keys you no longer use (Settings → Keys).
  • Keep bearer authentication on. It is on in this image; turning it off in Settings → Keys opens every MCP endpoint to anyone who can reach the VM.
  • Add only MCP servers you trust, and switch off tools a client should not call.
  • Use a certificate from a public authority for a real hostname (Step 12) so clients can verify the gateway.
  • Keep unattended upgrades on. They are enabled in this image.

Troubleshooting

Nothing answers on port 443. nginx starts only after first boot has completed. Check it:

sudo systemctl status mcphub-firstboot.service --no-pager
sudo journalctl -u mcphub-firstboot.service --no-pager | tail -30

The browser warns about the certificate. Expected until you install your own certificate; compare the fingerprint with MCPHUB_TLS_SHA256_FINGERPRINT in the credentials file.

The credentials file shows a private address. Azure did not expose the public IP to the VM when it first started. Use the VM's public IP from the Azure Portal instead; everything else is unaffected.

A client gets 401. The Authorization: Bearer header is missing or the key is wrong, disabled or scoped to other groups. Check the key under Settings → Keys.

A server shows as disconnected. Open it under Servers to see its error, and check sudo journalctl -u mcphub.service. For npx and uvx servers the VM needs outbound access to the package registries.

Support

This image is published and supported by cloudimg. For assistance with the image, deployment or configuration, contact cloudimg support 24/7 through the Azure Marketplace listing.