Compare commits
2 Commits
5558db6d56
...
748b485c9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 748b485c9b | |||
| 5ca084bd4a |
@@ -20,3 +20,6 @@ database/*.sqlite-journal
|
|||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
docs/*.pdf
|
docs/*.pdf
|
||||||
public/storage
|
public/storage
|
||||||
|
|
||||||
|
# Runs on a developer machine, next to Claude Code, never in the image.
|
||||||
|
mcp
|
||||||
|
|||||||
@@ -123,3 +123,18 @@ MAIL_USERNAME=
|
|||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
MAIL_FROM_ADDRESS="admin@example.org"
|
MAIL_FROM_ADDRESS="admin@example.org"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
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=
|
||||||
|
|||||||
21
.github/workflows/tests.yml
vendored
21
.github/workflows/tests.yml
vendored
@@ -79,3 +79,24 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- run: docker compose -f docker-compose.test.yml run --rm test
|
- 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
1
.gitignore
vendored
@@ -27,3 +27,4 @@ _ide_helper.php
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/mcp/node_modules
|
||||||
|
|||||||
15
.mcp.json
Normal file
15
.mcp.json
Normal 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:-}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
README.md
96
README.md
@@ -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
|
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
||||||
prosa, y un tipo a secas no la conserva.
|
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
|
## Comandos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1458,7 +1551,7 @@ párrafo aparte sin relación programática con él.
|
|||||||
## Estructura
|
## Estructura
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml stack de producción (landing-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
|
docker-compose.test.yml la suite contra Postgres, desechable
|
||||||
Dockerfile imagen única para los tres contenedores PHP
|
Dockerfile imagen única para los tres contenedores PHP
|
||||||
docker/ Caddyfile, php.ini y entrypoint
|
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/media.php disco, anchos y formatos de imagen
|
||||||
config/git.php proveedores de git y etiquetas de issues
|
config/git.php proveedores de git y etiquetas de issues
|
||||||
public/openapi.yaml especificación de ambas APIs
|
public/openapi.yaml especificación de ambas APIs
|
||||||
|
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
|
||||||
```
|
```
|
||||||
|
|
||||||
## Despliegue con Docker
|
## Despliegue con Docker
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
||||||
->by($request->user()?->id ?: $request->ip()));
|
->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
|
// The public API is a different problem. The public frontend renders
|
||||||
// server-side, so every visitor's page view arrives from the same IP:
|
// 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
|
// a per-IP budget sized for one person would throttle the entire
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# Production stack.
|
# 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
|
# 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 one less thing to run, back up and forget to secure. Swapping it in later
|
||||||
# is three environment variables.
|
# is three environment variables.
|
||||||
@@ -210,6 +210,45 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
stop_grace_period: 30s
|
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:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
4
mcp/.dockerignore
Normal file
4
mcp/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
test
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
26
mcp/Dockerfile
Normal file
26
mcp/Dockerfile
Normal 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
1222
mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
mcp/package.json
Normal file
20
mcp/package.json
Normal 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
94
mcp/server.mjs
Normal 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
78
mcp/src/catalogue.mjs
Normal 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
198
mcp/src/client.mjs
Normal 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
288
mcp/src/http.mjs
Normal 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
52
mcp/src/mcp.mjs
Normal 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
303
mcp/src/tools.mjs
Normal 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
151
mcp/test/client.test.mjs
Normal 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
155
mcp/test/http.test.mjs
Normal 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
81
mcp/test/tools.test.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -909,8 +909,9 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Proxies the identity server's `password` and `refresh_token` grants so
|
Proxies the identity server's `password` and `refresh_token` grants so
|
||||||
first-party clients need not embed the OAuth server's URL or secret.
|
first-party clients need not embed the OAuth server's URL or secret.
|
||||||
Rate limited to 6 requests per minute. Browser clients should use
|
Rate limited to 6 requests per minute per account and address, and 30
|
||||||
Authorization Code + PKCE instead.
|
per address overall. Browser clients should use Authorization Code +
|
||||||
|
PKCE instead.
|
||||||
security: []
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
// Convenience endpoint for first-party clients: exchanges credentials for
|
// Convenience endpoint for first-party clients: exchanges credentials for
|
||||||
// a token upstream so they do not need to know the identity server's URL.
|
// a token upstream so they do not need to know the identity server's URL.
|
||||||
Route::post('auth/token', [TokenController::class, 'store'])
|
Route::post('auth/token', [TokenController::class, 'store'])
|
||||||
->middleware('throttle:6,1')
|
->middleware('throttle:auth-token')
|
||||||
->name('auth.token');
|
->name('auth.token');
|
||||||
|
|
||||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||||
|
|||||||
@@ -103,6 +103,38 @@ class TokenEndpointTest extends TestCase
|
|||||||
->assertJsonValidationErrors('grant_type');
|
->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]
|
#[Test]
|
||||||
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
|
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user