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 → pick dev-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:

  1. The @server.tool() decorator registers the function as an MCP tool — the server includes it in tools/list responses

  2. Type hints (ticker: str) become JSON Schema — the LLM knows it needs to pass a string argument called ticker

  3. 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

get_stock_info

Current price, P/E, market cap, 52-week range, company profile

get_historical_stock_prices

OHLCV data with configurable period and interval

get_stock_news

Latest news articles from Yahoo Finance

get_stock_actions

Dividends and stock splits history

get_financial_statement

Income statement, balance sheet, cash flow (annual or quarterly)

get_holder_info

Institutional holders, insider transactions, major holders

get_option_expiration_dates

Available option expiration dates

get_option_chain

Calls/puts with optional strike filtering and field projection

get_recommendations

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 initialize JSON-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

spec.source.type: ContainerImage

Tells the operator to pull a pre-built image (vs. building from source)

spec.source.containerImage.ref

The immutable image digest — the operator knows exactly what it runs

spec.config.port: 8080

Must match what server.py listens on — the operator connects here during the handshake

spec.config.path: /mcp

The HTTP path for MCP requests — must match server.py

spec.config.storage

Writable directories. The operator enforces a read-only root filesystem, so yfinance (/.cache) and Python (/tmp) need EmptyDir mounts. Without them, the pod crashes with Read-only file system

spec.mcp.stateless: true

Tells the operator this server holds no session state — it can freely restart or reschedule the pod

spec.runtime.resources

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:

  1. Pulled your container image

  2. Created a Deployment with security hardening (check: oc get deploy ${USER_PREFIX}-stock-mcp -o yaml | grep -A10 securityContext)

  3. Created a Service (check: oc get svc ${USER_PREFIX}-stock-mcp)

  4. Sent an MCP initialize handshake to port 8080 at /mcp and received a valid response

  5. 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

HandshakeFailed

Server isn’t responding on port 8080 at /mcp. Check that server.py uses host="0.0.0.0" and port=8080.

DeploymentUnavailable

Pod is crashing. Check oc logs deployment/${USER_PREFIX}-stock-mcp for Python import errors.

ImagePullBackOff

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

  1. In the left sidebar, click AI Hub

  2. 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

  1. Click the Deployments tab (next to Catalog)

  2. 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.

  1. 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/list response — 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 as user1_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

spec.parentRefs

Attaches this route to the shared mcp-gateway in mcp-system

spec.hostnames

The gateway’s external hostname — all MCP servers share this

spec.rules[].matches[].path.value

Your unique path: /mcp/${USER_PREFIX}-stock. Each participant gets their own

spec.rules[].backendRefs

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

spec.prefix

Your tool namespace. user1_stock means get_stock_info becomes user1_stock_get_stock_info on the gateway

spec.targetRef

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:

  1. Client → Server: JSON request with method and params

  2. Server → Client: JSON response with result (or error)

There are three key methods:

Method What happens

initialize

Handshake — client and server exchange capabilities, agree on protocol version, start a session

tools/list

Discovery — client asks "what tools do you have?" and gets back names, descriptions, and parameter schemas

tools/call

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:

  1. Initialize — handshake, establish session, exchange capabilities

  2. tools/list — discover available tools (this is what AI agents call to learn your capabilities)

  3. 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

Connection refused or timeout

Your MCPServer pod may not be running. Verify: oc get mcpserver ${USER_PREFIX}-stock-mcp shows READY=True. Check: oc get pods -l mcp-server=${USER_PREFIX}-stock-mcp.

Not Acceptable error

The server requires both Accept types. Make sure your curl includes -H "Accept: application/json, text/event-stream".

404 Not Found

The URL path may be wrong. Verify the MCP_URL is http://${USER_PREFIX}-stock-mcp:8080/mcp.

initialize returns an error

Your MCPServer pod may not be running. Check: oc get mcpserver ${USER_PREFIX}-stock-mcp shows READY=True. Check logs: oc logs deployment/${USER_PREFIX}-stock-mcp.

tools/list returns empty

The MCPServerRegistration may not be ready. Check: oc get mcpserverregistration ${USER_PREFIX}-stock-mcp.

tools/call returns an error

Check your MCPServer pod logs: oc logs deployment/${USER_PREFIX}-stock-mcp. Yahoo Finance may have rate-limited the request — wait a moment and try again.

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

initialize → tools/list → tools/call

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 from server.py to 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 same initialize → tools/list → tools/call flow 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.