An RHDH (Red Hat Developer Hub) Software Template that scaffolds a FastMCP MCP server with full GitOps CI/CD on OpenShift. When a developer runs this template it creates:
- An application repo with Python MCP server code, Dockerfile, Dev Spaces devfile, and Goose agent skills
- A GitOps repo with Kustomize manifests managed by ArgoCD
- A Tekton CI pipeline triggered by GitHub webhooks
- An ArgoCD Application for continuous deployment
- A Backstage catalog entry with CI, CD, and Kubernetes tabs
Developer
|
v
RHDH Template ──> GitHub (app repo + gitops repo + webhook)
|
push to main ───┘
|
v
Tekton EventListener
|
v
Tekton Pipeline (fastmcp-ci)
| | | |
clone build+push clone-gitops push-gitops
|
v
ArgoCD syncs
|
v
MCP Server running
on OpenShift
Before using this template, search and replace the following placeholders across the repository:
| Placeholder | Where | Example Value |
|---|---|---|
<GITHUB_ORG> |
template.yaml, TriggerTemplate, app-config-rhdh |
my-org |
<CLUSTER_DOMAIN> |
template.yaml, skeleton/catalog-info.yaml |
apps.ocp.example.com |
<ARGOCD_SERVER_URL> |
dynamic-plugins-rhdh |
https://openshift-gitops-server-openshift-gitops.apps.ocp.example.com |
Then follow the installation guide below to set up the OpenShift cluster infrastructure and RHDH configuration.
The following operators must be installed on the OpenShift cluster:
| Operator | Purpose |
|---|---|
| Red Hat Developer Hub (RHDH) | Internal developer portal |
| OpenShift Pipelines (Tekton) | CI pipeline execution |
| OpenShift GitOps (ArgoCD) | Continuous deployment |
| OpenShift Dev Spaces | Cloud-based development workspaces |
You also need a GitHub account with a personal access token that has repo,
admin:repo_hook, and workflow scopes.
oc new-project mcp-serversThe pipeline ServiceAccount in mcp-servers needs edit and
system:image-builder roles to build and deploy applications.
oc adm policy add-role-to-user edit system:serviceaccount:mcp-servers:pipeline -n mcp-servers
oc adm policy add-role-to-user system:image-builder system:serviceaccount:mcp-servers:pipeline -n mcp-serversThis pipeline is shared by all MCP servers. It clones the source, builds the container image, then updates the GitOps repo with the new image tag.
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: fastmcp-ci
namespace: mcp-servers
spec:
params:
- name: git-url
type: string
- name: git-revision
type: string
default: main
- name: repo-name
type: string
- name: deploy-namespace
type: string
default: mcp-servers
- name: gitops-repo-url
type: string
description: HTTPS URL of the GitOps repository
tasks:
- name: clone
params:
- name: URL
value: $(params.git-url)
- name: REVISION
value: $(params.git-revision)
taskRef:
resolver: cluster
params:
- name: kind
value: task
- name: name
value: git-clone
- name: namespace
value: openshift-pipelines
workspaces:
- name: output
workspace: source
- name: build-push
runAfter: [clone]
params:
- name: IMAGE
value: >-
image-registry.openshift-image-registry.svc:5000/$(params.deploy-namespace)/$(params.repo-name):$(tasks.clone.results.COMMIT)
- name: TLS_VERIFY
value: "false"
taskRef:
resolver: cluster
params:
- name: kind
value: task
- name: name
value: buildah
- name: namespace
value: openshift-pipelines
workspaces:
- name: source
workspace: source
- name: clone-gitops
runAfter: [build-push]
params:
- name: URL
value: $(params.gitops-repo-url)
- name: REVISION
value: main
- name: DEPTH
value: "0"
taskRef:
resolver: cluster
params:
- name: kind
value: task
- name: name
value: git-clone
- name: namespace
value: openshift-pipelines
workspaces:
- name: output
workspace: gitops
- name: basic-auth
workspace: git-credentials
- name: push-gitops
runAfter: [clone-gitops]
params:
- name: GIT_USER_NAME
value: Tekton CI
- name: GIT_USER_EMAIL
value: tekton-ci@openshift.local
- name: DELETE_EXISTING
value: "false"
- name: GIT_SCRIPT
value: |
COMMIT_SHA="$(tasks.clone.results.COMMIT)"
IMAGE="image-registry.openshift-image-registry.svc:5000/$(params.deploy-namespace)/$(params.repo-name):${COMMIT_SHA}"
git checkout -b main 2>/dev/null || git checkout main
cd manifests/base
sed -i "s|image:.*|image: ${IMAGE}|" deployment.yaml
cd /workspace/source
git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "ci: update image to ${COMMIT_SHA:0:8}"
git push origin HEAD:main
taskRef:
resolver: cluster
params:
- name: kind
value: task
- name: name
value: git-cli
- name: namespace
value: openshift-pipelines
workspaces:
- name: source
workspace: gitops
- name: basic-auth
workspace: git-credentials
workspaces:
- name: source
- name: gitops
- name: git-credentialsThis secret is used by the EventListener to validate incoming GitHub
webhook payloads. The value must match the webhookSecret in
template.yaml.
oc create secret generic github-webhook-secret \
--from-literal=webhook-secret=pac-webhook-shared-secret \
-n mcp-serversThe pipeline needs credentials to push image tag updates to the GitOps repo.
oc create secret generic github-basic-auth \
--type=kubernetes.io/basic-auth \
--from-literal=username=<GITHUB_USERNAME> \
--from-literal=password=<GITHUB_TOKEN> \
-n mcp-servers
oc annotate secret github-basic-auth \
"tekton.dev/git-0=https://github.com" \
-n mcp-serversTriggerBinding -- extracts values from the GitHub webhook payload:
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
name: github-push-binding
namespace: mcp-servers
spec:
params:
- name: git-url
value: $(body.repository.clone_url)
- name: git-revision
value: $(body.after)
- name: repo-name
value: $(body.repository.name)TriggerTemplate -- creates a PipelineRun with Backstage labels for CI tab visibility:
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
name: fastmcp-deploy-template
namespace: mcp-servers
spec:
params:
- name: git-url
- name: git-revision
- name: repo-name
resourcetemplates:
- apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: $(tt.params.repo-name)-run-
namespace: mcp-servers
labels:
app: $(tt.params.repo-name)
backstage.io/kubernetes-id: $(tt.params.repo-name)
tekton.dev/pipeline: fastmcp-ci
spec:
pipelineRef:
name: fastmcp-ci
params:
- name: git-url
value: $(tt.params.git-url)
- name: git-revision
value: $(tt.params.git-revision)
- name: repo-name
value: $(tt.params.repo-name)
- name: deploy-namespace
value: mcp-servers
- name: gitops-repo-url
value: https://github.com/<GITHUB_ORG>/$(tt.params.repo-name)-gitops.git
workspaces:
- name: source
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
- name: gitops
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 256Mi
- name: git-credentials
secret:
secretName: github-basic-authReplace
<GITHUB_ORG>with the GitHub organization or username that owns the repos (must matchallowedOwnersintemplate.yaml).
EventListener -- receives GitHub webhooks and filters for pushes to
main:
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
name: fastmcp-listener
namespace: mcp-servers
spec:
serviceAccountName: pipeline
triggers:
- name: github-push
bindings:
- ref: github-push-binding
interceptors:
- ref:
name: github
kind: ClusterInterceptor
params:
- name: secretRef
value:
secretName: github-webhook-secret
secretKey: webhook-secret
- name: eventTypes
value: [push]
- ref:
name: cel
kind: ClusterInterceptor
params:
- name: filter
value: "body.ref == 'refs/heads/main'"
template:
ref: fastmcp-deploy-templateCreate a route so GitHub can reach the EventListener. Note the URL --
it will be used in template.yaml for the webhookUrl.
oc expose svc el-fastmcp-listener -n mcp-servers
WEBHOOK_URL=$(oc get route el-fastmcp-listener -n mcp-servers -o jsonpath='{.spec.host}')
echo "Webhook URL: https://$WEBHOOK_URL"If TLS is required, create an edge-terminated route instead:
oc create route edge el-fastmcp-listener \
--service=el-fastmcp-listener \
--insecure-policy=Redirect \
-n mcp-serversUpdate template.yaml line 142 (webhookUrl) with this route URL.
All configuration lives in three resources in the RHDH namespace
(typically rhdh-operator).
Create a secret with all credentials the plugins need:
oc create secret generic rhdh-secrets \
--from-literal=GITHUB_TOKEN=<github-pat> \
--from-literal=K8S_SA_TOKEN=<sa-token> \
--from-literal=ARGOCD_USERNAME=admin \
--from-literal=ARGOCD_PASSWORD=<argocd-admin-password> \
--from-literal=ARGOCD_AUTH_TOKEN=<argocd-auth-token> \
--from-literal=KEYCLOAK_BASE_URL=<keycloak-oidc-metadata-url> \
--from-literal=KEYCLOAK_CLIENT_ID=<client-id> \
--from-literal=KEYCLOAK_CLIENT_SECRET=<client-secret> \
--from-literal=KEYCLOAK_REALM=<realm> \
--from-literal=KEYCLOAK_LOGIN_REALM=<login-realm> \
-n rhdh-operatorHow to get K8S_SA_TOKEN:
# Create a ServiceAccount for the K8s plugin
oc create sa backstage-k8s-plugin -n rhdh-operator
# Create the ClusterRole (see section below)
# Bind it
oc create clusterrolebinding backstage-k8s-reader-binding \
--clusterrole=backstage-k8s-reader \
--serviceaccount=rhdh-operator:backstage-k8s-plugin
# Create a long-lived token
oc create token backstage-k8s-plugin -n rhdh-operator --duration=8760hThis role gives the Kubernetes plugin read access to workloads, Tekton resources, ArgoCD applications, routes, and Dev Spaces clusters.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backstage-k8s-reader
rules:
- apiGroups: [""]
resources: [pods, pods/log, services, configmaps, events,
limitranges, resourcequotas]
verbs: [get, list, watch]
- apiGroups: [apps]
resources: [deployments, replicasets, statefulsets, daemonsets]
verbs: [get, list, watch]
- apiGroups: [batch]
resources: [jobs, cronjobs]
verbs: [get, list, watch]
- apiGroups: [autoscaling]
resources: [horizontalpodautoscalers]
verbs: [get, list, watch]
- apiGroups: [networking.k8s.io]
resources: [ingresses]
verbs: [get, list, watch]
- apiGroups: [metrics.k8s.io]
resources: [pods]
verbs: [get, list]
- apiGroups: [tekton.dev]
resources: [pipelines, pipelineruns, taskruns, tasks]
verbs: [get, list, watch]
- apiGroups: [triggers.tekton.dev]
resources: [eventlisteners, triggerbindings, triggertemplates]
verbs: [get, list, watch]
- apiGroups: [route.openshift.io]
resources: [routes]
verbs: [get, list, watch]
- apiGroups: [org.eclipse.che]
resources: [checlusters]
verbs: [get, list]
- apiGroups: [argoproj.io]
resources: [applications, appprojects]
verbs: [get, list, watch]Core RHDH application config. Replace placeholder values with your environment-specific URLs and credentials.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-rhdh
namespace: rhdh-operator
data:
app-config.yaml: |
app:
title: Red Hat Developer Hub
integrations:
github:
- host: github.com
token: ${GITHUB_TOKEN}
signInPage: oidc
auth:
session:
secret: <random-session-secret>
environment: production
providers:
oidc:
production:
metadataUrl: ${KEYCLOAK_BASE_URL}
clientId: ${KEYCLOAK_CLIENT_ID}
clientSecret: ${KEYCLOAK_CLIENT_SECRET}
prompt: auto
signIn:
resolvers:
- resolver: preferredUsernameMatchingUserEntityName
- resolver: emailLocalPartMatchingUserEntityName
dangerouslyAllowSignInWithoutUserInCatalog: true
catalog:
locations:
- type: url
target: https://github.com/<GITHUB_ORG>/rhdh-mcp-template/blob/main/location.yaml
rules:
- allow: [Template]
providers:
keycloakOrg:
default:
baseUrl: <KEYCLOAK_BASE_URL>
clientId: ${KEYCLOAK_CLIENT_ID}
clientSecret: ${KEYCLOAK_CLIENT_SECRET}
realm: ${KEYCLOAK_REALM}
loginRealm: ${KEYCLOAK_LOGIN_REALM}
schedule:
frequency: { minutes: 1 }
timeout: { minutes: 1 }
initialDelay: { seconds: 15 }This is where every plugin is enabled. Each entry below is required for the template to work end-to-end with CI/CD visibility.
apiVersion: v1
kind: ConfigMap
metadata:
name: dynamic-plugins-rhdh
namespace: rhdh-operator
data:
dynamic-plugins.yaml: |
includes:
- dynamic-plugins.default.yaml
plugins:
# --- GitHub scaffolder (publish:github, github:webhook) ---
- package: ./dynamic-plugins/dist/backstage-plugin-scaffolder-backend-module-github-dynamic
disabled: false
# --- Kubernetes backend (cluster API access) ---
- package: ./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic
disabled: false
pluginConfig:
kubernetes:
serviceLocatorMethod:
type: multiTenant
clusterLocatorMethods:
- type: config
clusters:
- name: local-cluster
url: https://kubernetes.default.svc
authProvider: serviceAccount
skipTLSVerify: true
serviceAccountToken: ${K8S_SA_TOKEN}
customResources:
- group: 'tekton.dev'
apiVersion: 'v1'
plural: 'pipelineruns'
- group: 'tekton.dev'
apiVersion: 'v1'
plural: 'taskruns'
# --- Kubernetes frontend (Kubernetes tab) ---
- package: ./dynamic-plugins/dist/backstage-plugin-kubernetes
disabled: false
pluginConfig:
dynamicPlugins:
frontend:
backstage.plugin-kubernetes:
mountPoints:
- mountPoint: entity.page.kubernetes/cards
importName: EntityKubernetesContent
config:
layout:
gridColumn: 1 / -1
if:
anyOf:
- hasAnnotation: backstage.io/kubernetes-id
- hasAnnotation: backstage.io/kubernetes-namespace
# --- Tekton CI tab ---
- package: ./dynamic-plugins/dist/backstage-community-plugin-tekton
disabled: false
pluginConfig:
dynamicPlugins:
frontend:
backstage-community.plugin-tekton:
mountPoints:
- mountPoint: entity.page.ci/cards
importName: TektonCI
config:
layout:
gridColumn: 1 / -1
if:
allOf:
- isTektonCIAvailable
# --- ArgoCD scaffolder (argocd:create-resources action) ---
- package: ./dynamic-plugins/dist/roadiehq-scaffolder-backend-argocd-dynamic
disabled: false
pluginConfig:
argocd:
username: ${ARGOCD_USERNAME}
password: ${ARGOCD_PASSWORD}
appLocatorMethods:
- type: config
instances:
- name: openshift-gitops
url: <ARGOCD_SERVER_URL>
token: ${ARGOCD_AUTH_TOKEN}
# --- ArgoCD backend (CD tab data) ---
- package: ./dynamic-plugins/dist/roadiehq-backstage-plugin-argo-cd-backend-dynamic
disabled: false
pluginConfig:
argocd:
username: ${ARGOCD_USERNAME}
password: ${ARGOCD_PASSWORD}
appLocatorMethods:
- type: config
instances:
- name: openshift-gitops
url: <ARGOCD_SERVER_URL>
token: ${ARGOCD_AUTH_TOKEN}
# --- ArgoCD frontend (CD tab UI) ---
- package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:bs_1.45.3__2.4.3
disabled: false
pluginConfig:
dynamicPlugins:
frontend:
backstage-community.plugin-argocd:
translationResources:
- importName: argocdTranslations
module: Alpha
ref: argocdTranslationRef
mountPoints:
- mountPoint: entity.page.overview/cards
importName: ArgocdDeploymentSummary
config:
layout:
gridColumnEnd:
lg: span 8
xs: span 12
if:
allOf:
- isArgocdConfigured
- mountPoint: entity.page.cd/cards
importName: ArgocdDeploymentLifecycle
config:
layout:
gridColumn: 1 / -1
if:
allOf:
- isArgocdConfiguredReplace
<ARGOCD_SERVER_URL>with your ArgoCD route, e.g.https://openshift-gitops-server-openshift-gitops.apps.<cluster-domain>
The ArgoCD instance name
openshift-gitopsmust match theargoInstancevalue intemplate.yamlstepcreateArgoApp.
The Backstage custom resource must reference the ConfigMaps and Secrets above, and use a ServiceAccount that has the mounted token:
apiVersion: rhdh.redhat.com/v1alpha5
kind: Backstage
metadata:
name: developer-hub
namespace: rhdh-operator
spec:
application:
appConfig:
configMaps:
- name: app-config-rhdh
dynamicPluginsConfigMapName: dynamic-plugins-rhdh
extraEnvs:
secrets:
- name: rhdh-secrets
route:
enabled: true
database:
enableLocalDb: trueUpdate these locations:
| File | Field |
|---|---|
template.yaml line 82 |
allowedOwners |
template.yaml line 142 |
webhookUrl (cluster-specific) |
| TriggerTemplate | gitops-repo-url value |
The default namespace is mcp-servers. To change it, update:
template.yamlline 73 (default: mcp-servers)- All Tekton resources (Pipeline, TriggerTemplate, EventListener)
- ArgoCD Application target namespace
If you need to see additional Tekton resources in the Kubernetes tab, add
them to the customResources list in the Kubernetes backend plugin config.
The most common cause is missing customResources in the Kubernetes
backend plugin config. The backend only fetches standard Kubernetes
resources by default. You must explicitly list pipelineruns and
taskruns under customResources.
Also verify:
- PipelineRuns have label
backstage.io/kubernetes-id: <component-name> - Component has annotation
janus-idp.io/tekton: <component-name> - Component has annotation
backstage.io/kubernetes-namespace: mcp-servers
Verify:
- Component has annotation
argocd/app-name: <component-name> - ArgoCD plugin is configured with the correct instance URL and token
- The ArgoCD Application exists in the
openshift-gitopsnamespace
The argocd:create-resources scaffolder action requires the
roadiehq-scaffolder-backend-argocd-dynamic plugin to be enabled. Check
the scaffolder log for available actions:
oc logs deploy/backstage-developer-hub -n rhdh-operator -c backstage-backend \
| grep "actions enabled"Ensure backstage-plugin-scaffolder-backend-module-github-dynamic is set
to disabled: false in the dynamic plugins ConfigMap.