Why Build Your Own Joomla MCP Server?
Open-source Joomla MCP servers already exist and work well for standard use cases. But if you have custom Joomla components, proprietary business logic, or specific data requirements, building your own MCP server gives you full control over which tools the AI can call, what data it can access, and how requests are authenticated.
This guide walks through building a Joomla MCP server from scratch using Node.js (TypeScript) and the Model Context Protocol SDK. By the end, you will have a working server that exposes Joomla's REST API as structured MCP tools, ready to connect to Claude or any other MCP-compatible AI client.
Prerequisites
- Joomla 4.x or 5.x site with REST API enabled
- A Joomla API token (generate in User Manager → your account → Joomla API Token)
- Node.js 18+ installed on your machine
- Basic TypeScript familiarity
- Claude Desktop or another MCP client for testing
Understanding the MCP Architecture
Before writing a line of code, understand what you are building. An MCP server is a lightweight process that:
- Declares a list of tools — each with a name, description, and input schema.
- Listens for tool call requests from an AI client.
- Executes the requested operation (in our case, a Joomla REST API call).
- Returns the result as structured data back to the client.
The AI model never speaks directly to Joomla. All communication goes through the MCP server, which acts as a typed, validated proxy. This is a security feature — the server controls exactly what the AI can and cannot do.
AI Client (Claude) ←→ MCP Protocol ←→ Your MCP Server ←→ Joomla REST API
Project Setup
Create the project and install dependencies:
mkdir joomla-mcp-server
cd joomla-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk axios zod
npm install -D typescript @types/node tsx
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Create a .env file for your Joomla credentials:
JOOMLA_URL=https://yoursite.com
JOOMLA_API_TOKEN=your_api_token_here
The Joomla API Client
Create src/joomla-client.ts — a thin wrapper around Joomla's REST API:
import axios, { AxiosInstance } from 'axios';
export class JoomlaClient {
private http: AxiosInstance;
constructor(baseUrl: string, apiToken: string) {
this.http = axios.create({
baseURL: `${baseUrl}/api/index.php/v1`,
headers: {
'X-Joomla-Token': apiToken,
'Content-Type': 'application/json',
},
});
}
async getArticles(params?: Record<string, unknown>) {
const res = await this.http.get('/content/articles', { params });
return res.data;
}
async getArticle(id: number) {
const res = await this.http.get(`/content/articles/${id}`);
return res.data;
}
async createArticle(payload: Record<string, unknown>) {
const res = await this.http.post('/content/articles', payload);
return res.data;
}
async updateArticle(id: number, payload: Record<string, unknown>) {
const res = await this.http.patch(`/content/articles/${id}`, payload);
return res.data;
}
async deleteArticle(id: number) {
await this.http.delete(`/content/articles/${id}`);
return { success: true };
}
async getCategories(params?: Record<string, unknown>) {
const res = await this.http.get('/content/categories', { params });
return res.data;
}
}
Building the MCP Server
Create src/server.ts. This is the core of the MCP server:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { JoomlaClient } from './joomla-client.js';
const client = new JoomlaClient(
process.env.JOOMLA_URL!,
process.env.JOOMLA_API_TOKEN!
);
const server = new Server(
{ name: 'joomla-mcp-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// ──────────────────────────────────────────
// DECLARE TOOLS
// ──────────────────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_articles',
description: 'List articles from Joomla with optional filters.',
inputSchema: {
type: 'object',
properties: {
category_id: { type: 'number', description: 'Filter by category ID' },
state: { type: 'number', description: '1=published, 0=unpublished' },
limit: { type: 'number', description: 'Max results (default 20)' },
},
},
},
{
name: 'get_article',
description: 'Get a single article by ID.',
inputSchema: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'number', description: 'Article ID' },
},
},
},
{
name: 'create_article',
description: 'Create a new Joomla article.',
inputSchema: {
type: 'object',
required: ['title', 'category_id'],
properties: {
title: { type: 'string' },
category_id: { type: 'number' },
body: { type: 'string', description: 'HTML content' },
state: { type: 'number', description: '1=publish, 0=draft' },
metadesc: { type: 'string' },
},
},
},
{
name: 'update_article',
description: 'Update fields on an existing article.',
inputSchema: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'number' },
title: { type: 'string' },
body: { type: 'string' },
state: { type: 'number' },
},
},
},
],
}));
Handling Tool Calls
Add the tool execution handler to src/server.ts:
// ──────────────────────────────────────────
// HANDLE TOOL CALLS
// ──────────────────────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'list_articles': {
const data = await client.getArticles({
'filter[catid]': args?.category_id,
'filter[state]': args?.state,
'page[limit]': args?.limit ?? 20,
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
};
}
case 'get_article': {
const data = await client.getArticle(args!.id as number);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
};
}
case 'create_article': {
const payload = {
title: args!.title,
catid: args!.category_id,
articletext: args!.body ?? '',
state: args!.state ?? 0,
metadata: { metadesc: args!.metadesc ?? '' },
};
const data = await client.createArticle(payload);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
};
}
case 'update_article': {
const { id, ...fields } = args as Record<string, unknown>;
const data = await client.updateArticle(id as number, fields);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
});
// ──────────────────────────────────────────
// START SERVER
// ──────────────────────────────────────────
const transport = new StdioServerTransport();
await server.connect(transport);
Connecting to Claude Desktop
Add a build script to package.json:
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
}
Then register the server in Claude Desktop's config at ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"joomla": {
"command": "node",
"args": ["/absolute/path/to/joomla-mcp-server/dist/server.js"],
"env": {
"JOOMLA_URL": "https://yoursite.com",
"JOOMLA_API_TOKEN": "your_token_here"
}
}
}
}
Restart Claude Desktop. You should see the Joomla tools appear in Claude's tool list. Test with: "List the 5 most recent published articles on my Joomla site."
Adding More Tools
The four tools above are a minimal viable server. To make it genuinely useful, extend it with:
- search_articles — full-text search across article content.
- list_categories — retrieve the category tree.
- publish_article / unpublish_article — toggle article state.
- upload_media — upload images via the Joomla media API.
- get_site_info — retrieve Joomla version, installed extensions, configuration.
- create_category — create new content categories.
Each new tool follows the same pattern: declare it in ListToolsRequestSchema, handle it in CallToolRequestSchema, and add the corresponding method to JoomlaClient.
Security Best Practices
An MCP server is a powerful interface — treat it accordingly:
- Use a dedicated API user with minimal permissions. Do not use a Super User token for your MCP server unless absolutely necessary.
- Validate all inputs before passing them to the Joomla API. Use Zod schemas to enforce types and ranges.
- Never expose delete tools by default. Require a confirmation step — the AI must call a separate
confirm_deletetool before any destructive operation executes. - Log all tool calls with timestamps. If something goes wrong, you need the audit trail.
- Restrict the server to localhost or a private network. The MCP transport (stdio) is inherently local, but if you later expose this over HTTP, add authentication.
Testing Your Server
Use the MCP Inspector tool for testing before connecting to Claude:
npx @modelcontextprotocol/inspector node dist/server.js
This opens a browser UI where you can call tools manually, inspect inputs and outputs, and verify error handling — without involving an AI model. Fix any issues here before deploying to production.
What You Have Built
A working Joomla MCP server gives you an AI-addressable layer over your Joomla site. Any MCP-compatible AI client — Claude Desktop, Claude Code, or a custom agentic application — can now manage your Joomla content through natural language. The four-tool starter server in this guide is production-ready for basic content management tasks and extensible to cover your full Joomla workflow.