Skip to main content
Glama
README.md
# storefront-mcp

Servidor MCP con **MCP Apps** (SEP-1865, spec [`2026-01-26`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)) para el catálogo de alquiler temporario de "Vidriera" (`storefront-demo/`). Cuando el modelo busca propiedades, la respuesta trae un **recurso de UI** — un iframe con tarjetas, navegación a detalle y un flujo de reserva simulado de punta a punta — que el host (Claude Desktop) renderiza dentro del chat.

Es una PoC local: el catálogo es una copia congelada de `storefront-demo/data/catalog.json`, no hay base de datos ni red hacia el front.

El servidor tiene **dos entrypoints** sobre la misma factory (`createStorefrontMcpServer()` en `src/server.ts`), con las mismas tools y el mismo recurso `ui://storefront-mcp/catalog.html` en ambos casos:

- **`src/main.ts`** → stdio, para Claude Desktop local.
- **`src/http-main.ts`** → Streamable HTTP stateless, para desplegar como Web Service (Render u otro).

## Instalación

```bash
npm install
npm run build   # compila el iframe (vite) + el servidor (tsc), ambos entrypoints
```

`npm run build:view` recompila sólo `src/view/` a `dist-view/mcp-app.html` (un único archivo, JS y CSS inlineados vía `vite-plugin-singlefile`); `npm run build:server` recompila sólo el servidor (ambos entrypoints, `dist/main.js` y `dist/http-main.js`).

## Modo local con Claude Desktop (stdio)

```bash
npm install
npm run build
npm start
```

