current-time-mcp
by ggalancs
README.md
# current-time — remote MCP server
A small, hardened **remote MCP server** exposing date/time tools over the
**Streamable HTTP** transport, so it can be registered as a custom connector in
Claude.ai or used by any MCP client.
Most time MCP servers are local (stdio) — fine for desktop clients, but web
clients require a *remote* server reachable over HTTPS. This is one, packaged to
be deployed and hardened rather than just run.
- **Endpoint:** `https://<your-domain>/mcp`
- **Auth:** none. See [Exposure](#exposure) before putting it on the internet.
- **Tools:**
- `get_current_time(timezone="UTC")` → date/time for an IANA timezone
- `list_timezones(prefix="")` → available IANA timezone names
The reported time is the **host's clock**, so the host must be NTP-synchronised.
That is the service's real correctness dependency; `preflight.sh` checks it.
## Files
| File | Purpose |
|------|---------|
| `server.py` | The server (Streamable HTTP on `/mcp`, plus `/health` and an icon) |
| `requirements.txt` | Python deps — **pinned**, see [Dependencies](#dependencies) |
| `Dockerfile` | Hardened image (non-root, read-only-friendly, healthcheck) |
| `healthcheck.py` | HTTP `/health` probe used by Docker |
| `docker-compose.yml` | App joined to an existing Nginx Proxy Manager network |
| `npm-advanced.conf` | SSE-friendly + rate-limited snippet for the NPM Proxy Host |
| `npm-http-ratelimit.conf` | Rate-limit zones for NPM's nginx `http` context |
| `docker-compose.caddy.yml` · `Caddyfile` · `Dockerfile.caddy` | Alternative: self-managed TLS with Caddy (rate-limit plugin compiled in) |
| `preflight.sh` | Pre-deploy checks: clock sync, network, config, pinning |
| `smoke_test.py` | End-to-end client test over Streamable HTTP |
| `AUDIT.md` | What was found and fixed while hardening this, with evidence |
## 1. Run it locally
You need Docker. Nothing else — Python and the dependencies live inside the
image.
```bash
git clone https://github.com/ggalancs/current-time-mcp.git
cd current-time-mcp
docker build -t current-time-mcp .
docker run -d --name mcp -p 8000:8000 current-time-mcp
```
Check it answers:
```bash
curl http://127.0.0.1:8000/health
# {"status":"ok","version":"dev","default_timezone":"UTC", ...}
```
Then exercise the actual MCP protocol, which is what a client will do:
```bash
pip install mcp # only needed for the test
python smoke_test.py http://127.0.0.1:8000/mcp
# tools: ['get_current_time', 'list_timezones']
# ...
# ALL CHECKS PASSED
```
Or point the MCP Inspector at it — transport **Streamable HTTP**, URL
`http://127.0.0.1:8000/mcp`:
```bash
npx @modelcontextprotocol/inspector
```
## 2. Put it on the internet
Web clients such as Claude.ai connect **from the vendor's cloud, not from your
machine**, so the server has to be reachable over public HTTPS. A local tunnel
or a private network will not do unless you allowlist the vendor's addresses.
You need a domain whose DNS points at your server, and a reverse proxy that
terminates TLS. This repo ships two tested paths.
### Path A — behind an existing Nginx Proxy Manager
```bash
cp .env.example .env
$EDITOR .env # NPM_NETWORK (see below), APP_VERSION, DEFAULT_TIMEZONE
docker network ls # find NPM's network: npm_default, proxy-network, ...
sh preflight.sh # clock sync, network, config, pinning
docker compose up -d --build
docker compose ps # expect "healthy"
```
The container **publishes no host port**: NPM reaches it by name over the shared
Docker network. Then, once per NPM instance, install the rate-limit zones —
follow the header of [`npm-http-ratelimit.conf`](npm-http-ratelimit.conf). Do
this **before** the next step: `limit_req` referencing zones that do not exist
stops nginx from starting, taking every site it serves with it. Always
`nginx -t` before reloading.
Now create the Proxy Host in NPM:
- **Details** — Domain: your hostname · Scheme `http` · Forward
`current-time-mcp` : `8000` · Block Common Exploits **on** · Websockets
Support **off** (the transport is SSE over HTTP/1.1, not WebSocket)
- **SSL** — request a Let's Encrypt certificate, Force SSL, HTTP/2
- **Advanced** — paste [`npm-advanced.conf`](npm-advanced.conf)
### Path B — self-managed TLS with Caddy
For a host with nothing else on ports 80/443. Set `MCP_DOMAIN` and `ACME_EMAIL`
in `.env`, then:
```bash
docker compose -f docker-compose.caddy.yml up -d --build
```
Caddy obtains the certificate itself. `Dockerfile.caddy` builds Caddy with a
rate-limiting plugin, because the stock image has none.
### Then lock the hostname down
Once the proxy works, set `ALLOWED_HOSTS` to your domain and redeploy:
```bash
echo "ALLOWED_HOSTS=mcp.example.com" >> .env
docker compose up -d
```
Do it in that order. Setting it before the proxy works rejects every request
with `421`, and you will debug the wrong thing.
### Verify from outside
```bash
curl https://mcp.example.com/health
python smoke_test.py https://mcp.example.com/mcp
```
## 3. Register it in Claude.ai
1. **Customize → Connectors** (it is under *Customize*, not *Settings*)
2. **"+" → Add custom connector**
3. URL: `https://mcp.example.com/mcp` — including `/mcp`
4. Leave the OAuth Client ID and Secret **empty**: this server is
unauthenticated
5. Add it, then enable it **per conversation** with the chat's **"+" →
Connectors** toggle — a connector that exists but is not toggled on looks
exactly like a broken server
Ask it something only the tool can answer, such as the current time in Tokyo
*with seconds*. Free accounts are limited to one custom connector; on Team and
Enterprise an Owner adds it for the organisation first.
## Troubleshooting
Everything here was hit for real while deploying this.
**The connector hangs, or tool calls never return.** Your proxy is buffering.
The transport streams over SSE, and a buffering proxy stalls it. Use the
`proxy_buffering off` block in `npm-advanced.conf`. Do **not** add
`chunked_transfer_encoding off` — it is widely copy-pasted into SSE configs and
stops nginx framing a body that has no `Content-Length`.
**Every request returns 421.** `ALLOWED_HOSTS` does not match the `Host` your
proxy forwards. Check for a port suffix; `mcp.example.com:*` accepts any port.
**The container exits immediately on a rebuild.** Check the log for
`ModuleNotFoundError: mcp.server.fastmcp`. An unpinned `mcp` installs 2.x, which
removed that module. See [Dependencies](#dependencies).
**The container reports healthy but every call fails.** An invalid
`DEFAULT_TIMEZONE` now aborts startup instead, so this should be impossible —
if you see it, the timezone database is missing (`tzdata` is in
`requirements.txt` for exactly this reason).
**The connector shows someone else's logo.** Clients that fall back to fetching
`/favicon.ico` inherit the favicon of the registrable domain if the subdomain
has none — this server serves its own at `/favicon.ico` and `/icon.png`, and
declares it in the MCP handshake.
**The time is wrong.** The server reports the *host's* clock. Run
`timedatectl` — `preflight.sh` checks this because nothing else will.
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `DEFAULT_TIMEZONE` | `UTC` | Timezone when the caller passes none. Validated at startup — an invalid value aborts the boot. **UTC on purpose**: a geographic default hands the wrong region's time to everyone else, with nothing to signal it. |
| `ALLOWED_HOSTS` | *(unset)* | Comma-separated `Host` allowlist. **Set this in production** (see below). |
| `ALLOWED_ORIGINS` | *(unset)* | Optional `Origin` allowlist. An absent `Origin` is always allowed. |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | Which upstreams may set `X-Forwarded-For`. Set to `*` only when the app port is unpublished and reachable solely by your proxy. |
| `PUBLIC_URL` | *(unset)* | Advertised as the server's `website_url` in the MCP handshake. |
| `APP_VERSION` | `dev` | Build stamp, reported on `/health`. |
## Exposure
**This server has no authentication.** Anyone who can reach the URL can call the
tools. The tools only read a clock — no secrets, no side effects — but an open
endpoint on the internet still attracts scanners and costs you bandwidth.
Before exposing it publicly, at minimum:
- **Set `ALLOWED_HOSTS`** to your public hostname. The MCP spec requires
`Host`/`Origin` validation, and the SDK only enables it automatically when
bound to loopback — so a containerised server listening on `0.0.0.0` has it
**off** unless you set this. Set it *after* your proxy works, or every request
is rejected with `421`.
- **Enable the rate limiting** shipped here: `npm-http-ratelimit.conf` plus the
`limit_req` lines in `npm-advanced.conf` (nginx), or the `rate_limit` block in
the `Caddyfile` (Caddy — needs `Dockerfile.caddy`, stock Caddy has none).
If you need real access control, put an authenticating proxy in front, or
implement OAuth 2.1 per the MCP specification. Deliberately out of scope here:
this repo is the plain server.
Hardening already in place: non-root (uid 10001), read-only root filesystem, all
Linux capabilities dropped, `no-new-privileges`, CPU/memory limits, no published
host port, and a **stateless transport** so an unauthenticated caller cannot grow
server memory by repeatedly opening sessions.
## Dependencies
`requirements.txt` is **pinned on purpose**. `mcp` 2.x removed
`mcp.server.fastmcp`, so an open version range installs an SDK this server
cannot import and the container dies on boot — that actually happened here, and
`AUDIT.md` documents it. Bump deliberately, then re-run `smoke_test.py`.
Rebuild with `--pull` periodically anyway: it picks up base-image fixes **and** a
fresh `tzdata`, which a clock server needs when DST rules change.
## License
MIT — see [LICENSE](LICENSE).
Use it, change it, deploy it, sell it. Attribution is the only condition. It is
published this way on purpose: the point is that it is worth copying from.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues