Compare commits

...

10 Commits

Author SHA1 Message Date
748b485c9b Add an MCP server for the API, deployable next to the application
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
mcp/ exposes both APIs as MCP tools, one per operation in openapi.yaml,
named after the Laravel route each lands on (public_projects_index,
admin_pages_update, ...). Nothing is written by hand: the tools are
derived from the document the target application serves, which the
contract tests already hold to the truth.

Two transports over one core:

- stdio (`node mcp/server.mjs`), registered in .mcp.json, signing in
  with credentials from the environment and refreshing the token.
- Streamable HTTP (`--http`), stateless, deployed as the landing-mcp
  service. It holds no credentials: each request brings its caller's
  Pireon Basic credentials or bearer token, so the API applies that
  person's permissions. Sessions live in memory keyed by a hash,
  refused credentials are remembered for a minute, and the spec is
  reloaded every minute so new endpoints appear without a redeploy.

Left out on purpose: the token exchange, telemetry, and the contact and
apply forms, which notify real people. The preview secret is never sent
for anonymous callers, and is unset in the deployment by default.

A CI job runs the Node tests, including the HTTP mode end to end
against a fake application.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:14 +02:00
5ca084bd4a Throttle the token endpoint per account and address, not per address
POST /api/v1/auth/token was limited to 6 requests a minute per IP. The
deployed MCP server signs every user in from a single container, so
under a per-IP budget one person's mistyped password, or a stranger's
guesses, would lock every other user out of it.

The named `auth-token` limiter keys on the lower-cased username (or the
refresh token) plus the address, still 6 a minute, with a 30-a-minute
ceiling per address to bound how many accounts one client can try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:13 +02:00
5558db6d56 Rename the app service to landing-app to avoid a Dokploy network collision
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Compose aliases a service to its own name on every network it joins,
including the shared external dokploy-network. A service named plain
"app" collided with another project's equally generic "app" on that
same network, so its containers started resolving "app" to this one
instead — breaking oauth-iam's PHP-FPM upstream whenever both stacks
were up together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:09:00 +02:00
1e925a2b07 add port
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:47:24 +02:00
080c4238e8 remove port
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:43:43 +02:00
ffde884f5b fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:27:07 +02:00
2a58833190 fix track
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:15:23 +02:00
c30863ab9b fix panel
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:48:56 +02:00
2cca79cf04 fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:30:18 +02:00
033cec6573 fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:03:30 +02:00
31 changed files with 3082 additions and 166 deletions

View File

@@ -20,3 +20,6 @@ database/*.sqlite-journal
.phpunit.result.cache
docs/*.pdf
public/storage
# Runs on a developer machine, next to Claude Code, never in the image.
mcp

View File

@@ -2,10 +2,17 @@
# Production environment for the Docker stack.
#
# cp .env.production.example .env.production
# docker compose run --rm --no-deps app php artisan key:generate --show
# # fill in at least DB_PASSWORD below — docker-compose.yml refuses to
# # interpolate at all without it, even for the next command
# docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show
#
# Everything the containers need is here; docker-compose.yml sets the database,
# cache, session and queue drivers itself so they cannot drift.
#
# This file only matters for a manual deploy. A platform that injects its own
# "Environment Variables" (Dokploy, Coolify, ...) exports them as real process
# environment variables instead, which docker-compose.yml's `${VAR}`
# interpolation reads directly — no file needed on the server at all.
###############################################################################
APP_NAME="Pireon Admin"
@@ -116,3 +123,18 @@ MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS="admin@example.org"
MAIL_FROM_NAME="${APP_NAME}"
###############################################################################
# MCP server (landing-mcp)
#
# Holds no credentials: every caller signs in with their own. Its domain is
# added in Dokploy's "Domains" tab, pointing at port 3000.
###############################################################################
# "true" refuses callers without credentials instead of offering them the
# public API — which is already public, so the default is to allow it.
LANDING_MCP_REQUIRE_AUTH=false
# Empty on purpose. Set to SITE_PREVIEW_TOKEN's value and anyone who can sign
# in sees every draft through the public tools, whatever their role.
LANDING_MCP_PREVIEW_TOKEN=

View File

@@ -79,3 +79,24 @@ jobs:
- uses: actions/checkout@v4
- run: docker compose -f docker-compose.test.yml run --rm test
# The MCP server derives its tools from openapi.yaml, so a change to the
# document can break it without touching a line under mcp/ — two operations
# that map to the same tool name, say. Cheap enough to run on every push.
mcp:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: mcp/package-lock.json
- run: npm ci
- run: npm test

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
/mcp/node_modules

15
.mcp.json Normal file
View File

@@ -0,0 +1,15 @@
{
"mcpServers": {
"landing": {
"command": "node",
"args": ["mcp/server.mjs"],
"env": {
"LANDING_URL": "${LANDING_URL:-http://localhost:8000}",
"LANDING_USERNAME": "${LANDING_USERNAME:-}",
"LANDING_PASSWORD": "${LANDING_PASSWORD:-}",
"LANDING_TOKEN": "${LANDING_TOKEN:-}",
"LANDING_PREVIEW_TOKEN": "${LANDING_PREVIEW_TOKEN:-}"
}
}
}
}

View File

@@ -20,13 +20,13 @@ WORKDIR /app
# sideways staying sideways. pcntl lets the queue worker shut down cleanly
# instead of being killed mid-job.
RUN install-php-extensions \
pdo_pgsql \
gd \
exif \
intl \
zip \
pcntl \
opcache \
pdo_pgsql \
gd \
exif \
intl \
zip \
pcntl \
opcache \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
@@ -49,11 +49,11 @@ COPY composer.json composer.lock ./
# application cannot boot before its code is here. They are run explicitly in
# the runtime stage instead, where everything exists.
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
COPY . .
@@ -93,7 +93,7 @@ RUN touch .env \
FROM base AS runtime
LABEL org.opencontainers.image.title="Pireon Admin" \
org.opencontainers.image.description="Admin panel and REST API for the collective"
org.opencontainers.image.description="Admin panel and REST API for the collective"
COPY docker/Caddyfile /etc/caddy/Caddyfile
@@ -113,9 +113,9 @@ RUN php artisan package:discover --ansi \
RUN chown -R www-data:www-data storage bootstrap/cache
ENV SERVER_NAME=:80
ENV SERVER_NAME=:8080
EXPOSE 80
EXPOSE 8080
ENTRYPOINT ["entrypoint"]

119
README.md
View File

@@ -1332,6 +1332,99 @@ Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
prosa, y un tipo a secas no la conserva.
## Servidor MCP
[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier
cliente MCP— las dos APIs como herramientas: `public_projects_index`,
`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada,
con el nombre de la ruta de Laravel en la que acaba.
**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la
aplicación a la que apunta y las genera de ahí, así que describe la API con la
que está hablando —local, staging o producción— y no la de este checkout. Por lo
mismo que los tipos: el documento lo sujetan los tests de contrato, y una
segunda descripción de la API escrita a mano no la sujetaría nada. La
introducción del documento (las dos APIs, idiomas, campos traducibles) viaja
como instrucciones del servidor.
### Desplegado (HTTP)
Va en el stack como un contenedor más, `landing-mcp`, que habla con
`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña
«Domains» apuntando al puerto 3000; el endpoint es `https://<dominio>/mcp`
(Streamable HTTP, sin estado).
**No tiene credenciales propias.** Cada petición trae las de quien la hace, y
la API aplica los permisos de esa persona, igual que en el panel:
```bash
claude mcp add --transport http landing https://mcp.example.org/mcp \
--header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)"
```
- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la
primera vez, guarda la sesión en memoria (indexada por un hash, nunca en
disco ni en logs), refresca el token antes de que caduque y la olvida tras
12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan.
- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida
contra `GET /v1/me` y se revalida cada minuto, como hace la propia app.
- **Sin cabecera**: solo la API pública, que ya es pública.
`LANDING_MCP_REQUIRE_AUTH=true` lo cierra.
Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a
la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección**
(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos
los logins del MCP salen del mismo contenedor, y un límite por IP haría que la
contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a
todos los demás.
El documento se relee cada minuto, así que un endpoint nuevo desplegado en la
app aparece como herramienta sin redesplegar el MCP; si la app está caída, se
sigue usando el último que cargó.
`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de
`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los
borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda
a quien llega sin credenciales.
### En local (stdio)
```bash
cd mcp && npm install
```
En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por
stdio con la configuración del entorno. Aquí sí las credenciales van en
variables, porque quien lo lanza es quien inicia sesión:
```
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
LANDING_USERNAME credenciales de Pireon para la API de administración;
LANDING_PASSWORD el token se pide y se refresca solo
LANDING_TOKEN o un bearer token fijo en vez de credenciales
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
LANDING_OPENAPI opcional: ruta o URL de la especificación
```
`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000`
salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
### En los dos modos
- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que
solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo.
- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la
telemetría (la manda el frontend con su propio secreto) y los dos formularios
del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y
avisa a gente real.
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la
configuración, no del modelo.
`npm test` (en `mcp/`, y en CI) genera las herramientas desde el
`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos
nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de
punta a punta contra una app simulada.
## Comandos
```bash
@@ -1364,7 +1457,7 @@ php artisan site:maintenance --up
## Tests
```bash
php artisan test # 394 tests, 1128 aserciones
php artisan test # 391 tests, 1121 aserciones
```
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
@@ -1458,7 +1551,7 @@ párrafo aparte sin relación programática con él.
## Estructura
```
docker-compose.yml stack de producción (app, worker, scheduler, postgres)
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
docker-compose.test.yml la suite contra Postgres, desechable
Dockerfile imagen única para los tres contenedores PHP
docker/ Caddyfile, php.ini y entrypoint
@@ -1491,6 +1584,7 @@ config/site.php frontend público, preview, caché, rate
config/media.php disco, anchos y formatos de imagen
config/git.php proveedores de git y etiquetas de issues
public/openapi.yaml especificación de ambas APIs
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
```
## Despliegue con Docker
@@ -1499,16 +1593,29 @@ Cuatro contenedores y una base de datos:
```bash
cp .env.production.example .env.production
docker compose run --rm --no-deps app php artisan key:generate --show # pégalo en APP_KEY
# Ahora edita .env.production: al menos DB_PASSWORD tiene que tener un valor
# real, porque docker-compose.yml no arranca sin él — ni siquiera para el
# comando siguiente, que solo interpola el fichero entero antes de ejecutar
# nada. APP_KEY puede quedarse vacío por ahora, se rellena con este paso:
docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show # pégalo en APP_KEY
docker compose --env-file .env.production up -d --build
# el primer administrador, igual que en local
docker compose exec app php artisan user:role tu@email.com super_admin
docker compose exec landing-app php artisan user:role tu@email.com super_admin
```
Todas las variables de `docker-compose.yml` llegan por interpolación `${VAR}`,
no por `env_file:` — así que esto funciona igual con `--env-file`, con
variables ya exportadas en el shell, o con el panel de "Environment
Variables" de una plataforma como Dokploy o Coolify, que las inyecta como
variables de entorno reales del proceso que lanza `docker compose`. Ninguna
de las tres formas necesita que exista un fichero `.env.production` en el
servidor.
| Contenedor | Qué hace |
|---|---|
| `app` | FrankenPHP sirviendo el panel y la API |
| `landing-app` | FrankenPHP sirviendo el panel y la API |
| `worker` | procesa imágenes y manda los avisos del buzón |
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
| `postgres` | la base de datos |
@@ -1587,7 +1694,7 @@ docker compose -f docker-compose.test.yml run --rm test
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
antes del primer despliegue. **394 tests en verde contra Postgres 17.**
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
y ahí no está ni el runner de tests.

View File

@@ -54,6 +54,21 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
->by($request->user()?->id ?: $request->ip()));
// Backs POST /api/v1/auth/token. Budgeted per account and address
// rather than per address alone, because the deployed MCP server
// (mcp/) signs everyone in from one container: per IP, one person's
// wrong password — or a stranger's guesses — would lock every other
// user out of it. The per-address ceiling still bounds how many
// accounts a single client can try.
RateLimiter::for('auth-token', fn (Request $request) => [
Limit::perMinute(6)->by('auth-token:'.sha1(
Str::lower((string) ($request->input('username') ?: $request->input('refresh_token')))
.'|'.$request->ip()
)),
Limit::perMinute(30)->by('auth-token-ip:'.$request->ip()),
]);
// The public API is a different problem. The public frontend renders
// server-side, so every visitor's page view arrives from the same IP:
// a per-IP budget sized for one person would throttle the entire

View File

@@ -86,14 +86,6 @@ class AdminPanelProvider extends PanelProvider
])
// Language switcher, rendered next to the user menu.
// First render hook to fire on every page, panel and login alike:
// the one place that has to be true regardless of which screen
// somebody lands on first.
->renderHook(
PanelsRenderHook::BODY_START,
fn (): string => view('filament.partials.environment-banner')->render(),
)
->renderHook(
PanelsRenderHook::USER_MENU_BEFORE,
fn (): string => view('filament.partials.language-switcher')->render(),

View File

@@ -1,36 +1,59 @@
###############################################################################
# Production stack.
#
# Four containers and one database. Deliberately no Redis: Postgres holds the
# Five containers and one database. Deliberately no Redis: Postgres holds the
# cache, the sessions and the queue, which for a site this size is plenty and
# is one less thing to run, back up and forget to secure. Swapping it in later
# is three environment variables.
#
# Every application value below comes from `${VAR}` interpolation, not
# `env_file:` — so this works the same way whichever way the variables reach
# `docker compose`: a real shell environment, a platform's own "Environment
# Variables" panel (Dokploy, Coolify, ...), or an explicit `--env-file`:
#
# docker compose --env-file .env.production up -d --build
#
# `APP_KEY` is deliberately not required here even though the application
# cannot run without it: `docker/entrypoint.sh` checks it instead, with a
# message that says how to generate one. Requiring it at this level would also
# break generating it in the first place — `docker compose run --rm --no-deps
# landing-app php artisan key:generate --show` has to work before `APP_KEY`
# exists.
#
# The web service is named `landing-app`, not `app`: Compose aliases a
# service to its own name on every network it joins, `dokploy-network`
# included, and that network is shared with whatever else Dokploy runs on the
# same host. A service called plain `app` collides with the equally common
# `app` another compose project puts on the same network — its containers
# start resolving `app` to *this* one, on a port nothing here is listening on.
# That is exactly what broke `oauth-iam`'s PHP-FPM upstream the moment both
# stacks were up together, and it stops happening the moment the name isn't
# generic enough to collide.
#
# `landing-app` is not published on a host port either: it only joins the
# external `dokploy-network` so Dokploy's Traefik can reach it, and TLS is
# terminated there before anything reaches this container — same reason
# `docker/Caddyfile` trusts `X-Forwarded-*` only from private ranges. The
# domain itself is configured through Dokploy's own "Domains" tab, which
# injects its own Traefik labels — don't also add them here, Traefik ends up
# with two routers for the same Host() and picks one unpredictably.
#
# A manual deploy without Dokploy needs its own reverse proxy joined to that
# network (with its own labels/config), or `landing-app` added back to
# `default` with a published port.
#
# The web container is the only one that migrates, so a deploy runs them once
# rather than once per replica.
###############################################################################
name: landing
x-app: &app
build:
context: .
dockerfile: Dockerfile
image: pireon-admin:latest
restart: unless-stopped
env_file: .env.production
environment:
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
# The maintenance flag and the health badge are read on nearly every
# request, so the cache has to be shared by every container — a per-process
# one would have the panel and the API disagreeing about whether the site
# is down.
CACHE_STORE: database
SESSION_DRIVER: database
QUEUE_CONNECTION: database
volumes:
# Uploaded images live here, and they are the one thing in this stack that
# cannot be rebuilt from the repository.
@@ -39,23 +62,95 @@ x-app: &app
postgres:
condition: service_healthy
x-app-env: &app-env
APP_NAME: ${APP_NAME:-Pireon Admin}
APP_ENV: ${APP_ENV:-production}
APP_KEY: ${APP_KEY}
APP_DEBUG: ${APP_DEBUG:-false}
APP_URL: ${APP_URL}
APP_LOCALE: ${APP_LOCALE:-es}
APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-es}
LOG_CHANNEL: ${LOG_CHANNEL:-stack}
LOG_STACK: ${LOG_STACK:-single}
LOG_LEVEL: ${LOG_LEVEL:-warning}
# Fixed by the service names below, not something a deploy should override —
# otherwise the panel and the API could disagree about the site's state.
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: ${DB_DATABASE:-pireon}
DB_USERNAME: ${DB_USERNAME:-pireon}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
CACHE_STORE: database
SESSION_DRIVER: database
QUEUE_CONNECTION: database
PIREON_SERVER: ${PIREON_SERVER:-https://oauth.pireon.dev}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET}
PIREON_REDIRECT_URI: ${PIREON_REDIRECT_URI}
PIREON_SCOPES: ${PIREON_SCOPES:-account:read}
PIREON_PASSWORD_GRANT: ${PIREON_PASSWORD_GRANT:-true}
PIREON_INTROSPECTION_TTL: ${PIREON_INTROSPECTION_TTL:-60}
PIREON_DEFAULT_ROLE: ${PIREON_DEFAULT_ROLE:-viewer}
PIREON_REQUIRE_ACTIVE: ${PIREON_REQUIRE_ACTIVE:-true}
PIREON_REQUIRE_APPROVED: ${PIREON_REQUIRE_APPROVED:-true}
SITE_URL: ${SITE_URL}
SITE_PREFIX_DEFAULT_LOCALE: ${SITE_PREFIX_DEFAULT_LOCALE:-false}
SITE_CACHE_SECONDS: ${SITE_CACHE_SECONDS:-60}
SITE_PREVIEW_TOKEN: ${SITE_PREVIEW_TOKEN}
SITE_RATE_LIMIT: ${SITE_RATE_LIMIT:-600}
SITE_TRUSTED_IPS: ${SITE_TRUSTED_IPS:-}
SITE_INGEST_TOKEN: ${SITE_INGEST_TOKEN}
SITE_TELEMETRY_RATE_LIMIT: ${SITE_TELEMETRY_RATE_LIMIT:-60}
SITE_TELEMETRY_MAX_BATCH: ${SITE_TELEMETRY_MAX_BATCH:-50}
SITE_ACCEPT_SUBMISSIONS: ${SITE_ACCEPT_SUBMISSIONS:-true}
SITE_HONEYPOT_FIELD: ${SITE_HONEYPOT_FIELD:-website}
SITE_SUBMISSION_RATE_LIMIT: ${SITE_SUBMISSION_RATE_LIMIT:-3}
SITE_SUBMISSION_HOURLY_LIMIT: ${SITE_SUBMISSION_HOURLY_LIMIT:-60}
SITE_SUBMISSION_DEDUPE_MINUTES: ${SITE_SUBMISSION_DEDUPE_MINUTES:-1440}
SITE_SUBMISSION_MAIL: ${SITE_SUBMISSION_MAIL:-false}
MEDIA_DISK: ${MEDIA_DISK:-public}
MEDIA_MAX_PIXELS: ${MEDIA_MAX_PIXELS:-50000000}
GIT_PROVIDER: ${GIT_PROVIDER:-gitea}
GITEA_URL: ${GITEA_URL:-}
GITEA_TOKEN: ${GITEA_TOKEN:-}
GITEA_ORG: ${GITEA_ORG:-}
GIT_SYNC_CRON: ${GIT_SYNC_CRON:-hourly}
MAIL_MAILER: ${MAIL_MAILER:-log}
MAIL_HOST: ${MAIL_HOST:-}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin}
services:
app:
landing-app:
<<: *app
environment:
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
CACHE_STORE: database
SESSION_DRIVER: database
QUEUE_CONNECTION: database
<<: *app-env
# Only here: migrations run once per deploy, not once per container.
RUN_MIGRATIONS: "true"
ports:
- "${APP_PORT:-8000}:80"
expose:
- "8080"
networks:
- dokploy-network
- default
# No Traefik labels here on purpose: the domain is configured through
# Dokploy's own "Domains" tab for this app, which injects its own labels
# at deploy time. Hand-writing another set on top of those, for the same
# Host(), gave Traefik two routers to choose between — it only broke once
# both existed together, which is exactly what happened.
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
test: ["CMD", "curl", "-fsS", "http://localhost:8080/up"]
interval: 30s
timeout: 5s
start_period: 40s
@@ -67,6 +162,7 @@ services:
worker:
<<: *app
command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=3"]
environment: *app-env
# Waits for the web container, not just the database: it is the one that
# migrates, and a worker that starts against an empty schema spends its
@@ -74,7 +170,7 @@ services:
depends_on:
postgres:
condition: service_healthy
app:
landing-app:
condition: service_healthy
healthcheck:
test: ["CMD", "php", "artisan", "queue:monitor", "default"]
@@ -89,6 +185,7 @@ services:
scheduler:
<<: *app
command: ["php", "artisan", "schedule:work"]
environment: *app-env
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin
# API, and this container does not serve HTTP — inherited, it could never
@@ -109,17 +206,56 @@ services:
depends_on:
postgres:
condition: service_healthy
app:
landing-app:
condition: service_healthy
stop_grace_period: 30s
# The MCP server (mcp/), so Claude and other MCP clients can use the API
# from anywhere, not only from a checkout of this repository. It reaches the
# application over the stack's own network rather than the public domain:
# one hop, no TLS, and it keeps working while the domain is misconfigured.
#
# Holds no credentials of its own. Every request brings its caller's Pireon
# credentials, and the API applies that person's permissions — see the
# README's "Servidor MCP" section for how to connect.
#
# Like `landing-app`, published only through `dokploy-network`: add its
# domain in Dokploy's "Domains" tab, pointing at port 3000, and the endpoint
# is https://<that domain>/mcp.
landing-mcp:
build:
context: ./mcp
image: landing-mcp:latest
restart: unless-stopped
environment:
LANDING_URL: http://landing-app:8080
LANDING_MCP_REQUIRE_AUTH: ${LANDING_MCP_REQUIRE_AUTH:-false}
# Deliberately not SITE_PREVIEW_TOKEN by default: with it, anyone who can
# sign in sees every draft through the public tools, whatever their role
# would let them see in the panel. Set it to that value knowingly.
LANDING_PREVIEW_TOKEN: ${LANDING_MCP_PREVIEW_TOKEN:-}
expose:
- "3000"
networks:
- dokploy-network
- default
depends_on:
landing-app:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/up').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_DATABASE:-pireon}
POSTGRES_USER: ${DB_USERNAME:-pireon}
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env.production}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
# Content is written by a handful of people and read constantly; the
# default collation rules are what the JSON slug lookups sort under.
@@ -141,3 +277,7 @@ services:
volumes:
pgdata:
storage:
networks:
dokploy-network:
external: true

View File

@@ -30,11 +30,6 @@ return [
],
],
'environment' => [
'production' => 'Producció — això és real',
'other' => 'Entorn de :name',
],
'concurrency' => [
'title' => 'No s\'ha desat',
'body' => 'Algú altre ha canviat això mentre ho editaves. Recarrega la pàgina per veure la versió actual: si ho deses ara, perdries aquest canvi.',

View File

@@ -30,11 +30,6 @@ return [
],
],
'environment' => [
'production' => 'Production — this is real',
'other' => ':name environment',
],
'concurrency' => [
'title' => 'Not saved',
'body' => 'Somebody else changed this while you were editing it. Reload the page to see the current version — saving now would overwrite that change.',

View File

@@ -30,11 +30,6 @@ return [
],
],
'environment' => [
'production' => 'Producción — esto es real',
'other' => 'Entorno de :name',
],
'concurrency' => [
'title' => 'No se ha guardado',
'body' => 'Alguien más ha cambiado esto mientras lo editabas. Recarga la página para ver la versión actual: si guardas ahora, perderías ese cambio.',

4
mcp/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
test
Dockerfile
.dockerignore

26
mcp/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
###############################################################################
# The MCP server, over HTTP. Its own image rather than a fourth command in the
# PHP one: it shares no code with the application, only the API between them,
# and Node is a runtime that image has no other reason to carry.
###############################################################################
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production \
LANDING_MCP_HOST=0.0.0.0 \
LANDING_MCP_PORT=3000
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server.mjs ./
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "server.mjs", "--http"]

1222
mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
mcp/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "landing-mcp",
"private": true,
"type": "module",
"description": "MCP server exposing the landing REST API as tools, generated from its OpenAPI document.",
"bin": {
"landing-mcp": "server.mjs"
},
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node server.mjs",
"test": "node --test"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.1",
"yaml": "^2.9.1"
}
}

94
mcp/server.mjs Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* MCP server for the landing API.
*
* Reads the OpenAPI document the running application serves and offers one
* tool per documented operation, so it always describes the API it is talking
* to — local, staging or production — rather than the one in this checkout.
*
* node server.mjs stdio, for a client that launches it locally
* node server.mjs --http Streamable HTTP on /mcp, for deploying
*
* Configuration, all through the environment:
*
* LANDING_URL Application root. Default http://localhost:8000
* LANDING_OPENAPI Path or URL of the spec, if not LANDING_URL/openapi.yaml.
* LANDING_PREVIEW_TOKEN Lets the public tools see unpublished content. Over
* HTTP, only for signed-in callers.
*
* stdio only — the one person running it is the one it signs in as:
*
* LANDING_USERNAME Pireon credentials for the admin API (/v1). The
* LANDING_PASSWORD token is obtained and refreshed automatically.
* LANDING_TOKEN Or a fixed bearer token instead.
*
* HTTP only — every request brings its own credentials (see src/http.mjs):
*
* LANDING_MCP_PORT Default 3000.
* LANDING_MCP_HOST Default 127.0.0.1; the image sets 0.0.0.0.
* LANDING_MCP_REQUIRE_AUTH "true" refuses anonymous callers instead of
* offering them the public API.
*
* stdout is the stdio protocol channel: anything for a human goes to stderr.
*/
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { Catalogue } from './src/catalogue.mjs';
import { LandingClient } from './src/client.mjs';
import { startHttpServer } from './src/http.mjs';
import { createMcpServer } from './src/mcp.mjs';
const env = (name) => process.env[name]?.trim() || undefined;
const baseUrl = (env('LANDING_URL') ?? 'http://localhost:8000').replace(/\/+$/, '');
const http = process.argv.includes('--http');
const catalogue = new Catalogue({
source: env('LANDING_OPENAPI') ?? `${baseUrl}/openapi.yaml`,
baseUrl,
// A deployment outlives many deploys of the application; a stdio process
// lives for one editor session and describes its tools once anyway.
ttlMs: http ? 60_000 : Infinity,
});
if (http) {
// Credentials in the environment would sign every anonymous caller in as
// whoever they belong to.
for (const name of ['LANDING_USERNAME', 'LANDING_PASSWORD', 'LANDING_TOKEN']) {
if (env(name)) console.error(`landing-mcp: ${name} is ignored over HTTP; each request brings its own credentials`);
}
// Tried now only to report a misconfiguration early. The application may
// well still be starting, so a failure here is not fatal: the catalogue
// retries on the first request.
catalogue.get().catch((error) => console.error(`landing-mcp: OpenAPI document not available yet: ${error.message}`));
startHttpServer({
catalogue,
port: Number(env('LANDING_MCP_PORT') ?? 3000),
host: env('LANDING_MCP_HOST') ?? '127.0.0.1',
previewToken: env('LANDING_PREVIEW_TOKEN'),
requireAuth: env('LANDING_MCP_REQUIRE_AUTH') === 'true',
});
} else {
let current;
try {
current = await catalogue.get();
} catch (error) {
console.error(`landing-mcp: could not load the OpenAPI document from ${catalogue.source}: ${error.message}`);
process.exit(1);
}
const client = new LandingClient({
apiBase: current.apiBase,
token: env('LANDING_TOKEN'),
username: env('LANDING_USERNAME'),
password: env('LANDING_PASSWORD'),
previewToken: env('LANDING_PREVIEW_TOKEN'),
});
await createMcpServer({ spec: current.spec, tools: current.tools, client }).connect(new StdioServerTransport());
console.error(`landing-mcp: stdio against ${current.apiBase}${client.canAuthenticate() ? '' : ' (public API only: no credentials)'}`);
}

