Files
bin/phase2-ping-mcp-claude-code.md
2026-06-16 16:33:37 +02:00

6.7 KiB

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

{
  "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

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

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)

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

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)

# 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)

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.