Deploying a Python MCP Server to SAP BTP Kyma
Share

[[{“value”:”

Python MCP server, running in Kyma, connected to Claude via a custom connector.

Setup

server.py (FastMCP, streamable-HTTP transport), requirements.txt, Dockerfile, three manifests in k8s/: deployment.yaml, service.yaml, apirule.yaml.

from mcp.server.fastmcp import FastMCP

# host="0.0.0.0" -> listen on all interfaces (required inside a container).
# port=8000 -> matches the Service's targetPort.
mcp = FastMCP("kyma-mcp", host="0.0.0.0", port=8000)

# @mcp.tool() registers the function as a callable MCP tool. Function name
# becomes the tool name, the docstring becomes its description to the model.
@mcp.tool()
def echo(text: str) -> str:
    """Return the text you send, unchanged."""
    return text

@mcp.tool()
def server_info() -> dict:
    """Report where this MCP server is running."""
    return {
        # gethostname() inside a pod returns the pod name.
        "hostname": socket.gethostname(),
        # POD_NAMESPACE isn't set automatically - the Deployment injects it
        # via the downward API.
        "pod_namespace": os.environ.get("POD_NAMESPACE", "unknown"),
        "utc_time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }

if __name__ == "__main__":
    # transport="streamable-http" serves MCP over HTTP at /mcp, instead of
    # "stdio" (only works for a local process launched by the client).
    mcp.run(transport="streamable-http")

mcp 2.x renamed FastMCP to MCPServer. requirements.txt pins mcp>=1.2.0,<2.

Image pushed to Docker Hub as neilaspin/kyma-mcp:0.1. Deployment runs the container, Service exposes port 8000, APIRule routes external HTTPS to it with noAuth: true.

First deploy, two failures

kubectl apply -f k8s/ created all three objects. Neither came up healthy.

Pod:

Failed to pull image "neilaspin/kyma-mcp:0.1": no match for platform in manifest

Built with plain docker build on Apple Silicon – arm64 only. Cluster nodes are amd64.

docker buildx build --platform linux/amd64 -t neilaspin/kyma-mcp:0.1 --push .

APIRule:

Validation errors: Attribute '.spec.rules[0]': Pod default/kyma-mcp-... does not have an injected istio sidecar

default namespace didn’t have Istio sidecar injection enabled.

kubectl label namespace default istio-injection=enabled
kubectl rollout restart deployment/kyma-mcp

Labelling the namespace doesn’t inject a sidecar into a pod already running – only at creation, hence the restart.

Both fixed: pod 2/2 Running, APIRule state: "Ready". Confirmed with a real initialize call:

curl -s -X POST "https://kyma-mcp.f558d38.kyma.ondemand.com/mcp" 
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" 
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{...},"serverInfo":{"name":"kyma-mcp","version":"1.30.0"}}}

Adding list_pods

from kubernetes import client, config

@mcp.tool()
def list_pods(namespace: str = "default") -> list:
    """List pod names and their status in the given namespace."""
    # Reads the service-account token/CA cert Kubernetes auto-mounts into
    # every pod - no kubeconfig file needed inside the cluster.
    config.load_incluster_config()
    # CoreV1Api covers core resources (pods, namespaces, services).
    # Deployments live in a separate group, AppsV1Api.
    v1 = client.CoreV1Api()
    pods = v1.list_namespaced_pod(namespace)
    # pods.items is a list of typed pod objects, not plain dicts - pull out
    # just the two fields that matter here.
    return [{"name": p.metadata.name, "status": p.status.phase} for p in pods.items]

load_incluster_config() reads the service account token/CA cert Kubernetes mounts into every pod. Needs its own RBAC – the pod runs as default with no permissions otherwise:

# A fresh identity for the pod to run as, instead of the default one.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: kyma-mcp-sa
  namespace: default
---
# The actual permission grant: read-only (get/list), pods only, this
# namespace only. apiGroups: [""] is the "core" group - same one
# CoreV1Api maps to.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: kyma-mcp-pod-reader
  namespace: default
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]
---
# Connects the identity (ServiceAccount) to the permission (Role).
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: kyma-mcp-pod-reader-binding
  namespace: default
subjects:
  - kind: ServiceAccount
    name: kyma-mcp-sa
    namespace: default
roleRef:
  kind: Role
  name: kyma-mcp-pod-reader
  apiGroup: rbac.authorization.k8s.io

deployment.yaml needs serviceAccountName: kyma-mcp-sa at the pod-spec level. First attempt put it as a list item inside containers: instead – malformed, missing name/image on that entry.

Redeployed, ran the old code anyway

Rebuilt the image with the new code, pushed under the same tag, deleted the pod. It came back running the old code. Pod event log:

Pulled  Container image "neilaspin/kyma-mcp:0.1" already present on machine

Default imagePullPolicy for a non-latest tag is IfNotPresent – the node keeps whatever it already has cached under that tag, regardless of what changed on the registry. Fix:

        - name: kyma-mcp
          image: neilaspin/kyma-mcp:0.1
          imagePullPolicy: Always

Re-applied, deleted the pod again – event log showed Pulling then Successfully pulled this time.

APIRule stuck on a stale status

Pod was healthy, APIRule still showed State: Error, referencing the old deleted pod. api-gateway-controller-manager logs (kyma-system) showed no reconciliation attempts since before the fix landed. An annotation-only touch didn’t trigger a new reconcile – controller-runtime typically only watches .metadata.generation, which annotations don’t bump. Delete and recreate did:

kubectl delete apirule kyma-mcp
kubectl apply -f k8s/apirule.yaml

state: "Ready" within around 20 seconds.

Testing list_pods

Streamable-HTTP needs a session ID from initialize first – a tools/call without it fails with Missing session ID.

curl -s -X POST "https://kyma-mcp.f558d38.kyma.ondemand.com/mcp" 
  -H "mcp-session-id: <id from initialize>" 
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_pods","arguments":{"namespace":"default"}}}'
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{n  "name": "kyma-mcp-...",n  "status": "Running"n}"}],"isError":false}}

One pod, itself – correct, kyma-mcp was the only thing in default.

Replacing list_pods with search_documents

kubectl get pods answers that faster than a chat tool, and the Role only covers default anyway – kyma-system returns Forbidden. Next: wrap an existing RAG search endpoint from another project (btp-agent-poc – question in, HANA full-text search, documents out) as a search_documents tool.

That endpoint is on a different SAP subscription (ap10) from this cluster (personal trial, us10-001). Cross-account, but from a pod it’s just an outbound HTTPS call – no different from calling any public address. Checked the request format directly first:

curl -s -X POST "https://btp-agent-retrieval-srv.cfapps.ap10.hana.ondemand.com/retrieval/search" 
  -H "Content-Type: application/json" -d '{"query":"Blade Runner"}'

POST, JSON body {"query": "..."}, OData-shaped response ({"value": [...]} with title/content per document).

The tool:

import requests

@mcp.tool()
def search_documents(query: str) -> list:
    """Search documents in the btp-agent-poc knowledge base."""
    resp = requests.post(
        "https://btp-agent-retrieval-srv.cfapps.ap10.hana.ondemand.com/retrieval/search",
        json={"query": query},
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()
    return [{"title": d["title"], "content": d["content"]} for d in data.get("value", [])]

One bug on the way in: the function got added at the top of the file, above mcp = FastMCP(...). @McP.tool() needs mcp to exist already, so it crashed on import with NameError before the server started. Moved it down next to the other tools.

Rebuilt (docker buildx build --platform linux/amd64 --push), deleted the pod to force the pull, tested via tools/call:

{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{n  "title": "Philip K. Dick - Major Works", ...}"}, ...],"isError":false}}

Returns real documents. The search itself is loose – a multi-word query gets split into terms and matches any of them fuzzily, so most of the corpus comes back for a broad question. That’s the backing endpoint’s behaviour, not the tool’s.

Connecting it to Claude

The point of an MCP server is a client calling it. Added the endpoint as a custom connector in claude.ai:

Screenshot 2026-09-10 at 08.22.01.png

Connected, all four tools listed (echo, server_info, list_pods, search_documents), each set to “needs approval” before Claude can call it.

Screenshot 2026-09-10 at 08.22.56.png

From then on it’s plain conversation and Claude calls the tool on the cluster-hosted server itself.

list_pods:

Screenshot 2026-09-10 at 08.24.57.png

server_info:

:457342i50BAF2C246757C5A:

search_documents:

Screenshot 2026-09-10 at 08.26.55.png

The content behind search_documents right now is test data: Philip K. Dick, R2D2.

“}]] 

  Read More Technology Blog Posts by Members articles 

#abap

By ali

Leave a Reply