Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,29 @@ Term read tools support `fields: ["acf"]` and `acf_format` for focused ACF reads

- `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.
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. Use `target` plus `resource`, for example `{ "target": "content", "resource": "post" }`, `{ "target": "content", "resource": "steals" }`, `{ "target": "term", "resource": "category" }`, or `{ "target": "user", "resource": "me" }`. ACF writes must be sent under the nested `acf` object on the relevant create/update tool.

Local ACF schema smoke test:

```bash
npm run test:acf-schema
npm run test:acf-schema -- --target content --resource page
npm run test:acf-schema -- --target content --resource steals
npm run test:acf-schema -- --target term --resource category
npm run test:acf-schema -- --target user --resource me
```

The script loads local `.env` configuration, invokes the same `get_acf_schema` handler used by the MCP server, and prints the normalized tool response.

Local ACF content read smoke test:

```bash
npm run test:acf-content -- --content-type post --id 123
npm run test:acf-content -- --content-type page --id 456
npm run test:acf-content -- --content-type steals --per-page 10
```

This reads `id,slug,title,acf` with `acf_format=standard` through the same REST helpers used by the MCP content tools.

### Media

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@missionsquad/mcp-wordpress",
"version": "0.2.4",
"version": "0.2.5",
"description": "A Model Context Protocol server for interacting with WordPress.",
"type": "module",
"main": "./build/server.js",
Expand All @@ -17,6 +17,8 @@
"dev": "tsx watch src/server.ts",
"clean": "rimraf build",
"test": "vitest run",
"test:acf-content": "tsx ./scripts/test-acf-content.ts",
"test:acf-schema": "tsx ./scripts/test-acf-schema.ts",
"test:login": "tsx ./scripts/test-login.ts",
"test:watch": "vitest",
"typecheck:scripts": "tsc --project tsconfig.scripts.json",
Expand Down
171 changes: 91 additions & 80 deletions src/tools/acf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,11 @@ export const getAcfSchemaSchema = z
}, z.enum(['content', 'term', 'user']))
.default('content')
.describe('Schema target. Use content for posts/pages/CPTs, term for taxonomy terms, and user for users.'),
content_type: z
resource: z
.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), z.string().default('post'))
.describe('Used only when target is content. WordPress post type slug, such as post, page, book, or product. Defaults to post.'),
taxonomy: z
.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), z.string().default('category'))
.describe('Used only when target is term. WordPress taxonomy slug, such as category, post_tag, or genre. Defaults to category.'),
id: z
.preprocess((value) => {
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed === '') {
return undefined
}
if (/^\d+$/.test(trimmed)) {
return Number(trimmed)
}
return trimmed
}

return value
}, z.union([z.number(), z.literal('me')]).optional())
.optional()
.describe('Optional target ID. For users, this may also be "me". Omit to inspect the collection schema.'),
.describe(
'Actual WordPress resource to inspect. For content use post, page, steals, or category-page. For terms use category or post_tag. For users use me or a numeric user ID. Defaults to post.',
),
})
.passthrough()

Expand All @@ -88,6 +70,43 @@ function readPath(source: unknown, path: string[]): unknown {
}, source)
}

function readOptionalNonEmptyString(source: Record<string, unknown>, key: string): string | undefined {
const value = source[key]
if (typeof value !== 'string') {
return undefined
}

const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}

function readOptionalId(source: Record<string, unknown>, key: string): number | 'me' | undefined {
const value = source[key]

if (value === undefined || value === null) {
return undefined
}

if (typeof value === 'number' && Number.isInteger(value)) {
return value
}

if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed.length === 0) {
return undefined
}
if (trimmed === 'me') {
return 'me'
}
if (/^\d+$/.test(trimmed)) {
return Number(trimmed)
}
}

throw new Error(`${key} must be a numeric ID or "me".`)
}

function findAcfSchemaDeep(source: unknown, depth = 0): Record<string, unknown> | null {
if (depth > 8) {
return null
Expand All @@ -109,7 +128,7 @@ function findAcfSchemaDeep(source: unknown, depth = 0): Record<string, unknown>
}

const acf = source.acf
if (isRecord(acf) && isRecord(acf.properties)) {
if (isRecord(acf) && Object.prototype.hasOwnProperty.call(acf, 'properties')) {
return acf
}

Expand All @@ -131,15 +150,15 @@ function extractAcfSchema(response: unknown): Record<string, unknown> | null {
]

for (const candidate of candidates) {
if (isRecord(candidate) && isRecord(candidate.properties)) {
if (isRecord(candidate) && Object.prototype.hasOwnProperty.call(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)) {
if (isRecord(routeAcfSchema) && Object.prototype.hasOwnProperty.call(routeAcfSchema, 'properties')) {
return routeAcfSchema
}
}
Expand All @@ -149,7 +168,7 @@ function extractAcfSchema(response: unknown): Record<string, unknown> | null {
if (isRecord(routes)) {
for (const routeDefinition of Object.values(routes)) {
const routeAcfSchema = readPath(routeDefinition, ['schema', 'properties', 'acf'])
if (isRecord(routeAcfSchema) && isRecord(routeAcfSchema.properties)) {
if (isRecord(routeAcfSchema) && Object.prototype.hasOwnProperty.call(routeAcfSchema, 'properties')) {
return routeAcfSchema
}
}
Expand All @@ -164,33 +183,43 @@ async function requestOptionsForResolvedRoute(route: RestRoute, id?: number): Pr
}

function validateGetAcfSchemaParams(params: z.infer<typeof getAcfSchemaSchema>): GetAcfSchemaParams {
const rawParams = params as Record<string, unknown>
const resource = readOptionalNonEmptyString(rawParams, 'resource') ?? 'post'
const id = readOptionalId(rawParams, 'id')

if (params.target === 'content') {
if (params.id === 'me') {
if (id === 'me') {
throw new Error('id must be numeric when target is "content".')
}

return {
target: 'content',
content_type: params.content_type,
id: params.id,
content_type:
readOptionalNonEmptyString(rawParams, 'content_type') ??
readOptionalNonEmptyString(rawParams, 'contentType') ??
resource,
id,
}
}

if (params.target === 'term') {
if (params.id === 'me') {
if (id === 'me') {
throw new Error('id must be numeric when target is "term".')
}

return {
target: 'term',
taxonomy: params.taxonomy,
id: params.id,
taxonomy: readOptionalNonEmptyString(rawParams, 'taxonomy') ?? resource,
id,
}
}

const userId =
id ?? (resource !== 'post' && resource !== 'user' && resource !== 'users' ? readOptionalId({ resource }, 'resource') : undefined)

return {
target: 'user',
id: params.id,
id: userId,
}
}

Expand Down Expand Up @@ -235,55 +264,32 @@ 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.',
'Discovers Advanced Custom Fields (ACF/ACF Pro) REST schema for content, terms, or users. Use target plus resource, for example {"target":"content","resource":"post"}, {"target":"content","resource":"page"}, {"target":"content","resource":"steals"}, {"target":"term","resource":"category"}, or {"target":"user","resource":"me"}. 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,
properties: {
target: {
type: 'string',
enum: ['content', 'term', 'user'],
default: 'content',
description:
'Resource kind to inspect. Use content for posts/pages/CPTs, term for taxonomies, or user for users.',
},
{
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,
resource: {
type: 'string',
default: 'post',
description:
'Actual WordPress resource to inspect. For content use post, page, steals, or category-page. For terms use category or post_tag. For users use me or a numeric user ID.',
},
},
examples: [
{ target: 'content', resource: 'post' },
{ target: 'content', resource: 'page' },
{ target: 'content', resource: 'steals' },
{ target: 'term', resource: 'category' },
{ target: 'user', resource: 'me' },
],
additionalProperties: true,
},
zodSchema: getAcfSchemaSchema,
},
Expand All @@ -294,8 +300,9 @@ export const acfHandlers = {
try {
const { params, response, resolvedEndpoint } = await resolveAcfSchemaRequest(rawParams)
const rawAcfSchema = extractAcfSchema(response)
const acfSchema = isRecord(rawAcfSchema?.properties) ? rawAcfSchema.properties : {}
const acfAvailable = Object.keys(acfSchema).length > 0
const rawAcfProperties = rawAcfSchema?.properties
const acfSchema = isRecord(rawAcfProperties) ? rawAcfProperties : {}
const acfAvailable = rawAcfSchema !== null

return {
toolResult: {
Expand All @@ -308,9 +315,13 @@ export const acfHandlers = {
resolved_endpoint: resolvedEndpoint,
acf_available: acfAvailable,
acf_schema: acfSchema,
acf_properties: rawAcfProperties ?? null,
acf_schema_has_field_properties: Object.keys(acfSchema).length > 0,
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.'
? Object.keys(acfSchema).length > 0
? 'ACF fields are exposed in the REST schema. Use these field names under the nested "acf" object when creating or updating.'
: 'The REST schema exposes an ACF field data object, but it does not enumerate individual ACF field properties for this target. Reads may still include an acf key; writes require known field names from WordPress/ACF configuration.'
: '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,
Expand Down
17 changes: 8 additions & 9 deletions test/acf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ describe('getAcfSchemaSchema', () => {

expect(parsed).toEqual({
target: 'content',
content_type: 'post',
taxonomy: 'category',
resource: 'post',
})
})

Expand All @@ -24,20 +23,21 @@ describe('getAcfSchemaSchema', () => {

expect(parsed).toEqual({
target: 'content',
resource: 'post',
content_type: 'post',
taxonomy: 'category',
id: undefined,
taxonomy: '',
id: '',
})
})

it('coerces numeric string ids from form inputs', () => {
it('accepts numeric string ids from form inputs', () => {
const parsed = getAcfSchemaSchema.parse({
target: 'content',
content_type: 'post',
id: '123',
})

expect(parsed.id).toBe(123)
expect(parsed.id).toBe('123')
})

it('accepts common target aliases and extra llm-supplied fields', () => {
Expand All @@ -49,8 +49,8 @@ describe('getAcfSchemaSchema', () => {

expect(parsed).toMatchObject({
target: 'content',
resource: 'post',
content_type: 'page',
taxonomy: 'category',
reason: 'inspect ACF fields',
})
})
Expand All @@ -60,8 +60,7 @@ describe('getAcfSchemaSchema', () => {

expect(parsed).toMatchObject({
target: 'content',
content_type: 'post',
taxonomy: 'category',
resource: 'post',
})
})
})
Loading