78
mcp/src/catalogue.mjs Normal file
View File

@@ -0,0 +1,78 @@
/**
* The OpenAPI document and the tools derived from it.
*
* Loaded from the application the server talks to, and — for a long-running
* deployment — reloaded now and then, so that deploying a new endpoint to the
* application makes it a tool here without also redeploying this.
*/
import { readFile } from 'node:fs/promises';
import { parse } from 'yaml';
import { buildTools } from './tools.mjs';
export class Catalogue {
/**
* @param {object} options
* @param {string} options.source Path or URL of openapi.yaml.
* @param {string} options.baseUrl Application root the document's server URL is relative to.
* @param {number} [options.ttlMs] How long a loaded document is trusted. Infinity: load once.
*/
constructor({ source, baseUrl, ttlMs = Infinity }) {
this.source = source;
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.ttlMs = ttlMs;
this.current = null;
this.loadedAt = 0;
this.pending = null;
}
/** @returns {Promise<{spec: object, tools: import('./tools.mjs').Tool[], apiBase: string}>} */
async get() {
if (this.current && Date.now() - this.loadedAt < this.ttlMs) return this.current;
this.pending ??= this.load().finally(() => { this.pending = null; });
try {
return await this.pending;
} catch (error) {
// An application that is redeploying, or briefly down, should not take
// the tools with it: the last document that loaded is still the best
// description available. Only the very first load has nothing to fall
// back on.
if (this.current) {
console.error(`landing-mcp: keeping the previous OpenAPI document: ${error.message}`);
this.loadedAt = Date.now();
return this.current;
}
throw error;
}
}
async load() {
const spec = parse(await this.read());
// The document's own server URL ("/api"), relative to the application root.
const serverUrl = spec.servers?.[0]?.url ?? '/api';
this.current = {
spec,
tools: buildTools(spec),
apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`,
};
this.loadedAt = Date.now();
return this.current;
}
async read() {
if (!/^https?:\/\//.test(this.source)) return readFile(this.source, 'utf8');
const response = await fetch(this.source, { signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error(`GET ${this.source} answered ${response.status}`);
return response.text();
}
}

198
mcp/src/client.mjs Normal file
View File

@@ -0,0 +1,198 @@
/**
* Calls the API on the model's behalf, and keeps it signed in.
*
* Tokens come from the Pireon identity server through POST /api/v1/auth/token
* and expire, so a fixed bearer token in the MCP config would die mid-session.
* With credentials instead, this client logs in on the first authenticated
* call, refreshes before expiry, and retries once on a 401 — a token revoked or
* expired upstream costs one extra round trip, not a broken session.
*/
// Refresh this long before the stated expiry, so a token never expires between
// the check and the request.
const EXPIRY_MARGIN_MS = 60_000;
const TIMEOUT_MS = 30_000;
export class LandingClient {
/**
* @param {object} options
* @param {string} options.apiBase e.g. https://admin.example.com/api
* @param {string} [options.token] A fixed bearer token.
* @param {string} [options.username]
* @param {string} [options.password]
* @param {string} [options.previewToken] Sent on public calls to see drafts.
* @param {typeof fetch} [options.fetch]
*/
constructor({ apiBase, token, username, password, previewToken, fetch = globalThis.fetch }) {
this.apiBase = apiBase.replace(/\/+$/, '');
this.fixedToken = token || null;
this.credentials = username && password ? { username, password } : null;
this.previewToken = previewToken || null;
this.fetch = fetch;
/** @type {{access: string, refresh: string|null, expiresAt: number|null}|null} */
this.session = null;
this.pending = null;
}
canAuthenticate() {
return Boolean(this.fixedToken || this.credentials);
}
/**
* Proves the credentials or token work, by spending them on GET /v1/me.
* Throws for credentials the application refused; resolves the response
* otherwise, for the caller to judge its status.
*/
async verify() {
return this.call({ method: 'GET', path: '/v1/me', params: [], hasBody: false, requiresAuth: true });
}
/**
* @param {import('./tools.mjs').Operation} operation
* @param {Record<string, unknown>} args
* @returns {Promise<{status: number, body: unknown}>}
*/
async call(operation, args = {}) {
const request = this.buildRequest(operation, args);
if (!operation.requiresAuth) {
if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken;
return this.send(request);
}
request.headers.Authorization = `Bearer ${await this.accessToken()}`;
let response = await this.send(request);
// A fixed token has nothing to renew itself with, so its 401 stands.
if (response.status === 401 && !this.fixedToken) {
this.session = { ...this.session, access: null };
request.headers.Authorization = `Bearer ${await this.accessToken()}`;
response = await this.send(request);
}
return response;
}
buildRequest(operation, args) {
let path = operation.path;
const query = new URLSearchParams();
for (const { name, in: location } of operation.params) {
const value = args[name];
if (location === 'path') {
if (value === undefined || value === null || value === '') {
throw new Error(`Missing path parameter "${name}"`);
}
path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
} else if (value !== undefined && value !== null) {
// Laravel's boolean() and the `boolean` rule both accept 1/0; only the
// former accepts "true".
query.set(name, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
}
}
const headers = { Accept: 'application/json' };
let body;
if (operation.hasBody && args.body !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(args.body);
}
const qs = query.toString();
return {
url: `${this.apiBase}${path}${qs ? `?${qs}` : ''}`,
method: operation.method,
headers,
body,
};
}
async send({ url, method, headers, body }) {
const response = await this.fetch(url, {
method,
headers,
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const text = await response.text();
let parsed = text;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
// Not JSON (an HTML error page from a proxy, say): hand it over as text.
}
return { status: response.status, body: parsed };
}
async accessToken() {
if (this.fixedToken) return this.fixedToken;
if (!this.credentials) {
throw new Error('This endpoint needs a token: set LANDING_TOKEN, or LANDING_USERNAME and LANDING_PASSWORD.');
}
const session = this.session;
const fresh = session?.access
&& (session.expiresAt === null || session.expiresAt - Date.now() > EXPIRY_MARGIN_MS);
if (fresh) return session.access;
// Parallel tool calls would otherwise each log in, and the token endpoint
// allows six requests a minute.
this.pending ??= this.renew().finally(() => { this.pending = null; });
return (await this.pending).access;
}
async renew() {
if (this.session?.refresh) {
try {
return await this.exchange({ grant_type: 'refresh_token', refresh_token: this.session.refresh });
} catch {
// Refresh tokens expire and get revoked too; the password still works.
}
}
return this.exchange({ grant_type: 'password', ...this.credentials });
}
async exchange(grant) {
const { status, body } = await this.send({
url: `${this.apiBase}/v1/auth/token`,
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(grant),
});
if (status !== 200 || !body?.access_token) {
const reason = body?.message ?? (typeof body === 'string' ? body.slice(0, 200) : `HTTP ${status}`);
const error = new Error(`Could not obtain a token (${grant.grant_type} grant): ${reason}`);
// 401/403/422 are the application's answer about these credentials;
// anything else says nothing about them. The HTTP transport needs to
// tell the two apart.
error.status = status;
throw error;
}
this.session = {
access: body.access_token,
refresh: body.refresh_token ?? null,
expiresAt: body.expires_at ? Date.parse(body.expires_at) : null,
};
return this.session;
}
}

288
mcp/src/http.mjs Normal file
View File

@@ -0,0 +1,288 @@
/**
* The MCP server over Streamable HTTP, for deploying next to the application.
*
* Stateless: every POST builds a fresh MCP server around the caller's client,
* so nothing about one request survives into the next except the sign-in
* below, and any number of replicas can sit behind the same domain.
*
* Nobody signs in to this server as such. Each request carries its caller's
* own credentials, and the API applies that person's permissions exactly as the
* panel would:
*
* Authorization: Basic base64(email:password) Pireon credentials. Signed
* in once, then kept fresh.
* Authorization: Bearer <token> A Pireon access token.
* (none) Public API only, unless
* LANDING_MCP_REQUIRE_AUTH.
*
* Credentials never reach a log, and are held only in memory, keyed by a hash,
* for as long as they are in use.
*/
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { LandingClient } from './client.mjs';
import { createMcpServer } from './mcp.mjs';
const MAX_BODY_BYTES = 1_000_000;
// A refused credential is refused again without asking the application, for
// this long. A client retrying in a loop with a wrong password would otherwise
// spend the account's sign-in budget for its owner too.
const REJECTION_TTL_MS = 60_000;
// A bearer token is re-checked this often, matching the application's own
// introspection cache: past that, a token revoked upstream stops working here
// at the same moment it stops working there.
const BEARER_TTL_MS = 60_000;
// A signed-in session nobody has used for this long is forgotten, password
// included.
const IDLE_MS = 12 * 60 * 60 * 1000;
const MAX_SESSIONS = 500;
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
/**
* @param {object} options
* @param {import('./catalogue.mjs').Catalogue} options.catalogue
* @param {number} options.port
* @param {string} options.host
* @param {string} [options.previewToken] Only ever sent for signed-in callers.
* @param {boolean} [options.requireAuth]
*/
export function startHttpServer({ catalogue, port, host, previewToken, requireAuth = false }) {
const sessions = new Sessions({ previewToken, requireAuth });
const server = createServer((req, res) => {
handle(req, res, { catalogue, sessions }).catch((error) => {
console.error(`landing-mcp: ${error.stack ?? error.message}`);
if (!res.headersSent) sendError(res, 500, 'Internal error');
});
});
server.listen(port, host, () => {
console.error(`landing-mcp: listening on http://${host}:${port}/mcp${requireAuth ? '' : ' (anonymous callers get the public API)'}`);
});
// PID 1 in a container: without this, `docker stop` waits out its timeout
// and then kills whatever request was in flight.
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => server.close(() => process.exit(0)));
}
return server;
}
async function handle(req, res, { catalogue, sessions }) {
const { pathname } = new URL(req.url, 'http://localhost');
// Liveness only, like the application's /up: it must not fail because the
// application is redeploying, or both would restart each other.
if (pathname === '/up') return send(res, 200, { status: 'ok' });
if (pathname !== '/mcp') return sendError(res, 404, 'Not found');
// Stateless: there is no session to open a stream on or to delete.
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return sendError(res, 405, 'This server is stateless and only accepts POST');
}
let current;
try {
current = await catalogue.get();
} catch (error) {
return sendError(res, 503, `The API description is not available: ${error.message}`);
}
let client;
try {
client = await sessions.authenticate(req.headers.authorization, current.apiBase);
} catch (error) {
if (!(error instanceof HttpError)) throw error;
if (error.status === 401) res.setHeader('WWW-Authenticate', 'Basic realm="landing-mcp", charset="UTF-8"');
return sendError(res, error.status, error.message);
}
let body;
try {
body = await readJson(req);
} catch (error) {
return sendError(res, error.status ?? 400, error.message);
}
const mcp = createMcpServer({ spec: current.spec, tools: current.tools, client });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
res.on('close', () => {
transport.close();
mcp.close();
});
await mcp.connect(transport);
await transport.handleRequest(req, res, body);
}
class Sessions {
constructor({ previewToken, requireAuth }) {
this.previewToken = previewToken;
this.requireAuth = requireAuth;
/** @type {Map<string, {client: Promise<LandingClient>, lastUsed: number, expiresAt: number}>} */
this.entries = new Map();
/** @type {Map<string, number>} */
this.rejected = new Map();
this.anonymous = null;
}
/** @returns {Promise<LandingClient>} */
async authenticate(header, apiBase) {
if (!header) {
if (this.requireAuth) throw new HttpError(401, 'Authentication required: send Basic credentials or a Bearer token');
// No preview secret: that would hand drafts to anyone who can reach this.
this.anonymous ??= new LandingClient({ apiBase });
return this.anonymous;
}
const credentials = parseAuthorization(header);
if (!credentials) throw new HttpError(401, 'Unsupported Authorization header: use Basic or Bearer');
const now = Date.now();
const key = createHash('sha256').update(header).digest('hex');
this.sweep(now);
if ((this.rejected.get(key) ?? 0) > now) throw new HttpError(401, 'Credentials refused by the application');
const entry = this.entries.get(key);
if (entry && entry.expiresAt > now) {
entry.lastUsed = now;
return entry.client;
}
// The promise, not the client, so concurrent first requests with the same
// credentials sign in once between them.
const client = this.open(credentials, apiBase);
this.entries.set(key, {
client,
lastUsed: now,
expiresAt: credentials.token ? now + BEARER_TTL_MS : Infinity,
});
try {
return await client;
} catch (error) {
this.entries.delete(key);
if (error instanceof HttpError && error.status === 401) this.rejected.set(key, Date.now() + REJECTION_TTL_MS);
throw error;
}
}
async open(credentials, apiBase) {
const client = new LandingClient({ apiBase, ...credentials, previewToken: this.previewToken });
let response;
try {
response = await client.verify();
} catch (error) {
if ([401, 403, 422].includes(error.status)) throw new HttpError(401, 'Credentials refused by the application');
if (error.status === 429) throw new HttpError(429, 'Too many sign-in attempts for this account; wait a minute');
throw new HttpError(502, `Could not reach the application: ${error.message}`);
}
if (response.status === 401 || response.status === 403) {
throw new HttpError(401, 'Token refused by the application');
}
if (response.status !== 200) throw new HttpError(502, `The application answered ${response.status} to GET /v1/me`);
return client;
}
sweep(now) {
for (const [key, until] of this.rejected) {
if (until <= now) this.rejected.delete(key);
}
for (const [key, entry] of this.entries) {
if (now - entry.lastUsed > IDLE_MS || entry.expiresAt <= now) this.entries.delete(key);
}
// Insertion order is sign-in order: the oldest goes first.
while (this.entries.size >= MAX_SESSIONS) this.entries.delete(this.entries.keys().next().value);
}
}
/** @returns {{username: string, password: string}|{token: string}|null} */
export function parseAuthorization(header) {
const match = /^(\w+)\s+(\S+)\s*$/.exec(header);
if (!match) return null;
const [, scheme, value] = match;
if (scheme.toLowerCase() === 'bearer') return { token: value };
if (scheme.toLowerCase() === 'basic') {
const decoded = Buffer.from(value, 'base64').toString('utf8');
const colon = decoded.indexOf(':');
if (colon < 1 || colon === decoded.length - 1) return null;
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
}
return null;
}
async function readJson(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'Request body too large');
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw new HttpError(400, 'Request body is not valid JSON');
}
}
function send(res, status, payload) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
// Shaped as a JSON-RPC error, which is what an MCP client knows how to show.
function sendError(res, status, message) {
send(res, status, { jsonrpc: '2.0', error: { code: -32000, message }, id: null });
}

