@patronage/mcp-wordpress 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,9 @@ The WordPress connector for [`@patronage/mcp-core`](../mcp-core). In the gateway
4
4
 
5
5
  - `wp.graphql({ query, variables? })` runs WPGraphQL queries, never mutations.
6
6
  - `wp.get({ path, query? })` reads the REST API under `/wp-json/`, and refuses method overrides.
7
- - `wp.apply({ intent, operations })` stages `update_post` operations (core and ACF fields, with an optional `expected_modified_gmt` check) for approval.
7
+ - `wp.apply({ intent, operations })` stages operations for approval:
8
+ - `update_post` changes core fields, ACF fields and registered post meta (`meta`), with an optional `expected_modified_gmt` check.
9
+ - `upsert_redirect` creates or updates one plain-URL rule in the Redirection plugin: `{ source: "/old-page", target: "/new-page", code?: 301 | 302 | 307 | 308 }`.
8
10
 
9
11
  It also gives `describe_site` the site's post types and GraphQL types.
10
12
 
@@ -13,3 +15,22 @@ Requests go out as the signed-in person: the gateway forwards the Access JWT wit
13
15
  ```ts
14
16
  import { wordpressConnector } from "@patronage/mcp-wordpress";
15
17
  ```
18
+
19
+ ## Post meta
20
+
21
+ WordPress only accepts meta keys a site registers for REST, so the site decides what is writable. A key that isn't registered is dropped, and the receipt reports it as not stored. For example, to let editors change Yoast's meta description and noindex setting on pages:
22
+
23
+ ```php
24
+ foreach ( [ '_yoast_wpseo_metadesc', '_yoast_wpseo_meta-robots-noindex' ] as $key ) {
25
+ register_post_meta( 'page', $key, [
26
+ 'single' => true,
27
+ 'type' => 'string',
28
+ 'show_in_rest' => true,
29
+ 'auth_callback' => fn ( $allowed, $meta_key, $post_id ) => current_user_can( 'edit_post', $post_id ),
30
+ ] );
31
+ }
32
+ ```
33
+
34
+ ## Redirects
35
+
36
+ `upsert_redirect` uses Redirection's REST API (`/wp-json/redirection/v1/`). It owns the one rule with the same source, a plain URL match and no regex. It updates that rule in place, keeping its group and title, or creates one in Redirection's default group (else the first enabled WordPress group). The preview shows the current target and code against the new ones, and apply refuses to write if that rule changed after the preview. A disabled rule, or two rules for the same source, stop the operation for an editor to sort out in Redirection. The approver needs Redirection's capability: `manage_options`, unless the site filters `redirection_role`.
package/dist/index.d.ts CHANGED
@@ -50,13 +50,21 @@ declare const updatePostOperationSchema: z.ZodObject<{
50
50
  }, z.core.$strict>>;
51
51
  id: z.ZodNumber;
52
52
  kind: z.ZodLiteral<"update_post">;
53
+ meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
53
54
  rest_base: z.ZodString;
54
55
  }, z.core.$strict>;
55
56
  type UpdatePostOperation = z.infer<typeof updatePostOperationSchema>;
