A remote MCP server where row-level security is the only authorization layer
nutri. exposes its own MCP server: OAuth 2.1 with dynamic client registration on Supabase Auth, eight tools that reuse the app's domain validators, and no privileged key anywhere in the stack. Verified against production with a zero-net-alteration battery.
nutri. now has a second client that isn’t a browser: any MCP-capable AI — Claude on claude.ai, Claude Code, or whatever the user prefers — can log meals and query the diary in natural language, against the same production database, as the same authenticated user. It runs as a serverless function in the app’s existing Vercel project. Marginal infrastructure cost: zero.
Auth without a secrets drawer
There are no static tokens and no passwords in environment variables anywhere in this design. The flow is standard OAuth 2.1 doing all the work:
A tokenless request gets a 401 pointing at the resource metadata; the client discovers Supabase Auth as the authorization server, registers itself dynamically (claude.ai requires this), and sends the user to the app’s own consent screen to sign in and approve. From then on, every request carries a JWT that the server verifies against the project’s JWKS — issuer, audience, signature — on every call.
RLS is the only wall
The interesting architectural bet: the verified JWT is handed to a regular database client with the publishable key — so row-level security executes every query as that user. The server never holds a privileged key; there is nothing in the connector that could read another user’s data even if the tool logic had a bug. The user id extracted from the token is used for exactly two things: stamping ownership on writes and marking is_mine on reads. Authorization has one implementation, in the database, shared with the web app — not a second copy in the API layer that could drift.
Eight tools, two deliberate omissions
| Tool | Kind | What it does |
|---|---|---|
search_catalog |
read | Foods and recipes by name/brand; RLS decides visibility |
get_day |
read | A day’s totals plus its entries, from the same SQL views the app reads |
get_targets |
read | The nutrition target resolved for a date, phase-aware |
log_entry |
write | Log a consumption — by id first, name as fallback |
delete_entry |
write | Remove an entry (RLS limits to your own) |
create_food |
write | New food, per-100 g, validated |
create_recipe |
write | New recipe from catalog ingredients; returns computed per-100 g |
update_food |
write | Edit in place — or fork, if the food isn’t yours |
What’s not there is as deliberate: no delete_food, no delete_recipe. Destructive flows live in the app, where undo toasts and review flags exist. The tech log states the operational consequence honestly: a create_food is not reversible through the connector alone.
The same validators, no matter who writes
The connector reuses the app’s domain logic instead of reimplementing it. Hard validation blocks the write and returns an MCP error (unknown micro keys — the error lists the valid ones —, negative or non-finite numbers, malformed portions). Plausibility warnings — the same ⚠ the UI shows — never block: the write lands and the response carries warnings[], because the app recomputes them from views anyway. A dubious value is flagged the same way whether a human typed it or a model did.
Two sharp edges got specific treatment. Editing a food you don’t own (including the shared catalog) forks it: your copy gets the changes, the original stays intact for everyone else. And logging by name could silently hook a homonym from the shared catalog — fixed with a migration that makes id-based logging first-class and biases name lookup toward your own foods.
Proof against production
The functional battery ran against the live database (2026-07-16) with zero net alteration: the Atwater kcal cross-check, 150 g of a food = exactly 1.5× its per-100 g values across macros and micros, the canonical recipe case ((A + 2B) / 2.5), an invalid micro key blocking with the right error, implausible macros saving with a warning, a fork leaving the base food untouched, and an own-food update landing in place. The connector’s pure logic sits in one module under the same vitest suite as the rest of the domain. Three curl checks pin the auth surface: resource metadata answers 200, a tokenless call answers 401 with WWW-Authenticate, the app keeps serving.
The PM takeaway: an MCP server is an API product with an unusually capable and unusually careless consumer. The design decisions that matter — what to omit, where authorization lives, what blocks versus what warns — are product decisions, and they’re all in the open repo: github.com/vryahn/nutri, api/mcp.js and src/lib/mcp.js. Context for the whole system: nutri. — running a production app inside an agentic stack.