Skip to main content
Glama
liam-edmunds

enable-nz-mcp

by liam-edmunds
README.md
# enable-nz-mcp

A public, hosted [MCP](https://modelcontextprotocol.io) server that exposes the content of
[enable.co.nz](https://enable.co.nz) as searchable tools, connectable from Copilot Studio,
VS Code Copilot, Claude Desktop, or any MCP client.

This is a personal portfolio project. It is **not affiliated with Enable New Zealand or any
employer**, and reads only publicly available content from a public website.

- Server: `https://enable-nz-mcp.duckdns.org/mcp` (Streamable HTTP)
- Health check: `https://enable-nz-mcp.duckdns.org/health`

---

## Architecture

```
GitHub Actions (weekly cron)
  └─ crawl enable.co.nz → build index.db.new
     └─ rsync over SSH → droplet:/opt/enable-nz-mcp/data/index.db.new
        └─ atomic mv → index.db

DigitalOcean droplet (Ubuntu 24.04, Sydney)
  ├─ Caddy  → TLS termination + reverse proxy on enable-nz-mcp.duckdns.org
  └─ pm2    → node dist/server.js  (port 8081, localhost only)
                └─ opens a fresh SQLite read connection per query
```

The server never caches the index in memory — every tool call opens a fresh readonly
connection against `index.db` on disk. A weekly crawl rebuilds the index and swaps it in
with a single atomic `mv`, so the index refreshes with zero downtime and no restart.

Read-only in every direction: the crawler only reads enable.co.nz, and the MCP tools only
read the local SQLite index. No write operations of any kind.

---

## Tools

### `search_enable_nz`

Full-text search across enable.co.nz.

| Param | Type | Required | Notes |
|---|---|---|---|
| `query` | string | yes | Search terms |
| `limit` | integer | no | Default 5, max 20 |
| `section` | string | no | Restrict to one section, e.g. `services` |

Returns matching pages ranked by relevance, each with `url`, `title`, `section`, and a
highlighted snippet.

### `get_page`

Retrieve one page's full text by URL.

| Param | Type | Required |
|---|---|---|
| `url` | string | yes |

Returns `url`, `title`, `section`, `description`, `content`, and `crawled_at`. Content over
~15,000 characters is truncated, with `truncated: true` in the response.

### `list_sections`

No input. Lists the site's top-level sections with page counts, and the index's
`crawled_at` timestamp — useful for orienting before a search, or checking how fresh the
data is.

---

## Example queries

Once connected, ask your MCP client things like:

- "Search enable.co.nz for wheelchair funding information."
- "What sections does the enable.co.nz index cover?"
- "Get the full content of https://www.enable.co.nz/services and summarise it."

---

## Connecting an MCP client

### Copilot Studio

1. Open your agent → **Tools** → **Add a tool** → **New tool** → **Model Context Protocol**.
2. **Server name**: `enable-nz-mcp` (or anything you like — this is just a label).
3. **Server URL**: `https://enable-nz-mcp.duckdns.org/mcp`
4. **Authentication**: **No authentication** (leave as-is unless the server has `API_KEY`
   set, in which case choose an API key/bearer auth option and supply it).
5. Create → Copilot Studio connects over Streamable HTTP (the only transport it supports
   since it dropped SSE in August 2025) and lists `search_enable_nz`, `get_page`, and
   `list_sections`. Add the tool to your agent and test with a prompt like "search
   enable.co.nz for wheelchair funding".

### VS Code Copilot

1. Open the Command Palette → **MCP: Add Server**.
2. Choose **HTTP** and enter `https://enable-nz-mcp.duckdns.org/mcp`.
3. VS Code will list `search_enable_nz`, `get_page`, and `list_sections` as available tools
   once connected.

### Claude Desktop

1. Settings → **Connectors** → **Add custom connector**.
2. URL: `https://enable-nz-mcp.duckdns.org/mcp`.
3. Claude will negotiate the Streamable HTTP transport automatically.

### Any other MCP client

Point it at `https://enable-nz-mcp.duckdns.org/mcp` using the Streamable HTTP transport. If
`API_KEY` is set on the server, send `Authorization: Bearer <key>`.

---

## Local development

```bash
npm ci
cp .env.example .env        # edit as needed

npm run crawl                # builds data/index.db.new
mv data/index.db.new data/index.db

npm run dev                  # starts the server with tsx, reading .env
```

Test the running server with [MCP Inspector](https://github.com/modelcontextprotocol/inspector):

```bash
npx @modelcontextprotocol/inspector
# connect to http://127.0.0.1:8081/mcp
```

Or smoke-test by hand:

```bash
curl http://127.0.0.1:8081/health

curl -X POST http://127.0.0.1:8081/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Run the test suite:

```bash
npm test
```

---

## Deployment

The server runs on an existing DigitalOcean droplet under its own unprivileged system user
(`mcpsvc`), isolated from other services on the same box — `mcpsvc` has no sudo and no read
access outside `/opt/enable-nz-mcp`. See `deploy/ecosystem.config.cjs` for the pm2 process
config and `deploy/Caddyfile.example` for the reverse-proxy block.

Both pm2 and Caddy config changes need root, so `mcpsvc` can go no further than `pm2 save`
on its own — an administrator has to run `pm2 startup` (to persist the process across
reboots) and install/reload the Caddy site block. Rate limiting is not currently applied at
the Caddy layer: it requires the third-party `caddy-ratelimit` plugin, which isn't in this
box's Caddy build.

The `.github/workflows/refresh-index.yml` workflow re-crawls the site weekly (Sunday 02:00
UTC, or manually via `workflow_dispatch`), gates on a minimum page count/size, and deploys
the new index via `rsync` + an atomic `mv` over SSH. It needs three repository secrets:
`DEPLOY_SSH_KEY`, `DEPLOY_HOST`, `DEPLOY_USER` — using a dedicated deploy keypair restricted
via a `command=` directive in `authorized_keys` so it can only run the rsync/mv, not an
arbitrary shell.

---

## Out of scope for v1

- Embeddings / semantic search (keyword search via SQLite FTS5 is sufficient for one
  marketing site)
- Headless-browser rendering
- Multi-site support
- Write operations of any kind
- User-level auth or per-tenant isolation