56
- type WriteOperation = UpdatePostOperation;
57
+ declare const upsertRedirectOperationSchema: z.ZodObject<{
58
+ code: z.ZodDefault<z.ZodLiteral<301 | 302 | 307 | 308>>;
59
+ kind: z.ZodLiteral<"upsert_redirect">;
60
+ source: z.ZodString;
61
+ target: z.ZodString;
62
+ }, z.core.$strict>;
63
+ type UpsertRedirectOperation = z.infer<typeof upsertRedirectOperationSchema>;
64
+ type WriteOperation = UpdatePostOperation | UpsertRedirectOperation;
57
65
  export declare const applyInputSchema: z.ZodObject<{
58
66
  intent: z.ZodString;
59
- operations: z.ZodArray<z.ZodObject<{
67
+ operations: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
60
68
  acf: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
61
69
  expected_modified_gmt: z.ZodOptional<z.ZodString>;
62
70
  fields: z.ZodOptional<z.ZodObject<{
@@ -73,8 +81,14 @@ export declare const applyInputSchema: z.ZodObject<{
73
81
  }, z.core.$strict>>;
74
82
  id: z.ZodNumber;
75
83
  kind: z.ZodLiteral<"update_post">;
84
+ meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
76
85
  rest_base: z.ZodString;
77
- }, z.core.$strict>>;
86
+ }, z.core.$strict>, z.ZodObject<{
87
+ code: z.ZodDefault<z.ZodLiteral<301 | 302 | 307 | 308>>;
88
+ kind: z.ZodLiteral<"upsert_redirect">;
89
+ source: z.ZodString;
90
+ target: z.ZodString;
91
+ }, z.core.$strict>], "kind">>;
78
92
  }, z.core.$strip>;
79
93
  type ApplyInput = z.infer<typeof applyInputSchema>;
80
94
  export declare const parseApplyInput: <T>(input: T) => ApplyInput;
@@ -144,4 +158,4 @@ export declare const createWordPressTools: (deps: ConnectorDeps<WordPressEnv>) =
144
158
  };
145
159
  };
146
160
  };
