Skip to main content
Glama
README.md
# support-kb-server

A role-based knowledge-base MCP server for a support bot. It serves FAQ answers
out of a local vector store and gates write operations behind an **admin** bearer
token, while a **read_only** token can only ask questions.

Everything runs locally — no API keys, no hosted services:

| Concern      | Choice                                                         |
|--------------|----------------------------------------------------------------|
| MCP server   | `mcp` Python SDK (**FastMCP**), over **streamable HTTP**        |
| Vector store | **ChromaDB**, embedded persistent mode → `./chroma_data`        |
| Embeddings   | **sentence-transformers** `all-MiniLM-L6-v2` (fully local)      |
| Auth         | Two bearer tokens → two roles, in-memory `token → role` mapping |

## Tools & permissions

| Tool                              | admin | read_only | Notes                                        |
|-----------------------------------|:-----:|:---------:|----------------------------------------------|
| `answer_question(query)`          |  ✅   |    ✅     | Retrieves top FAQ match + source snippet     |
| `add_knowledge(question, answer)` |  ✅   |    ❌     | Adds an FAQ entry (audit-logged)             |
| `delete_knowledge(doc_id)`        |  ✅   |    ❌     | Removes an FAQ entry by id (audit-logged)    |
| `list_knowledge()`                |  ✅   |    ❌     | Lists all entries with ids (for delete refs) |

Every tool call reads the caller's `Authorization: Bearer <token>` header, looks
up the role, and returns a clear **"Permission denied"** error if that role may
not call the tool.

## Setup

Requires Python 3.10+.

```bash
pip install -r requirements.txt
```

> The first run downloads the `all-MiniLM-L6-v2` model (~90 MB) from Hugging Face
> and caches it locally. Every run after that is fully offline.

## Run the server

```bash
python server.py
```

On startup the server:

1. Seeds the Chroma collection with 10 sample FAQ entries (first run only).
2. **Prints the two bearer tokens**, clearly labeled, to the console — this is
   where you copy them from:

   ```
   ====================================================================
     support-kb-server - BEARER TOKENS (copy these now)
   ====================================================================
     ADMIN     (full access)     : admin-XXXXXXXXXXXXXXXXXXXX
     READ_ONLY (answer_question) : readonly-XXXXXXXXXXXXXXXXXXXX
   ====================================================================
     Endpoint: http://127.0.0.1:8000/mcp
   ====================================================================
   ```

3. Starts the MCP endpoint at **`http://127.0.0.1:8000/mcp`**.

Fresh random tokens are generated on each startup. To pin them (useful for
scripts/CI), set env vars before launching:

```bash
# PowerShell
$env:KB_ADMIN_TOKEN="admin-my-fixed-token"; $env:KB_READONLY_TOKEN="readonly-my-fixed-token"; python server.py

# bash
KB_ADMIN_TOKEN=admin-my-fixed-token KB_READONLY_TOKEN=readonly-my-fixed-token python server.py
```

Other env vars: `KB_HOST` (default `127.0.0.1`), `KB_PORT` (default `8000`).

The active tokens + endpoint are also written to `.mcp_tokens.json` so the test
script can authenticate without copy-pasting.

## Test it

With the server running in one terminal, run the test harness in another:

```bash
python test_local.py
```

It opens real MCP client sessions over HTTP with each token and verifies the full
matrix — `read_only` is allowed only on `answer_question` and denied on the other
three; `admin` succeeds on all four — printing clear `PASS`/`FAIL` per check:

```
--- read_only role ---
[PASS] read_only can call answer_question
[PASS] read_only is DENIED add_knowledge
[PASS] read_only is DENIED delete_knowledge
[PASS] read_only is DENIED list_knowledge
--- admin role ---
[PASS] admin can call answer_question
[PASS] admin can call add_knowledge
[PASS] admin can call list_knowledge (sees new entry)
[PASS] admin can call delete_knowledge

8/8 checks passed
```

## Audit log

Every **successful** `add_knowledge` / `delete_knowledge` call appends one JSON
line to `audit_log.jsonl`:

```json
{"timestamp": "2026-09-06T16:05:37.458146+00:00", "role": "admin", "tool": "add_knowledge", "arguments": {"question": "...", "answer": "...", "doc_id": "faq-7ee3b2da05e3"}}
```

Denied calls and read-only queries are **not** logged.

## Connect it to Claude Desktop / claude.ai as a custom connector

This server speaks streamable HTTP with bearer auth, so it plugs in as a **custom
connector**.

claude.ai (and Claude Desktop) require an **HTTPS** URL for custom connectors, so a
local `http://127.0.0.1:8000` URL is rejected. Expose the server over HTTPS with a
tunnel such as **ngrok**.

### 1. Start the server so it accepts the tunnel's hostname

The MCP server has built-in DNS-rebinding protection that, by default, only trusts
`localhost`. A tunnel forwards requests under its own public hostname, so tell the
server to accept it via `KB_ALLOWED_HOSTS`. Because every tool is already gated by a
bearer token, `*` (accept any host) is fine here:

```powershell
# PowerShell — pin tokens too so they survive restarts
$env:KB_ALLOWED_HOSTS="*"
$env:KB_ADMIN_TOKEN="admin-supportkb-8f3a2c17d9e4"
$env:KB_READONLY_TOKEN="readonly-supportkb-2b6d40af71c8"
python server.py
```

```bash
# bash
KB_ALLOWED_HOSTS='*' python server.py
```

(You can instead pin exact hosts: `KB_ALLOWED_HOSTS="myname.ngrok-free.app"`.)

### 2. Open the tunnel

```bash
ngrok http 8000
```

ngrok prints a forwarding URL like `https://eb41-49-238-50-150.ngrok-free.app`. Your
MCP endpoint is that URL **with `/mcp` appended**. (A free-tier ngrok subdomain
changes each time you restart ngrok — that's the reason for `KB_ALLOWED_HOSTS="*"`.)

### 3. Add it in Claude → Settings → Connectors → "Add custom connector"

1. Give it a name, e.g. `Support KB`.
2. **URL:** `https://<your-ngrok-subdomain>.ngrok-free.app/mcp`
3. **Authentication:** choose the bearer / access-token option and paste one of the
   tokens the server printed:
   - the **admin** token to enable all four tools, or
   - the **read_only** token to expose only `answer_question`.
4. Save. Claude runs the MCP handshake and lists the tools your token is allowed to
   use. Ask a support question and it will call `answer_question`.

> **Troubleshooting `421 Misdirected Request`:** the server didn't trust the tunnel
> hostname — start it with `KB_ALLOWED_HOSTS="*"` (or the exact ngrok host) as above.
> Keep both the server and `ngrok` processes running the whole time Claude is
> connected.

## Project layout

```
kb_store.py       # Chroma + embeddings + seed data + query/add/delete/list helpers
server.py         # FastMCP server: tools, auth/roles, audit logging, token printing
test_local.py     # End-to-end role/permission test over HTTP
requirements.txt
chroma_data/      # persisted vector store (created on first run)
audit_log.jsonl   # append-only audit trail (created on first admin write)
.mcp_tokens.json  # active tokens+endpoint, written for the test script
```