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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues