Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions DOCKER-README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# ckan-mcp-server — Docker

These files allow you to run [ckan-mcp-server](https://github.com/ondata/ckan-mcp-server) as a Docker image.

## File Structure

Copy these three files into the root of the repo:

```
ckan-mcp-server/
├── Dockerfile ← multi-stage build (builder + runtime)
├── docker-compose.yml ← orchestration with variables and healthcheck
├── .dockerignore ← excludes node_modules, dist, docs, etc.
└── ... (rest of the repo)
```

## Quick Start

```bash
# 1. Clone the repo (if you haven't already)
git clone https://github.com/ondata/ckan-mcp-server.git
cd ckan-mcp-server

# 2. Copy the Docker files into the repo root
# (Dockerfile, docker-compose.yml, .dockerignore)

# 3. Build and start
docker compose up --build -d
```

The MCP server will be available at `http://localhost:3000/mcp`.

## Manual Build (without Compose)

```bash
# Build the image
docker build -t ckan-mcp-server:latest .

# Start the container
docker run -d \
--name ckan-mcp-server \
-p 3000:3000 \
-e TRANSPORT=http \
-e PORT=3000 \
--restart unless-stopped \
ckan-mcp-server:latest
```

## Environment Variables

| Variable | Default | Description |
|---|---|---|
| `TRANSPORT` | `http` | Mode: `http` (HTTP server) or `stdio` (local pipe) |
| `PORT` | `3000` | Port the server listens on |
| `NODE_ENV` | `production` | Node.js environment |

## Configuring Claude Desktop with the Container

Save ckan-mcp-bridge.js (config your local IP if necessary instead localhost in row 14)

Once the container is running, add the following to `claude_desktop_config.json`:

```json
{
"mcpServers": {
"ckan": {
"command": "node",
"args": ["/usr/local/bin/ckan-mcp-bridge.js"],
"env": {
"MCP_URL": "http://localhost:3000/mcp"
}
}
},
"preferences": {
"coworkWebSearchEnabled": true
}
}
```

## Logs and Monitoring

```bash
# Follow logs in real time
docker compose logs -f
```

## Technical Notes

- **Multi-stage build**: the `builder` stage compiles TypeScript, the `runtime` stage includes only the compiled JS and production `node_modules` → final image ~120–150 MB.
- **Non-root user**: the container runs as the `node` user (UID 1000) for security.
- **Healthcheck**: verifies every 30s that the server responds to MCP JSON-RPC requests.
47 changes: 47 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Copia solo i file di dipendenze prima (layer cache)
COPY package*.json ./
RUN npm ci

# Copia tutto il sorgente e compila
COPY . .
RUN npm run build

# ─── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime

WORKDIR /app

# Installa solo le dipendenze di produzione
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Copia i file compilati dallo stage di build
COPY --from=builder /app/dist ./dist

# Copia eventuali asset statici necessari a runtime (opzionale: il file potrebbe non esistere)
COPY --from=builder /app/src/portals.jso[n] ./src/

# Variabili d'ambiente con valori di default
ENV NODE_ENV=production
ENV TRANSPORT=http
ENV PORT=3000

# Espone la porta HTTP del server MCP
EXPOSE 3000

# Healthcheck: verifica che il server risponda
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:${PORT}/health 2>/dev/null || \
curl -sf -X POST http://localhost:${PORT}/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"ping","id":1}' || exit 1

# Utente non-root per sicurezza
USER node

CMD ["node", "dist/index.js"]
101 changes: 101 additions & 0 deletions ckan-mcp-bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* stdio-bridge.js
* Wrapper che permette a Claude Desktop (stdio) di parlare
* con il container ckan-mcp-server (HTTP/SSE).
*
* Claude Desktop <--stdio--> questo script <--HTTP--> container Docker
*/

const http = require("http");
const https = require("https");

/*CHANGE LOCALHOST WITH IP*/
const MCP_URL = process.env.MCP_URL || "http://localhost:3000/mcp";

const url = new URL(MCP_URL);
const transport = url.protocol === "https:" ? https : http;

function sendToServer(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Content-Length": Buffer.byteLength(data),
},
};

const req = transport.request(options, (res) => {
let raw = "";
res.on("data", (chunk) => (raw += chunk));
res.on("end", () => {
// Gestisce sia risposte JSON pure che SSE (data: {...})
const lines = raw.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("data:")) {
const jsonStr = trimmed.slice(5).trim();
if (jsonStr && jsonStr !== "[DONE]") {
try {
resolve(JSON.parse(jsonStr));
return;
} catch {}
}
} else if (trimmed.startsWith("{")) {
try {
resolve(JSON.parse(trimmed));
return;
} catch {}
}
}
// Fallback: prova a parsare tutto il body
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error("Cannot parse response: " + raw.slice(0, 200)));
}
});
});

req.on("error", reject);
req.write(data);
req.end();
});
}

// Legge messaggi JSON-RPC da stdin (uno per riga)
let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", async (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop(); // l'ultima riga potrebbe essere incompleta

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed);
const response = await sendToServer(message);
process.stdout.write(JSON.stringify(response) + "\n");
} catch (err) {
// Invia un errore JSON-RPC valido in caso di problema
const errResponse = {
jsonrpc: "2.0",
error: { code: -32603, message: err.message },
id: null,
};
process.stdout.write(JSON.stringify(errResponse) + "\n");
}
}
});

process.stdin.on("end", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));
48 changes: 48 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
services:
ckan-mcp-server:
build:
context: .
dockerfile: Dockerfile
image: ckan-mcp-server:latest
container_name: ckan-mcp-server
restart: unless-stopped

ports:
- "3000:3000"

environment:
# Modalità di trasporto: "http" per server HTTP, "stdio" per pipe locale
TRANSPORT: http
PORT: 3000
NODE_ENV: production

# Opzionale: imposta un CKAN portal di default
# CKAN_DEFAULT_SERVER_URL: https://www.dati.gov.it/opendata

# Limiti di risorse (opzionale, regola in base al tuo host)
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"

# Log con rotazione automatica
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

healthcheck:
test:
- CMD
- sh
- -c
- |
curl -sf -X POST http://localhost:$${PORT}/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' > /dev/null
interval: 30s
timeout: 10s
retries: 3
start_period: 20s