A TypeScript MCP server that writes index cards to Supabase, deployed on Google Cloud Run. One service exposes the same seven tools twice — over the Model Context Protocol for MCP clients, and over REST for the ChatGPT Apps SDK.
- About
- Features
- Tech Stack
- Architecture
- Project Structure
- Getting Started
- Configuration
- Card Shape
- Deployment
- Security
- How to Contribute
- What's Next
- License
- Acknowledgements
- Author
An index card is one durable fact — a decision, a constraint, a thing learned — small enough to write down and specific enough to find again. This server is the write path for that corpus.
The problem it solves: an assistant that can only read your notes forgets everything it figures out with you. Giving a model a general-purpose database connection fixes that and introduces a much worse problem. So the surface here is deliberately narrow — seven tools, a validated card shape, revision history on every write, and no ability to drop a table.
Built for a single author's knowledge base, but nothing in it is personal to that use case. If you want an assistant that accumulates rather than resets, this is the shape of it.
Cards are never hard-deleted. Deleting one sets a deleted_at timestamp and every read path
quietly skips it, but the row and its full history stay in the database — you can always go
looking. Every write also appends to card_revisions, so the corpus accumulates an audit
trail rather than a series of overwrites.
One caveat worth knowing: the card upsert and its revision insert are separate statements,
not one transaction. If the upsert lands and the revision insert fails, the card still counts
as written and the response carries a Revision for "<title>" error alongside it. Rare, but
it means a row can move without a matching revision — check error_details rather than
assuming a non-zero written implies clean history.
That history is grouped, too. A write_cards call opens a generation_runs batch before it
touches anything, which means a run that dies partway through still leaves a record of
exactly what landed and what didn't.
Batch writes are the easy place to hide problems. This one doesn't: a response reports how
many cards were written, how many produced errors, and a per-card error_details list
naming each failure. Nineteen good cards and one bad one gets you nineteen cards and a
complaint — not a silent twenty-success.
Six of the seven tools are exposed twice — over MCP streamable HTTP for MCP clients, and over
REST at /api/* for Actions-style integrations. Those six call the identical handler
functions on both paths, so they cannot drift apart as the project changes, and both paths sit
behind the same Supabase OAuth 2.1 gate, discoverable through standard .well-known metadata.
health is the exception, and the difference matters. The MCP health tool actually probes
Supabase connectivity and is behind auth. The public GET /health and GET /status routes
are liveness checks for uptime monitors — they return ok unconditionally and never touch the
database. A green /health says the process is up, not that it can reach Postgres.
Zod schemas validate every card before it reaches Postgres, so malformed input never touches
the database. Dedicated lookup tools for categories, projects, and tags let an assistant
orient itself before writing instead of inventing a taxonomy. And the tools/list output is
sanitized on the way out — ChatGPT rejects descriptors containing $schema or default, so
those keywords are stripped.
| Tool | Description |
|---|---|
health |
Check server status and Supabase connectivity |
write_cards |
Validate and upsert index cards with revision history |
lookup_card_by_id |
Find specific index cards by UUID list (excludes soft-deleted) |
lookup_categories |
Get all unique categories used across active cards |
lookup_projects |
Get all unique project identifiers used across active cards |
lookup_tags |
Get all unique lvl0/lvl1 tags used across active cards |
search_cards |
Keyword search by category/tag/project/fact (excludes soft-deleted) |
Both batch endpoints cap at 50 per call — 50 cards for write_cards, 50 IDs for
lookup_card_by_id. Go over and the schema rejects the request rather than silently
truncating it.
| Layer | Tools |
|---|---|
| Runtime | Node.js 24+, TypeScript 5.7, Express 5, ESM only |
| MCP & AI | @modelcontextprotocol/sdk, OpenAI SDK |
| Data | Supabase (@supabase/supabase-js), Postgres with RLS, Zod validation |
| Logging | Pino |
| Testing | Vitest, @vitest/coverage-v8 |
| Quality Gates | ESLint, Prettier, Lefthook, Commitlint + commitlint-plugin-rai, secretlint |
| Infra & CI/CD | Google Cloud Run, GitHub Actions, Release Please, SonarCloud |
One Express app serves three audiences from overlapping routes. The interesting part is the
root route: / is simultaneously a public HTML help page and the authenticated MCP endpoint,
resolved by content negotiation rather than by path.
flowchart TB
accTitle: Supascribe Notes MCP request routing
accDescr: Browsers, MCP clients, and ChatGPT Apps enter one Express app. The root route splits by Accept header into a public help page or the authenticated MCP transport. REST API routes and MCP tools share the same handler layer, which talks to Supabase Postgres.
Browser["Browser<br/>(human)"]
MCPClient["MCP client<br/>(Claude, ChatGPT)"]
Apps["ChatGPT Apps SDK<br/>(REST actions)"]
subgraph App["Express app (Cloud Run)"]
direction TB
Root{"GET / <br/>Accept negotiation"}
Help["Help page<br/>(public HTML)"]
Public["Public routes<br/>/status /health<br/>/openapi.json<br/>/.well-known/*"]
Auth["Auth middleware<br/>Supabase JWT verify"]
Transport["Streamable HTTP<br/>MCP transport<br/>(session map)"]
Rest["REST routes<br/>/api/*"]
Handlers["Shared tool handlers<br/>health · write_cards<br/>lookup_* · search_cards"]
Zod["Zod card schemas"]
end
DB[("Supabase Postgres<br/>cards · card_revisions<br/>generation_runs")]
SupaAuth["Supabase Auth<br/>OAuth 2.1 + PKCE"]
Browser --> Root
Browser --> Public
MCPClient --> Root
Apps --> Rest
Root -->|"text/html"| Help
Root -->|"text/event-stream<br/>or POST/DELETE"| Auth
Rest --> Auth
Auth -->|"401 + WWW-Authenticate"| SupaAuth
Auth --> Transport
Transport --> Handlers
Auth --> Handlers
Handlers --> Zod
Zod --> DB
write_cards opens a generation run before touching any card, so a batch that dies halfway
still leaves an auditable record of what landed.
sequenceDiagram
accTitle: write_cards batch execution and revision history
accDescr: A client calls write_cards. The server opens a generation run, then for each card checks for an existing row, upserts it, and appends a revision. The run is finalized as success or partial, and per-card errors are returned rather than swallowed.
autonumber
participant C as MCP client
participant S as Express + MCP server
participant Z as Zod schema
participant DB as Supabase Postgres
C->>S: write_cards { cards[1..50] }
S->>Z: validate payload
Z-->>S: parsed cards (or 400)
S->>DB: INSERT generation_runs (status partial)
DB-->>S: run_id
loop each card
S->>DB: SELECT objectID FROM cards
DB-->>S: existing? (created vs updated)
S->>DB: UPSERT cards ON CONFLICT objectID
S->>DB: INSERT card_revisions (run_id)
DB-->>S: ok / error captured per card
end
S->>DB: UPDATE generation_runs (success | partial)
S-->>C: { run_id, written, errors, results[] }
Almost everything interesting is in server.ts — it builds both the Express app and the MCP
server and wires every route. If you're looking for where something happens, start there and
follow it out to tools/, which holds the actual work.
src/
index.ts # process entrypoint: load config, start server, wire shutdown
config.ts # env parsing and required-var enforcement
server.ts # Express app + MCP server construction, all route wiring
middleware/
auth.ts # Supabase JWT verification, 401 + WWW-Authenticate challenge
request-logger.ts # per-request Pino child logger
tools/
health.ts # connectivity probe
write-cards.ts # batch upsert, revision history, generation runs
lookup-tools.ts # by-id, categories, projects, tags, search
schemas/
card.ts # Zod card shape, batch limits, search filters
lib/
auth-provider.ts # SupabaseTokenVerifier
openapi.ts # OpenAPI spec generation for the Apps SDK surface
supabase.ts # service-role client factory
logger.ts # Pino instance
shutdown.ts # graceful shutdown handlers
views/
auth-view.ts # OAuth consent page
help-view.ts # public landing page served at /
supabase/migrations/ # ordered SQL migrations (see supabase/AGENTS.md)
tests/ # unit + integration suites (see tests/AGENTS.md)
docs/ # troubleshooting and reference material
You need a Supabase project of your own before any of this works — the server is a write path into a database, and there's no bundled one to fall back on. Everything else is optional until you deploy.
- Node.js 24+
- A Supabase project with the migrations in
supabase/migrationsapplied - Docker, if you want to run it containerized
- Google Cloud CLI (
gcloud), only if you're deploying to Cloud Run
git clone git@github.com:anchildress1/supascribe-notes-mcp.git
cd supascribe-notes-mcp
# Installs dependencies and registers Lefthook git hooks
make install
# Copy the example env and fill in your Supabase credentials
cp .env.example .envmake dev # hot-reload dev server on PORT (default 8080)
make test # full Vitest suite
make test-coverage # suite + coverage report
make lint # ESLint
make format # Prettier write
make ai-checks # everything CI runs, in CI orderVerify locally:
curl http://localhost:8080/statusThree things live outside the make targets and are easy to mistake for leftovers. They
aren't — but nothing referenced them until now, which is why they looked like strays.
verify-mcp.sh <token>— walks the full Streamable HTTP MCP handshake against a deployed service with curl. Reach for it when a client says the server is broken and you want to see the raw protocol exchange. It prints instructions for grabbing a Supabase token out of your browser session.test-remote.mjs— the same job from the other end: a real MCP client built on the SDK that connects, lists tools, and exercises them against the deployed service. Heavier than the shell script, and the one to use when you care whether a tool works rather than whether the transport works.algolia/conversion.js— not part of the server at all. It's the transform function you paste into an Algolia ingestion pipeline to index cards from this database; it drops soft-deleted rows and normalizes tags. Nothing in the repo runs it, and nothing should.conversion-input.sample.jsonis a sample record to test it against.
Apply the migrations in supabase/migrations using the Supabase CLI or the SQL editor.
Keep RLS enabled and re-run the Supabase linter if you touch policies — see
supabase/AGENTS.md for migration conventions.
| Variable | Required | Default | Description |
|---|---|---|---|
SUPABASE_URL |
✅ | — | Supabase project URL |
SUPABASE_SERVICE_ROLE_KEY |
✅ | — | Service role key used for all server-side data access |
SUPABASE_ANON_KEY |
✅ | — | Anon key, injected into the browser-facing consent page |
PORT |
❌ | 8080 |
HTTP listen port (0–65535) |
PUBLIC_URL |
❌ | http://localhost:PORT |
Public origin for OAuth metadata and MCP transport endpoints |
SERVER_VERSION |
❌ | 1.0.0 |
Version hint for MCP/OpenAPI client cache busting |
CORS_ORIGINS |
❌ | — | Comma-separated extra allowed origins; PUBLIC_URL is always one |
SUPABASE_ACCESS_TOKEN |
❌ | — | Supabase CLI/MCP token — only needed to apply remote migrations |
PUBLIC_URLis optional but effectively required in any deployed environment: it seeds the OAuth discovery documents and the CORS allowlist. Leave it unset in production and clients will be told to authenticate againstlocalhost.
When adding or changing tools, bump SERVER_VERSION before deploying so ChatGPT refreshes its
cached tool metadata.
All MCP operations and /api/* routes require a Supabase-issued Bearer token. MCP clients
discover the OAuth configuration automatically via:
/.well-known/oauth-authorization-server/.well-known/oauth-protected-resource
The MCP initialize response returns an Mcp-Session-Id header. Send that header on every
subsequent MCP request in the session.
These are the REST/Actions path, not the Apps SDK surface. A ChatGPT App discovers tools
through MCP tools/list over streaming HTTP — the endpoint above. Point an integration at
these routes and you get a working API with no composer or tool UI.
The OpenAPI surface at /openapi.json exposes:
POST /api/write-cardsPOST /api/lookup-card-by-id— expects{ "ids": ["<uuid>", "<uuid>"] }GET /api/lookup-categoriesGET /api/lookup-projectsGET /api/lookup-tagsPOST /api/search-cards
{
"objectID": "uuid (auto-generated when omitted)",
"title": "string (required)",
"blurb": "string (required)",
"fact": "string (required)",
"url": "string (optional, must be a valid URL)",
"tags": { "lvl0": ["string"], "lvl1": ["string"] },
"projects": ["string"],
"category": "string (required)",
"signal": "number 1–10 (required)",
"created_at": "timestamptz (optional input for historical imports; normalized on write)",
"updated_at": "timestamptz (auto)",
"deleted_at": "timestamptz (optional soft-delete timestamp; omit to leave deletion status unchanged)"
}Both tags.lvl0 and tags.lvl1 are required arrays — pass [] rather than omitting them.
# Build and run locally in Docker
docker build -t supascribe-notes-mcp .
docker run -p 8080:8080 --env-file .env supascribe-notes-mcp
# Deploy to Cloud Run — set your own project first
gcloud config set project <YOUR_GCP_PROJECT_ID>
bash deploy.shVerify a deployment:
SERVICE_URL="https://your-service-url"
# Public health check
curl "$SERVICE_URL/status"
# Authenticated MCP initialize — replace YOUR_TOKEN with a valid Supabase JWT
curl -i -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}' \
"$SERVICE_URL"Tools not showing up in ChatGPT? See
docs/troubleshoot-tools.md.
The threat model here is simple: this thing has write access to a personal knowledge base and is driven by a language model. Both halves of that sentence are reasons to be careful, so the following are load-bearing rather than decorative. If you're changing any of them, that's the part of a PR that gets read closely.
- OAuth-gated for MCP + API calls. MCP protocol requests (to
/when negotiated as MCP) and/api/*endpoints require a Supabase-issued Bearer token; missing or invalid tokens return a 401 plus aWWW-Authenticatechallenge. MCP always gets a plain-text 401 rather than the browser-friendly HTML page, so clients can discover the OAuth flow. Browser-facing routes (help page,/auth/authorize,/health,/status,/openapi.json, and/.well-known/*) are intentionally public. - CORS is an allowlist, not a wildcard. Only
PUBLIC_URLand any origins named inCORS_ORIGINSare accepted; malformed origins are logged and dropped. - Input handled like it's hostile.
authorization_idis regex-constrained to a safe token shape and URL-encoded before it is ever interpolated into a redirect. Config values injected into the consent page's inline script are escaped against</script>breakout. - RLS enforced at the database. Row-Level Security stays on for every table. Read
supabase/AGENTS.mdbefore touching policies. - No secrets in the repo.
secretlintruns in CI and in Lefthook's pre-commit hook, before a commit can land. - No hard deletes. Cards carry
deleted_at; the write path can soft-delete but nothing in the tool surface can destroy a row or a revision.
Branch off main and open a PR — nothing lands on main directly. Before you push, run
make ai-checks; it runs exactly what CI runs, so if it passes locally you've already cleared
the gate, including the 85% business-logic coverage floor.
Two things about commits will bite you if nobody warns you first. They follow
Conventional Commits, which is unremarkable, and they must
also carry an attribution footer — Generated-by: or Assisted-by: naming the model, or
Authored-by: naming yourself if you wrote it unaided. Every commit needs one; there is no
omit-it case. That second rule is enforced by
commitlint-plugin-rai in a Lefthook hook, so a missing footer fails at commit time rather
than in review. Commits are signed too (git commit -S).
If you're touching tests, migrations, or documentation, read the AGENTS.md in that directory
first. They're written for AI agents rather than for you, so they're blunt and skimmable —
but they're also where the non-obvious rules live, like why views must re-declare their
security options on every CREATE OR REPLACE.
Three workflows run on this repo: CI (lint, test with coverage, secrets scan, build), Release Please (conventional-commit driven versioning), and RAI Attribution, which scores the attribution footers across history and rewrites the badge at the top of this file.
The obvious gap is the embedded UI. Tools already declare _meta.ui.visibility, which is half
of what an Apps SDK app needs, but none of them declare a _meta.ui.resourceUri — so cards
come back as text rather than as anything you can look at. A rendered card surface in the
ChatGPT composer is the next real feature.
Less exciting but genuinely annoying: the migrations directory carries two naming conventions
at once. The early files use NNN_description.sql and everything since uses a timestamp
prefix, which makes ordering ambiguous at a glance. Worth normalizing before the list gets
longer.
PolyForm Shield 1.0.0, with Additional Terms.
Translated from lawyer: take it, fork it, run it, learn from it, build your own thing on top of it. Use it at work, use it for client projects, use it to teach. All fine, all encouraged.
Two things it forbids. You may not build something that competes with this software or with anything I build using it — that one is Shield's. And you may not make money off it: no selling, no reselling, no relicensing, no running it as a service for other people whether you charge for that or not — that one is mine, and it is the reason this is not plain Shield.
The distinction that trips people up: using it while getting paid for something else is fine. Bill a client for work you did, ship an internal tool at a for-profit company, teach a paid course that uses it — all fine, because the money is for your work, not for my project. What you cannot do is make the project itself the thing that earns. Fork it publicly and you also owe an attribution line back here.
Both are waivable in writing. If you want to do either, ask — the address is at the bottom of LICENSE.
- Model Context Protocol and the TypeScript SDK team
- Supabase for auth, Postgres, and RLS that behaves
- PolyForm Project for licenses that read like sentences
- OpenAI Apps SDK documentation, which made the ChatGPT tool-descriptor requirements figure-out-able