Python MCP server, running in Kyma, connected to Claude via a custom connector.
Filesystem Layout:
server.py
requirements.txt
Dockerfile
k8s/
├── deployment.yaml
├── service.yaml
└── apirule.yaml
server.py:
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")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 manifestBuilt 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 sidecardefault namespace didn't have Istio sidecar injection enabled.
kubectl label namespace default istio-injection=enabled
kubectl rollout restart deployment/kyma-mcpLabelling 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.iodeployment.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 machineDefault 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: AlwaysRe-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.yamlstate: "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:
- Settings, Connectors, Add custom connector
- URL:
https://kyma-mcp.f558d38.kyma.ondemand.com/mcp - No authentication (the APIRule is
noAuth: true)
Connected, all four tools listed (echo, server_info, list_pods, search_documents), each set to "needs approval" before Claude can call it.
From then on it's plain conversation and Claude calls the tool on the cluster-hosted server itself.
list_pods:
server_info::457342i50BAF2C246757C5A:
search_documents:
The content behind search_documents right now is test data: Philip K. Dick, R2D2.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.