Compare commits
2 Commits
a39f9f170c
...
3e7bc4dbc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e7bc4dbc6 | |||
| 68783ca0b7 |
@@ -31,5 +31,8 @@ DB_TABLE_PREFIX=ga_
|
|||||||
# true = la documentación Swagger (/rest-api/docs/) es pública; false = solo usuarios conectados
|
# true = la documentación Swagger (/rest-api/docs/) es pública; false = solo usuarios conectados
|
||||||
API_DOCS_PUBLIC=false
|
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)
|
# true = crea 4 animales de ejemplo en la primera instalación (útil para probar)
|
||||||
SEED_DEMO_ANIMALS=false
|
SEED_DEMO_ANIMALS=false
|
||||||
|
|||||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"bilagun": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "${BILAGUN_MCP_URL:-https://mcp.gureametsabilagun.pireon.dev/mcp}",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Basic ${BILAGUN_MCP_AUTH}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
- **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*.
|
- **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).
|
- **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)
|
### Pendiente fuera del código (requiere cuentas del cliente)
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 60s
|
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:
|
db:
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
2
mcp/.dockerignore
Normal file
2
mcp/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
*.md
|
||||||
11
mcp/Dockerfile
Normal file
11
mcp/Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -22,16 +22,67 @@ through the website's REST API. Examples:
|
|||||||
|
|
||||||
It can't read the contact messages, delete content or change settings: that stays in the dashboard.
|
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*,
|
| | Remote (recommended) | Local |
|
||||||
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+).
|
| Where it runs | On the server, next to WordPress (service `mcp` in `docker-compose.yml`) | On each person's computer |
|
||||||
3. Add the server to your assistant.
|
| 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 |
|
||||||
|
|
||||||
**Claude Code**
|
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 (from this repository)**
|
||||||
|
|
||||||
|
The repo includes `.mcp.json`, so Claude Code offers the `bilagun` server when you open the project.
|
||||||
|
Each person only sets their own credential (never committed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ~/.zshrc or ~/.bashrc
|
||||||
|
export BILAGUN_MCP_AUTH="$(printf 'asistente:xxxx xxxx xxxx xxxx xxxx xxxx' | base64)"
|
||||||
|
# optional, if the MCP domain is different:
|
||||||
|
# export BILAGUN_MCP_URL="https://mcp.example.org/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
Open a new terminal, run `claude` in the project, approve the `bilagun` server and check it with `/mcp`.
|
||||||
|
|
||||||
|
**Claude Code (any folder)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http --scope user bilagun https://mcp.gureametsabilagun.pireon.dev/mcp \
|
||||||
|
--header "Authorization: Basic <base64 from step 1>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <same base64>`.
|
||||||
|
|
||||||
|
> 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 \
|
claude mcp add bilagun \
|
||||||
--env BILAGUN_URL=https://gureametsabilagun.pireon.dev \
|
--env BILAGUN_URL=https://gureametsabilagun.pireon.dev \
|
||||||
--env BILAGUN_USER=asistente \
|
--env BILAGUN_USER=asistente \
|
||||||
@@ -39,7 +90,7 @@ claude mcp add bilagun \
|
|||||||
-- node /absolute/path/to/mcp/index.js
|
-- node /absolute/path/to/mcp/index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
**Claude Desktop** (`claude_desktop_config.json`)
|
Claude Desktop (`claude_desktop_config.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
157
mcp/http.js
Normal file
157
mcp/http.js
Normal file
@@ -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)));
|
||||||
210
mcp/index.js
210
mcp/index.js
@@ -1,18 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Bilagun MCP server.
|
* Bilagun MCP server — local mode (stdio), for Claude Desktop / Claude Code on your computer.
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*
|
*
|
||||||
* Env: BILAGUN_URL (https://…), BILAGUN_USER, BILAGUN_APP_PASSWORD
|
* 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { z } from 'zod';
|
import { createWpClient, basicAuth } from './src/wp.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { createBilagunServer } from './src/tools.js';
|
||||||
import { basename, extname } from 'node:path';
|
|
||||||
|
|
||||||
const BASE = (process.env.BILAGUN_URL || '').replace(/\/+$/, '');
|
const BASE = (process.env.BILAGUN_URL || '').replace(/\/+$/, '');
|
||||||
const USER = process.env.BILAGUN_USER || '';
|
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.');
|
console.error('Set BILAGUN_URL, BILAGUN_USER and BILAGUN_APP_PASSWORD.');
|
||||||
process.exit(1);
|
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=<id of the Spanish record>: 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());
|
await server.connect(new StdioServerTransport());
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "bilagun-mcp",
|
"name": "bilagun-mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "MCP server to manage the Bilagun website (animals, photos, material needs) through its REST API.",
|
"description": "MCP server to manage the Bilagun website (animals, photos, material needs) through its REST API. Local (stdio) or remote (HTTP).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": { "bilagun-mcp": "index.js" },
|
"bin": {
|
||||||
|
"bilagun-mcp": "index.js",
|
||||||
|
"bilagun-mcp-http": "http.js"
|
||||||
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"engines": { "node": ">=20" },
|
"engines": {
|
||||||
"scripts": { "start": "node index.js" },
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"start:http": "node http.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.30.1",
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
|
|||||||
208
mcp/src/tools.js
Normal file
208
mcp/src/tools.js
Normal file
@@ -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=<id of the Spanish record>: 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;
|
||||||
|
}
|
||||||
34
mcp/src/wp.js
Normal file
34
mcp/src/wp.js
Normal file
@@ -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');
|
||||||
Reference in New Issue
Block a user