#!/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)));