wp-mcp-gateway 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.
@@ -0,0 +1,40 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ToolError } from "../utils/errors.js";
3
+ export class ConfirmationService {
4
+ ttlSeconds;
5
+ store = new Map();
6
+ constructor(ttlSeconds) {
7
+ this.ttlSeconds = ttlSeconds;
8
+ }
9
+ issue(action, key, input) {
10
+ const token = randomUUID();
11
+ const issuedAt = new Date();
12
+ const expiresAt = new Date(issuedAt.getTime() + this.ttlSeconds * 1000);
13
+ const payload = {
14
+ action,
15
+ key,
16
+ input,
17
+ issued_at: issuedAt.toISOString(),
18
+ expires_at: expiresAt.toISOString(),
19
+ };
20
+ this.store.set(token, {
21
+ payload,
22
+ expiresAtEpochMs: expiresAt.getTime(),
23
+ });
24
+ return { token, payload };
25
+ }
26
+ consume(token, expectedAction, expectedKey) {
27
+ const stored = this.store.get(token);
28
+ if (!stored) {
29
+ throw new ToolError("INVALID_CONFIRMATION_TOKEN", "Confirmation token is invalid or already used");
30
+ }
31
+ this.store.delete(token);
32
+ if (Date.now() > stored.expiresAtEpochMs) {
33
+ throw new ToolError("EXPIRED_CONFIRMATION_TOKEN", "Confirmation token has expired");
34
+ }
35
+ if (stored.payload.action !== expectedAction || stored.payload.key !== expectedKey) {
36
+ throw new ToolError("MISMATCH_CONFIRMATION_TOKEN", "Confirmation token does not match action payload");
37
+ }
38
+ return stored.payload;
39
+ }
40
+ }
@@ -0,0 +1,37 @@
1
+ import MarkdownIt from "markdown-it";
2
+ const md = new MarkdownIt({
3
+ html: true, // Allow HTML passthrough (content may already contain HTML)
4
+ linkify: true, // Auto-convert URLs to links
5
+ typographer: true, // Smart quotes, dashes
6
+ });
7
+ /**
8
+ * Detect whether content is likely Markdown vs HTML.
9
+ * Heuristic: if it contains block-level HTML tags, treat as HTML.
10
+ */
11
+ function looksLikeHtml(content) {
12
+ // Check for common block-level HTML elements
13
+ return /<(div|p|h[1-6]|ul|ol|table|figure|section|article|blockquote)\b/i.test(content);
14
+ }
15
+ /**
16
+ * Convert content to HTML based on the specified format.
17
+ * - "html": pass through unchanged
18
+ * - "markdown": always convert
19
+ * - "auto" (default): detect and convert if it looks like markdown
20
+ */
21
+ export function toHtml(content, format = "auto") {
22
+ if (!content || !content.trim()) {
23
+ return content;
24
+ }
25
+ if (format === "html") {
26
+ return content;
27
+ }
28
+ if (format === "markdown") {
29
+ return md.render(content);
30
+ }
31
+ // Auto mode: if it already looks like HTML, leave it alone
32
+ if (looksLikeHtml(content)) {
33
+ return content;
34
+ }
35
+ // Otherwise, convert from markdown
36
+ return md.render(content);
37
+ }
@@ -0,0 +1,75 @@
1
+ import { ToolError } from "../utils/errors.js";
2
+ const DEFAULT_PATCH_FIELDS = new Set([
3
+ "title",
4
+ "content",
5
+ "excerpt",
6
+ "slug",
7
+ "featured_media",
8
+ "author",
9
+ "date",
10
+ ]);
11
+ const DEFAULT_YOAST_META_FIELDS = new Set([
12
+ "yoast_wpseo_title",
13
+ "yoast_wpseo_metadesc",
14
+ "yoast_wpseo_canonical",
15
+ "yoast_wpseo_focuskw",
16
+ "yoast_wpseo_opengraph-title",
17
+ "yoast_wpseo_opengraph-description",
18
+ "yoast_wpseo_twitter-title",
19
+ "yoast_wpseo_twitter-description",
20
+ ]);
21
+ export class PolicyService {
22
+ config;
23
+ constructor(config) {
24
+ this.config = config;
25
+ }
26
+ assertAllowedContentType(contentType) {
27
+ if (!this.config.allowedContentTypes.includes(contentType)) {
28
+ throw new ToolError("DISALLOWED_CONTENT_TYPE", `Content type is not allowed: ${contentType}`);
29
+ }
30
+ }
31
+ assertAllowedTaxonomy(taxonomy) {
32
+ if (!this.config.allowedTaxonomies.includes(taxonomy)) {
33
+ throw new ToolError("DISALLOWED_TAXONOMY", `Taxonomy is not allowed: ${taxonomy}`);
34
+ }
35
+ }
36
+ assertAllowedAuthor(authorId) {
37
+ if (this.config.allowedAuthorIds.length === 0) {
38
+ return;
39
+ }
40
+ if (!this.config.allowedAuthorIds.includes(authorId)) {
41
+ throw new ToolError("DISALLOWED_AUTHOR", `Author is not allowlisted: ${authorId}`);
42
+ }
43
+ }
44
+ assertPatchFields(patch) {
45
+ for (const field of Object.keys(patch)) {
46
+ if (!DEFAULT_PATCH_FIELDS.has(field)) {
47
+ throw new ToolError("DISALLOWED_PATCH_FIELD", `Patch field is not allowed: ${field}`);
48
+ }
49
+ }
50
+ }
51
+ assertYoastPath(path) {
52
+ if (!this.config.yoastAllowedPaths.some((allowed) => path.startsWith(allowed))) {
53
+ throw new ToolError("DISALLOWED_YOAST_PATH", `Yoast path is not allowed: ${path}`);
54
+ }
55
+ }
56
+ assertYoastMetaKeys(meta) {
57
+ for (const key of Object.keys(meta)) {
58
+ if (!DEFAULT_YOAST_META_FIELDS.has(key)) {
59
+ throw new ToolError("DISALLOWED_YOAST_META_KEY", `Yoast meta key is not allowed: ${key}`);
60
+ }
61
+ }
62
+ }
63
+ assertMediaPolicy(mimeType, sizeBytes, altText) {
64
+ if (!this.config.mediaAllowedMimeTypes.includes(mimeType)) {
65
+ throw new ToolError("DISALLOWED_MEDIA_MIME", `MIME type is not allowed: ${mimeType}`);
66
+ }
67
+ const maxBytes = this.config.mediaMaxSizeMb * 1024 * 1024;
68
+ if (sizeBytes > maxBytes) {
69
+ throw new ToolError("MEDIA_SIZE_EXCEEDED", `Media exceeds max size of ${this.config.mediaMaxSizeMb}MB`);
70
+ }
71
+ if (this.config.mediaRequireAltText && (!altText || !altText.trim())) {
72
+ throw new ToolError("MISSING_ALT_TEXT", "Alt text is required by policy");
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,46 @@
1
+ import { ToolError } from "../utils/errors.js";
2
+ /**
3
+ * Simple token-bucket rate limiter.
4
+ * Limits the number of outgoing requests per time window.
5
+ */
6
+ export class RateLimiter {
7
+ maxTokens;
8
+ refillRatePerSecond;
9
+ bucket;
10
+ /**
11
+ * @param maxTokens Maximum burst size (requests)
12
+ * @param refillRatePerSecond Tokens added per second
13
+ */
14
+ constructor(maxTokens = 30, refillRatePerSecond = 1) {
15
+ this.maxTokens = maxTokens;
16
+ this.refillRatePerSecond = refillRatePerSecond;
17
+ this.bucket = {
18
+ tokens: maxTokens,
19
+ lastRefillEpochMs: Date.now(),
20
+ };
21
+ }
22
+ /**
23
+ * Consume one token. Throws if rate limit exceeded.
24
+ */
25
+ consume() {
26
+ this.refill();
27
+ if (this.bucket.tokens < 1) {
28
+ throw new ToolError("RATE_LIMIT_EXCEEDED", "Too many requests. Please wait before retrying.", { retryable: true });
29
+ }
30
+ this.bucket.tokens -= 1;
31
+ }
32
+ /**
33
+ * Check remaining tokens without consuming.
34
+ */
35
+ remaining() {
36
+ this.refill();
37
+ return Math.floor(this.bucket.tokens);
38
+ }
39
+ refill() {
40
+ const now = Date.now();
41
+ const elapsedSeconds = (now - this.bucket.lastRefillEpochMs) / 1000;
42
+ const newTokens = elapsedSeconds * this.refillRatePerSecond;
43
+ this.bucket.tokens = Math.min(this.maxTokens, this.bucket.tokens + newTokens);
44
+ this.bucket.lastRefillEpochMs = now;
45
+ }
46
+ }
@@ -0,0 +1,384 @@
1
+ import { z } from "zod";
2
+ import { serializeError, ToolError } from "../utils/errors.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import { toHtml } from "../services/markdownService.js";
5
+ const ContentTypeSchema = z.enum(["post", "page", "featured_item"]);
6
+ const PlacementSchema = z.enum(["start", "end", "before_heading", "after_heading", "marker"]);
7
+ function respond(data) {
8
+ return {
9
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
10
+ };
11
+ }
12
+ function requireConfirmation(confirmations, action, key, input, confirmationToken) {
13
+ if (!confirmationToken) {
14
+ const issued = confirmations.issue(action, key, input);
15
+ return {
16
+ confirmed: false,
17
+ payload: {
18
+ requires_confirmation: true,
19
+ confirmation_token: issued.token,
20
+ confirmation_payload: issued.payload,
21
+ },
22
+ };
23
+ }
24
+ confirmations.consume(confirmationToken, action, key);
25
+ return { confirmed: true };
26
+ }
27
+ export function registerTools(context) {
28
+ const { server, client, policy, confirmations, rateLimiter } = context;
29
+ async function runToolWithLimit(name, input, fn) {
30
+ try {
31
+ rateLimiter.consume();
32
+ const result = await fn();
33
+ return respond({ success: true, tool: name, data: result });
34
+ }
35
+ catch (error) {
36
+ const serialized = serializeError(error);
37
+ logger.error(`Tool ${name} failed`, { input, error: serialized });
38
+ return respond({ success: false, tool: name, error: serialized });
39
+ }
40
+ }
41
+ server.tool("wp_site_info", "Get site and capability metadata.", {}, async () => runToolWithLimit("wp_site_info", {}, async () => client.siteInfo()));
42
+ server.tool("wp_list_content_types", "List content types allowed for this MCP server.", {}, async () => runToolWithLimit("wp_list_content_types", {}, async () => client.listContentTypes()));
43
+ server.tool("wp_find_content", "Search and list content items across allowed content types.", {
44
+ content_type: ContentTypeSchema.optional(),
45
+ search: z.string().optional(),
46
+ status: z.string().optional(),
47
+ author: z.number().int().positive().optional(),
48
+ page: z.number().int().positive().optional(),
49
+ per_page: z.number().int().positive().max(100).optional(),
50
+ parent_term_slug: z.string().optional(),
51
+ parent_taxonomy: z.string().optional(),
52
+ solutions_only: z.boolean().optional(),
53
+ }, async (rawInput) => runToolWithLimit("wp_find_content", rawInput, async () => {
54
+ if (typeof rawInput.content_type === "string") {
55
+ policy.assertAllowedContentType(rawInput.content_type);
56
+ }
57
+ if (typeof rawInput.author === "number") {
58
+ policy.assertAllowedAuthor(rawInput.author);
59
+ }
60
+ return client.findContent(rawInput);
61
+ }));
62
+ server.tool("wp_get_content", "Get a single content item with edit context.", {
63
+ id: z.number().int().positive(),
64
+ content_type: ContentTypeSchema,
65
+ }, async (rawInput) => runToolWithLimit("wp_get_content", rawInput, async () => {
66
+ policy.assertAllowedContentType(String(rawInput.content_type));
67
+ return client.getContent(Number(rawInput.id));
68
+ }));
69
+ server.tool("wp_create_draft", "Create a draft for an allowed content type.", {
70
+ content_type: ContentTypeSchema,
71
+ title: z.string().min(1),
72
+ content: z.string().optional(),
73
+ excerpt: z.string().optional(),
74
+ content_format: z.enum(["html", "markdown", "auto"]).optional(),
75
+ slug: z.string().optional(),
76
+ author: z.number().int().positive().optional(),
77
+ date: z.string().optional(),
78
+ terms: z.record(z.array(z.number().int().positive())).optional(),
79
+ yoast_meta: z.record(z.unknown()).optional(),
80
+ }, async (rawInput) => runToolWithLimit("wp_create_draft", rawInput, async () => {
81
+ const contentType = String(rawInput.content_type);
82
+ policy.assertAllowedContentType(contentType);
83
+ if (typeof rawInput.author === "number") {
84
+ policy.assertAllowedAuthor(rawInput.author);
85
+ }
86
+ if (rawInput.terms && typeof rawInput.terms === "object") {
87
+ for (const taxonomy of Object.keys(rawInput.terms)) {
88
+ policy.assertAllowedTaxonomy(taxonomy);
89
+ }
90
+ }
91
+ if (rawInput.yoast_meta && typeof rawInput.yoast_meta === "object") {
92
+ policy.assertYoastMetaKeys(rawInput.yoast_meta);
93
+ }
94
+ const contentFormat = rawInput.content_format || "auto";
95
+ const processedInput = { ...rawInput, status: "draft" };
96
+ if (typeof processedInput.content === "string") {
97
+ processedInput.content = toHtml(processedInput.content, contentFormat);
98
+ }
99
+ if (typeof processedInput.excerpt === "string" && processedInput.excerpt) {
100
+ processedInput.excerpt = toHtml(processedInput.excerpt, contentFormat);
101
+ }
102
+ return client.createContent(processedInput);
103
+ }));
104
+ server.tool("wp_update_content", "Patch an existing content item.", {
105
+ id: z.number().int().positive(),
106
+ content_type: ContentTypeSchema,
107
+ patch: z.record(z.unknown()),
108
+ content_format: z.enum(["html", "markdown", "auto"]).optional(),
109
+ }, async (rawInput) => runToolWithLimit("wp_update_content", rawInput, async () => {
110
+ const contentType = String(rawInput.content_type);
111
+ policy.assertAllowedContentType(contentType);
112
+ const patch = rawInput.patch;
113
+ policy.assertPatchFields(patch);
114
+ if (typeof patch.author === "number") {
115
+ policy.assertAllowedAuthor(patch.author);
116
+ }
117
+ const contentFormat = rawInput.content_format || "auto";
118
+ if (typeof patch.content === "string") {
119
+ patch.content = toHtml(patch.content, contentFormat);
120
+ }
121
+ if (typeof patch.excerpt === "string" && patch.excerpt) {
122
+ patch.excerpt = toHtml(patch.excerpt, contentFormat);
123
+ }
124
+ return client.updateContent(Number(rawInput.id), {
125
+ content_type: contentType,
126
+ patch,
127
+ });
128
+ }));
129
+ server.tool("wp_publish_content", "Publish or schedule content with confirmation flow.", {
130
+ id: z.number().int().positive(),
131
+ content_type: ContentTypeSchema,
132
+ date: z.string().optional(),
133
+ confirmation_token: z.string().optional(),
134
+ }, async (rawInput) => runToolWithLimit("wp_publish_content", rawInput, async () => {
135
+ const id = Number(rawInput.id);
136
+ const contentType = String(rawInput.content_type);
137
+ policy.assertAllowedContentType(contentType);
138
+ const key = `${contentType}:${id}:publish`;
139
+ const gate = requireConfirmation(confirmations, "publish_content", key, rawInput, rawInput.confirmation_token);
140
+ if (!gate.confirmed) {
141
+ return gate.payload;
142
+ }
143
+ return client.publishContent(id, {
144
+ content_type: contentType,
145
+ date: rawInput.date,
146
+ });
147
+ }));
148
+ server.tool("wp_trash_content", "Move content to trash (confirmation required).", {
149
+ id: z.number().int().positive(),
150
+ content_type: ContentTypeSchema,
151
+ confirmation_token: z.string().optional(),
152
+ }, async (rawInput) => runToolWithLimit("wp_trash_content", rawInput, async () => {
153
+ const id = Number(rawInput.id);
154
+ const contentType = String(rawInput.content_type);
155
+ policy.assertAllowedContentType(contentType);
156
+ const key = `${contentType}:${id}:trash`;
157
+ const gate = requireConfirmation(confirmations, "trash_content", key, rawInput, rawInput.confirmation_token);
158
+ if (!gate.confirmed) {
159
+ return gate.payload;
160
+ }
161
+ return client.trashContent(id);
162
+ }));
163
+ server.tool("wp_restore_content", "Restore content from trash (confirmation required).", {
164
+ id: z.number().int().positive(),
165
+ content_type: ContentTypeSchema,
166
+ confirmation_token: z.string().optional(),
167
+ }, async (rawInput) => runToolWithLimit("wp_restore_content", rawInput, async () => {
168
+ const id = Number(rawInput.id);
169
+ const contentType = String(rawInput.content_type);
170
+ policy.assertAllowedContentType(contentType);
171
+ const key = `${contentType}:${id}:restore`;
172
+ const gate = requireConfirmation(confirmations, "restore_content", key, rawInput, rawInput.confirmation_token);
173
+ if (!gate.confirmed) {
174
+ return gate.payload;
175
+ }
176
+ return client.restoreContent(id);
177
+ }));
178
+ server.tool("wp_list_revisions", "List revisions for a content item.", {
179
+ id: z.number().int().positive(),
180
+ content_type: ContentTypeSchema,
181
+ }, async (rawInput) => runToolWithLimit("wp_list_revisions", rawInput, async () => {
182
+ policy.assertAllowedContentType(String(rawInput.content_type));
183
+ return client.listRevisions(Number(rawInput.id));
184
+ }));
185
+ server.tool("wp_restore_revision", "Restore a specific revision (confirmation required).", {
186
+ id: z.number().int().positive(),
187
+ revision_id: z.number().int().positive(),
188
+ content_type: ContentTypeSchema,
189
+ confirmation_token: z.string().optional(),
190
+ }, async (rawInput) => runToolWithLimit("wp_restore_revision", rawInput, async () => {
191
+ const id = Number(rawInput.id);
192
+ const revisionId = Number(rawInput.revision_id);
193
+ const contentType = String(rawInput.content_type);
194
+ policy.assertAllowedContentType(contentType);
195
+ const key = `${contentType}:${id}:revision:${revisionId}`;
196
+ const gate = requireConfirmation(confirmations, "restore_revision", key, rawInput, rawInput.confirmation_token);
197
+ if (!gate.confirmed) {
198
+ return gate.payload;
199
+ }
200
+ return client.restoreRevision(id, revisionId);
201
+ }));
202
+ server.tool("wp_list_authors", "List allowlisted authors.", {}, async () => runToolWithLimit("wp_list_authors", {}, async () => client.listAuthors()));
203
+ server.tool("wp_assign_author", "Assign an allowlisted author to content.", {
204
+ id: z.number().int().positive(),
205
+ content_type: ContentTypeSchema,
206
+ author_id: z.number().int().positive(),
207
+ }, async (rawInput) => runToolWithLimit("wp_assign_author", rawInput, async () => {
208
+ policy.assertAllowedContentType(String(rawInput.content_type));
209
+ policy.assertAllowedAuthor(Number(rawInput.author_id));
210
+ return client.assignAuthor(Number(rawInput.id), Number(rawInput.author_id));
211
+ }));
212
+ server.tool("wp_list_terms", "List terms for an allowlisted taxonomy.", {
213
+ taxonomy: z.string().min(1),
214
+ search: z.string().optional(),
215
+ parent: z.number().int().min(0).optional(),
216
+ page: z.number().int().positive().optional(),
217
+ per_page: z.number().int().positive().max(100).optional(),
218
+ }, async (rawInput) => runToolWithLimit("wp_list_terms", rawInput, async () => {
219
+ const taxonomy = String(rawInput.taxonomy);
220
+ policy.assertAllowedTaxonomy(taxonomy);
221
+ return client.listTerms(taxonomy, rawInput);
222
+ }));
223
+ server.tool("wp_create_term", "Create a term in an allowlisted taxonomy.", {
224
+ taxonomy: z.string().min(1),
225
+ name: z.string().min(1),
226
+ slug: z.string().optional(),
227
+ parent: z.number().int().min(0).optional(),
228
+ description: z.string().optional(),
229
+ }, async (rawInput) => runToolWithLimit("wp_create_term", rawInput, async () => {
230
+ const taxonomy = String(rawInput.taxonomy);
231
+ policy.assertAllowedTaxonomy(taxonomy);
232
+ return client.createTerm(taxonomy, rawInput);
233
+ }));
234
+ server.tool("wp_assign_terms", "Assign term IDs to a content item.", {
235
+ id: z.number().int().positive(),
236
+ content_type: ContentTypeSchema,
237
+ terms: z.record(z.array(z.number().int().positive())),
238
+ }, async (rawInput) => runToolWithLimit("wp_assign_terms", rawInput, async () => {
239
+ policy.assertAllowedContentType(String(rawInput.content_type));
240
+ const terms = rawInput.terms;
241
+ for (const taxonomy of Object.keys(terms)) {
242
+ policy.assertAllowedTaxonomy(taxonomy);
243
+ }
244
+ return client.assignTerms(Number(rawInput.id), {
245
+ content_type: rawInput.content_type,
246
+ terms,
247
+ });
248
+ }));
249
+ server.tool("wp_upload_media", "Upload media with policy validation.", {
250
+ filename: z.string().min(1),
251
+ mime_type: z.string().min(1),
252
+ bytes_base64: z.string().min(1),
253
+ alt_text: z.string().optional(),
254
+ caption: z.string().optional(),
255
+ description: z.string().optional(),
256
+ title: z.string().optional(),
257
+ }, async (rawInput) => runToolWithLimit("wp_upload_media", rawInput, async () => {
258
+ const mimeType = String(rawInput.mime_type);
259
+ const bytes = Buffer.from(String(rawInput.bytes_base64), "base64").byteLength;
260
+ policy.assertMediaPolicy(mimeType, bytes, rawInput.alt_text);
261
+ return client.uploadMedia(rawInput);
262
+ }));
263
+ server.tool("wp_search_media", "Search media library.", {
264
+ search: z.string().optional(),
265
+ page: z.number().int().positive().optional(),
266
+ per_page: z.number().int().positive().max(100).optional(),
267
+ mime_type: z.string().optional(),
268
+ }, async (rawInput) => runToolWithLimit("wp_search_media", rawInput, async () => client.searchMedia(rawInput)));
269
+ server.tool("wp_update_media", "Update media metadata.", {
270
+ id: z.number().int().positive(),
271
+ alt_text: z.string().optional(),
272
+ caption: z.string().optional(),
273
+ description: z.string().optional(),
274
+ title: z.string().optional(),
275
+ }, async (rawInput) => runToolWithLimit("wp_update_media", rawInput, async () => client.updateMedia(Number(rawInput.id), rawInput)));
276
+ server.tool("wp_set_featured_image", "Set or remove a featured image on content.", {
277
+ id: z.number().int().positive(),
278
+ content_type: ContentTypeSchema,
279
+ media_id: z.number().int().positive().nullable(),
280
+ }, async (rawInput) => runToolWithLimit("wp_set_featured_image", rawInput, async () => {
281
+ policy.assertAllowedContentType(String(rawInput.content_type));
282
+ return client.setFeaturedImage(Number(rawInput.id), rawInput.media_id);
283
+ }));
284
+ server.tool("wp_insert_inline_image", "Insert an inline image into content in block-aware mode with HTML fallback.", {
285
+ id: z.number().int().positive(),
286
+ content_type: ContentTypeSchema,
287
+ media_id: z.number().int().positive(),
288
+ placement: PlacementSchema,
289
+ heading_text: z.string().optional(),
290
+ marker: z.string().optional(),
291
+ align: z.enum(["none", "left", "center", "right", "wide", "full"]).optional(),
292
+ size_slug: z.string().optional(),
293
+ caption: z.string().optional(),
294
+ alt_text: z.string().optional(),
295
+ }, async (rawInput) => runToolWithLimit("wp_insert_inline_image", rawInput, async () => {
296
+ policy.assertAllowedContentType(String(rawInput.content_type));
297
+ return client.insertInlineImage(Number(rawInput.id), rawInput);
298
+ }));
299
+ server.tool("wp_replace_inline_image", "Replace an inline image reference in content.", {
300
+ id: z.number().int().positive(),
301
+ content_type: ContentTypeSchema,
302
+ new_media_id: z.number().int().positive(),
303
+ match_media_id: z.number().int().positive().optional(),
304
+ match_src_substring: z.string().optional(),
305
+ alt_text: z.string().optional(),
306
+ caption: z.string().optional(),
307
+ }, async (rawInput) => runToolWithLimit("wp_replace_inline_image", rawInput, async () => {
308
+ policy.assertAllowedContentType(String(rawInput.content_type));
309
+ if (!rawInput.match_media_id && !rawInput.match_src_substring) {
310
+ throw new ToolError("INLINE_MATCH_REQUIRED", "Either match_media_id or match_src_substring must be provided");
311
+ }
312
+ return client.replaceInlineImage(Number(rawInput.id), rawInput);
313
+ }));
314
+ server.tool("wp_remove_inline_image", "Remove an inline image from content.", {
315
+ id: z.number().int().positive(),
316
+ content_type: ContentTypeSchema,
317
+ match_media_id: z.number().int().positive().optional(),
318
+ match_src_substring: z.string().optional(),
319
+ }, async (rawInput) => runToolWithLimit("wp_remove_inline_image", rawInput, async () => {
320
+ policy.assertAllowedContentType(String(rawInput.content_type));
321
+ if (!rawInput.match_media_id && !rawInput.match_src_substring) {
322
+ throw new ToolError("INLINE_MATCH_REQUIRED", "Either match_media_id or match_src_substring must be provided");
323
+ }
324
+ return client.removeInlineImage(Number(rawInput.id), rawInput);
325
+ }));
326
+ server.tool("wp_get_yoast_analysis", "Get Yoast readability and SEO analysis for content.", {
327
+ id: z.number().int().positive(),
328
+ content_type: ContentTypeSchema,
329
+ }, async (rawInput) => runToolWithLimit("wp_get_yoast_analysis", rawInput, async () => {
330
+ policy.assertAllowedContentType(String(rawInput.content_type));
331
+ policy.assertYoastPath("/yoast/analysis");
332
+ return client.getYoastAnalysis(Number(rawInput.id));
333
+ }));
334
+ server.tool("wp_update_yoast_metadata", "Update allowlisted Yoast metadata for content.", {
335
+ id: z.number().int().positive(),
336
+ content_type: ContentTypeSchema,
337
+ yoast_meta: z.record(z.unknown()),
338
+ }, async (rawInput) => runToolWithLimit("wp_update_yoast_metadata", rawInput, async () => {
339
+ policy.assertAllowedContentType(String(rawInput.content_type));
340
+ policy.assertYoastPath("/yoast/metadata");
341
+ policy.assertYoastMetaKeys(rawInput.yoast_meta);
342
+ return client.updateYoastMetadata(Number(rawInput.id), {
343
+ content_type: rawInput.content_type,
344
+ yoast_meta: rawInput.yoast_meta,
345
+ });
346
+ }));
347
+ server.tool("wp_get_yoast_head_preview", "Get Yoast head/meta preview payload.", {
348
+ id: z.number().int().positive(),
349
+ content_type: ContentTypeSchema,
350
+ }, async (rawInput) => runToolWithLimit("wp_get_yoast_head_preview", rawInput, async () => {
351
+ policy.assertAllowedContentType(String(rawInput.content_type));
352
+ policy.assertYoastPath("/yoast/head");
353
+ return client.getYoastHeadPreview(Number(rawInput.id));
354
+ }));
355
+ server.tool("wp_get_preview_link", "Return both WordPress preview and signed shareable preview URLs.", {
356
+ id: z.number().int().positive(),
357
+ content_type: ContentTypeSchema,
358
+ }, async (rawInput) => runToolWithLimit("wp_get_preview_link", rawInput, async () => {
359
+ policy.assertAllowedContentType(String(rawInput.content_type));
360
+ return client.getPreviewLink(Number(rawInput.id));
361
+ }));
362
+ server.tool("wp_get_media", "Get a single media item by ID with full metadata (dimensions, URLs, alt text).", {
363
+ id: z.number().int().positive(),
364
+ }, async (rawInput) => runToolWithLimit("wp_get_media", rawInput, async () => {
365
+ return client.getMedia(Number(rawInput.id));
366
+ }));
367
+ server.tool("wp_upload_media_from_url", "Upload media to WordPress by fetching from a URL (server-side download, no base64 needed).", {
368
+ url: z.string().url(),
369
+ alt_text: z.string().optional(),
370
+ title: z.string().optional(),
371
+ caption: z.string().optional(),
372
+ description: z.string().optional(),
373
+ }, async (rawInput) => runToolWithLimit("wp_upload_media_from_url", rawInput, async () => {
374
+ return client.uploadMediaFromUrl(rawInput);
375
+ }));
376
+ server.tool("wp_clone_content", "Clone an existing post or page as a new draft. Copies content, taxonomies, featured image, and Yoast meta.", {
377
+ id: z.number().int().positive(),
378
+ content_type: ContentTypeSchema,
379
+ title: z.string().optional(),
380
+ }, async (rawInput) => runToolWithLimit("wp_clone_content", rawInput, async () => {
381
+ policy.assertAllowedContentType(String(rawInput.content_type));
382
+ return client.cloneContent(Number(rawInput.id), rawInput);
383
+ }));
384
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ export class ToolError extends Error {
2
+ code;
3
+ details;
4
+ httpStatus;
5
+ retryable;
6
+ constructor(code, message, options) {
7
+ super(message);
8
+ this.name = "ToolError";
9
+ this.code = code;
10
+ this.details = options?.details;
11
+ this.httpStatus = options?.httpStatus;
12
+ this.retryable = options?.retryable ?? false;
13
+ }
14
+ }
15
+ export function toToolError(error) {
16
+ if (error instanceof ToolError) {
17
+ return error;
18
+ }
19
+ if (error instanceof Error) {
20
+ return new ToolError("UNEXPECTED_ERROR", error.message);
21
+ }
22
+ return new ToolError("UNEXPECTED_ERROR", "Unexpected unknown error", { details: error });
23
+ }
24
+ export function serializeError(error) {
25
+ const normalized = toToolError(error);
26
+ return {
27
+ code: normalized.code,
28
+ message: normalized.message,
29
+ details: normalized.details,
30
+ http_status: normalized.httpStatus,
31
+ retryable: normalized.retryable,
32
+ };
33
+ }
@@ -0,0 +1,40 @@
1
+ const SENSITIVE_KEYS = new Set([
2
+ "authorization",
3
+ "wpAppPassword",
4
+ "password",
5
+ "token",
6
+ "secret",
7
+ "wp_app_password",
8
+ ]);
9
+ function redact(value) {
10
+ if (!value || typeof value !== "object") {
11
+ return value;
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return value.map(redact);
15
+ }
16
+ const redacted = {};
17
+ for (const [key, nested] of Object.entries(value)) {
18
+ if (SENSITIVE_KEYS.has(key)) {
19
+ redacted[key] = "[REDACTED]";
20
+ continue;
21
+ }
22
+ redacted[key] = redact(nested);
23
+ }
24
+ return redacted;
25
+ }
26
+ function write(level, message, context) {
27
+ const payload = {
28
+ level,
29
+ message,
30
+ timestamp: new Date().toISOString(),
31
+ context: redact(context),
32
+ };
33
+ // Keep logs structured for easy ingestion.
34
+ console.error(JSON.stringify(payload));
35
+ }
36
+ export const logger = {
37
+ info: (message, context) => write("info", message, context),
38
+ warn: (message, context) => write("warn", message, context),
39
+ error: (message, context) => write("error", message, context),
40
+ };