Deploying the RHEL MCP Server on Openshift
Let’s set the stage
Several weeks ago I started a short series on Model Context Protocol (MCP) servers in my homelab. That first post was a broad overview of how I use MCP with Cursor across Openshift, RHEL, and Ansible Automation Platform. In my last post, I walked through enabling the Ansible MCP server on my operator-managed AAP instance.
This post is the RHEL piece of that series.
In the overview I described rhel-mcp as the read-only Linux diagnostics bridge: journal entries, service status, process lists, and the rest, without installing an agent on every host. What I did not cover then is how I actually run that server. I deploy the upstream linux-mcp-server image on my Openshift cluster, expose it over HTTPS on the ingress, and let it reach my RHEL systems over SSH from inside the cluster.
I’m going to walk through the manifests I use for that deployment and how I wire the server into Cursor. I’m only going to cover the command-line path here. For the upstream server behavior and SSH prerequisites on managed hosts, refer to the Red Hat documentation.
What you’re deploying
The MCP server for RHEL (linux-mcp-server) is a developer-preview offering from Red Hat. It wraps standard Linux utilities and returns formatted results to MCP clients. The tools are read-only by design, which is the main reason I’m comfortable pointing an AI assistant at my lab hosts.
In my homelab the server does not run on my laptop via stdio and Podman, even though that is the pattern in a lot of the getting-started guides. Instead it runs as a pod on Openshift in the rhel-mcp namespace. Cursor and other clients connect to it over HTTP at a route like linux-mcp-server-rhel-mcp.apps.ocp.bk.lab/mcp. When I ask the assistant to check a service on idm01.bk.lab, the MCP server SSHes to that host from the cluster using a dedicated mcp user and the key I mounted into the deployment.
Long story short, the cluster hosts the MCP endpoint; the RHEL VMs stay agents-free aside from SSH and sudo for the mcp account.
Prerequisites
Before you apply the manifests, you should have:
- Red Hat Openshift Container Platform with permission to create a namespace, deployment, route, and a custom SecurityContextConstraints object
- Network connectivity from the Openshift cluster to your target RHEL hosts on SSH (port 22)
- An SSH key pair and a dedicated user on each host you want the server to manage, per the RHEL MCP server docs
oclogged in to the cluster where you want the workload to run
The manifest layout
I keep a small directory of YAML files that together create the rhel-mcp namespace and roll out the server, service, route, storage, SSH configuration, and authorization policy. Here is what each manifest does.
namespace.yaml
Creates the rhel-mcp project so the MCP workload is isolated from aap, openshift-mcp, and everything else running on the cluster. The annotations give it a readable name in the Openshift console.
apiVersion: v1
kind: Namespace
metadata:
name: rhel-mcp
serviceaccount.yaml
Defines the linux-mcp-server service account the pod runs under. That account is what you bind the custom SCC to during deploy. Keeping a dedicated service account means you are not reusing default and you can scope permissions narrowly.
apiVersion: v1
kind: ServiceAccount
metadata:
name: linux-mcp-server
namespace: rhel-mcp
scc.yaml
Defines a SecurityContextConstraints (SCC) object named linux-mcp-server so the pod may run as the fixed UID/GID the upstream image requires.
apiVersion: security.openshift.io/v1
kind: SecurityContextConstraints
metadata:
name: linux-mcp-server
allowHostDirVolumePlugin: false
allowHostIPC: false
allowHostNetwork: false
allowHostPID: false
allowHostPorts: false
allowPrivilegeEscalation: false
allowPrivilegedContainer: false
fsGroup:
type: MustRunAs
ranges:
- min: 1001
max: 1001
runAsUser:
type: MustRunAs
uid: 1001
requiredDropCapabilities:
- KILL
- MKNOD
- SETUID
- SETGID
volumes:
- configMap
- downwardAPI
- emptyDir
- persistentVolumeClaim
- projected
- secret
If you’re new to Openshift, SCCs are worth understanding before you apply this file.
Kubernetes lets you declare a security context on a pod: run as a particular user, drop capabilities, forbid privilege escalation, and so on. Openshift adds SCCs on top of that model. An SCC is a cluster-scoped policy object that defines which security contexts pods are allowed to use. Every pod is admitted only if its security context matches an SCC that has been granted to its service account.
The default SCCs on a typical cluster are fairly restrictive. For example, the common restricted policy assigns pods an arbitrary UID from a high numeric range so containers do not run as root. That is good for security, but it breaks images that were built to run as a fixed non-root user. The upstream linux-mcp-server image expects UID and GID 1001. Without a matching SCC, Openshift will assign a different UID, the container entrypoint will not match what the image author intended, and the pod will sit in CreateContainerConfigError or crash immediately.
The scc.yaml manifest creates a dedicated policy so this deployment may run as UID/GID 1001, mount the volume types the pod needs, and still operate under a locked-down rule set: no privileged containers, no host namespaces, privilege escalation disabled, and capabilities dropped. Applying the manifest only creates the SCC object. During deploy you still run oc adm policy add-scc-to-user to bind that SCC to the linux-mcp-server service account in the rhel-mcp namespace. That grant is namespace-scoped through the service account; it does not open the policy cluster-wide.
From a security standpoint, SCCs are how Openshift enforces guardrails. The MCP server gets only the permissions its image actually needs, and everything else stays denied by default.
configmap.yaml
Supplies environment variables the linux-mcp-server process reads at startup. This is where I set HTTP transport, the listen address and port, the MCP path (/mcp), the SSH key path inside the container, and the location of the authorization policy file. Optional variables such as LINUX_MCP_ALLOWED_LOG_PATHS can be uncommented when you want to allow read_log_file against specific paths on managed hosts.
data:
LINUX_MCP_USER: "mcp"
LINUX_MCP_TRANSPORT: "http"
LINUX_MCP_HOST: "0.0.0.0"
LINUX_MCP_PORT: "8000"
LINUX_MCP_PATH: "/mcp"
LINUX_MCP_SSH_KEY_PATH: "/var/lib/mcp/.ssh/id_ed25519"
LINUX_MCP_POLICY_PATH: "/etc/linux-mcp/policy.yaml"
auth-policy-configmap.yaml
Mounts as /etc/linux-mcp/policy.yaml inside the pod. It tells the MCP server which tools may run against which remote hosts when clients connect over HTTP. In my lab the policy is permissive: any tool against any *.bk.lab host. I cover the security implications in a later section.
data:
policy.yaml: |
rules:
- host: "*.bk.lab"
tools: ["*"]
action: ssh_default
all_users: true
ssh-config-configmap.yaml
Provides an SSH client configuration the pod uses when connecting to lab RHEL systems. Each Host stanza names a target, sets the mcp user, points at the mounted private key, and relaxes host key checking for lab convenience. If a host is missing from this file, the MCP server cannot reach it even if the key and network are otherwise fine.
apiVersion: v1
kind: ConfigMap
metadata:
name: linux-mcp-ssh-config
namespace: rhel-mcp
data:
config: |
Host idm01.bk.lab
HostName idm01.bk.lab
User mcp
IdentityFile /var/lib/mcp/.ssh/id_ed25519
StrictHostKeyChecking accept-new
UserKnownHostsFile /dev/null
Host satellite.bk.lab
HostName satellite.bk.lab
User mcp
IdentityFile /var/lib/mcp/.ssh/id_ed25519
StrictHostKeyChecking accept-new
UserKnownHostsFile /dev/null
# ... additional lab hosts ...
pvc.yaml
Requests a small ReadWriteOnce volume for linux-mcp-server log files so they survive pod restarts. One gibibyte is plenty for my lab.
kind: PersistentVolumeClaim
metadata:
name: linux-mcp-server-logs
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
deployment.yaml
The core workload. It runs the quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest image, wires in the ConfigMaps and optional SSH secret as volumes, sets resource requests and limits, and configures liveness and readiness probes against the HTTP port. The pod spec uses serviceAccountName: linux-mcp-server and a pod-level security context matching UID 1001.
apiVersion: apps/v1
kind: Deployment
metadata:
name: linux-mcp-server
namespace: rhel-mcp
spec:
replicas: 1
template:
spec:
serviceAccountName: linux-mcp-server
securityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
containers:
- name: linux-mcp-server
image: quay.io/redhat-services-prod/rhel-lightspeed-tenant/linux-mcp-server:latest
ports:
- name: http
containerPort: 8000
envFrom:
- configMapRef:
name: linux-mcp-server
env:
- name: LINUX_MCP_USER
valueFrom:
secretKeyRef:
name: rhel-mcp-ssh
key: username
optional: true
volumeMounts:
- name: ssh-dir
mountPath: /var/lib/mcp/.ssh
readOnly: true
- name: auth-policy
mountPath: /etc/linux-mcp
readOnly: true
- name: logs
mountPath: /var/lib/mcp/.local/share/linux-mcp-server/logs
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
volumes:
- name: ssh-dir
projected:
sources:
- secret:
name: rhel-mcp-ssh
optional: true
- configMap:
name: linux-mcp-ssh-config
- name: auth-policy
configMap:
name: linux-mcp-auth-policy
- name: logs
persistentVolumeClaim:
claimName: linux-mcp-server-logs
The important volume mounts are:
- ssh-dir — projected SSH private key (from the optional
rhel-mcp-sshsecret) plus the SSH config ConfigMap - auth-policy — the HTTP authorization policy
- logs — the persistent volume for server logs
service.yaml
Exposes the deployment inside the cluster on port 8000. The route and probes target this service name.
spec:
selector:
app: linux-mcp-server
ports:
- name: http
port: 8000
targetPort: http
route.yaml
Creates an Openshift Route so external MCP clients (Cursor on my laptop, for example) reach the server over HTTPS. TLS terminates at the router with edge termination, and a five-minute timeout annotation keeps long-running tool calls from being cut off too aggressively.
spec:
to:
kind: Service
name: linux-mcp-server
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
In my lab the resulting URL is https://linux-mcp-server-rhel-mcp.apps.ocp.bk.lab/mcp.
Deploy on Openshift
-
From a machine with
ocaccess, change into the directory that holds your RHEL MCP manifests. -
Apply the manifests.
oc apply -f . -
Grant the custom SCC to the workload service account, as described under scc.yaml above. This step is required once per cluster.
oc adm policy add-scc-to-user linux-mcp-server -z linux-mcp-server -n rhel-mcp -
Watch the rollout.
oc get pods -n rhel-mcp -w -
Confirm the route and probe the MCP endpoint.
oc get route linux-mcp-server -n rhel-mcp -o jsonpath='https://{.spec.host}{.spec.path}{"\n"}'curl -sk "$(oc get route linux-mcp-server -n rhel-mcp -o jsonpath='https://{.spec.host}')/mcp" -o /dev/null -w '%{http_code}\n'An HTTP response (even unauthorized) means the route and service are wired.
SSH credentials and target hosts
The deployment mounts SSH configuration from a ConfigMap and an optional Secret. I create the secret with the private key I use for the mcp user on managed hosts:
oc create secret generic rhel-mcp-ssh -n rhel-mcp \
--from-file=id_ed25519_mcp=$HOME/.ssh/id_ed25519_mcp \
--from-literal=username=mcp \
--from-literal=key-passphrase='' \
--dry-run=client -o yaml | oc apply -f -
oc rollout restart deployment/linux-mcp-server -n rhel-mcp
The ssh-config-configmap.yaml file lists the lab hosts I want reachable from the pod. Each stanza points at a *.bk.lab system, uses the mcp user, and references the key mounted at /var/lib/mcp/.ssh/id_ed25519. I use the combination of rhel-mcp and aap-mcp in Cursor to keep that list current: I query AAP for inventory and managed hosts, compare what the automation platform knows about, and update the SSH ConfigMap so the same systems I run jobs against are the same systems I can ask the assistant to inspect.
When I add a new host, I still edit the ConfigMap, re-apply, and restart the deployment if needed. With both MCP servers enabled, I often let the assistant handle that update: it can read the current inventory from AAP, draft the new Host stanza for ssh-config-configmap.yaml, and even apply the change and trigger a deployment rollout so I am not maintaining two inventories by hand.
HTTP authorization policy
The upstream linux-mcp-server supports an authorization policy file for HTTP transport. My lab policy in auth-policy-configmap.yaml is intentionally permissive:
rules:
- host: "*.bk.lab"
tools: ["*"]
action: ssh_default
all_users: true
That allows every exposed tool against any host matching *.bk.lab without per-user OAuth, which is fine for an isolated lab network. The upstream server documentation is explicit about this: HTTP transport does not include authentication by default. TLS terminates at the Openshift route, but anyone who can reach the URL could invoke tools unless you tighten the policy or put an API gateway in front.
For production you would replace all_users: true with JWT or OAuth claim rules and narrow the host and tool lists.
Configuration worth knowing
Most environment variables live in configmap.yaml. The ones I touch or think about most often:
| Variable | Purpose |
|---|---|
LINUX_MCP_USER |
Default SSH user for targets (overridden by the rhel-mcp-ssh secret when present) |
LINUX_MCP_ALLOWED_LOG_PATHS |
Comma-separated paths allowed for read_log_file on managed hosts |
LINUX_MCP_TOOLSET |
fixed, run_script, or both — I leave the safer defaults unless I have a reason not to |
LINUX_MCP_VERIFY_HOST_KEYS |
I set false in the lab to reduce friction with rebuilt VMs; production should verify keys |
Review guarded command execution in the upstream docs before enabling script or write-oriented toolsets.
Connect Cursor
Add an HTTP entry to ~/.cursor/mcp.json. My lab configuration does not use a bearer token on this server because of the permissive policy above. Your posture may differ.
{
"mcpServers": {
"rhel-mcp": {
"type": "http",
"url": "https://linux-mcp-server-rhel-mcp.apps.ocp.example.com/mcp"
}
}
}
Restart or refresh MCP in Cursor, then try a low-risk prompt that names a host you configured:
What is the status of the sshd service on idm01.bk.lab?
If SSH from the pod to that host works, the assistant should return real systemctl or journal-backed output instead of a guess.
How this fits with my other MCP servers
On the same Openshift cluster I also run aap-mcp in the aap namespace and openshift-mcp in openshift-mcp. The division of labor is simple:
- openshift-mcp — cluster and platform objects
- aap-mcp — automation controller API
- rhel-mcp — operating system diagnostics on individual hosts
When I’m debugging something that spans layers, I might enable all three in Cursor. When I’m editing playbooks for a specific host, I often enable only rhel-mcp and aap-mcp.
Things that tripped me up
- SCC first. Forgetting
oc adm policy add-scc-to-userproduces a pod that never becomes ready. The events will mention UID constraints. - SSH from the pod, not from your laptop. A key that works when you
sshfrom your own workstation still has to be in therhel-mcp-sshsecret and listed in the SSH ConfigMap host entries. - Host key verification. I disabled strict checking in the lab ConfigMap (
StrictHostKeyChecking accept-new). That is a convenience trade-off, not a recommendation for production. - HTTP is not auth. Do not expose the route to untrusted networks without tightening
auth-policy-configmap.yamlor adding a proxy.
Troubleshooting
When MCP calls fail for a specific host, I check in this order:
- Pod health —
oc get pods -n rhel-mcpand deployment logs - Route — does the URL in
mcp.jsonmatchoc get route? - SSH from inside the pod — can the workload reach port 22 on the target?
- mcp user and key — is the public key in
authorized_keysformcpon that host? - ConfigMap host entry — is the hostname spelled the same way in
linux-mcp-ssh-config?
Most of my early failures were missing SCC grants or a host I had automated in AAP but had not yet added to the SSH ConfigMap.
Final Thoughts
Deploying the RHEL MCP server on Openshift turned out to be more moving parts than adding spec.mcp to an existing AAP custom resource, but the model is the same: run the bridge close to the infrastructure it talks to, expose one HTTPS endpoint, and keep the assistants read-only on hosts until you have a reason to loosen that.
If you’ve been following the MCP series, this is the homelab deployment story behind the RHEL bullet in the overview post. The Openshift MCP follow-up is posted next in the series.
For authoritative background, see Using the MCP server for RHEL and Leverage AI for root-cause analysis with MCP servers in VS Code and Cursor. The upstream project is at rhel-lightspeed/linux-mcp-server.
As with everything I write about my lab, this is how I run things. Your namespaces, hostnames, and security posture may differ. Use developer-preview features accordingly, and keep secret data out of git.