147
- export type { ApplyInput, WordPressDescription, WordPressEnv, WriteOperation };
161
+ export type { ApplyInput, UpdatePostOperation, UpsertRedirectOperation, WordPressDescription, WordPressEnv, WriteOperation };
package/dist/index.js CHANGED
@@ -244,14 +244,30 @@ const updatePostOperationSchema = z.strictObject({
244
244
  fields: updatePostFieldsSchema.optional(),
245
245
  id: z.number().int().positive(),
246
246
  kind: z.literal("update_post"),
247
+ meta: z.record(z.string().min(1).max(255), z.json()).optional(),
247
248
  rest_base: restBaseSchema
248
- }).refine((operation) => changedFieldKeys(operation.fields).length > 0 || Object.keys(operation.acf ?? {}).length > 0, { message: "operation must change at least one field or acf key" });
249
- const operationTarget = (operation) => `${operation.rest_base}/${operation.id}`;
249
+ }).refine((operation) => changedFieldKeys(operation.fields).length > 0 || Object.keys(operation.acf ?? {}).length > 0 || Object.keys(operation.meta ?? {}).length > 0, { message: "operation must change at least one field, acf key or meta key" });
250
+ const redirectSourceSchema = z.string().trim().min(2).max(2e3).regex(/^\/(?!\/)[^\s#]*$/u, "source must be a site path such as /old-page");
251
+ const redirectTargetSchema = z.string().trim().min(1).max(2e3).refine((value) => /^\/(?!\/)\S*$/u.test(value) || /^https?:\/\/[^\s/]+\S*$/iu.test(value), { message: "target must be a site path or an http(s) URL" });
252
+ const upsertRedirectOperationSchema = z.strictObject({
253
+ code: z.literal([
254
+ 301,
255
+ 302,
256
+ 307,
257
+ 308
258
+ ]).default(301),
259
+ kind: z.literal("upsert_redirect"),
260
+ source: redirectSourceSchema,
261
+ target: redirectTargetSchema
262
+ }).refine((operation) => operation.source !== operation.target, { message: "a redirect cannot point at its own source" });
263
+ const writeOperationSchema = z.discriminatedUnion("kind", [updatePostOperationSchema, upsertRedirectOperationSchema]);
264
+ const isRedirectOperation = (operation) => operation.kind === "upsert_redirect";
265
+ const operationTarget = (operation) => isRedirectOperation(operation) ? `redirect ${operation.source}` : `${operation.rest_base}/${operation.id}`;
250
266
  const DUPLICATE_TARGET_CODE = "duplicate_target";
251
267
  const duplicateTargetIssueSchema = z.object({ params: z.object({ code: z.literal(DUPLICATE_TARGET_CODE) }) });
252
268
  const applyInputSchema = z.object({
253
269
  intent: z.string().trim().min(1).max(500),
254
- operations: z.array(updatePostOperationSchema).min(1).max(MAX_OPERATIONS)
270
+ operations: z.array(writeOperationSchema).min(1).max(MAX_OPERATIONS)
255
271
  }).superRefine((input, ctx) => {
256
272
  const seen = /* @__PURE__ */ new Set();
257
273
  for (const [index, operation] of input.operations.entries()) {
@@ -272,33 +288,264 @@ const parseApplyInput = (input) => {
272
288
  const duplicate = parsed.error.issues.some((issue) => duplicateTargetIssueSchema.safeParse(issue).success);
273
289
  throw new ApplyValidationError(duplicate ? DUPLICATE_TARGET_CODE : "invalid_operations", detail);
274
290
  };
291
+ const REDIRECT_PATHS = ["target", "code"];
275
292
  const changedPaths = (operation) => {
293
+ if (isRedirectOperation(operation)) return [...REDIRECT_PATHS];
276
294
  const paths = changedFieldKeys(operation.fields);
277
295
  for (const key of Object.keys(operation.acf ?? {}).toSorted()) paths.push(`acf.${key}`);
296
+ for (const key of Object.keys(operation.meta ?? {}).toSorted()) paths.push(`meta.${key}`);
278
297
  return paths;
279
298
  };
280
299
  const operationSentence = (operation) => {
300
+ if (isRedirectOperation(operation)) return `Redirect ${operation.source} to ${operation.target} (${operation.code})`;
281
301
  return `Update ${joinPhrase(changedPaths(operation))} on ${operation.rest_base} ${operation.id}`;
282
302
  };
283
- // src/wp-apply.ts
303
+ // src/wp-receipts.ts
284
304
  const MISSED_PATH_LIMIT = 5;
305
+ const toReceiptError = (error) => {
306
+ if (error instanceof ApplyConflictError) return {
307
+ code: error.code,
308
+ message: error.message
309
+ };
310
+ if (error instanceof UpstreamRequestError) return {
311
+ code: "wordpress_request_failed",
312
+ message: error.message
313
+ };
314
+ return {
315
+ code: "unexpected_error",
316
+ message: "The operation failed"
317
+ };
318
+ };
319
+ const missedPathsMessage = (missed) => {
320
+ const listed = missed.slice(0, MISSED_PATH_LIMIT);
321
+ const extra = missed.length - listed.length;
322
+ const suffix = extra > 0 ? ` and ${extra} more` : "";
323
+ return `WordPress did not store ${listed.join(", ")}${suffix}`;
324
+ };
325
+ // src/wp-redirects.ts
326
+ const REDIRECTS_PATH = "/wp-json/redirection/v1/redirect";
327
+ const GROUPS_PATH = "/wp-json/redirection/v1/group";
328
+ const WORDPRESS_MODULE = 1;
329
+ const PAGE_SIZE = 200;
330
+ const MAX_PAGES = 10;
331
+ const REDIRECTION_EDITOR = "Redirection";
332
+ const redirectItemSchema = z.object({
333
+ action_code: z.coerce.number().int(),
334
+ action_data: z.unknown().optional(),
335
+ action_type: z.string().optional(),
336
+ enabled: z.boolean().optional(),
337
+ group_id: z.coerce.number().int(),
338
+ id: z.coerce.number().int().positive(),
339
+ match_type: z.string().optional(),
340
+ regex: z.coerce.boolean().optional(),
341
+ status: z.string().optional(),
342
+ title: z.string().nullish(),
343
+ url: z.string()
344
+ });
345
+ const listSchema = z.object({
346
+ items: z.array(z.unknown()),
347
+ total: z.coerce.number().int().nonnegative().optional()
348
+ });
349
+ const actionUrlSchema = z.object({ url: z.string() });
350
+ const groupSchema = z.object({
351
+ default: z.boolean().optional(),
352
+ enabled: z.boolean().optional(),
353
+ id: z.coerce.number().int().positive(),
354
+ module_id: z.coerce.number().int()
355
+ });
356
+ const isEnabled = (item) => item.enabled ?? item.status !== "disabled";
357
+ const actionUrl = (item) => {
358
+ const parsed = actionUrlSchema.safeParse(item.action_data);
359
+ return parsed.success ? parsed.data.url : null;
360
+ };
361
+ const toRule = (item) => ({
362
+ code: item.action_code,
363
+ enabled: isEnabled(item),
364
+ groupId: item.group_id,
365
+ id: item.id,
366
+ target: actionUrl(item),
367
+ title: item.title ?? ""
368
+ });
369
+ const redirectionError = (result, fallback) => {
370
+ if (result.status === 404 && result.errorCode === "rest_no_route") return new ApplyConflictError("redirection_unavailable", "The Redirection plugin's REST API is not available on this site");
371
+ return new ApplyConflictError(result.errorCode ?? "read_failed", wordPressErrorMessage(result.status, result.errorCode) ?? fallback);
372
+ };
373
+ const listPath = (base, query) => `${base}?${new URLSearchParams(query).toString()}`;
374
+ const readPage = async (client, source, page, telemetry) => {
375
+ const result = await wpFetch(client, listPath(REDIRECTS_PATH, {
376
+ "filterBy[url]": source,
377
+ page: String(page),
378
+ per_page: String(PAGE_SIZE)
379
+ }), telemetry);
380
+ const parsed = listSchema.safeParse(result.json);
381
+ if (result.status < 200 || result.status >= 300 || !parsed.success) throw redirectionError(result, "Could not read Redirection rules");
382
+ const items = parsed.data.items.flatMap((item) => {
383
+ const rule = redirectItemSchema.safeParse(item);
384
+ return rule.success ? [rule.data] : [];
385
+ });
386
+ const seen = (page + 1) * PAGE_SIZE;
387
+ return {
388
+ items,
389
+ more: parsed.data.items.length === PAGE_SIZE && (parsed.data.total === void 0 || seen < parsed.data.total)
390
+ };
391
+ };
392
+ const collectPages = async (client, source, telemetry, page, collected) => {
393
+ const { items, more } = await readPage(client, source, page, telemetry);
394
+ collected.push(...items);
395
+ if (!more) return collected;
396
+ if (page + 1 >= MAX_PAGES) throw new ApplyConflictError("too_many_redirect_matches", `Too many Redirection rules contain ${source} to check it safely`);
397
+ return collectPages(client, source, telemetry, page + 1, collected);
398
+ };
399
+ const findRule = async (client, source, telemetry) => {
400
+ const exact = (await collectPages(client, source, telemetry, 0, [])).filter((item) => item.url === source && item.regex !== true && (item.match_type ?? "url") === "url" && (item.action_type ?? "url") === "url");
401
+ if (exact.length > 1) throw new ApplyConflictError("ambiguous_redirect", `Redirection has ${exact.length} rules for ${source}; merge them in Redirection first`);
402
+ const [only] = exact;
403
+ return only === void 0 ? void 0 : toRule(only);
404
+ };
405
+ const defaultGroup = async (client, telemetry) => {
406
+ const result = await wpFetch(client, listPath(GROUPS_PATH, { per_page: String(PAGE_SIZE) }), telemetry);
407
+ const parsed = listSchema.safeParse(result.json);
408
+ if (result.status < 200 || result.status >= 300 || !parsed.success) throw redirectionError(result, "Could not read Redirection groups");
409
+ const usable = parsed.data.items.flatMap((item) => {
410
+ const group = groupSchema.safeParse(item);
411
+ return group.success && group.data.module_id === WORDPRESS_MODULE && group.data.enabled !== false ? [group.data] : [];
412
+ });
413
+ const chosen = usable.find((group) => group.default === true) ?? usable[0];
414
+ if (chosen === void 0) throw new ApplyConflictError("no_redirect_group", "Redirection has no enabled WordPress group to add the rule to");
415
+ return chosen.id;
416
+ };
417
+ const ruleStamp = (rule) => rule === void 0 ? "redirect:none" : `redirect:${JSON.stringify([
418
+ rule.id,
419
+ rule.target,
420
+ rule.code,
421
+ rule.enabled
422
+ ])}`;
423
+ const ruleValue = (rule, path) => {
424
+ if (rule === void 0) return null;
425
+ return path === "code" ? rule.code : rule.target;
426
+ };
427
+ const requestedRuleValue = (operation, path) => path === "code" ? operation.code : operation.target;
428
+ const redirectionAdminUrl = (apiUrl) => new URL("/wp-admin/tools.php?page=redirection.php", apiUrl).toString();
429
+ const assertWritable = (operation, rule) => {
430
+ if (rule !== void 0 && !rule.enabled) throw new ApplyConflictError("redirect_disabled", `The Redirection rule for ${operation.source} is disabled; enable or delete it in Redirection first`);
431
+ };
432
+ const preflightRedirect = async (client, operation, telemetry, index) => {
433
+ const rule = await findRule(client, operation.source, telemetry);
434
+ assertWritable(operation, rule);
435
+ if (rule === void 0) await defaultGroup(client, telemetry);
436
+ return {
437
+ editUrl: redirectionAdminUrl(client.apiUrl),
438
+ editor: REDIRECTION_EDITOR,
439
+ fields: REDIRECT_PATHS.map((path) => {
440
+ const before = ruleValue(rule, path);
441
+ const after = requestedRuleValue(operation, path);
442
+ return {
443
+ after: previewValue(after),
444
+ before: previewValue(before),
445
+ changed: !deepEqual(before, after),
446
+ path
447
+ };
448
+ }),
449
+ modifiedGmt: ruleStamp(rule),
450
+ operationIndex: index,
451
+ sentence: operationSentence(operation),
452
+ slug: operation.source,
453
+ target: operationTarget(operation)
454
+ };
455
+ };
456
+ const ruleBody = (operation, groupId, title) => ({
457
+ action_code: operation.code,
458
+ action_data: { url: operation.target },
459
+ action_type: "url",
460
+ group_id: groupId,
461
+ match_type: "url",
462
+ regex: false,
463
+ title,
464
+ url: operation.source
465
+ });
466
+ const writeRule = async (client, operation, rule, telemetry) => {
467
+ const body = rule === void 0 ? ruleBody(operation, await defaultGroup(client, telemetry), "") : ruleBody(operation, rule.groupId, rule.title);
468
+ return await wpFetch(client, rule === void 0 ? REDIRECTS_PATH : `${REDIRECTS_PATH}/${rule.id}`, telemetry, {
469
+ body: JSON.stringify(body),
470
+ headers: { "Content-Type": "application/json" },
471
+ method: "POST"
472
+ });
473
+ };
474
+ const applyRedirect = async (client, operation, snapshot, telemetry) => {
475
+ const target = operationTarget(operation);
476
+ const result = (status, changed, error) => {
477
+ const receipt = {
478
+ changed_paths: changed,
479
+ kind: operation.kind,
480
+ normalized_paths: [],
481
+ operation_index: snapshot.operationIndex,
482
+ status,
483
+ target
484
+ };
485
+ if (error !== void 0) receipt.error = error;
486
+ return receipt;
487
+ };
488
+ if (snapshot.target !== target) return result("failed", [], {
489
+ code: "preflight_mismatch",
490
+ message: "The approval preview describes a different target"
491
+ });
492
+ let before;
493
+ try {
494
+ before = await findRule(client, operation.source, telemetry);
495
+ } catch (error) {
496
+ return result("failed", [], toReceiptError(error));
497
+ }
498
+ if (ruleStamp(before) !== snapshot.modifiedGmt) return result("conflict", [], {
499
+ code: "modified_since_preflight",
500
+ message: `${target} changed after the approval preview`
501
+ });
502
+ const changed = REDIRECT_PATHS.filter((path) => !deepEqual(ruleValue(before, path), requestedRuleValue(operation, path)));
503
+ if (changed.length === 0) return result("unchanged", []);
504
+ let write;
505
+ try {
506
+ write = await writeRule(client, operation, before, telemetry);
507
+ } catch (error) {
508
+ return result("failed", [], toReceiptError(error));
509
+ }
510
+ if (write.status < 200 || write.status >= 300) return result("failed", [], {
511
+ code: write.errorCode ?? "write_failed",
512
+ message: wordPressErrorMessage(write.status, write.errorCode) ?? `Redirection rejected the rule for ${operation.source}`
513
+ });
514
+ let after;
515
+ try {
516
+ after = await findRule(client, operation.source, telemetry);
517
+ } catch (error) {
518
+ return result("failed", [], toReceiptError(error));
519
+ }
520
+ const missed = changed.filter((path) => !deepEqual(ruleValue(after, path), requestedRuleValue(operation, path)));
521
+ if (missed.length > 0) return result("failed", changed.filter((path) => !missed.includes(path)), {
522
+ code: "readback_mismatch",
523
+ message: missedPathsMessage(missed)
524
+ });
525
+ return result("changed", changed);
526
+ };
527
+ // src/wp-apply.ts
285
528
  const ACF_PREFIX = "acf.";
529
+ const META_PREFIX = "meta.";
286
530
  const UNREADABLE_MODIFIED_GMT_CODE = "unreadable_modified_gmt";
287
531
  const UNREADABLE_MODIFIED_GMT_MESSAGE = "WordPress did not report when this post was last modified, so the change cannot be checked against the approval preview";
288
532
  const recordSchema = z.record(z.string(), z.json());
289
533
  const stringSchema = z.string();
290
534
  const editableFieldSchema = z.object({ raw: z.json() });
535
+ const nestedValue = (container, key) => {
536
+ const parsed = recordSchema.safeParse(container);
537
+ return parsed.success ? parsed.data[key] : void 0;
538
+ };
291
539
  const readPath = (record, path) => {
292
- if (path.startsWith(ACF_PREFIX)) {
293
- const acf = recordSchema.safeParse(record.acf);
294
- return acf.success ? acf.data[path.slice(4)] : void 0;
295
- }
540
+ if (path.startsWith(ACF_PREFIX)) return nestedValue(record.acf, path.slice(4));
541
+ if (path.startsWith(META_PREFIX)) return nestedValue(record.meta, path.slice(5));
296
542
  const value = record[path];
297
543
  const editable = editableFieldSchema.safeParse(value);
298
544
  return editable.success ? editable.data.raw : value;
299
545
  };
300
546
  const requestedValue = (operation, path) => {
301
547
  if (path.startsWith(ACF_PREFIX)) return operation.acf?.[path.slice(4)];
548
+ if (path.startsWith(META_PREFIX)) return operation.meta?.[path.slice(5)];
302
549
  const { fields } = operation;
303
550
  if (fields === void 0) return;
304
551
  return Object.entries(fields).find(([key]) => key === path)?.[1];
@@ -319,23 +566,7 @@ const readRecord = async (client, operation, telemetry) => {
319
566
  if (result.status < 200 || result.status >= 300 || !parsed.success) throw new ApplyConflictError(result.errorCode ?? "read_failed", wordPressErrorMessage(result.status, result.errorCode) ?? `Could not read ${operationTarget(operation)}`);
320
567
  return parsed.data;
321
568
  };
322
- const toReceiptError = (error) => {
323
- if (error instanceof ApplyConflictError) return {
324
- code: error.code,
325
- message: error.message
326
- };
327
- if (error instanceof UpstreamRequestError) return {
328
- code: "wordpress_request_failed",
329
- message: error.message
330
- };
331
- return {
332
- code: "unexpected_error",
333
- message: "The operation failed"
334
- };
335
- };
336
- const preflightRemaining = async (client, operations, telemetry, index, collected) => {
337
- const operation = operations.at(index);
338
- if (operation === void 0) return collected;
569
+ const preflightPost = async (client, operation, telemetry, index) => {
339
570
  const record = await readRecord(client, operation, telemetry);
340
571
  const current = modifiedGmt(record);
341
572
  if (current === void 0) throw new ApplyConflictError(UNREADABLE_MODIFIED_GMT_CODE, UNREADABLE_MODIFIED_GMT_MESSAGE);
@@ -350,7 +581,7 @@ const preflightRemaining = async (client, operations, telemetry, index, collecte
350
581
  path
351
582
  };
352
583
  });
353
- collected.push({
584
+ return {
354
585
  editUrl: adminEditUrl(client.apiUrl, operation.id),
355
586
  fields,
356
587
  modifiedGmt: current,
@@ -358,7 +589,12 @@ const preflightRemaining = async (client, operations, telemetry, index, collecte
358
589
  sentence: operationSentence(operation),
359
590
  slug: postSlug(record),
360
591
  target: operationTarget(operation)
361
- });
592
+ };
593
+ };
594
+ const preflightRemaining = async (client, operations, telemetry, index, collected) => {
595
+ const operation = operations.at(index);
596
+ if (operation === void 0) return collected;
597
+ collected.push(isRedirectOperation(operation) ? await preflightRedirect(client, operation, telemetry, index) : await preflightPost(client, operation, telemetry, index));
362
598
  return preflightRemaining(client, operations, telemetry, index + 1, collected);
363
599
  };
364
600
  const preflightOperations = async (client, intent, operations, telemetry) => {
@@ -374,6 +610,7 @@ const writeBody = (operation) => {
374
610
  for (const [key, value] of Object.entries(fields)) if (value !== void 0) body[key] = value;
375
611
  }
376
612
  if (operation.acf !== void 0) body.acf = operation.acf;
613
+ if (operation.meta !== void 0) body.meta = operation.meta;
377
614
  return body;
378
615
  };
379
616
  const classifyReadback = (operation, before, after) => {
@@ -398,13 +635,7 @@ const classifyReadback = (operation, before, after) => {
398
635
  normalized
399
636
  };
400
637
  };
401
- const missedPathsMessage = (missed) => {
402
- const listed = missed.slice(0, MISSED_PATH_LIMIT);
403
- const extra = missed.length - listed.length;
404
- const suffix = extra > 0 ? ` and ${extra} more` : "";
405
- return `WordPress did not store ${listed.join(", ")}${suffix}`;
406
- };
407
- const applyOne = async (client, operation, snapshot, telemetry) => {
638
+ const applyPost = async (client, operation, snapshot, telemetry) => {
408
639
  const base = {
409
640
  kind: "update_post",
410
641
  operation_index: snapshot.operationIndex,
@@ -520,13 +751,13 @@ const applyRemaining = async (client, operations, preflight, telemetry, index, c
520
751
  code: "missing_preflight",
521
752
  message: "No approval preview exists for this operation"
522
753
  },
523
- kind: "update_post",
754
+ kind: operation.kind,
524
755
  normalized_paths: [],
525
756
  operation_index: index,
526
757
  status: "failed",
527
758
  target: operationTarget(operation)
528
759
  });
529
- else collected.push(await applyOne(client, operation, snapshot, telemetry));
760
+ else collected.push(isRedirectOperation(operation) ? await applyRedirect(client, operation, snapshot, telemetry) : await applyPost(client, operation, snapshot, telemetry));
530
761
  return applyRemaining(client, operations, preflight, telemetry, index + 1, collected);
531
762
  };
532
763
  const applyOperations = async (options) => {
@@ -545,7 +776,7 @@ const WP_CONNECTOR_INSTRUCTIONS = [
545
776
  "Reads: wp.graphql({query, variables?}) runs WPGraphQL (introspection allowed);",
546
777
  "wp.get({path, query?}) GETs any /wp-json/ path and throws on a non-2xx response.",
547
778
  "Writes: wp.apply({intent, operations}) is the only write path and pauses for human approval.",
548
- `Send 1-${MAX_OPERATIONS} update_post operations per call and set expected_modified_gmt when you read the post first.`
779
+ `Send 1-${MAX_OPERATIONS} update_post or upsert_redirect operations per call and set expected_modified_gmt when you read the post first.`
549
780
  ].join(" ");
550
781
  const assertQueryOnly = (query) => {
551
782
  let document;
@@ -584,7 +815,7 @@ const queryString = (query) => {
584
815
  };
585
816
  const createWordPressTools = (deps) => ({
586
817
  apply: {
587
- description: `Apply 1-${MAX_OPERATIONS} update_post operations to WordPress. Give the work a concise, one-sentence intent for the human approval card. Pauses for human approval; returns one receipt per operation.`,
818
+ description: `Apply 1-${MAX_OPERATIONS} update_post or upsert_redirect operations to WordPress. Give the work a concise, one-sentence intent for the human approval card. Pauses for human approval; returns one receipt per operation.`,
588
819
  execute: async (args, ctx) => {
589
820
  const input = parseApplyInput(args);
590
821
  const caller = deps.scope.require();
@@ -664,7 +895,8 @@ const wordpressConnector = {
664
895
  executeDescription: [
665
896
  "The wp global reaches WordPress.",
666
897
  "Reads: await wp.graphql({ query, variables? }); or await wp.get({ path, query? }), where path starts /wp-json/, contains no ?, and query values are scalars.",
667
- "Writes: await wp.apply({ intent, operations }), with 1-20 operations shaped { kind: \"update_post\", rest_base, id, expected_modified_gmt?, fields?, acf? }; fields may contain title, content, excerpt, slug, or status. Put all changes for one post in one operation. Read with context: \"edit\" first and copy modified_gmt to expected_modified_gmt.",
898
+ "Writes: await wp.apply({ intent, operations }), with 1-20 operations shaped { kind: \"update_post\", rest_base, id, expected_modified_gmt?, fields?, acf?, meta? } or { kind: \"upsert_redirect\", source, target, code? }. fields may contain title, content, excerpt, slug, or status; meta holds registered post meta as the post's context: \"edit\" read shows it under meta (keys missing there are not writable). Put all changes for one post in one operation. Read with context: \"edit\" first and copy modified_gmt to expected_modified_gmt.",
899
+ "upsert_redirect creates or updates the Redirection plugin rule for one site path (source, such as /old-page) to a site path or URL (target), with code 301 (default), 302, 307 or 308. Read existing rules with wp.get({ path: \"/wp-json/redirection/v1/redirect\", query: { \"filterBy[url]\": source } }).",
668
900
  "Read example: async () => await wp.get({ path: \"/wp-json/wp/v2/posts/123\", query: { context: \"edit\" } }).",
669
901
  "Write example: async () => { const post = await wp.get({ path: \"/wp-json/wp/v2/posts/123\", query: { context: \"edit\" } }); return await wp.apply({ intent: \"Fix the title.\", operations: [{ kind: \"update_post\", rest_base: \"posts\", id: post.id, expected_modified_gmt: post.modified_gmt, fields: { title: \"Correct title\" } }] }); }"
670
902
  ].join(" "),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/mcp-wordpress",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The WordPress connector for @patronage/mcp-core: WPGraphQL and REST reads, and post updates through the approval step.",
5
5
  "license": "MIT",
6
6
  "repository": {