Skip to main content
Glama
johnawadlow

Snackbar MCP Server

by johnawadlow
README.md
# Snackbar

A personal note store with an MCP front door, so any assistant you're talking to
can capture an idea wherever you happen to be.

The problem it solves is narrow and specific: ideas arrive on the couch, on a
walk, at a desk that isn't yours — and by the time you're back at the machine
where your notes live, they're gone. Snackbar puts the notes somewhere both you
and your assistants can reach, and lets whichever assistant is already in the
conversation write them down.

It is not a Claude accessory. MCP is cross-vendor, so anything that speaks the
protocol can post to it. The store is yours; the assistants are interchangeable
front ends.

## How it works

```
Phone / laptop / wherever          this app                Supabase
┌──────────────────────┐      ┌──────────────────┐      ┌──────────┐
│ any MCP-speaking     │─────▶│ /api/mcp         │─────▶│  notes   │
│ assistant            │ HTTP │ OAuth or bearer  │      │          │
└──────────────────────┘      │   → author       │      └──────────┘
                              └──────────────────┘
```

One Next.js route handler, four tools, one table. That's the whole thing.

**The credential is the author field.** claude.ai connects over OAuth, and its
notes are authored `claude`. Any client that can set a header presents a
per-client bearer token that maps to a name of its own. Either way the server
*knows* who wrote a note instead of trusting a self-reported value.

One OAuth connector covers an entire Claude account — web, desktop, and mobile
all ride the same connection — so it can say *who* wrote a note but not *which
device*. If you need that distinction it has to come from somewhere other than
the credential.

**Notes carry two orthogonal qualifiers.** `category` is the subject (music,
code, video…) and is a free-form string the server never rejects — new ones just
start appearing in `list_categories`. `type` is what the entry *is*, and is one
of `idea`, `memory`, or `todo`. The distinction is load-bearing: category says
which shelf, type says what eventually happens to it. A `memory` stays a note
forever; a `todo` becomes work immediately; an `idea` waits until it's ripe.

## Tools

| Tool | What it does |
|---|---|
| `create_note` | Save a note. Author comes from the credential, never from the caller. |
| `list_notes` | Newest first; filter by type, category, or unreviewed-only. |
| `search_notes` | Substring match over note text. |
| `list_categories` | Which categories exist, with counts — check before inventing one. |

Listings return one compact line per note rather than JSON, so reading the whole
store costs very little context.

## Setup

**1. Database.** Create a Supabase project, then:

```bash
supabase link --project-ref <your-project-ref>
supabase db push
```

**2. Authorization server.** Any OAuth 2.1 server that publishes
[RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) metadata will do; this was
built against a free Auth0 tenant. You need two things in it:

- an **API** whose identifier is this server's MCP URL, exactly
  (`https://<your-deployment>/api/mcp`) — it becomes the token audience
- an **application**, registered as a public client so it authenticates with
  PKCE and needs no secret, with `https://claude.ai/api/mcp/auth_callback` as an
  allowed callback

Then grant the application access to the API, and enable offline access on the
API so refresh tokens are issued. In Auth0 that grant lives on the API's
**Application Access** tab as *user-delegated* access — not the Client Access
list beside it, which is for the machine-to-machine flow this doesn't use.
Skipping it produces a clean login followed by `Client ... is not authorized to
access resource server ...` in the tenant logs.

**3. Environment.** Copy `.env.example` to `.env.local` and fill in:

- `SUPABASE_URL` — `https://<project-ref>.supabase.co`
- `SUPABASE_SERVICE_ROLE_KEY` — the secret key, not the publishable one
- `SNACKBAR_AUTH_SERVER` — your authorization server's issuer URL, exactly as it
  appears in its own metadata, trailing slash and all
- `SNACKBAR_RESOURCE` — this server's MCP URL. It has to equal the API
  identifier *and* the URL users type into their client; all three are compared
- `SNACKBAR_ALLOWED_SUBJECT` — the single `sub` allowed in. **Leave it unset and
  every OAuth token is refused**, which is the safe direction to fail

Every client authenticates the same way — OAuth against the authorization
server, presenting `Authorization: Bearer <token>`. There is no second door.

**4. Run it.**

```bash
npm install
npm run dev
```

Deploy anywhere that runs Next.js; set the same variables there.

## Connecting a client

**claude.ai (set up on desktop web, then usable on mobile)** — Settings →
Connectors → Add custom connector, with the plain MCP URL and no credential in
it:

```
https://<your-deployment>/api/mcp
```

Open **Advanced settings** and paste your application's OAuth Client ID. Leave
the secret blank — a public client proves itself with PKCE. Connecting sends you
to your authorization server to sign in and returns you with a token.

Leaving the Client ID blank makes Claude attempt Dynamic Client Registration
instead. Auth0 gates DCR behind a paid plan, so on a free tenant the
pre-registered Client ID is the way through.

**Claude Code** — the same OAuth flow, with no token to manage:

```bash
claude mcp add --transport http snackbar https://<your-deployment>/api/mcp
```

Then run `/mcp` and authenticate. The unauthenticated 401 carries a
`WWW-Authenticate` header naming the protected-resource document, which names
the authorization server; the client walks that chain on its own.

## A note on security

There are no users, no roles, and no sharing. Row Level Security is enabled on
the table with **zero policies**, so the anon key can read nothing; the server
holds the service role key and does its own authorization.

**Signing in is not the same as being allowed in.** A valid OAuth token proves
who someone is, and on an authorization server that accepts Google logins that
is anyone with a Google account. `SNACKBAR_ALLOWED_SUBJECT` names the one
subject admitted. Unset, it refuses everyone rather than admitting everyone —
a half-configured deployment should be shut, not open. A refused token logs the
subject it presented, which is how you learn your own the first time.

A token carried in a query string or a path segment lands in server access logs,
proxies, and browser history, and the MCP specification prohibits it outright.
Those routes remain for clients that can do nothing else, but OAuth or an
`Authorization` header is the answer wherever a client supports one.

This is built for one person's notes. If you fork it for anything with more than
one human in it, the auth model is the first thing to replace.

## Status

Working, but young. Built with [Claude Code](https://claude.com/claude-code) as
part of an ongoing experiment in working with AI as a development partner — see
[ai-workshop](https://github.com/johnawadlow/ai-workshop) for the wider setup.

Maintenance

ActivityMaintained
ResponsivenessNo issues