52
mcp/src/mcp.mjs Normal file
View File

@@ -0,0 +1,52 @@
/**
* The MCP server itself, independent of the transport it is reached through.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
/**
* @param {object} options
* @param {object} options.spec
* @param {import('./tools.mjs').Tool[]} options.tools
* @param {import('./client.mjs').LandingClient} options.client
*/
export function createMcpServer({ spec, tools, client }) {
// Offering tools that can only fail would cost the model a round trip each
// to learn that; without credentials the admin half is simply not there.
const offered = tools.filter((tool) => !tool.operation.requiresAuth || client.canAuthenticate());
const byName = new Map(offered.map((tool) => [tool.name, tool]));
const server = new Server(
{ name: 'landing', version: spec.info?.version ?? '1.0.0' },
{
capabilities: { tools: {} },
// The document's introduction already explains the two APIs, auth and
// how translatable fields are read and written.
instructions: spec.info?.description,
},
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: offered.map(({ operation, ...tool }) => tool),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = byName.get(request.params.name);
if (!tool) {
return { isError: true, content: [{ type: 'text', text: `Unknown tool ${request.params.name}` }] };
}
try {
const { status, body } = await client.call(tool.operation, request.params.arguments ?? {});
const payload = body === null || body === '' ? '' : `\n${typeof body === 'string' ? body : JSON.stringify(body)}`;
return { isError: status >= 400, content: [{ type: 'text', text: `HTTP ${status}${payload}` }] };
} catch (error) {
return { isError: true, content: [{ type: 'text', text: error.message }] };
}
});
return server;
}

