Skip to main content
Glama
amar-tya

ishari-mcp-server

by amar-tya
README.md
# ishari-mcp-server

MCP server exposing read-only ISHARI shalawat content (from Supabase) as
a tool an AI agent can call. Built as a learning scaffold following the
official MCP best-practice guidelines (tool naming, Zod validation,
pagination, annotations, RLS-respecting auth).

## Setup

```bash
npm install
cp .env.example .env   # then fill in SUPABASE_URL and SUPABASE_ANON_KEY
npm run build
```

## Run locally (stdio)

```bash
npm start
```

It will sit waiting for an MCP client to connect over stdin/stdout - that's normal, it's not a web server.

## Test with MCP Inspector

```bash
npx @modelcontextprotocol/inspector node dist/index.js
```

This opens a local UI where you can call `ishari_search_shalawat` directly
and see the raw request/response, without needing a full AI client.

## Connect to Claude Desktop / Claude Code

Add to your MCP client config (e.g. `claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "ishari": {
      "command": "node",
      "args": ["/absolute/path/to/ishari-mcp-server/dist/index.js"],
      "env": {
        "SUPABASE_URL": "https://your-project-ref.supabase.co",
        "SUPABASE_ANON_KEY": "your-anon-public-key"
      }
    }
  }
}
```

Restart the client, then ask it something like "cari sholawat tentang rezeki".

## Run remotely over HTTP (e.g. a homelab box + Tailscale)

stdio only works when the client can spawn the server as a local child
process. To run this on a separate machine (a homelab mini PC reachable
over Tailscale) and keep it running continuously, use the HTTP entry point
instead - same tools, same Supabase logic, just a different transport.

**On the remote machine:**

```bash
git clone <this-repo-url> ishari-mcp && cd ishari-mcp
npm install
cp .env.example .env   # fill in SUPABASE_URL, SUPABASE_ANON_KEY
npm run build

echo "PORT=3000" >> .env
echo "HOST=0.0.0.0" >> .env   # 0.0.0.0 so Tailscale peers (not just localhost) can reach it

# Pick ONE auth mode below (multi-client is recommended if you have more
# than one MCP client connecting - see "Multi-client auth" section).

# Mode A: single shared secret (simplest, one token for everyone)
echo "MCP_API_KEY=$(openssl rand -hex 32)" >> .env

# Mode B: multi-client tokens - see "Multi-client auth" section below
# instead of the line above.

npm run start:http
```

### Multi-client auth (per-client tokens)

If more than one MCP client will connect (e.g. Claude Desktop + Claude
Code + another agent), give each one its own revocable token instead of
sharing one `MCP_API_KEY`.

**1. Run the migration** (creates `mcp_clients`, RLS enabled, deny-by-default -
only the `service_role` key can read/write it):

```sql
create table public.mcp_clients (
  id uuid primary key default gen_random_uuid(),
  client_name text not null,
  token text not null unique,
  is_active boolean not null default true,
  created_at timestamptz not null default now(),
  last_used_at timestamptz
);

alter table public.mcp_clients enable row level security;
```

**2. Add the service-role key to `.env`** (Project Settings > API >
`service_role` - this is a secret, never commit it):

```
SUPABASE_SERVICE_ROLE_KEY=your-service-role-secret-key
```

Setting this env var switches the server into multi-client mode - it
takes priority over `MCP_API_KEY`, and (unlike `MCP_API_KEY`) there is no
"open" fallback: every request must carry a valid token.

**3. Insert a token per client** (run per client you want to connect):

```sql
insert into public.mcp_clients (client_name, token)
values ('my-laptop-claude-code', 'generate-a-random-token-here');
```

Give each client its own row/token. Give it to the client as its
`Authorization: Bearer <token>` value, same as `MCP_API_KEY` was used
before.

**4. Revoke a client** by flipping its row:

```sql
update public.mcp_clients set is_active = false where client_name = 'my-laptop-claude-code';
```

Takes effect on the client's next request - no restart needed.

Visit `http://<tailscale-ip>:3000/mcp` from another device on your tailnet
to confirm it's reachable (a bare GET returns a 405 - that's expected,
it means the server is up and only accepts MCP's POST requests).

**Run it as a systemd service** (so it survives reboots/SSH disconnects) -
create `/etc/systemd/system/ishari-mcp.service`:

```ini
[Unit]
Description=ishari-mcp-server (HTTP)
After=network.target

[Service]
Type=simple
WorkingDirectory=/home/YOUR_USER/ishari-mcp
ExecStart=/usr/bin/node --env-file=.env dist/httpServer.js
Restart=on-failure
User=YOUR_USER

[Install]
WantedBy=multi-user.target
```

Then:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now ishari-mcp
journalctl -u ishari-mcp -f   # tail logs
```

**On the client machine**, point it at the URL instead of a local command.
For Claude Code:

```bash
claude mcp add ishari-remote -s user --transport http \
  http://<tailscale-ip>:3000/mcp \
  --header "Authorization: Bearer <your MCP_API_KEY>"
```

For Claude Desktop (`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "ishari-remote": {
      "url": "http://<tailscale-ip>:3000/mcp",
      "headers": { "Authorization": "Bearer <your MCP_API_KEY>" }
    }
  }
}
```

**Security notes specific to this mode:**

- Without `MCP_API_KEY` or `SUPABASE_SERVICE_ROLE_KEY` set, anyone who can
  reach the port (i.e. anyone on your tailnet, unless restricted by
  Tailscale ACLs) can call every tool. Since tools use the Supabase
  **anon** key (RLS-scoped, same as any anonymous app user could see), the
  blast radius is limited - but `ishari_list_bookmarks` still requires the
  *caller's own* access_token per-request regardless, so no amount of
  API-key bypass exposes another user's bookmarks.
- **Multi-client mode's one deviation from the anon-key-only model:**
  checking a bearer token against `mcp_clients` happens *before* any
  caller identity exists, so it can't go through RLS the way every other
  query in this codebase does. It uses the `service_role` key instead, in
  one dedicated file (`src/services/supabaseAdminClient.ts`) that does
  nothing else - it's never passed to `ishari_*` tool logic, and every
  other Supabase call in the server still goes through the anon key. Treat
  `SUPABASE_SERVICE_ROLE_KEY` with the same care as any credential that
  bypasses RLS project-wide: don't log it, don't commit it, rotate it if
  it ever leaks.
- This is a stateless HTTP server (`sessionIdGenerator: undefined`) - every
  request gets a fresh `McpServer` instance, so there's no session state
  that could leak between different callers.
- Prefer Tailscale ACLs restricting which of your own devices can reach
  this port, on top of the token check - defense in depth.

## Current tools

- `ishari_search_shalawat` - search verse translations by keyword, paginated.
- `ishari_get_chapter(book_id, chapter_number)` - fetch a full chapter with all verses.
- `ishari_list_bookmarks(access_token)` - per-user bookmarks, scoped by the
  caller's own Supabase Auth token via RLS (see `services/supabaseClient.ts`
  for why a fresh client is created per call instead of a shared one).
- `ishari_get_verse_audio(book_id, chapter_number, verse_number?)` - audio
  recitation links for a chapter (or one verse), with reciter name and style.
- `ishari_list_hadi(query?)` - list reciters (qari), optionally filtered by
  name, each with a count of their audio recitations.

## Evaluating tool-use quality

See `evals/EVALS.md` - 10 prompts to run against a real agent to check it
picks the right tool, extracts arguments correctly, and knows when a request
falls outside what these tools can do.

## Next steps (left as exercises)

- Add a way to search by chapter title, not just translation text (see
  eval #5 in `evals/EVALS.md` for why this gap matters).
- `ishari_search_shalawat` currently only searches one `language_code` at a
  time - consider whether searching across all languages by default is
  better UX.

## Security notes

- Uses the Supabase **anon** key, so every query goes through RLS - this
  server can never see more than an anonymous app user legitimately can.
- No secrets are hardcoded; both values come from environment variables.
- Errors from Supabase are surfaced with a hint, not raw internals.