Skip to main content
Glama
laurentyew

SQL Account MCP Server

by laurentyew
README.md
# SQL Account MCP Server

A hosted remote [Model Context Protocol](https://modelcontextprotocol.io) server that lets any
MCP-capable AI app (Claude, ChatGPT, Cursor, VS Code, …) read and write a user's
**SQL Account** (Malaysian accounting software) data.

Users install by pasting **one URL** (`https://<mcp>/mcp`) and clicking **Connect** (OAuth login).
No Node, no config files, no pasted secrets in AI app settings.

The server never talks to `api.sql.my` directly. It calls the existing
`sqlaccount-proxy` Edge Function, which owns billing, rate limits, request signing and
validation.

## How it works

```
AI app  ──►  MCP server (/mcp)  ──►  sqlaccount-proxy  ──►  api.sql.my
              resource server          (billing, SigV4,
              + OAuth (Supabase Auth)   rate limit, validation)
```

- **OAuth server:** Supabase Auth (OAuth 2.1 beta). Issues JWT access tokens via PKCE.
- **Resource server:** this repo. Validates JWTs against the project JWKS, requires the
  `client_id` claim (OAuth-issued tokens only), loads the user's stored credentials from
  Supabase Vault, and calls the Proxy.
- **Credentials:** entered once in the portal's "Connect SQL Account" page and stored
  encrypted in Supabase Vault. This is a change from the n8n node's "never stored" promise;
  see [`docs/SECURITY.md`](docs/SECURITY.md).

## Endpoints

| Path | Method | Purpose |
|------|--------|---------|
| `/mcp` | POST (GET/DELETE per transport) | Streamable HTTP MCP endpoint, protected |
| `/.well-known/oauth-protected-resource` | GET | RFC 9728 metadata |
| `/.well-known/oauth-protected-resource/mcp` | GET | Same document (path-inserted form) |
| `/health` | GET | Liveness, no auth |

## Configuration

Copy [`.env.example`](.env.example) to `.env.local` and fill it in. All values are server-only.
The server fails fast at startup if any required variable is missing.

| Name | Purpose |
|------|---------|
| `SUPABASE_URL` | `https://<ref>.supabase.co` |
| `SUPABASE_SERVICE_ROLE_KEY` | Server-only; used only to call the Vault read function |
| `SUPABASE_JWT_ISSUER` | `https://<ref>.supabase.co/auth/v1` |
| `SUPABASE_JWKS_URL` | `https://<ref>.supabase.co/auth/v1/.well-known/jwks.json` |
| `PROXY_URL` | `https://<ref>.supabase.co/functions/v1/sqlaccount` |
| `MCP_PUBLIC_URL` | Canonical URL of this server, no trailing slash |
| `PORTAL_URL` | Portal URL used in user-facing error messages |
| `ENABLE_WRITE_TOOLS` | `true` to expose write tools (default `false`, read-only) |

## Local development

```bash
npm install
cp .env.example .env.local   # fill in
npm run dev                  # http://localhost:3000/mcp
```

```bash
npm run lint
npm run typecheck
npm run test
npm run build
npm run gen                  # regenerate src/lib/registry.generated.ts from operations.json
npm run check:contract       # fail if contract/ drifts from CHECKSUMS.txt
npm run check:gen            # fail if the generated registry drifts
```

## Connecting a client

Paste the server URL; OAuth handles the rest.

- **Claude / claude.ai custom connector:** add a remote MCP server with URL `https://<mcp>/mcp`, click Connect, approve.
- **Cursor** (`.cursor/mcp.json`):
  ```json
  { "mcpServers": { "sqlaccount": { "url": "https://<mcp>/mcp" } } }
  ```
- **VS Code** (`mcp.json`):
  ```json
  { "servers": { "sqlaccount": { "type": "http", "url": "https://<mcp>/mcp" } } }
  ```
- **ChatGPT connector:** add a custom connector with URL `https://<mcp>/mcp`.
- **MCP Inspector:** `npx @modelcontextprotocol/inspector`, transport *Streamable HTTP*, URL `https://<mcp>/mcp`.

## Tools

Tools are generated from [`contract/operations.json`](contract/operations.json) — never hand-written.

- `sqlaccount_list_resources` — discovery: every resource, its ops, `path_param` type, and whether it is verified.
- `sqlaccount_<resource>_read` — `list`, `get`, report `get`. Read-only.
- `sqlaccount_<resource>_write` — `create`, `update`, `delete`. Only when `ENABLE_WRITE_TOOLS=true`; clients should prompt for approval.

Query filters accept the SQL Account wildcards `*` and `~`. Lists paginate at 50 rows per page;
set `fetch_all_pages: true` to page automatically (capped at 500 rows / 10 pages).

## Contract

[`contract/CONTRACT.md`](contract/CONTRACT.md) and [`contract/operations.json`](contract/operations.json)
are copied verbatim from the node/proxy repos and pinned by
[`contract/CHECKSUMS.txt`](contract/CHECKSUMS.txt). CI fails on drift. Changing the contract means
bumping `contract_version` in every repo.

## Security

See [`docs/SECURITY.md`](docs/SECURITY.md). Highlights: JWTs verified against JWKS with an ES256/RS256
allowlist; stored credentials read only through a `SECURITY DEFINER` Vault function with the `sub`
of a verified token; secrets never logged or echoed; the Proxy URL is the only outbound host.

## Deployment notes

- **Host:** Vercel (App Router). Route segment config sets `maxDuration = 60` on `/mcp`, because the Proxy plus SQL Account can take up to 30 s.
- **Sessions:** `mcp-handler` reads `REDIS_URL` (or `KV_URL`) automatically to keep MCP sessions across serverless instances. Without it, sessions are in-memory per instance. Set `REDIS_URL` to an Upstash/Vercel KV database for reliable session continuity.
- **Well-known paths:** `MCP_PUBLIC_URL` must be the canonical public origin (no trailing slash) so the 401 `resource_metadata` URL and the metadata `resource` field match what clients probe. RFC 9728 metadata is served at predictable root paths, which is why this runs on Vercel rather than as a Supabase Edge Function.
- **Redis is optional for tool correctness:** each request re-resolves credentials and the business logic is stateless; Redis only affects transport session continuity.

## License

MIT — see [`LICENSE`](LICENSE).