diff --git a/CLAUDE.md b/CLAUDE.md
index 43e0a15..5e02ba6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -151,7 +151,7 @@ Handles ALL taxonomies (categories, tags, custom taxonomies) with a single set o
- `test_site` - Test connection to a WordPress site
#### **Other Specialized Tools**
-- **Media** (`media.ts`): Media library management (~5 tools)
+- **Media** (`media.ts`): Media library management (5 canonical tools plus legacy `edit_media` alias)
- **Users** (`users.ts`): User management (~5 tools)
- **Comments** (`comments.ts`): Comment management (~5 tools)
- **Plugins** (`plugins.ts`): Plugin activation/deactivation (~5 tools)
@@ -249,4 +249,4 @@ The server integrates with Claude Desktop via the configuration in `claude_deskt
- `axios`: HTTP client for WordPress REST API
- `zod`: Runtime type validation for tool inputs
- `dotenv`: Environment variable management
-- `tsx`: TypeScript execution for development
\ No newline at end of file
+- `tsx`: TypeScript execution for development
diff --git a/README.md b/README.md
index b67ba53..e9ae91d 100644
--- a/README.md
+++ b/README.md
@@ -59,9 +59,10 @@ Handles ALL taxonomies (categories, tags, custom taxonomies) with a single set o
- **Media:**
- `list_media`: List all media items (supports pagination and searching).
- `get_media`: Retrieve a specific media item by ID.
- - `create_media`: Create a new media item from a URL.
+ - `create_media`: Create a new media item from a URL or local file path.
- `update_media`: Update an existing media item.
- `delete_media`: Delete a media item.
+ - `edit_media`: Legacy alias for `update_media` kept for backward compatibility.
- **Users:**
- `list_users`: List all users with filtering, sorting, and pagination options.
- `get_user`: Retrieve a specific user by ID.
@@ -88,6 +89,39 @@ Handles ALL taxonomies (categories, tags, custom taxonomies) with a single set o
### **Key Advantages**
+#### Media Upload Workflows
+
+Upload a local screenshot from the same machine running the MCP server:
+
+```json
+{
+ "file_path": "./screenshots/homepage.png",
+ "title": "Homepage Screenshot",
+ "alt_text": "Homepage screenshot showing the hero section"
+}
+```
+
+Upload media from a remote URL:
+
+```json
+{
+ "source_url": "https://example.com/assets/hero-image.png",
+ "title": "Hero Image",
+ "caption": "Imported from the design system"
+}
+```
+
+Use the returned media ID as featured media on new content:
+
+```json
+{
+ "content_type": "post",
+ "title": "Release Notes",
+ "content": "
Launch summary...
",
+ "featured_media": 123
+}
+```
+
#### Smart URL Resolution
The `find_content_by_url` tool can:
@@ -360,7 +394,7 @@ src/
├── site-management.ts # Site management (3 tools)
├── unified-content.ts # Universal content management (8 tools)
├── unified-taxonomies.ts # Universal taxonomy management (8 tools)
- ├── media.ts # Media management (~5 tools)
+ ├── media.ts # Media management (5 canonical tools + edit_media alias)
├── users.ts # User management (~5 tools)
├── comments.ts # Comment management (~5 tools)
├── plugins.ts # Plugin management (~5 tools)
diff --git a/package.json b/package.json
index 8661ed9..fbe7624 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"@modelcontextprotocol/sdk": "^1.4.1",
"axios": "^1.6.7",
"dotenv": "^16.4.5",
+ "form-data": "^4.0.5",
"fs-extra": "^11.2.0",
"marked": "^17.0.0",
"zod": "^3.23.8",
diff --git a/src/tools/media.ts b/src/tools/media.ts
index 8573769..bd5dccf 100644
--- a/src/tools/media.ts
+++ b/src/tools/media.ts
@@ -1,174 +1,432 @@
// src/tools/media.ts
+import axios from 'axios';
+import FormData from 'form-data';
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { makeWordPressRequest } from '../wordpress.js';
+import { WPMedia } from '../types/wordpress-types.js';
import { z } from 'zod';
-// Schema for listing media items
+const mediaContextSchema = z.enum(['view', 'embed', 'edit']);
+const mediaTypeSchema = z.enum(['image', 'video', 'text', 'application', 'audio']);
+const mediaOrderSchema = z.enum(['asc', 'desc']);
+const mediaOrderBySchema = z.enum([
+ 'author',
+ 'date',
+ 'id',
+ 'include',
+ 'modified',
+ 'parent',
+ 'relevance',
+ 'slug',
+ 'include_slugs',
+ 'title'
+]);
+
const listMediaSchema = z.object({
- page: z.number().optional().describe("Page number"),
- per_page: z.number().min(1).max(100).optional().describe("Items per page"),
- search: z.string().optional().describe("Search term for media")
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
+ page: z.coerce.number().optional().describe("Page number (default 1)"),
+ per_page: z.coerce.number().min(1).max(100).optional().describe("Items per page (default 10, max 100)"),
+ search: z.string().optional().describe("Search term for media"),
+ context: mediaContextSchema.optional().describe("Scope under which the request is made"),
+ media_type: mediaTypeSchema.optional().describe("Limit results to a specific media type"),
+ mime_type: z.string().optional().describe("Limit results to a specific MIME type"),
+ parent: z.union([z.coerce.number(), z.array(z.coerce.number())]).optional().describe("Parent content ID or array of parent IDs"),
+ orderby: mediaOrderBySchema.optional().describe("Sort media by parameter"),
+ order: mediaOrderSchema.optional().describe("Order sort attribute ascending or descending"),
+ after: z.string().optional().describe("ISO8601 date string to get media published after this date"),
+ before: z.string().optional().describe("ISO8601 date string to get media published before this date")
+}).strict();
+
+const getMediaSchema = z.object({
+ id: z.coerce.number().describe("Media ID"),
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
+ context: mediaContextSchema.optional().describe("Scope under which the request is made")
}).strict();
-// Schema for creating a new media item
const createMediaSchema = z.object({
- title: z.string().describe("Media title"),
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
+ title: z.string().optional().describe("Media title. If omitted, derived from the uploaded filename."),
alt_text: z.string().optional().describe("Alternate text for the media"),
caption: z.string().optional().describe("Caption of the media"),
description: z.string().optional().describe("Description of the media"),
- source_url: z.string().describe("Source URL of the media file")
+ post: z.coerce.number().optional().describe("Associated post ID"),
+ source_url: z.string().optional().describe("Remote HTTP(S) URL of the media file"),
+ file_path: z.string().optional().describe("Local file path to upload. Relative paths are resolved from the server working directory.")
}).strict();
-// Schema for editing an existing media item
-const editMediaSchema = z.object({
- id: z.coerce.number().describe("Media ID to edit"),
+const updateMediaSchema = z.object({
+ id: z.coerce.number().describe("Media ID to update"),
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
title: z.string().optional().describe("Media title"),
alt_text: z.string().optional().describe("Alternate text for the media"),
caption: z.string().optional().describe("Caption of the media"),
- description: z.string().optional().describe("Description of the media")
+ description: z.string().optional().describe("Description of the media"),
+ post: z.coerce.number().optional().describe("Associated post ID")
}).strict();
-// Schema for deleting a media item
const deleteMediaSchema = z.object({
id: z.coerce.number().describe("Media ID to delete"),
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
force: z.boolean().optional().describe("Force deletion bypassing trash")
}).strict();
-// Define the tool set for media operations
+type ListMediaParams = z.infer;
+type GetMediaParams = z.infer;
+type CreateMediaParams = z.infer;
+type UpdateMediaParams = z.infer;
+type DeleteMediaParams = z.infer;
+
+type UploadSource = {
+ buffer: Buffer;
+ filename: string;
+ mimeType: string;
+ derivedTitle: string;
+};
+
+const MIME_TYPE_BY_EXTENSION: Record = {
+ '.avif': 'image/avif',
+ '.gif': 'image/gif',
+ '.jpeg': 'image/jpeg',
+ '.jpg': 'image/jpeg',
+ '.json': 'application/json',
+ '.mov': 'video/quicktime',
+ '.mp3': 'audio/mpeg',
+ '.mp4': 'video/mp4',
+ '.pdf': 'application/pdf',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.txt': 'text/plain',
+ '.wav': 'audio/wav',
+ '.webm': 'video/webm',
+ '.webp': 'image/webp',
+ '.zip': 'application/zip'
+};
+
+const DEFAULT_EXTENSION_BY_MIME_TYPE: Record = {
+ 'application/json': '.json',
+ 'application/pdf': '.pdf',
+ 'application/zip': '.zip',
+ 'audio/mpeg': '.mp3',
+ 'audio/wav': '.wav',
+ 'image/avif': '.avif',
+ 'image/gif': '.gif',
+ 'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/svg+xml': '.svg',
+ 'image/webp': '.webp',
+ 'text/plain': '.txt',
+ 'video/mp4': '.mp4',
+ 'video/quicktime': '.mov',
+ 'video/webm': '.webm'
+};
+
+function successResult(payload: unknown) {
+ return {
+ toolResult: {
+ isError: false,
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }]
+ }
+ };
+}
+
+function errorResult(action: string, error: unknown) {
+ const errorMessage = error instanceof Error
+ ? error.message
+ : ((error as any)?.response?.data?.message || (error as any)?.message || String(error));
+
+ return {
+ toolResult: {
+ isError: true,
+ content: [{ type: 'text', text: `Error ${action}: ${errorMessage}` }]
+ }
+ };
+}
+
+function sanitizeFilenamePart(value: string) {
+ const sanitized = value
+ .trim()
+ .replace(/[^\w.-]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+
+ return sanitized || 'upload';
+}
+
+function humanizeTitle(filename: string) {
+ const basename = path.parse(filename).name;
+ const normalized = basename.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
+ return normalized || 'Upload';
+}
+
+function inferMimeType(filename: string) {
+ return MIME_TYPE_BY_EXTENSION[path.extname(filename).toLowerCase()] || 'application/octet-stream';
+}
+
+function defaultExtensionForMimeType(mimeType: string) {
+ return DEFAULT_EXTENSION_BY_MIME_TYPE[mimeType] || '';
+}
+
+function ensureFilenameHasExtension(filename: string, mimeType: string) {
+ if (path.extname(filename)) {
+ return filename;
+ }
+
+ const extension = defaultExtensionForMimeType(mimeType);
+ return extension ? `${filename}${extension}` : filename;
+}
+
+function buildUploadFilename(originalFilename: string, explicitTitle?: string) {
+ if (!explicitTitle?.trim()) {
+ return originalFilename;
+ }
+
+ const extension = path.extname(originalFilename);
+ const sanitizedTitle = sanitizeFilenamePart(explicitTitle);
+ return extension ? `${sanitizedTitle}${extension}` : sanitizedTitle;
+}
+
+function normalizeMimeType(contentTypeHeader?: string) {
+ if (!contentTypeHeader) {
+ return 'application/octet-stream';
+ }
+
+ return contentTypeHeader.split(';')[0].trim() || 'application/octet-stream';
+}
+
+function isHttpUrl(value: string) {
+ try {
+ const url = new URL(value);
+ return url.protocol === 'http:' || url.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+function deriveFilenameFromUrl(sourceUrl: string, mimeType: string) {
+ try {
+ const url = new URL(sourceUrl);
+ const pathname = decodeURIComponent(url.pathname);
+ const basename = path.basename(pathname);
+
+ if (basename && basename !== '.' && basename !== '/') {
+ return ensureFilenameHasExtension(basename, mimeType);
+ }
+ } catch {
+ // Invalid URLs are handled earlier; this only protects filename parsing.
+ }
+
+ return `upload${defaultExtensionForMimeType(mimeType)}`;
+}
+
+async function loadUploadFromFilePath(filePath: string, explicitTitle?: string): Promise {
+ const resolvedPath = path.resolve(process.cwd(), filePath);
+
+ let fileStats;
+ try {
+ fileStats = await fs.stat(resolvedPath);
+ } catch (error: any) {
+ if (error.code === 'ENOENT') {
+ throw new Error(`File not found: ${filePath}`);
+ }
+ throw new Error(`Unable to access file_path '${filePath}': ${error.message}`);
+ }
+
+ if (!fileStats.isFile()) {
+ throw new Error(`Path is not a file: ${filePath}`);
+ }
+
+ const originalFilename = path.basename(resolvedPath);
+ const filename = buildUploadFilename(originalFilename, explicitTitle);
+ const mimeType = inferMimeType(originalFilename);
+ const buffer = await fs.readFile(resolvedPath);
+
+ return {
+ buffer,
+ filename,
+ mimeType,
+ derivedTitle: explicitTitle?.trim() || humanizeTitle(filename)
+ };
+}
+
+async function loadUploadFromUrl(sourceUrl: string, explicitTitle?: string): Promise {
+ if (!isHttpUrl(sourceUrl)) {
+ throw new Error('source_url must be an absolute http or https URL');
+ }
+
+ const response = await axios.get(sourceUrl, { responseType: 'arraybuffer' });
+ const mimeType = normalizeMimeType(response.headers['content-type']);
+ const originalFilename = deriveFilenameFromUrl(sourceUrl, mimeType);
+ const filename = buildUploadFilename(originalFilename, explicitTitle);
+
+ return {
+ buffer: Buffer.from(response.data),
+ filename,
+ mimeType,
+ derivedTitle: explicitTitle?.trim() || humanizeTitle(filename)
+ };
+}
+
+async function loadUploadSource(params: CreateMediaParams) {
+ const sourceCount = Number(Boolean(params.source_url)) + Number(Boolean(params.file_path));
+
+ if (sourceCount !== 1) {
+ throw new Error('Provide exactly one of source_url or file_path when creating media.');
+ }
+
+ if (params.file_path) {
+ return loadUploadFromFilePath(params.file_path, params.title);
+ }
+
+ return loadUploadFromUrl(params.source_url!, params.title);
+}
+
+function appendOptionalFormField(form: FormData, key: string, value: string | number | undefined) {
+ if (value === undefined) {
+ return;
+ }
+
+ form.append(key, String(value));
+}
+
+async function uploadMedia(params: CreateMediaParams): Promise {
+ const uploadSource = await loadUploadSource(params);
+ const form = new FormData();
+ const title = params.title?.trim() || uploadSource.derivedTitle;
+
+ form.append('file', uploadSource.buffer, {
+ filename: uploadSource.filename,
+ contentType: uploadSource.mimeType
+ });
+
+ appendOptionalFormField(form, 'title', title);
+ appendOptionalFormField(form, 'alt_text', params.alt_text);
+ appendOptionalFormField(form, 'caption', params.caption);
+ appendOptionalFormField(form, 'description', params.description);
+ appendOptionalFormField(form, 'post', params.post);
+
+ const response = await makeWordPressRequest('POST', 'media', form, {
+ isFormData: true,
+ headers: form.getHeaders(),
+ siteId: params.site_id
+ });
+
+ return response as WPMedia;
+}
+
+function buildMediaUpdateData(params: UpdateMediaParams) {
+ const updateData: Record = {};
+
+ if (params.title !== undefined) updateData.title = params.title;
+ if (params.alt_text !== undefined) updateData.alt_text = params.alt_text;
+ if (params.caption !== undefined) updateData.caption = params.caption;
+ if (params.description !== undefined) updateData.description = params.description;
+ if (params.post !== undefined) updateData.post = params.post;
+
+ if (Object.keys(updateData).length === 0) {
+ throw new Error('Provide at least one field to update.');
+ }
+
+ return updateData;
+}
+
+const updateMediaHandler = async (params: UpdateMediaParams) => {
+ try {
+ const { id, site_id } = params;
+ const response = await makeWordPressRequest(
+ 'POST',
+ `media/${id}`,
+ buildMediaUpdateData(params),
+ { siteId: site_id }
+ );
+
+ const media: WPMedia = response;
+ return successResult(media);
+ } catch (error: any) {
+ return errorResult('updating media', error);
+ }
+};
+
export const mediaTools: Tool[] = [
{
- name: "list_media",
- description: "Lists media items with filtering and pagination options",
- inputSchema: { type: "object", properties: listMediaSchema.shape }
+ name: 'list_media',
+ description: 'Lists media items with filtering and pagination options',
+ inputSchema: { type: 'object', properties: listMediaSchema.shape }
+ },
+ {
+ name: 'get_media',
+ description: 'Gets a media item by ID',
+ inputSchema: { type: 'object', properties: getMediaSchema.shape }
+ },
+ {
+ name: 'create_media',
+ description: 'Creates a new media item from a URL or local file path',
+ inputSchema: { type: 'object', properties: createMediaSchema.shape }
},
{
- name: "create_media",
- description: "Creates a new media item",
- inputSchema: { type: "object", properties: createMediaSchema.shape }
+ name: 'update_media',
+ description: 'Updates an existing media item',
+ inputSchema: { type: 'object', properties: updateMediaSchema.shape }
},
{
- name: "edit_media",
- description: "Updates an existing media item",
- inputSchema: { type: "object", properties: editMediaSchema.shape }
+ name: 'edit_media',
+ description: 'Legacy alias for update_media',
+ inputSchema: { type: 'object', properties: updateMediaSchema.shape }
},
{
- name: "delete_media",
- description: "Deletes a media item",
- inputSchema: { type: "object", properties: deleteMediaSchema.shape }
+ name: 'delete_media',
+ description: 'Deletes a media item',
+ inputSchema: { type: 'object', properties: deleteMediaSchema.shape }
}
];
-// Define handlers for each media operation
export const mediaHandlers = {
- list_media: async (params: z.infer) => {
+ list_media: async (params: ListMediaParams) => {
try {
- const response = await makeWordPressRequest("GET", "media", params);
- return {
- toolResult: {
- content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
- }
- };
+ const { site_id, ...queryParams } = params;
+ const response = await makeWordPressRequest('GET', 'media', queryParams, { siteId: site_id });
+ const media: WPMedia[] = response;
+ return successResult(media);
} catch (error: any) {
- const errorMessage = error.response?.data?.message || error.message;
- return {
- toolResult: {
- isError: true,
- content: [{ type: "text", text: `Error listing media: ${errorMessage}` }]
- }
- };
+ return errorResult('listing media', error);
}
},
- create_media: async (params: z.infer) => {
+
+ get_media: async (params: GetMediaParams) => {
try {
- if (params.source_url && params.source_url.startsWith('http')) {
- // Download the media file from the URL and upload as multipart form-data
- const axios = (await import('axios')).default;
- const FormData = (await import('form-data')).default;
- const fileRes = await axios.get(params.source_url, { responseType: 'arraybuffer' });
- // Derive a filename from the title or fallback
- const filename = params.title ? `${params.title.replace(/\s+/g, '_')}.jpg` : 'upload.jpg';
-
- const form = new FormData();
- form.append('file', Buffer.from(fileRes.data), {
- filename: filename,
- contentType: fileRes.headers['content-type'] || 'application/octet-stream'
- });
- // Append additional fields if provided
- if (params.title) form.append('title', params.title);
- if (params.alt_text) form.append('alt_text', params.alt_text);
- if (params.caption) form.append('caption', params.caption);
- if (params.description) form.append('description', params.description);
-
- // Use the enhanced makeWordPressRequest function with FormData support
- const response = await makeWordPressRequest(
- 'POST',
- 'media',
- form,
- {
- isFormData: true,
- headers: form.getHeaders(),
- rawResponse: true
- }
- );
- return {
- toolResult: {
- content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }]
- }
- };
- } else {
- const response = await makeWordPressRequest("POST", "media", params);
- return {
- toolResult: {
- content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
- }
- };
- }
+ const response = await makeWordPressRequest(
+ 'GET',
+ `media/${params.id}`,
+ params.context ? { context: params.context } : undefined,
+ { siteId: params.site_id }
+ );
+
+ const media: WPMedia = response;
+ return successResult(media);
} catch (error: any) {
- const errorMessage = error.response?.data?.message || error.message;
- return {
- toolResult: {
- isError: true,
- content: [{ type: "text", text: `Error creating media: ${errorMessage}` }]
- }
- };
+ return errorResult('getting media', error);
}
},
- edit_media: async (params: z.infer) => {
+
+ create_media: async (params: CreateMediaParams) => {
try {
- const { id, ...updateData } = params;
- const response = await makeWordPressRequest("POST", `media/${id}`, updateData);
- return {
- toolResult: {
- content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
- }
- };
+ const media = await uploadMedia(params);
+ return successResult(media);
} catch (error: any) {
- const errorMessage = error.response?.data?.message || error.message;
- return {
- toolResult: {
- isError: true,
- content: [{ type: "text", text: `Error editing media: ${errorMessage}` }]
- }
- };
+ return errorResult('creating media', error);
}
},
- delete_media: async (params: z.infer) => {
+
+ update_media: updateMediaHandler,
+ edit_media: updateMediaHandler,
+
+ delete_media: async (params: DeleteMediaParams) => {
try {
- const { id, ...deleteData } = params;
- const response = await makeWordPressRequest("DELETE", `media/${id}`, deleteData);
- return {
- toolResult: {
- content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
- }
- };
+ const { id, force, site_id } = params;
+ const response = await makeWordPressRequest('DELETE', `media/${id}`, { force }, { siteId: site_id });
+ return successResult(response);
} catch (error: any) {
- const errorMessage = error.response?.data?.message || error.message;
- return {
- toolResult: {
- isError: true,
- content: [{ type: "text", text: `Error deleting media: ${errorMessage}` }]
- }
- };
+ return errorResult('deleting media', error);
}
}
};
diff --git a/src/types/wordpress-types.ts b/src/types/wordpress-types.ts
index 8927d43..48cd872 100644
--- a/src/types/wordpress-types.ts
+++ b/src/types/wordpress-types.ts
@@ -128,6 +128,46 @@ export interface WPComment {
_links: Record;
}
+// WordPress Media type
+export interface WPMedia {
+ id: number;
+ date: string | null;
+ date_gmt: string | null;
+ guid: {
+ rendered: string;
+ };
+ modified: string;
+ modified_gmt: string;
+ slug: string;
+ status: string;
+ type: string;
+ link: string;
+ title: {
+ rendered: string;
+ };
+ author: number;
+ comment_status: string;
+ ping_status: string;
+ template: string;
+ meta: Record;
+ alt_text: string;
+ caption: {
+ rendered: string;
+ raw?: string;
+ };
+ description: {
+ rendered: string;
+ raw?: string;
+ };
+ media_type: string;
+ mime_type: string;
+ media_details: Record;
+ post: number;
+ source_url: string;
+ missing_image_sizes?: string[];
+ _links: Record;
+}
+
// WordPress Custom Post Type
export interface WPCustomPost extends WPContent {
// Custom post types can have any additional fields
@@ -171,4 +211,4 @@ export interface WPTerm {
parent: number;
meta: Record[];
_links: Record;
-}
\ No newline at end of file
+}