# Claude Code task — minimal "ping" MCP server on sv006 (reachability test) **Audience:** Claude Code, run by user `mkt` on host **sv006**. **Goal:** Stand up a tiny, self-contained Model Context Protocol (MCP) server that exposes a single `ping` tool over the **Streamable HTTP** transport. It is a throwaway reachability probe — it does **not** talk to EspoCRM and needs no secrets. A separate reverse-proxy route will publish it as `https://mcp-crm.creature-go.com/mcp`; you only build the local container. This is intentionally a stand-in. The port and container layout are chosen so the public hostname and proxy route can be reused **unchanged** for the real CRM-MCP server later. ## Fixed parameters (do not change — other components depend on them) | Parameter | Value | |---|---| | Host loopback bind | `127.0.0.1:8095` (reserved "MCP port") | | Container internal port | `8095` | | MCP endpoint path | `/mcp` (Streamable HTTP, POST) | | Health path | `/health` (GET, returns `ok`) | | Container name | `ping_mcp_ctr` | | Image tag | `ping-mcp:1.0.0` | | Runtime | rootless Podman, user `mkt` | The container must publish **only on host loopback** (`127.0.0.1:8095`). The rootless Traefik pod reaches it via the slirp4netns host-loopback address `10.0.2.2:8095` (same mechanism the EspoCRM pod already uses) — do not publish on `0.0.0.0`. ## Step 1 — Create the app directory `~/ping_mcp/` **`~/ping_mcp/package.json`** ```json { "name": "ping-mcp", "version": "1.0.0", "private": true, "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "express": "^4.21.0" } } ``` Use the current published versions if these are outdated; the only requirement is a Streamable-HTTP-capable `@modelcontextprotocol/sdk`. **`~/ping_mcp/server.js`** ```js import express from "express"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const PORT = 8095; const app = express(); app.use(express.json()); // Plain HTTP health check (NOT part of MCP) — for humans / curl. app.get("/health", (_req, res) => res.status(200).send("ok")); function buildServer() { const server = new McpServer({ name: "ping-mcp", version: "1.0.0" }); server.tool( "ping", "Reachability health-check. Returns pong with a timestamp and a server identity string.", {}, async () => ({ content: [{ type: "text", text: `pong @ ${new Date().toISOString()} — ping-mcp on sv006 (nonce ${randomUUID()})`, }], }), ); return server; } // Stateless Streamable HTTP: a fresh server + transport per request. app.post("/mcp", async (req, res) => { try { const server = buildServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (err) { console.error("MCP request error:", err); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }); } } }); // Stateless mode has no server-initiated stream / session teardown. const notAllowed = (_req, res) => res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless)" }, id: null }); app.get("/mcp", notAllowed); app.delete("/mcp", notAllowed); app.listen(PORT, "0.0.0.0", () => console.log(`ping-mcp listening on 0.0.0.0:${PORT} (/mcp, /health)`)); ``` > If the installed SDK's API differs (e.g. `registerTool` instead of `tool`, or a changed `StreamableHTTPServerTransport` constructor), adapt the code. The contract that matters: a working **stateless Streamable HTTP** MCP endpoint at `POST /mcp` exposing exactly one tool named `ping`, plus a `GET /health` returning `ok`. Binding `0.0.0.0` inside the container is correct; the container is only published to host loopback. **`~/ping_mcp/Containerfile`** ```dockerfile FROM docker.io/library/node:22-bookworm-slim WORKDIR /app COPY package.json ./ RUN npm install --omit=dev COPY server.js ./ EXPOSE 8095 USER node CMD ["node", "server.js"] ``` Pin the base image to a concrete patch tag (e.g. `node:22.x.y-bookworm-slim`) consistent with the house pinning discipline used for the EspoCRM/MariaDB images. ## Step 2 — Build and run (rootless Podman, user `mkt`) ```bash cd ~/ping_mcp podman build -t ping-mcp:1.0.0 . podman run -d --name ping_mcp_ctr -p 127.0.0.1:8095:8095 ping-mcp:1.0.0 ``` ## Step 3 — Autostart via user systemd + linger ```bash mkdir -p ~/.config/systemd/user cd ~/.config/systemd/user podman generate systemd --new --name ping_mcp_ctr --files systemctl --user daemon-reload systemctl --user enable --now container-ping_mcp_ctr.service loginctl enable-linger mkt # likely already enabled for the EspoCRM pod; harmless if so ``` ## Step 4 — Local verification (before handing off) ```bash # 1) Health endpoint curl -s http://127.0.0.1:8095/health # expect: ok # 2) MCP initialize handshake (Streamable HTTP requires this Accept header) curl -s -X POST http://127.0.0.1:8095/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' # expect: a JSON-RPC result containing serverInfo {name:"ping-mcp", ...} and capabilities. # 3) tools/list curl -s -X POST http://127.0.0.1:8095/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' # expect: a tools array containing one tool named "ping". ``` The response body may be framed as `text/event-stream` (a `data:` line) rather than plain JSON — that is expected for Streamable HTTP. As long as the JSON-RPC result is present and well-formed, the server is good. ## Definition of done - `curl http://127.0.0.1:8095/health` returns `ok`. - The `initialize` and `tools/list` calls return valid JSON-RPC results, and `tools/list` shows the `ping` tool. - `systemctl --user status container-ping_mcp_ctr.service` is active and enabled. - Report back: confirmation of the above, plus the exact base-image tag you pinned. ## Teardown (after the reachability test concludes) ```bash systemctl --user disable --now container-ping_mcp_ctr.service rm -f ~/.config/systemd/user/container-ping_mcp_ctr.service && systemctl --user daemon-reload podman rm -f ping_mcp_ctr podman rmi ping-mcp:1.0.0 # ~/ping_mcp/ can be kept as a template or removed. ```