Skip to main content
Glama
TahiryMSX

basic-mcp-server

by TahiryMSX
README.md
# ai-news-mcp

A [Model Context Protocol](https://modelcontextprotocol.io) server that answers questions
about an AI news corpus, served over **Streamable HTTP**.

It is the data backend for an AI news agent: the agent asks questions, these tools search
and read `news.articles` in Supabase Postgres.

**Every tool is read-only.** See [Read-only guarantee](#read-only-guarantee).

Stateless: each request gets its own MCP server instance, nothing is kept between
requests, so no session IDs and no sticky routing. The database pool is shared.

## The corpus

`news.articles`, in a Supabase Postgres. As of 2026-08-24:

| | |
| --- | --- |
| Articles | 1083 |
| Date range | 2026-04-01 → 2026-08-24 |
| Language | **French** |
| Distinct categories | 668 |
| Distinct tags | 927 |

Two properties drive the tool design:

- **Categories are free-text and fragmented** — 668 distinct values across 1083 articles,
  with near-duplicates like `Cybersécurité`, `Cybersécurité des agents IA` and
  `Cybersécurité et agents IA`. Exact category matching is close to useless, so
  `search_news` matches categories as a case-insensitive **substring**.
- **`views` is `NULL` for every row.** There is deliberately no "most read" tool; it
  would return noise.

## What it exposes

| Kind | Name | Description |
| --- | --- | --- |
| Tool | `search_news` | Search by free text, category, tags and date range; paged |
| Tool | `get_article` | One article in full, by `id` or `article_url` |
| Tool | `list_categories` | Distinct categories with counts, for discovery |
| Tool | `list_tags` | Distinct `tags` or `key_words` with counts |
| Tool | `news_stats` | Coverage: totals, date range, volume per month, leading themes |
| Resource | `news://overview` | Corpus size, date range and top tags as JSON |
| Prompt | `ai_news_analyst` | Grounds an answer in the corpus, with citations |

`search_news` returns summaries truncated to 320 characters — full summaries fill a
model's context fast. Call `get_article` for the complete text of one article.

## Files

- `index.js` — HTTP(S) host: routing, auth check, TLS, lifecycle
- `mcp-server.js` — the MCP server: tools, resource, prompt
- `db.js` — Postgres pool and the read-only query helper
- `auth.js` — bearer token verification
- `inspect-cli.js` — dev helper; runs the MCP Inspector CLI against this server

## Run

```bash
npm install
cp .env.example .env     # then set DATABASE_URL
npm start
# ai-news-mcp listening on http://0.0.0.0:8080/mcp
```

`DATABASE_URL` is required — the server exits with instructions if it is missing. Use
the Supabase **transaction pooler** string (port 6543): dashboard → Connect →
Transaction pooler.

## Read-only guarantee

The MCP endpoint may be exposed without authentication, so "our SQL only does SELECTs"
is not a strong enough guarantee. Two independent layers:

1. **No tool accepts SQL.** All five run fixed statements; caller input only ever
   arrives as bound parameters, and the one interpolated identifier (`tags` vs
   `key_words` in `list_tags`) is constrained by a Zod enum before it is used.
2. **Postgres refuses writes.** Every query runs inside `BEGIN READ ONLY` with a
   `SET LOCAL statement_timeout` (`db.js`). `SET LOCAL` rather than a session-level
   `SET` because under transaction pooling a session setting would leak to whichever
   client is handed that backend next.

Verified against the live database:

```
INSERT  blocked: cannot execute INSERT in a read-only transaction
UPDATE  blocked: cannot execute UPDATE in a read-only transaction
DELETE  blocked: cannot execute DELETE in a read-only transaction
DDL     blocked: cannot execute CREATE TABLE in a read-only transaction
SELECT  still works: 1083 rows
```

For defence in depth, point `DATABASE_URL` at a dedicated read-only role rather than
the owner:

```sql
CREATE ROLE mcp_reader LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA news TO mcp_reader;
GRANT SELECT ON news.articles TO mcp_reader;
```

Then a bug in this server cannot write even if the transaction guard were removed.

## Authentication

Off unless `MCP_AUTH_TOKEN` is set. **Set it.** Every tool reads your database, so an
open endpoint lets anyone who finds the URL query the corpus and burn your Supabase
quota. The startup banner warns when the token is missing.

```bash
npm run gen-token          # prints a random 32-byte hex token (64 chars)
MCP_AUTH_TOKEN=<token> npm start
```

With a token set, requests need `Authorization: Bearer <token>`. The scheme is
case-insensitive; the token is not. Missing or bad credentials get `401` and a
`WWW-Authenticate` challenge, compared in constant time (both sides SHA-256'd, then
`timingSafeEqual`) so the token can't be recovered by timing the responses.

Tokens shorter than 32 characters trigger a startup warning — a token guarding a
database should not be a memorable word. Use `gen-token`.

`GET /health` never requires a credential, so it works as a platform health check.

Verified behaviour:

| Request | Result |
| --- | --- |
| No `Authorization` header | `401` + `WWW-Authenticate: Bearer` |
| Wrong token | `401` + `error="invalid_token"` |
| Token without the `Bearer ` prefix | `401` |
| Correct prefix of the real token | `401` |
| Correct token | `200` |
| `GET /health`, no credential | `200` |

### Configuration

| Env var | Default | Purpose |
| --- | --- | --- |
| `DATABASE_URL` | — | **Required.** Supabase Postgres connection string |
| `DIRECT_URL` | — | Fallback if `DATABASE_URL` is unset |
| `MCP_AUTH_TOKEN` | — | Unset ⇒ open to everyone; set ⇒ bearer token required |
| `PORT` | `8080` | Listen port |
| `HOST` | `0.0.0.0` | Bind address. Set `127.0.0.1` for local-only |
| `MCP_PATH` | `/mcp` | Endpoint path |
| `PGPOOL_MAX` | `4` | Pool size; keep small, poolers have connection limits |
| `PG_STATEMENT_TIMEOUT_MS` | `8000` | Per-query ceiling |
| `PGSSL_STRICT` | — | `1` verifies the server cert (needs the Supabase CA) |
| `TLS_KEY` / `TLS_CERT` | — | Paths to PEM files; both set ⇒ HTTPS |

## Try it with curl

```bash
curl -X POST http://127.0.0.1:8080/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_news","arguments":{"query":"Nvidia","limit":3}}}'
```

Add `-H "authorization: Bearer $MCP_AUTH_TOKEN"` if you set a token.

## Try it in the Inspector

```bash
npm run inspect:cli -- --method tools/list
npm run inspect:cli -- --method tools/call --tool-name news_stats
npm run inspect:cli -- --method tools/call --tool-name search_news --tool-arg query=Anthropic
npm run inspect:cli -- --method resources/read --uri "news://overview"
```

The web UI (`npm run inspect`) needs Transport `Streamable HTTP` and URL
`http://127.0.0.1:8080/mcp`. If you set a token, fill in **Authentication** →
Header Name `Authorization`, value `Bearer <token>` — otherwise the Inspector sees the
`401`, assumes OAuth, and starts a discovery flow this server doesn't implement.

## Connect it to an agent

```bash
claude mcp add --transport http ai-news https://ai-news-mcp.onrender.com/mcp
```

With a token, append `--header "Authorization: Bearer <token>"`.

The `ai_news_analyst` prompt is the intended entry point for an agent: it tells the
model to search before answering, to search in French regardless of the question's
language, to cite `article_url`, and to say so plainly when the corpus has nothing
rather than falling back on general knowledge.

## Searching French text

Search uses Postgres full-text search with the `french` configuration, which handles
stemming and stop words. It does **not** fold accents, because the `unaccent` extension
is not installed on this database — `energie` will not match `énergie`.

To fix that, run once in the Supabase SQL editor and adjust `FTS` in `mcp-server.js`:

```sql
CREATE EXTENSION IF NOT EXISTS unaccent;
```

At 1083 rows every query is a fast sequential scan, so no index is needed yet. If the
corpus grows past ~50k rows, add a GIN index on the `to_tsvector` expression.

## Deploying

Currently live on Render. See **[DEPLOYMENT.md](DEPLOYMENT.md)** for that service's
settings, redeploy steps and platform quirks.

Set `DATABASE_URL` — and ideally `MCP_AUTH_TOKEN` — in the host's environment settings.
Never commit them; `.gitignore` covers `.env` and `*.pem`.

The server binds `0.0.0.0` and honours an injected `PORT`, which is what container
platforms require. A process bound to `127.0.0.1` is unreachable from outside its
container even though its logs look healthy.

## Adding your own tool

In `mcp-server.js`, and route the query through the read-only helper:

```js
import { query } from "./db.js";

server.registerTool(
  "my_tool",
  {
    title: "My tool",
    description: "What it does — the model reads this to decide when to call it.",
    inputSchema: { term: z.string() },
  },
  async ({ term }) => {
    const rows = await query("SELECT id, title FROM news.articles WHERE title ILIKE $1", [`%${term}%`]);
    return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
  }
);
```

Never build SQL by concatenating caller input, and never add a tool that takes SQL as an
argument — that would hand the corpus's read surface to whoever can reach the endpoint.