303
mcp/src/tools.mjs Normal file
View File

@@ -0,0 +1,303 @@
/**
* Turns the API's OpenAPI document into MCP tools, one per operation.
*
* The tools are derived rather than written by hand for the same reason the
* TypeScript types are: `openapi.yaml` is held to the truth by the contract
* tests, and nothing would hold a second, hand-written description of the API
* to anything. A new endpoint documented there is a new tool here with no
* change to this file.
*/
// Endpoints that exist for someone other than an operator. Telemetry is the
// frontend reporting back with its own secret; the submissions are a visitor's
// forms, and a call to one puts a row in the inbox and notifies real people.
const EXCLUDED_TAGS = new Set(['Telemetry', 'Submissions']);
// Obtaining a token is the client's job, not the model's.
const EXCLUDED_OPERATIONS = new Set(['POST /v1/auth/token']);
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
// OpenAPI keywords that either mean nothing to JSON Schema or only describe
// responses. `nullable` is translated rather than dropped.
const DROPPED_KEYWORDS = new Set([
'nullable', 'example', 'readOnly', 'writeOnly', 'discriminator', 'xml', 'externalDocs',
]);
/**
* @typedef {object} Operation
* @property {string} method Upper-case HTTP method.
* @property {string} path Path as documented, e.g. /v1/pages/{page}.
* @property {{name: string, in: string}[]} params
* @property {boolean} hasBody
* @property {boolean} requiresAuth
*
* @typedef {object} Tool
* @property {string} name
* @property {string} description
* @property {object} inputSchema
* @property {object} annotations
* @property {Operation} operation Kept server-side; never sent to the client.
*/
/**
* @param {object} spec The parsed OpenAPI document.
* @returns {Tool[]}
*/
export function buildTools(spec) {
const paths = Object.keys(spec.paths ?? {});
const tools = [];
for (const path of paths) {
const item = spec.paths[path];
for (const method of METHODS) {
const op = item[method];
if (!op) continue;
if (EXCLUDED_OPERATIONS.has(`${method.toUpperCase()} ${path}`)) continue;
if ((op.tags ?? []).some((tag) => EXCLUDED_TAGS.has(tag))) continue;
tools.push(buildTool(spec, paths, path, method, item, op));
}
}
// A clash would silently shadow one endpoint behind another, so it is a
// startup failure rather than something to discover by calling the wrong one.
const seen = new Map();
for (const tool of tools) {
const { method, path } = tool.operation;
if (seen.has(tool.name)) {
throw new Error(`Tool name "${tool.name}" is derived from both ${seen.get(tool.name)} and ${method} ${path}`);
}
seen.set(tool.name, `${method} ${path}`);
}
return tools;
}
function buildTool(spec, paths, path, method, item, op) {
const params = mergeParameters(spec, item.parameters, op.parameters)
// Headers are credentials here (preview and ingest secrets), which come
// from the server's configuration, never from the model.
.filter((param) => param.in === 'path' || param.in === 'query');
const properties = {};
const required = [];
for (const param of params) {
const schema = toJsonSchema(spec, param.schema ?? { type: 'string' });
properties[param.name] = param.description
? { ...schema, description: param.description.trim() }
: schema;
if (param.required || param.in === 'path') required.push(param.name);
}
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
if (bodySchema) {
if ('body' in properties) {
throw new Error(`${method.toUpperCase()} ${path} has a parameter named "body", which the request body needs`);
}
const body = toJsonSchema(spec, bodySchema);
// Create and update share one write schema, so its `required` describes
// creation. On a partial update it would force the model to resend fields
// it has no reason to touch.
if (method === 'patch') delete body.required;
properties.body = { ...body, description: 'JSON request body.' };
if (op.requestBody.required) required.push('body');
}
const security = op.security ?? spec.security ?? [];
return {
name: toolName(paths, path, method),
description: describe(path, method, op, security.length > 0),
inputSchema: {
type: 'object',
properties,
...(required.length ? { required } : {}),
additionalProperties: false,
},
annotations: {
title: op.summary,
readOnlyHint: method === 'get',
destructiveHint: method === 'delete',
idempotentHint: method === 'get' || method === 'put' || method === 'delete',
},
operation: {
method: method.toUpperCase(),
path,
params: params.map(({ name, in: location }) => ({ name, in: location })),
hasBody: Boolean(bodySchema),
requiresAuth: security.length > 0,
},
};
}
/**
* Named after the Laravel route the operation lands on, so a tool reads the
* same as `routes/api.php`: /v1/pages/{page} under PATCH is admin_pages_update,
* /public/v1/projects/{slug} under GET is public_projects_show.
*/
export function toolName(paths, path, method) {
const segments = path.split('/').filter(Boolean).filter((s) => s !== 'v1');
const scope = segments[0] === 'public' ? 'public' : 'admin';
if (segments[0] === 'public') segments.shift();
const isParam = (segment) => segment.startsWith('{');
const endsInParam = isParam(segments.at(-1));
const base = segments.filter((s) => !isParam(s)).map((s) => s.replaceAll('-', '_'));
let action = null;
if (endsInParam) {
action = { get: 'show', put: 'update', patch: 'update', delete: 'destroy' }[method];
} else if (method === 'get') {
// A collection has an item route next to it; /site or /radar does not,
// and is named after itself alone.
const hasItemRoute = paths.some((p) => p.startsWith(`${path}/{`));
action = hasItemRoute ? 'index' : null;
} else if (method === 'post') {
// /pages/{page}/restore is an action named by its last segment already.
action = segments.length > 1 && isParam(segments.at(-2)) ? null : 'store';
} else {
action = method;
}
return [scope, ...base, ...(action ? [action] : [])].join('_');
}
function describe(path, method, op, requiresAuth) {
const parts = [op.summary, op.description].filter(Boolean).map((s) => s.trim());
parts.push(`${method.toUpperCase()} /api${path} · ${requiresAuth ? 'authenticated' : 'public, no token'}`);
return parts.join('\n\n');
}
/**
* Path-level parameters apply to every operation under the path; an operation
* may redeclare one, and its own declaration wins.
*/
function mergeParameters(spec, pathLevel = [], operationLevel = []) {
const merged = new Map();
for (const raw of [...pathLevel, ...operationLevel]) {
const param = resolve(spec, raw);
merged.set(`${param.in}:${param.name}`, param);
}
return [...merged.values()];
}
function resolve(spec, node) {
let current = node;
const visited = new Set();
while (current && typeof current.$ref === 'string') {
if (visited.has(current.$ref)) throw new Error(`Circular $ref ${current.$ref}`);
visited.add(current.$ref);
current = lookup(spec, current.$ref);
}
return current;
}
function lookup(spec, ref) {
if (!ref.startsWith('#/')) throw new Error(`Only local $refs are supported, got ${ref}`);
const target = ref
.slice(2)
.split('/')
.map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
.reduce((node, key) => node?.[key], spec);
if (target === undefined) throw new Error(`Unresolvable $ref ${ref}`);
return target;
}
/**
* OpenAPI 3.0 schema → self-contained JSON Schema: references inlined (MCP
* clients receive each tool's schema on its own, with no `components` to point
* into) and `nullable` spelled the way JSON Schema spells it.
*/
export function toJsonSchema(spec, schema, stack = []) {
if (schema === null || typeof schema !== 'object') return schema;
if (typeof schema.$ref === 'string') {
// A recursive schema cannot be inlined forever; past the first repeat the
// model gets "anything" and the server's validation has the last word.
if (stack.includes(schema.$ref)) return {};
const { $ref, nullable: isNullable, ...siblings } = schema;
return nullable(isNullable, {
...toJsonSchema(spec, lookup(spec, $ref), [...stack, $ref]),
...toJsonSchema(spec, siblings, stack),
});
}
// `allOf: [{$ref}]` next to a description is how this document annotates a
// shared schema in place. Flattened, the model reads one schema, not a
// composition of one.
if (Array.isArray(schema.allOf) && schema.allOf.length === 1) {
const { allOf, nullable: isNullable, ...siblings } = schema;
return nullable(isNullable, {
...toJsonSchema(spec, allOf[0], stack),
...toJsonSchema(spec, siblings, stack),
});
}
const out = {};
for (const [key, value] of Object.entries(schema)) {
if (DROPPED_KEYWORDS.has(key)) continue;
if (key === 'properties') {
out.properties = Object.fromEntries(
Object.entries(value)
// Response-only fields have no business in something the model writes.
.filter(([, property]) => !resolve(spec, property)?.readOnly)
.map(([name, property]) => [name, toJsonSchema(spec, property, stack)]),
);
} else if (['allOf', 'oneOf', 'anyOf'].includes(key)) {
out[key] = value.map((member) => toJsonSchema(spec, member, stack));
} else if (['items', 'additionalProperties', 'not'].includes(key) && typeof value === 'object') {
out[key] = toJsonSchema(spec, value, stack);
} else {
out[key] = value;
}
}
return nullable(schema.nullable, out);
}
function nullable(isNullable, schema) {
if (!isNullable) return schema;
if (typeof schema.type === 'string') {
const out = { ...schema, type: [schema.type, 'null'] };
if (Array.isArray(out.enum) && !out.enum.includes(null)) out.enum = [...out.enum, null];
return out;
}
const { description, ...rest } = schema;
return { ...(description ? { description } : {}), anyOf: [rest, { type: 'null' }] };
}

151
mcp/test/client.test.mjs Normal file
View File

@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { LandingClient } from '../src/client.mjs';
const showPage = {
method: 'GET',
path: '/v1/pages/{page}',
params: [{ name: 'page', in: 'path' }, { name: 'lang', in: 'query' }],
hasBody: false,
requiresAuth: true,
};
const publicProjects = {
method: 'GET',
path: '/public/v1/projects',
params: [{ name: 'featured', in: 'query' }],
hasBody: false,
requiresAuth: false,
};
/** A fake fetch that answers from a list of handlers and records every call. */
function fakeFetch(handler) {
const calls = [];
const fetch = async (url, init) => {
calls.push({ url, ...init });
const { status = 200, body = {} } = handler(url, init, calls.length);
return new Response(status === 204 ? null : JSON.stringify(body), { status });
};
return { fetch, calls };
}
const tokenResponse = (access, expiresInMs = 3_600_000) => ({
access_token: access,
refresh_token: `refresh-${access}`,
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
});
test('builds the URL from path and query parameters', async () => {
const { fetch, calls } = fakeFetch(() => ({ body: { data: [] } }));
const client = new LandingClient({ apiBase: 'http://app.test/api/', fetch });
await client.call(publicProjects, { featured: true });
assert.equal(calls[0].url, 'http://app.test/api/public/v1/projects?featured=1');
assert.equal(calls[0].headers.Authorization, undefined);
});
test('sends the preview secret on public calls only', async () => {
const { fetch, calls } = fakeFetch((url) => (
url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: {} }
));
const client = new LandingClient({
apiBase: 'http://app.test/api', username: 'u', password: 'p', previewToken: 'secret', fetch,
});
await client.call(publicProjects, {});
await client.call(showPage, { page: 1 });
assert.equal(calls[0].headers['X-Preview-Token'], 'secret');
assert.equal(calls.at(-1).headers['X-Preview-Token'], undefined);
});
test('logs in once and reuses the token', async () => {
const { fetch, calls } = fakeFetch((url) => (
url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: { data: {} } }
));
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
await Promise.all([client.call(showPage, { page: 1 }), client.call(showPage, { page: 2 })]);
await client.call(showPage, { page: 3 });
assert.equal(calls.filter((c) => c.url.endsWith('/auth/token')).length, 1);
assert.equal(calls.at(-1).url, 'http://app.test/api/v1/pages/3');
assert.equal(calls.at(-1).headers.Authorization, 'Bearer a');
});
test('refreshes a token about to expire', async () => {
let issued = 0;
const { fetch, calls } = fakeFetch((url) => (
url.endsWith('/auth/token') ? { body: tokenResponse(`t${++issued}`, 10_000) } : { body: {} }
));
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
await client.call(showPage, { page: 1 });
await client.call(showPage, { page: 1 });
const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type);
assert.deepEqual(grants, ['password', 'refresh_token']);
assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2');
});
test('retries once with a new token after a 401', async () => {
let issued = 0;
const { fetch, calls } = fakeFetch((url, init) => {
if (url.endsWith('/auth/token')) return { body: tokenResponse(`t${++issued}`) };
return init.headers.Authorization === 'Bearer t1' ? { status: 401, body: { message: 'Unauthenticated.' } } : { body: { ok: true } };
});
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
const response = await client.call(showPage, { page: 1 });
assert.equal(response.status, 200);
assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2');
});
test('falls back to the password when the refresh token is refused', async () => {
let issued = 0;
const { fetch, calls } = fakeFetch((url, init) => {
if (!url.endsWith('/auth/token')) return { body: {} };
return JSON.parse(init.body).grant_type === 'refresh_token'
? { status: 422, body: { message: 'invalid' } }
: { body: tokenResponse(`t${++issued}`, 10_000) };
});
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
await client.call(showPage, { page: 1 });
await client.call(showPage, { page: 1 });
const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type);
assert.deepEqual(grants, ['password', 'refresh_token', 'password']);
});
test('a fixed token is used as is and never renewed', async () => {
const { fetch, calls } = fakeFetch(() => ({ status: 401, body: {} }));
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 'fixed', fetch });
const response = await client.call(showPage, { page: 1 });
assert.equal(response.status, 401);
assert.equal(calls.length, 1);
assert.equal(calls[0].headers.Authorization, 'Bearer fixed');
});
test('refuses an authenticated call without credentials', async () => {
const client = new LandingClient({ apiBase: 'http://app.test/api', fetch: async () => assert.fail('no request') });
await assert.rejects(client.call(showPage, { page: 1 }), /LANDING_TOKEN/);
});
test('refuses a call missing a path parameter', async () => {
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch: async () => assert.fail('no request') });
await assert.rejects(client.call(showPage, {}), /"page"/);
});

155
mcp/test/http.test.mjs Normal file
View File

@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { after, before, test } from 'node:test';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Catalogue } from '../src/catalogue.mjs';
import { parseAuthorization, startHttpServer } from '../src/http.mjs';
const spec = await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8');
/**
* Stands in for the application: the spec, the token endpoint, /v1/me and one
* public endpoint that reports whether it was sent the preview secret.
*/
const app = { logins: 0, requests: [] };
const appServer = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
app.requests.push({ url: req.url, headers: req.headers });
const json = (status, payload) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
if (req.url === '/openapi.yaml') return res.end(spec);
if (req.url === '/api/v1/auth/token') {
app.logins++;
return body.username === 'ada@example.com' && body.password === 'secret'
? json(200, { access_token: 'ada-token', refresh_token: 'r', expires_at: new Date(Date.now() + 3_600_000).toISOString() })
: json(422, { message: 'These credentials do not match our records.' });
}
if (req.url.startsWith('/api/v1/')) {
return ['Bearer ada-token', 'Bearer valid-token'].includes(req.headers.authorization)
? json(200, { data: { email: 'ada@example.com' } })
: json(401, { message: 'Unauthenticated.' });
}
if (req.url.startsWith('/api/public/v1/site')) {
return json(200, { data: { preview: req.headers['x-preview-token'] === 'drafts' } });
}
json(404, { message: 'Not found' });
});
let mcpUrl;
let mcpServer;
before(async () => {
appServer.listen(0, '127.0.0.1');
await once(appServer, 'listening');
const appUrl = `http://127.0.0.1:${appServer.address().port}`;
mcpServer = startHttpServer({
catalogue: new Catalogue({ source: `${appUrl}/openapi.yaml`, baseUrl: appUrl }),
port: 0,
host: '127.0.0.1',
previewToken: 'drafts',
});
await once(mcpServer, 'listening');
mcpUrl = new URL(`http://127.0.0.1:${mcpServer.address().port}/mcp`);
});
after(() => {
mcpServer.close();
appServer.close();
});
async function connect(authorization) {
const client = new Client({ name: 'test', version: '0' });
const headers = authorization ? { Authorization: authorization } : {};
await client.connect(new StreamableHTTPClientTransport(mcpUrl, { requestInit: { headers } }));
return client;
}
const basic = (user, password) => `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
test('an anonymous caller gets the public API, without the preview secret', async () => {
const client = await connect();
const { tools } = await client.listTools();
assert.ok(tools.length > 0);
assert.ok(tools.every((tool) => tool.name.startsWith('public_')));
const result = await client.callTool({ name: 'public_site', arguments: {} });
assert.match(result.content[0].text, /"preview":false/);
await client.close();
});
test('Basic credentials get both APIs, and sign in once across requests', async () => {
const before = app.logins;
for (let i = 0; i < 3; i++) {
const client = await connect(basic('ada@example.com', 'secret'));
const { tools } = await client.listTools();
assert.ok(tools.some((tool) => tool.name === 'admin_pages_update'));
const me = await client.callTool({ name: 'admin_me', arguments: {} });
assert.equal(me.isError, false);
const site = await client.callTool({ name: 'public_site', arguments: {} });
assert.match(site.content[0].text, /"preview":true/);
await client.close();
}
assert.equal(app.logins - before, 1);
});
test('wrong credentials are a 401, remembered rather than retried', async () => {
const before = app.logins;
for (let i = 0; i < 2; i++) {
await assert.rejects(connect(basic('ada@example.com', 'wrong')), /401|Credentials refused/);
}
assert.equal(app.logins - before, 1);
});
test('a bearer token is checked against the application', async () => {
const good = await connect('Bearer valid-token');
assert.ok((await good.listTools()).tools.some((tool) => tool.name === 'admin_me'));
await good.close();
await assert.rejects(connect('Bearer revoked-token'), /401|refused/);
});
test('only POST /mcp is served, and /up answers without the application', async () => {
const base = `http://127.0.0.1:${mcpServer.address().port}`;
assert.equal((await fetch(`${base}/up`)).status, 200);
assert.equal((await fetch(`${base}/mcp`)).status, 405);
assert.equal((await fetch(`${base}/elsewhere`)).status, 404);
});
test('parses Authorization headers', () => {
assert.deepEqual(parseAuthorization(basic('a@b.c', 'p:with:colons')), { username: 'a@b.c', password: 'p:with:colons' });
assert.deepEqual(parseAuthorization('bearer abc'), { token: 'abc' });
assert.equal(parseAuthorization(basic('', 'p')), null);
assert.equal(parseAuthorization('Digest abc'), null);
});

