@gravitykit/block-mcp 2.0.0-beta

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.
Files changed (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Yoast SEO tools — read/write SEO metadata for posts and pages.
3
+ *
4
+ * Backed by `Yoast_Bridge` inside gk-block-api itself (routes:
5
+ * `gk-block-api/v1/yoast/{id}` and `gk-block-api/v1/yoast/bulk`). Routes
6
+ * register only when Yoast SEO is active on the target site; if Yoast SEO
7
+ * isn't installed, calls return a 404 `rest_no_route` from WordPress.
8
+ *
9
+ * Replaces the standalone `yoast-seo-mcp` MCP server (deprecated as of v1.2)
10
+ * and the older Block-Theme mu-plugin namespace (`gravitykit/v1/yoast-seo`).
11
+ */
12
+
13
+ import type { WordPressBlockClient } from '../client.js';
14
+ import type {
15
+ YoastSchemaPageType,
16
+ YoastSchemaArticleType,
17
+ YoastRobotsAdvanced,
18
+ YoastUpdateRequest,
19
+ YoastBulkUpdateItem,
20
+ } from '../types.js';
21
+
22
+ const SCHEMA_PAGE_TYPES: YoastSchemaPageType[] = [
23
+ 'WebPage', 'ItemPage', 'AboutPage', 'FAQPage', 'QAPage',
24
+ 'ProfilePage', 'ContactPage', 'MedicalWebPage', 'CollectionPage',
25
+ 'CheckoutPage', 'RealEstateListing', 'SearchResultsPage',
26
+ ];
27
+
28
+ const SCHEMA_ARTICLE_TYPES: YoastSchemaArticleType[] = [
29
+ 'Article', 'BlogPosting', 'SocialMediaPosting', 'NewsArticle',
30
+ 'AdvertiserContentArticle', 'SatiricalArticle', 'ScholarlyArticle',
31
+ 'TechArticle', 'Report', 'None',
32
+ ];
33
+
34
+ const ROBOTS_ADVANCED: YoastRobotsAdvanced[] = ['noimageindex', 'noarchive', 'nosnippet'];
35
+
36
+ /** Field-level schema reused by yoast_update_seo and yoast_bulk_update_seo. */
37
+ const YOAST_FIELD_PROPERTIES = {
38
+ title: { type: 'string', description: 'SEO title (supports Yoast variables like %%title%%).' },
39
+ description: { type: 'string', description: 'Meta description.' },
40
+ canonical: { type: 'string', description: 'Canonical URL override.' },
41
+ focus_keyword: { type: 'string', description: 'Focus keyphrase.' },
42
+ noindex: {
43
+ type: ['boolean', 'null'],
44
+ description: 'Tri-state: true=noindex, false=explicit index, null=post-type default.',
45
+ },
46
+ nofollow: { type: 'boolean', description: 'true=nofollow, false=follow.' },
47
+ robots_advanced: {
48
+ type: 'array',
49
+ items: { type: 'string', enum: ROBOTS_ADVANCED },
50
+ description: 'Subset of: noimageindex, noarchive, nosnippet.',
51
+ },
52
+ og_title: { type: 'string', description: 'Open Graph title.' },
53
+ og_description: { type: 'string', description: 'Open Graph description.' },
54
+ og_image: { type: 'string', description: 'Open Graph image URL.' },
55
+ og_image_id: { type: 'number', description: 'Attachment ID for the OG image.' },
56
+ twitter_title: { type: 'string', description: 'Twitter card title.' },
57
+ twitter_description: { type: 'string', description: 'Twitter card description.' },
58
+ twitter_image: { type: 'string', description: 'Twitter card image URL.' },
59
+ twitter_image_id: { type: 'number', description: 'Attachment ID for the Twitter image.' },
60
+ schema_page_type: { type: 'string', enum: SCHEMA_PAGE_TYPES, description: 'Schema.org page type.' },
61
+ schema_article_type: {
62
+ type: 'string', enum: SCHEMA_ARTICLE_TYPES, description: 'Schema.org article type.',
63
+ },
64
+ is_cornerstone: { type: 'boolean', description: 'Cornerstone content flag.' },
65
+ breadcrumb_title: { type: 'string', description: 'Breadcrumb title override.' },
66
+ redirect: { type: 'string', description: 'Redirect URL (Yoast Premium).' },
67
+ primary_terms: {
68
+ type: 'object',
69
+ additionalProperties: { type: 'number' },
70
+ description: '{ taxonomy_name: term_id } — set primary term per taxonomy.',
71
+ },
72
+ };
73
+
74
+ export const YOAST_TOOLS = [
75
+ {
76
+ name: 'yoast_get_seo',
77
+ description:
78
+ 'Read all Yoast SEO metadata for a post or page (title, description, robots, Open Graph, Twitter card, schema types, cornerstone flag, breadcrumb, redirect, scores, primary terms).',
79
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, title: 'Get Yoast SEO metadata' },
80
+ inputSchema: {
81
+ type: 'object' as const,
82
+ properties: {
83
+ post_id: { type: 'number', description: 'WordPress post or page ID.' },
84
+ },
85
+ required: ['post_id'],
86
+ },
87
+ },
88
+ {
89
+ name: 'yoast_update_seo',
90
+ description:
91
+ 'Update one or more Yoast SEO fields on a single post or page. Only supplied fields are written. `noindex` is tri-state (true / false / null).',
92
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Update Yoast SEO metadata' },
93
+ inputSchema: {
94
+ type: 'object' as const,
95
+ properties: {
96
+ post_id: { type: 'number', description: 'WordPress post or page ID.' },
97
+ ...YOAST_FIELD_PROPERTIES,
98
+ },
99
+ required: ['post_id'],
100
+ },
101
+ },
102
+ {
103
+ name: 'yoast_bulk_update_seo',
104
+ description:
105
+ 'Update Yoast SEO fields on multiple posts in one call. Each item must include `post_id` plus any fields to update. Response preserves order.',
106
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, title: 'Bulk-update Yoast SEO metadata' },
107
+ inputSchema: {
108
+ type: 'object' as const,
109
+ properties: {
110
+ posts: {
111
+ type: 'array',
112
+ description: 'Array of objects, each with post_id and fields to update.',
113
+ items: {
114
+ type: 'object',
115
+ properties: {
116
+ post_id: { type: 'number' },
117
+ ...YOAST_FIELD_PROPERTIES,
118
+ },
119
+ required: ['post_id'],
120
+ },
121
+ },
122
+ },
123
+ required: ['posts'],
124
+ },
125
+ },
126
+ ];
127
+
128
+ export async function handleYoastTool(
129
+ toolName: string,
130
+ args: Record<string, unknown>,
131
+ client: WordPressBlockClient,
132
+ ): Promise<unknown> {
133
+ switch (toolName) {
134
+ case 'yoast_get_seo': {
135
+ const postId = args.post_id;
136
+ if (typeof postId !== 'number') {
137
+ throw new Error('yoast_get_seo: "post_id" (number) is required');
138
+ }
139
+ return client.getYoastSEO(postId);
140
+ }
141
+
142
+ case 'yoast_update_seo': {
143
+ const postId = args.post_id;
144
+ if (typeof postId !== 'number') {
145
+ throw new Error('yoast_update_seo: "post_id" (number) is required');
146
+ }
147
+ const { post_id: _omit, ...rest } = args;
148
+ void _omit;
149
+ const fields = narrowYoastFields(rest);
150
+ if (Object.keys(fields).length === 0) {
151
+ throw new Error('yoast_update_seo: provide at least one Yoast field besides post_id');
152
+ }
153
+ return client.updateYoastSEO(postId, fields);
154
+ }
155
+
156
+ case 'yoast_bulk_update_seo': {
157
+ if (!Array.isArray(args.posts) || args.posts.length === 0) {
158
+ throw new Error('yoast_bulk_update_seo: non-empty `posts` array is required');
159
+ }
160
+ const items: YoastBulkUpdateItem[] = [];
161
+ for (const raw of args.posts as unknown[]) {
162
+ if (!raw || typeof raw !== 'object') {
163
+ throw new Error('yoast_bulk_update_seo: each item in `posts` must be an object');
164
+ }
165
+ const obj = raw as Record<string, unknown>;
166
+ if (typeof obj.post_id !== 'number') {
167
+ throw new Error('yoast_bulk_update_seo: each item requires `post_id` (number)');
168
+ }
169
+ const { post_id: id, ...rest } = obj;
170
+ items.push({ post_id: id as number, ...narrowYoastFields(rest) });
171
+ }
172
+ return client.bulkUpdateYoastSEO(items);
173
+ }
174
+
175
+ default:
176
+ throw new Error(`Unknown yoast tool: ${toolName}`);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Filter incoming object to known Yoast fields with the right value shapes.
182
+ * Mirrors the JSON Schema in YOAST_FIELD_PROPERTIES.
183
+ */
184
+ function narrowYoastFields(input: Record<string, unknown>): YoastUpdateRequest {
185
+ const out: YoastUpdateRequest = {};
186
+ if (typeof input.title === 'string') out.title = input.title;
187
+ if (typeof input.description === 'string') out.description = input.description;
188
+ if (typeof input.canonical === 'string') out.canonical = input.canonical;
189
+ if (typeof input.focus_keyword === 'string') out.focus_keyword = input.focus_keyword;
190
+ if (input.noindex === true || input.noindex === false || input.noindex === null) {
191
+ out.noindex = input.noindex;
192
+ }
193
+ if (typeof input.nofollow === 'boolean') out.nofollow = input.nofollow;
194
+ if (Array.isArray(input.robots_advanced)) {
195
+ out.robots_advanced = (input.robots_advanced as unknown[]).filter(
196
+ (v): v is YoastRobotsAdvanced => v === 'noimageindex' || v === 'noarchive' || v === 'nosnippet',
197
+ );
198
+ }
199
+ if (typeof input.og_title === 'string') out.og_title = input.og_title;
200
+ if (typeof input.og_description === 'string') out.og_description = input.og_description;
201
+ if (typeof input.og_image === 'string') out.og_image = input.og_image;
202
+ if (typeof input.og_image_id === 'number') out.og_image_id = input.og_image_id;
203
+ if (typeof input.twitter_title === 'string') out.twitter_title = input.twitter_title;
204
+ if (typeof input.twitter_description === 'string') out.twitter_description = input.twitter_description;
205
+ if (typeof input.twitter_image === 'string') out.twitter_image = input.twitter_image;
206
+ if (typeof input.twitter_image_id === 'number') out.twitter_image_id = input.twitter_image_id;
207
+ if (typeof input.schema_page_type === 'string' && SCHEMA_PAGE_TYPES.includes(input.schema_page_type as YoastSchemaPageType)) {
208
+ out.schema_page_type = input.schema_page_type as YoastSchemaPageType;
209
+ }
210
+ if (typeof input.schema_article_type === 'string' && SCHEMA_ARTICLE_TYPES.includes(input.schema_article_type as YoastSchemaArticleType)) {
211
+ out.schema_article_type = input.schema_article_type as YoastSchemaArticleType;
212
+ }
213
+ if (typeof input.is_cornerstone === 'boolean') out.is_cornerstone = input.is_cornerstone;
214
+ if (typeof input.breadcrumb_title === 'string') out.breadcrumb_title = input.breadcrumb_title;
215
+ if (typeof input.redirect === 'string') out.redirect = input.redirect;
216
+ if (input.primary_terms && typeof input.primary_terms === 'object' && !Array.isArray(input.primary_terms)) {
217
+ const pt: Record<string, number> = {};
218
+ for (const [k, v] of Object.entries(input.primary_terms as Record<string, unknown>)) {
219
+ if (typeof v === 'number') pt[k] = v;
220
+ }
221
+ out.primary_terms = pt;
222
+ }
223
+ return out;
224
+ }