@patronage/mcp-wordpress 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patronage
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @patronage/mcp-wordpress
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The WordPress connector for [`@patronage/mcp-core`](../mcp-core). In the gateway's sandbox it is `wp`:
4
+
5
+ - `wp.graphql({ query, variables? })` runs WPGraphQL queries, never mutations.
6
+ - `wp.get({ path, query? })` reads the REST API under `/wp-json/`, and refuses method overrides.
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 }`.
10
+
11
+ It also gives `describe_site` the site's post types and GraphQL types.
12
+
13
+ Requests go out as the signed-in person: the gateway forwards the Access JWT with a proof signed by `WORDPRESS_ACCESS_PROOF_SECRET`, which the `patronage-access` mu-plugin checks before it maps the JWT to a WordPress user. Bindings are in `WordPressEnv` (`src/env.ts`); the connector is on when `WORDPRESS_API_URL` is set.
14
+
15
+ ```ts
16
+ import { wordpressConnector } from "@patronage/mcp-wordpress";
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`.
@@ -0,0 +1,161 @@
1
+ import { AccessResult, ApplyPreflight, BatchReceipt, ConnectorDeps, GatewayConnector, JsonObject, JsonValue, UpstreamClient, UpstreamFetchResult, UpstreamResponseInfo, UpstreamTelemetry } from "@patronage/mcp-core";
2
+ import { z } from "zod";
3
+ interface PostTypeSummary {
4
+ name: string;
5
+ rest_base: string;
6
+ supports: string[];
7
+ taxonomies: string[];
8
+ }
9
+ interface GraphqlTypeSummary {
10
+ fields: string[];
11
+ name: string;
12
+ }
13
+ interface WordPressDescription {
14
+ acfTypes: GraphqlTypeSummary[];
15
+ contentTypes: GraphqlTypeSummary[];
16
+ postTypes: PostTypeSummary[];
17
+ site: {
18
+ name: string;
19
+ url: string;
20
+ };
21
+ }
22
+ export declare const buildSiteDescription: (client: UpstreamClient, telemetry: UpstreamTelemetry) => Promise<WordPressDescription>;
23
+ interface WordPressEnv {
24
+ WORDPRESS_ACCESS_CLIENT_ID?: string;
25
+ WORDPRESS_ACCESS_CLIENT_SECRET?: string;
26
+ WORDPRESS_ACCESS_PROOF_SECRET?: string;
27
+ WORDPRESS_API_URL?: string;
28
+ }
29
+ export declare const inspectWordPressResponse: (response: Response, json: JsonValue) => UpstreamResponseInfo;
30
+ type VerifiedAccessResult = Extract<AccessResult, {
31
+ ok: true;
32
+ }>;
33
+ export declare const createWordPressClient: (env: WordPressEnv, access: VerifiedAccessResult) => UpstreamClient;
34
+ export declare const wpFetch: (client: UpstreamClient, path: string, telemetry: UpstreamTelemetry, init?: RequestInit) => Promise<UpstreamFetchResult>;
35
+ export declare const wpGraphql: (client: UpstreamClient, query: string, variables: JsonObject | undefined, telemetry: UpstreamTelemetry) => Promise<JsonValue>;
36
+ declare const updatePostOperationSchema: z.ZodObject<{
37
+ acf: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
38
+ expected_modified_gmt: z.ZodOptional<z.ZodString>;
39
+ fields: z.ZodOptional<z.ZodObject<{
40
+ content: z.ZodOptional<z.ZodString>;
41
+ excerpt: z.ZodOptional<z.ZodString>;
42
+ slug: z.ZodOptional<z.ZodString>;
43
+ status: z.ZodOptional<z.ZodEnum<{
44
+ draft: "draft";
45
+ pending: "pending";
46
+ private: "private";
47
+ publish: "publish";
48
+ }>>;
49
+ title: z.ZodOptional<z.ZodString>;
50
+ }, z.core.$strict>>;
51
+ id: z.ZodNumber;
52
+ kind: z.ZodLiteral<"update_post">;
53
+ meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
54
+ rest_base: z.ZodString;
55
+ }, z.core.$strict>;
56
+ type UpdatePostOperation = z.infer<typeof updatePostOperationSchema>;
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;
65
+ export declare const applyInputSchema: z.ZodObject<{
66
+ intent: z.ZodString;
67
+ operations: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
68
+ acf: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
69
+ expected_modified_gmt: z.ZodOptional<z.ZodString>;
70
+ fields: z.ZodOptional<z.ZodObject<{
71
+ content: z.ZodOptional<z.ZodString>;
72
+ excerpt: z.ZodOptional<z.ZodString>;
73
+ slug: z.ZodOptional<z.ZodString>;
74
+ status: z.ZodOptional<z.ZodEnum<{
75
+ draft: "draft";
76
+ pending: "pending";
77
+ private: "private";
78
+ publish: "publish";
79
+ }>>;
80
+ title: z.ZodOptional<z.ZodString>;
81
+ }, z.core.$strict>>;
82
+ id: z.ZodNumber;
83
+ kind: z.ZodLiteral<"update_post">;
84
+ meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
85
+ rest_base: z.ZodString;
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">>;
92
+ }, z.core.$strip>;
93
+ type ApplyInput = z.infer<typeof applyInputSchema>;
94
+ export declare const parseApplyInput: <T>(input: T) => ApplyInput;
95
+ export declare const preflightOperations: (client: UpstreamClient, intent: string, operations: WriteOperation[], telemetry: UpstreamTelemetry) => Promise<ApplyPreflight>;
96
+ export declare const applyOperations: (options: {
97
+ approverOpaque: string;
98
+ client: UpstreamClient;
99
+ executionId: string;
100
+ operations: WriteOperation[];
101
+ preflight: ApplyPreflight;
102
+ requesterOpaque: string;
103
+ telemetry: UpstreamTelemetry;
104
+ }) => Promise<BatchReceipt>;
105
+ export declare const wordpressConnector: GatewayConnector<WordPressEnv>;
106
+ export declare const WP_CONNECTOR_INSTRUCTIONS: string;
107
+ export declare const createWordPressTools: (deps: ConnectorDeps<WordPressEnv>) => {
108
+ apply: {
109
+ description: string;
110
+ execute: <T>(args: T, ctx: import("@patronage/mcp-core").ToolCallContext | undefined) => Promise<import("@patronage/mcp-core").BatchReceipt>;
111
+ inputSchema: {
112
+ properties: {
113
+ intent: {
114
+ type: "string";
115
+ };
116
+ operations: {
117
+ items: {
118
+ type: "object";
119
+ };
120
+ type: "array";
121
+ };
122
+ };
123
+ required: string[];
124
+ type: "object";
125
+ };
126
+ requiresApproval: true;
127
+ };
128
+ get: {
129
+ description: string;
130
+ execute: <T>(args: T) => Promise<z.JSONType>;
131
+ inputSchema: {
132
+ properties: {
133
+ path: {
134
+ type: "string";
135
+ };
136
+ query: {
137
+ type: "object";
138
+ };
139
+ };
140
+ required: string[];
141
+ type: "object";
142
+ };
143
+ };
144
+ graphql: {
145
+ description: string;
146
+ execute: <T>(args: T) => Promise<z.JSONType>;
147
+ inputSchema: {
148
+ properties: {
149
+ query: {
150
+ type: "string";
151
+ };
152
+ variables: {
153
+ type: "object";
154
+ };
155
+ };
156
+ required: string[];
157
+ type: "object";
158
+ };
159
+ };
160
+ };
161
+ export type { ApplyInput, UpdatePostOperation, UpsertRedirectOperation, WordPressDescription, WordPressEnv, WriteOperation };
package/dist/index.js ADDED
@@ -0,0 +1,921 @@
1
+ import { ApplyConflictError, ApplyValidationError, MAX_OPERATIONS, UpstreamRequestError, assertSafeApiUrl, batchStatus, deepEqual, joinPhrase, numericHeader, previewValue, upstreamActorSchema, upstreamFetch } from "@patronage/mcp-core";
2
+ import { z } from "zod";
3
+ import { Kind, OperationTypeNode, parse } from "graphql";
4
+ // src/wordpress.ts
5
+ const MAX_WORDPRESS_ERROR_CODE_LENGTH = 64;
6
+ const WORDPRESS_USER_ID_HEADER = "X-Access-WordPress-User-ID";
7
+ const WORDPRESS_USER_SLUG_HEADER = "X-Access-WordPress-User-Slug";
8
+ const jsonObjectSchema = z.record(z.string(), z.json());
9
+ const stringSchema$1 = z.string();
10
+ const resolveWpUrl = (apiUrl, path) => {
11
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/u.test(path) || path.startsWith("//")) throw new UpstreamRequestError("WordPress path must be a site-relative /wp-json/ path");
12
+ if (!path.startsWith("/wp-json/")) throw new UpstreamRequestError("WordPress path must start with /wp-json/");
13
+ const base = assertSafeApiUrl(apiUrl);
14
+ const url = new URL(path, base);
15
+ if (url.origin !== base.origin) throw new UpstreamRequestError("WordPress path escaped origin");
16
+ if (!url.pathname.startsWith("/wp-json/")) throw new UpstreamRequestError("WordPress path escaped /wp-json/");
17
+ return url;
18
+ };
19
+ const boundedWordPressErrorCode = (value) => {
20
+ if (!/^[a-z0-9_]+$/u.test(value)) return;
21
+ return value.slice(0, MAX_WORDPRESS_ERROR_CODE_LENGTH);
22
+ };
23
+ const wordPressErrorCode = (json) => {
24
+ const object = jsonObjectSchema.safeParse(json);
25
+ if (!object.success) return;
26
+ const code = stringSchema$1.safeParse(object.data.code);
27
+ return code.success ? boundedWordPressErrorCode(code.data) : void 0;
28
+ };
29
+ const wordPressErrorMessage = (status, errorCode) => {
30
+ switch (errorCode) {
31
+ case "patronage_access_invalid_token": return "WordPress rejected the Access token. Reconnect the MCP client and retry.";
32
+ case "patronage_access_no_email": return "Service tokens cannot act as a WordPress user.";
33
+ case "patronage_access_missing_proof":
34
+ case "patronage_access_invalid_proof": return "WordPress rejected the gateway proof. The Worker and WordPress proof secrets are out of sync.";
35
+ case "patronage_access_no_user": return "No WordPress account matches your Access login. Ask a site administrator to create one with the same email.";
36
+ case "patronage_access_not_configured": return "The WordPress site's Access authentication plugin is not configured.";
37
+ case "rest_cannot_view":
38
+ case "rest_forbidden":
39
+ case "rest_forbidden_context":
40
+ case "rest_forbidden_status": return "Your WordPress account does not have permission for this content.";
41
+ }
42
+ if (status === 401 || status === 403) return "WordPress did not accept the request's authentication.";
43
+ };
44
+ const wordpressActor = (headers) => {
45
+ const id = numericHeader(headers, WORDPRESS_USER_ID_HEADER);
46
+ const encodedSlug = headers.get(WORDPRESS_USER_SLUG_HEADER);
47
+ if (!(id && encodedSlug)) return;
48
+ let slug;
49
+ try {
50
+ slug = decodeURIComponent(encodedSlug);
51
+ } catch {
52
+ return;
53
+ }
54
+ const parsed = upstreamActorSchema.safeParse({
55
+ id,
56
+ slug
57
+ });
58
+ return parsed.success ? parsed.data : void 0;
59
+ };
60
+ const inspectWordPressResponse = (response, json) => ({
61
+ actor: wordpressActor(response.headers),
62
+ errorCode: wordPressErrorCode(json),
63
+ totalItems: numericHeader(response.headers, "X-WP-Total"),
64
+ totalPages: numericHeader(response.headers, "X-WP-TotalPages")
65
+ });
66
+ const createWordPressClient = (env, access) => {
67
+ const apiUrl = env.WORDPRESS_API_URL?.trim();
68
+ if (!apiUrl) throw new UpstreamRequestError("WORDPRESS_API_URL is not configured");
69
+ assertSafeApiUrl(apiUrl);
70
+ const auth = {
71
+ kind: "access-jwt",
72
+ token: access.token
73
+ };
74
+ const proofSecret = env.WORDPRESS_ACCESS_PROOF_SECRET?.trim();
75
+ if (proofSecret) auth.proofSecret = proofSecret;
76
+ const clientId = env.WORDPRESS_ACCESS_CLIENT_ID?.trim();
77
+ const clientSecret = env.WORDPRESS_ACCESS_CLIENT_SECRET?.trim();
78
+ if (clientId && clientSecret) auth.serviceToken = {
79
+ clientId,
80
+ clientSecret
81
+ };
82
+ return {
83
+ apiUrl,
84
+ auth,
85
+ inspect: inspectWordPressResponse,
86
+ label: "WordPress"
87
+ };
88
+ };
89
+ const wpFetch = async (client, path, telemetry, init = {}) => await upstreamFetch(client, resolveWpUrl(client.apiUrl, path), telemetry, init);
90
+ const graphqlUrl = (apiUrl) => new URL("/graphql", assertSafeApiUrl(apiUrl));
91
+ const wpGraphql = async (client, query, variables, telemetry) => {
92
+ const body = variables ? {
93
+ query,
94
+ variables
95
+ } : { query };
96
+ const result = await upstreamFetch(client, graphqlUrl(client.apiUrl), telemetry, {
97
+ body: JSON.stringify(body),
98
+ headers: { "Content-Type": "application/json" },
99
+ method: "POST"
100
+ });
101
+ if (result.status < 200 || result.status >= 300) throw new UpstreamRequestError(wordPressErrorMessage(result.status, result.errorCode) ?? "WPGraphQL request failed");
102
+ return result.json;
103
+ };
104
+ // src/describe.ts
105
+ const MAX_CONTENT_TYPES = 40;
106
+ const MAX_FIELDS = 60;
107
+ const CONTENT_NODE_INTERFACE = "ContentNode";
108
+ const ACF_GROUP_INTERFACE = "AcfFieldGroup";
109
+ const SCHEMA_INTROSPECTION_ERROR_MESSAGE = "WordPress GraphQL schema introspection failed.";
110
+ const SCHEMA_QUERY = `query LaunchMcpSchema {
111
+ __schema {
112
+ types {
113
+ kind
114
+ name
115
+ interfaces { name }
116
+ fields {
117
+ name
118
+ type { kind name ofType { kind name ofType { kind name } } }
119
+ }
120
+ }
121
+ }
122
+ }`;
123
+ const postTypeSchema = z.object({
124
+ name: z.string(),
125
+ rest_base: z.string().min(1),
126
+ supports: z.record(z.string(), z.boolean()).optional(),
127
+ taxonomies: z.array(z.string()).optional()
128
+ });
129
+ const namedTypeSchema = z.object({
130
+ kind: z.string().optional(),
131
+ name: z.string().nullable().optional(),
132
+ ofType: z.lazy(() => namedTypeSchema).nullable().optional()
133
+ });
134
+ const schemaTypeSchema = z.object({
135
+ fields: z.array(z.object({
136
+ name: z.string(),
137
+ type: namedTypeSchema
138
+ })).nullable().optional(),
139
+ interfaces: z.array(z.object({ name: z.string().nullable() })).nullable().optional(),
140
+ kind: z.string(),
141
+ name: z.string()
142
+ });
143
+ const introspectionSchema = z.object({ data: z.object({ __schema: z.object({ types: z.array(schemaTypeSchema) }) }) });
144
+ const graphqlErrorsSchema = z.object({ errors: z.array(z.json()).min(1) });
145
+ const siteRootSchema = z.object({
146
+ name: z.string(),
147
+ url: z.string()
148
+ });
149
+ const parsePostTypes = (value) => {
150
+ const record = z.record(z.string(), z.json()).safeParse(value);
151
+ if (!record.success) return [];
152
+ const summaries = [];
153
+ for (const entry of Object.values(record.data)) {
154
+ const parsed = postTypeSchema.safeParse(entry);
155
+ if (!parsed.success) continue;
156
+ summaries.push({
157
+ name: parsed.data.name,
158
+ rest_base: parsed.data.rest_base,
159
+ supports: Object.entries(parsed.data.supports ?? {}).filter(([, enabled]) => enabled).map(([key]) => key).toSorted(),
160
+ taxonomies: parsed.data.taxonomies ?? []
161
+ });
162
+ }
163
+ return summaries;
164
+ };
165
+ const unwrapKind = (type) => type.ofType ? unwrapKind(type.ofType) : type.kind ?? "";
166
+ const hasInterface = (interfaces, target) => (interfaces ?? []).some((entry) => entry.name === target);
167
+ const hasGraphqlErrors = (value) => graphqlErrorsSchema.safeParse(value).success;
168
+ const summarizeSchema = (value) => {
169
+ const parsed = introspectionSchema.safeParse(value);
170
+ if (!parsed.success) return {
171
+ acfTypes: [],
172
+ contentTypes: []
173
+ };
174
+ const acfTypes = [];
175
+ const contentTypes = [];
176
+ for (const type of parsed.data.data.__schema.types) {
177
+ if (type.kind !== "OBJECT") continue;
178
+ const fields = type.fields ?? [];
179
+ if (hasInterface(type.interfaces, ACF_GROUP_INTERFACE)) {
180
+ acfTypes.push({
181
+ fields: fields.map((field) => field.name).toSorted().slice(0, MAX_FIELDS),
182
+ name: type.name
183
+ });
184
+ continue;
185
+ }
186
+ if (!hasInterface(type.interfaces, CONTENT_NODE_INTERFACE)) continue;
187
+ contentTypes.push({
188
+ fields: fields.filter((field) => {
189
+ const kind = unwrapKind(field.type);
190
+ return kind === "SCALAR" || kind === "ENUM";
191
+ }).map((field) => field.name).toSorted().slice(0, MAX_FIELDS),
192
+ name: type.name
193
+ });
194
+ }
195
+ return {
196
+ acfTypes,
197
+ contentTypes: contentTypes.slice(0, MAX_CONTENT_TYPES)
198
+ };
199
+ };
200
+ const buildSiteDescription = async (client, telemetry) => {
201
+ const root = await wpFetch(client, "/wp-json/", telemetry);
202
+ const types = await wpFetch(client, "/wp-json/wp/v2/types?context=edit", telemetry);
203
+ for (const response of [root, types]) if (response.status < 200 || response.status >= 300) throw new UpstreamRequestError(wordPressErrorMessage(response.status, response.errorCode) ?? `WordPress returned HTTP ${response.status}`);
204
+ const schema = await wpGraphql(client, SCHEMA_QUERY, void 0, telemetry);
205
+ if (hasGraphqlErrors(schema)) throw new UpstreamRequestError(SCHEMA_INTROSPECTION_ERROR_MESSAGE);
206
+ const site = siteRootSchema.safeParse(root.json);
207
+ return {
208
+ ...summarizeSchema(schema),
209
+ postTypes: parsePostTypes(types.json),
210
+ site: site.success ? {
211
+ name: site.data.name,
212
+ url: site.data.url
213
+ } : {
214
+ name: "unknown",
215
+ url: client.apiUrl
216
+ }
217
+ };
218
+ };
219
+ // src/wp-operations.ts
220
+ const FIELD_ORDER = [
221
+ "title",
222
+ "content",
223
+ "excerpt",
224
+ "slug",
225
+ "status"
226
+ ];
227
+ const restBaseSchema = z.string().trim().min(1).max(64).regex(/^[a-z0-9_-]+$/u);
228
+ const updatePostFieldsSchema = z.strictObject({
229
+ content: z.string().max(5e5).optional(),
230
+ excerpt: z.string().max(5e4).optional(),
231
+ slug: z.string().trim().min(1).max(200).optional(),
232
+ status: z.enum([
233
+ "draft",
234
+ "pending",
235
+ "private",
236
+ "publish"
237
+ ]).optional(),
238
+ title: z.string().max(500).optional()
239
+ });
240
+ const changedFieldKeys = (fields) => FIELD_ORDER.filter((key) => fields?.[key] !== void 0);
241
+ const updatePostOperationSchema = z.strictObject({
242
+ acf: z.record(z.string().min(1).max(64), z.json()).optional(),
243
+ expected_modified_gmt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/u).optional(),
244
+ fields: updatePostFieldsSchema.optional(),
245
+ id: z.number().int().positive(),
246
+ kind: z.literal("update_post"),
247
+ meta: z.record(z.string().min(1).max(255), z.json()).optional(),
248
+ rest_base: restBaseSchema
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}`;
266
+ const DUPLICATE_TARGET_CODE = "duplicate_target";
267
+ const duplicateTargetIssueSchema = z.object({ params: z.object({ code: z.literal(DUPLICATE_TARGET_CODE) }) });
268
+ const applyInputSchema = z.object({
269
+ intent: z.string().trim().min(1).max(500),
270
+ operations: z.array(writeOperationSchema).min(1).max(MAX_OPERATIONS)
271
+ }).superRefine((input, ctx) => {
272
+ const seen = /* @__PURE__ */ new Set();
273
+ for (const [index, operation] of input.operations.entries()) {
274
+ const target = operationTarget(operation);
275
+ if (seen.has(target)) ctx.addIssue({
276
+ code: "custom",
277
+ message: `batch addresses ${target} more than once`,
278
+ params: { code: DUPLICATE_TARGET_CODE },
279
+ path: ["operations", index]
280
+ });
281
+ seen.add(target);
282
+ }
283
+ });
284
+ const parseApplyInput = (input) => {
285
+ const parsed = applyInputSchema.safeParse(input);
286
+ if (parsed.success) return parsed.data;
287
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
288
+ const duplicate = parsed.error.issues.some((issue) => duplicateTargetIssueSchema.safeParse(issue).success);
289
+ throw new ApplyValidationError(duplicate ? DUPLICATE_TARGET_CODE : "invalid_operations", detail);
290
+ };
291
+ const REDIRECT_PATHS = ["target", "code"];
292
+ const changedPaths = (operation) => {
293
+ if (isRedirectOperation(operation)) return [...REDIRECT_PATHS];
294
+ const paths = changedFieldKeys(operation.fields);
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}`);
297
+ return paths;
298
+ };
299
+ const operationSentence = (operation) => {
300
+ if (isRedirectOperation(operation)) return `Redirect ${operation.source} to ${operation.target} (${operation.code})`;
301
+ return `Update ${joinPhrase(changedPaths(operation))} on ${operation.rest_base} ${operation.id}`;
302
+ };
303
+ // src/wp-receipts.ts
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
528
+ const ACF_PREFIX = "acf.";
529
+ const META_PREFIX = "meta.";
530
+ const UNREADABLE_MODIFIED_GMT_CODE = "unreadable_modified_gmt";
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";
532
+ const recordSchema = z.record(z.string(), z.json());
533
+ const stringSchema = z.string();
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
+ };
539
+ const readPath = (record, path) => {
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));
542
+ const value = record[path];
543
+ const editable = editableFieldSchema.safeParse(value);
544
+ return editable.success ? editable.data.raw : value;
545
+ };
546
+ const requestedValue = (operation, path) => {
547
+ if (path.startsWith(ACF_PREFIX)) return operation.acf?.[path.slice(4)];
548
+ if (path.startsWith(META_PREFIX)) return operation.meta?.[path.slice(5)];
549
+ const { fields } = operation;
550
+ if (fields === void 0) return;
551
+ return Object.entries(fields).find(([key]) => key === path)?.[1];
552
+ };
553
+ const adminEditUrl = (apiUrl, id) => new URL(`/wp-admin/post.php?post=${id}&action=edit`, apiUrl).toString();
554
+ const modifiedGmt = (record) => {
555
+ const value = stringSchema.safeParse(record.modified_gmt);
556
+ return value.success ? value.data : void 0;
557
+ };
558
+ const postSlug = (record) => {
559
+ const value = stringSchema.safeParse(record.slug);
560
+ return value.success ? value.data : "";
561
+ };
562
+ const resourcePath = (operation) => `/wp-json/wp/v2/${operation.rest_base}/${operation.id}`;
563
+ const readRecord = async (client, operation, telemetry) => {
564
+ const result = await wpFetch(client, `${resourcePath(operation)}?context=edit`, telemetry);
565
+ const parsed = recordSchema.safeParse(result.json);
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)}`);
567
+ return parsed.data;
568
+ };
569
+ const preflightPost = async (client, operation, telemetry, index) => {
570
+ const record = await readRecord(client, operation, telemetry);
571
+ const current = modifiedGmt(record);
572
+ if (current === void 0) throw new ApplyConflictError(UNREADABLE_MODIFIED_GMT_CODE, UNREADABLE_MODIFIED_GMT_MESSAGE);
573
+ if (operation.expected_modified_gmt !== void 0 && operation.expected_modified_gmt !== current) throw new ApplyConflictError("stale_expected_modified_gmt", `${operationTarget(operation)} was last modified at ${current}`);
574
+ const fields = changedPaths(operation).map((path) => {
575
+ const before = readPath(record, path);
576
+ const after = requestedValue(operation, path);
577
+ return {
578
+ after: previewValue(after),
579
+ before: previewValue(before),
580
+ changed: !deepEqual(before, after),
581
+ path
582
+ };
583
+ });
584
+ return {
585
+ editUrl: adminEditUrl(client.apiUrl, operation.id),
586
+ fields,
587
+ modifiedGmt: current,
588
+ operationIndex: index,
589
+ sentence: operationSentence(operation),
590
+ slug: postSlug(record),
591
+ target: operationTarget(operation)
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));
598
+ return preflightRemaining(client, operations, telemetry, index + 1, collected);
599
+ };
600
+ const preflightOperations = async (client, intent, operations, telemetry) => {
601
+ return {
602
+ intent,
603
+ operations: await preflightRemaining(client, operations, telemetry, 0, [])
604
+ };
605
+ };
606
+ const writeBody = (operation) => {
607
+ const body = {};
608
+ const { fields } = operation;
609
+ if (fields !== void 0) {
610
+ for (const [key, value] of Object.entries(fields)) if (value !== void 0) body[key] = value;
611
+ }
612
+ if (operation.acf !== void 0) body.acf = operation.acf;
613
+ if (operation.meta !== void 0) body.meta = operation.meta;
614
+ return body;
615
+ };
616
+ const classifyReadback = (operation, before, after) => {
617
+ const changed = [];
618
+ const missed = [];
619
+ const normalized = [];
620
+ for (const path of changedPaths(operation)) {
621
+ const requested = requestedValue(operation, path);
622
+ const previous = readPath(before, path);
623
+ if (deepEqual(previous, requested)) continue;
624
+ const stored = readPath(after, path);
625
+ if (deepEqual(stored, requested)) changed.push(path);
626
+ else if (deepEqual(stored, previous)) missed.push(path);
627
+ else {
628
+ changed.push(path);
629
+ normalized.push(path);
630
+ }
631
+ }
632
+ return {
633
+ changed,
634
+ missed,
635
+ normalized
636
+ };
637
+ };
638
+ const applyPost = async (client, operation, snapshot, telemetry) => {
639
+ const base = {
640
+ kind: "update_post",
641
+ operation_index: snapshot.operationIndex,
642
+ target: snapshot.target
643
+ };
644
+ if (snapshot.target !== operationTarget(operation)) return {
645
+ ...base,
646
+ changed_paths: [],
647
+ error: {
648
+ code: "preflight_mismatch",
649
+ message: "The approval preview describes a different target"
650
+ },
651
+ normalized_paths: [],
652
+ status: "failed",
653
+ target: operationTarget(operation)
654
+ };
655
+ let before;
656
+ try {
657
+ before = await readRecord(client, operation, telemetry);
658
+ } catch (error) {
659
+ return {
660
+ ...base,
661
+ changed_paths: [],
662
+ error: toReceiptError(error),
663
+ normalized_paths: [],
664
+ status: "failed"
665
+ };
666
+ }
667
+ const currentModifiedGmt = modifiedGmt(before);
668
+ if (currentModifiedGmt === void 0) return {
669
+ ...base,
670
+ changed_paths: [],
671
+ error: {
672
+ code: UNREADABLE_MODIFIED_GMT_CODE,
673
+ message: UNREADABLE_MODIFIED_GMT_MESSAGE
674
+ },
675
+ normalized_paths: [],
676
+ status: "conflict"
677
+ };
678
+ if (currentModifiedGmt !== snapshot.modifiedGmt) return {
679
+ ...base,
680
+ changed_paths: [],
681
+ error: {
682
+ code: "modified_since_preflight",
683
+ message: `${snapshot.target} changed after the approval preview`
684
+ },
685
+ normalized_paths: [],
686
+ status: "conflict"
687
+ };
688
+ let write;
689
+ try {
690
+ write = await wpFetch(client, resourcePath(operation), telemetry, {
691
+ body: JSON.stringify(writeBody(operation)),
692
+ headers: { "Content-Type": "application/json" },
693
+ method: "POST"
694
+ });
695
+ } catch (error) {
696
+ return {
697
+ ...base,
698
+ changed_paths: [],
699
+ error: toReceiptError(error),
700
+ normalized_paths: [],
701
+ status: "failed"
702
+ };
703
+ }
704
+ if (write.status < 200 || write.status >= 300) return {
705
+ ...base,
706
+ changed_paths: [],
707
+ error: {
708
+ code: write.errorCode ?? "write_failed",
709
+ message: wordPressErrorMessage(write.status, write.errorCode) ?? `WordPress rejected the write to ${snapshot.target}`
710
+ },
711
+ normalized_paths: [],
712
+ status: "failed"
713
+ };
714
+ let after;
715
+ try {
716
+ after = await readRecord(client, operation, telemetry);
717
+ } catch (error) {
718
+ return {
719
+ ...base,
720
+ changed_paths: [],
721
+ error: toReceiptError(error),
722
+ normalized_paths: [],
723
+ status: "failed"
724
+ };
725
+ }
726
+ const readback = classifyReadback(operation, before, after);
727
+ if (readback.missed.length > 0) return {
728
+ ...base,
729
+ changed_paths: readback.changed,
730
+ error: {
731
+ code: "readback_mismatch",
732
+ message: missedPathsMessage(readback.missed)
733
+ },
734
+ normalized_paths: readback.normalized,
735
+ status: "failed"
736
+ };
737
+ return {
738
+ ...base,
739
+ changed_paths: readback.changed,
740
+ normalized_paths: readback.normalized,
741
+ status: readback.changed.length > 0 ? "changed" : "unchanged"
742
+ };
743
+ };
744
+ const applyRemaining = async (client, operations, preflight, telemetry, index, collected) => {
745
+ const operation = operations.at(index);
746
+ if (operation === void 0) return collected;
747
+ const snapshot = preflight.operations.at(index);
748
+ if (snapshot === void 0) collected.push({
749
+ changed_paths: [],
750
+ error: {
751
+ code: "missing_preflight",
752
+ message: "No approval preview exists for this operation"
753
+ },
754
+ kind: operation.kind,
755
+ normalized_paths: [],
756
+ operation_index: index,
757
+ status: "failed",
758
+ target: operationTarget(operation)
759
+ });
760
+ else collected.push(isRedirectOperation(operation) ? await applyRedirect(client, operation, snapshot, telemetry) : await applyPost(client, operation, snapshot, telemetry));
761
+ return applyRemaining(client, operations, preflight, telemetry, index + 1, collected);
762
+ };
763
+ const applyOperations = async (options) => {
764
+ const receipts = await applyRemaining(options.client, options.operations, options.preflight, options.telemetry, 0, []);
765
+ return {
766
+ approverOpaque: options.approverOpaque,
767
+ executionId: options.executionId,
768
+ operations: receipts,
769
+ requesterOpaque: options.requesterOpaque,
770
+ status: batchStatus(receipts)
771
+ };
772
+ };
773
+ // src/wp-tools.ts
774
+ const WP_CONNECTOR_INSTRUCTIONS = [
775
+ "WordPress site reached as the signed-in Cloudflare Access user.",
776
+ "Reads: wp.graphql({query, variables?}) runs WPGraphQL (introspection allowed);",
777
+ "wp.get({path, query?}) GETs any /wp-json/ path and throws on a non-2xx response.",
778
+ "Writes: wp.apply({intent, operations}) is the only write path and pauses for human approval.",
779
+ `Send 1-${MAX_OPERATIONS} update_post or upsert_redirect operations per call and set expected_modified_gmt when you read the post first.`
780
+ ].join(" ");
781
+ const assertQueryOnly = (query) => {
782
+ let document;
783
+ try {
784
+ document = parse(query);
785
+ } catch {
786
+ throw new UpstreamRequestError("wp.graphql could not parse the query; send one valid GraphQL document");
787
+ }
788
+ if (document.definitions.some((definition) => definition.kind === Kind.OPERATION_DEFINITION && definition.operation !== OperationTypeNode.QUERY)) throw new UpstreamRequestError("wp.graphql runs queries only; make changes with wp.apply, which pauses for approval");
789
+ };
790
+ const METHOD_OVERRIDE_KEY = /method/iu;
791
+ const graphqlArgsSchema = z.object({
792
+ query: z.string().trim().min(1),
793
+ variables: z.record(z.string(), z.json()).optional()
794
+ });
795
+ const restQuerySchema = z.record(z.string(), z.union([
796
+ z.boolean(),
797
+ z.number(),
798
+ z.string()
799
+ ]));
800
+ const getArgsSchema = z.object({
801
+ path: z.string().min(1),
802
+ query: restQuerySchema.optional()
803
+ });
804
+ const callerClient = (env, caller) => createWordPressClient(env, {
805
+ ok: true,
806
+ subjectOpaque: caller.subjectOpaque,
807
+ token: caller.token
808
+ });
809
+ const queryString = (query) => {
810
+ if (query === void 0) return "";
811
+ const params = new URLSearchParams();
812
+ for (const [key, value] of Object.entries(query)) params.set(key, String(value));
813
+ const encoded = params.toString();
814
+ return encoded ? `?${encoded}` : "";
815
+ };
816
+ const createWordPressTools = (deps) => ({
817
+ apply: {
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.`,
819
+ execute: async (args, ctx) => {
820
+ const input = parseApplyInput(args);
821
+ const caller = deps.scope.require();
822
+ const executionId = ctx?.executionId ?? caller.executionId;
823
+ if (executionId === void 0) throw new UpstreamRequestError("wp.apply requires an execution id from the codemode runtime");
824
+ const preflight = caller.preflight ?? await deps.store.readPreflight(executionId) ?? {
825
+ intent: input.intent,
826
+ operations: []
827
+ };
828
+ return await applyOperations({
829
+ approverOpaque: caller.subjectOpaque,
830
+ client: callerClient(deps.env, caller),
831
+ executionId,
832
+ operations: input.operations,
833
+ preflight,
834
+ requesterOpaque: await deps.store.readRequester(executionId) ?? "unknown",
835
+ telemetry: deps.telemetry()
836
+ });
837
+ },
838
+ inputSchema: {
839
+ properties: {
840
+ intent: { type: "string" },
841
+ operations: {
842
+ items: { type: "object" },
843
+ type: "array"
844
+ }
845
+ },
846
+ required: ["intent", "operations"],
847
+ type: "object"
848
+ },
849
+ requiresApproval: true
850
+ },
851
+ get: {
852
+ description: "GET a WordPress REST path. The path must start with /wp-json/. Returns parsed JSON, or throws if WordPress responds with a non-2xx status.",
853
+ execute: async (args) => {
854
+ const parsed = getArgsSchema.safeParse(args);
855
+ if (!parsed.success) throw new UpstreamRequestError("wp.get requires a string path and scalar query values");
856
+ if (parsed.data.path.includes("?")) throw new UpstreamRequestError("wp.get path must not contain a query string; pass parameters in the query argument");
857
+ if (Object.keys(parsed.data.query ?? {}).some((key) => METHOD_OVERRIDE_KEY.test(key))) throw new UpstreamRequestError("wp.get is a read and takes no method override; make changes with wp.apply");
858
+ const result = await wpFetch(callerClient(deps.env, deps.scope.require()), `${parsed.data.path}${queryString(parsed.data.query)}`, deps.telemetry());
859
+ if (result.status < 200 || result.status >= 300) throw new UpstreamRequestError(wordPressErrorMessage(result.status, result.errorCode) ?? `WordPress returned HTTP ${result.status}`);
860
+ return result.json;
861
+ },
862
+ inputSchema: {
863
+ properties: {
864
+ path: { type: "string" },
865
+ query: { type: "object" }
866
+ },
867
+ required: ["path"],
868
+ type: "object"
869
+ }
870
+ },
871
+ graphql: {
872
+ description: "Run a WPGraphQL query against the site. Introspection works. Returns the full {data, errors?} response.",
873
+ execute: async (args) => {
874
+ const parsed = graphqlArgsSchema.safeParse(args);
875
+ if (!parsed.success) throw new UpstreamRequestError("wp.graphql requires a string query");
876
+ assertQueryOnly(parsed.data.query);
877
+ return await wpGraphql(callerClient(deps.env, deps.scope.require()), parsed.data.query, parsed.data.variables, deps.telemetry());
878
+ },
879
+ inputSchema: {
880
+ properties: {
881
+ query: { type: "string" },
882
+ variables: { type: "object" }
883
+ },
884
+ required: ["query"],
885
+ type: "object"
886
+ }
887
+ }
888
+ });
889
+ const wordpressConnector = {
890
+ configured: (env) => Boolean(env.WORDPRESS_API_URL?.trim()),
891
+ describe: (env, caller, telemetry) => buildSiteDescription(createWordPressClient(env, {
892
+ ok: true,
893
+ ...caller
894
+ }), telemetry),
895
+ executeDescription: [
896
+ "The wp global reaches WordPress.",
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.",
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 } }).",
900
+ "Read example: async () => await wp.get({ path: \"/wp-json/wp/v2/posts/123\", query: { context: \"edit\" } }).",
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\" } }] }); }"
902
+ ].join(" "),
903
+ instructions: WP_CONNECTOR_INSTRUCTIONS,
904
+ name: "wp",
905
+ parseApply: (args) => {
906
+ const input = parseApplyInput(args);
907
+ return {
908
+ intent: input.intent,
909
+ sentences: input.operations.map((operation) => operationSentence(operation))
910
+ };
911
+ },
912
+ preflight: (env, caller, args, telemetry) => {
913
+ const input = parseApplyInput(args);
914
+ return preflightOperations(createWordPressClient(env, {
915
+ ok: true,
916
+ ...caller
917
+ }), input.intent, input.operations, telemetry);
918
+ },
919
+ tools: createWordPressTools
920
+ };
921
+ export { WP_CONNECTOR_INSTRUCTIONS, applyInputSchema, applyOperations, buildSiteDescription, createWordPressClient, createWordPressTools, inspectWordPressResponse, parseApplyInput, preflightOperations, wordpressConnector, wpFetch, wpGraphql };
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@patronage/mcp-wordpress",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "The WordPress connector for @patronage/mcp-core: WPGraphQL and REST reads, and post updates through the approval step.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/patronage/launch.git",
9
+ "directory": "packages/mcp-wordpress"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@patronage/mcp-core": "0.1.0",
26
+ "graphql": "17.0.2",
27
+ "zod": "4.6.7"
28
+ },
29
+ "devDependencies": {
30
+ "@cloudflare/workers-types": "5.20261031.1",
31
+ "@patronage/access": "0.1.0",
32
+ "@types/node": "24.19.2",
33
+ "evlog": "2.26.0",
34
+ "tsdown": "0.23.4",
35
+ "vitest": "5.0.5"
36
+ },
37
+ "engines": {
38
+ "node": ">=24"
39
+ },
40
+ "scripts": {
41
+ "build": "pnpm --filter @patronage/mcp-core generate && tsdown",
42
+ "test": "pnpm --filter @patronage/mcp-core generate && vitest run",
43
+ "typecheck": "pnpm --filter @patronage/mcp-core generate && tsc --noEmit"
44
+ }
6
45
  }