`npm start` (= `npm run start:stdio`) corre `node dist/main.js` por **stdio** (para que lo levante Claude Desktop u otro host). Además levanta un servidor HTTP local aparte, sólo para servir la página estática de "pago simulado" (ver [Gaps](#gaps-y-decisiones-de-implementación)), en el puerto `4405` por defecto (`STOREFRONT_MCP_PAY_PORT` para cambiarlo).

## Servidor HTTP local

```bash
PUBLIC_BASE_URL=http://localhost:3000 npm run start:http
```

`npm run start:http` corre `node dist/http-main.js`, un servidor **Streamable HTTP stateless** (sin sesiones, sin SSE legacy) escuchando en `0.0.0.0:${PORT}` (`PORT` default `3000`). Expone:

```text
http://localhost:3000/mcp        (POST/GET/DELETE — Streamable HTTP)
http://localhost:3000/health     (GET — health check)
http://localhost:3000/mock-pay   (GET — página de pago simulado)
```

`PUBLIC_BASE_URL` es la URL pública desde la que se sirve este mismo servidor; se usa para armar el link de "Ir a pagar" que genera `start_mock_booking` (`${PUBLIC_BASE_URL}/mock-pay?...`). Si no la definís, cae en `http://localhost:${PORT}` — sirve para probar en local sin configurar nada.

## Config para Claude Desktop

Agregá esto a `claude_desktop_config.json`, con la ruta absoluta a este repo:

```json
{
  "mcpServers": {
    "storefront-mcp": {
      "command": "node",
      "args": ["/ruta/absoluta/a/storefront-mcp/dist/main.js"]
    }
  }
}
```

Ubicación del archivo de config según el SO:

- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
- Linux: Claude Desktop no tiene build oficial; si usás un paquete no oficial (p. ej. el de AUR), la ruta suele ser `~/.config/Claude/claude_desktop_config.json` — verificá con esa distribución en particular, y tené en cuenta que el soporte de MCP Apps ahí no está garantizado igual que en el build oficial.

Después de editar el config, reiniciá Claude Desktop. Acordate de correr `npm run build` **antes** (Claude Desktop ejecuta `dist/main.js` ya compilado, no compila nada por su cuenta).

## Despliegue en Render

Configuración del Web Service:

```text
Runtime: Node
Build Command: npm ci && npm run build
Start Command: npm run start:http
Health Check Path: /health
```

Variable de entorno:

```text
PUBLIC_BASE_URL=https://<nombre-del-servicio>.onrender.com
```

No hace falta configurar `PORT` a mano — Render la inyecta y `src/http-main.ts` la lee de `process.env.PORT` (default `3000` sólo si corrés el servidor fuera de Render sin definirla). El servidor escucha en `0.0.0.0`, como pide Render, y no abre ningún otro puerto público (el server aparte del puerto `4405` es exclusivo del modo stdio y no se levanta en `http-main.ts`).

Una vez desplegado, para registrar el server remoto en un host compatible con MCP Apps sobre Streamable HTTP, la URL del endpoint es `https://<nombre-del-servicio>.onrender.com/mcp`.

## Cómo ver el iframe funcionando

En una conversación nueva de Claude Desktop con el servidor conectado, pedí algo como:

> Buscame departamentos en Coyoacán para 2 personas

El modelo debería llamar a `search_listings` y el chat debería mostrar el iframe con 3 tarjetas. Desde ahí: click en una tarjeta → detalle completo (descripción, amenities, anfitrión) → "Reservar" → resumen con fechas de ejemplo y total → "Ir a pagar" abre en el navegador la página estática de pago simulado (no cobra nada, no procesa nada).

Si el host no soporta MCP Apps, la tool sigue devolviendo una respuesta de texto legible (ver [Gaps](#gaps-y-decisiones-de-implementación)).

## Arquitectura

```
src/
  main.ts                 # entrypoint stdio: StdioServerTransport + arranca mock-pay (puerto aparte)
  http-main.ts             # entrypoint HTTP: Streamable HTTP stateless + /health + /mock-pay
  server.ts                 # createStorefrontMcpServer() — misma factory para los dos entrypoints
  mock-pay.ts                # servidor HTTP aparte (puerto 4405), sólo lo usa main.ts (stdio)
  mock-pay-page.ts             # renderPayPage(): el HTML de "pago simulado", sin duplicar
  catalog-data.ts                # carga data/catalog.json (copia del front)
  catalog-repository.ts           # búsqueda/detalle sobre esa copia
  format.ts                        # formateo de precio/huéspedes (portado del front)
  types.ts                          # tipos de dominio
  view/
    mcp-app.html                    # el recurso de UI: HTML + CSS (Barragán, replica el front)
    src/mcp-app.ts                    # lógica: App SDK, navegación, tool calls, openLink
data/
  catalog.json                        # copia de storefront-demo/data/catalog.json
scripts/
  smoke-stdio.mjs                      # smoke test JSON-RPC real sobre stdio (dist/main.js)
  smoke-http.mjs                        # smoke test Streamable HTTP real (dist/http-main.js)
  smoke-lib.mjs                          # aserciones compartidas entre los dos smoke tests
```

### Los dos entrypoints, un solo server

`createStorefrontMcpServer({ payBaseUrl })` (en `src/server.ts`) registra exactamente las mismas tools y el mismo recurso en los dos modos — no hay lógica de negocio duplicada entre stdio y HTTP:

- **`main.ts`** llama a la factory **una vez** y conecta esa única instancia a un `StdioServerTransport` (el proceso vive mientras dure la conexión con el host).
- **`http-main.ts`** sigue el patrón oficial *stateless* del SDK instalado (`@modelcontextprotocol/sdk@1.30.0`, ejemplo [`simpleStatelessStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/1.30.0/src/examples/server/simpleStatelessStreamableHttp.ts)): crea una instancia **nueva** de `McpServer` + `StreamableHTTPServerTransport` (con `sessionIdGenerator: undefined`) **en cada `POST /mcp`**, las conecta, atiende esa única request y las cierra al terminar. Ninguna instancia se reconecta ni se reutiliza entre requests — así evita el error de "transport ya conectado a otro server" que da el SDK si se intenta conectar dos veces la misma instancia.

Sólo `payBaseUrl` difiere entre modos: `main.ts` resuelve `PUBLIC_BASE_URL` (o cae a `http://localhost:${STOREFRONT_MCP_PAY_PORT ?? 4405}`); `http-main.ts` resuelve `PUBLIC_BASE_URL` (o cae a `http://localhost:${PORT}`, el propio servidor).

### Tools

| Tool | Visibilidad | Qué hace |
|---|---|---|
| `search_listings` | modelo + app | Busca por colonia/huéspedes/precio máx/tipo/texto libre. Devuelve hasta 3 tarjetas + el recurso de UI. |
| `get_listing_detail` | modelo + app | Ficha completa de una propiedad por ID. La usa el modelo (texto) y el iframe ya abierto, vía `app.callServerTool()`, para navegar a detalle sin recargar. |
| `start_mock_booking` | sólo app | Genera fechas de ejemplo, total y el link de pago simulado. Oculta al modelo a propósito — no tiene sentido que el LLM decida iniciar un "cobro" por su cuenta. |

## Qué se copió del front (y desde dónde)

**El front (`storefront-demo/`) es la fuente de la verdad. Todo lo de acá abajo es una copia manual, congelada a hoy (2026-07-28).** Si el front cambia, hay que repetir la copia a mano — no hay ningún proceso automático.

- **Datos**: `data/catalog.json` ← copia byte a byte de `storefront-demo/data/catalog.json` (220 listings, mismo esquema, sin renombrar campos). Ver `src/catalog-data.ts` y `src/types.ts`.
- **Paleta y estilos**: `src/view/mcp-app.html` (bloque `<style>`) ← replica a mano las variables y clases de `storefront-demo/app/globals.css` (paleta Barragán: rosa/añil/piedra, `shadow-hard`, `.skeleton`).
- **Layout de tarjeta**: replica `storefront-demo/components/ListingCard.tsx` + `StarRating.tsx`.
- **Fallback de foto rota**: replica `storefront-demo/components/PhotoWithFallback.tsx` (mismo array de colores de "muro" por seed, mismo ícono de ventana en SVG).
- **Layout de detalle**: replica `storefront-demo/app/listing/[id]/page.tsx` (precio + estadía mínima, descripción, grilla de amenities, anfitrión).

No hay cambios pendientes ni necesarios en `storefront-demo/` — este servidor sólo lee su copia local, nunca el repo del front.

## Tests

```bash
npm run build   # necesario antes: los smoke tests corren contra dist/
npm test         # smoke-stdio.mjs + smoke-http.mjs
```

No hay tests unitarios ni un framework de testing — son dos smoke tests que hablan el protocolo real:

- **`scripts/smoke-stdio.mjs`**: levanta `dist/main.js`, habla JSON-RPC por stdio (`initialize`, `tools/list`, `tools/call` ×3, `resources/read`) y valida que las tools, `_meta.ui.resourceUri` y el recurso `ui://storefront-mcp/catalog.html` respondan con las formas esperadas.
- **`scripts/smoke-http.mjs`**: levanta `dist/http-main.js` en un puerto de prueba con `PUBLIC_BASE_URL` apuntando a sí mismo, y repite las mismas validaciones (`initialize`, `tools/list`, `tools/call`, `resources/read`) pero vía `fetch` real contra `POST /mcp`, más `/health`, `GET`/`DELETE /mcp` (deben dar 405 con error JSON-RPC, como el ejemplo oficial stateless) y `GET /mock-pay`. También confirma que `payUrl` usa `PUBLIC_BASE_URL` y no tiene doble barra.

Ninguno de los dos reemplaza al otro — corren la misma lógica de negocio por los dos transportes.

## Cómo verifiqué esto (sin Claude Desktop a mano)

No tenía un host MCP Apps real en este entorno para probar el iframe de punta a punta, así que además de los smoke tests verifiqué el lado del iframe por separado: un harness HTML standalone hace de "host" falso (responde `ui/initialize` con `hostContext`/`hostCapabilities`, reenvía `tools/call` a datos de prueba, responde `ui/open-link`) sirviendo el `dist-view/mcp-app.html` real dentro de un iframe, manejado con Playwright. Confirmé visualmente el recorrido completo: lista → detalle → reserva → "Ir a pagar" (con la URL correcta) → volver → placeholder de foto rota con una URL que de verdad no resuelve → tema oscuro vía `hostContext.theme`.

Igual, la prueba definitiva del iframe es correrlo en Claude Desktop (local) o en el host remoto que uses (HTTP) — el harness no reemplaza eso, sólo reduce el riesgo de haber usado mal la spec de MCP Apps. El transporte Streamable HTTP en sí (`/mcp`, `/health`, `/mock-pay`) sí está probado de punta a punta con tráfico HTTP real vía `smoke-http.mjs`.

## Gaps y decisiones de implementación

- **Link de pago mock no es un `data:` URI**: la spec permite que `ui/open-link` rechace URLs como inválidas ("Invalid URL" / "Policy violation"), y `data:` URIs son un candidato típico a bloqueo por política de host. En vez de arriesgarme, en modo stdio agregué un servidor HTTP local aparte (`src/mock-pay.ts`, puerto `4405`) que sirve una página estática real; en modo HTTP, esa misma página es simplemente otra ruta (`GET /mock-pay`) del único servidor público.
- **Sin CORS**: no agregué `cors()` ni ningún header de CORS. El ejemplo oficial `simpleStatelessStreamableHttp.ts` de la versión instalada tampoco lo usa, y los clientes remotos de MCP (incluido un host llamando a este servidor) no son fetch de browser cross-origin típico — agregarlo sin necesidad hubiera sido exactamente el "CORS permisivo" que pedía evitar. Lo que sí agregué es la protección de DNS rebinding que ya trae `createMcpExpressApp()`: si `PUBLIC_BASE_URL` está definida, se pasa su hostname como `allowedHosts` (rechaza requests con `Host` header distinto); si no está definida (dev local pegándole a `0.0.0.0` sin configurar nada), queda sin esa restricción, igual que el comportamiento por defecto del SDK.
- **`enableJsonResponse: true`**: el transporte Streamable HTTP por defecto responde con un stream SSE de un solo evento. Para esta PoC (tools de request/respuesta simple, sin notificaciones de progreso) usé el modo de respuesta JSON plano que ya ofrece el SDK — sigue siendo Streamable HTTP válido (es el modo "JSON response, no SSE" documentado), sólo que más simple de consumir para un cliente API-style.
- **Sin fuente Archivo (la del front)**: cargarla implicaba declarar `resourceDomains` para `fonts.googleapis.com`/`fonts.gstatic.com` en el CSP del recurso y sumar una dependencia de red externa a una PoC que se pensó "local". Uso una pila de fuentes del sistema (`ui-sans-serif, system-ui, ...`) con la misma jerarquía tipográfica. Si esto importa para la demo, es un cambio de una línea en `_meta.ui.csp.resourceDomains` + un `@import` en el CSS.
- **`resourceDomains` para fotos**: el CSP por defecto de MCP Apps es `img-src 'self' data:` — bloquearía las fotos (`picsum.photos`) por completo. Se declaró `https://picsum.photos` explícitamente en `_meta.ui.csp.resourceDomains` del recurso.
- **Sin fallback de texto por falta de soporte del host**: la spec dice que los servidores *deberían* (`SHOULD`, no `MUST`) chequear `getUiCapability()` en el `initialize` del cliente y registrar una variante de tool sin `_meta.ui` si el host no soporta MCP Apps. No lo implementé — esta PoC apunta específicamente a Claude Desktop, que lo soporta nativamente desde el 26 de enero de 2026, y sumar esa rama duplicaba la lógica de las tools sin un caso de uso real acá. Si en algún momento hace falta targetear un host sin soporte, el punto de entrada es `getUiCapability` de `@modelcontextprotocol/ext-apps/server`, documentado en la spec.
- **Sin fechas/disponibilidad reales**: `catalog.json` copiado tiene `meta.hasAvailability: false` y ningún listing trae `availability` — por eso `search_listings` no filtra por check-in/checkout (a diferencia del `catalog-repository.ts` del front, que sí tiene esa lógica lista para cuando haya datos). Las fechas de la reserva simulada son sintéticas (próximo viernes + N noches), como pide la consigna ("fechas de ejemplo").
- **Cap de resultados**: `search_listings` siempre devuelve como máximo 3 propiedades (no configurable por el modelo más allá de bajarlo), acorde a "unas tres opciones".

TDQS

A3.9/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a distinct function: search for listings, get detail of a specific listing, and start a booking. No overlap in purpose.

Naming Consistency5/5

All tools follow a consistent verb_noun pattern with snake_case: search_listings, get_listing_detail, start_mock_booking.

Tool Count4/5

Three tools is minimal but covers the core workflow of searching, viewing details, and booking. Slightly under for a full-featured storefront, but acceptable.

Completeness3/5

Covers search, detail, and booking, but lacks common operations like canceling or modifying bookings, or managing user profiles. Notable gaps exist.

Maintenance

ActivitySlowing
ResponsivenessNo issues