Build & Deploy an MCP Server
Step-by-step guide to build, deploy, and use an MCP server on OpenShift using oc commands and curl.
The server code is pre-built and ready in server/. You read it, build it, deploy it, and use it.
Duration: ~50 minutes
| This is the primary workshop track. If you prefer an AI agent to generate the code and walk you through each step interactively, see the optional agent-guided track (Stages 1–9). |
Before you start
You need:
-
A terminal in your DevSpaces workspace — open one with F1 → type
terminal select→ pickdev-tools -
The pre-built server code in
server/(cloned with the workshop repo)
First, change into the workshop directory. All commands in this guide use relative paths from here:
cd /projects/mcp-dev-workshop
Step 0: Read the code
Before building anything, read the server code. Understanding what an MCP server is and how it works is the foundation for everything that follows.
cat server/server.py
Take 5 minutes to scroll through the file. Here are the key concepts to look for:
What is MCP?
MCP (Model Context Protocol) is an open standard — like USB for AI. It defines a protocol for AI agents to discover and call tools provided by external servers. The protocol uses JSON-RPC 2.0 over HTTP.
Without MCP, every AI integration requires custom code: different APIs, different auth, different data formats. MCP standardizes this — any MCP client (OpenCode, Cursor, Claude Desktop, VS Code) can connect to any MCP server using the same protocol.
How tools are defined
Look at how server.py defines tools:
@server.tool(
name="get_stock_info",
description="Get comprehensive stock information including price, metrics, and company details.",
)
async def get_stock_info(ticker: str) -> str:
Three things happen automatically from this code:
-
The
@server.tool()decorator registers the function as an MCP tool — the server includes it intools/listresponses -
Type hints (
ticker: str) become JSON Schema — the LLM knows it needs to pass a string argument calledticker -
The description is what the LLM reads to decide when to use the tool — it never sees the function body
This is a key MCP design principle: the LLM chooses tools by reading metadata (name + description + parameter schema), not by reading code.
Enums for constrained choices
Notice the FinancialType, HolderType, and RecommendationType enums:
class FinancialType(str, Enum):
income_stmt = "income_stmt"
quarterly_income_stmt = "quarterly_income_stmt"
...
When a parameter uses an Enum type, the MCP SDK generates a JSON Schema enum field. The LLM sees the allowed values and picks one — preventing invalid inputs like "quarterly_magic_sheet".
Transport: Streamable HTTP
At the bottom of server.py:
server.run(transport="streamable-http", host="0.0.0.0", port=8080)
MCP supports multiple transports (stdio, SSE, streamable HTTP). For remote servers on OpenShift, streamable HTTP is the right choice — the server listens on a port for JSON-RPC requests and returns responses as Server-Sent Events (SSE). This is what the MCP Lifecycle Operator connects to during the handshake.
The 9 tools
The server provides 9 tools covering different financial data domains:
| Tool | What it does |
|---|---|
|
Current price, P/E, market cap, 52-week range, company profile |
|
OHLCV data with configurable period and interval |
|
Latest news articles from Yahoo Finance |
|
Dividends and stock splits history |
|
Income statement, balance sheet, cash flow (annual or quarterly) |
|
Institutional holders, insider transactions, major holders |
|
Available option expiration dates |
|
Calls/puts with optional strike filtering and field projection |
|
Analyst recommendations and upgrades/downgrades |
All data comes from Yahoo Finance via the yfinance library. No API key required.
Step 1: Set your variables
# Your username prefix (ask your facilitator — usually user1, user2, etc.)
export USER_PREFIX=user1
# Your namespace (auto-set by DevSpaces)
export USER_NAMESPACE=$DEVWORKSPACE_NAMESPACE
# Set the active project so oc commands target your namespace
oc project $USER_NAMESPACE
echo "Prefix: $USER_PREFIX"
echo "Namespace: $USER_NAMESPACE"
Expected output:
Now using project "user1-devspaces" on server "https://...". Prefix: user1 Namespace: user1-devspaces
Step 2: Build the container image
An OpenShift BuildConfig compiles your code into a container image inside the cluster — no local Docker or Podman needed. The build happens on the cluster using the Dockerfile in server/.
# Create the BuildConfig (one-time setup)
oc new-build --name=${USER_PREFIX}-stock-mcp \
--strategy=docker \
--binary \
--to=${USER_PREFIX}-stock-mcp:latest
# Start the build from the server/ directory
oc start-build ${USER_PREFIX}-stock-mcp \
--from-dir=server/ \
--follow
The --follow flag streams build logs. You should see pip installing mcp[cli] and yfinance, then a "Push successful" message. The image is stored in the cluster’s internal registry.
If the build fails, check:
oc logs bc/${USER_PREFIX}-stock-mcp
Step 3: Get the image digest
The MCPServer CR needs the exact image reference (with sha256 digest), not a mutable :latest tag. Digests are immutable — the operator knows exactly what version it is running.
export IMAGE_REF=$(oc get istag ${USER_PREFIX}-stock-mcp:latest \
-o jsonpath='{.image.dockerImageReference}')
echo "Image: $IMAGE_REF"
Expected output (your digest will differ):
Image: image-registry.openshift-image-registry.svc:5000/user1-devspaces/user1-stock-mcp@sha256:abc123...
Step 4: Deploy the MCPServer CR
This is the most important step in the workshop. It demonstrates the core value proposition of the MCP Lifecycle in Red Hat OpenShift AI 3.5.
The problem: deploying MCP servers manually is hard
Without the MCP Lifecycle, deploying an MCP server on Kubernetes requires creating at least 5 resources manually:
-
A Deployment with security contexts (non-root UID, drop ALL Linux capabilities, read-only root filesystem, seccomp profile)
-
A Service (ClusterIP) to expose the port
-
A NetworkPolicy to restrict which pods can reach your server
-
Liveness/readiness probes to verify the server is healthy
-
EmptyDir volumes for writable directories (
/tmp,/.cache)
You also need to verify the server actually speaks MCP — manually sending an initialize request to check.
The solution: one CR does it all
With Red Hat OpenShift AI 3.5, you create a single MCPServer custom resource. The MCP Lifecycle Operator watches for these CRs and automatically:
-
Creates a security-hardened Deployment — non-root, drop ALL capabilities, read-only filesystem, seccomp RuntimeDefault
-
Creates a Service (ClusterIP on port 8080)
-
Creates a NetworkPolicy for network segmentation
-
Performs an MCP protocol handshake — sends an
initializeJSON-RPC call to verify your server responds correctly before marking it Ready
Read the YAML first
Before applying, read the MCPServer manifest to understand each field:
cat deploy/01-mcpserver.yaml
Key fields explained:
| Field | Purpose |
|---|---|
|
Tells the operator to pull a pre-built image (vs. building from source) |
|
The immutable image digest — the operator knows exactly what it runs |
|
Must match what |
|
The HTTP path for MCP requests — must match |
|
Writable directories. The operator enforces a read-only root filesystem, so yfinance ( |
|
Tells the operator this server holds no session state — it can freely restart or reschedule the pod |
|
CPU and memory requests/limits for the pod |
Apply it
sed "s|USER_PREFIX|$USER_PREFIX|g; s|IMAGE_REF|$IMAGE_REF|g" \
deploy/01-mcpserver.yaml | oc apply -f -
Wait for it to become Ready:
# Watch until READY shows True (Ctrl+C to stop watching)
oc get mcpserver ${USER_PREFIX}-stock-mcp -w
Expected output:
NAME READY AGE user1-stock-mcp True 45s
What happened behind the scenes
When READY becomes True, the operator has:
-
Pulled your container image
-
Created a Deployment with security hardening (check:
oc get deploy ${USER_PREFIX}-stock-mcp -o yaml | grep -A10 securityContext) -
Created a Service (check:
oc get svc ${USER_PREFIX}-stock-mcp) -
Sent an MCP
initializehandshake to port 8080 at/mcpand received a valid response -
Recorded your server’s capabilities (tools, resources, prompts) in the CR status
To see the handshake result:
oc get mcpserver ${USER_PREFIX}-stock-mcp -o jsonpath='{.status.serverInfo}' | python3 -m json.tool
You should see "tools": true — confirming the operator discovered your 9 tools.
If it stays not-ready for more than 2 minutes:
# Check events and conditions
oc describe mcpserver ${USER_PREFIX}-stock-mcp
# Check the server pod logs
oc logs deployment/${USER_PREFIX}-stock-mcp
Common issues:
| Symptom | Fix |
|---|---|
|
Server isn’t responding on port 8080 at |
|
Pod is crashing. Check |
|
Image reference is wrong. Re-run Step 3 to get the correct digest. |
Step 5: Verify your server in the AI Hub MCP Catalog
Your MCP server is now live — and the MCP Lifecycle Operator has automatically imported it into the AI Hub MCP Catalog. You did not need to register it anywhere. The operator detected your server’s tools during the handshake and published them to the catalog. This is the "Discover" layer of the MCP Lifecycle.
Now open the RHOAI dashboard to see it.
5a. Get the dashboard URL
Run this command to get the OpenShift AI dashboard URL:
echo "$(oc whoami --show-console)" | sed 's|console-openshift-console|rhods-dashboard-redhat-ods-applications|'
Copy the URL and open it in a new browser tab.
5b. Log in
Log in with your workshop credentials — the same username and password you used for DevSpaces:
-
Username: your assigned user (e.g.,
user1) -
Password: the password from your facilitator’s credentials card
After login, you land on the OpenShift AI dashboard home page.
5c. Navigate to the MCP servers page
-
In the left sidebar, click AI Hub
-
Click MCP servers
You land on the Catalog tab — this shows pre-built MCP servers from Red Hat, partners, and the community. Browse it if you like, but your deployed server is on a different tab.
5d. Find your deployed server
-
Click the Deployments tab (next to Catalog)
-
If there is a project selector at the top of the page, select your project:
${USER_PREFIX}-devspaces
You should see your ${USER_PREFIX}-stock-mcp server listed with a Ready status and the connection URL.
-
Click on your server name to open its details page
5e. What to explore
-
Tools tab — the operator discovered all 9 tools during the handshake. Click any tool to see its name, description, and input schema. This is the same metadata the LLM reads when deciding which tool to call.
-
Status — shows the server’s health, replica count, and internal address
-
Capabilities — shows what MCP features your server supports (tools, resources, prompts)
| You may also see MCP servers from other participants if you have access to their namespaces. |
Also browse the Catalog tab — it lists pre-built MCP servers from Red Hat, partners, and the community that can be deployed with one click.
What just happened
You did not manually import your server into the catalog. The MCP Lifecycle Operator did it automatically when your MCPServer CR became Ready. This is a key difference from traditional deployments — the operator handles both the infrastructure (Deployment, Service, NetworkPolicy) and the catalog registration in one step.
This confirms Layer 1 (Deploy) and Layer 2 (Discover) of the MCP Lifecycle are working.
Step 6: Get the MCP Gateway hostname
The MCP Gateway is the third layer of the MCP Lifecycle — it provides federated tool discovery and governance across all MCP servers on the cluster.
What is the MCP Gateway?
The MCP Gateway is powered by Red Hat Connectivity Link (RHCL). It acts as a single entry point that:
-
Federates tools from all registered MCP servers into one
tools/listresponse — an AI agent connects to one URL and discovers tools from every server -
Prefixes tool names to prevent collisions — when 30 participants each have
get_stock_info, the gateway namespaces them asuser1_stock_get_stock_info,user2_stock_get_stock_info, etc. -
Provides auth, rate limiting, and observability at the gateway layer — without modifying individual servers
Get the gateway’s external hostname:
export GATEWAY_HOSTNAME=$(oc get gateway mcp-gateway -n mcp-system \
-o jsonpath='{.spec.listeners[0].hostname}')
echo "Gateway: $GATEWAY_HOSTNAME"
Expected output:
Gateway: mcp-gateway.apps.ocp.example.com
Step 7: Create the HTTPRoute
The HTTPRoute tells the gateway how to route traffic to your server. It maps a unique path (/mcp/${USER_PREFIX}-stock) to your MCPServer’s Service.
Read the YAML first
cat deploy/02-httproute.yaml
Key fields:
| Field | Purpose |
|---|---|
|
Attaches this route to the shared |
|
The gateway’s external hostname — all MCP servers share this |
|
Your unique path: |
|
Points to the Service the MCP Lifecycle Operator created for your MCPServer |
Think of it like adding a new route to a shared load balancer — the gateway handles TLS and routing; you just register your path.
Apply it
sed "s|USER_PREFIX|$USER_PREFIX|g; s|GATEWAY_HOSTNAME|$GATEWAY_HOSTNAME|g" \
deploy/02-httproute.yaml | oc apply -f -
Verify:
oc describe httproute ${USER_PREFIX}-stock-mcp | grep -A2 "Conditions:"
Expected: you should see Accepted: True in the conditions. If not, check that the gateway hostname matches and the MCP Gateway is programmed (oc get gateway mcp-gateway -n mcp-system).
Step 8: Register with the MCP Gateway
The HTTPRoute handles traffic routing. The MCPServerRegistration handles tool discovery — it tells the gateway what tools your server provides and adds a prefix to avoid name collisions.
Why prefixes matter
Without prefixes, there is a problem: 30 participants each deploy a server with a tool called get_stock_info. When an AI agent calls tools/list on the gateway, it gets 30 tools with the same name. Which one should it call?
The MCPServerRegistration solves this with the prefix field. Your tool get_stock_info becomes user1_stock_get_stock_info on the gateway. Each participant’s tools are namespaced — no collisions.
Read the YAML first
cat deploy/03-mcpserverregistration.yaml
Key fields:
| Field | Purpose |
|---|---|
|
Your tool namespace. |
|
Points to your HTTPRoute (not directly to the MCPServer). The gateway controller discovers the backend service from the route |
Apply it
sed "s|USER_PREFIX|$USER_PREFIX|g" \
deploy/03-mcpserverregistration.yaml | oc apply -f -
The field is prefix, NOT toolPrefix. Using toolPrefix is silently ignored — your tools won’t be prefixed and may collide.
|
Verify:
oc get mcpserverregistration ${USER_PREFIX}-stock-mcp
The gateway now knows about your tools. It discovers them by connecting to your backend (via the HTTPRoute) and calling tools/list.
Step 9: Test your MCP server with curl
Now the payoff — you will speak the MCP protocol directly to your server. This is exactly what AI agents do behind the scenes. Understanding these three calls demystifies how every MCP integration works.
The MCP protocol in 30 seconds
MCP uses JSON-RPC 2.0 over HTTP. Every interaction follows this pattern:
-
Client → Server: JSON request with
methodandparams -
Server → Client: JSON response with
result(orerror)
There are three key methods:
| Method | What happens |
|---|---|
|
Handshake — client and server exchange capabilities, agree on protocol version, start a session |
|
Discovery — client asks "what tools do you have?" and gets back names, descriptions, and parameter schemas |
|
Invocation — client calls a specific tool with arguments and gets results back |
When you connect OpenCode (or any MCP client) to your server, it calls these three methods automatically. You are about to do it by hand.
First, set the MCP endpoint URL. Your MCPServer’s Service is accessible within the cluster:
export MCP_URL="http://${USER_PREFIX}-stock-mcp:8080/mcp"
echo "MCP endpoint: $MCP_URL"
9a. Initialize (MCP handshake)
Every MCP session starts with an initialize call. The client sends its name and capabilities; the server responds with its name, protocol version, and what it supports (tools, resources, prompts). The server also returns an Mcp-Session-Id header for subsequent calls.
curl -s -D /tmp/mcp-headers.txt -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "manual-test", "version": "1.0"}
}
}'
Expected: a response containing "result" with server info and capabilities. Look for "tools": {"listChanged": false} — that confirms the server supports tool discovery.
Save the session ID for subsequent calls:
export MCP_SESSION=$(grep -i mcp-session-id /tmp/mcp-headers.txt | tr -d '\r' | awk '{print $2}')
echo "Session: $MCP_SESSION"
9b. List tools (tool discovery)
This is the call an AI agent makes to find out what your server can do. The response is the same tool list you saw in the AI Hub.
curl -s -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $MCP_SESSION" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}'
Expected: a "result" object with a "tools" array listing all 9 tools. Each tool has:
-
name— the identifier the LLM uses to call the tool (e.g.,get_stock_info) -
description— natural language that the LLM reads to decide when to use it -
inputSchema— JSON Schema defining required and optional parameters with types
This is the same metadata you defined in server.py with @server.tool() decorators, type hints, and docstrings. The MCP SDK automatically converted your Python code into this schema.
9c. Call a tool (live data)
Now call a tool and get real stock data. This is exactly what an LLM does when it decides to use a tool — it sends a tools/call request with the tool name and arguments.
curl -s -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $MCP_SESSION" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_stock_info",
"arguments": {"ticker": "AAPL"}
}
}'
Expected: live Apple stock data — current price, volume, market cap, 52-week range, company profile. This data came from Yahoo Finance, processed by YOUR server, deployed on OpenShift, accessed via the MCP protocol.
9d. Try more tools
# Get latest news for Tesla
curl -s -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $MCP_SESSION" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_stock_news","arguments":{"ticker":"TSLA"}}}'
# Get analyst recommendations for NVIDIA
curl -s -X POST "$MCP_URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $MCP_SESSION" \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_recommendations","arguments":{"ticker":"NVDA","recommendation_type":"recommendations"}}}'
What just happened
You spoke the MCP protocol directly:
-
Initialize — handshake, establish session, exchange capabilities
-
tools/list — discover available tools (this is what AI agents call to learn your capabilities)
-
tools/call — invoke a tool with arguments and get results (this is what happens when an LLM decides to use a tool)
The same three calls happen behind the scenes when any MCP client (OpenCode, Claude, Cursor, etc.) connects to your server. You just did it by hand — now you understand exactly what the protocol does.
Troubleshooting
| Symptom | Fix |
|---|---|
|
Your MCPServer pod may not be running. Verify: |
|
The server requires both Accept types. Make sure your curl includes |
|
The URL path may be wrong. Verify the MCP_URL is |
|
Your MCPServer pod may not be running. Check: |
|
The MCPServerRegistration may not be ready. Check: |
|
Check your MCPServer pod logs: |
Cleanup
Remove resources in reverse dependency order:
# 1. Gateway registration
oc delete mcpserverregistration ${USER_PREFIX}-stock-mcp
# 2. Gateway route
oc delete httproute ${USER_PREFIX}-stock-mcp
# 3. MCPServer CR (operator auto-deletes Deployment, Service, NetworkPolicy)
oc delete mcpserver ${USER_PREFIX}-stock-mcp
# 4. Build artifacts
oc delete bc/${USER_PREFIX}-stock-mcp
oc delete is/${USER_PREFIX}-stock-mcp
Verify everything is gone:
oc get mcpserver,mcpserverregistration,httproute,bc,is 2>/dev/null | grep stock-mcp
Expected: no output.
What you just did
You completed the full MCP Lifecycle on Red Hat OpenShift AI 3.5:
| Layer | What | RHOAI 3.5 Component |
|---|---|---|
Build |
OpenShift binary build compiled your code into a container image |
BuildConfig + ImageStream |
Deploy |
MCP Lifecycle Operator created a security-hardened server from a single CR, verified it speaks MCP via handshake |
MCPServer CR → Deployment + Service + NetworkPolicy + handshake |
Discover |
AI Hub made your server and its 9 tools browsable in a centralized catalog |
AI Hub → MCP servers → Deployments |
Route & Govern |
MCP Gateway provides federated tool discovery with per-server prefixing via a single governed endpoint |
HTTPRoute + MCPServerRegistration (powered by Red Hat Connectivity Link) |
Test |
You spoke the MCP protocol directly and got live stock data |
|
Key takeaways
-
One CR replaces five resources — the MCPServer CR is all you need. The operator handles Deployment, Service, NetworkPolicy, security hardening, and protocol verification.
-
Security by default — the operator enforces non-root, drop ALL capabilities, read-only filesystem, and seccomp. You did not write any security config.
-
AI Hub is automatic — your server appeared in the catalog the moment the operator marked it Ready. No manual registration.
-
The MCP Gateway federates — 30 participants' servers are discoverable through a single URL, with tool prefixing to avoid collisions. An AI agent connecting to the gateway sees all tools from all servers.
-
MCP is an open standard — the same server you deployed works with OpenCode, Cursor, Claude Desktop, VS Code, or any MCP-compatible client. No vendor lock-in.
All from 3 YAML files, 3 curl commands, and a handful of oc commands.
Next steps
Now that you have built, deployed, and tested an MCP server on OpenShift, here are ways to go further:
-
Build your own MCP server — pick an internal API, database, or system you work with. Use the same
@server.tool()pattern fromserver.pyto expose it as MCP tools. The same 3 YAMLs (MCPServer,HTTPRoute,MCPServerRegistration) will deploy it. -
Connect an AI agent — instead of
curl, point an MCP client (OpenCode, Cursor, Claude Desktop, VS Code) at your server’s URL. The agent calls the sameinitialize→tools/list→tools/callflow you just did by hand. -
Explore the AI Hub catalog — browse pre-built MCP servers from Red Hat, partners, and the community. Many can be deployed with one click.
-
Try the agent-guided track — if you have time, go back to Stage 1 and let AgentSherpa walk you through the agentic SDLC — from intent capture to code generation to deployment.
-
Read the MCP specification — modelcontextprotocol.io/specification has the full protocol reference, including resources, prompts, sampling, and transport details.
-
Review the reference page — Reference has MCPServer CR field details, troubleshooting, and additional examples.