@patronage/mcp-wordpress 0.0.0-stage → 0.1.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,15 @@
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 `update_post` operations (core and ACF fields, with an optional `expected_modified_gmt` check) for approval.
8
+
9
+ It also gives `describe_site` the site's post types and GraphQL types.
10
+
11
+ 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.
12
+
13
+ ```ts
14
+ import { wordpressConnector } from "@patronage/mcp-wordpress";
15
+ ```
@@ -0,0 +1,147 @@
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
+ rest_base: z.ZodString;
54
+ }, z.core.$strict>;
55
+ type UpdatePostOperation = z.infer<typeof updatePostOperationSchema>;
56
+ type WriteOperation = UpdatePostOperation;
57
+ export declare const applyInputSchema: z.ZodObject<{
58
+ intent: z.ZodString;
59
+ operations: z.ZodArray<z.ZodObject<{
60
+ acf: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
61
+ expected_modified_gmt: z.ZodOptional<z.ZodString>;
62
+ fields: z.ZodOptional<z.ZodObject<{
63
+ content: z.ZodOptional<z.ZodString>;
64
+ excerpt: z.ZodOptional<z.ZodString>;
65
+ slug: z.ZodOptional<z.ZodString>;
66
+ status: z.ZodOptional<z.ZodEnum<{
67
+ draft: "draft";
68
+ pending: "pending";
69
+ private: "private";
70
+ publish: "publish";
71
+ }>>;
72
+ title: z.ZodOptional<z.ZodString>;
73
+ }, z.core.$strict>>;
74
+ id: z.ZodNumber;
75
+ kind: z.ZodLiteral<"update_post">;
76
+ rest_base: z.ZodString;
77
+ }, z.core.$strict>>;
78
+ }, z.core.$strip>;
79
+ type ApplyInput = z.infer<typeof applyInputSchema>;
80
+ export declare const parseApplyInput: <T>(input: T) => ApplyInput;
81
+ export declare const preflightOperations: (client: UpstreamClient, intent: string, operations: WriteOperation[], telemetry: UpstreamTelemetry) => Promise<ApplyPreflight>;
82
+ export declare const applyOperations: (options: {
83
+ approverOpaque: string;
84
+ client: UpstreamClient;
85
+ executionId: string;
86
+ operations: WriteOperation[];
87
+ preflight: ApplyPreflight;
88
+ requesterOpaque: string;
89
+ telemetry: UpstreamTelemetry;
90
+ }) => Promise<BatchReceipt>;
91
+ export declare const wordpressConnector: GatewayConnector<WordPressEnv>;
92
+ export declare const WP_CONNECTOR_INSTRUCTIONS: string;
93
+ export declare const createWordPressTools: (deps: ConnectorDeps<WordPressEnv>) => {
94
+ apply: {
95
+ description: string;
96
+ execute: <T>(args: T, ctx: import("@patronage/mcp-core").ToolCallContext | undefined) => Promise<import("@patronage/mcp-core").BatchReceipt>;
97
+ inputSchema: {
98
+ properties: {
99
+ intent: {
100
+ type: "string";
101
+ };
102
+ operations: {
103
+ items: {
104
+ type: "object";
105
+ };
106
+ type: "array";
107
+ };
108
+ };
109
+ required: string[];
110
+ type: "object";
111
+ };
112
+ requiresApproval: true;
113
+ };
114
+ get: {
115
+ description: string;
116
+ execute: <T>(args: T) => Promise<z.JSONType>;
117
+ inputSchema: {
118
+ properties: {
119
+ path: {
120
+ type: "string";
121
+ };
122
+ query: {
123
+ type: "object";
124
+ };
125
+ };
126
+ required: string[];
127
+ type: "object";
128
+ };
129
+ };
130
+ graphql: {
131
+ description: string;
132
+ execute: <T>(args: T) => Promise<z.JSONType>;
133
+ inputSchema: {
134
+ properties: {
135
+ query: {
136
+ type: "string";
137
+ };
138
+ variables: {
139
+ type: "object";
140
+ };
141
+ };
142
+ required: string[];
143
+ type: "object";
144
+ };
145
+ };
146
+ };
147
+ export type { ApplyInput, WordPressDescription, WordPressEnv, WriteOperation };
package/dist/index.js ADDED
@@ -0,0 +1,689 @@
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
+ 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}`;
250
+ const DUPLICATE_TARGET_CODE = "duplicate_target";
251
+ const duplicateTargetIssueSchema = z.object({ params: z.object({ code: z.literal(DUPLICATE_TARGET_CODE) }) });
252
+ const applyInputSchema = z.object({
253
+ intent: z.string().trim().min(1).max(500),
254
+ operations: z.array(updatePostOperationSchema).min(1).max(MAX_OPERATIONS)
255
+ }).superRefine((input, ctx) => {
256
+ const seen = /* @__PURE__ */ new Set();
257
+ for (const [index, operation] of input.operations.entries()) {
258
+ const target = operationTarget(operation);
259
+ if (seen.has(target)) ctx.addIssue({
260
+ code: "custom",
261
+ message: `batch addresses ${target} more than once`,
262
+ params: { code: DUPLICATE_TARGET_CODE },
263
+ path: ["operations", index]
264
+ });
265
+ seen.add(target);
266
+ }
267
+ });
268
+ const parseApplyInput = (input) => {
269
+ const parsed = applyInputSchema.safeParse(input);
270
+ if (parsed.success) return parsed.data;
271
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
272
+ const duplicate = parsed.error.issues.some((issue) => duplicateTargetIssueSchema.safeParse(issue).success);
273
+ throw new ApplyValidationError(duplicate ? DUPLICATE_TARGET_CODE : "invalid_operations", detail);
274
+ };
275
+ const changedPaths = (operation) => {
276
+ const paths = changedFieldKeys(operation.fields);
277
+ for (const key of Object.keys(operation.acf ?? {}).toSorted()) paths.push(`acf.${key}`);
278
+ return paths;
279
+ };
280
+ const operationSentence = (operation) => {
281
+ return `Update ${joinPhrase(changedPaths(operation))} on ${operation.rest_base} ${operation.id}`;
282
+ };
283
+ // src/wp-apply.ts
284
+ const MISSED_PATH_LIMIT = 5;
285
+ const ACF_PREFIX = "acf.";
286
+ const UNREADABLE_MODIFIED_GMT_CODE = "unreadable_modified_gmt";
287
+ 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
+ const recordSchema = z.record(z.string(), z.json());
289
+ const stringSchema = z.string();
290
+ const editableFieldSchema = z.object({ raw: z.json() });
291
+ 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
+ }
296
+ const value = record[path];
297
+ const editable = editableFieldSchema.safeParse(value);
298
+ return editable.success ? editable.data.raw : value;
299
+ };
300
+ const requestedValue = (operation, path) => {
301
+ if (path.startsWith(ACF_PREFIX)) return operation.acf?.[path.slice(4)];
302
+ const { fields } = operation;
303
+ if (fields === void 0) return;
304
+ return Object.entries(fields).find(([key]) => key === path)?.[1];
305
+ };
306
+ const adminEditUrl = (apiUrl, id) => new URL(`/wp-admin/post.php?post=${id}&action=edit`, apiUrl).toString();
307
+ const modifiedGmt = (record) => {
308
+ const value = stringSchema.safeParse(record.modified_gmt);
309
+ return value.success ? value.data : void 0;
310
+ };
311
+ const postSlug = (record) => {
312
+ const value = stringSchema.safeParse(record.slug);
313
+ return value.success ? value.data : "";
314
+ };
315
+ const resourcePath = (operation) => `/wp-json/wp/v2/${operation.rest_base}/${operation.id}`;
316
+ const readRecord = async (client, operation, telemetry) => {
317
+ const result = await wpFetch(client, `${resourcePath(operation)}?context=edit`, telemetry);
318
+ const parsed = recordSchema.safeParse(result.json);
319
+ 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
+ return parsed.data;
321
+ };
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;
339
+ const record = await readRecord(client, operation, telemetry);
340
+ const current = modifiedGmt(record);
341
+ if (current === void 0) throw new ApplyConflictError(UNREADABLE_MODIFIED_GMT_CODE, UNREADABLE_MODIFIED_GMT_MESSAGE);
342
+ 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}`);
343
+ const fields = changedPaths(operation).map((path) => {
344
+ const before = readPath(record, path);
345
+ const after = requestedValue(operation, path);
346
+ return {
347
+ after: previewValue(after),
348
+ before: previewValue(before),
349
+ changed: !deepEqual(before, after),
350
+ path
351
+ };
352
+ });
353
+ collected.push({
354
+ editUrl: adminEditUrl(client.apiUrl, operation.id),
355
+ fields,
356
+ modifiedGmt: current,
357
+ operationIndex: index,
358
+ sentence: operationSentence(operation),
359
+ slug: postSlug(record),
360
+ target: operationTarget(operation)
361
+ });
362
+ return preflightRemaining(client, operations, telemetry, index + 1, collected);
363
+ };
364
+ const preflightOperations = async (client, intent, operations, telemetry) => {
365
+ return {
366
+ intent,
367
+ operations: await preflightRemaining(client, operations, telemetry, 0, [])
368
+ };
369
+ };
370
+ const writeBody = (operation) => {
371
+ const body = {};
372
+ const { fields } = operation;
373
+ if (fields !== void 0) {
374
+ for (const [key, value] of Object.entries(fields)) if (value !== void 0) body[key] = value;
375
+ }
376
+ if (operation.acf !== void 0) body.acf = operation.acf;
377
+ return body;
378
+ };
379
+ const classifyReadback = (operation, before, after) => {
380
+ const changed = [];
381
+ const missed = [];
382
+ const normalized = [];
383
+ for (const path of changedPaths(operation)) {
384
+ const requested = requestedValue(operation, path);
385
+ const previous = readPath(before, path);
386
+ if (deepEqual(previous, requested)) continue;
387
+ const stored = readPath(after, path);
388
+ if (deepEqual(stored, requested)) changed.push(path);
389
+ else if (deepEqual(stored, previous)) missed.push(path);
390
+ else {
391
+ changed.push(path);
392
+ normalized.push(path);
393
+ }
394
+ }
395
+ return {
396
+ changed,
397
+ missed,
398
+ normalized
399
+ };
400
+ };
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) => {
408
+ const base = {
409
+ kind: "update_post",
410
+ operation_index: snapshot.operationIndex,
411
+ target: snapshot.target
412
+ };
413
+ if (snapshot.target !== operationTarget(operation)) return {
414
+ ...base,
415
+ changed_paths: [],
416
+ error: {
417
+ code: "preflight_mismatch",
418
+ message: "The approval preview describes a different target"
419
+ },
420
+ normalized_paths: [],
421
+ status: "failed",
422
+ target: operationTarget(operation)
423
+ };
424
+ let before;
425
+ try {
426
+ before = await readRecord(client, operation, telemetry);
427
+ } catch (error) {
428
+ return {
429
+ ...base,
430
+ changed_paths: [],
431
+ error: toReceiptError(error),
432
+ normalized_paths: [],
433
+ status: "failed"
434
+ };
435
+ }
436
+ const currentModifiedGmt = modifiedGmt(before);
437
+ if (currentModifiedGmt === void 0) return {
438
+ ...base,
439
+ changed_paths: [],
440
+ error: {
441
+ code: UNREADABLE_MODIFIED_GMT_CODE,
442
+ message: UNREADABLE_MODIFIED_GMT_MESSAGE
443
+ },
444
+ normalized_paths: [],
445
+ status: "conflict"
446
+ };
447
+ if (currentModifiedGmt !== snapshot.modifiedGmt) return {
448
+ ...base,
449
+ changed_paths: [],
450
+ error: {
451
+ code: "modified_since_preflight",
452
+ message: `${snapshot.target} changed after the approval preview`
453
+ },
454
+ normalized_paths: [],
455
+ status: "conflict"
456
+ };
457
+ let write;
458
+ try {
459
+ write = await wpFetch(client, resourcePath(operation), telemetry, {
460
+ body: JSON.stringify(writeBody(operation)),
461
+ headers: { "Content-Type": "application/json" },
462
+ method: "POST"
463
+ });
464
+ } catch (error) {
465
+ return {
466
+ ...base,
467
+ changed_paths: [],
468
+ error: toReceiptError(error),
469
+ normalized_paths: [],
470
+ status: "failed"
471
+ };
472
+ }
473
+ if (write.status < 200 || write.status >= 300) return {
474
+ ...base,
475
+ changed_paths: [],
476
+ error: {
477
+ code: write.errorCode ?? "write_failed",
478
+ message: wordPressErrorMessage(write.status, write.errorCode) ?? `WordPress rejected the write to ${snapshot.target}`
479
+ },
480
+ normalized_paths: [],
481
+ status: "failed"
482
+ };
483
+ let after;
484
+ try {
485
+ after = await readRecord(client, operation, telemetry);
486
+ } catch (error) {
487
+ return {
488
+ ...base,
489
+ changed_paths: [],
490
+ error: toReceiptError(error),
491
+ normalized_paths: [],
492
+ status: "failed"
493
+ };
494
+ }
495
+ const readback = classifyReadback(operation, before, after);
496
+ if (readback.missed.length > 0) return {
497
+ ...base,
498
+ changed_paths: readback.changed,
499
+ error: {
500
+ code: "readback_mismatch",
501
+ message: missedPathsMessage(readback.missed)
502
+ },
503
+ normalized_paths: readback.normalized,
504
+ status: "failed"
505
+ };
506
+ return {
507
+ ...base,
508
+ changed_paths: readback.changed,
509
+ normalized_paths: readback.normalized,
510
+ status: readback.changed.length > 0 ? "changed" : "unchanged"
511
+ };
512
+ };
513
+ const applyRemaining = async (client, operations, preflight, telemetry, index, collected) => {
514
+ const operation = operations.at(index);
515
+ if (operation === void 0) return collected;
516
+ const snapshot = preflight.operations.at(index);
517
+ if (snapshot === void 0) collected.push({
518
+ changed_paths: [],
519
+ error: {
520
+ code: "missing_preflight",
521
+ message: "No approval preview exists for this operation"
522
+ },
523
+ kind: "update_post",
524
+ normalized_paths: [],
525
+ operation_index: index,
526
+ status: "failed",
527
+ target: operationTarget(operation)
528
+ });
529
+ else collected.push(await applyOne(client, operation, snapshot, telemetry));
530
+ return applyRemaining(client, operations, preflight, telemetry, index + 1, collected);
531
+ };
532
+ const applyOperations = async (options) => {
533
+ const receipts = await applyRemaining(options.client, options.operations, options.preflight, options.telemetry, 0, []);
534
+ return {
535
+ approverOpaque: options.approverOpaque,
536
+ executionId: options.executionId,
537
+ operations: receipts,
538
+ requesterOpaque: options.requesterOpaque,
539
+ status: batchStatus(receipts)
540
+ };
541
+ };
542
+ // src/wp-tools.ts
543
+ const WP_CONNECTOR_INSTRUCTIONS = [
544
+ "WordPress site reached as the signed-in Cloudflare Access user.",
545
+ "Reads: wp.graphql({query, variables?}) runs WPGraphQL (introspection allowed);",
546
+ "wp.get({path, query?}) GETs any /wp-json/ path and throws on a non-2xx response.",
547
+ "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.`
549
+ ].join(" ");
550
+ const assertQueryOnly = (query) => {
551
+ let document;
552
+ try {
553
+ document = parse(query);
554
+ } catch {
555
+ throw new UpstreamRequestError("wp.graphql could not parse the query; send one valid GraphQL document");
556
+ }
557
+ 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");
558
+ };
559
+ const METHOD_OVERRIDE_KEY = /method/iu;
560
+ const graphqlArgsSchema = z.object({
561
+ query: z.string().trim().min(1),
562
+ variables: z.record(z.string(), z.json()).optional()
563
+ });
564
+ const restQuerySchema = z.record(z.string(), z.union([
565
+ z.boolean(),
566
+ z.number(),
567
+ z.string()
568
+ ]));
569
+ const getArgsSchema = z.object({
570
+ path: z.string().min(1),
571
+ query: restQuerySchema.optional()
572
+ });
573
+ const callerClient = (env, caller) => createWordPressClient(env, {
574
+ ok: true,
575
+ subjectOpaque: caller.subjectOpaque,
576
+ token: caller.token
577
+ });
578
+ const queryString = (query) => {
579
+ if (query === void 0) return "";
580
+ const params = new URLSearchParams();
581
+ for (const [key, value] of Object.entries(query)) params.set(key, String(value));
582
+ const encoded = params.toString();
583
+ return encoded ? `?${encoded}` : "";
584
+ };
585
+ const createWordPressTools = (deps) => ({
586
+ 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.`,
588
+ execute: async (args, ctx) => {
589
+ const input = parseApplyInput(args);
590
+ const caller = deps.scope.require();
591
+ const executionId = ctx?.executionId ?? caller.executionId;
592
+ if (executionId === void 0) throw new UpstreamRequestError("wp.apply requires an execution id from the codemode runtime");
593
+ const preflight = caller.preflight ?? await deps.store.readPreflight(executionId) ?? {
594
+ intent: input.intent,
595
+ operations: []
596
+ };
597
+ return await applyOperations({
598
+ approverOpaque: caller.subjectOpaque,
599
+ client: callerClient(deps.env, caller),
600
+ executionId,
601
+ operations: input.operations,
602
+ preflight,
603
+ requesterOpaque: await deps.store.readRequester(executionId) ?? "unknown",
604
+ telemetry: deps.telemetry()
605
+ });
606
+ },
607
+ inputSchema: {
608
+ properties: {
609
+ intent: { type: "string" },
610
+ operations: {
611
+ items: { type: "object" },
612
+ type: "array"
613
+ }
614
+ },
615
+ required: ["intent", "operations"],
616
+ type: "object"
617
+ },
618
+ requiresApproval: true
619
+ },
620
+ get: {
621
+ 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.",
622
+ execute: async (args) => {
623
+ const parsed = getArgsSchema.safeParse(args);
624
+ if (!parsed.success) throw new UpstreamRequestError("wp.get requires a string path and scalar query values");
625
+ if (parsed.data.path.includes("?")) throw new UpstreamRequestError("wp.get path must not contain a query string; pass parameters in the query argument");
626
+ 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");
627
+ const result = await wpFetch(callerClient(deps.env, deps.scope.require()), `${parsed.data.path}${queryString(parsed.data.query)}`, deps.telemetry());
628
+ if (result.status < 200 || result.status >= 300) throw new UpstreamRequestError(wordPressErrorMessage(result.status, result.errorCode) ?? `WordPress returned HTTP ${result.status}`);
629
+ return result.json;
630
+ },
631
+ inputSchema: {
632
+ properties: {
633
+ path: { type: "string" },
634
+ query: { type: "object" }
635
+ },
636
+ required: ["path"],
637
+ type: "object"
638
+ }
639
+ },
640
+ graphql: {
641
+ description: "Run a WPGraphQL query against the site. Introspection works. Returns the full {data, errors?} response.",
642
+ execute: async (args) => {
643
+ const parsed = graphqlArgsSchema.safeParse(args);
644
+ if (!parsed.success) throw new UpstreamRequestError("wp.graphql requires a string query");
645
+ assertQueryOnly(parsed.data.query);
646
+ return await wpGraphql(callerClient(deps.env, deps.scope.require()), parsed.data.query, parsed.data.variables, deps.telemetry());
647
+ },
648
+ inputSchema: {
649
+ properties: {
650
+ query: { type: "string" },
651
+ variables: { type: "object" }
652
+ },
653
+ required: ["query"],
654
+ type: "object"
655
+ }
656
+ }
657
+ });
658
+ const wordpressConnector = {
659
+ configured: (env) => Boolean(env.WORDPRESS_API_URL?.trim()),
660
+ describe: (env, caller, telemetry) => buildSiteDescription(createWordPressClient(env, {
661
+ ok: true,
662
+ ...caller
663
+ }), telemetry),
664
+ executeDescription: [
665
+ "The wp global reaches WordPress.",
666
+ "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.",
668
+ "Read example: async () => await wp.get({ path: \"/wp-json/wp/v2/posts/123\", query: { context: \"edit\" } }).",
669
+ "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
+ ].join(" "),
671
+ instructions: WP_CONNECTOR_INSTRUCTIONS,
672
+ name: "wp",
673
+ parseApply: (args) => {
674
+ const input = parseApplyInput(args);
675
+ return {
676
+ intent: input.intent,
677
+ sentences: input.operations.map((operation) => operationSentence(operation))
678
+ };
679
+ },
680
+ preflight: (env, caller, args, telemetry) => {
681
+ const input = parseApplyInput(args);
682
+ return preflightOperations(createWordPressClient(env, {
683
+ ok: true,
684
+ ...caller
685
+ }), input.intent, input.operations, telemetry);
686
+ },
687
+ tools: createWordPressTools
688
+ };
689
+ 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.1.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
  }