blog-mcp
by julienbrg
README.md
# blog-mcp
MCP server exposing three tools against the `posts` table on `db.w3hc.org`:
| Tool | Description |
|---|---|
| `posts_list` | List recent posts, optionally filtered by slug prefix |
| `posts_latest` | Fetch the most recent post whose slug matches a prefix |
| `posts_upsert` | Insert or update a post by slug |
No raw SQL, no delete: the surface area is intentionally limited to what's
safe to expose over the internet.
## Architecture
```
Claude --HTTPS--> reverse proxy (TLS) --HTTP, localhost--> this server --> Postgres
```
The server binds to `127.0.0.1` only. It is never directly reachable from the
internet — whatever already terminates TLS on the VPS (nginx, Caddy, ...) is
the sole public entry point. Auth is a single bearer token, checked with a
constant-time comparison. Transport is MCP's stateless Streamable HTTP: every
request builds a fresh in-memory server, handles the call, and tears it down.
## Local development
```bash
pnpm install
cp .env.example .env
```
Fill in `.env`:
- `DATABASE_URL` — already-issued credentials, just append `?sslmode=verify-full`.
Do **not** add `sslrootcert=system`: that's a libpq-only value (works with
`psql`) and makes node-postgres try to read a literal file named `system`.
Plain `sslmode=verify-full` gives correct full chain + hostname verification
under Node, using its bundled CA store.
- `MCP_BEARER_TOKEN` — generate with `openssl rand -hex 32`.
`pnpm start`/`pnpm dev` load `.env` via Node's built-in `--env-file` flag
(Node 20.6+), so no `dotenv` dependency is needed — but `.env` must exist in
the working directory or the process exits immediately with a missing
variable error.
Run it:
```bash
pnpm build && pnpm start
# or, with reload on change:
pnpm dev
```
### Running the tests
```bash
pnpm test
```
Runs the full suite via Node's built-in test runner (`node:test`, through
`tsx --test`). No database connection or network access is required — `pg`'s
`pool.query` is mocked in `test/db.test.ts` and `test/tools.test.ts`.
### Testing with curl
```bash
TOKEN=... # from .env
# Handshake
curl -s -X POST http://127.0.0.1:3939/mcp \
-H "Authorization: Bearer $TOKEN" \
-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-test","version":"1.0"}}}'
# List tools
curl -s -X POST http://127.0.0.1:3939/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# Call a tool
curl -s -X POST http://127.0.0.1:3939/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"posts_list","arguments":{"limit":3}}}'
```
### Testing with MCP Inspector
```bash
npx @modelcontextprotocol/inspector
```
Point it at `http://127.0.0.1:3939/mcp`, transport "Streamable HTTP", and set
the `Authorization` header to `Bearer <your token>` in its auth settings.
## Deployment (Infomaniak Ubuntu VPS)
1. **Ship the code.** On the VPS:
```bash
sudo mkdir -p /opt/blog-mcp
sudo useradd --system --home /opt/blog-mcp --shell /usr/sbin/nologin blog-mcp
```
Copy the repo there (git clone, rsync, or CI), then:
```bash
cd /opt/blog-mcp
pnpm install --prod=false # devDependencies needed for the build step
pnpm build
pnpm prune --prod # drop devDependencies after building
```
2. **Write the real `.env`** at `/opt/blog-mcp/.env` (copy `.env.example`,
fill in the real `DATABASE_URL` and a freshly generated `MCP_BEARER_TOKEN`).
Lock it down:
```bash
sudo chown blog-mcp:blog-mcp /opt/blog-mcp/.env
sudo chmod 600 /opt/blog-mcp/.env
sudo chown -R blog-mcp:blog-mcp /opt/blog-mcp
```
3. **Install the systemd unit:**
```bash
sudo cp deploy/blog-mcp.service /etc/systemd/system/blog-mcp.service
sudo systemctl daemon-reload
sudo systemctl enable --now blog-mcp
sudo systemctl status blog-mcp
```
4. **Reverse proxy.** Example nginx server block terminating TLS and
forwarding to the local port (adjust the domain/cert paths to whatever
already manages TLS on this VPS, e.g. certbot):
```nginx
server {
listen 443 ssl http2;
server_name blog.mcp.w3hc.org;
ssl_certificate /etc/letsencrypt/live/blog.mcp.w3hc.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.mcp.w3hc.org/privkey.pem;
location /mcp {
proxy_pass http://127.0.0.1:3939/mcp;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 300s;
}
}
```
Because `proxy_set_header Host $host;` forwards the public hostname, set
`ALLOWED_HOSTS=blog.mcp.w3hc.org` (plus `localhost,127.0.0.1,[::1]` for local
testing) in `.env` — otherwise the SDK's DNS-rebinding protection will
403 every proxied request. Restart the service after changing `.env`:
```bash
sudo systemctl restart blog-mcp
```
5. **Register the connector with your MCP client.** The server is a
standard MCP endpoint (Streamable HTTP, bearer auth) — it isn't tied to
any one provider. See [Connecting a client](#connecting-a-client) below
for Claude, OpenAI, Mistral, and Qwen.
## Connecting a client
This server speaks plain MCP over Streamable HTTP with a bearer token — no
Claude-specific behavior anywhere in `src/`. Any MCP-compatible client can
call it; the config shape just differs per provider.
### Claude
Claude Desktop, Claude.ai, and Claude Code all support custom connectors:
add one with URL `https://blog.mcp.w3hc.org/mcp` and the bearer token from
`.env`.
### OpenAI
The Responses API has a native `mcp` tool type:
```python
from openai import OpenAI
client = OpenAI()
response = client.responses.create(
model="gpt-5",
input="List the 5 most recent posts",
tools=[{
"type": "mcp",
"server_label": "blog-mcp",
"server_url": "https://blog.mcp.w3hc.org/mcp",
"headers": {"Authorization": "Bearer <MCP_BEARER_TOKEN>"},
"require_approval": "never",
}],
)
```
### Mistral
In Mistral Studio: **Connectors → Add Connector → Custom MCP Connector**,
then set the URL to `https://blog.mcp.w3hc.org/mcp` and add a static header
`Authorization: Bearer <MCP_BEARER_TOKEN>`. Mistral requires the token to
be entered in Studio itself — it doesn't support passing it
programmatically per request.
### Qwen
[Qwen Code](https://qwenlm.github.io/qwen-code-docs/) supports remote MCP
servers over HTTP. In `.qwen/settings.json`:
```json
{
"mcpServers": {
"blog-mcp": {
"httpUrl": "https://blog.mcp.w3hc.org/mcp",
"headers": { "Authorization": "Bearer <MCP_BEARER_TOKEN>" }
}
}
}
```
or via the CLI:
```bash
qwen mcp add --transport http blog-mcp https://blog.mcp.w3hc.org/mcp \
--header "Authorization: Bearer <MCP_BEARER_TOKEN>"
```
### DeepSeek
DeepSeek's API (chat completions and Responses API) has no native remote-MCP
tool as of this writing — its Responses API explicitly ignores the `mcp`
tool type. To use this server from DeepSeek, put an MCP-aware host in
between: an agent framework (e.g. LangChain's MCP adapter) or a coding CLI
that owns the MCP connection while delegating generation to
`deepseek-chat`/`deepseek-reasoner` as the backend model. There's no direct
provider-to-server config to hand you here — check back as DeepSeek's API
evolves.
## Operations
- Logs: `journalctl -u blog-mcp -f`. Each tool call logs one line
(`tool=posts_list result=ok duration=12ms`, or `result=error:<category>`
with a `ref=<id>` for unexpected errors, matching the id the client sees).
Rejected tokens log at most one `401` line per minute.
- Startup check: the server runs `select 1 from posts limit 1` before
listening and exits with code 1 if it fails, logging a one-line reason with
the password redacted, e.g. `Database check failed (auth): password
authentication failed for user "website"`. A bad `.env` therefore shows up
as a failed unit rather than as silently failing tool calls.
- Health: `curl -s 127.0.0.1:3939/health` on the VPS returns
`200 {"db":"ok"}` or `503 {"db":"error","reason":"auth|unreachable|timeout"}`.
It needs no token, and nginx only forwards `/mcp`, so it isn't public. It
goes through the same `ALLOWED_HOSTS` check as `/mcp`, so keep `127.0.0.1`
in that list (or pass `-H "Host: blog.mcp.w3hc.org"`).
- Restart: `sudo systemctl restart blog-mcp`
- Token rotation: generate a new one (`openssl rand -hex 32`), update
`.env`, restart, update the connector config in Claude. No fixed cadence
is enforced; rotate every few months or after any suspected exposure.
- Resource footprint: idle ~40-80 MB resident memory, negligible CPU. Load
is a handful of requests per day, millisecond-scale CPU cost each — far
lighter than the nightly `pg_dumpall`/restic backup jobs already running
on this box.
## Security notes
- Never add a raw-SQL or delete tool. If a new use case needs one, write a
new narrow, purpose-built tool instead of widening an existing one.
- The `website` Postgres role is reused as-is — no elevated grants, same
`pg_hba`/connection-limit restrictions as everything else using it.
- The process never binds to a public interface; only the reverse proxy is
internet-facing.
## License
GPL-3.0
## Contact
**Julien Béranger** ([GitHub](https://github.com/julienbrg))
- Element: [@julienbrg:matrix.org](https://matrix.to/#/@julienbrg:matrix.org)
- Farcaster: [julien-](https://warpcast.com/julien-)
- Telegram: [@julienbrg](https://t.me/julienbrg)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive