From 68783ca0b74a996fecc09baa1ce5cbddb88fc631 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Fri, 25 Sep 2026 11:25:42 +0200 Subject: [PATCH] MCP remoto: servidor Streamable HTTP como servicio del compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcp/http.js: MCP por HTTP (sin estado). Cada petición lleva la contraseña de aplicación del propio usuario (Basic o Bearer); se valida contra WordPress (caché 5 min), exige permiso de edición y no se guarda. Límite por IP, /healthz, IP real reenviada a WordPress. - Herramientas comunes en mcp/src/tools.js (local y remoto); en remoto las fotos van por URL o base64 (sin acceso a archivos del servidor). - Dockerfile del MCP y servicio «mcp» en docker-compose.yml. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + README.md | 2 +- docker-compose.yml | 15 ++++ mcp/.dockerignore | 2 + mcp/Dockerfile | 11 +++ mcp/README.md | 49 +++++++++-- mcp/http.js | 157 +++++++++++++++++++++++++++++++++ mcp/index.js | 210 ++------------------------------------------- mcp/package.json | 18 ++-- mcp/src/tools.js | 208 ++++++++++++++++++++++++++++++++++++++++++++ mcp/src/wp.js | 34 ++++++++ 11 files changed, 493 insertions(+), 216 deletions(-) create mode 100644 mcp/.dockerignore create mode 100644 mcp/Dockerfile create mode 100644 mcp/http.js create mode 100644 mcp/src/tools.js create mode 100644 mcp/src/wp.js diff --git a/.env.example b/.env.example index 7e99d08..0d08b30 100644 --- a/.env.example +++ b/.env.example @@ -31,5 +31,8 @@ DB_TABLE_PREFIX=ga_ # true = la documentación Swagger (/rest-api/docs/) es pública; false = solo usuarios conectados API_DOCS_PUBLIC=false +# Servidor MCP remoto: peticiones por minuto e IP +MCP_RATE_LIMIT=120 + # true = crea 4 animales de ejemplo en la primera instalación (útil para probar) SEED_DEMO_ANIMALS=false diff --git a/README.md b/README.md index f5163bc..9168879 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Cada ficha tiene su propio enlace y botones para compartirla por WhatsApp o Face - **Finales felices** (Animales → Finales felices): historias enviadas por las familias desde la página «Finales felices» (borrador hasta que queráis publicarla); se publican solo tras revisarlas. - **Cuestionario de adopción** online: página fuera del menú y sin indexar; enviad el enlace tras el primer contacto (`?adoptar=ID` lo rellena con el animal). Preguntas en *Personalizar → Adopción y colabora → Cuestionario*. - **Mensajes**: se borran solos a los 12 meses (configurable). -- **Asistente (MCP)**: ver [`mcp/README.md`](mcp/README.md). +- **Asistente (MCP)**: remoto (servicio `mcp` del compose, añade su dominio en Dokploy → puerto 3000) o local. Ver [`mcp/README.md`](mcp/README.md). ### Pendiente fuera del código (requiere cuentas del cliente) diff --git a/docker-compose.yml b/docker-compose.yml index 0a9b90e..2e5f140 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,21 @@ services: retries: 5 start_period: 60s + # Servidor MCP remoto para el asistente del equipo (ver mcp/README.md). + # En Dokploy: añade un dominio (p. ej. mcp.tudominio) → servicio mcp, puerto 3000. + mcp: + build: ./mcp + image: gure-ametsa-mcp:latest + restart: unless-stopped + depends_on: + - wordpress + expose: + - "3000" + environment: + BILAGUN_URL: http://wordpress + BILAGUN_PUBLIC_URL: ${WP_URL} + RATE_LIMIT: ${MCP_RATE_LIMIT:-120} + db: image: mariadb:11.4 restart: unless-stopped diff --git a/mcp/.dockerignore b/mcp/.dockerignore new file mode 100644 index 0000000..4764e2f --- /dev/null +++ b/mcp/.dockerignore @@ -0,0 +1,2 @@ +node_modules +*.md diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 0000000..1324b0a --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY index.js http.js ./ +COPY src ./src +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1 +CMD ["node", "http.js"] diff --git a/mcp/README.md b/mcp/README.md index 985f0dc..413b9a1 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -22,16 +22,53 @@ through the website's REST API. Examples: It can't read the contact messages, delete content or change settings: that stays in the dashboard. -## Setup +## Two ways to run it -1. In WordPress create a dedicated user (role **Editor**) and, in *Users → Profile → Application passwords*, - create one called "Asistente". Copy it (it is shown only once). It can be revoked at any time. -2. Install: `cd mcp && npm install` (Node 20+). -3. Add the server to your assistant. +| | Remote (recommended) | Local | +|---|---|---| +| Where it runs | On the server, next to WordPress (service `mcp` in `docker-compose.yml`) | On each person's computer | +| What each person needs | A URL and their own application password | Node 20+, this folder, `npm install` | +| Photos | By URL or dragged into the chat (base64) | Also local file paths | + +In both cases every person uses **their own WordPress user and application password**. The remote +server stores no credentials: it checks them against WordPress on each request and forwards them, +so each person acts with their own permissions and access can be revoked from the dashboard. + +## 1. Create the access (per person) + +1. In WordPress create a user for the person (role **Editor** is enough). +2. *Users → Profile → Application passwords* → name it "Asistente" → **Add**. Copy it (shown only once). +3. Build the credential: base64 of `user:application-password`: + + ```bash + printf 'asistente:xxxx xxxx xxxx xxxx xxxx xxxx' | base64 + ``` + +## 2a. Remote server (Dokploy) + +After deploying, in Dokploy add a **domain** to the service `mcp`, port **3000** (e.g. `mcp.gureametsabilagun.pireon.dev`, HTTPS on). +Check it: `https://mcp.gureametsabilagun.pireon.dev/healthz` → `{"ok":true}`. **Claude Code** ```bash +claude mcp add --transport http bilagun https://mcp.gureametsabilagun.pireon.dev/mcp \ + --header "Authorization: Basic " +``` + +**Other clients** (Cursor, VS Code, Claude Desktop via `mcp-remote`…): use the same URL and header. +Clients that only allow a *Bearer token* can use `Authorization: Bearer `. + +> claude.ai web/mobile "custom connectors" require OAuth login, which this server does not implement yet. + +Server settings (environment): `BILAGUN_URL` (internal WordPress URL, default `http://wordpress`), +`BILAGUN_PUBLIC_URL`, `RATE_LIMIT` (requests per minute and IP, default 120). +It exposes `POST /mcp` (Streamable HTTP, stateless) and `GET /healthz`. + +## 2b. Local server (stdio) + +```bash +cd mcp && npm install claude mcp add bilagun \ --env BILAGUN_URL=https://gureametsabilagun.pireon.dev \ --env BILAGUN_USER=asistente \ @@ -39,7 +76,7 @@ claude mcp add bilagun \ -- node /absolute/path/to/mcp/index.js ``` -**Claude Desktop** (`claude_desktop_config.json`) +Claude Desktop (`claude_desktop_config.json`): ```json { diff --git a/mcp/http.js b/mcp/http.js new file mode 100644 index 0000000..4dd6d91 --- /dev/null +++ b/mcp/http.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * Bilagun MCP server — remote mode (Streamable HTTP, stateless). + * + * Each request must carry the user's own WordPress application password: + * Authorization: Basic base64(user:app-password) (or Bearer base64(user:app-password)) + * The server stores no credentials: it validates them against WordPress and forwards them, + * so every person acts with their own permissions and access can be revoked in the dashboard. + * + * Env: + * BILAGUN_URL WordPress URL reachable from this container (default http://wordpress) + * BILAGUN_PUBLIC_URL Public site URL, used in links (default = BILAGUN_URL) + * PORT (default 3000) MCP_PATH (default /mcp) + * RATE_LIMIT requests per minute and IP (default 120) + */ +import { createServer } from 'node:http'; +import { createHash } from 'node:crypto'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createWpClient } from './src/wp.js'; +import { createBilagunServer } from './src/tools.js'; + +const WP_URL = (process.env.BILAGUN_URL || 'http://wordpress').replace(/\/+$/, ''); +const PUBLIC_URL = (process.env.BILAGUN_PUBLIC_URL || WP_URL).replace(/\/+$/, ''); +const PORT = Number(process.env.PORT || 3000); +const MCP_PATH = process.env.MCP_PATH || '/mcp'; +const RATE_LIMIT = Number(process.env.RATE_LIMIT || 120); +const MAX_BODY = 16 * 1024 * 1024; // images in base64 + +const log = (...args) => console.log(new Date().toISOString(), ...args); + +/* ---------- helpers ---------- */ + +function clientIp(req) { + const fwd = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); + return fwd || req.socket.remoteAddress || 'unknown'; +} + +function send(res, status, body, headers = {}) { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); +} + +/** Accept "Basic x" or "Bearer x" (x = base64(user:password)); return a Basic header or null. */ +function normalizeAuth(header) { + const m = /^(Basic|Bearer)\s+([A-Za-z0-9+/=]+)$/.exec(String(header || '').trim()); + if (!m) return null; + const decoded = Buffer.from(m[2], 'base64').toString('utf8'); + return decoded.includes(':') ? `Basic ${m[2]}` : null; +} + +async function readBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY) throw Object.assign(new Error('Request too large'), { status: 413 }); + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString('utf8'); + return text ? JSON.parse(text) : undefined; +} + +/* ---------- rate limit (sliding minute per IP) ---------- */ + +const hits = new Map(); +function rateLimited(ip) { + const now = Date.now(); + const recent = (hits.get(ip) || []).filter((t) => now - t < 60_000); + recent.push(now); + hits.set(ip, recent); + return recent.length > RATE_LIMIT; +} +setInterval(() => { + const now = Date.now(); + for (const [ip, list] of hits) if (!list.some((t) => now - t < 60_000)) hits.delete(ip); +}, 60_000).unref(); + +/* ---------- credential check against WordPress (cached) ---------- */ + +const authCache = new Map(); // sha256(auth) → { ok, user, until } +async function checkAuth(authorization, headers) { + const key = createHash('sha256').update(authorization).digest('hex'); + const cached = authCache.get(key); + if (cached && cached.until > Date.now()) return cached; + let result; + try { + const { api } = createWpClient({ baseUrl: WP_URL, authorization, extraHeaders: headers }); + const me = await api('/wp/v2/users/me?context=edit&_fields=id,name,capabilities'); + const canEdit = !!me.capabilities?.edit_posts; + result = { ok: canEdit, user: me.name, reason: canEdit ? '' : 'This user cannot edit content.', until: Date.now() + 5 * 60_000 }; + } catch (e) { + result = { ok: false, reason: 'Invalid WordPress user or application password.', until: Date.now() + 60_000 }; + } + authCache.set(key, result); + return result; +} + +/* ---------- server ---------- */ + +const httpServer = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + + if (url.pathname === '/healthz') { + return send(res, 200, { ok: true }); + } + if (url.pathname !== MCP_PATH) { + return send(res, 404, { error: 'Not found' }); + } + const ip = clientIp(req); + if (rateLimited(ip)) { + return send(res, 429, { error: 'Too many requests' }, { 'Retry-After': '60' }); + } + if (req.method !== 'POST') { + // Stateless server: no SSE stream (GET) nor sessions (DELETE). + return send(res, 405, { error: 'Method not allowed' }, { Allow: 'POST' }); + } + + const authorization = normalizeAuth(req.headers.authorization); + if (!authorization) { + return send(res, 401, { error: 'Authorization required: Basic base64(user:application-password)' }, { 'WWW-Authenticate': 'Basic realm="Bilagun MCP"' }); + } + // WordPress sees the real client IP (Limit Login Attempts) and HTTPS (application passwords). + const wpHeaders = { 'X-Forwarded-For': ip, 'X-Forwarded-Proto': 'https' }; + const auth = await checkAuth(authorization, wpHeaders); + if (!auth.ok) { + log('auth rejected', ip); + return send(res, 401, { error: auth.reason }, { 'WWW-Authenticate': 'Basic realm="Bilagun MCP"' }); + } + + let body; + try { + body = await readBody(req); + } catch (e) { + return send(res, e.status || 400, { error: e.status === 413 ? 'Request too large' : 'Invalid JSON' }); + } + + const { api } = createWpClient({ baseUrl: WP_URL, authorization, extraHeaders: wpHeaders }); + const server = createBilagunServer({ api, publicUrl: PUBLIC_URL, remote: true }); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + res.on('close', () => { + transport.close(); + server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, body); + if (Array.isArray(body) ? false : body?.method === 'tools/call') { + log('tool', body.params?.name, 'by', auth.user); + } + } catch (e) { + log('error', e.message); + if (!res.headersSent) send(res, 500, { error: 'Internal error' }); + } +}); + +httpServer.listen(PORT, () => log(`Bilagun MCP listening on :${PORT}${MCP_PATH} → ${WP_URL}`)); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => httpServer.close(() => process.exit(0))); diff --git a/mcp/index.js b/mcp/index.js index 42a1388..783a568 100644 --- a/mcp/index.js +++ b/mcp/index.js @@ -1,18 +1,13 @@ #!/usr/bin/env node /** - * Bilagun MCP server. - * - * Lets an assistant (Claude Desktop, Claude Code…) manage the website through its REST API: - * list / read / create / update animals, change status, feature, upload photos and edit the - * material needs list. Authentication: WordPress application password (Users → Profile). + * Bilagun MCP server — local mode (stdio), for Claude Desktop / Claude Code on your computer. * * Env: BILAGUN_URL (https://…), BILAGUN_USER, BILAGUN_APP_PASSWORD + * For the remote (HTTP) server see http.js. */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import { readFile } from 'node:fs/promises'; -import { basename, extname } from 'node:path'; +import { createWpClient, basicAuth } from './src/wp.js'; +import { createBilagunServer } from './src/tools.js'; const BASE = (process.env.BILAGUN_URL || '').replace(/\/+$/, ''); const USER = process.env.BILAGUN_USER || ''; @@ -21,200 +16,7 @@ if (!BASE || !USER || !PASS) { console.error('Set BILAGUN_URL, BILAGUN_USER and BILAGUN_APP_PASSWORD.'); process.exit(1); } -const AUTH = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64'); - -async function api(path, { method = 'GET', body, headers = {}, raw = false } = {}) { - const res = await fetch(`${BASE}/wp-json${path}`, { - method, - headers: { Authorization: AUTH, ...(body && !raw ? { 'Content-Type': 'application/json' } : {}), ...headers }, - body: body ? (raw ? body : JSON.stringify(body)) : undefined, - }); - const text = await res.text(); - let data; - try { data = JSON.parse(text); } catch { data = text; } - if (!res.ok) { - const msg = (data && data.message) || text.slice(0, 300); - throw new Error(`${method} ${path} → ${res.status}: ${msg}`); - } - return data; -} - -const ok = (data) => ({ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] }); -const fail = (e) => ({ isError: true, content: [{ type: 'text', text: String(e.message || e) }] }); -const tool = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } }; - -const SPECIES = { dog: { es: 'perro', eu: 'txakurra' }, cat: { es: 'gato', eu: 'katua' } }; -async function speciesId(kind, lang) { - const slug = SPECIES[kind][lang]; - const terms = await api(`/wp/v2/species?slug=${slug}&lang=${lang}&_fields=id`); - if (!terms.length) throw new Error(`Species "${slug}" not found.`); - return terms[0].id; -} - -/** Compact view of an animal for the assistant. */ -const summary = (a) => ({ - id: a.id, - name: a.title?.rendered ?? a.title?.raw, - wpStatus: a.status, - language: a.language, - translationOf: a.translation_of, - link: a.link, - profile: a.profile, -}); - -const Profile = { - status: z.enum(['available', 'pending', 'adopted']).optional().describe('Adoption status. Adopted animals disappear from public listings.'), - featured: z.boolean().optional().describe('Show on the home page.'), - sponsorable: z.boolean().optional().describe('Looking for a sponsor.'), - sex: z.enum(['male', 'female']).optional(), - birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'), - age: z.number().nonnegative().optional().describe('Approximate age when the birth date is unknown.'), - ageUnit: z.enum(['years', 'months']).optional(), - size: z.enum(['small', 'medium', 'large']).optional().describe('Dogs only.'), - arrivalDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Date it arrived at the shelter (only if confirmed).'), - video: z.string().url().optional(), - personality: z.string().optional().describe('What it is like (in the language of this record).'), - needs: z.string().optional().describe('What it needs.'), - compatibility: z.string().optional().describe('Observed behaviour with children and other animals, with context. Never claim what is not known.'), - idealHome: z.string().optional().describe('The home we are looking for.'), -}; -const pickProfile = (args) => Object.fromEntries(Object.keys(Profile).filter((k) => args[k] !== undefined).map((k) => [k, args[k]])); - -const server = new McpServer({ name: 'bilagun', version: '1.0.0' }); - -server.registerTool('list_animals', { - title: 'List animals', - description: 'List animals of the website. Filters are optional.', - inputSchema: { - language: z.enum(['es', 'eu']).default('es'), - status: z.enum(['available', 'pending', 'adopted']).optional(), - species: z.enum(['dog', 'cat']).optional(), - search: z.string().optional().describe('Text to search in the name or story.'), - includeDrafts: z.boolean().default(false), - }, -}, tool(async ({ language, status, species, search, includeDrafts }) => { - const params = new URLSearchParams({ lang: language, per_page: '100', context: 'edit', _fields: 'id,title,status,link,language,translation_of,profile' }); - params.set('status', includeDrafts ? 'publish,draft,pending' : 'publish'); - if (search) params.set('search', search); - if (species) params.set('species', String(await speciesId(species, language))); - let animals = (await api(`/wp/v2/animals?${params}`)).map(summary); - if (status) animals = animals.filter((a) => a.profile?.status === status); - return animals.map(({ id, name, wpStatus, profile, translationOf }) => ({ id, name, wpStatus, status: profile?.status, featured: profile?.featured, age: profile?.labels?.age, translationOf })); -})); - -server.registerTool('get_animal', { - title: 'Get animal', - description: 'Full record of one animal, including its story and profile.', - inputSchema: { id: z.number().int() }, -}, tool(async ({ id }) => { - const a = await api(`/wp/v2/animals/${id}?context=edit&_fields=id,title,content,excerpt,status,link,language,translation_of,featured_media,profile`); - return { ...summary(a), story: a.content?.raw, shortPhrase: a.excerpt?.raw, featuredMedia: a.featured_media }; -})); - -server.registerTool('create_animal', { - title: 'Create animal', - description: 'Create an animal record. It is saved as a draft unless publish=true. To create the Basque version of an existing animal, pass language="eu" and translationOf=: shared data (status, dates, photos…) is copied automatically; write the texts in Basque.', - inputSchema: { - name: z.string().min(1), - species: z.enum(['dog', 'cat']), - language: z.enum(['es', 'eu']).default('es'), - translationOf: z.number().int().optional(), - story: z.string().optional().describe('Main story (plain text or HTML).'), - shortPhrase: z.string().optional().describe('Optional one-line phrase.'), - publish: z.boolean().default(false), - ...Profile, - }, -}, tool(async (args) => { - const body = { - title: args.name, - content: args.story ?? '', - excerpt: args.shortPhrase ?? '', - status: args.publish ? 'publish' : 'draft', - species: [await speciesId(args.species, args.language)], - language: args.language, - profile: { status: 'available', ...pickProfile(args) }, - }; - if (args.translationOf) body.translation_of = args.translationOf; - const created = await api('/wp/v2/animals', { method: 'POST', body }); - return { created: summary(created), editInDashboard: `${BASE}/wp-admin/post.php?post=${created.id}&action=edit` }; -})); - -server.registerTool('update_animal', { - title: 'Update animal', - description: 'Update an animal. Only the fields provided change. Shared fields (status, featured, dates, size, photos) are applied to both languages automatically; texts only to this record.', - inputSchema: { - id: z.number().int(), - name: z.string().optional(), - story: z.string().optional(), - shortPhrase: z.string().optional(), - publish: z.boolean().optional().describe('true = publish, false = back to draft.'), - ...Profile, - }, -}, tool(async (args) => { - const body = {}; - if (args.name !== undefined) body.title = args.name; - if (args.story !== undefined) body.content = args.story; - if (args.shortPhrase !== undefined) body.excerpt = args.shortPhrase; - if (args.publish !== undefined) body.status = args.publish ? 'publish' : 'draft'; - const profile = pickProfile(args); - if (Object.keys(profile).length) body.profile = profile; - return summary(await api(`/wp/v2/animals/${args.id}`, { method: 'POST', body })); -})); - -server.registerTool('set_status', { - title: 'Set adoption status', - description: 'Shortcut to mark an animal as available, pending (in process) or adopted, in both languages.', - inputSchema: { id: z.number().int(), status: z.enum(['available', 'pending', 'adopted']) }, -}, tool(async ({ id, status }) => summary(await api(`/wp/v2/animals/${id}`, { method: 'POST', body: { profile: { status } } })))); - -server.registerTool('upload_photo', { - title: 'Upload photo', - description: 'Upload a photo (local file path or https URL) and attach it to an animal, as the main photo or added to its gallery.', - inputSchema: { - animalId: z.number().int(), - source: z.string().describe('Local file path or https URL of a JPG, PNG or WebP image.'), - asMain: z.boolean().default(false).describe('true = main photo; false = add to the gallery.'), - altText: z.string().optional(), - }, -}, tool(async ({ animalId, source, asMain, altText }) => { - let bytes, name; - if (/^https?:\/\//.test(source)) { - const res = await fetch(source); - if (!res.ok) throw new Error(`Could not download ${source}: ${res.status}`); - bytes = Buffer.from(await res.arrayBuffer()); - name = basename(new URL(source).pathname) || 'photo.jpg'; - } else { - bytes = await readFile(source); - name = basename(source); - } - const ext = extname(name).toLowerCase(); - const type = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[ext]; - if (!type) throw new Error('Only JPG, PNG or WebP images are allowed.'); - const media = await api('/wp/v2/media', { - method: 'POST', raw: true, body: bytes, - headers: { 'Content-Type': type, 'Content-Disposition': `attachment; filename="${name.replace(/"/g, '')}"` }, - }); - if (altText) await api(`/wp/v2/media/${media.id}`, { method: 'POST', body: { alt_text: altText } }); - if (asMain) { - await api(`/wp/v2/animals/${animalId}`, { method: 'POST', body: { featured_media: media.id } }); - } else { - const a = await api(`/wp/v2/animals/${animalId}?context=edit&_fields=profile`); - const ids = [...new Set([...(a.profile?.galleryIds || []), media.id])]; - await api(`/wp/v2/animals/${animalId}`, { method: 'POST', body: { profile: { galleryIds: ids } } }); - } - return { mediaId: media.id, url: media.source_url, attachedTo: animalId, as: asMain ? 'main photo' : 'gallery' }; -})); - -server.registerTool('get_needs', { - title: 'Get material needs', - description: 'Current list of material needs shown on the website, in Spanish and Basque.', - inputSchema: {}, -}, tool(async () => api('/bilagun/v1/needs'))); - -server.registerTool('set_needs', { - title: 'Set material needs', - description: 'Replace the list of material needs. Provide both languages when possible (es = Spanish, eu = Basque).', - inputSchema: { es: z.array(z.string()).optional(), eu: z.array(z.string()).optional() }, -}, tool(async ({ es, eu }) => api('/bilagun/v1/needs', { method: 'PUT', body: { ...(es ? { es } : {}), ...(eu ? { eu } : {}) } }))); +const { api } = createWpClient({ baseUrl: BASE, authorization: basicAuth(USER, PASS) }); +const server = createBilagunServer({ api, publicUrl: BASE, remote: false }); await server.connect(new StdioServerTransport()); diff --git a/mcp/package.json b/mcp/package.json index bb98a20..b191162 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,12 +1,20 @@ { "name": "bilagun-mcp", - "version": "1.0.0", - "description": "MCP server to manage the Bilagun website (animals, photos, material needs) through its REST API.", + "version": "1.1.0", + "description": "MCP server to manage the Bilagun website (animals, photos, material needs) through its REST API. Local (stdio) or remote (HTTP).", "type": "module", - "bin": { "bilagun-mcp": "index.js" }, + "bin": { + "bilagun-mcp": "index.js", + "bilagun-mcp-http": "http.js" + }, "main": "index.js", - "engines": { "node": ">=20" }, - "scripts": { "start": "node index.js" }, + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node index.js", + "start:http": "node http.js" + }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", "zod": "^4.6.5" diff --git a/mcp/src/tools.js b/mcp/src/tools.js new file mode 100644 index 0000000..747464c --- /dev/null +++ b/mcp/src/tools.js @@ -0,0 +1,208 @@ +/** + * Bilagun MCP tools (shared by the local stdio server and the remote HTTP server). + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import { basename, extname } from 'node:path'; + +const MAX_UPLOAD = 10 * 1024 * 1024; + +/** + * @param {object} opts + * @param {Function} opts.api WordPress REST client (see wp.js) + * @param {string} opts.publicUrl Public site URL, for links in answers + * @param {boolean} opts.remote true on the HTTP server (no access to local files) + */ +export function createBilagunServer({ api, publicUrl, remote = false }) { + const server = new McpServer({ name: 'bilagun', version: '1.1.0' }); + + const ok = (data) => ({ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] }); + const fail = (e) => ({ isError: true, content: [{ type: 'text', text: String(e.message || e) }] }); + const tool = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } }; + + const SPECIES = { dog: { es: 'perro', eu: 'txakurra' }, cat: { es: 'gato', eu: 'katua' } }; + async function speciesId(kind, lang) { + const slug = SPECIES[kind][lang]; + const terms = await api(`/wp/v2/species?slug=${slug}&lang=${lang}&_fields=id`); + if (!terms.length) throw new Error(`Species "${slug}" not found.`); + return terms[0].id; + } + + /** Compact view of an animal for the assistant. */ + const summary = (a) => ({ + id: a.id, + name: a.title?.rendered ?? a.title?.raw, + wpStatus: a.status, + language: a.language, + translationOf: a.translation_of, + link: a.link, + profile: a.profile, + }); + + const Profile = { + status: z.enum(['available', 'pending', 'adopted']).optional().describe('Adoption status. Adopted animals disappear from public listings.'), + featured: z.boolean().optional().describe('Show on the home page.'), + sponsorable: z.boolean().optional().describe('Looking for a sponsor.'), + sex: z.enum(['male', 'female']).optional(), + birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'), + age: z.number().nonnegative().optional().describe('Approximate age when the birth date is unknown.'), + ageUnit: z.enum(['years', 'months']).optional(), + size: z.enum(['small', 'medium', 'large']).optional().describe('Dogs only.'), + arrivalDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Date it arrived at the shelter (only if confirmed).'), + video: z.string().url().optional(), + personality: z.string().optional().describe('What it is like (in the language of this record).'), + needs: z.string().optional().describe('What it needs.'), + compatibility: z.string().optional().describe('Observed behaviour with children and other animals, with context. Never claim what is not known.'), + idealHome: z.string().optional().describe('The home we are looking for.'), + }; + const pickProfile = (args) => Object.fromEntries(Object.keys(Profile).filter((k) => args[k] !== undefined).map((k) => [k, args[k]])); + + + server.registerTool('list_animals', { + title: 'List animals', + description: 'List animals of the website. Filters are optional.', + inputSchema: { + language: z.enum(['es', 'eu']).default('es'), + status: z.enum(['available', 'pending', 'adopted']).optional(), + species: z.enum(['dog', 'cat']).optional(), + search: z.string().optional().describe('Text to search in the name or story.'), + includeDrafts: z.boolean().default(false), + }, + }, tool(async ({ language, status, species, search, includeDrafts }) => { + const params = new URLSearchParams({ lang: language, per_page: '100', context: 'edit', _fields: 'id,title,status,link,language,translation_of,profile' }); + params.set('status', includeDrafts ? 'publish,draft,pending' : 'publish'); + if (search) params.set('search', search); + if (species) params.set('species', String(await speciesId(species, language))); + let animals = (await api(`/wp/v2/animals?${params}`)).map(summary); + if (status) animals = animals.filter((a) => a.profile?.status === status); + return animals.map(({ id, name, wpStatus, profile, translationOf }) => ({ id, name, wpStatus, status: profile?.status, featured: profile?.featured, age: profile?.labels?.age, translationOf })); + })); + + server.registerTool('get_animal', { + title: 'Get animal', + description: 'Full record of one animal, including its story and profile.', + inputSchema: { id: z.number().int() }, + }, tool(async ({ id }) => { + const a = await api(`/wp/v2/animals/${id}?context=edit&_fields=id,title,content,excerpt,status,link,language,translation_of,featured_media,profile`); + return { ...summary(a), story: a.content?.raw, shortPhrase: a.excerpt?.raw, featuredMedia: a.featured_media }; + })); + + server.registerTool('create_animal', { + title: 'Create animal', + description: 'Create an animal record. It is saved as a draft unless publish=true. To create the Basque version of an existing animal, pass language="eu" and translationOf=: shared data (status, dates, photos…) is copied automatically; write the texts in Basque.', + inputSchema: { + name: z.string().min(1), + species: z.enum(['dog', 'cat']), + language: z.enum(['es', 'eu']).default('es'), + translationOf: z.number().int().optional(), + story: z.string().optional().describe('Main story (plain text or HTML).'), + shortPhrase: z.string().optional().describe('Optional one-line phrase.'), + publish: z.boolean().default(false), + ...Profile, + }, + }, tool(async (args) => { + const body = { + title: args.name, + content: args.story ?? '', + excerpt: args.shortPhrase ?? '', + status: args.publish ? 'publish' : 'draft', + species: [await speciesId(args.species, args.language)], + language: args.language, + profile: { status: 'available', ...pickProfile(args) }, + }; + if (args.translationOf) body.translation_of = args.translationOf; + const created = await api('/wp/v2/animals', { method: 'POST', body }); + return { created: summary(created), editInDashboard: `${publicUrl}/wp-admin/post.php?post=${created.id}&action=edit` }; + })); + + server.registerTool('update_animal', { + title: 'Update animal', + description: 'Update an animal. Only the fields provided change. Shared fields (status, featured, dates, size, photos) are applied to both languages automatically; texts only to this record.', + inputSchema: { + id: z.number().int(), + name: z.string().optional(), + story: z.string().optional(), + shortPhrase: z.string().optional(), + publish: z.boolean().optional().describe('true = publish, false = back to draft.'), + ...Profile, + }, + }, tool(async (args) => { + const body = {}; + if (args.name !== undefined) body.title = args.name; + if (args.story !== undefined) body.content = args.story; + if (args.shortPhrase !== undefined) body.excerpt = args.shortPhrase; + if (args.publish !== undefined) body.status = args.publish ? 'publish' : 'draft'; + const profile = pickProfile(args); + if (Object.keys(profile).length) body.profile = profile; + return summary(await api(`/wp/v2/animals/${args.id}`, { method: 'POST', body })); + })); + + server.registerTool('set_status', { + title: 'Set adoption status', + description: 'Shortcut to mark an animal as available, pending (in process) or adopted, in both languages.', + inputSchema: { id: z.number().int(), status: z.enum(['available', 'pending', 'adopted']) }, + }, tool(async ({ id, status }) => summary(await api(`/wp/v2/animals/${id}`, { method: 'POST', body: { profile: { status } } })))); + + server.registerTool('upload_photo', { + title: 'Upload photo', + description: remote + ? 'Attach a photo to an animal, as the main photo or added to its gallery. Provide an https URL of the image, or its content in base64 (dataBase64 + filename).' + : 'Attach a photo to an animal, as the main photo or added to its gallery. Provide a local file path or an https URL (or base64 content).', + inputSchema: { + animalId: z.number().int(), + source: z.string().optional().describe(remote ? 'https URL of a JPG, PNG or WebP image.' : 'Local file path or https URL of a JPG, PNG or WebP image.'), + dataBase64: z.string().optional().describe('Image content in base64 (alternative to source).'), + filename: z.string().optional().describe('File name when using dataBase64, e.g. kira.jpg'), + asMain: z.boolean().default(false).describe('true = main photo; false = add to the gallery.'), + altText: z.string().optional(), + }, + }, tool(async ({ animalId, source, dataBase64, filename, asMain, altText }) => { + let bytes, name; + if (dataBase64) { + bytes = Buffer.from(dataBase64, 'base64'); + name = filename || 'photo.jpg'; + } else if (source && /^https:\/\//.test(source)) { + const res = await fetch(source); + if (!res.ok) throw new Error(`Could not download ${source}: ${res.status}`); + bytes = Buffer.from(await res.arrayBuffer()); + name = basename(new URL(source).pathname) || 'photo.jpg'; + } else if (source && !remote) { + bytes = await readFile(source); + name = basename(source); + } else { + throw new Error(remote ? 'Provide an https URL or the image in base64 (this server cannot read files from your computer).' : 'Provide a file path, an https URL or base64 content.'); + } + if (bytes.length > MAX_UPLOAD) throw new Error('Image too large (max 10 MB).'); + const ext = extname(name).toLowerCase(); + const type = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[ext]; + if (!type) throw new Error('Only JPG, PNG or WebP images are allowed.'); + const media = await api('/wp/v2/media', { + method: 'POST', raw: true, body: bytes, + headers: { 'Content-Type': type, 'Content-Disposition': `attachment; filename="${name.replace(/"/g, '')}"` }, + }); + if (altText) await api(`/wp/v2/media/${media.id}`, { method: 'POST', body: { alt_text: altText } }); + if (asMain) { + await api(`/wp/v2/animals/${animalId}`, { method: 'POST', body: { featured_media: media.id } }); + } else { + const a = await api(`/wp/v2/animals/${animalId}?context=edit&_fields=profile`); + const ids = [...new Set([...(a.profile?.galleryIds || []), media.id])]; + await api(`/wp/v2/animals/${animalId}`, { method: 'POST', body: { profile: { galleryIds: ids } } }); + } + return { mediaId: media.id, url: media.source_url, attachedTo: animalId, as: asMain ? 'main photo' : 'gallery' }; + })); + + server.registerTool('get_needs', { + title: 'Get material needs', + description: 'Current list of material needs shown on the website, in Spanish and Basque.', + inputSchema: {}, + }, tool(async () => api('/bilagun/v1/needs'))); + + server.registerTool('set_needs', { + title: 'Set material needs', + description: 'Replace the list of material needs. Provide both languages when possible (es = Spanish, eu = Basque).', + inputSchema: { es: z.array(z.string()).optional(), eu: z.array(z.string()).optional() }, + }, tool(async ({ es, eu }) => api('/bilagun/v1/needs', { method: 'PUT', body: { ...(es ? { es } : {}), ...(eu ? { eu } : {}) } }))); + + return server; +} diff --git a/mcp/src/wp.js b/mcp/src/wp.js new file mode 100644 index 0000000..dd9843c --- /dev/null +++ b/mcp/src/wp.js @@ -0,0 +1,34 @@ +/** + * Minimal WordPress REST client. One instance per set of credentials. + */ +export function createWpClient({ baseUrl, authorization, extraHeaders = {} }) { + const base = baseUrl.replace(/\/+$/, ''); + + async function api(path, { method = 'GET', body, headers = {}, raw = false } = {}) { + const res = await fetch(`${base}/wp-json${path}`, { + method, + headers: { + Authorization: authorization, + ...extraHeaders, + ...(body && !raw ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }, + body: body ? (raw ? body : JSON.stringify(body)) : undefined, + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = text; } + if (!res.ok) { + const msg = (data && data.message) || String(text).slice(0, 300); + const error = new Error(`${method} ${path} → ${res.status}: ${msg}`); + error.status = res.status; + throw error; + } + return data; + } + + return { api, base }; +} + +/** "Basic …" header from user + application password. */ +export const basicAuth = (user, password) => 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');