Portafolio · Parte de nutri.

Producto personal · nutri.

Un servidor MCP remoto con seguridad por filas como única capa de autorización

Servidor MCP remoto con OAuth 2.1, PKCE y seguridad por filas como única capa de autorización: el caso de APIs e integraciones de este portafolio.

MCP · OAuth 2.1 · Seguridad por filas

nutri. tiene ahora un segundo cliente que no es un navegador: cualquier IA compatible con MCP —Claude en claude.ai, Claude Code o la herramienta que prefiera el usuario— puede registrar comidas y consultar el diario en lenguaje natural, en la misma base de producción y con la identidad del usuario autenticado. Se ejecuta como función sin servidor en el proyecto Vercel de la aplicación. Costo marginal de infraestructura: cero.

Autenticación sin un cajón de secretos

Este diseño no utiliza tokens estáticos ni contraseñas en variables de entorno. El flujo estándar de OAuth 2.1 hace el trabajo:

Una solicitud sin token recibe un 401 que apunta a los metadatos del recurso; el cliente descubre Supabase Auth como servidor de autorización, se registra dinámicamente —claude.ai lo exige— y lleva al usuario a la pantalla de consentimiento de la aplicación para iniciar sesión y aprobar el acceso. A partir de ahí, cada solicitud incluye un JWT que el servidor verifica frente al JWKS del proyecto: emisor, audiencia y firma, en cada llamada.

Pantalla de consentimiento OAuth para el conector MCP de nutri.: Claude solicita acceso, se muestran los permisos y botones para permitir o denegar
La pantalla de consentimiento es una ruta de la propia app: los mismos tokens, componentes y soporte de idiomas.

RLS es la única barrera

La decisión de arquitectura es entregar el JWT verificado a un cliente ordinario de base de datos con la clave publicable, para que la seguridad por filas evalúe las consultas como ese usuario. El servidor no utiliza una clave privilegiada de base de datos. La autorización sobre datos de usuario depende de las mismas políticas que usa la app web; dichas políticas deben mantenerse y probarse. El identificador de usuario extraído del token marca la propiedad de las escrituras y determina is_mine en las lecturas. La política de acceso permanece en la base de datos en lugar de duplicarse en el conector.

El mismo token protege ahora las claves de IA

Ese patrón de verificación se extendió más allá del conector. Antes, las claves de proveedores de la app —Gemini, Groq, Mistral y USDA FoodData Central— viajaban en el bundle del cliente como variables VITE_*. Era un riesgo conocido y documentado, aceptable mientras los únicos dos usuarios eran dueños de las claves. Una demo pública cambió la situación: el acceso anónimo cuenta como registro y abrir los registros hacía que cualquiera pudiera leer el bundle.

Ahora cada llamada al proveedor pasa por un proxy sin servidor que verifica el mismo JWT de Supabase frente al mismo JWKS que el servidor MCP: mismo emisor, audiencia y algoritmo, sin ninguna clave service_role. Hay controles para que el proxy no se convierta en un relay abierto: una lista cerrada de modelos —cualquier otro devuelve 400 antes de contactar al proveedor—, rechazo de la cuenta anónima de demo del lado del servidor con 403 aunque el cliente ya use datos predefinidos, e IA real restringida a las cuentas existentes antes de abrir registros, porque «no anónimo» dejó de equivaler a «confiable». Las solicitudes pueden contener fotos de etiquetas, así que sus cuerpos nunca se registran, ni completos ni truncados.

Dos detalles merecen conservarse. La consulta a USDA utiliza el mismo proxy aunque no sea IA, para sacar su clave del bundle; su URL se construye en el servidor a partir de dos formas fijas y el identificador del alimento se valida como dígitos. Ningún texto del cliente llega a la ruta. Además, una clave ausente devuelve 501, que la cascada interpreta como otro error y salta al siguiente proveedor: el mecanismo que resiste una caída del servicio también resiste un secreto no configurado.

Alcance original de las herramientas

La tabla documenta la versión de julio de 2026. El conector creció después; este caso trata las decisiones y la verificación de esa primera entrega.

Herramienta Tipo Función
search_catalog lectura Alimentos y recetas por nombre o marca; RLS decide qué puede verse
get_day lectura Totales y entradas de un día, desde las mismas vistas SQL que lee la app
get_targets lectura Objetivo nutricional resuelto para una fecha, según la fase
log_entry escritura Registrar consumo, primero por identificador y luego por nombre
delete_entry escritura Borrar una entrada; RLS limita la operación a las propias
create_food escritura Crear un alimento validado por 100 g
create_recipe escritura Crear una receta con ingredientes del catálogo; devuelve valores calculados por 100 g
update_food escritura Editar en sitio o crear una copia si el alimento no es propio

La versión original no incluía delete_food ni delete_recipe. Esos flujos destructivos permanecían en la app, que dispone de avisos para deshacer y marcas de revisión. El registro técnico declara la consecuencia operativa: un create_food no podía revertirse usando solo el conector.

Los mismos validadores, escriba quien escriba

El conector reutiliza la lógica de dominio de la app en lugar de reimplementarla. La validación estricta bloquea la escritura y devuelve un error MCP: claves de micronutrientes desconocidas —el error enumera las válidas—, números negativos o no finitos y porciones mal formadas. Las advertencias de plausibilidad —el mismo ⚠ que muestra la interfaz— no bloquean: la escritura se guarda y la respuesta contiene warnings[], porque la app las recalcula desde sus vistas. Un valor dudoso se marca igual si lo escribió una persona o un modelo.

Dos bordes exigieron un tratamiento específico. Editar un alimento ajeno —incluido el catálogo compartido— crea una copia: esta recibe los cambios y el original permanece intacto para los demás. Registrar por nombre podía seleccionar silenciosamente un homónimo del catálogo compartido; una migración hizo prioritario el registro por identificador y dio preferencia a los alimentos propios al buscar por nombre.

Prueba contra producción

La batería funcional se ejecutó contra la base en vivo el 16 de julio de 2026 sin alteración neta: cruce de kcal por Atwater, 150 g de un alimento equivalentes a 1.5 veces sus valores por 100 g tanto en macros como micros, caso canónico de receta ((A + 2B) / 2.5), clave de micronutriente inválida bloqueada con el error correcto, macros inverosímiles guardados con advertencia, creación de copia sin alterar el alimento base y edición de alimento propio en sitio. La lógica pura del conector está en un módulo de la misma suite vitest que el resto del dominio. Tres verificaciones con curl fijan la superficie de autenticación: metadatos del recurso → 200, llamada sin token → 401 con WWW-Authenticate y la app sigue sirviendo.


La lección de producto: un servidor MCP es un producto API cuyo consumidor tiene gran capacidad y puede cometer errores con facilidad. Las decisiones relevantes —qué omitir, dónde vive la autorización, qué bloquea y qué solo advierte— son decisiones de producto. Están en el repositorio público github.com/vryahn/nutri, en api/mcp.js y src/lib/mcp.js. Contexto del sistema completo: nutri. — aplicación en producción con un entorno agéntico.

BR / CONTACTO

Hablemos

Para oportunidades profesionales y conversaciones sobre producto, pagos e IA aplicada.