From 2164bdc76620e5f5ee0aa57a46c6726440dd0fba Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Wed, 25 Mar 2026 00:59:45 +0100 Subject: [PATCH] Clean up media tools and add file path uploads --- CLAUDE.md | 4 +- README.md | 38 ++- package.json | 1 + src/tools/media.ts | 500 ++++++++++++++++++++++++++--------- src/types/wordpress-types.ts | 42 ++- 5 files changed, 459 insertions(+), 126 deletions(-) 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 +}