166 lines
6.7 KiB
Markdown
166 lines
6.7 KiB
Markdown
# 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.
|
|
```
|