diff --git a/.gitignore b/.gitignore index 0274e12..67bfec3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build logs *.code-workspace .claude/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index f9302c2..227304e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Legacy numbered `WORDPRESS_N_*` multi-site env configuration is no longer suppor - unified taxonomy tools - media, users, comments, plugins, and plugin-repository tools - optional SQL query tool with custom endpoint +- ACF/ACF Pro REST support for exposed field groups on content, taxonomy terms, and users ## Tool Surface @@ -70,6 +71,8 @@ These now describe the current request-scoped site only. - `find_content_by_url` - `get_content_by_slug` +Content read tools support `fields: ["acf"]` and `acf_format` for focused ACF reads. Content create/update tools support a nested `acf` object for ACF/ACF Pro writes. + ### Taxonomies - `discover_taxonomies` @@ -81,6 +84,14 @@ These now describe the current request-scoped site only. - `assign_terms_to_content` - `get_content_terms` +Term read tools support `fields: ["acf"]` and `acf_format` for focused ACF reads. Term create/update tools support a nested `acf` object for ACF/ACF Pro writes. + +### ACF + +- `get_acf_schema` + +Use `get_acf_schema` before writing unknown ACF fields. It checks the WordPress REST `OPTIONS` schema for content, taxonomy terms, or users and returns only fields exposed by ACF through REST. ACF writes must be sent under the nested `acf` object on the relevant create/update tool. + ### Media - `list_media` diff --git a/package.json b/package.json index 550d700..2bfca42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/mcp-wordpress", - "version": "0.1.0", + "version": "0.2.0", "description": "A Model Context Protocol server for interacting with WordPress.", "type": "module", "main": "./build/server.js", @@ -9,7 +9,7 @@ "mcp-wp": "./build/server.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "scripts": { "build": "tsc --project tsconfig.json", @@ -17,7 +17,9 @@ "dev": "tsx watch src/server.ts", "clean": "rimraf build", "test": "vitest run", + "test:login": "tsx ./scripts/test-login.ts", "test:watch": "vitest", + "typecheck:scripts": "tsc --project tsconfig.scripts.json", "prepare": "npm run build" }, "keywords": [ diff --git a/src/server.ts b/src/server.ts index c13c280..cb5871b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,7 +35,8 @@ for (const tool of allTools) { continue } - const parameters = z.object(tool.inputSchema.properties as z.ZodRawShape) + const toolDefinition = tool as typeof tool & { zodSchema?: z.ZodTypeAny } + const parameters = toolDefinition.zodSchema ?? z.object(tool.inputSchema.properties as z.ZodRawShape) server.addTool({ name: tool.name, @@ -86,4 +87,3 @@ process.on('unhandledRejection', () => { void main().catch(() => { void shutdown(1) }) - diff --git a/src/tools/acf.ts b/src/tools/acf.ts new file mode 100644 index 0000000..8cd12a5 --- /dev/null +++ b/src/tools/acf.ts @@ -0,0 +1,285 @@ +import { type Tool } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' +import { + describeRestRoute, + makeRestRouteRequest, + resolveContentRoute, + resolveTaxonomyRoute, + type RestRoute, +} from './rest-helpers.js' +import { makeWordPressRequest } from '../wordpress.js' + +type ToolWithZodSchema = Tool & { + zodSchema?: z.ZodTypeAny +} + +const contentAcfSchemaTarget = z + .object({ + target: z.literal('content').describe('Use for posts, pages, and custom post types.'), + content_type: z + .string() + .describe('WordPress post type slug, such as post, page, book, product, or another custom post type slug.'), + id: z + .number() + .optional() + .describe('Optional content ID. Omit to inspect the collection schema for this post type.'), + }) + .strict() + +const termAcfSchemaTarget = z + .object({ + target: z.literal('term').describe('Use for categories, tags, and custom taxonomy terms.'), + taxonomy: z + .string() + .describe('WordPress taxonomy slug, such as category, post_tag, genre, or another custom taxonomy slug.'), + id: z + .number() + .optional() + .describe('Optional term ID. Omit to inspect the collection schema for this taxonomy.'), + }) + .strict() + +const userAcfSchemaTarget = z + .object({ + target: z.literal('user').describe('Use for ACF field groups attached to WordPress users.'), + id: z + .union([z.number(), z.literal('me')]) + .optional() + .describe('Optional user ID, or "me" for the authenticated user. Omit to inspect the users collection schema.'), + }) + .strict() + +const getAcfSchemaSchema = z.discriminatedUnion('target', [ + contentAcfSchemaTarget, + termAcfSchemaTarget, + userAcfSchemaTarget, +]) + +type GetAcfSchemaParams = z.infer + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readPath(source: unknown, path: string[]): unknown { + return path.reduce((current, key) => { + if (!isRecord(current)) { + return undefined + } + + return current[key] + }, source) +} + +function findAcfSchemaDeep(source: unknown, depth = 0): Record | null { + if (depth > 8) { + return null + } + + if (Array.isArray(source)) { + for (const item of source) { + const found = findAcfSchemaDeep(item, depth + 1) + if (found) { + return found + } + } + + return null + } + + if (!isRecord(source)) { + return null + } + + const acf = source.acf + if (isRecord(acf) && isRecord(acf.properties)) { + return acf + } + + for (const value of Object.values(source)) { + const found = findAcfSchemaDeep(value, depth + 1) + if (found) { + return found + } + } + + return null +} + +function extractAcfSchema(response: unknown): Record | null { + const candidates = [ + readPath(response, ['acf']), + readPath(response, ['schema', 'properties', 'acf']), + readPath(response, ['routes']), + ] + + for (const candidate of candidates) { + if (isRecord(candidate) && isRecord(candidate.properties)) { + return candidate + } + } + + if (isRecord(response)) { + for (const routeDefinition of Object.values(response)) { + const routeAcfSchema = readPath(routeDefinition, ['schema', 'properties', 'acf']) + if (isRecord(routeAcfSchema) && isRecord(routeAcfSchema.properties)) { + return routeAcfSchema + } + } + } + + const routes = readPath(response, ['routes']) + if (isRecord(routes)) { + for (const routeDefinition of Object.values(routes)) { + const routeAcfSchema = readPath(routeDefinition, ['schema', 'properties', 'acf']) + if (isRecord(routeAcfSchema) && isRecord(routeAcfSchema.properties)) { + return routeAcfSchema + } + } + } + + return findAcfSchemaDeep(response) +} + +async function requestOptionsForResolvedRoute(route: RestRoute, id?: number): Promise { + const suffix = id === undefined ? '' : `/${id}` + return makeRestRouteRequest('OPTIONS', route, suffix) +} + +async function resolveAcfSchemaRequest( + params: GetAcfSchemaParams, +): Promise<{ response: unknown; resolvedEndpoint: string }> { + if (params.target === 'content') { + const route = await resolveContentRoute(params.content_type) + return { + response: await requestOptionsForResolvedRoute(route, params.id), + resolvedEndpoint: describeRestRoute({ + namespace: route.namespace, + endpoint: params.id === undefined ? route.endpoint : `${route.endpoint}/${params.id}`, + }), + } + } + + if (params.target === 'term') { + const route = await resolveTaxonomyRoute(params.taxonomy) + return { + response: await requestOptionsForResolvedRoute(route, params.id), + resolvedEndpoint: describeRestRoute({ + namespace: route.namespace, + endpoint: params.id === undefined ? route.endpoint : `${route.endpoint}/${params.id}`, + }), + } + } + + const endpoint = params.id === undefined ? 'users' : `users/${params.id}` + return { + response: await makeWordPressRequest('OPTIONS', endpoint), + resolvedEndpoint: `wp/v2/${endpoint}`, + } +} + +export const acfTools: ToolWithZodSchema[] = [ + { + name: 'get_acf_schema', + description: + 'Discovers Advanced Custom Fields (ACF/ACF Pro) REST schema for content, terms, or users. Use this before writing unknown ACF fields. It returns only fields exposed by WordPress/ACF through REST; it does not infer database meta keys. When updating ACF fields, pass values under the nested "acf" object on the relevant create/update tool.', + inputSchema: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + target: { const: 'content', description: 'Posts, pages, and custom post types.' }, + content_type: { + type: 'string', + description: 'WordPress post type slug, such as post, page, book, or product.', + }, + id: { + type: 'number', + description: 'Optional content ID. Omit to inspect the collection schema.', + }, + }, + required: ['target', 'content_type'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + target: { const: 'term', description: 'Categories, tags, and custom taxonomy terms.' }, + taxonomy: { + type: 'string', + description: 'WordPress taxonomy slug, such as category, post_tag, or genre.', + }, + id: { + type: 'number', + description: 'Optional term ID. Omit to inspect the collection schema.', + }, + }, + required: ['target', 'taxonomy'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + target: { const: 'user', description: 'WordPress users.' }, + id: { + anyOf: [{ type: 'number' }, { const: 'me' }], + description: 'Optional user ID, or "me" for the authenticated user.', + }, + }, + required: ['target'], + additionalProperties: false, + }, + ], + }, + zodSchema: getAcfSchemaSchema, + }, +] + +export const acfHandlers = { + get_acf_schema: async (params: GetAcfSchemaParams) => { + try { + const { response, resolvedEndpoint } = await resolveAcfSchemaRequest(params) + const rawAcfSchema = extractAcfSchema(response) + const acfSchema = isRecord(rawAcfSchema?.properties) ? rawAcfSchema.properties : {} + const acfAvailable = Object.keys(acfSchema).length > 0 + + return { + toolResult: { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + target: params.target, + resolved_endpoint: resolvedEndpoint, + acf_available: acfAvailable, + acf_schema: acfSchema, + raw_acf_schema: rawAcfSchema, + message: acfAvailable + ? 'ACF fields are exposed in the REST schema. Use these field names under the nested "acf" object when creating or updating.' + : 'No ACF schema was present in the REST OPTIONS response. ACF may be disabled, the field group may not have Show in REST API enabled, or no ACF field group applies to this target.', + }, + null, + 2, + ), + }, + ], + isError: false, + }, + } + } catch (error: any) { + return { + toolResult: { + content: [ + { + type: 'text' as const, + text: `Error getting ACF schema: ${error.response?.data?.message || error.message}`, + }, + ], + isError: true, + }, + } + } + }, +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 6a5bab3..8b3e0ac 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ import { pluginRepositoryTools, pluginRepositoryHandlers } from './plugin-reposi import { commentTools, commentHandlers } from './comments.js'; import { siteManagementTools, siteManagementHandlers } from './site-management.js'; import { sqlQueryTools, sqlQueryHandlers } from './sql-query.js'; +import { acfTools, acfHandlers } from './acf.js'; // Combine all tools - now significantly reduced from ~65 to ~38 tools export const allTools: Tool[] = [ @@ -20,6 +21,7 @@ export const allTools: Tool[] = [ ...pluginRepositoryTools, // ~2 tools ...commentTools, // ~5 tools ...siteManagementTools, // 3 tools (current request site support) + ...acfTools, // 1 tool (ACF schema discovery) ...sqlQueryTools ]; @@ -33,5 +35,6 @@ export const toolHandlers = { ...pluginRepositoryHandlers, ...commentHandlers, ...siteManagementHandlers, + ...acfHandlers, ...sqlQueryHandlers }; diff --git a/src/tools/rest-helpers.ts b/src/tools/rest-helpers.ts new file mode 100644 index 0000000..631fa54 --- /dev/null +++ b/src/tools/rest-helpers.ts @@ -0,0 +1,172 @@ +import { + getCurrentSiteCacheKey, + logToFile, + makeWordPressRequest, + makeWordPressRestRequest, + type RequestOptions, +} from '../wordpress.js' + +export type RestRoute = { + namespace: string + endpoint: string +} + +export type AcfFormat = 'light' | 'standard' + +export type RestReadOptions = { + fields?: string[] + acf_format?: AcfFormat +} + +const CACHE_DURATION = 5 * 60 * 1000 +const postTypesCache = new Map; timestamp: number }>() +const taxonomiesCache = new Map; timestamp: number }>() + +function normalizeNamespace(namespace: unknown): string { + return typeof namespace === 'string' && namespace.trim().length > 0 ? namespace.trim() : 'wp/v2' +} + +function normalizeEndpoint(endpoint: string): string { + return endpoint.replace(/^\/+|\/+$/g, '') +} + +function fallbackContentEndpoint(contentType: string): string { + const endpointMap: Record = { + post: 'posts', + page: 'pages', + } + + return endpointMap[contentType] || contentType +} + +function fallbackTaxonomyEndpoint(taxonomy: string): string { + const endpointMap: Record = { + category: 'categories', + post_tag: 'tags', + nav_menu: 'menus', + link_category: 'link_categories', + } + + return endpointMap[taxonomy] || taxonomy +} + +export function describeRestRoute(route: RestRoute): string { + return `${normalizeNamespace(route.namespace)}/${normalizeEndpoint(route.endpoint)}` +} + +export function buildReadQueryParams( + options: RestReadOptions, + requiredFields: string[] = [], +): Record { + const queryParams: Record = {} + const requestedFields = options.fields ?? [] + const mergedFields = [...new Set([...requestedFields, ...requiredFields])] + + if (mergedFields.length > 0) { + queryParams._fields = mergedFields.join(',') + } + + if (options.acf_format !== undefined) { + queryParams.acf_format = options.acf_format + } + + return queryParams +} + +export async function getPostTypes(forceRefresh = false): Promise> { + const now = Date.now() + const cacheKey = getCurrentSiteCacheKey() + const cached = postTypesCache.get(cacheKey) + + if (!forceRefresh && cached && now - cached.timestamp < CACHE_DURATION) { + logToFile('Using cached post types') + return cached.value + } + + logToFile('Fetching post types from API') + const response = await makeWordPressRequest('GET', 'types') + postTypesCache.set(cacheKey, { value: response, timestamp: now }) + return response +} + +export async function getTaxonomies(forceRefresh = false): Promise> { + const now = Date.now() + const cacheKey = getCurrentSiteCacheKey() + const cached = taxonomiesCache.get(cacheKey) + + if (!forceRefresh && cached && now - cached.timestamp < CACHE_DURATION) { + logToFile('Using cached taxonomies') + return cached.value + } + + logToFile('Fetching taxonomies from API') + const response = await makeWordPressRequest('GET', 'taxonomies') + taxonomiesCache.set(cacheKey, { value: response, timestamp: now }) + return response +} + +export async function resolveContentRoute(contentType: string): Promise { + try { + const postTypes = await getPostTypes() + const postType = postTypes[contentType] + + if (postType && typeof postType === 'object') { + return { + namespace: normalizeNamespace(postType.rest_namespace), + endpoint: normalizeEndpoint( + typeof postType.rest_base === 'string' && postType.rest_base.length > 0 + ? postType.rest_base + : fallbackContentEndpoint(contentType), + ), + } + } + } catch (error) { + logToFile(`Warning: Could not resolve REST base for content type "${contentType}": ${String(error)}`) + } + + return { + namespace: 'wp/v2', + endpoint: fallbackContentEndpoint(contentType), + } +} + +export async function resolveTaxonomyRoute(taxonomy: string): Promise { + try { + const taxonomies = await getTaxonomies() + const taxonomyDefinition = taxonomies[taxonomy] + + if (taxonomyDefinition && typeof taxonomyDefinition === 'object') { + return { + namespace: normalizeNamespace(taxonomyDefinition.rest_namespace), + endpoint: normalizeEndpoint( + typeof taxonomyDefinition.rest_base === 'string' && taxonomyDefinition.rest_base.length > 0 + ? taxonomyDefinition.rest_base + : fallbackTaxonomyEndpoint(taxonomy), + ), + } + } + } catch (error) { + logToFile(`Warning: Could not resolve REST base for taxonomy "${taxonomy}": ${String(error)}`) + } + + return { + namespace: 'wp/v2', + endpoint: fallbackTaxonomyEndpoint(taxonomy), + } +} + +export async function makeRestRouteRequest( + method: Parameters[0], + route: RestRoute, + endpointSuffix = '', + data?: unknown, + options?: RequestOptions, +): Promise { + const endpoint = normalizeEndpoint(`${route.endpoint}${endpointSuffix}`) + + if (normalizeNamespace(route.namespace) === 'wp/v2') { + return makeWordPressRequest(method, endpoint, data, options) + } + + return makeWordPressRestRequest(method, route.namespace, endpoint, data, options) +} diff --git a/src/tools/unified-content.ts b/src/tools/unified-content.ts index b93c737..e3e11c3 100644 --- a/src/tools/unified-content.ts +++ b/src/tools/unified-content.ts @@ -1,42 +1,14 @@ // src/tools/unified-content.ts import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { getCurrentSiteCacheKey, makeWordPressRequest, logToFile } from '../wordpress.js'; +import { logToFile } from '../wordpress.js'; import { z } from 'zod'; - -const postTypesCache = new Map(); -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes - -// Helper function to get all post types with caching -async function getPostTypes(forceRefresh = false) { - const now = Date.now(); - const cacheKey = getCurrentSiteCacheKey(); - const cached = postTypesCache.get(cacheKey); - - if (!forceRefresh && cached && (now - cached.timestamp) < CACHE_DURATION) { - logToFile('Using cached post types'); - return cached.value; - } - - try { - logToFile('Fetching post types from API'); - const response = await makeWordPressRequest('GET', 'types'); - postTypesCache.set(cacheKey, { value: response, timestamp: now }); - return response; - } catch (error: any) { - logToFile(`Error fetching post types: ${error.message}`); - throw error; - } -} - -// Helper function to get the correct endpoint for a content type -function getContentEndpoint(contentType: string): string { - const endpointMap: Record = { - 'post': 'posts', - 'page': 'pages' - }; - - return endpointMap[contentType] || contentType; -} +import { + buildReadQueryParams, + getPostTypes, + makeRestRouteRequest, + resolveContentRoute, + type RestReadOptions, +} from './rest-helpers.js'; // Helper function to parse URL and extract slug and potential post type hints function parseUrl(url: string): { slug: string; pathHints: string[] } { @@ -61,7 +33,7 @@ function parseUrl(url: string): { slug: string; pathHints: string[] } { } // Helper function to find content across multiple post types -async function findContentAcrossTypes(slug: string, contentTypes?: string[]) { +async function findContentAcrossTypes(slug: string, contentTypes?: string[], readOptions: RestReadOptions = {}) { const typesToSearch = contentTypes || []; // If no specific content types provided, get all available types @@ -77,11 +49,11 @@ async function findContentAcrossTypes(slug: string, contentTypes?: string[]) { // Search each content type for the slug for (const contentType of typesToSearch) { try { - const endpoint = getContentEndpoint(contentType); - - const response = await makeWordPressRequest('GET', endpoint, { + const route = await resolveContentRoute(contentType); + const response = await makeRestRouteRequest('GET', route, '', { slug: slug, - per_page: 1 + per_page: 1, + ...buildReadQueryParams(readOptions, ['id']) }); if (Array.isArray(response) && response.length > 0) { @@ -97,6 +69,21 @@ async function findContentAcrossTypes(slug: string, contentTypes?: string[]) { } // Schema definitions +const restFieldsSchema = z + .array(z.string()) + .optional() + .describe('Optional WordPress REST _fields selector. Use ["acf"] to return only ACF data, or entries like "acf.author", "id", and "title.rendered" for focused reads.'); + +const acfFormatSchema = z + .enum(['light', 'standard']) + .optional() + .describe('Optional ACF REST output format. "light" is the ACF default raw/schema-aligned format; "standard" applies full ACF formatting for richer field values such as images.'); + +const acfPayloadSchema = z + .record(z.unknown()) + .optional() + .describe('Advanced Custom Fields (ACF/ACF Pro) field values. These are sent exactly as the nested WordPress REST "acf" object. Use get_acf_schema first when field names or value types are unknown.'); + const listContentSchema = z.object({ content_type: z.string().describe("The content type slug (e.g., 'post', 'page', 'product', 'documentation')"), page: z.number().optional().describe("Page number (default 1)"), @@ -111,12 +98,16 @@ const listContentSchema = z.object({ orderby: z.string().optional().describe("Sort content by parameter"), order: z.enum(['asc', 'desc']).optional().describe("Order sort attribute"), after: z.string().optional().describe("ISO8601 date string to get content published after this date"), - before: z.string().optional().describe("ISO8601 date string to get content published before this date") + before: z.string().optional().describe("ISO8601 date string to get content published before this date"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }); const getContentSchema = z.object({ content_type: z.string().describe("The content type slug"), - id: z.number().describe("Content ID") + id: z.number().describe("Content ID"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }); const createContentSchema = z.object({ @@ -134,7 +125,8 @@ const createContentSchema = z.object({ format: z.string().optional().describe("Content format"), menu_order: z.number().optional().describe("Menu order (for pages)"), meta: z.record(z.any()).optional().describe("Meta fields"), - custom_fields: z.record(z.any()).optional().describe("Custom fields specific to this content type") + acf: acfPayloadSchema, + custom_fields: z.record(z.any()).optional().describe("Legacy top-level custom REST fields for this content type. Do not use for ACF; use the nested acf object instead.") }); const updateContentSchema = z.object({ @@ -153,7 +145,8 @@ const updateContentSchema = z.object({ format: z.string().optional().describe("Content format"), menu_order: z.number().optional().describe("Menu order"), meta: z.record(z.any()).optional().describe("Meta fields"), - custom_fields: z.record(z.any()).optional().describe("Custom fields") + acf: acfPayloadSchema, + custom_fields: z.record(z.any()).optional().describe("Legacy top-level custom REST fields. Do not use for ACF; use the nested acf object instead.") }); const deleteContentSchema = z.object({ @@ -173,13 +166,16 @@ const findContentByUrlSchema = z.object({ content: z.string().optional(), status: z.string().optional(), meta: z.record(z.any()).optional(), - custom_fields: z.record(z.any()).optional() + acf: acfPayloadSchema, + custom_fields: z.record(z.any()).optional().describe("Legacy top-level custom REST fields. Do not use for ACF; use the nested acf object instead.") }).optional().describe("Optional fields to update after finding the content") }); const getContentBySlugSchema = z.object({ slug: z.string().describe("The slug to search for"), - content_types: z.array(z.string()).optional().describe("Content types to search in (defaults to all)") + content_types: z.array(z.string()).optional().describe("Content types to search in (defaults to all)"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }); // Type definitions @@ -195,22 +191,22 @@ type GetContentBySlugParams = z.infer; export const unifiedContentTools: Tool[] = [ { name: "list_content", - description: "Lists content of any type (posts, pages, or custom post types) with filtering and pagination", + description: "Lists content of any type (posts, pages, or custom post types) with filtering and pagination. ACF fields are returned when the site exposes them; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: listContentSchema.shape } }, { name: "get_content", - description: "Gets specific content by ID and content type", + description: "Gets specific content by ID and content type. ACF fields are returned when the site exposes them; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: getContentSchema.shape } }, { name: "create_content", - description: "Creates new content of any type", + description: "Creates new content of any type. To set ACF/ACF Pro fields, pass them under the nested acf object after verifying unknown fields with get_acf_schema.", inputSchema: { type: "object", properties: createContentSchema.shape } }, { name: "update_content", - description: "Updates existing content of any type", + description: "Updates existing content of any type. To set ACF/ACF Pro fields, pass them under the nested acf object after verifying unknown fields with get_acf_schema.", inputSchema: { type: "object", properties: updateContentSchema.shape } }, { @@ -225,12 +221,12 @@ export const unifiedContentTools: Tool[] = [ }, { name: "find_content_by_url", - description: "Finds content by its URL, automatically detecting the content type, and optionally updates it", + description: "Finds content by its URL, automatically detecting the content type, and optionally updates it. To update ACF/ACF Pro fields, pass them under update_fields.acf.", inputSchema: { type: "object", properties: findContentByUrlSchema.shape } }, { name: "get_content_by_slug", - description: "Searches for content by slug across one or more content types", + description: "Searches for content by slug across one or more content types. ACF fields are returned when exposed; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: getContentBySlugSchema.shape } } ]; @@ -238,10 +234,13 @@ export const unifiedContentTools: Tool[] = [ export const unifiedContentHandlers = { list_content: async (params: ListContentParams) => { try { - const endpoint = getContentEndpoint(params.content_type); - const { content_type, ...queryParams } = params; + const route = await resolveContentRoute(params.content_type); + const { content_type, fields, acf_format, ...queryParams } = params; - const response = await makeWordPressRequest('GET', endpoint, queryParams); + const response = await makeRestRouteRequest('GET', route, '', { + ...queryParams, + ...buildReadQueryParams({ fields, acf_format }) + }); return { toolResult: { @@ -267,8 +266,13 @@ export const unifiedContentHandlers = { get_content: async (params: GetContentParams) => { try { - const endpoint = getContentEndpoint(params.content_type); - const response = await makeWordPressRequest('GET', `${endpoint}/${params.id}`); + const route = await resolveContentRoute(params.content_type); + const response = await makeRestRouteRequest( + 'GET', + route, + `/${params.id}`, + buildReadQueryParams({ fields: params.fields, acf_format: params.acf_format }) + ); return { toolResult: { @@ -294,7 +298,7 @@ export const unifiedContentHandlers = { create_content: async (params: CreateContentParams) => { try { - const endpoint = getContentEndpoint(params.content_type); + const route = await resolveContentRoute(params.content_type); const contentData: any = { title: params.title, @@ -320,6 +324,9 @@ export const unifiedContentHandlers = { if (params.custom_fields) { Object.assign(contentData, params.custom_fields); } + + // Add ACF fields using the documented nested acf REST payload. + if (params.acf !== undefined) contentData.acf = params.acf; // Remove undefined values Object.keys(contentData).forEach(key => { @@ -328,7 +335,7 @@ export const unifiedContentHandlers = { } }); - const response = await makeWordPressRequest('POST', endpoint, contentData); + const response = await makeRestRouteRequest('POST', route, '', contentData); return { toolResult: { @@ -354,7 +361,7 @@ export const unifiedContentHandlers = { update_content: async (params: UpdateContentParams) => { try { - const endpoint = getContentEndpoint(params.content_type); + const route = await resolveContentRoute(params.content_type); const updateData: any = {}; @@ -377,8 +384,11 @@ export const unifiedContentHandlers = { if (params.custom_fields) { Object.assign(updateData, params.custom_fields); } + + // Add ACF fields using the documented nested acf REST payload. + if (params.acf !== undefined) updateData.acf = params.acf; - const response = await makeWordPressRequest('POST', `${endpoint}/${params.id}`, updateData); + const response = await makeRestRouteRequest('POST', route, `/${params.id}`, updateData); return { toolResult: { @@ -404,9 +414,9 @@ export const unifiedContentHandlers = { delete_content: async (params: DeleteContentParams) => { try { - const endpoint = getContentEndpoint(params.content_type); + const route = await resolveContentRoute(params.content_type); - const response = await makeWordPressRequest('DELETE', `${endpoint}/${params.id}`, { + const response = await makeRestRouteRequest('DELETE', route, `/${params.id}`, { force: params.force || false }); @@ -442,6 +452,7 @@ export const unifiedContentHandlers = { name: type.name, description: type.description, rest_base: type.rest_base, + rest_namespace: type.rest_namespace, hierarchical: type.hierarchical, supports: type.supports, taxonomies: type.taxonomies @@ -523,7 +534,7 @@ export const unifiedContentHandlers = { // Update if requested if (params.update_fields) { - const endpoint = getContentEndpoint(contentType); + const route = await resolveContentRoute(contentType); const updateData: any = {}; if (params.update_fields.title !== undefined) updateData.title = params.update_fields.title; @@ -533,8 +544,9 @@ export const unifiedContentHandlers = { if (params.update_fields.custom_fields !== undefined) { Object.assign(updateData, params.update_fields.custom_fields); } + if (params.update_fields.acf !== undefined) updateData.acf = params.update_fields.acf; - const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData); + const updatedContent = await makeRestRouteRequest('POST', route, `/${content.id}`, updateData); return { toolResult: { @@ -575,7 +587,7 @@ export const unifiedContentHandlers = { // Update if requested if (params.update_fields) { - const endpoint = getContentEndpoint(contentType); + const route = await resolveContentRoute(contentType); const updateData: any = {}; if (params.update_fields.title !== undefined) updateData.title = params.update_fields.title; @@ -585,8 +597,9 @@ export const unifiedContentHandlers = { if (params.update_fields.custom_fields !== undefined) { Object.assign(updateData, params.update_fields.custom_fields); } + if (params.update_fields.acf !== undefined) updateData.acf = params.update_fields.acf; - const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData); + const updatedContent = await makeRestRouteRequest('POST', route, `/${content.id}`, updateData); return { toolResult: { @@ -636,7 +649,10 @@ export const unifiedContentHandlers = { get_content_by_slug: async (params: GetContentBySlugParams) => { try { - const result = await findContentAcrossTypes(params.slug, params.content_types); + const result = await findContentAcrossTypes(params.slug, params.content_types, { + fields: params.fields, + acf_format: params.acf_format, + }); if (!result) { throw new Error(`No content found with slug: ${params.slug}`); diff --git a/src/tools/unified-taxonomies.ts b/src/tools/unified-taxonomies.ts index 833dac3..d75dfeb 100644 --- a/src/tools/unified-taxonomies.ts +++ b/src/tools/unified-taxonomies.ts @@ -1,56 +1,31 @@ // src/tools/unified-taxonomies.ts import { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { getCurrentSiteCacheKey, makeWordPressRequest, logToFile } from '../wordpress.js'; +import { logToFile } from '../wordpress.js'; import { z } from 'zod'; +import { + buildReadQueryParams, + getTaxonomies, + makeRestRouteRequest, + resolveContentRoute, + resolveTaxonomyRoute, +} from './rest-helpers.js'; -const taxonomiesCache = new Map(); -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes - -// Helper function to get all taxonomies with caching -async function getTaxonomies(forceRefresh = false) { - const now = Date.now(); - const cacheKey = getCurrentSiteCacheKey(); - const cached = taxonomiesCache.get(cacheKey); - - if (!forceRefresh && cached && (now - cached.timestamp) < CACHE_DURATION) { - logToFile('Using cached taxonomies'); - return cached.value; - } - - try { - logToFile('Fetching taxonomies from API'); - const response = await makeWordPressRequest('GET', 'taxonomies'); - taxonomiesCache.set(cacheKey, { value: response, timestamp: now }); - return response; - } catch (error: any) { - logToFile(`Error fetching taxonomies: ${error.message}`); - throw error; - } -} +// Schema definitions +const restFieldsSchema = z + .array(z.string()) + .optional() + .describe('Optional WordPress REST _fields selector. Use ["acf"] to return only ACF term data, or entries like "acf.field_name", "id", and "name" for focused reads.'); -// Helper function to get the correct endpoint for a taxonomy -function getTaxonomyEndpoint(taxonomy: string): string { - const endpointMap: Record = { - 'category': 'categories', - 'post_tag': 'tags', - 'nav_menu': 'menus', - 'link_category': 'link_categories' - }; - - return endpointMap[taxonomy] || taxonomy; -} +const acfFormatSchema = z + .enum(['light', 'standard']) + .optional() + .describe('Optional ACF REST output format. "light" is the ACF default raw/schema-aligned format; "standard" applies full ACF formatting.'); -// Helper function to get the correct content endpoint -function getContentEndpoint(contentType: string): string { - const endpointMap: Record = { - 'post': 'posts', - 'page': 'pages' - }; - - return endpointMap[contentType] || contentType; -} +const acfPayloadSchema = z + .record(z.unknown()) + .optional() + .describe('Advanced Custom Fields (ACF/ACF Pro) values for this taxonomy term. Sent exactly as the nested WordPress REST "acf" object. Use get_acf_schema first when field names or value types are unknown.'); -// Schema definitions const discoverTaxonomiesSchema = z.object({ content_type: z.string().optional().describe("Limit results to taxonomies associated with a specific content type"), refresh_cache: z.boolean().optional().describe("Force refresh the taxonomies cache") @@ -65,12 +40,16 @@ const listTermsSchema = z.object({ slug: z.string().optional().describe("Limit result to terms with a specific slug"), hide_empty: z.boolean().optional().describe("Whether to hide terms not assigned to any content"), orderby: z.enum(['id', 'include', 'name', 'slug', 'term_group', 'description', 'count']).optional().describe("Sort terms by parameter"), - order: z.enum(['asc', 'desc']).optional().describe("Order sort attribute") + order: z.enum(['asc', 'desc']).optional().describe("Order sort attribute"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }); const getTermSchema = z.object({ taxonomy: z.string().describe("The taxonomy slug"), - id: z.number().describe("Term ID") + id: z.number().describe("Term ID"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }); const createTermSchema = z.object({ @@ -79,7 +58,8 @@ const createTermSchema = z.object({ slug: z.string().optional().describe("Term slug"), parent: z.number().optional().describe("Parent term ID"), description: z.string().optional().describe("Term description"), - meta: z.record(z.any()).optional().describe("Term meta fields") + meta: z.record(z.any()).optional().describe("Term meta fields"), + acf: acfPayloadSchema }); const updateTermSchema = z.object({ @@ -89,7 +69,8 @@ const updateTermSchema = z.object({ slug: z.string().optional().describe("Term slug"), parent: z.number().optional().describe("Parent term ID"), description: z.string().optional().describe("Term description"), - meta: z.record(z.any()).optional().describe("Term meta fields") + meta: z.record(z.any()).optional().describe("Term meta fields"), + acf: acfPayloadSchema }); const deleteTermSchema = z.object({ @@ -130,22 +111,22 @@ export const unifiedTaxonomyTools: Tool[] = [ }, { name: "list_terms", - description: "Lists terms in any taxonomy (categories, tags, or custom taxonomies) with filtering and pagination", + description: "Lists terms in any taxonomy (categories, tags, or custom taxonomies) with filtering and pagination. ACF fields are returned when the site exposes them; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: listTermsSchema.shape } }, { name: "get_term", - description: "Gets a specific term by ID from any taxonomy", + description: "Gets a specific term by ID from any taxonomy. ACF fields are returned when the site exposes them; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: getTermSchema.shape } }, { name: "create_term", - description: "Creates a new term in any taxonomy", + description: "Creates a new term in any taxonomy. To set ACF/ACF Pro term fields, pass them under the nested acf object after verifying unknown fields with get_acf_schema.", inputSchema: { type: "object", properties: createTermSchema.shape } }, { name: "update_term", - description: "Updates an existing term in any taxonomy", + description: "Updates an existing term in any taxonomy. To set ACF/ACF Pro term fields, pass them under the nested acf object after verifying unknown fields with get_acf_schema.", inputSchema: { type: "object", properties: updateTermSchema.shape } }, { @@ -188,6 +169,7 @@ export const unifiedTaxonomyHandlers = { types: tax.types, hierarchical: tax.hierarchical, rest_base: tax.rest_base, + rest_namespace: tax.rest_namespace, labels: tax.labels })); @@ -215,10 +197,13 @@ export const unifiedTaxonomyHandlers = { list_terms: async (params: ListTermsParams) => { try { - const endpoint = getTaxonomyEndpoint(params.taxonomy); - const { taxonomy, ...queryParams } = params; + const route = await resolveTaxonomyRoute(params.taxonomy); + const { taxonomy, fields, acf_format, ...queryParams } = params; - const response = await makeWordPressRequest('GET', endpoint, queryParams); + const response = await makeRestRouteRequest('GET', route, '', { + ...queryParams, + ...buildReadQueryParams({ fields, acf_format }) + }); return { toolResult: { @@ -244,9 +229,14 @@ export const unifiedTaxonomyHandlers = { get_term: async (params: GetTermParams) => { try { - const endpoint = getTaxonomyEndpoint(params.taxonomy); + const route = await resolveTaxonomyRoute(params.taxonomy); - const response = await makeWordPressRequest('GET', `${endpoint}/${params.id}`); + const response = await makeRestRouteRequest( + 'GET', + route, + `/${params.id}`, + buildReadQueryParams({ fields: params.fields, acf_format: params.acf_format }) + ); return { toolResult: { @@ -272,7 +262,7 @@ export const unifiedTaxonomyHandlers = { create_term: async (params: CreateTermParams) => { try { - const endpoint = getTaxonomyEndpoint(params.taxonomy); + const route = await resolveTaxonomyRoute(params.taxonomy); const termData: any = { name: params.name @@ -282,8 +272,9 @@ export const unifiedTaxonomyHandlers = { if (params.parent !== undefined) termData.parent = params.parent; if (params.description !== undefined) termData.description = params.description; if (params.meta !== undefined) termData.meta = params.meta; + if (params.acf !== undefined) termData.acf = params.acf; - const response = await makeWordPressRequest('POST', endpoint, termData); + const response = await makeRestRouteRequest('POST', route, '', termData); return { toolResult: { @@ -309,7 +300,7 @@ export const unifiedTaxonomyHandlers = { update_term: async (params: UpdateTermParams) => { try { - const endpoint = getTaxonomyEndpoint(params.taxonomy); + const route = await resolveTaxonomyRoute(params.taxonomy); const updateData: any = {}; @@ -318,8 +309,9 @@ export const unifiedTaxonomyHandlers = { if (params.parent !== undefined) updateData.parent = params.parent; if (params.description !== undefined) updateData.description = params.description; if (params.meta !== undefined) updateData.meta = params.meta; + if (params.acf !== undefined) updateData.acf = params.acf; - const response = await makeWordPressRequest('POST', `${endpoint}/${params.id}`, updateData); + const response = await makeRestRouteRequest('POST', route, `/${params.id}`, updateData); return { toolResult: { @@ -345,9 +337,9 @@ export const unifiedTaxonomyHandlers = { delete_term: async (params: DeleteTermParams) => { try { - const endpoint = getTaxonomyEndpoint(params.taxonomy); + const route = await resolveTaxonomyRoute(params.taxonomy); - const response = await makeWordPressRequest('DELETE', `${endpoint}/${params.id}`, { + const response = await makeRestRouteRequest('DELETE', route, `/${params.id}`, { force: true // Terms require force to be true }); @@ -376,7 +368,7 @@ export const unifiedTaxonomyHandlers = { assign_terms_to_content: async (params: AssignTermsToContentParams) => { try { // Determine the content endpoint - const contentEndpoint = getContentEndpoint(params.content_type); + const contentRoute = await resolveContentRoute(params.content_type); // Prepare the update data const updateData: any = {}; @@ -394,7 +386,7 @@ export const unifiedTaxonomyHandlers = { // If appending, we need to get current terms first if (params.append) { try { - const currentContent = await makeWordPressRequest('GET', `${contentEndpoint}/${params.content_id}`); + const currentContent = await makeRestRouteRequest('GET', contentRoute, `/${params.content_id}`); const currentTerms = currentContent[params.taxonomy === 'category' ? 'categories' : params.taxonomy === 'post_tag' ? 'tags' : params.taxonomy] || []; @@ -410,7 +402,7 @@ export const unifiedTaxonomyHandlers = { } } - const response = await makeWordPressRequest('POST', `${contentEndpoint}/${params.content_id}`, updateData); + const response = await makeRestRouteRequest('POST', contentRoute, `/${params.content_id}`, updateData); return { toolResult: { @@ -445,8 +437,8 @@ export const unifiedTaxonomyHandlers = { get_content_terms: async (params: GetContentTermsParams) => { try { // First, get the content to see what taxonomies are assigned - const contentEndpoint = getContentEndpoint(params.content_type); - const content = await makeWordPressRequest('GET', `${contentEndpoint}/${params.content_id}`); + const contentRoute = await resolveContentRoute(params.content_type); + const content = await makeRestRouteRequest('GET', contentRoute, `/${params.content_id}`); // Get all available taxonomies const taxonomies = await getTaxonomies(); @@ -461,11 +453,11 @@ export const unifiedTaxonomyHandlers = { if (content[taxonomyField]) { // Get full term details - const endpoint = getTaxonomyEndpoint(params.taxonomy); + const route = await resolveTaxonomyRoute(params.taxonomy); const termDetails = await Promise.all( content[taxonomyField].map(async (termId: number) => { try { - return await makeWordPressRequest('GET', `${endpoint}/${termId}`); + return await makeRestRouteRequest('GET', route, `/${termId}`); } catch { return { id: termId, error: 'Could not fetch term details' }; } @@ -484,11 +476,11 @@ export const unifiedTaxonomyHandlers = { taxonomySlug; if (content[taxonomyField] && Array.isArray(content[taxonomyField]) && content[taxonomyField].length > 0) { - const endpoint = getTaxonomyEndpoint(taxonomySlug); + const route = await resolveTaxonomyRoute(taxonomySlug); const termDetails = await Promise.all( content[taxonomyField].map(async (termId: number) => { try { - return await makeWordPressRequest('GET', `${endpoint}/${termId}`); + return await makeRestRouteRequest('GET', route, `/${termId}`); } catch { return { id: termId, error: 'Could not fetch term details' }; } diff --git a/src/tools/users.ts b/src/tools/users.ts index bf006f2..ba701b4 100644 --- a/src/tools/users.ts +++ b/src/tools/users.ts @@ -4,6 +4,22 @@ import { makeWordPressRequest } from '../wordpress.js'; import { WPUser } from '../types/wordpress-types.js'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import { buildReadQueryParams } from './rest-helpers.js'; + +const restFieldsSchema = z + .array(z.string()) + .optional() + .describe('Optional WordPress REST _fields selector. Use ["acf"] to return only ACF user data, or entries like "acf.field_name", "id", and "name" for focused reads.'); + +const acfFormatSchema = z + .enum(['light', 'standard']) + .optional() + .describe('Optional ACF REST output format. "light" is the ACF default raw/schema-aligned format; "standard" applies full ACF formatting.'); + +const acfPayloadSchema = z + .record(z.unknown()) + .optional() + .describe('Advanced Custom Fields (ACF/ACF Pro) values for this user. Sent exactly as the nested WordPress REST "acf" object. Use get_acf_schema first when field names or value types are unknown.'); const listUsersSchema = z.object({ page: z.number().optional().describe("Page number (default 1)"), @@ -12,12 +28,16 @@ const listUsersSchema = z.object({ context: z.enum(['view', 'embed', 'edit']).optional().describe("Scope under which the request is made"), orderby: z.enum(['id', 'include', 'name', 'registered_date', 'slug', 'email', 'url']).optional().describe("Sort users by parameter"), order: z.enum(['asc', 'desc']).optional().describe("Order sort attribute ascending or descending"), - roles: z.array(z.string()).optional().describe("Array of role names to filter by") + roles: z.array(z.string()).optional().describe("Array of role names to filter by"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }); const getUserSchema = z.object({ id: z.number().describe("User ID"), - context: z.enum(['view', 'embed', 'edit']).optional().describe("Scope under which the request is made") + context: z.enum(['view', 'embed', 'edit']).optional().describe("Scope under which the request is made"), + fields: restFieldsSchema, + acf_format: acfFormatSchema }).strict(); const createUserSchema = z.object({ @@ -32,7 +52,8 @@ const createUserSchema = z.object({ nickname: z.string().optional().describe("Nickname for the user"), slug: z.string().optional().describe("Slug for the user"), roles: z.array(z.string()).optional().describe("Roles assigned to the user"), - password: z.string().describe("Password for the user") + password: z.string().describe("Password for the user"), + acf: acfPayloadSchema }).strict(); const updateUserSchema = z.object({ @@ -48,7 +69,8 @@ const updateUserSchema = z.object({ nickname: z.string().optional().describe("Nickname for the user"), slug: z.string().optional().describe("Slug for the user"), roles: z.array(z.string()).optional().describe("Roles assigned to the user"), - password: z.string().optional().describe("Password for the user") + password: z.string().optional().describe("Password for the user"), + acf: acfPayloadSchema }).strict(); const deleteUserSchema = z.object({ @@ -66,22 +88,22 @@ type DeleteUserParams = z.infer; export const userTools: Tool[] = [ { name: "list_users", - description: "Lists all users with filtering, sorting, and pagination options", + description: "Lists all users with filtering, sorting, and pagination options. ACF user fields are returned when the site exposes them; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: listUsersSchema.shape } }, { name: "get_user", - description: "Gets a user by ID", + description: "Gets a user by ID. ACF user fields are returned when the site exposes them; use fields: [\"acf\"] and optional acf_format for focused ACF reads.", inputSchema: { type: "object", properties: getUserSchema.shape } }, { name: "create_user", - description: "Creates a new user", + description: "Creates a new user. To set ACF/ACF Pro user fields, pass them under the nested acf object after verifying unknown fields with get_acf_schema.", inputSchema: { type: "object", properties: createUserSchema.shape } }, { name: "update_user", - description: "Updates an existing user", + description: "Updates an existing user. To set ACF/ACF Pro user fields, pass them under the nested acf object after verifying unknown fields with get_acf_schema.", inputSchema: { type: "object", properties: updateUserSchema.shape } }, { @@ -94,7 +116,11 @@ export const userTools: Tool[] = [ export const userHandlers = { list_users: async (params: ListUsersParams) => { try { - const response = await makeWordPressRequest('GET', "users", params); + const { fields, acf_format, ...queryParams } = params; + const response = await makeWordPressRequest('GET', "users", { + ...queryParams, + ...buildReadQueryParams({ fields, acf_format }) + }); const users: WPUser[] = response; return { toolResult: { @@ -113,7 +139,10 @@ export const userHandlers = { }, get_user: async (params: GetUserParams) => { try { - const response = await makeWordPressRequest('GET', `users/${params.id}`, { context: params.context }); + const response = await makeWordPressRequest('GET', `users/${params.id}`, { + context: params.context, + ...buildReadQueryParams({ fields: params.fields, acf_format: params.acf_format }) + }); const user: WPUser = response; return { toolResult: { diff --git a/src/wordpress.ts b/src/wordpress.ts index 25076e2..a52e301 100644 --- a/src/wordpress.ts +++ b/src/wordpress.ts @@ -1,9 +1,9 @@ import axios, { type AxiosInstance, type AxiosResponse } from 'axios' import { getCurrentRequestClient, getCurrentRequestConfig, setCurrentRequestClient } from './request-context.js' -type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'PUT' +type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'PUT' | 'OPTIONS' -interface RequestOptions { +export interface RequestOptions { headers?: Record isFormData?: boolean rawResponse?: boolean @@ -22,6 +22,16 @@ function buildBaseUrl(siteUrl: string): string { return normalized.endsWith('/') ? normalized : `${normalized}/` } +function buildRestUrl(siteUrl: string, namespace: string, endpoint: string): string { + const normalizedSiteUrl = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl + const wpJsonIndex = normalizedSiteUrl.indexOf('/wp-json') + const siteRoot = wpJsonIndex >= 0 ? normalizedSiteUrl.slice(0, wpJsonIndex) : normalizedSiteUrl + const cleanNamespace = namespace.replace(/^\/+|\/+$/g, '') + const cleanEndpoint = endpoint.replace(/^\/+/, '') + + return `${siteRoot}/wp-json/${cleanNamespace}/${cleanEndpoint}` +} + function createWordPressClient(): AxiosInstance { const { site: { siteUrl, username, password }, @@ -76,7 +86,7 @@ export function getCurrentSqlEndpoint(): string { export async function testCurrentSiteConnection(): Promise<{ success: boolean; error?: string }> { try { const client = getWordPressClient() - await client.get('') + await client.get('users/me') return { success: true } } catch (error: any) { return { success: false, error: error.message } @@ -91,7 +101,30 @@ export async function makeWordPressRequest( ): Promise { const client = getWordPressClient() const path = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint + return makeRequest(client, method, path, data, options) +} + +export async function makeWordPressRestRequest( + method: HttpMethod, + namespace: string, + endpoint: string, + data?: unknown, + options?: RequestOptions, +): Promise { + const client = getWordPressClient() + const { + site: { siteUrl }, + } = getCurrentRequestConfig() + return makeRequest(client, method, buildRestUrl(siteUrl, namespace, endpoint), data, options) +} +async function makeRequest( + client: AxiosInstance, + method: HttpMethod, + path: string, + data?: unknown, + options?: RequestOptions, +): Promise { const requestConfig: { method: HttpMethod url: string