81
mcp/test/tools.test.mjs Normal file
View File

@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { parse } from 'yaml';
import { buildTools } from '../src/tools.mjs';
// The document in this checkout: the one the application serves.
const spec = parse(await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8'));
const tools = buildTools(spec);
const byName = new Map(tools.map((tool) => [tool.name, tool]));
test('names follow the Laravel routes', () => {
const expected = {
public_site: 'GET /public/v1/site',
public_good_first_issues: 'GET /public/v1/good-first-issues',
public_projects_index: 'GET /public/v1/projects',
public_projects_show: 'GET /public/v1/projects/{slug}',
public_clients: 'GET /public/v1/clients',
admin_me: 'GET /v1/me',
admin_pages_index: 'GET /v1/pages',
admin_pages_store: 'POST /v1/pages',
admin_pages_update: 'PATCH /v1/pages/{page}',
admin_pages_destroy: 'DELETE /v1/pages/{page}',
admin_pages_restore: 'POST /v1/pages/{page}/restore',
admin_roles_catalogue: 'GET /v1/roles/catalogue',
};
for (const [name, route] of Object.entries(expected)) {
const tool = byName.get(name);
assert.ok(tool, `missing ${name}`);
assert.equal(`${tool.operation.method} ${tool.operation.path}`, route);
}
});
test('names are valid MCP tool names', () => {
for (const { name } of tools) assert.match(name, /^[a-z0-9_]{1,64}$/);
});
test('leaves out what is not for an operator', () => {
const routes = tools.map(({ operation }) => `${operation.method} ${operation.path}`);
assert.ok(!routes.includes('POST /v1/auth/token'));
assert.ok(!routes.some((route) => route.includes('/telemetry/')));
assert.ok(!routes.includes('POST /public/v1/contact'));
assert.ok(!routes.includes('POST /public/v1/calls/{slug}/apply'));
});
test('every other documented operation is a tool', () => {
const documented = Object.values(spec.paths)
.flatMap((item) => ['get', 'post', 'put', 'patch', 'delete'].filter((m) => item[m])).length;
// auth/token, two telemetry endpoints, contact and apply.
assert.equal(tools.length, documented - 5);
});
test('schemas are self-contained JSON Schema', () => {
for (const tool of tools) {
const json = JSON.stringify(tool.inputSchema);
assert.ok(!json.includes('"$ref"'), `${tool.name} still has a $ref`);
assert.ok(!json.includes('"nullable"'), `${tool.name} still has nullable`);
}
});
test('path parameters are required, headers are not offered', () => {
const show = byName.get('public_projects_show');
assert.deepEqual(show.inputSchema.required, ['slug']);
assert.ok(!('X-Preview-Token' in show.inputSchema.properties));
});
test('a create requires its fields, an update does not', () => {
assert.deepEqual(byName.get('admin_pages_store').inputSchema.properties.body.required, ['title', 'slug']);
assert.equal(byName.get('admin_pages_update').inputSchema.properties.body.required, undefined);
});
test('only the admin API is marked as needing a token', () => {
for (const tool of tools) {
assert.equal(tool.operation.requiresAuth, tool.name.startsWith('admin_'), tool.name);
}
});

View File

@@ -909,8 +909,9 @@ paths:
description: |
Proxies the identity server's `password` and `refresh_token` grants so
first-party clients need not embed the OAuth server's URL or secret.
Rate limited to 6 requests per minute. Browser clients should use
Authorization Code + PKCE instead.
Rate limited to 6 requests per minute per account and address, and 30
per address overall. Browser clients should use Authorization Code +
PKCE instead.
security: []
requestBody:
required: true

View File

@@ -1,22 +0,0 @@
{{-- Rendered at the very top of every page in the panel, on every install.
The failure this exists for runs in one direction only: mistaking a real
deployment for a throwaway copy, not the other way round. Nobody edits a
local clone carefully because they think it is production — the mistake
that costs something is the reverse. So `local` stays silent, and
anything else says so, loudly in the one case that matters. --}}
@unless (app()->environment('local'))
<div
role="status"
@class([
'w-full py-1 text-center text-xs font-semibold tracking-wide text-white uppercase',
'bg-danger-600' => app()->environment('production'),
'bg-warning-600' => ! app()->environment('production'),
])
>
{{ app()->environment('production')
? __('panel.environment.production')
: __('panel.environment.other', ['name' => app()->environment()]) }}
</div>
@endunless

View File

@@ -152,7 +152,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
// Convenience endpoint for first-party clients: exchanges credentials for
// a token upstream so they do not need to know the identity server's URL.
Route::post('auth/token', [TokenController::class, 'store'])
->middleware('throttle:6,1')
->middleware('throttle:auth-token')
->name('auth.token');
Route::middleware(['auth:api', 'throttle:api'])->group(function () {

View File

@@ -103,6 +103,38 @@ class TokenEndpointTest extends TestCase
->assertJsonValidationErrors('grant_type');
}
#[Test]
public function one_account_exhausting_its_budget_does_not_lock_out_another_on_the_same_address(): void
{
// Everyone the MCP server signs in arrives from its one container.
// Registered first so it wins over fakePireon()'s token stub, which
// would otherwise accept every password.
Http::fake(["{$this->pireonServer}/oauth/token" => fn ($request) => $request['username'] === 'ada@example.com'
? Http::response(['access_token' => 'fake-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600])
: Http::response(['error' => 'invalid_grant'], 400)]);
$this->fakePireon();
foreach (range(1, 6) as $attempt) {
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'mallory@example.com',
'password' => "guess-{$attempt}",
])->assertStatus(422);
}
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'MALLORY@example.com',
'password' => 'guess-7',
])->assertTooManyRequests();
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'secret',
])->assertOk();
}
#[Test]
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
{

View File

@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The one banner that has to be right in the one direction that matters.
*
* Nobody edits a local clone carefully because they mistake it for
* production — the expensive mistake runs the other way. So the only
* environment that stays silent is `local`; every other one says so, and
* production says so the loudest.
*/
class EnvironmentBannerTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->actingAs($this->userWithRoles('admin'));
}
protected function setEnvironment(string $environment): void
{
$this->app['env'] = $environment;
}
#[Test]
public function nothing_is_shown_in_a_local_install(): void
{
$this->setEnvironment('local');
$this->get('/admin')
->assertOk()
->assertDontSee(__('panel.environment.production'))
->assertDontSeeText('bg-danger-600', escape: false);
}
#[Test]
public function production_says_so_in_the_loudest_colour(): void
{
$this->setEnvironment('production');
$this->get('/admin')
->assertOk()
->assertSee(__('panel.environment.production'));
}
#[Test]
public function anything_else_names_itself(): void
{
$this->setEnvironment('staging');
$this->get('/admin')
->assertOk()
->assertSee(__('panel.environment.other', ['name' => 'staging']));
}
}