- 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 <noreply@anthropic.com>
158 lines
6.2 KiB
JavaScript
158 lines
6.2 KiB
JavaScript
#!/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)));
|