#!/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). * * Env: BILAGUN_URL (https://…), BILAGUN_USER, BILAGUN_APP_PASSWORD */ 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'; const BASE = (process.env.BILAGUN_URL || '').replace(/\/+$/, ''); const USER = process.env.BILAGUN_USER || ''; const PASS = process.env.BILAGUN_APP_PASSWORD || ''; 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 } : {}) } }))); await server.connect(new StdioServerTransport());